diff --git a/README.md b/README.md index 04d5910be..ddde9ff03 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ ### 70+ Databases, One Tool -MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, and more. Agent/JDBC-oriented profiles extend DBX to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, JDBCX, and custom JDBC connections. New native and agent-driven drivers also cover Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, IRIS, and more. Message queue admin is also available for Pulsar, Kafka, and RocketMQ. All in a single ~20 MB app. No bundled Chromium. +MySQL, PostgreSQL, SQLite, Cloudflare D1, Redis, MongoDB, DuckDB, ClickHouse, SQL Server, Oracle, Elasticsearch, Easysearch, Qdrant, Milvus, Weaviate, MariaDB, TiDB, OceanBase, openGauss, GaussDB, KWDB, KingBase, Vastbase, GoldenDB, Doris, SelectDB, StarRocks, Manticore Search, Redshift, DM, TDengine, XuguDB, CockroachDB, Access, HighGo, UXDB, Dolt, and more. Agent/JDBC-oriented profiles extend DBX to H2, Snowflake, Trino, PrestoSQL, Hive, DB2, Informix, Neo4j, Cassandra, BigQuery, Kylin, SunDB, JDBCX, and custom JDBC connections. New native and agent-driven drivers also cover Databricks, SAP HANA, Teradata, Vertica, Firebird, Exasol, YashanDB, GBase 8a/8s, Databend, RQLite, Turso, InfluxDB, QuestDB, IoTDB, etcd, ZooKeeper, Nacos, IRIS, and more. Message queue admin is also available for Pulsar, Kafka, and RocketMQ. All in a single ~20 MB app. No bundled Chromium. ### Query Editor diff --git a/README.zh-CN.md b/README.zh-CN.md index 1a1683bbd..3900c6a1b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -90,7 +90,7 @@ ### 70+ 种数据库,一个工具搞定 -MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB 等数据库都能直接连接。Agent/JDBC 方向的配置还可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、JDBCX 和自定义 JDBC。新增的原生与 Agent 驱动还覆盖了 Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、IRIS 等。全部装进约 20 MB 的应用里,不内嵌 Chromium。 +MySQL、PostgreSQL、SQLite、Cloudflare D1、Redis、MongoDB、DuckDB、ClickHouse、SQL Server、Oracle、Elasticsearch、Easysearch、MariaDB、TiDB、OceanBase、openGauss、GaussDB、KWDB、KingBase、Vastbase、GoldenDB、Doris、SelectDB、StarRocks、Manticore Search、Redshift、DM、TDengine、虚谷 XuguDB、CockroachDB、Access、HighGo、UXDB、Dolt 等数据库都能直接连接。Agent/JDBC 方向的配置还可扩展到 H2、Snowflake、Trino、Hive、DB2、Informix、Neo4j、Cassandra、BigQuery、Kylin、SunDB、JDBCX 和自定义 JDBC。新增的原生与 Agent 驱动还覆盖了 Databricks、SAP HANA、Teradata、Vertica、Firebird、Exasol、崖山 YashanDB、GBase、Databend、RQLite、Turso、InfluxDB、QuestDB、IoTDB、etcd、IRIS 等。全部装进约 20 MB 的应用里,不内嵌 Chromium。 ### 查询编辑器 diff --git a/apps/desktop/public/icons/database/dolt.svg b/apps/desktop/public/icons/database/dolt.svg new file mode 100644 index 000000000..3f07ce8ae --- /dev/null +++ b/apps/desktop/public/icons/database/dolt.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index c161037e0..071a6a298 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -1143,6 +1143,7 @@ const driverProfiles: Record< icon: "mysql", urlParams: "", }, + dolt: { type: "mysql", port: 3306, user: "root", label: "Dolt", icon: "dolt", urlParams: "" }, custom_postgres: { type: "postgres", port: 5432, @@ -2662,6 +2663,7 @@ const iconTypeMap: Record = { victoriametrics: "victoriametrics", jdbc: "jdbc", custom_mysql: "mysql", + dolt: "dolt", custom_postgres: "postgres", ...jdbcProductIconTypes(), }; @@ -2749,6 +2751,7 @@ const dbOptions: DbOption[] = [ { value: "jdbcx", label: "JDBCX" }, { value: "manticoresearch", label: "Manticore Search" }, { value: "custom_mysql", label: "Custom (MySQL)" }, + { value: "dolt", label: "Dolt" }, { value: "custom_postgres", label: "Custom (PostgreSQL)" }, { value: "dremio", label: "Dremio" }, ...jdbcProductPickerOptions(), @@ -2762,7 +2765,7 @@ const dbCategoryDefinitions: Array<{ { key: "sql", titleKey: "connection.databaseCategorySql", - optionValues: ["postgres", "mysql", "oracle", "sqlserver", "mariadb", "cockroachdb", "db2", "informix", "firebird", "iris", "jdbcx", "custom_mysql", "custom_postgres"], + optionValues: ["postgres", "mysql", "oracle", "sqlserver", "mariadb", "cockroachdb", "db2", "informix", "firebird", "iris", "jdbcx", "custom_mysql", "custom_postgres", "dolt"], }, { key: "analytics", diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index c4a083a35..b11fc5ab1 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -223,6 +223,7 @@ const snippetDatabaseType = computed(() => { const connection = props.connectionId ? connectionStore.getConfig(props.connectionId) : undefined; return sqlSnippetDatabaseTypeForConnection(connection) ?? props.databaseType; }); +const sqlDriverProfile = computed(() => (props.connectionId ? connectionStore.getConfig(props.connectionId)?.driver_profile : undefined)); const SQL_FUNCTION_NAMES = [ "COUNT", @@ -3192,6 +3193,7 @@ async function provideSqlCompletions(context: CompletionContext) { snippets: settingsStore.editorSettings.snippets, dialect: props.dialect, databaseType: snippetDatabaseType.value, + driverProfile: sqlDriverProfile.value, currentSchema: props.schema, keywordCase: settingsStore.editorSettings.sqlFormatter.keywordCase, functionCase: settingsStore.editorSettings.sqlFormatter.functionCase, @@ -3252,6 +3254,7 @@ async function provideSqlCompletions(context: CompletionContext) { snippets: settingsStore.editorSettings.snippets, dialect: props.dialect, databaseType: snippetDatabaseType.value, + driverProfile: sqlDriverProfile.value, currentSchema: props.schema, keywordCase: settingsStore.editorSettings.sqlFormatter.keywordCase, functionCase: settingsStore.editorSettings.sqlFormatter.functionCase, @@ -3519,6 +3522,7 @@ function buildLocalSqlCompletionResult(completionContext: ReturnType { }; buildSqlSignatureExtension = () => showTooltip.compute(["doc", "selection"], (currentState) => { - const signature = getSqlFunctionSignatureHelp(currentState.doc.toString(), currentState.selection.main.head, props.databaseType); + const signature = getSqlFunctionSignatureHelp(currentState.doc.toString(), currentState.selection.main.head, props.databaseType, sqlDriverProfile.value); if (!signature) return null; return { pos: currentState.selection.main.head, @@ -4376,7 +4381,7 @@ onMounted(async () => { buildSqlLanguageExtension = () => langSql.sql({ - dialect: createDbxCodeMirrorSqlDialect(langSql, props.syntaxDialect ?? props.dialect, props.databaseType), + dialect: createDbxCodeMirrorSqlDialect(langSql, props.syntaxDialect ?? props.dialect, props.databaseType, sqlDriverProfile.value), }); buildSqlSemanticHighlightExtension = () => [ ViewPlugin.fromClass( @@ -5112,7 +5117,7 @@ watch([() => props.clientSessionId, () => props.completionContextVersion], () => scheduleSemanticDiagnostics(); }); -watch([() => props.databaseType, () => props.dialect, () => props.syntaxDialect], () => { +watch([() => props.databaseType, () => props.dialect, () => props.syntaxDialect, sqlDriverProfile], () => { executableStatementRangeCache = null; if (!view.value || !sqlLanguageComp || !buildSqlLanguageExtension || !sqlSemanticHighlightComp || !buildSqlSemanticHighlightExtension || !sqlSignatureComp || !buildSqlSignatureExtension) return; // Signature tooltips depend on the external dialect, so refresh them even when the document and selection stay unchanged. diff --git a/apps/desktop/src/components/icons/DatabaseIcon.vue b/apps/desktop/src/components/icons/DatabaseIcon.vue index 484b52c1d..6810d1987 100644 --- a/apps/desktop/src/components/icons/DatabaseIcon.vue +++ b/apps/desktop/src/components/icons/DatabaseIcon.vue @@ -106,6 +106,7 @@ const assetIcons: Record = { oscar: "oscar.png", jdbcx: "jdbcx", mqtt: "mqtt", + dolt: "dolt", }; const normalizedType = computed(() => props.dbType.toLowerCase().replace(/[\s-]+/g, "_")); diff --git a/apps/desktop/src/lib/__tests__/database/doltProfile.spec.ts b/apps/desktop/src/lib/__tests__/database/doltProfile.spec.ts new file mode 100644 index 000000000..7bce75808 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/database/doltProfile.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { DOLT_SQL_ROUTINES, doltSqlBuiltinTerms, doltSqlRoutineSignatures, isDoltDriverProfile } from "@/lib/database/doltProfile"; + +describe("doltProfile", () => { + it("matches only the dedicated Dolt driver profile", () => { + expect(isDoltDriverProfile("dolt")).toBe(true); + expect(isDoltDriverProfile("DOLT")).toBe(true); + expect(isDoltDriverProfile("mysql")).toBe(false); + expect(isDoltDriverProfile()).toBe(false); + }); + + it("exposes Dolt routines only for Dolt connections", () => { + expect(doltSqlBuiltinTerms("mysql")).toBe(""); + expect(doltSqlRoutineSignatures("mysql").size).toBe(0); + + const terms = new Set(doltSqlBuiltinTerms("dolt").split(" ")); + const signatures = doltSqlRoutineSignatures("dolt"); + expect(terms.has("active_branch")).toBe(true); + expect(terms.has("dolt_branch")).toBe(true); + expect(terms.has("dolt_version")).toBe(true); + expect(signatures.get("DOLT_MERGE_BASE")).toEqual(["revision_a", "revision_b"]); + expect(signatures.size).toBe(DOLT_SQL_ROUTINES.length); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/editor/codemirrorSqlDialect.spec.ts b/apps/desktop/src/lib/__tests__/editor/codemirrorSqlDialect.spec.ts index f728d2d2d..5e567dbbc 100644 --- a/apps/desktop/src/lib/__tests__/editor/codemirrorSqlDialect.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/codemirrorSqlDialect.spec.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; -import { PostgreSQL } from "@codemirror/lang-sql"; -import { postgresKeywordSyntaxTerms } from "@/lib/editor/codemirrorSqlDialect"; +import * as langSql from "@codemirror/lang-sql"; +import { createDbxCodeMirrorSqlDialect, postgresKeywordSyntaxTerms } from "@/lib/editor/codemirrorSqlDialect"; describe("codemirrorSqlDialect", () => { it("keeps common PostgreSQL identifier names out of keyword highlighting", () => { - const keywords = new Set(postgresKeywordSyntaxTerms(PostgreSQL.spec.keywords || "").split(/\s+/)); + const keywords = new Set(postgresKeywordSyntaxTerms(langSql.PostgreSQL.spec.keywords || "").split(/\s+/)); expect(keywords.has("select")).toBe(true); expect(keywords.has("from")).toBe(true); @@ -14,4 +14,13 @@ describe("codemirrorSqlDialect", () => { expect(keywords.has("user")).toBe(false); expect(keywords.has("count")).toBe(false); }); + + it("adds Dolt routines to highlighting without changing standard MySQL", () => { + const doltBuiltins = new Set(createDbxCodeMirrorSqlDialect(langSql, "mysql", "mysql", "dolt").spec.builtin?.split(/\s+/)); + const mysqlBuiltins = new Set(createDbxCodeMirrorSqlDialect(langSql, "mysql", "mysql", "mysql").spec.builtin?.split(/\s+/)); + + expect(doltBuiltins.has("dolt_branch")).toBe(true); + expect(doltBuiltins.has("dolt_merge")).toBe(true); + expect(mysqlBuiltins.has("dolt_branch")).toBe(false); + }); }); diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorSqlSignature.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorSqlSignature.spec.ts index 38640fef5..2da6d1b3e 100644 --- a/apps/desktop/src/lib/__tests__/editor/queryEditorSqlSignature.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorSqlSignature.spec.ts @@ -4,11 +4,11 @@ import { describe, expect, it } from "vitest"; const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8"); describe("QueryEditor SQL signature refresh wiring", () => { - it("reconfigures signature help when the database dialect changes", () => { + it("reconfigures signature help when the database dialect or driver profile changes", () => { expect(queryEditorSource).toContain('let sqlSignatureComp: import("@codemirror/state").Compartment | null = null;'); expect(queryEditorSource).toContain("sqlSignatureComp.of(buildSqlSignatureExtension())"); - const watcherStart = queryEditorSource.indexOf("watch([() => props.databaseType, () => props.dialect, () => props.syntaxDialect]"); + const watcherStart = queryEditorSource.indexOf("watch([() => props.databaseType, () => props.dialect, () => props.syntaxDialect, sqlDriverProfile]"); const watcherEnd = queryEditorSource.indexOf("\n});", watcherStart); const dialectWatcher = queryEditorSource.slice(watcherStart, watcherEnd); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.dolt.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.dolt.spec.ts new file mode 100644 index 000000000..227784425 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.dolt.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { buildSqlCompletionItems } from "@/lib/sql/sqlCompletion"; + +function completionLabels(sql: string, driverProfile: string): string[] { + return buildSqlCompletionItems(sql, sql.length, { + tables: [], + objects: [], + columnsByTable: new Map(), + databaseType: "mysql", + driverProfile, + }).map((item) => item.label); +} + +describe("Dolt SQL completion", () => { + it("suggests Dolt routines for the Dolt profile", () => { + expect(completionLabels("DOLT_BR", "dolt")).toContain("DOLT_BRANCH"); + expect(completionLabels("CALL DOLT_CH", "dolt")).toContain("DOLT_CHECKOUT"); + }); + + it("does not expose Dolt routines to standard MySQL", () => { + expect(completionLabels("DOLT_BR", "mysql")).not.toContain("DOLT_BRANCH"); + expect(completionLabels("CALL DOLT_CH", "mysql")).not.toContain("DOLT_CHECKOUT"); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.signature.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.signature.spec.ts index 8613b0766..94ad4fa0a 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.signature.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.signature.spec.ts @@ -25,4 +25,14 @@ describe("ClickHouse signature help", () => { expect(help?.overloads).toHaveLength(1); expect(help?.overloads[0].parameterGroups).toEqual([["date", "INTERVAL expr unit"]]); }); + + it("adds Dolt signature help only for the Dolt profile", () => { + const sql = "CALL DOLT_MERGE_BASE('main', "; + expect(getSqlFunctionSignatureHelp(sql, sql.length, "mysql", "mysql")).toBeNull(); + + const help = getSqlFunctionSignatureHelp(sql, sql.length, "mysql", "dolt"); + expect(help?.name).toBe("DOLT_MERGE_BASE"); + expect(help?.parameters).toEqual(["revision_a", "revision_b"]); + expect(help?.activeParameter).toBe(1); + }); }); diff --git a/apps/desktop/src/lib/database/doltProfile.ts b/apps/desktop/src/lib/database/doltProfile.ts new file mode 100644 index 000000000..6252c899c --- /dev/null +++ b/apps/desktop/src/lib/database/doltProfile.ts @@ -0,0 +1,60 @@ +export const DOLT_DRIVER_PROFILE = "dolt"; + +export type DoltSqlRoutineDefinition = { + name: string; + type: "procedure" | "function"; + signature: string; +}; + +export const DOLT_SQL_ROUTINES: readonly DoltSqlRoutineDefinition[] = [ + { name: "ACTIVE_BRANCH", type: "function", signature: "" }, + { name: "DOLT_ADD", type: "procedure", signature: "arguments" }, + { name: "DOLT_BRANCH", type: "procedure", signature: "arguments" }, + { name: "DOLT_CHECKOUT", type: "procedure", signature: "arguments" }, + { name: "DOLT_CHERRY_PICK", type: "procedure", signature: "arguments" }, + { name: "DOLT_COMMIT", type: "procedure", signature: "arguments" }, + { name: "DOLT_FETCH", type: "procedure", signature: "arguments" }, + { name: "DOLT_MERGE", type: "procedure", signature: "arguments" }, + { name: "DOLT_PULL", type: "procedure", signature: "arguments" }, + { name: "DOLT_PUSH", type: "procedure", signature: "arguments" }, + { name: "DOLT_REBASE", type: "procedure", signature: "arguments" }, + { name: "DOLT_REMOTE", type: "procedure", signature: "arguments" }, + { name: "DOLT_RESET", type: "procedure", signature: "arguments" }, + { name: "DOLT_REVERT", type: "procedure", signature: "arguments" }, + { name: "DOLT_TAG", type: "procedure", signature: "arguments" }, + { name: "DOLT_DIFF", type: "function", signature: "arguments" }, + { name: "DOLT_DIFF_STAT", type: "function", signature: "arguments" }, + { name: "DOLT_DIFF_SUMMARY", type: "function", signature: "arguments" }, + { name: "DOLT_HASHOF", type: "function", signature: "revision" }, + { name: "DOLT_HASHOF_DB", type: "function", signature: "revision" }, + { name: "DOLT_HASHOF_TABLE", type: "function", signature: "table" }, + { name: "DOLT_LOG", type: "function", signature: "arguments" }, + { name: "DOLT_MERGE_BASE", type: "function", signature: "revision_a, revision_b" }, + { name: "DOLT_PATCH", type: "function", signature: "arguments" }, + { name: "DOLT_QUERY_DIFF", type: "function", signature: "query_a, query_b" }, + { name: "DOLT_REFLOG", type: "function", signature: "arguments" }, + { name: "DOLT_SCHEMA_DIFF", type: "function", signature: "arguments" }, + { name: "DOLT_VERSION", type: "function", signature: "" }, +]; + +export function isDoltDriverProfile(driverProfile?: string): boolean { + return driverProfile?.toLowerCase() === DOLT_DRIVER_PROFILE; +} + +export function doltSqlBuiltinTerms(driverProfile?: string): string { + if (!isDoltDriverProfile(driverProfile)) return ""; + return DOLT_SQL_ROUTINES.map((routine) => routine.name.toLowerCase()).join(" "); +} + +export function doltSqlRoutineSignatures(driverProfile?: string): Map { + if (!isDoltDriverProfile(driverProfile)) return new Map(); + return new Map( + DOLT_SQL_ROUTINES.map((routine) => [ + routine.name, + routine.signature + .split(",") + .map((parameter) => parameter.trim()) + .filter(Boolean), + ]), + ); +} diff --git a/apps/desktop/src/lib/editor/codemirrorSqlDialect.ts b/apps/desktop/src/lib/editor/codemirrorSqlDialect.ts index 9c3790ee3..4fee9d60b 100644 --- a/apps/desktop/src/lib/editor/codemirrorSqlDialect.ts +++ b/apps/desktop/src/lib/editor/codemirrorSqlDialect.ts @@ -1,5 +1,6 @@ import type { SQLDialect } from "@codemirror/lang-sql"; import type { DatabaseType } from "@/types/database"; +import { doltSqlBuiltinTerms } from "@/lib/database/doltProfile"; export type CodeMirrorSqlDialectName = "mysql" | "postgres" | "sqlserver" | "clickhouse"; @@ -175,7 +176,7 @@ function codeMirrorBaseDialect(langSql: CodeMirrorSqlLanguageModule, dialectName return dialectName === "postgres" ? langSql.PostgreSQL : dialectName === "sqlserver" ? langSql.MSSQL : langSql.MySQL; } -export function createDbxCodeMirrorSqlDialect(langSql: CodeMirrorSqlLanguageModule, dialectName: CodeMirrorSqlDialectName = "mysql", databaseType?: DatabaseType): SQLDialect { +export function createDbxCodeMirrorSqlDialect(langSql: CodeMirrorSqlLanguageModule, dialectName: CodeMirrorSqlDialectName = "mysql", databaseType?: DatabaseType, driverProfile?: string): SQLDialect { const baseDialect = codeMirrorBaseDialect(langSql, dialectName, databaseType); const isPostgres = baseDialect === langSql.PostgreSQL; const isSqlServer = baseDialect === langSql.MSSQL; @@ -188,7 +189,7 @@ export function createDbxCodeMirrorSqlDialect(langSql: CodeMirrorSqlLanguageModu ...baseDialect.spec, keywords: [baseKeywords, commonKeywords, isClickHouse ? CLICKHOUSE_KEYWORDS : "", isPostgres ? POSTGRES_PLPGSQL_KEYWORDS : "", isSqlServer ? SQLSERVER_KEYWORDS : ""].filter(Boolean).join(" "), types: [baseTypes, isClickHouse ? CLICKHOUSE_TYPES : "", isPostgres ? POSTGRES_PLPGSQL_TYPES : ""].filter(Boolean).join(" ") || undefined, - builtin: [baseDialect.spec.builtin || "", isClickHouse ? CLICKHOUSE_BUILTINS : "", isPostgres ? POSTGRES_PLPGSQL_BUILTIN : ""].filter(Boolean).join(" ") || undefined, + builtin: [baseDialect.spec.builtin || "", isClickHouse ? CLICKHOUSE_BUILTINS : "", isPostgres ? POSTGRES_PLPGSQL_BUILTIN : "", doltSqlBuiltinTerms(driverProfile)].filter(Boolean).join(" ") || undefined, ...(isClickHouse ? { identifierQuotes: '"`', diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index fb9385adb..a685d765f 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -13,6 +13,7 @@ import { requiresPostgresIdentifierQuote } from "@/lib/sql/sqlIdentifier"; import { identifierMatchScore, matchesIdentifierSearch } from "@/lib/sql/identifierSearch"; import { containsHan, orderedSubsequenceSpan, pinyinFirstLetters } from "@/lib/common/pinyin"; import { quoteTableIdentifier } from "@/lib/table/tableSelectSql"; +import { DOLT_SQL_ROUTINES, doltSqlRoutineSignatures, isDoltDriverProfile } from "@/lib/database/doltProfile"; export { DEFAULT_SQL_SNIPPETS, resolveSqlSnippetBodyForDatabase } from "@/lib/sql/sqlSnippetTemplates"; @@ -1304,6 +1305,7 @@ export interface SqlCompletionProviderInput { snippets?: SqlSnippet[]; dialect?: "mysql" | "postgres" | "sqlserver"; databaseType?: DatabaseType; + driverProfile?: string; currentSchema?: string; keywordCase?: SqlKeywordCase; functionCase?: SqlKeywordCase; @@ -1322,6 +1324,7 @@ export function buildSqlCompletionItems( translations?: SqlCompletionTranslations; dialect?: "mysql" | "postgres" | "sqlserver"; databaseType?: DatabaseType; + driverProfile?: string; currentSchema?: string; keywordCase?: SqlKeywordCase; functionCase?: SqlKeywordCase; @@ -1367,7 +1370,7 @@ class SqlCompletionProvider { this.items.push(...buildSnippetItems(context.prefix, snippets, this.input.keywordCase, this.databaseType)); } if (!preferReferencedColumns || context.suggestRoutines) { - const functionItems = context.dataTypeContext ? [] : buildFunctionSnippetItems(context.prefix, getFunctionDescriptions(this.t), this.databaseType, context.openingParenAfterCursor, this.input.keywordCase, this.input.functionCase); + const functionItems = context.dataTypeContext ? [] : buildFunctionSnippetItems(context.prefix, getFunctionDescriptions(this.t), this.databaseType, this.input.driverProfile, context.openingParenAfterCursor, this.input.keywordCase, this.input.functionCase); this.items.push(...(preferReferencedColumns ? functionItems.filter((item) => item.label.toLowerCase().startsWith(context.prefix.toLowerCase())) : functionItems)); if (isOracleLikeDatabase(this.databaseType)) { this.items.push(...buildOracleSystemValueItems(context.prefix, this.input.keywordCase)); @@ -1435,7 +1438,8 @@ class SqlCompletionProvider { } if (context.suggestRoutines || context.exclusiveRoutineSuggestions || context.oracleTableFunctionContext) { - this.items.push(...buildObjectItems(context, this.input.objects ?? [], this.dialect, this.databaseType, this.input.currentSchema)); + const profileObjects = isDoltDriverProfile(this.input.driverProfile) ? DOLT_SQL_ROUTINES : []; + this.items.push(...buildObjectItems(context, [...(this.input.objects ?? []), ...profileObjects], this.dialect, this.databaseType, this.input.currentSchema)); } if (context.comparisonLeftColumn && context.suggestKeywords) { @@ -1654,7 +1658,7 @@ export function getSqlCompletionResultValidFor(sql: string, cursor: number): Reg return undefined; } -export function getSqlFunctionSignatureHelp(sql: string, cursor: number, databaseType?: DatabaseType): SqlFunctionSignatureHelp | null { +export function getSqlFunctionSignatureHelp(sql: string, cursor: number, databaseType?: DatabaseType, driverProfile?: string): SqlFunctionSignatureHelp | null { const beforeCursor = sql.slice(0, cursor); const call = findActiveFunctionCall(beforeCursor); if (!call) return null; @@ -1662,7 +1666,7 @@ export function getSqlFunctionSignatureHelp(sql: string, cursor: number, databas const observedParameter = countTopLevelCommas(call.groupText); if (databaseType !== "clickhouse") { const lookupName = call.name.toUpperCase(); - const parameters = (databaseType ? DATABASE_FUNCTION_SIGNATURES[databaseType]?.get(lookupName) : undefined) ?? SQL_FUNCTION_SIGNATURES.get(lookupName); + const parameters = activeFunctionSignatures(databaseType, driverProfile).get(lookupName); if (!parameters) return null; const activeParameter = Math.min(observedParameter, Math.max(0, parameters.length - 1)); const signature = `${lookupName}(${parameters.join(", ")})`; @@ -4167,13 +4171,14 @@ function buildSnippetItems(prefix: string, snippets: SqlSnippet[], keywordCase?: }); } -function activeFunctionSignatures(databaseType?: DatabaseType): Map { +function activeFunctionSignatures(databaseType?: DatabaseType, driverProfile?: string): Map { const commonFunctionNames = databaseType === "cloudflare-d1" ? CLOUDFLARE_D1_COMMON_FUNCTION_NAMES : COMMON_SQL_FUNCTION_NAMES; const signatures = databaseType ? new Map(Array.from(SQL_FUNCTION_SIGNATURES.entries()).filter(([name]) => commonFunctionNames.has(name))) : new Map(SQL_FUNCTION_SIGNATURES); const databaseSignatures = databaseType ? DATABASE_FUNCTION_SIGNATURES[databaseType] : undefined; if (databaseSignatures) { for (const [name, parameters] of databaseSignatures) signatures.set(name, parameters); } + for (const [name, parameters] of doltSqlRoutineSignatures(driverProfile)) signatures.set(name, parameters); return signatures; } @@ -4215,11 +4220,11 @@ function buildClickHouseFunctionItems(prefix: string, omitOpeningParen: boolean, }); } -function buildFunctionSnippetItems(prefix: string, functionDescriptions: Map, databaseType?: DatabaseType, omitOpeningParen = false, keywordCase?: SqlKeywordCase, functionCase?: SqlKeywordCase): SqlCompletionItem[] { +function buildFunctionSnippetItems(prefix: string, functionDescriptions: Map, databaseType?: DatabaseType, driverProfile?: string, omitOpeningParen = false, keywordCase?: SqlKeywordCase, functionCase?: SqlKeywordCase): SqlCompletionItem[] { if (databaseType === "clickhouse") return buildClickHouseFunctionItems(prefix, omitOpeningParen); const items: SqlCompletionItem[] = []; - for (const [name, parameters] of activeFunctionSignatures(databaseType).entries()) { + for (const [name, parameters] of activeFunctionSignatures(databaseType, driverProfile).entries()) { if (!matchesPrefix(name, prefix)) continue; const functionName = applySqlFunctionCase(name, functionCase); const paramStr = parameters.length > 0 ? parameters.map((p) => `\${${applyGeneratedSqlTemplateKeywordCase(p, keywordCase)}}`).join(", ") : ""; @@ -4227,7 +4232,7 @@ function buildFunctionSnippetItems(prefix: string, functionDescriptions: Map bool { + is_profile(&config.db_type, config.driver_profile.as_deref()) +} + +fn is_profile(db_type: &DatabaseType, driver_profile: Option<&str>) -> bool { + *db_type == DatabaseType::Mysql && driver_profile.is_some_and(|profile| profile.eq_ignore_ascii_case("dolt")) +} + +pub async fn list_databases(pool: &MySqlPool) -> Result, String> { + let mut conn = get_conn_with_timeout(pool, super::connection_timeout()).await?; + if let Err(error) = conn.query_drop(ENABLE_BRANCH_DATABASES_SQL).await { + log::debug!("Dolt branch database system variable is unavailable; falling back to dolt_branches: {error}"); + return list_databases_from_branches(&mut conn).await; + } + let databases = query_database_names(&mut conn).await?; + // 主路径成功读完 SHOW DATABASES 后,归还连接前复位 session 变量, + // 避免该连接被复用于无关元数据查询时仍残留 branch 数据库可见性。 + if let Err(error) = conn.query_drop(DISABLE_BRANCH_DATABASES_SQL).await { + log::warn!("Failed to reset Dolt branch database session variable before returning connection: {error}"); + } + Ok(database_infos(databases)) +} + +async fn list_databases_from_branches(conn: &mut mysql_async::Conn) -> Result, String> { + let base_names = query_database_names(conn).await?; + let discovery_names: Vec = + base_names.iter().filter(|name| is_branch_discovery_database(name)).cloned().collect(); + let mut seen: HashSet = base_names.iter().cloned().collect(); + let mut names = base_names; + + for database in discovery_names { + let branches: Vec = match conn.query(list_branches_sql(&database)).await { + Ok(branches) => branches, + Err(error) => { + // fallback 进入这里说明实例不支持 dolt_show_branch_databases 系统变量。 + // 首个非系统库的 dolt_branches 查询报错即可判定该实例并非 Dolt + // (可能是同一实例上混用的纯 MySQL 库),逐库试探只会对每个普通库 + // 产生一次无效报错噪音。首次失败即整体降级为纯 SHOW DATABASES 结果, + // 不再继续试探剩余库。注意区分「查询报错(非 Dolt)」与「查询成功但为空 + // (是 Dolt 但无 branch)」:只有前者触发降级,后者继续遍历后续库。 + log::debug!( + "Dolt branch discovery failed on `{database}`, treating instance as non-Dolt and falling back to plain SHOW DATABASES: {error}" + ); + return Ok(database_infos(names)); + } + }; + for branch in branches { + let revision = format!("{database}/{}", branch.trim()); + if !branch.trim().is_empty() && seen.insert(revision.clone()) { + names.push(revision); + } + } + } + + Ok(database_infos(names)) +} + +async fn query_database_names(conn: &mut mysql_async::Conn) -> Result, String> { + conn.query(SHOW_DATABASES_SQL).await.map_err(|error| error.to_string()) +} + +fn database_infos(names: Vec) -> Vec { + names + .into_iter() + .map(|name| name.trim().to_string()) + .filter(|name| !name.is_empty()) + .map(|name| DatabaseInfo { name }) + .collect() +} + +fn is_branch_discovery_database(name: &str) -> bool { + let normalized = name.trim().to_ascii_lowercase(); + !normalized.is_empty() + && !normalized.contains('/') + && !matches!(normalized.as_str(), "information_schema" | "mysql" | "performance_schema" | "sys") +} + +fn list_branches_sql(database: &str) -> String { + format!("SELECT name FROM {}.dolt_branches ORDER BY name", quote_identifier(database)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_is_isolated_from_standard_mysql() { + assert!(is_profile(&DatabaseType::Mysql, Some("Dolt"))); + assert!(!is_profile(&DatabaseType::Mysql, None)); + assert!(!is_profile(&DatabaseType::Postgres, Some("dolt"))); + } + + #[test] + fn fallback_only_discovers_base_user_databases() { + assert!(is_branch_discovery_database("inventory")); + assert!(!is_branch_discovery_database("inventory/main")); + assert!(!is_branch_discovery_database("information_schema")); + assert!(!is_branch_discovery_database("mysql")); + } + + #[test] + fn fallback_quotes_database_identifier() { + assert_eq!( + list_branches_sql("inventory`archive"), + "SELECT name FROM `inventory``archive`.dolt_branches ORDER BY name" + ); + } +} diff --git a/crates/dbx-core/src/db/mod.rs b/crates/dbx-core/src/db/mod.rs index 1220ed650..321f47698 100644 --- a/crates/dbx-core/src/db/mod.rs +++ b/crates/dbx-core/src/db/mod.rs @@ -4,6 +4,7 @@ pub mod cloudberry; pub mod cloudflare_d1; pub use cloudflare_d1 as cloudflare_d1_driver; pub mod document_result; +pub mod dolt; pub mod doris; pub mod duckdb_sql; #[cfg(feature = "duckdb-sidecar")] diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index f0ea58e2b..9c2fc9b73 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -605,6 +605,11 @@ async fn list_databases_once(state: &AppState, connection_id: &str) -> Result + { + db::dolt::list_databases(p).await + } PoolKind::Mysql(p, _) if db_config.as_ref().is_some_and(is_doris_family_config) => { db::mysql::list_databases_show(p) .await diff --git a/docs/content/docs/databases.cn.mdx b/docs/content/docs/databases.cn.mdx index 48aa46c95..318477379 100644 --- a/docs/content/docs/databases.cn.mdx +++ b/docs/content/docs/databases.cn.mdx @@ -12,7 +12,7 @@ DBX 的新建连接窗口提供 70+ 数据库和服务 profile。Profile 决定 ### SQL 和兼容协议 - MySQL、PostgreSQL、SQLite、SQL Server、ClickHouse、Oracle、Redshift 等核心 SQL 数据库 -- MariaDB、TiDB、OceanBase、Doris、SelectDB、StarRocks、Manticore Search、GoldenDB 等 MySQL 协议或语法兼容 profile +- MariaDB、TiDB、OceanBase、Doris、SelectDB、StarRocks、Manticore Search、GoldenDB、Dolt 等 MySQL 协议或语法兼容 profile - openGauss、GaussDB、KingBase、HighGo、Vastbase、CockroachDB 等 PostgreSQL 兼容 profile - DM、KWDB、YashanDB、GBase、Teradata、Vertica、Exasol、Firebird、SAP HANA 等厂商数据库 - Snowflake、Trino、PrestoSQL、Hive、Spark、DB2、Informix、Databricks、BigQuery 等 Agent/JDBC 分析数据库 diff --git a/docs/content/docs/databases.mdx b/docs/content/docs/databases.mdx index 70230eaad..540050194 100644 --- a/docs/content/docs/databases.mdx +++ b/docs/content/docs/databases.mdx @@ -12,7 +12,7 @@ The new-connection picker provides more than 70 database and service profiles. A ### SQL and Compatible Protocols - Core SQL engines such as MySQL, PostgreSQL, SQLite, SQL Server, ClickHouse, Oracle, and Redshift -- MySQL-protocol or syntax profiles such as MariaDB, TiDB, OceanBase, Doris, SelectDB, StarRocks, Manticore Search, and GoldenDB +- MySQL-protocol or syntax profiles such as MariaDB, TiDB, OceanBase, Doris, SelectDB, StarRocks, Manticore Search, GoldenDB, and Dolt - PostgreSQL-compatible profiles such as openGauss, GaussDB, KingBase, HighGo, Vastbase, and CockroachDB - Vendor databases including DM, KWDB, YashanDB, GBase, Teradata, Vertica, Exasol, Firebird, and SAP HANA - Agent/JDBC analytics engines such as Snowflake, Trino, PrestoSQL, Hive, Spark, DB2, Informix, Databricks, and BigQuery