feat(execution): multi-database SQL execution with scoped targets
This commit is contained in:
1 parent
328a2aa107
commit
71ccb3d97d
46 files changed
+4421
-182
No files matched your search
@@ -2,7 +2,17 @@ import { describe, expect, it } from "vitest";
|
||||
import { connectionNamespaceCreationTarget, databaseNodeNamespaceCreationTarget } from "@/lib/database/databaseNamespaceCreation";
|
||||
import { editableDatabasePropertyGroups, editableSchemaPropertyGroups } from "@/lib/database/databasePropertyEditing";
|
||||
import { buildGetDatabaseCommentSql } from "@/lib/database/dbAdminSql";
|
||||
import { isSchemaAware, supportsDatabaseNameCompletion, supportsDatabaseSchemaQualifier, supportsSqlInListPaste, supportsTableImport, supportsTransaction } from "@/lib/database/databaseFeatureSupport";
|
||||
import {
|
||||
isSchemaAware,
|
||||
supportsConnectionScopedQueryExecution,
|
||||
supportsDatabaseNameCompletion,
|
||||
supportsDatabaseSchemaQualifier,
|
||||
supportsQueryTargetDatabaseListing,
|
||||
supportsSqlInListPaste,
|
||||
supportsTableImport,
|
||||
supportsTransaction,
|
||||
usesConnectionOnlyQueryTarget,
|
||||
} from "@/lib/database/databaseFeatureSupport";
|
||||
|
||||
describe("schema awareness", () => {
|
||||
it("keeps SQLite database aliases separate from schema-capable databases", () => {
|
||||
@@ -10,6 +20,21 @@ describe("schema awareness", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("connection-scoped query targets", () => {
|
||||
it("keeps connection-only target types separate from unregistered namespace targets", () => {
|
||||
expect(usesConnectionOnlyQueryTarget("etcd")).toBe(true);
|
||||
expect(usesConnectionOnlyQueryTarget("zookeeper")).toBe(true);
|
||||
expect(usesConnectionOnlyQueryTarget("elasticsearch")).toBe(true);
|
||||
expect(supportsConnectionScopedQueryExecution("elasticsearch")).toBe(true);
|
||||
expect(supportsQueryTargetDatabaseListing("elasticsearch")).toBe(false);
|
||||
expect(usesConnectionOnlyQueryTarget("qdrant")).toBe(true);
|
||||
expect(usesConnectionOnlyQueryTarget("milvus")).toBe(true);
|
||||
expect(usesConnectionOnlyQueryTarget("weaviate")).toBe(true);
|
||||
expect(usesConnectionOnlyQueryTarget("chromadb")).toBe(true);
|
||||
expect(supportsQueryTargetDatabaseListing("etcd")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("database and schema qualifiers", () => {
|
||||
it.each(["sqlserver", "trino", "prestosql"] as const)("supports three-part object names for %s", (databaseType) => {
|
||||
expect(supportsDatabaseSchemaQualifier(databaseType)).toBe(true);
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
import { manifestDatabaseTypes, supportsDatabaseFeature } from "@/lib/database/databaseDriverManifest";
|
||||
import {
|
||||
normalizeSqlExecutionTarget,
|
||||
sqlExecutionTargetCapabilities,
|
||||
targetAllowsEmptyDatabase,
|
||||
targetCanUseDefaultWhenDatabaseListEmpty,
|
||||
targetDefaultDatabase,
|
||||
targetIsSingleDatabase,
|
||||
targetSupportsSchema,
|
||||
targetUsesConnectionOnlyScope,
|
||||
targetUsesNamespaceScope,
|
||||
} from "@/lib/database/sqlExecutionTargetCapabilities";
|
||||
import { normalizeSqlExecutionTargetContext, resolveSqlExecutionTargetProvider } from "@/lib/database/sqlExecutionTargetRegistry";
|
||||
|
||||
function connection(overrides: Partial<ConnectionConfig>): ConnectionConfig {
|
||||
return {
|
||||
id: "connection-1",
|
||||
name: "Test connection",
|
||||
db_type: "mysql",
|
||||
host: "localhost",
|
||||
port: 3306,
|
||||
username: "user",
|
||||
password: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("sql execution target capabilities", () => {
|
||||
it("exposes a target capability for every manifest database type with query execution", () => {
|
||||
for (const dbType of manifestDatabaseTypes()) {
|
||||
if (!supportsDatabaseFeature(dbType, "queryExecution")) continue;
|
||||
expect(sqlExecutionTargetCapabilities(connection({ db_type: dbType })), dbType).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("models connection-only query targets without database or schema", () => {
|
||||
const config = connection({ db_type: "etcd" });
|
||||
const capabilities = sqlExecutionTargetCapabilities(config);
|
||||
|
||||
expect(capabilities?.scope).toBe("connection");
|
||||
expect(capabilities?.supportsDatabase).toBe(false);
|
||||
expect(capabilities?.supportsSchema).toBe(false);
|
||||
expect(targetUsesConnectionOnlyScope(config)).toBe(true);
|
||||
expect(targetAllowsEmptyDatabase(config)).toBe(true);
|
||||
expect(normalizeSqlExecutionTarget(config, { connectionId: config.id, database: "stale", catalog: "old", schema: "old" })).toEqual({
|
||||
connectionId: config.id,
|
||||
database: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("models document and vector query targets as connection-only until a namespace provider exists", () => {
|
||||
const config = connection({ db_type: "qdrant" });
|
||||
const capabilities = sqlExecutionTargetCapabilities(config);
|
||||
|
||||
expect(capabilities?.scope).toBe("connection");
|
||||
expect(capabilities?.supportsDatabase).toBe(false);
|
||||
expect(capabilities?.supportsSchema).toBe(false);
|
||||
expect(targetUsesNamespaceScope(config)).toBe(false);
|
||||
expect(targetUsesConnectionOnlyScope(config)).toBe(true);
|
||||
expect(normalizeSqlExecutionTarget(config, { connectionId: config.id, database: "collection", schema: "stale" })).toEqual({
|
||||
connectionId: config.id,
|
||||
database: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the registered VictoriaMetrics default without treating arbitrary names as valid", () => {
|
||||
const config = connection({ db_type: "victoriametrics", database: undefined });
|
||||
const capabilities = sqlExecutionTargetCapabilities(config);
|
||||
|
||||
expect(capabilities?.defaultDatabase).toBe("metrics");
|
||||
expect(targetDefaultDatabase(config)).toBe("metrics");
|
||||
expect(targetCanUseDefaultWhenDatabaseListEmpty(config)).toBe(true);
|
||||
expect(targetIsSingleDatabase(config)).toBe(true);
|
||||
expect(targetAllowsEmptyDatabase(config)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the effective JDBC dialect for target semantics", () => {
|
||||
const oracle = connection({ db_type: "jdbc", connection_string: "jdbc:oracle:thin:@localhost:1521/XEPDB1" });
|
||||
const postgres = connection({ db_type: "jdbc", connection_string: "jdbc:postgresql://localhost:5432/app" });
|
||||
|
||||
expect(sqlExecutionTargetCapabilities(oracle)?.databaseType).toBe("oracle");
|
||||
expect(targetIsSingleDatabase(oracle)).toBe(true);
|
||||
expect(targetSupportsSchema(oracle)).toBe(true);
|
||||
expect(sqlExecutionTargetCapabilities(postgres)?.databaseType).toBe("postgres");
|
||||
expect(targetIsSingleDatabase(postgres)).toBe(false);
|
||||
expect(targetSupportsSchema(postgres)).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers a configured PostgreSQL database for the stable default", () => {
|
||||
const config = connection({ db_type: "postgres", database: "app" });
|
||||
|
||||
expect(targetDefaultDatabase(config)).toBe("app");
|
||||
expect(targetCanUseDefaultWhenDatabaseListEmpty(config)).toBe(true);
|
||||
expect(sqlExecutionTargetCapabilities(config)?.provider.id).toBe("builtin:database");
|
||||
});
|
||||
|
||||
it("maps every built-in target scope through the shared provider contract", () => {
|
||||
const databaseProvider = resolveSqlExecutionTargetProvider("database");
|
||||
const connectionProvider = resolveSqlExecutionTargetProvider("connection");
|
||||
const config = connection({ id: "conn-1", db_type: "postgres" });
|
||||
|
||||
expect(databaseProvider.toExecutionContext({ connectionId: "conn-1", database: "app", schema: "public" }, config)).toEqual({
|
||||
scope: "database",
|
||||
database: "app",
|
||||
schema: "public",
|
||||
});
|
||||
expect(connectionProvider.toExecutionContext({ connectionId: "conn-1", database: "" }, config)).toEqual({ scope: "connection" });
|
||||
expect(normalizeSqlExecutionTargetContext({ scope: "connection", database: "stale", schema: "stale", catalog: "stale" })).toEqual({ scope: "connection" });
|
||||
});
|
||||
|
||||
it("rejects stale namespace fields on a connection-scoped target", () => {
|
||||
const config = connection({ id: "conn-1", db_type: "etcd" });
|
||||
const provider = sqlExecutionTargetCapabilities(config)!.provider;
|
||||
|
||||
expect(() => provider.validateTarget({ connectionId: "conn-1", database: "old", schema: "old" }, config)).toThrow();
|
||||
});
|
||||
|
||||
it("does not expose targets for connections without query execution capability", () => {
|
||||
expect(sqlExecutionTargetCapabilities(connection({ db_type: "mq" }))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -3725,6 +3725,7 @@ export interface QueryResultExportRequest {
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
catalog?: string;
|
||||
sql: string;
|
||||
queryBaseSql: string;
|
||||
setupSql?: string[];
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ConnectionConfig, DatabaseType, TreeNodeType } from "@/types/datab
|
||||
import { supportsDatabaseFeature } from "@/lib/database/databaseDriverManifest";
|
||||
import { canEditTableStructure } from "@/lib/table/tableStructureCapabilities";
|
||||
import { CLEARABLE_QUERY_SCHEMA_TYPES, DATABASE_OBJECT_TREE_TYPES, DATABASE_SCHEMA_QUALIFIED_TYPES, FETCH_FIRST_TYPES, PG_LIKE_STRUCTURE_TYPES, SCHEMA_AWARE_TYPES, SINGLE_DATABASE_TYPES, TREE_SCHEMA_TYPES } from "@/lib/database/databaseCapabilitySets";
|
||||
import { supportsRegisteredConnectionScopedQueryExecution, supportsRegisteredQueryTargetDatabaseListing, usesRegisteredConnectionOnlyQueryTarget } from "@/lib/database/sqlExecutionTargetRegistry";
|
||||
|
||||
export function isSchemaAware(dbType?: DatabaseType): boolean {
|
||||
return !!dbType && SCHEMA_AWARE_TYPES.has(dbType);
|
||||
@@ -81,6 +82,40 @@ export function supportsConnectionQueryActions(dbType?: DatabaseType): boolean {
|
||||
return dbType !== "nacos" && dbType !== "hbase";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current product surface exposes a query execution path for the
|
||||
* database type. This is intentionally distinct from sqlFileExecution:
|
||||
* document/vector/key-value editors can execute commands from a query tab
|
||||
* even when importing a SQL file is not supported.
|
||||
*/
|
||||
export function supportsQueryExecution(dbType?: DatabaseType): boolean {
|
||||
return supportsDatabaseFeature(dbType, "queryExecution");
|
||||
}
|
||||
|
||||
export function supportsConnectionScopedQueryExecution(dbType?: DatabaseType): boolean {
|
||||
return supportsRegisteredConnectionScopedQueryExecution(dbType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query surfaces whose target is the connection itself and which do not expose
|
||||
* a database namespace to select. Keep this separate from
|
||||
* supportsConnectionScopedQueryExecution: document/vector stores may execute
|
||||
* without a selected database while still exposing database-like namespaces
|
||||
* (for example indexes or collections) for browsing and target selection.
|
||||
*/
|
||||
export function usesConnectionOnlyQueryTarget(dbType?: DatabaseType): boolean {
|
||||
return usesRegisteredConnectionOnlyQueryTarget(dbType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Database-like namespaces exposed by connection-scoped document/vector
|
||||
* query surfaces. This is the extension point for drivers whose query target
|
||||
* is still selected from a database/index list rather than from SQL metadata.
|
||||
*/
|
||||
export function supportsQueryTargetDatabaseListing(dbType?: DatabaseType): boolean {
|
||||
return supportsRegisteredQueryTargetDatabaseListing(dbType);
|
||||
}
|
||||
|
||||
export function usesFetchFirst(dbType?: DatabaseType): boolean {
|
||||
return !!dbType && FETCH_FIRST_TYPES.has(dbType);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { ConnectionConfig, DatabaseType } from "@/types/database";
|
||||
import type { MultiDbExecutionTarget } from "@/types/sqlExecution";
|
||||
import { isSchemaAware, isSingleDatabase, supportsConnectionScopedQueryExecution, supportsQueryTargetDatabaseListing, usesConnectionOnlyQueryTarget } from "@/lib/database/databaseFeatureSupport";
|
||||
import { supportsConnectionLevelSqlExecution } from "@/lib/connection/connectionLevelDatabaseBootstrap";
|
||||
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { supportsQueryExecution } from "@/lib/database/databaseFeatureSupport";
|
||||
import { registeredSqlExecutionTargetAllowsEmptyMetadataDefault, registeredSqlExecutionTargetDefaultDatabase, resolveSqlExecutionTargetProvider, supportsRegisteredCatalogTarget, type SqlExecutionTargetProvider, type SqlExecutionTargetScope } from "@/lib/database/sqlExecutionTargetRegistry";
|
||||
|
||||
/**
|
||||
* Defaults that are part of the driver contract rather than values inferred
|
||||
* from a possibly incomplete metadata response. A configured connection
|
||||
* database is handled separately below and is also considered a stable
|
||||
* default for that connection.
|
||||
*/
|
||||
export interface SqlExecutionTargetCapabilities {
|
||||
databaseType: DatabaseType;
|
||||
scope: SqlExecutionTargetScope;
|
||||
provider: SqlExecutionTargetProvider;
|
||||
supportsCatalog: boolean;
|
||||
supportsDatabase: boolean;
|
||||
supportsSchema: boolean;
|
||||
/** Whether a database-like namespace is required to construct a target. */
|
||||
databaseRequired: boolean;
|
||||
/** Whether the namespace is enumerated by the existing metadata path. */
|
||||
databaseListable: boolean;
|
||||
/** Whether the connection itself is a valid fallback target. */
|
||||
connectionFallback: boolean;
|
||||
/** Whether an empty database is a valid target value for this scope. */
|
||||
allowsEmptyDatabaseTarget: boolean;
|
||||
/** Stable default used only before metadata has been loaded or by registered fallback rules. */
|
||||
defaultDatabase?: string;
|
||||
/** A loaded empty metadata list must not be widened to this fallback implicitly. */
|
||||
allowDefaultWhenDatabaseListEmpty: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the target shape for every query-capable connection surface.
|
||||
*
|
||||
* This is the single extension point for multi-database target semantics. UI,
|
||||
* validation and persistence consumers should use this result instead of
|
||||
* maintaining database-type allow-lists of their own.
|
||||
*/
|
||||
export function sqlExecutionTargetCapabilities(connection: ConnectionConfig | undefined): SqlExecutionTargetCapabilities | undefined {
|
||||
const databaseType = effectiveDatabaseTypeForConnection(connection);
|
||||
if (!connection || !databaseType || !supportsQueryExecution(connection.db_type)) return undefined;
|
||||
|
||||
const connectionOnly = usesConnectionOnlyQueryTarget(databaseType);
|
||||
const connectionScoped = supportsConnectionScopedQueryExecution(databaseType);
|
||||
const supportsCatalog = supportsRegisteredCatalogTarget(connection, databaseType);
|
||||
const supportsSchema = !connectionOnly && isSchemaAware(databaseType);
|
||||
const supportsDatabase = !connectionOnly;
|
||||
const databaseListable = !connectionOnly;
|
||||
const namespaceTarget = connectionScoped && !connectionOnly;
|
||||
const defaultDatabase = defaultExecutionTargetDatabase(connection, databaseType);
|
||||
const connectionLevelExecution = supportsConnectionLevelSqlExecution(connection);
|
||||
const hasStableDefault = defaultDatabase.length > 0 && (registeredSqlExecutionTargetAllowsEmptyMetadataDefault(databaseType) || !!connection.database?.trim());
|
||||
const scope: SqlExecutionTargetScope = connectionOnly ? "connection" : supportsCatalog ? "catalog" : namespaceTarget ? "namespace" : "database";
|
||||
|
||||
return {
|
||||
databaseType,
|
||||
scope,
|
||||
provider: resolveSqlExecutionTargetProvider(scope, databaseType),
|
||||
supportsCatalog,
|
||||
supportsDatabase,
|
||||
supportsSchema,
|
||||
databaseRequired: connectionOnly ? false : namespaceTarget ? true : !isSingleDatabase(databaseType) && !connectionLevelExecution,
|
||||
databaseListable,
|
||||
connectionFallback: connectionOnly,
|
||||
allowsEmptyDatabaseTarget: connectionOnly || isSingleDatabase(databaseType),
|
||||
defaultDatabase,
|
||||
allowDefaultWhenDatabaseListEmpty: hasStableDefault,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultExecutionTargetDatabase(connection: ConnectionConfig, databaseType: DatabaseType): string {
|
||||
return registeredSqlExecutionTargetDefaultDatabase(connection, databaseType);
|
||||
}
|
||||
|
||||
export function targetUsesConnectionOnlyScope(connection: ConnectionConfig | undefined): boolean {
|
||||
return sqlExecutionTargetCapabilities(connection)?.scope === "connection";
|
||||
}
|
||||
|
||||
export function targetUsesNamespaceScope(connection: ConnectionConfig | undefined): boolean {
|
||||
return sqlExecutionTargetCapabilities(connection)?.scope === "namespace";
|
||||
}
|
||||
|
||||
export function targetUsesConnectionFallback(connection: ConnectionConfig | undefined): boolean {
|
||||
return sqlExecutionTargetCapabilities(connection)?.connectionFallback === true;
|
||||
}
|
||||
|
||||
export function targetAllowsEmptyDatabase(connection: ConnectionConfig | undefined): boolean {
|
||||
return sqlExecutionTargetCapabilities(connection)?.allowsEmptyDatabaseTarget === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a target at the boundary where it is created from a live
|
||||
* connection. This prevents an old tab namespace from leaking into a
|
||||
* connection-scoped target and makes all producers use the same shape.
|
||||
* Persisted targets are intentionally not silently normalized on load; they
|
||||
* must be validated and shown as invalid when their stored shape is stale.
|
||||
*/
|
||||
export function normalizeSqlExecutionTarget(connection: ConnectionConfig | undefined, target: MultiDbExecutionTarget): MultiDbExecutionTarget {
|
||||
const capabilities = sqlExecutionTargetCapabilities(connection);
|
||||
if (!capabilities) return { ...target };
|
||||
if (capabilities.scope === "connection") return { connectionId: target.connectionId, database: "" };
|
||||
return {
|
||||
connectionId: target.connectionId,
|
||||
database: capabilities.supportsDatabase ? target.database : "",
|
||||
...(capabilities.supportsCatalog && target.catalog ? { catalog: target.catalog } : {}),
|
||||
...(capabilities.supportsSchema && target.schema ? { schema: target.schema } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function targetIsSingleDatabase(connection: ConnectionConfig | undefined): boolean {
|
||||
const capabilities = sqlExecutionTargetCapabilities(connection);
|
||||
return capabilities ? isSingleDatabase(capabilities.databaseType) : false;
|
||||
}
|
||||
|
||||
export function targetDefaultDatabase(connection: ConnectionConfig | undefined): string {
|
||||
return sqlExecutionTargetCapabilities(connection)?.defaultDatabase ?? "";
|
||||
}
|
||||
|
||||
export function targetCanUseDefaultWhenDatabaseListEmpty(connection: ConnectionConfig | undefined): boolean {
|
||||
return sqlExecutionTargetCapabilities(connection)?.allowDefaultWhenDatabaseListEmpty === true;
|
||||
}
|
||||
|
||||
export function targetDatabaseListIsRequired(connection: ConnectionConfig | undefined): boolean {
|
||||
const capabilities = sqlExecutionTargetCapabilities(connection);
|
||||
return !!capabilities?.databaseRequired;
|
||||
}
|
||||
|
||||
export function targetDatabaseMetadataIsListable(connection: ConnectionConfig | undefined): boolean {
|
||||
return sqlExecutionTargetCapabilities(connection)?.databaseListable === true;
|
||||
}
|
||||
|
||||
export function targetSupportsSchema(connection: ConnectionConfig | undefined): boolean {
|
||||
return sqlExecutionTargetCapabilities(connection)?.supportsSchema === true;
|
||||
}
|
||||
|
||||
export function targetSupportsCatalog(connection: ConnectionConfig | undefined): boolean {
|
||||
return sqlExecutionTargetCapabilities(connection)?.supportsCatalog === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Namespace lists are already backed by the existing database-options loader
|
||||
* for document/vector query surfaces. Keep this helper explicit so a future
|
||||
* target provider can register a different loader without changing consumers.
|
||||
*/
|
||||
export function targetNamespaceListIsRegistered(connection: ConnectionConfig | undefined): boolean {
|
||||
const databaseType = sqlExecutionTargetCapabilities(connection)?.databaseType;
|
||||
return !!databaseType && supportsQueryTargetDatabaseListing(databaseType);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { ConnectionConfig, DatabaseType } from "@/types/database";
|
||||
import type { MultiDbExecutionTarget } from "@/types/sqlExecution";
|
||||
|
||||
export type SqlExecutionTargetScope = "catalog" | "database" | "namespace" | "connection";
|
||||
|
||||
export type SqlExecutionTargetListLevel = "catalog" | "database" | "schema";
|
||||
|
||||
export type SqlExecutionTargetContext = { scope: "catalog"; catalog?: string; database: string; schema?: string } | { scope: "database"; database: string; schema?: string } | { scope: "namespace"; namespaceKind: string; namespace: string } | { scope: "connection" };
|
||||
|
||||
export interface SqlExecutionTargetMetadataLoader {
|
||||
listCatalogs?: () => Promise<readonly string[]>;
|
||||
listDatabases?: (catalog?: string) => Promise<readonly string[]>;
|
||||
listSchemas?: (database: string) => Promise<readonly string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The target provider is the common contract for target discovery, validation
|
||||
* and execution-context mapping. Metadata itself remains owned by the
|
||||
* existing composables; providers receive a narrow loader adapter so a new
|
||||
* target kind does not need to know about Vue stores or backend caches.
|
||||
*/
|
||||
export interface SqlExecutionTargetProvider {
|
||||
readonly id: string;
|
||||
readonly scope: SqlExecutionTargetScope;
|
||||
canListTargets(connection: ConnectionConfig): boolean;
|
||||
listTargets(connection: ConnectionConfig, request: { level: SqlExecutionTargetListLevel; catalog?: string; database?: string }, loader: SqlExecutionTargetMetadataLoader): Promise<readonly string[]>;
|
||||
validateTarget(target: MultiDbExecutionTarget, connection: ConnectionConfig): void | Promise<void>;
|
||||
toExecutionContext(target: MultiDbExecutionTarget, connection: ConnectionConfig): SqlExecutionTargetContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Target-scope registration for query surfaces whose execution context is not
|
||||
* the ordinary database/schema hierarchy.
|
||||
*
|
||||
* Keep this registry independent from UI components, metadata loaders and the
|
||||
* executor. Adding a connection-scoped query driver should require one
|
||||
* registration here; consumers derive their behavior from the registered
|
||||
* scope instead of maintaining separate database-type lists.
|
||||
*/
|
||||
export type RegisteredSqlExecutionTargetScope = "namespace" | "connection";
|
||||
|
||||
interface SqlExecutionTargetRegistration {
|
||||
scope?: RegisteredSqlExecutionTargetScope;
|
||||
provider?: SqlExecutionTargetProvider;
|
||||
supportsCatalog?: boolean;
|
||||
/**
|
||||
* Returns a driver-defined stable namespace default, if one exists. This
|
||||
* is deliberately separate from connection.database, which is handled as
|
||||
* a per-connection default by the capability resolver.
|
||||
*/
|
||||
defaultDatabase?: (connection: Pick<ConnectionConfig, "database" | "driver_profile">) => string;
|
||||
/** Whether the default remains valid when metadata enumeration is empty. */
|
||||
allowDefaultWhenDatabaseListEmpty?: boolean;
|
||||
}
|
||||
|
||||
function optional(value: string | undefined): string | undefined {
|
||||
const normalized = value?.trim();
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
function providerError(message: string): never {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function createProvider(scope: SqlExecutionTargetScope): SqlExecutionTargetProvider {
|
||||
return {
|
||||
id: `builtin:${scope}`,
|
||||
scope,
|
||||
canListTargets: (connection) => scope !== "connection" && Boolean(connection.id),
|
||||
async listTargets(_connection, request, loader) {
|
||||
if (request.level === "catalog") return (await loader.listCatalogs?.()) ?? [];
|
||||
if (request.level === "database") return (await loader.listDatabases?.(request.catalog)) ?? [];
|
||||
return (await loader.listSchemas?.(request.database ?? "")) ?? [];
|
||||
},
|
||||
validateTarget(target, connection) {
|
||||
if (target.connectionId !== connection.id) providerError("Target connection does not match the selected connection.");
|
||||
if (scope === "connection") {
|
||||
if (target.database.trim() || target.catalog !== undefined || target.schema !== undefined) providerError("Connection-scoped targets cannot contain a database, catalog, or schema.");
|
||||
return;
|
||||
}
|
||||
if (scope !== "catalog" && target.catalog !== undefined) providerError("This target scope does not support catalogs.");
|
||||
if (scope !== "namespace" && target.schema !== undefined && typeof target.schema !== "string") providerError("Invalid target schema.");
|
||||
if (scope === "namespace" && !target.database.trim()) providerError("Namespace targets require a namespace.");
|
||||
},
|
||||
toExecutionContext(target, _connection) {
|
||||
if (scope === "connection") return { scope };
|
||||
if (scope === "namespace") return { scope, namespaceKind: "namespace", namespace: target.database };
|
||||
return {
|
||||
scope,
|
||||
...(scope === "catalog" && target.catalog !== undefined ? { catalog: target.catalog } : {}),
|
||||
database: target.database,
|
||||
...(target.schema !== undefined ? { schema: target.schema } : {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const SQL_EXECUTION_TARGET_PROVIDERS: Record<SqlExecutionTargetScope, SqlExecutionTargetProvider> = {
|
||||
catalog: createProvider("catalog"),
|
||||
database: createProvider("database"),
|
||||
namespace: createProvider("namespace"),
|
||||
connection: createProvider("connection"),
|
||||
};
|
||||
|
||||
const SQL_EXECUTION_TARGET_REGISTRY: Partial<Record<DatabaseType, SqlExecutionTargetRegistration>> = {
|
||||
// These query surfaces currently accept the target namespace in the SQL/REST
|
||||
// text itself, while the shared executeQuery boundary does not carry a
|
||||
// namespace context. Keep them connection-scoped until a driver provider can
|
||||
// safely apply a selected index/collection/database to the actual request.
|
||||
elasticsearch: { scope: "connection" },
|
||||
easysearch: { scope: "connection" },
|
||||
qdrant: { scope: "connection" },
|
||||
milvus: { scope: "connection" },
|
||||
weaviate: { scope: "connection" },
|
||||
chromadb: { scope: "connection" },
|
||||
etcd: { scope: "connection" },
|
||||
zookeeper: { scope: "connection" },
|
||||
doris: { supportsCatalog: true },
|
||||
starrocks: { supportsCatalog: true },
|
||||
"cloudflare-d1": { defaultDatabase: () => "main", allowDefaultWhenDatabaseListEmpty: true },
|
||||
sqlite: { defaultDatabase: (connection) => connection.database?.trim() || "main", allowDefaultWhenDatabaseListEmpty: true },
|
||||
postgres: {
|
||||
defaultDatabase: (connection) => connection.database?.trim() || (connection.driver_profile === "cockroachdb" ? "defaultdb" : "postgres"),
|
||||
allowDefaultWhenDatabaseListEmpty: true,
|
||||
},
|
||||
victoriametrics: { defaultDatabase: (connection) => connection.database?.trim() || "metrics", allowDefaultWhenDatabaseListEmpty: true },
|
||||
};
|
||||
|
||||
export function registeredSqlExecutionTargetScope(dbType?: DatabaseType): RegisteredSqlExecutionTargetScope | undefined {
|
||||
return dbType ? SQL_EXECUTION_TARGET_REGISTRY[dbType]?.scope : undefined;
|
||||
}
|
||||
|
||||
export function supportsRegisteredConnectionScopedQueryExecution(dbType?: DatabaseType): boolean {
|
||||
return registeredSqlExecutionTargetScope(dbType) !== undefined;
|
||||
}
|
||||
|
||||
export function usesRegisteredConnectionOnlyQueryTarget(dbType?: DatabaseType): boolean {
|
||||
return registeredSqlExecutionTargetScope(dbType) === "connection";
|
||||
}
|
||||
|
||||
export function supportsRegisteredQueryTargetDatabaseListing(dbType?: DatabaseType): boolean {
|
||||
return registeredSqlExecutionTargetScope(dbType) === "namespace";
|
||||
}
|
||||
|
||||
export function supportsRegisteredCatalogTarget(connection: Pick<ConnectionConfig, "db_type" | "driver_profile"> | undefined, dbType?: DatabaseType): boolean {
|
||||
if (!connection || !dbType) return false;
|
||||
return SQL_EXECUTION_TARGET_REGISTRY[dbType]?.supportsCatalog === true;
|
||||
}
|
||||
|
||||
export function registeredSqlExecutionTargetDefaultDatabase(connection: Pick<ConnectionConfig, "database" | "driver_profile">, dbType: DatabaseType): string {
|
||||
return SQL_EXECUTION_TARGET_REGISTRY[dbType]?.defaultDatabase?.(connection) ?? connection.database?.trim() ?? "";
|
||||
}
|
||||
|
||||
export function registeredSqlExecutionTargetAllowsEmptyMetadataDefault(dbType?: DatabaseType): boolean {
|
||||
return dbType ? SQL_EXECUTION_TARGET_REGISTRY[dbType]?.allowDefaultWhenDatabaseListEmpty === true : false;
|
||||
}
|
||||
|
||||
export function resolveSqlExecutionTargetProvider(scope: SqlExecutionTargetScope, dbType?: DatabaseType): SqlExecutionTargetProvider {
|
||||
return (dbType ? SQL_EXECUTION_TARGET_REGISTRY[dbType]?.provider : undefined) ?? SQL_EXECUTION_TARGET_PROVIDERS[scope];
|
||||
}
|
||||
|
||||
export function registeredSqlExecutionTargetNamespaceKind(dbType?: DatabaseType): string | undefined {
|
||||
return dbType && SQL_EXECUTION_TARGET_REGISTRY[dbType]?.scope === "namespace" ? "namespace" : undefined;
|
||||
}
|
||||
|
||||
export function normalizeSqlExecutionTargetContext(context: SqlExecutionTargetContext): SqlExecutionTargetContext {
|
||||
if (context.scope === "connection") return { scope: "connection" };
|
||||
if (context.scope === "namespace") {
|
||||
return {
|
||||
scope: "namespace",
|
||||
namespaceKind: optional(context.namespaceKind) ?? "namespace",
|
||||
namespace: context.namespace?.trim() ?? "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
scope: context.scope,
|
||||
...(context.scope === "catalog" && context.catalog !== undefined ? { catalog: optional(context.catalog) } : {}),
|
||||
database: context.database.trim(),
|
||||
...(context.schema !== undefined ? { schema: optional(context.schema) } : {}),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user