Compare commits

...

2 Commits

Author SHA1 Message Date
Hervé Labas
f9c0ddfbef Tests for connection string parsing 2025-05-03 01:42:29 +02:00
Hervé Labas
54597a3a06 Prototyped pasting a connection string 2025-05-03 01:08:28 +02:00
6 changed files with 504 additions and 5 deletions

View File

@@ -14,11 +14,13 @@ import DatabaseSectionField from "../DatabaseSectionField";
export interface DatabaseDetailFieldProps {
field: EngineField;
autoFocus?: boolean;
onPaste?: React.ClipboardEventHandler<HTMLInputElement>;
}
const DatabaseDetailField = ({
field,
autoFocus,
onPaste,
}: DatabaseDetailFieldProps): JSX.Element | null => {
const override = FIELD_OVERRIDES[field.name];
const type = getFieldType(field, override);
@@ -34,11 +36,25 @@ const DatabaseDetailField = ({
switch (type) {
case "password":
return <FormInput {...props} {...getPasswordProps(field)} nullable />;
return (
<FormInput
{...props}
{...getPasswordProps(field)}
onPaste={onPaste}
nullable
/>
);
case "text":
return <FormTextArea {...props} />;
case "integer":
return <FormNumericInput {...props} {...getInputProps(field)} nullable />;
return (
<FormNumericInput
{...props}
{...getInputProps(field)}
onPaste={onPaste}
nullable
/>
);
case "boolean":
return <FormToggle {...props} />;
case "select":
@@ -52,7 +68,14 @@ const DatabaseDetailField = ({
case "hidden":
return null;
default:
return <FormInput {...props} {...getInputProps(field)} nullable />;
return (
<FormInput
{...props}
{...getInputProps(field)}
onPaste={onPaste}
nullable
/>
);
}
};

View File

@@ -14,6 +14,10 @@ import { Flex } from "metabase/ui";
import type { DatabaseData, Engine } from "metabase-types/api";
import { getEngines, getIsHosted } from "../../selectors";
import {
type ParsedConnectionResult,
parseConnectionString,
} from "../../utils/connectionString";
import { getDefaultEngineKey } from "../../utils/engine";
import {
getSubmitValues,
@@ -147,7 +151,13 @@ const DatabaseFormBody = ({
setIsDirty,
config,
}: DatabaseFormBodyProps): JSX.Element => {
const { values, dirty } = useFormikContext<DatabaseData>();
const { values, dirty, setFieldValue, validateForm } =
useFormikContext<DatabaseData>();
const [pendingConnectionString, setPendingConnectionString] = useState<
string | null
>(null);
const [needsValidationAfterPaste, setNeedsValidationAfterPaste] =
useState(false);
useEffect(() => {
setIsDirty?.(dirty);
@@ -157,6 +167,96 @@ const DatabaseFormBody = ({
return engine ? getVisibleFields(engine, values, isAdvanced) : [];
}, [engine, values, isAdvanced]);
const applyParsedFields = useCallback(
(parsedResult: ParsedConnectionResult) => {
Object.entries(parsedResult.fieldValues).forEach(([path, value]) => {
const key = path.startsWith("details.") ? path.substring(8) : null;
if (key && values.details && key in values.details) {
setFieldValue(path, value);
} else if (!path.includes(".") && path in values) {
setFieldValue(path, value);
}
});
},
[setFieldValue, values],
);
const tryEnableSave = useCallback(() => {
setIsDirty?.(true);
setNeedsValidationAfterPaste(true); // Formik requires a cycle to detect the change and enable the save button
}, [setIsDirty]);
useEffect(() => {
if (pendingConnectionString && engine) {
const result = parseConnectionString(
pendingConnectionString,
engines,
engine,
);
applyParsedFields(result);
setPendingConnectionString(null);
tryEnableSave();
}
}, [
engine,
engines,
pendingConnectionString,
applyParsedFields,
tryEnableSave,
]);
useEffect(() => {
if (needsValidationAfterPaste) {
void validateForm();
setNeedsValidationAfterPaste(false);
}
}, [needsValidationAfterPaste, validateForm]);
const handleConnectionStringPaste = useCallback(
(e: React.ClipboardEvent<HTMLInputElement>) => {
const connectionString = e.clipboardData.getData("text");
if (!connectionString) {
return;
}
const result = parseConnectionString(connectionString, engines, engine);
if (!result.isValid) {
return;
}
e.preventDefault();
const needsEngineChange =
result.engineKey && result.engineKey !== engineKey;
if (needsEngineChange) {
// Trigger engine change and store string for processing after the form is updated
onEngineChange(result.engineKey);
setPendingConnectionString(connectionString);
// DO NOT apply fields here; useEffect will handle it with the correct engine context
} else {
// Engine doesn't change, apply all fields immediately using the helper
applyParsedFields(result);
setPendingConnectionString(null);
tryEnableSave();
}
},
[
engine,
engineKey,
engines,
onEngineChange,
applyParsedFields,
tryEnableSave,
],
);
const connectionStringPastable = [
"host",
"port",
"dbname",
"user",
"password",
];
return (
<Form data-testid="database-form" className="database-form">
{engineFieldState !== "hidden" && (
@@ -176,21 +276,29 @@ const DatabaseFormBody = ({
/>
</>
)}
{engine && (
<DatabaseNameField
engine={engine}
config={config}
onPaste={handleConnectionStringPaste}
autoFocus={autofocusFieldName === "name"}
/>
)}
{fields.map((field) => (
<DatabaseDetailField
key={field.name}
field={field}
autoFocus={autofocusFieldName === field.name}
data-kek={field.name}
onPaste={
connectionStringPastable.includes(field.name)
? handleConnectionStringPaste
: undefined
}
/>
))}
<DatabaseFormFooter
isDirty={dirty}
isAdvanced={isAdvanced}

View File

@@ -10,12 +10,14 @@ export interface DatabaseNameFieldProps {
engine: Engine;
config: DatabaseFormConfig;
autoFocus?: boolean;
onPaste?: (event: React.ClipboardEvent<HTMLInputElement>) => void;
}
const DatabaseNameField = ({
engine,
autoFocus,
config,
onPaste,
...props
}: DatabaseNameFieldProps): JSX.Element => {
const name = engine["driver-name"] ?? t`Database`;
@@ -26,6 +28,7 @@ const DatabaseNameField = ({
return (
<FormInput
name="name"
onPaste={onPaste}
title={t`Display name`}
placeholder={t`Our ${name}`}
{...PLUGIN_DB_ROUTING.getDatabaseNameFieldProps(

View File

@@ -171,3 +171,24 @@ export const FIELD_OVERRIDES: Record<string, EngineFieldOverride> = {
name: "refingerprint",
},
};
export const CONNECTION_STRING_CONFIG = {
/**
* Maps common connection string parameter names (lowercase) to Metabase fields.
*/
paramMap: {
host: "details.host",
port: "details.port",
database: ["name", "details.dbname"],
user: "details.user",
password: "details.password",
} as Record<string, string | string[]>,
/**
* Maps common connection string provider names (lowercase) to Metabase engine keys to handle mismatches.
*/
providerMap: {
postgresql: "postgres",
mssql: "sqlserver",
} as Record<string, string>,
};

View File

@@ -0,0 +1,151 @@
import type { Engine } from "metabase-types/api";
import { CONNECTION_STRING_CONFIG } from "../constants";
// provider://user:pass@host:port/database?options
const CS_PATTERN =
/^(?:([^:]+):\/\/)?(?:([^:]+)(?::([^@]+))?@)?([^:/]+)(?::(\d+))?(?:\/([^?]*))?(?:\?(.*))?$/;
export interface ParsedConnectionResult {
isValid: boolean;
engineKey?: string;
fieldValues: Record<string, string | boolean | number | null>;
error?: string;
}
const getEngineFromProviderInternal = (
provider: string | undefined,
engines: Record<string, Engine>,
): string | undefined => {
if (!provider) {
return undefined;
}
const normalizedProvider = provider.toLowerCase();
if (engines[normalizedProvider]) {
return normalizedProvider;
}
if (CONNECTION_STRING_CONFIG.providerMap[normalizedProvider]) {
const mappedEngine =
CONNECTION_STRING_CONFIG.providerMap[normalizedProvider];
if (engines[mappedEngine]) {
return mappedEngine;
}
}
const engineEntry = Object.entries(engines).find(
([, engine]) => engine["driver-name"]?.toLowerCase() === normalizedProvider,
);
return engineEntry?.[0];
};
/**
* Parses a JDBC-style connection string and maps its components to field paths.
* @param connectionString The raw connection string.
* @param engines The map of available Metabase engines.
* @param currentEngine The currently selected engine (used to determine field types).
* @returns A ParsedConnectionResult object.
*/
export const parseConnectionString = (
connectionString: string,
engines: Record<string, Engine>,
currentEngine: Engine | undefined,
): ParsedConnectionResult => {
const result: ParsedConnectionResult = {
isValid: false,
fieldValues: {},
};
if (!connectionString.includes("://") && !connectionString.includes("@")) {
return result;
}
try {
const match = connectionString.match(CS_PATTERN);
if (!match) {
return result;
}
result.isValid = true;
const [, provider, user, password, host, port, database, queryString] =
match;
const rawParts: Record<string, string | undefined> = {
provider,
user,
password,
host,
port,
database,
};
result.engineKey = getEngineFromProviderInternal(provider, engines);
// Map main parts (host, port, user, etc.)
Object.entries(rawParts).forEach(([key, value]) => {
if (value === undefined) {
return;
}
const targetPaths = CONNECTION_STRING_CONFIG.paramMap[key];
if (!targetPaths) {
return;
}
if (Array.isArray(targetPaths)) {
targetPaths.forEach((path) => {
result.fieldValues[path] = value;
});
} else {
result.fieldValues[targetPaths] = value;
}
});
// Set default port if not specified nor filled in
const PORT_TARGET_PATH = CONNECTION_STRING_CONFIG.paramMap[
"port"
] as string;
if (
!port &&
currentEngine?.["details-fields"] &&
!result.fieldValues[PORT_TARGET_PATH]
) {
const portField = currentEngine["details-fields"].find(
(field) => field.name === "port",
);
const defaultPort = portField?.placeholder;
if (typeof defaultPort === "number" || typeof defaultPort === "string") {
result.fieldValues[PORT_TARGET_PATH] = defaultPort;
}
}
// Query string options to map SSL mode and push the rest as additional options
let additionalOptionsString: string | undefined = undefined;
if (queryString) {
const params = new URLSearchParams(queryString);
additionalOptionsString = queryString;
if (params.has("sslmode")) {
const sslmodeValue = params.get("sslmode") ?? "";
result.fieldValues["details.ssl"] = true;
result.fieldValues["details.ssl-mode"] = sslmodeValue;
// Create new params excluding sslmode for additional options
const additionalParams = new URLSearchParams(queryString);
additionalParams.delete("sslmode");
additionalOptionsString = additionalParams.toString();
}
const hasAdditionalOptionsField = currentEngine?.["details-fields"]?.some(
(field) => field.name === "additional-options",
);
if (hasAdditionalOptionsField) {
result.fieldValues["details.additional-options"] =
additionalOptionsString;
}
}
} catch (error) {
console.error("Error parsing connection string:", error);
result.isValid = false;
result.fieldValues = {};
result.engineKey = undefined;
result.error = String(error instanceof Error ? error.message : error);
}
return result;
};

View File

@@ -0,0 +1,193 @@
import type { Engine } from "metabase-types/api";
import { parseConnectionString } from "./connectionString";
const TEST_ENGINES: Record<string, Engine> = {
postgres: {
"driver-name": "PostgreSQL",
"details-fields": [
{ name: "host", placeholder: "localhost" },
{ name: "port", placeholder: 5432 },
{ name: "dbname" },
{ name: "user" },
{ name: "password" },
{ name: "ssl" },
{ name: "ssl-mode" },
{ name: "additional-options" },
],
source: { type: "official", contact: null },
"superseded-by": null,
},
mysql: {
"driver-name": "MySQL",
"details-fields": [
{ name: "host", placeholder: "localhost" },
{ name: "port", placeholder: 3306 },
{ name: "dbname" },
{ name: "user" },
{ name: "password" },
{ name: "additional-options" },
],
source: { type: "official", contact: null },
"superseded-by": null,
},
sqlserver: {
"driver-name": "SQL Server",
"details-fields": [
{ name: "host" },
{ name: "port", placeholder: 1433 },
{ name: "dbname" },
{ name: "user" },
{ name: "password" },
],
source: { type: "official", contact: null },
"superseded-by": null,
},
oracle: {
"driver-name": "Oracle",
"details-fields": [
{ name: "host" },
{ name: "port", placeholder: 1521 },
{ name: "sid" },
{ name: "user" },
{ name: "password" },
],
source: { type: "official", contact: null },
"superseded-by": null,
},
};
describe("parseConnectionString", () => {
it("should parse a basic PostgreSQL connection string", () => {
const connectionString = "postgresql://user:pass@localhost:5432/mydb";
const result = parseConnectionString(
connectionString,
TEST_ENGINES,
TEST_ENGINES.postgres,
);
expect(result.isValid).toBe(true);
expect(result.engineKey).toBe("postgres");
expect(result.fieldValues).toEqual({
"details.host": "localhost",
"details.port": "5432",
"details.dbname": "mydb",
name: "mydb",
"details.user": "user",
"details.password": "pass",
});
});
it("should parse a connection string with a different provider mapping", () => {
const connectionString =
"mssql://user:pass@sqlserver.example.com:1433/adventureworks";
const result = parseConnectionString(
connectionString,
TEST_ENGINES,
TEST_ENGINES.sqlserver,
);
expect(result.isValid).toBe(true);
expect(result.engineKey).toBe("sqlserver");
expect(result.fieldValues).toEqual({
"details.host": "sqlserver.example.com",
"details.port": "1433",
"details.dbname": "adventureworks",
name: "adventureworks",
"details.user": "user",
"details.password": "pass",
});
});
it("should parse a connection string without credentials", () => {
const connectionString = "postgres://localhost/mydb";
const result = parseConnectionString(
connectionString,
TEST_ENGINES,
TEST_ENGINES.postgres,
);
expect(result.isValid).toBe(true);
expect(result.engineKey).toBe("postgres");
expect(result.fieldValues).toEqual({
"details.host": "localhost",
"details.port": 5432,
"details.dbname": "mydb",
name: "mydb",
});
});
it("should parse a connection string with SSL mode", () => {
const connectionString = "postgres://localhost/mydb?sslmode=require";
const result = parseConnectionString(
connectionString,
TEST_ENGINES,
TEST_ENGINES.postgres,
);
expect(result.isValid).toBe(true);
expect(result.fieldValues["details.ssl"]).toBe(true);
expect(result.fieldValues["details.ssl-mode"]).toBe("require");
expect(result.fieldValues["details.additional-options"]).toBe("");
});
it("should handle additional options", () => {
const connectionString =
"postgres://localhost/mydb?connect_timeout=10&application_name=myapp&sslmode=verify-full";
const result = parseConnectionString(
connectionString,
TEST_ENGINES,
TEST_ENGINES.postgres,
);
expect(result.isValid).toBe(true);
expect(result.fieldValues["details.ssl"]).toBe(true);
expect(result.fieldValues["details.ssl-mode"]).toBe("verify-full");
expect(result.fieldValues["details.additional-options"]).toBe(
"connect_timeout=10&application_name=myapp",
);
});
it("should return invalid for malformed connection strings", () => {
const connectionString = "not-a-valid-connection-string";
const result = parseConnectionString(
connectionString,
TEST_ENGINES,
TEST_ENGINES.postgres,
);
expect(result.isValid).toBe(false);
});
it("should handle connection strings without provider", () => {
const connectionString = "user:pass@localhost:5432/mydb";
const result = parseConnectionString(
connectionString,
TEST_ENGINES,
TEST_ENGINES.postgres,
);
expect(result.isValid).toBe(true);
expect(result.engineKey).toBeUndefined();
expect(result.fieldValues).toEqual({
"details.host": "localhost",
"details.port": "5432",
"details.dbname": "mydb",
name: "mydb",
"details.user": "user",
"details.password": "pass",
});
});
it("should find engine by driver name", () => {
const connectionString = "postgresql://localhost/mydb";
const result = parseConnectionString(
connectionString,
TEST_ENGINES,
undefined,
);
expect(result.isValid).toBe(true);
expect(result.engineKey).toBe("postgres");
});
});