From d0a55b1b077af40cfbe290739cbdad433a719776 Mon Sep 17 00:00:00 2001 From: Guoyu Su Date: Thu, 30 Jul 2026 03:12:02 +0800 Subject: [PATCH] fix(sqlite): normalize database context routing --- apps/desktop/src/App.vue | 4 +- .../connection/ConnectionDialog.vue | 5 + .../src/components/editor/AiAssistant.vue | 6 +- .../src/lib/__tests__/ai/aiContext.spec.ts | 63 ++++++++++ .../database/defaultDatabase.spec.ts | 18 ++- .../database/sqliteNamespace.spec.ts | 29 +++++ .../__tests__/imports/datagripImport.spec.ts | 22 ++++ .../__tests__/imports/dbeaverImport.spec.ts | 26 ++++ .../__tests__/imports/navicatImport.spec.ts | 7 +- .../lib/__tests__/sql/newQueryContext.spec.ts | 34 ++++- apps/desktop/src/lib/ai/ai.ts | 64 +++++----- .../src/lib/database/defaultDatabase.ts | 7 +- .../src/lib/database/sqliteNamespace.ts | 36 ++++++ .../src/lib/history/historyRestoreTarget.ts | 4 +- .../desktop/src/lib/imports/datagripImport.ts | 4 +- apps/desktop/src/lib/imports/dbeaverImport.ts | 6 +- apps/desktop/src/lib/imports/navicatImport.ts | 2 +- apps/desktop/src/lib/sql/newQueryContext.ts | 11 +- crates/dbx-core/src/db/sqlite.rs | 117 +++++++++++++++--- crates/dbx-core/src/schema.rs | 43 ++++++- packages/app-tests/defaultDatabase.test.ts | 12 ++ packages/app-tests/newQueryContext.test.ts | 23 ++++ packages/app-tests/queryStore.test.ts | 25 ++++ 23 files changed, 499 insertions(+), 69 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/ai/aiContext.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/database/sqliteNamespace.spec.ts create mode 100644 apps/desktop/src/lib/database/sqliteNamespace.ts diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 83071973da..df4cd4364a 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -41,6 +41,7 @@ import * as api from "@/lib/backend/api"; import { connectionRedactedNameLabel } from "@/lib/connection/connectionPresentation"; import { quickConnectionOpenTarget } from "@/lib/connection/connectionOpenTarget"; import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase"; +import { normalizeSqliteNamespace } from "@/lib/database/sqliteNamespace"; import { findTreeNodeById, resolveNewQueryTarget, resolveNewQueryInitialSql } from "@/lib/sql/newQueryContext"; import { sqlObjectNavigationSourceKind, sqlObjectNavigationTableType, type SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation"; import { buildExecutableObjectSourceStatements, executeObjectSourceSave } from "@/lib/table/objectSourceEditor"; @@ -678,7 +679,8 @@ function analyzeHistoryWithAi(entry: HistoryEntry) { } openAiPanel(); - const database = entry.database || activeTab.value?.database || resolveDefaultDatabase(config, []); + const storedDatabase = entry.database || activeTab.value?.database || resolveDefaultDatabase(config, []); + const database = config.db_type === "sqlite" ? normalizeSqliteNamespace(storedDatabase, config) : storedDatabase; const title = t("history.aiAnalysisTab"); const tabId = queryStore.createTab(connectionId, database || "", title, "query"); queryStore.updateSql(tabId, entry.sql); diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 8bcc8299bd..a2c956f9d6 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -99,6 +99,7 @@ import { applyHiveKerberosSubmitConfig, hiveKerberosFormConfig, type HiveKerbero import { hasCloudflareD1Credentials, isCloudflareD1Connection, normalizeCloudflareD1Connection } from "@/lib/connection/cloudflareD1"; import { buildElasticsearchExternalConfig, elasticsearchConnectionModeFromConfig, elasticsearchConnectivityCheckPathFromConfig, elasticsearchKibanaBasePathFromConfig, type ElasticsearchConnectionMode } from "@/lib/connection/elasticsearchKibanaProxy"; import { gaussdbIdentifierQuoteStyle, setGaussdbIdentifierQuoteStyle, supportsGaussdbIdentifierQuoteStyle, type GaussdbIdentifierQuoteStyle } from "@/lib/database/jdbcDialect"; +import { normalizeStoredConnectionDatabase } from "@/lib/database/sqliteNamespace"; type DbOption = { value: string; label: string }; type DbCategoryKey = "sql" | "analytics" | "domestic" | "lightweight" | "document" | "graph_ai" | "timeseries" | "mq" | "registry_config"; @@ -1873,6 +1874,9 @@ function applyProfile(val: string, preserveConnectionFields = false) { if (profile.type === "sqlite" || profile.type === "duckdb" || profile.type === "access") { form.value.host = ""; } + if (profile.type === "sqlite") { + form.value.database = undefined; + } if (profile.type === "h2") { h2ConnectionMode.value = "file"; form.value.host = ""; @@ -3083,6 +3087,7 @@ function generateConnectionName(): string { function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionConfig { const config = { ...formValueForSubmit(), id } as LegacyConnectionConfig; config.database_info = undefined; + config.database = normalizeStoredConnectionDatabase(config.db_type, config.database); config.note = config.note?.trim() || undefined; if (selectedType.value === "oceanbase" && (config.driver_profile === "oceanbase" || config.driver_profile === "oceanbase-oracle")) { Object.assign(config, oceanbaseModeConnectionPatch(oceanbaseSubMode.value)); diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index 7b647c7a68..285232158c 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -76,6 +76,7 @@ import type { AiConfigItem, AiEffortCapability, AiEffortOption, AiEffortSelectio import type { ConnectionConfig, QueryTab, SavedSqlFile, TableInfo } from "@/types/database"; import { useDatabaseOptions } from "@/composables/useDatabaseOptions"; import { decodeSelectableDatabaseValue, encodeSelectableDatabaseValue, formatDatabaseLabel, resolveDefaultDatabase } from "@/lib/database/defaultDatabase"; +import { normalizeSqliteNamespace } from "@/lib/database/sqliteNamespace"; import { isSchemaAware } from "@/lib/database/databaseCapabilities"; import ExplainPlanViewer from "@/components/explain/ExplainPlanViewer.vue"; import { parseExplainResult, parseOracleExplainText, type ParsedExplainPlan } from "@/lib/diagram/explainPlan"; @@ -1251,8 +1252,9 @@ async function loadMentionCandidates(query: string) { ); tableCandidates = filterAiTableMentionCandidates(results.flat(), "", AI_TABLE_MENTION_CANDIDATE_LIMIT); } else { - const schema = props.tab.database || props.connection.database || "main"; - const tables = await listTables(props.tab.connectionId, props.tab.database, schema, tableFilter || undefined, AI_TABLE_MENTION_CANDIDATE_LIMIT); + const database = props.connection.db_type === "sqlite" ? normalizeSqliteNamespace(props.tab.database || props.connection.database, props.connection) : props.tab.database; + const schema = database || props.connection.database || "main"; + const tables = await listTables(props.tab.connectionId, database, schema, tableFilter || undefined, AI_TABLE_MENTION_CANDIDATE_LIMIT); tableCandidates = filterAiTableMentionCandidates( tables.map((table) => mentionCandidateFromTable(table)), tableFilter, diff --git a/apps/desktop/src/lib/__tests__/ai/aiContext.spec.ts b/apps/desktop/src/lib/__tests__/ai/aiContext.spec.ts new file mode 100644 index 0000000000..f3b3a73a4a --- /dev/null +++ b/apps/desktop/src/lib/__tests__/ai/aiContext.spec.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { buildAiContext } from "@/lib/ai/ai"; +import type { ConnectionConfig, QueryTab } from "@/types/database"; + +const apiMock = vi.hoisted(() => ({ + listTables: vi.fn(), + getColumns: vi.fn(), + listIndexes: vi.fn(), + listForeignKeys: vi.fn(), +})); + +vi.mock("@/lib/backend/api", () => apiMock); + +function sqliteConnection(database?: string): ConnectionConfig { + return { + id: "sqlite-1", + name: "SQLite", + db_type: "sqlite", + host: "/tmp/real.sqlite", + port: 0, + username: "", + password: "", + database, + }; +} + +function queryTab(database: string): QueryTab { + return { + id: "tab-1", + title: "Query", + connectionId: "sqlite-1", + database, + sql: "", + isExecuting: false, + isCancelling: false, + isExplaining: false, + mode: "query", + }; +} + +describe("SQLite AI context routing", () => { + beforeEach(() => { + vi.clearAllMocks(); + apiMock.listTables.mockResolvedValue([]); + apiMock.getColumns.mockResolvedValue([]); + apiMock.listIndexes.mockResolvedValue([]); + apiMock.listForeignKeys.mockResolvedValue([]); + }); + + it("uses main for a stale path-shaped SQLite database value", async () => { + const context = await buildAiContext(queryTab("/tmp/stale.sqlite"), sqliteConnection("/tmp/legacy.sqlite")); + + expect(context.database).toBe("main"); + expect(apiMock.listTables).toHaveBeenCalledWith("sqlite-1", "main", "main"); + }); + + it("preserves an attached SQLite database alias", async () => { + const context = await buildAiContext(queryTab("analytics"), sqliteConnection()); + + expect(context.database).toBe("analytics"); + expect(apiMock.listTables).toHaveBeenCalledWith("sqlite-1", "analytics", "analytics"); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/database/defaultDatabase.spec.ts b/apps/desktop/src/lib/__tests__/database/defaultDatabase.spec.ts index de584c5828..1b388af736 100644 --- a/apps/desktop/src/lib/__tests__/database/defaultDatabase.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/defaultDatabase.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { decodeSelectableDatabaseValue, EMPTY_DATABASE_SELECT_VALUE, encodeSelectableDatabaseValue, TREE_SCHEMA_DEFAULT_DATABASE_SELECT_VALUE } from "@/lib/database/defaultDatabase"; +import { decodeSelectableDatabaseValue, EMPTY_DATABASE_SELECT_VALUE, encodeSelectableDatabaseValue, isDefaultDatabase, resolveDefaultDatabase, TREE_SCHEMA_DEFAULT_DATABASE_SELECT_VALUE } from "@/lib/database/defaultDatabase"; describe("defaultDatabase selectable values", () => { it("encodes empty tree-schema databases with the default database sentinel", () => { @@ -16,4 +16,20 @@ describe("defaultDatabase selectable values", () => { expect(encodeSelectableDatabaseValue("access", "653128SXB.mdb")).toBe("653128SXB.mdb"); expect(decodeSelectableDatabaseValue("access", "653128SXB.mdb")).toBe("653128SXB.mdb"); }); + + it("uses main for SQLite connections with stale file paths", () => { + expect(resolveDefaultDatabase({ db_type: "sqlite", database: "/tmp/stale.sqlite" }, [])).toBe("main"); + expect(resolveDefaultDatabase({ db_type: "sqlite", database: "C:\\data\\stale.db" }, [])).toBe("main"); + expect(resolveDefaultDatabase({ db_type: "sqlite", database: "file:/tmp/stale.sqlite" }, [])).toBe("main"); + expect(resolveDefaultDatabase({ db_type: "sqlite", database: ":memory:" }, [])).toBe("main"); + expect(isDefaultDatabase({ db_type: "sqlite", database: "/tmp/stale.sqlite" }, "main")).toBe(true); + expect(resolveDefaultDatabase({ db_type: "sqlite", host: "relative.sqlite", database: undefined }, ["relative.sqlite"])).toBe("main"); + expect(resolveDefaultDatabase({ db_type: "sqlite", host: "legacyfile", database: undefined }, ["legacyfile"])).toBe("main"); + }); + + it("preserves a SQLite attached database alias", () => { + expect(resolveDefaultDatabase({ db_type: "sqlite", database: "analytics" }, ["main", "analytics"])).toBe("analytics"); + expect(isDefaultDatabase({ db_type: "sqlite", database: "analytics" }, "analytics")).toBe(true); + expect(resolveDefaultDatabase({ db_type: "sqlite", host: "primary.db", database: undefined }, ["analytics.db"])).toBe("analytics.db"); + }); }); diff --git a/apps/desktop/src/lib/__tests__/database/sqliteNamespace.spec.ts b/apps/desktop/src/lib/__tests__/database/sqliteNamespace.spec.ts new file mode 100644 index 0000000000..0db1eb2036 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/database/sqliteNamespace.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { isSqliteFileNamespace, normalizeSqliteNamespace, normalizeStoredConnectionDatabase } from "@/lib/database/sqliteNamespace"; + +describe("SQLite namespace normalization", () => { + it("recognizes physical SQLite locations across platforms", () => { + for (const value of ["/tmp/app.sqlite", "C:\\data\\app.db", "./relative.db3", "file:/tmp/app.sqlite", "sqlite:/tmp/app.sqlite", ":memory:"]) { + expect(isSqliteFileNamespace(value)).toBe(true); + expect(normalizeSqliteNamespace(value)).toBe("main"); + } + }); + + it("preserves logical attached database aliases", () => { + expect(isSqliteFileNamespace("analytics")).toBe(false); + expect(normalizeSqliteNamespace("analytics")).toBe("analytics"); + expect(isSqliteFileNamespace("analytics.db")).toBe(false); + expect(normalizeSqliteNamespace("analytics.db", { database: "analytics.db" })).toBe("analytics.db"); + expect(normalizeSqliteNamespace(undefined)).toBe("main"); + }); + + it("recognizes ambiguous file names only from the connection context", () => { + expect(normalizeSqliteNamespace("primary.db", { host: "primary.db" })).toBe("main"); + expect(normalizeSqliteNamespace("legacyfile", { host: "legacyfile" })).toBe("main"); + }); + + it("removes the hidden database field only for stored SQLite connections", () => { + expect(normalizeStoredConnectionDatabase("sqlite", "/tmp/stale.sqlite")).toBeUndefined(); + expect(normalizeStoredConnectionDatabase("postgres", "app")).toBe("app"); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/imports/datagripImport.spec.ts b/apps/desktop/src/lib/__tests__/imports/datagripImport.spec.ts index 9be188be12..8d5633f5ae 100644 --- a/apps/desktop/src/lib/__tests__/imports/datagripImport.spec.ts +++ b/apps/desktop/src/lib/__tests__/imports/datagripImport.spec.ts @@ -15,6 +15,28 @@ function layoutLabels(layout: SidebarLayout, connectionNames: Map { + it("imports a SQLite file path without treating it as a schema", () => { + const [connection] = parseDataGripConnections( + payload(` + + + + sqlite.xerial + jdbc:sqlite:/tmp/app.sqlite + + + + `), + ); + + expect(connection).toMatchObject({ + name: "Local SQLite", + db_type: "sqlite", + host: "/tmp/app.sqlite", + }); + expect(connection?.database).toBeUndefined(); + }); + it("recognizes Kingbase custom JDBC drivers", () => { const connections = parseDataGripConnections( payload(` diff --git a/apps/desktop/src/lib/__tests__/imports/dbeaverImport.spec.ts b/apps/desktop/src/lib/__tests__/imports/dbeaverImport.spec.ts index ad35f7a5a0..256783dac2 100644 --- a/apps/desktop/src/lib/__tests__/imports/dbeaverImport.spec.ts +++ b/apps/desktop/src/lib/__tests__/imports/dbeaverImport.spec.ts @@ -24,6 +24,32 @@ function layoutLabels(layout: SidebarLayout, connectionNames: Map { + it("imports a SQLite file path without treating it as a schema", async () => { + const [connection] = await parseDbeaverConnections( + payload({ + connections: { + sqlite: { + id: "sqlite", + name: "Local SQLite", + provider: "sqlite", + driver: "sqlite_jdbc", + configuration: { + url: "jdbc:sqlite:/tmp/app.sqlite", + database: "/tmp/app.sqlite", + }, + }, + }, + }), + ); + + expect(connection).toMatchObject({ + name: "Local SQLite", + db_type: "sqlite", + host: "/tmp/app.sqlite", + }); + expect(connection?.database).toBeUndefined(); + }); + it("keeps parseDbeaverConnections compatible when no folders exist", async () => { const connections = await parseDbeaverConnections(payload({ connections: { root: mysqlConnection("root", "Root") } })); diff --git a/apps/desktop/src/lib/__tests__/imports/navicatImport.spec.ts b/apps/desktop/src/lib/__tests__/imports/navicatImport.spec.ts index b1c1d7397b..e2729ff3b5 100644 --- a/apps/desktop/src/lib/__tests__/imports/navicatImport.spec.ts +++ b/apps/desktop/src/lib/__tests__/imports/navicatImport.spec.ts @@ -76,14 +76,14 @@ if (!globalThis.DOMParser) { } describe("parseNavicatConnections", () => { - it("imports SQLite DatabaseFile as both host and database", async () => { + it("imports SQLite DatabaseFile as the host without treating it as a schema", async () => { const [connection] = await parseNavicatConnections(` `); expect(connection?.db_type).toBe("sqlite"); expect(connection?.host).toBe("C:\\Users\\Yang\\demo.db"); - expect(connection?.database).toBe("C:\\Users\\Yang\\demo.db"); + expect(connection?.database).toBeUndefined(); expect(connection?.port).toBe(0); }); @@ -94,6 +94,7 @@ describe("parseNavicatConnections", () => { expect(connection?.db_type).toBe("sqlite"); expect(connection?.host).toBe("/home/yang/demo.sqlite"); + expect(connection?.database).toBeUndefined(); }); it("uses SQLite Database field as the file path", async () => { @@ -103,7 +104,7 @@ describe("parseNavicatConnections", () => { expect(connection?.db_type).toBe("sqlite"); expect(connection?.host).toBe("/tmp/app.data"); - expect(connection?.database).toBe("/tmp/app.data"); + expect(connection?.database).toBeUndefined(); }); it("keeps non-SQLite host and database mapping unchanged", async () => { diff --git a/apps/desktop/src/lib/__tests__/sql/newQueryContext.spec.ts b/apps/desktop/src/lib/__tests__/sql/newQueryContext.spec.ts index c47449b411..52f48c4845 100644 --- a/apps/desktop/src/lib/__tests__/sql/newQueryContext.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/newQueryContext.spec.ts @@ -28,7 +28,7 @@ describe("resolveNewQueryTarget", () => { database: "bi", objectBrowser: { catalog: "paimon_catalog" }, }, - connections: [{ id: "conn-1", database: "" }], + connections: [{ id: "conn-1", host: "localhost", database: "", db_type: "starrocks" }], preferredSource: "tab", }), ).toEqual({ @@ -53,10 +53,40 @@ describe("resolveNewQueryTarget", () => { primaryKeys: [], }, }, - connections: [{ id: "conn-1", database: "" }], + connections: [{ id: "conn-1", host: "localhost", database: "", db_type: "starrocks" }], })?.catalog, ).toBe("paimon_catalog"); }); + + it("repairs a stale SQLite file path inherited from an active tab", () => { + expect( + resolveNewQueryTarget({ + activeTab: { + connectionId: "conn-sqlite", + database: "/tmp/stale.sqlite", + }, + connections: [{ id: "conn-sqlite", host: "/tmp/stale.sqlite", database: "/tmp/stale.sqlite", db_type: "sqlite" }], + }), + ).toEqual({ + connectionId: "conn-sqlite", + database: "main", + schema: undefined, + catalog: undefined, + shouldRefreshDefaultDatabase: false, + }); + }); + + it("preserves an attached SQLite database alias inherited from an active tab", () => { + expect( + resolveNewQueryTarget({ + activeTab: { + connectionId: "conn-sqlite", + database: "analytics.db", + }, + connections: [{ id: "conn-sqlite", host: "primary.db", database: undefined, db_type: "sqlite" }], + })?.database, + ).toBe("analytics.db"); + }); }); describe("resolveNewQueryTable", () => { diff --git a/apps/desktop/src/lib/ai/ai.ts b/apps/desktop/src/lib/ai/ai.ts index cca4e40900..2130edb59f 100644 --- a/apps/desktop/src/lib/ai/ai.ts +++ b/apps/desktop/src/lib/ai/ai.ts @@ -8,6 +8,7 @@ import { aiTableMentionKey, type AiTableMention } from "@/lib/ai/aiTableMentions import { aiSkillForAction } from "@/lib/ai/aiSkills"; import { isSchemaAware } from "@/lib/database/databaseCapabilities"; import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; +import { normalizeSqliteNamespace } from "@/lib/database/sqliteNamespace"; import type { AgentEvent } from "@/lib/backend/tauri"; @@ -434,6 +435,7 @@ export async function buildAiContext(tab: QueryTab, connection: ConnectionConfig const maxIndexesPerTable = options.maxIndexesPerTable ?? 10; const maxFksPerTable = options.maxFksPerTable ?? 10; const databaseType = aiDatabaseTypeForConnection(connection); + const database = aiDatabaseNamespace(tab, connection); const tables: AiSchemaTable[] = []; const tableKeys = new Set(); let truncated = false; @@ -444,8 +446,8 @@ export async function buildAiContext(tab: QueryTab, connection: ConnectionConfig schemaScope = "focused_table"; const s = tab.tableMeta.schema ?? ""; const tName = tab.tableMeta.tableName; - const [indexes, foreignKeys] = await Promise.all([api.listIndexes(tab.connectionId, tab.database, s, tName).catch(() => [] as IndexInfo[]), api.listForeignKeys(tab.connectionId, tab.database, s, tName).catch(() => [] as ForeignKeyInfo[])]); - const tableComment = await loadTableComment(tab.connectionId, tab.database, s, tName).catch(() => undefined); + const [indexes, foreignKeys] = await Promise.all([api.listIndexes(tab.connectionId, database, s, tName).catch(() => [] as IndexInfo[]), api.listForeignKeys(tab.connectionId, database, s, tName).catch(() => [] as ForeignKeyInfo[])]); + const tableComment = await loadTableComment(tab.connectionId, database, s, tName).catch(() => undefined); tables.push({ schema: tab.tableMeta.schema, name: tName, @@ -471,7 +473,7 @@ export async function buildAiContext(tab: QueryTab, connection: ConnectionConfig // Vector databases: load collections instead of SQL tables if (isVectorDbType(databaseType)) { try { - const collections = await api.vectorListCollections(tab.connectionId, tab.database); + const collections = await api.vectorListCollections(tab.connectionId, database); // Find the currently opened collection (tab.sql is UUID for ChromaDB, name for others) const currentCollection = collections.find((c) => c.id === tab.sql || c.name === tab.sql); @@ -508,26 +510,24 @@ export async function buildAiContext(tab: QueryTab, connection: ConnectionConfig try { const schemas = await loadCandidateSchemas(tab, connection); for (const schema of schemas) { - const tableList = await api.listTables(tab.connectionId, tab.database, schema); + const tableList = await api.listTables(tab.connectionId, database, schema); const candidates = tableList.slice(0, maxTables - tables.length); if (candidates.length < tableList.length) truncated = true; const metaResults = await Promise.all( candidates.map((table) => - Promise.all([ - api.getColumns(tab.connectionId, tab.database, schema, table.name), - api.listIndexes(tab.connectionId, tab.database, schema, table.name).catch(() => [] as IndexInfo[]), - api.listForeignKeys(tab.connectionId, tab.database, schema, table.name).catch(() => [] as ForeignKeyInfo[]), - ]).then(([columns, indexes, foreignKeys]) => ({ - schema: schema === tab.database && !isSchemaAware(databaseType) ? undefined : schema, - name: table.name, - tableType: table.table_type, - comment: table.comment, - columns: columns.slice(0, maxColumnsPerTable), - indexes: indexes.slice(0, maxIndexesPerTable), - foreignKeys: foreignKeys.slice(0, maxFksPerTable), - _truncatedCols: columns.length > maxColumnsPerTable, - })), + Promise.all([api.getColumns(tab.connectionId, database, schema, table.name), api.listIndexes(tab.connectionId, database, schema, table.name).catch(() => [] as IndexInfo[]), api.listForeignKeys(tab.connectionId, database, schema, table.name).catch(() => [] as ForeignKeyInfo[])]).then( + ([columns, indexes, foreignKeys]) => ({ + schema: schema === database && !isSchemaAware(databaseType) ? undefined : schema, + name: table.name, + tableType: table.table_type, + comment: table.comment, + columns: columns.slice(0, maxColumnsPerTable), + indexes: indexes.slice(0, maxIndexesPerTable), + foreignKeys: foreignKeys.slice(0, maxFksPerTable), + _truncatedCols: columns.length > maxColumnsPerTable, + }), + ), ), ); @@ -550,7 +550,7 @@ export async function buildAiContext(tab: QueryTab, connection: ConnectionConfig connectionId: tab.connectionId, connectionName: connection.name, databaseType, - database: tab.database, + database, currentSql: currentCollectionName ?? tab.sql, lastError: extractLastError(tab.result), lastResultPreview: formatResultPreview(tab.result), @@ -563,15 +563,16 @@ export async function buildAiContext(tab: QueryTab, connection: ConnectionConfig async function loadMentionedTableContext(tab: QueryTab, connection: ConnectionConfig, mention: AiTableMention, maxColumnsPerTable: number, maxIndexesPerTable: number, maxFksPerTable: number): Promise { const databaseType = aiDatabaseTypeForConnection(connection); + const database = aiDatabaseNamespace(tab, connection); const schema = await resolveMentionedTableSchema(tab, connection, mention); const [columns, indexes, foreignKeys, tableComment] = await Promise.all([ - api.getColumns(tab.connectionId, tab.database, schema, mention.table), - api.listIndexes(tab.connectionId, tab.database, schema, mention.table).catch(() => [] as IndexInfo[]), - api.listForeignKeys(tab.connectionId, tab.database, schema, mention.table).catch(() => [] as ForeignKeyInfo[]), - loadTableComment(tab.connectionId, tab.database, schema, mention.table).catch(() => undefined), + api.getColumns(tab.connectionId, database, schema, mention.table), + api.listIndexes(tab.connectionId, database, schema, mention.table).catch(() => [] as IndexInfo[]), + api.listForeignKeys(tab.connectionId, database, schema, mention.table).catch(() => [] as ForeignKeyInfo[]), + loadTableComment(tab.connectionId, database, schema, mention.table).catch(() => undefined), ]); return { - schema: schema === tab.database && !isSchemaAware(databaseType) ? undefined : schema, + schema: schema === database && !isSchemaAware(databaseType) ? undefined : schema, name: mention.table, tableType: "TABLE", comment: tableComment, @@ -592,27 +593,34 @@ async function resolveMentionedTableSchema(tab: QueryTab, connection: Connection return tab.tableMeta.schema; } if (isSchemaAware(aiDatabaseTypeForConnection(connection))) { + const database = aiDatabaseNamespace(tab, connection); const schemas = await loadCandidateSchemas(tab, connection); for (const schema of schemas) { - const tables = await api.listTables(tab.connectionId, tab.database, schema, mention.table, 10).catch(() => []); + const tables = await api.listTables(tab.connectionId, database, schema, mention.table, 10).catch(() => []); if (tables.some((table) => table.name.toLowerCase() === mention.table.toLowerCase())) return schema; } } - return tab.database || connection.database || "main"; + return aiDatabaseNamespace(tab, connection); } async function loadCandidateSchemas(tab: QueryTab, connection: ConnectionConfig): Promise { + const database = aiDatabaseNamespace(tab, connection); if (isSchemaAware(aiDatabaseTypeForConnection(connection))) { - const schemas = await api.listSchemas(tab.connectionId, tab.database); + const schemas = await api.listSchemas(tab.connectionId, database); return prioritizeSchemas(schemas); } - return [tab.database || connection.database || "main"]; + return [database]; } function aiDatabaseTypeForConnection(connection: ConnectionConfig): DatabaseType { return effectiveDatabaseTypeForConnection(connection) ?? connection.db_type; } +function aiDatabaseNamespace(tab: QueryTab, connection: ConnectionConfig): string { + const database = tab.database || connection.database || "main"; + return connection.db_type === "sqlite" ? normalizeSqliteNamespace(database, connection) : database; +} + function prioritizeSchemas(schemas: string[]): string[] { const preferred = ["public", "dbo", "main"]; return [...schemas].sort((a, b) => { diff --git a/apps/desktop/src/lib/database/defaultDatabase.ts b/apps/desktop/src/lib/database/defaultDatabase.ts index 1a923eeecd..f78e6fa500 100644 --- a/apps/desktop/src/lib/database/defaultDatabase.ts +++ b/apps/desktop/src/lib/database/defaultDatabase.ts @@ -1,11 +1,13 @@ import type { ConnectionConfig, DatabaseType } from "@/types/database"; import { usesTreeSchemaMode } from "@/lib/database/databaseCapabilities"; +import { normalizeSqliteNamespace } from "@/lib/database/sqliteNamespace"; export const TREE_SCHEMA_DEFAULT_DATABASE_SELECT_VALUE = "__dbx_tree_schema_default_database__"; export const EMPTY_DATABASE_SELECT_VALUE = "__dbx_empty_database__"; -export function resolveDefaultDatabase(connection: Pick & Partial>, options: string[]): string { +export function resolveDefaultDatabase(connection: Pick & Partial>, options: string[]): string { if (connection.db_type === "cloudflare-d1") return "main"; + if (connection.db_type === "sqlite") return normalizeSqliteNamespace(connection.database || options[0], connection); return connection.database || options[0] || ""; } @@ -30,7 +32,8 @@ export function formatDatabaseLabel(connection: Pick & Partial>) | undefined, database: string): boolean { +export function isDefaultDatabase(connection: (Pick & Partial>) | undefined, database: string): boolean { if (connection?.db_type === "cloudflare-d1") return database === "main"; + if (connection?.db_type === "sqlite") return database === normalizeSqliteNamespace(connection.database, connection); return !!connection?.database && !!database && connection.database === database; } diff --git a/apps/desktop/src/lib/database/sqliteNamespace.ts b/apps/desktop/src/lib/database/sqliteNamespace.ts new file mode 100644 index 0000000000..5361a283d0 --- /dev/null +++ b/apps/desktop/src/lib/database/sqliteNamespace.ts @@ -0,0 +1,36 @@ +import type { ConnectionConfig } from "@/types/database"; + +const WINDOWS_DRIVE_PATH_RE = /^[a-z]:[\\/]/i; + +type SqliteNamespaceConnection = Partial>; + +export function isSqliteFileNamespace(value: string | undefined, connection?: SqliteNamespaceConnection): boolean { + const normalized = value?.trim() || ""; + if (!normalized) return false; + + const lower = normalized.toLowerCase(); + const connectionHost = connection?.host?.trim() || ""; + return ( + lower === ":memory:" || + lower.startsWith("file:") || + lower.startsWith("sqlite:") || + WINDOWS_DRIVE_PATH_RE.test(normalized) || + normalized.startsWith("/") || + normalized.startsWith("\\") || + normalized.startsWith("~/") || + normalized.startsWith("./") || + normalized.startsWith("../") || + normalized.includes("/") || + normalized.includes("\\") || + normalized === connectionHost + ); +} + +export function normalizeSqliteNamespace(value: string | undefined, connection?: SqliteNamespaceConnection): string { + const normalized = value?.trim() || ""; + return !normalized || isSqliteFileNamespace(normalized, connection) ? "main" : normalized; +} + +export function normalizeStoredConnectionDatabase(dbType: ConnectionConfig["db_type"], database: string | undefined): string | undefined { + return dbType === "sqlite" ? undefined : database; +} diff --git a/apps/desktop/src/lib/history/historyRestoreTarget.ts b/apps/desktop/src/lib/history/historyRestoreTarget.ts index 36bfaadcf4..e7d7ab8a4d 100644 --- a/apps/desktop/src/lib/history/historyRestoreTarget.ts +++ b/apps/desktop/src/lib/history/historyRestoreTarget.ts @@ -1,5 +1,6 @@ import type { HistoryEntry } from "@/lib/backend/api"; import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase"; +import { normalizeSqliteNamespace } from "@/lib/database/sqliteNamespace"; import type { ConnectionConfig, QueryTab } from "@/types/database"; export interface HistorySqlRestoreTarget { @@ -13,7 +14,8 @@ export function resolveHistorySqlRestoreTarget(options: { entry: HistoryEntry; a const connectionId = entry.connection_id || activeTab?.connectionId || firstConnectionId; if (!connectionId) return null; const config = getConfig(connectionId); - const database = entry.database || activeTab?.database || (config ? resolveDefaultDatabase(config, []) : ""); + const storedDatabase = entry.database || activeTab?.database || (config ? resolveDefaultDatabase(config, []) : ""); + const database = config?.db_type === "sqlite" ? normalizeSqliteNamespace(storedDatabase, config) : storedDatabase; const schema = activeTab?.connectionId === connectionId && activeTab.database === database ? activeTab.schema : undefined; return { connectionId, database: database || "", schema }; } diff --git a/apps/desktop/src/lib/imports/datagripImport.ts b/apps/desktop/src/lib/imports/datagripImport.ts index ec1a20ec5d..993d9cadf5 100644 --- a/apps/desktop/src/lib/imports/datagripImport.ts +++ b/apps/desktop/src/lib/imports/datagripImport.ts @@ -177,7 +177,9 @@ function parseJdbcUrl(jdbcUrl: string): { const fileMatch = url.match(/^(sqlite|duckdb):(.+)$/i); if (fileMatch) { result.host = expandPathMacros(fileMatch[2].split("?")[0]); - result.database = result.host; + if (fileMatch[1].toLowerCase() !== "sqlite") { + result.database = result.host; + } return result; } diff --git a/apps/desktop/src/lib/imports/dbeaverImport.ts b/apps/desktop/src/lib/imports/dbeaverImport.ts index 8cf4f4bd72..87763e09e0 100644 --- a/apps/desktop/src/lib/imports/dbeaverImport.ts +++ b/apps/desktop/src/lib/imports/dbeaverImport.ts @@ -171,7 +171,6 @@ function parseJdbcUrl(url: string, profile: ConnectionProfile) { const sqliteMatch = withoutJdbc.match(/^sqlite:(.+)$/i); if (sqliteMatch) { result.host = sqliteMatch[1]; - result.database = sqliteMatch[1]; return result; } @@ -221,8 +220,9 @@ function buildConnection(entry: DbeaverConnectionEntry, credentials: ReturnType< const config = entry.configuration || {}; const url = getString(config.url); const parsedUrl = parseJdbcUrl(url, profile); - const host = getString(config.host || config["host-name"] || parsedUrl.host || (profile.dbType === "sqlite" ? "" : "127.0.0.1")); - const database = getString(config.database || config["database-name"] || config.schema || parsedUrl.database); + const configuredDatabase = getString(config.database || config["database-name"] || config.schema || parsedUrl.database); + const host = getString(config.host || config["host-name"] || parsedUrl.host || (profile.dbType === "sqlite" ? configuredDatabase : "127.0.0.1")); + const database = profile.dbType === "sqlite" ? "" : configuredDatabase; const name = getString(entry.name || database || host || profile.label); if (!entry.id || !name) return null; diff --git a/apps/desktop/src/lib/imports/navicatImport.ts b/apps/desktop/src/lib/imports/navicatImport.ts index f17d2fdc7a..b2b50c44c3 100644 --- a/apps/desktop/src/lib/imports/navicatImport.ts +++ b/apps/desktop/src/lib/imports/navicatImport.ts @@ -300,7 +300,7 @@ async function parseConnection(node: ParsedNode): Promise; selectedTreeNode?: Pick | null; activeConnectionId?: string | null; - connections: Pick[]; + connections: Pick[]; preferredSource?: NewQueryContextSource; } @@ -49,11 +50,15 @@ export function resolveNewQueryTarget(input: ResolveNewQueryTargetInput): NewQue : null; } -function targetFromContext(context: Pick | Pick | undefined, connections: Pick[]): NewQueryTarget | null { +function targetFromContext( + context: Pick | Pick | undefined, + connections: Pick[], +): NewQueryTarget | null { if (!context?.connectionId) return null; const connection = connections.find((item) => item.id === context.connectionId); if (!connection) return null; - const database = context.database || resolveDefaultDatabase(connection, []); + const contextDatabase = context.database || resolveDefaultDatabase(connection, []); + const database = connection.db_type === "sqlite" ? normalizeSqliteNamespace(contextDatabase, connection) : contextDatabase; const objectBrowser = "objectBrowser" in context ? context.objectBrowser : undefined; const tableMeta = "tableMeta" in context ? context.tableMeta : undefined; return { diff --git a/crates/dbx-core/src/db/sqlite.rs b/crates/dbx-core/src/db/sqlite.rs index 16b5e277a5..bc354a4494 100644 --- a/crates/dbx-core/src/db/sqlite.rs +++ b/crates/dbx-core/src/db/sqlite.rs @@ -535,6 +535,27 @@ pub fn is_memory_database_path(path: &str) -> bool { mod tests { use super::*; + #[test] + fn sqlite_schema_name_repairs_file_paths_and_preserves_attached_aliases() { + for value in [ + "", + ":memory:", + "/tmp/app.sqlite", + "./app.db", + "../app.db3", + "~/app.sqlite3", + "C:\\data\\app.db", + "file:/tmp/app.sqlite", + "sqlite:/tmp/app.sqlite", + "relative.sqlite?mode=ro", + ] { + assert_eq!(sqlite_schema_name(value), "main", "{value}"); + } + + assert_eq!(sqlite_schema_name("main"), "main"); + assert_eq!(sqlite_schema_name("analytics"), "analytics"); + } + #[test] fn persistent_attachments_require_a_file_backed_plaintext_main_database() { assert!(validate_persistent_attachments("/tmp/main.sqlite", "", false).is_ok()); @@ -1144,14 +1165,14 @@ mod tests { } let pool = connect_path(":memory:").await.expect("connect primary database"); - attach_database(&pool, "analytics", path.to_str().unwrap()).expect("attach database"); + attach_database(&pool, "analytics.db", path.to_str().unwrap()).expect("attach database"); execute_query(&pool, "CREATE TABLE child(source TEXT NOT NULL); INSERT INTO child(source) VALUES('main');") .await .expect("create same-named main table"); let qualified_child = crate::sql_dialect::qualified_table_name( Some(crate::models::connection::DatabaseType::Sqlite), - Some("analytics"), + Some("analytics.db"), "child", ); pool.with_connection(|conn| { @@ -1166,7 +1187,7 @@ mod tests { .query_row("SELECT source FROM main.child", [], |row| row.get(0)) .map_err(|error| error.to_string())?; let updated_attached_source: String = conn - .query_row("SELECT source FROM analytics.child", [], |row| row.get(0)) + .query_row("SELECT source FROM \"analytics.db\".child", [], |row| row.get(0)) .map_err(|error| error.to_string())?; assert_eq!(main_source, "main"); assert_eq!(updated_attached_source, "updated"); @@ -1175,29 +1196,39 @@ mod tests { .expect("query attached table by qualified name"); let databases = list_databases(&pool).await.expect("list databases"); - assert_eq!(databases.into_iter().map(|database| database.name).collect::>(), vec!["main", "analytics"]); - assert!(list_tables(&pool, "analytics") + assert_eq!( + databases.into_iter().map(|database| database.name).collect::>(), + vec!["main", "analytics.db"] + ); + assert!(list_tables(&pool, "analytics.db") .await .expect("list attached tables") .iter() .any(|table| table.name == "child")); - let columns = get_columns(&pool, "analytics", "child").await.expect("list attached columns"); + let columns = get_columns(&pool, "analytics.db", "child").await.expect("list attached columns"); assert_eq!( columns.iter().find(|column| column.name == "id").and_then(|column| column.extra.as_deref()), Some("autoincrement") ); - assert!(list_indexes(&pool, "analytics", "child") + assert!(list_indexes(&pool, "analytics.db", "child") .await .expect("list attached indexes") .iter() .any(|index| index.name == "child_parent_idx")); - assert_eq!(list_foreign_keys(&pool, "analytics", "child").await.expect("list attached foreign keys").len(), 1); - assert!(list_triggers(&pool, "analytics", "child") + assert_eq!( + list_foreign_keys(&pool, "analytics.db", "child").await.expect("list attached foreign keys").len(), + 1 + ); + assert!(list_triggers(&pool, "analytics.db", "child") .await .expect("list attached triggers") .iter() .any(|trigger| trigger.name == "child_touch")); + assert!(crate::schema::sqlite_ddl(&pool, "analytics.db", "child") + .await + .expect("read attached table DDL") + .contains("CREATE TABLE child")); drop(pool); let _ = std::fs::remove_file(path); @@ -1225,9 +1256,10 @@ pub async fn list_databases(pool: &SqliteHandle) -> Result, St pub async fn list_tables(pool: &SqliteHandle, schema: &str) -> Result, String> { let pool = pool.clone(); - let schema = sqlite_schema_name(schema).to_string(); + let requested_schema = schema.to_string(); tokio::task::spawn_blocking(move || { pool.with_connection(|conn| { + let schema = sqlite_schema_name_for_connection(conn, &requested_schema)?; let sql = format!( "SELECT name, type FROM {}.sqlite_master \ WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name", @@ -1255,11 +1287,12 @@ pub async fn list_tables(pool: &SqliteHandle, schema: &str) -> Result Result, String> { let pool = pool.clone(); - let schema = sqlite_schema_name(schema).to_string(); + let requested_schema = schema.to_string(); let table = table.to_string(); tokio::task::spawn_blocking(move || { - let sql = format!("PRAGMA {}.table_info({})", sqlite_quote_ident(&schema), sqlite_quote_string(&table)); pool.with_connection(|conn| { + let schema = sqlite_schema_name_for_connection(conn, &requested_schema)?; + let sql = format!("PRAGMA {}.table_info({})", sqlite_quote_ident(&schema), sqlite_quote_string(&table)); let autoincrement_columns = sqlite_autoincrement_pk_columns(conn, &schema, &table).unwrap_or_default(); let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; let rows = stmt @@ -1531,15 +1564,61 @@ pub(crate) fn sqlite_quote_schema_ident(value: &str) -> String { sqlite_quote_ident(sqlite_schema_name(value)) } -fn sqlite_schema_name(value: &str) -> &str { +pub(crate) fn sqlite_quote_schema_ident_for_connection(conn: &Connection, value: &str) -> Result { + Ok(sqlite_quote_ident(&sqlite_schema_name_for_connection(conn, value)?)) +} + +fn sqlite_schema_name_for_connection(conn: &Connection, value: &str) -> Result { let value = value.trim(); if value.is_empty() { + return Ok("main".to_string()); + } + + let mut stmt = conn.prepare("PRAGMA database_list").map_err(|e| e.to_string())?; + let names = stmt + .query_map([], |row| row.get::<_, String>("name")) + .map_err(|e| e.to_string())? + .collect::, _>>() + .map_err(|e| e.to_string())?; + if names.iter().any(|name| name.eq_ignore_ascii_case(value)) { + Ok(value.to_string()) + } else { + Ok(sqlite_schema_name(value).to_string()) + } +} + +fn sqlite_schema_name(value: &str) -> &str { + let value = value.trim(); + if value.is_empty() || is_sqlite_file_namespace(value) { "main" } else { value } } +fn is_sqlite_file_namespace(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + let path = lower.split(['?', '#']).next().unwrap_or(&lower); + lower == ":memory:" + || lower.starts_with("file:") + || lower.starts_with("sqlite:") + || value.starts_with('/') + || value.starts_with('\\') + || value.starts_with("~/") + || value.starts_with("./") + || value.starts_with("../") + || value.contains('/') + || value.contains('\\') + || path.ends_with(".db") + || path.ends_with(".db3") + || path.ends_with(".sqlite") + || path.ends_with(".sqlite3") + || (value.len() >= 3 + && value.as_bytes()[0].is_ascii_alphabetic() + && value.as_bytes()[1] == b':' + && matches!(value.as_bytes()[2], b'/' | b'\\')) +} + fn sqlite_quote_string(value: &str) -> String { format!("'{}'", value.replace('\'', "''")) } @@ -2104,10 +2183,11 @@ fn is_sql_keyword(value: &str) -> bool { pub async fn list_indexes(pool: &SqliteHandle, schema: &str, table: &str) -> Result, String> { let pool = pool.clone(); - let schema = sqlite_schema_name(schema).to_string(); + let requested_schema = schema.to_string(); let table = table.to_string(); tokio::task::spawn_blocking(move || { pool.with_connection(|conn| { + let schema = sqlite_schema_name_for_connection(conn, &requested_schema)?; let mut stmt = conn .prepare(&format!("PRAGMA {}.index_list({})", sqlite_quote_ident(&schema), sqlite_quote_string(&table))) .map_err(|e| e.to_string())?; @@ -2161,11 +2241,13 @@ pub async fn list_indexes(pool: &SqliteHandle, schema: &str, table: &str) -> Res pub async fn list_foreign_keys(pool: &SqliteHandle, schema: &str, table: &str) -> Result, String> { let pool = pool.clone(); - let schema = sqlite_schema_name(schema).to_string(); + let requested_schema = schema.to_string(); let table = table.to_string(); tokio::task::spawn_blocking(move || { - let sql = format!("PRAGMA {}.foreign_key_list({})", sqlite_quote_ident(&schema), sqlite_quote_string(&table)); pool.with_connection(|conn| { + let schema = sqlite_schema_name_for_connection(conn, &requested_schema)?; + let sql = + format!("PRAGMA {}.foreign_key_list({})", sqlite_quote_ident(&schema), sqlite_quote_string(&table)); let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; let rows = stmt .query_map([], |row| { @@ -2189,10 +2271,11 @@ pub async fn list_foreign_keys(pool: &SqliteHandle, schema: &str, table: &str) - pub async fn list_triggers(pool: &SqliteHandle, schema: &str, table: &str) -> Result, String> { let pool = pool.clone(); - let schema = sqlite_schema_name(schema).to_string(); + let requested_schema = schema.to_string(); let table = table.to_string(); tokio::task::spawn_blocking(move || { pool.with_connection(|conn| { + let schema = sqlite_schema_name_for_connection(conn, &requested_schema)?; let sql = format!( "SELECT name, sql FROM {}.sqlite_master WHERE type = 'trigger' AND tbl_name = ? ORDER BY name", sqlite_quote_ident(&schema) diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index e7c68bf23c..03dac4d3cd 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -5575,6 +5575,27 @@ pub fn sqlite_object_source_sql(schema: &str, name: &str, kind: &db::ObjectSourc ) } +async fn sqlite_object_source( + pool: &db::sqlite::SqliteHandle, + schema: &str, + name: &str, + kind: &db::ObjectSourceKind, +) -> Result { + let pool = pool.clone(); + let schema = schema.to_string(); + let name = sql_string(name); + let object_type = sql_string(sqlite_object_type(kind)); + tokio::task::spawn_blocking(move || { + pool.with_connection(|conn| { + let schema = db::sqlite::sqlite_quote_schema_ident_for_connection(conn, &schema)?; + let sql = format!("SELECT sql FROM {schema}.sqlite_master WHERE type = {object_type} AND name = {name}"); + conn.query_row(&sql, [], |row| row.get::<_, String>(0)).map_err(|e| e.to_string()) + }) + }) + .await + .map_err(|e| e.to_string())? +} + pub fn mysql_object_source_sql(database: &str, name: &str, kind: &db::ObjectSourceKind) -> String { let qualified_name = mysql_qualified_name(database, name); match kind { @@ -5805,9 +5826,7 @@ async fn get_object_source_once( ) .await? } - PoolKind::Sqlite(pool) => first_string_cell( - db::sqlite::execute_query(pool, &sqlite_object_source_sql(schema, name, &object_type)).await?, - )?, + PoolKind::Sqlite(pool) => sqlite_object_source(pool, schema, name, &object_type).await?, #[cfg(feature = "duckdb-sidecar")] PoolKind::DuckDbWorker(client) => { let client = client.clone(); @@ -6262,6 +6281,21 @@ mod object_source_tests { use super::*; use crate::types::ObjectSourceKind; + #[tokio::test] + async fn reads_sqlite_object_source_from_dotted_attached_schema() { + let pool = db::sqlite::connect_path(":memory:").await.expect("connect primary database"); + db::sqlite::attach_database(&pool, "analytics.db", ":memory:").expect("attach database"); + db::sqlite::execute_query(&pool, "CREATE VIEW \"analytics.db\".active_users AS SELECT 1 AS id") + .await + .expect("create attached view"); + + let source = sqlite_object_source(&pool, "analytics.db", "active_users", &ObjectSourceKind::View) + .await + .expect("read attached view source"); + + assert!(source.contains("CREATE VIEW active_users")); + } + #[test] fn builds_sqlserver_object_source_sql_for_schema_scoped_routines() { assert_eq!( @@ -6930,10 +6964,11 @@ fn ensure_display_ddl_terminated(sql: String) -> String { pub async fn sqlite_ddl(pool: &db::sqlite::SqliteHandle, schema: &str, table: &str) -> Result { let pool = pool.clone(); - let schema = db::sqlite::sqlite_quote_schema_ident(schema); + let schema = schema.to_string(); let table = table.to_string(); tokio::task::spawn_blocking(move || { pool.with_connection(|conn| { + let schema = db::sqlite::sqlite_quote_schema_ident_for_connection(conn, &schema)?; let sql = format!("SELECT sql FROM {}.sqlite_master WHERE type='table' AND name=?1", schema); conn.query_row(&sql, [table], |row| row.get::<_, String>(0)).map_err(|e| e.to_string()) }) diff --git a/packages/app-tests/defaultDatabase.test.ts b/packages/app-tests/defaultDatabase.test.ts index 6709329bf6..608711ca62 100644 --- a/packages/app-tests/defaultDatabase.test.ts +++ b/packages/app-tests/defaultDatabase.test.ts @@ -20,6 +20,18 @@ test("Cloudflare D1 使用 SQLite main 命名空间而不是连接凭据中的 D assert.equal(isDefaultDatabase({ db_type: "cloudflare-d1", database: "d1-database-uuid" }, "d1-database-uuid"), false); }); +test("SQLite 文件路径不会被当作逻辑数据库名", () => { + assert.equal(resolveDefaultDatabase({ db_type: "sqlite", database: "/tmp/stale.sqlite" }, []), "main"); + assert.equal(resolveDefaultDatabase({ db_type: "sqlite", database: "C:\\data\\stale.db" }, []), "main"); + assert.equal(resolveDefaultDatabase({ db_type: "sqlite", database: "file:/tmp/stale.sqlite" }, []), "main"); + assert.equal(isDefaultDatabase({ db_type: "sqlite", database: "/tmp/stale.sqlite" }, "main"), true); +}); + +test("SQLite 附加数据库别名保持不变", () => { + assert.equal(resolveDefaultDatabase({ db_type: "sqlite", database: "analytics" }, ["main"]), "analytics"); + assert.equal(isDefaultDatabase({ db_type: "sqlite", database: "analytics" }, "analytics"), true); +}); + test("判断当前数据库是否为默认数据库", () => { assert.equal(isDefaultDatabase({ database: "analytics" }, "analytics"), true); assert.equal(isDefaultDatabase({ database: "analytics" }, "app"), false); diff --git a/packages/app-tests/newQueryContext.test.ts b/packages/app-tests/newQueryContext.test.ts index 17445cf46d..caef3d6bf0 100644 --- a/packages/app-tests/newQueryContext.test.ts +++ b/packages/app-tests/newQueryContext.test.ts @@ -16,6 +16,15 @@ function connection(id: string, database = ""): ConnectionConfig { }; } +function sqliteConnection(id: string, database = ""): ConnectionConfig { + return { + ...connection(id, database), + db_type: "sqlite", + host: "/tmp/real.sqlite", + port: 0, + }; +} + function queryTab(connectionId: string, database: string, mode: QueryTab["mode"] = "data"): QueryTab { return { id: `${connectionId}-${database}`, @@ -119,3 +128,17 @@ test("new query target refreshes default database for connection-only sidebar no shouldRefreshDefaultDatabase: true, }); }); + +test("new query target repairs stale SQLite file paths without changing attached aliases", () => { + const stalePathTarget = resolveNewQueryTarget({ + activeTab: queryTab("sqlite", "/tmp/stale.sqlite"), + connections: [sqliteConnection("sqlite", "/tmp/stale.sqlite")], + }); + const attachedAliasTarget = resolveNewQueryTarget({ + activeTab: queryTab("sqlite", "analytics"), + connections: [sqliteConnection("sqlite")], + }); + + assert.equal(stalePathTarget?.database, "main"); + assert.equal(attachedAliasTarget?.database, "analytics"); +}); diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index 717fc683e4..b32146d946 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -5020,6 +5020,31 @@ test("Kingbase history restore inherits schema when the history entry keeps its } }); +test("SQLite history restore repairs a stale file path without changing attached aliases", () => { + const sqlite = { + ...conn("sqlite-1"), + db_type: "sqlite" as const, + host: "/tmp/real.sqlite", + port: 0, + database: "/tmp/legacy.sqlite", + }; + const getConfig = (connectionId: string) => (connectionId === sqlite.id ? sqlite : undefined); + const staleEntry: HistoryEntry = { + id: "history-sqlite-stale", + connection_id: sqlite.id, + connection_name: sqlite.name, + database: "/tmp/stale.sqlite", + sql: "select count(*) from agent_probe", + executed_at: "2026-07-29T08:00:00Z", + execution_time_ms: 1, + success: true, + }; + const attachedEntry = { ...staleEntry, id: "history-sqlite-attached", database: "analytics" }; + + assert.equal(resolveHistorySqlRestoreTarget({ entry: staleEntry, getConfig })?.database, "main"); + assert.equal(resolveHistorySqlRestoreTarget({ entry: attachedEntry, getConfig })?.database, "analytics"); +}); + test("data tab execution uses a tab-scoped client session", async () => { const restoreStorage = installMemoryStorage(); setActivePinia(createPinia());