diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 189537f0c0..8ec5c936ea 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -57,7 +57,7 @@ import type { ColumnInfo, ConnectionConfig, ForeignKeyInfo, IndexInfo, ObjectBro import { sortTablesByFkDependency, type TableWithFk } from "@/lib/table/tableDependencySort"; import { isSchemaAware } from "@/lib/database/databaseCapabilities"; import { supportsSchemaDiagram, supportsTableImport, supportsTableStructureEditing, supportsTableTruncate } from "@/lib/database/databaseFeatureSupport"; -import { codeMirrorSqlDialect, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; +import { codeMirrorSqlDialect, connectionObjectTreeNodeSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import { getTableMetadataCapabilities, type TableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities"; import { buildTableSelectSql } from "@/lib/table/tableSelectSql"; import { buildDropObjectSql, buildDropTableSql, buildDuplicateTableStructureSql, buildCopyTableDataSql, buildEmptyTableSql, buildTruncateTableSql, supportsDropTableCascade, supportsTruncateTableCascade, type TableAdminSqlOptions } from "@/lib/database/dbAdminSql"; @@ -2076,7 +2076,7 @@ async function loadObjects() { objects, database: props.database, fallbackSchema: schema, - needsSchema: needsSchema.value, + rowSchema: connectionObjectTreeNodeSchema(props.connection, props.database, selectedSchema.value), }); const availableTableIds = new Set(rows.value.filter((row) => row.type === "TABLE").map((row) => row.id)); setSelectedTableIds(new Set([...selectedTableIds.value].filter((id) => availableTableIds.has(id)))); diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 94b3372185..5472693fd6 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -89,7 +89,8 @@ import { copyNameForTreeNode, isDocumentBrowserTreeNode, objectSourceKindForTree import { dataTabOpenModeFromTreeClick, type DataTabOpenMode } from "@/lib/sidebar/dataTabOpenPolicy"; import { isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut } from "@/lib/editor/keyboardShortcuts"; import { canRefreshDataTableFromSingleActivationDoubleClick, dataTableDoubleClickAction } from "@/lib/tabs/dataTabActivation"; -import { buildCreateDatabaseSql, buildDuckDbAttachDatabaseSql, duckDbAttachedDatabaseNameFromPath, supportsCreateDatabaseCharset, uniqueDuckDbAttachedDatabaseName } from "@/lib/database/createDatabaseSql"; +import { attachedDatabaseNameFromPath, buildCreateDatabaseSql, buildDuckDbAttachDatabaseSql, buildSqliteAttachDatabaseSql, supportsCreateDatabaseCharset, uniqueAttachedDatabaseName } from "@/lib/database/createDatabaseSql"; +import { SQLITE_DATABASE_FILE_EXTENSIONS } from "@/lib/database/databaseFileDetection"; import { buildCreateSchemaSql, buildDropDatabaseSql, @@ -2003,7 +2004,12 @@ const canEditNacosNamespace = computed(() => { const isDuckDbConnection = computed(() => { const config = activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined; - return activeNode.value.type === "connection" && connectionNamespaceCreationTarget(config) === "attach"; + return activeNode.value.type === "connection" && config?.db_type === "duckdb" && connectionNamespaceCreationTarget(config) === "attach"; +}); + +const isSqliteAttachConnection = computed(() => { + const config = activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined; + return activeNode.value.type === "connection" && config?.db_type === "sqlite" && connectionNamespaceCreationTarget(config) === "attach"; }); const isConnectionSchemaCreation = computed(() => { @@ -2285,6 +2291,10 @@ async function openCreateDatabase() { await createDuckDbAttachedDatabaseFile(); return; } + if (isSqliteAttachConnection.value) { + await attachSqliteDatabaseFile(); + return; + } openCreateDatabaseDialog(); } @@ -2374,6 +2384,7 @@ function openConnectionNamespaceCreation() { function connectionNamespaceCreationLabel() { if (isDuckDbConnection.value) return t("contextMenu.createDuckDbFile"); + if (isSqliteAttachConnection.value) return t("contextMenu.attachSqliteDatabase"); if (isConnectionSchemaCreation.value) return t("contextMenu.createSchema"); return t("contextMenu.createDatabase"); } @@ -2447,12 +2458,13 @@ async function createDuckDbAttachedDatabaseFile() { const path = ensureDuckDbFileExtension(selectedPath); await connectionStore.ensureConnected(node.connectionId); const existingDatabases = await api.listDatabases(node.connectionId); - const name = uniqueDuckDbAttachedDatabaseName( - duckDbAttachedDatabaseNameFromPath(path), + const name = uniqueAttachedDatabaseName( + attachedDatabaseNameFromPath(path, "duckdb_database"), existingDatabases.map((database) => database.name), ); const sql = await buildDuckDbAttachDatabaseSql(path, name); - await executeTreeNodeSqlWithProductionGuard(node, sql, { database: "" }); + const executionResult = await executeTreeNodeSqlWithProductionGuard(node, sql, { database: "" }); + if (executionResult === undefined) return; const config = connectionStore.getConfig(node.connectionId); if (config) { @@ -2470,6 +2482,51 @@ async function createDuckDbAttachedDatabaseFile() { } } +async function attachSqliteDatabaseFile() { + const node = activeNode.value; + if (!node.connectionId) return; + if (!isTauriRuntime()) { + toast(t("contextMenu.attachSqliteDatabaseDesktopOnly"), 4000); + return; + } + + try { + const { open } = await import("@tauri-apps/plugin-dialog"); + const selected = await open({ + title: t("contextMenu.attachSqliteDatabase"), + multiple: false, + filters: [{ name: "SQLite", extensions: SQLITE_DATABASE_FILE_EXTENSIONS }], + }); + const path = Array.isArray(selected) ? selected[0] : selected; + if (!path || typeof path !== "string") return; + + await connectionStore.ensureConnected(node.connectionId); + const existingDatabases = await api.listDatabases(node.connectionId); + const name = uniqueAttachedDatabaseName( + attachedDatabaseNameFromPath(path, "sqlite_database"), + existingDatabases.map((database) => database.name), + ["main", "temp"], + ); + const sql = await buildSqliteAttachDatabaseSql(path, name); + const executionResult = await executeTreeNodeSqlWithProductionGuard(node, sql, { database: "" }); + if (executionResult === undefined) return; + + const config = connectionStore.getConfig(node.connectionId); + if (config) { + await connectionStore.updateConnection({ + ...config, + attached_databases: [...(config.attached_databases ?? []), { name, path }], + }); + } + await connectionStore.ensureVisibleDatabase(node.connectionId, name); + await connectionStore.loadDatabases(node.connectionId, { force: true }); + connectionStore.selectedTreeNodeId = `${node.connectionId}:${name}`; + toast(t("contextMenu.attachSqliteDatabaseSuccess", { name }), 3000); + } catch (e: any) { + toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); + } +} + async function confirmCreateDatabase() { const node = sidebarFormTarget.value ?? activeNode.value; const name = createDatabaseName.value.trim(); diff --git a/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts b/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts index b1adaa113b..aabdb2f0b1 100644 --- a/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts +++ b/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts @@ -162,6 +162,16 @@ describe("useNavigationTargets openTableTarget", () => { expect(mocks.tabs[0]?.tableMetaPending).toBe(true); }); + it("uses the SQLite attached database as the table namespace when a target omits schema", async () => { + mocks.databaseType = "sqlite"; + + await useNavigationTargets(dialogs).openLineageTarget({ connectionId: "connection-1", database: "analytics", tableName: "events" }); + + expect(mocks.tabs[0]?.schema).toBe("analytics"); + expect(mocks.tabs[0]?.tableMeta?.schema).toBe("analytics"); + expect(mocks.getColumns.mock.calls.some(([connectionId, database, schema, tableName]) => connectionId === "connection-1" && database === "analytics" && schema === "analytics" && tableName === "events")).toBe(true); + }); + it("does not let a stale navigation land metadata over a newer target on a reused tab", async () => { // A 的 getColumns 挂起;B 复用同一 tab 后 A 才返回 const columnGates = new Map void>(); diff --git a/apps/desktop/src/composables/useNavigationTargets.ts b/apps/desktop/src/composables/useNavigationTargets.ts index 223c8a6eaa..11dbcf4774 100644 --- a/apps/desktop/src/composables/useNavigationTargets.ts +++ b/apps/desktop/src/composables/useNavigationTargets.ts @@ -1,5 +1,5 @@ import * as api from "@/lib/backend/api"; -import { effectiveDatabaseTypeForConnection, metadataSchemaForConnection } from "@/lib/database/jdbcDialect"; +import { connectionObjectTreeNodeSchema, effectiveDatabaseTypeForConnection, metadataSchemaForConnection } from "@/lib/database/jdbcDialect"; import { invalidateTableMetadataCache, loadTableMetadata } from "@/lib/metadata/tableMetadataCache"; import { canApplyDataTabMetadata } from "@/lib/sidebar/dataTabOpenPolicy"; import { isNoSnapshotErrorResult } from "@/lib/query/queryResultError"; @@ -32,7 +32,8 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab connectionStore.activeConnectionId = target.connectionId; const config = connectionStore.getConfig(target.connectionId); - const tabTitle = target.catalog ? `${target.catalog}.${target.schema || target.database}.${target.tableName}` : target.schema ? `${target.schema}.${target.tableName}` : target.tableName; + const tableSchema = connectionObjectTreeNodeSchema(config, target.database, target.schema); + const tabTitle = target.catalog ? `${target.catalog}.${tableSchema || target.database}.${target.tableName}` : tableSchema ? `${tableSchema}.${target.tableName}` : target.tableName; if (config?.db_type === "qdrant" || config?.db_type === "milvus" || config?.db_type === "weaviate" || config?.db_type === "chromadb") { await connectionStore.ensureConnected(target.connectionId); const tabId = queryStore.createTab(target.connectionId, target.database || "default", tabTitle, "vector"); @@ -44,13 +45,13 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab const existing = queryStore.tabs.find((tab) => tab.mode === "data" && tab.connectionId === target.connectionId && tab.database === target.database && (tab.tableMeta?.catalog || "") === (target.catalog || "")); if (existing) { existing.title = tabTitle; - existing.schema = target.schema; + existing.schema = tableSchema; existing.tableInfoTab = options.tableInfoTab; queryStore.switchTab(existing.id); return existing.id; } } - return queryStore.createTab(target.connectionId, target.database, tabTitle, "data", target.schema); + return queryStore.createTab(target.connectionId, target.database, tabTitle, "data", tableSchema); })(); const targetTab = queryStore.tabs.find((tab) => tab.id === tabId); if (targetTab) targetTab.tableInfoTab = options.tableInfoTab; @@ -58,7 +59,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab // filters, row count) never read a stale tableMeta from a reused tab or // fall back to parsing the schema-qualified tab title (issue #3613). queryStore.setTableMeta(tabId, { - schema: target.schema, + schema: tableSchema, catalog: target.catalog, database: target.database, tableName: target.tableName, @@ -89,7 +90,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab { connectionId: target.connectionId, database: target.database, - schema: target.schema, + schema: tableSchema, catalog: target.catalog, tableName: target.tableName, }, @@ -113,7 +114,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab if (!config) throw new Error("Connection config not found"); const effectiveDbType = effectiveDatabaseTypeForConnection(config); const identifierQuote = connectionStore.connectionIdentifierQuote?.(target.connectionId); - const querySchema = metadataSchemaForConnection(config, target.database, target.schema); + const querySchema = metadataSchemaForConnection(config, target.database, tableSchema); const targetTableType = target.tableType ?? "TABLE"; if (config.db_type === "neo4j") { const columns = await api.getColumns(target.connectionId, target.database, querySchema, target.tableName); @@ -121,7 +122,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab const sql = await buildTableSelectSql({ databaseType: effectiveDbType, identifierQuote, - schema: target.schema, + schema: tableSchema, catalog: target.catalog, database: target.database, tableName: target.tableName, @@ -136,7 +137,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab queryStore.setTableMeta(tabId, { catalog: target.catalog, database: target.database, - schema: target.schema, + schema: tableSchema, tableName: target.tableName, tableType: targetTableType, columns, @@ -149,7 +150,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab const sql = await buildTableSelectSql({ databaseType: effectiveDbType, identifierQuote, - schema: target.schema, + schema: tableSchema, catalog: target.catalog, database: target.database, tableName: target.tableName, @@ -160,7 +161,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab if (!isPreparationCurrent()) return; queryStore.updateSql(tabId, sql); queryStore.setTableMeta(tabId, { - schema: target.schema, + schema: tableSchema, catalog: target.catalog, database: target.database, tableName: target.tableName, @@ -192,7 +193,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab const emptySql = await buildTableSelectSql({ databaseType: effectiveDbType, identifierQuote, - schema: target.schema, + schema: tableSchema, catalog: target.catalog, database: target.database, tableName: target.tableName, @@ -224,7 +225,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab if (!isCurrentTarget()) return; const useRowId = usesSyntheticRowIdKey(effectiveDbType, primaryKeys, targetTableType); queryStore.setTableMeta(tabId, { - schema: target.schema, + schema: tableSchema, catalog: target.catalog, database: target.database, tableName: target.tableName, @@ -236,7 +237,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab const newSql = await buildTableSelectSql({ databaseType: effectiveDbType, identifierQuote, - schema: target.schema, + schema: tableSchema, catalog: target.catalog, database: target.database, tableName: target.tableName, diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index e7e0039007..46ea3e933a 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1759,6 +1759,7 @@ export default { pasteTableClipboardUpdated: "Table copied to clipboard", createDatabase: "Create Database", createDuckDbFile: "Create Database File", + attachSqliteDatabase: "Attach SQLite Database", dropDatabase: "Drop Database", confirmDropDatabaseTitle: "Drop Database", confirmDropDatabaseMessage: 'Are you sure you want to drop database "{name}"? This will permanently delete the database and all its data.', @@ -1786,6 +1787,8 @@ export default { editDatabaseCommentPlaceholder: "Enter database comment...", createDuckDbFileSuccess: 'DuckDB database file "{name}" created and attached', createDuckDbFileDesktopOnly: "Creating DuckDB database files is only available in the desktop app", + attachSqliteDatabaseSuccess: 'SQLite database "{name}" attached', + attachSqliteDatabaseDesktopOnly: "Attaching SQLite database files is only available in the desktop app", dropDatabaseSuccess: 'Database "{name}" dropped', dropCollection: "Drop Collection", confirmDropCollectionTitle: "Drop Collection", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 6880fb156c..ae3eb9d71a 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1697,6 +1697,7 @@ export default withEnglishFallback({ pasteTableClipboardUpdated: "Tabla copiada al portapapeles", createDatabase: "Crear base de datos", createDuckDbFile: "Crear archivo de base de datos", + attachSqliteDatabase: "Adjuntar base de datos SQLite", dropDatabase: "Eliminar base de datos", confirmDropDatabaseTitle: "Eliminar base de datos", confirmDropDatabaseMessage: '¿Estás seguro de que deseas eliminar la base de datos "{name}"? Esto borrará permanentemente la base de datos y todos sus datos.', @@ -1724,6 +1725,8 @@ export default withEnglishFallback({ editDatabaseCommentPlaceholder: "Introduce el comentario de la base de datos...", createDuckDbFileSuccess: 'Archivo de base de datos DuckDB "{name}" creado y adjuntado', createDuckDbFileDesktopOnly: "Crear archivos de base de datos DuckDB solo está disponible en la app de escritorio", + attachSqliteDatabaseSuccess: 'Base de datos SQLite "{name}" adjuntada', + attachSqliteDatabaseDesktopOnly: "Adjuntar archivos de base de datos SQLite solo está disponible en la app de escritorio", dropDatabaseSuccess: 'Base de datos "{name}" eliminada', dropCollection: "Eliminar colección", confirmDropCollectionTitle: "Eliminar colección", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 712b957717..bc4b901ec3 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1695,6 +1695,7 @@ export default withEnglishFallback({ pasteTableClipboardUpdated: "Tabella copiata negli appunti", createDatabase: "Crea Database", createDuckDbFile: "Crea File Database", + attachSqliteDatabase: "Collega database SQLite", dropDatabase: "Elimina Database", confirmDropDatabaseTitle: "Elimina Database", confirmDropDatabaseMessage: 'Sei sicuro di voler eliminare il database "{name}"? Questa operazione eliminerà permanentemente il database e tutti i suoi dati.', @@ -1722,6 +1723,8 @@ export default withEnglishFallback({ editDatabaseCommentPlaceholder: "Inserisci commento database...", createDuckDbFileSuccess: 'File di database DuckDB "{name}" creato e collegato', createDuckDbFileDesktopOnly: "La creazione di file di database DuckDB è disponibile solo nell'applicazione desktop", + attachSqliteDatabaseSuccess: 'Database SQLite "{name}" collegato', + attachSqliteDatabaseDesktopOnly: "Il collegamento di file di database SQLite è disponibile solo nell'applicazione desktop", dropDatabaseSuccess: 'Database "{name}" eliminato', dropCollection: "Elimina Collection", confirmDropCollectionTitle: "Elimina Collection", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index f8b784a31f..b60bc7c9a2 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1692,6 +1692,7 @@ export default withEnglishFallback({ pasteTableClipboardUpdated: "テーブルをクリップボードにコピーしました", createDatabase: "データベースを作成", createDuckDbFile: "データベースファイルを作成", + attachSqliteDatabase: "SQLiteデータベースをアタッチ", dropDatabase: "データベースを削除", confirmDropDatabaseTitle: "データベースを削除", confirmDropDatabaseMessage: "本当にデータベース「{name}」をドロップ(削除)しますか?データベースとすべてのデータが永久に削除されます。", @@ -1719,6 +1720,8 @@ export default withEnglishFallback({ editDatabaseCommentPlaceholder: "データベースコメントを入力...", createDuckDbFileSuccess: "DuckDBデータベースファイル「{name}」を作成し、アタッチしました", createDuckDbFileDesktopOnly: "DuckDBデータベースファイルの作成はデスクトップアプリでのみ利用可能です", + attachSqliteDatabaseSuccess: "SQLiteデータベース「{name}」をアタッチしました", + attachSqliteDatabaseDesktopOnly: "SQLiteデータベースファイルのアタッチはデスクトップアプリでのみ利用可能です", dropDatabaseSuccess: "データベース「{name}」を削除しました", dropCollection: "コレクションを削除", confirmDropCollectionTitle: "コレクションを削除", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 3db1c6874b..7d487edd5b 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1697,6 +1697,7 @@ export default withEnglishFallback({ pasteTableClipboardUpdated: "Tabela copiada para a área de transferência", createDatabase: "Criar Banco de Dados", createDuckDbFile: "Criar Arquivo de Banco de Dados", + attachSqliteDatabase: "Anexar banco de dados SQLite", dropDatabase: "Remover Banco de Dados", confirmDropDatabaseTitle: "Remover Banco de Dados", confirmDropDatabaseMessage: 'Tem certeza de que deseja remover o banco de dados "{name}"? Isso excluirá permanentemente o banco de dados e todos os seus dados.', @@ -1724,6 +1725,8 @@ export default withEnglishFallback({ editDatabaseCommentPlaceholder: "Insira o comentário do banco de dados...", createDuckDbFileSuccess: 'Arquivo de banco de dados DuckDB "{name}" criado e anexado', createDuckDbFileDesktopOnly: "A criação de arquivos de banco de dados DuckDB só está disponível no aplicativo desktop", + attachSqliteDatabaseSuccess: 'Banco de dados SQLite "{name}" anexado', + attachSqliteDatabaseDesktopOnly: "Anexar arquivos de banco de dados SQLite só está disponível no aplicativo desktop", dropDatabaseSuccess: 'Banco de dados "{name}" removido', dropCollection: "Remover coleção", confirmDropCollectionTitle: "Remover coleção", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 68203a0414..70fb11f898 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1759,6 +1759,7 @@ export default withEnglishFallback({ pasteTableClipboardUpdated: "表已复制到剪贴板", createDatabase: "新建数据库", createDuckDbFile: "新建数据库文件", + attachSqliteDatabase: "附加 SQLite 数据库", dropDatabase: "删除数据库", confirmDropDatabaseTitle: "删除数据库", confirmDropDatabaseMessage: "确定要删除数据库「{name}」吗?这将永久删除该数据库及其所有数据。", @@ -1786,6 +1787,8 @@ export default withEnglishFallback({ editDatabaseCommentPlaceholder: "输入数据库注释...", createDuckDbFileSuccess: "DuckDB 数据库文件「{name}」已创建并附加", createDuckDbFileDesktopOnly: "新建 DuckDB 数据库文件仅支持桌面端", + attachSqliteDatabaseSuccess: "SQLite 数据库「{name}」已附加", + attachSqliteDatabaseDesktopOnly: "附加 SQLite 数据库文件仅支持桌面端", dropDatabaseSuccess: "数据库「{name}」已删除", dropCollection: "删除集合", confirmDropCollectionTitle: "删除集合", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 7a393ed845..afe85f8085 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1697,6 +1697,7 @@ export default withEnglishFallback({ pasteTableClipboardUpdated: "資料表已複製到剪貼簿", createDatabase: "建立資料庫", createDuckDbFile: "建立資料庫檔案", + attachSqliteDatabase: "附加 SQLite 資料庫", dropDatabase: "刪除資料庫", confirmDropDatabaseTitle: "刪除資料庫", confirmDropDatabaseMessage: "確定要刪除資料庫「{name}」嗎?這將永久刪除該資料庫及其所有資料。", @@ -1724,6 +1725,8 @@ export default withEnglishFallback({ editDatabaseCommentPlaceholder: "輸入資料庫註解...", createDuckDbFileSuccess: "DuckDB 資料庫檔案「{name}」已建立並附加", createDuckDbFileDesktopOnly: "建立 DuckDB 資料庫檔案僅支援桌面端", + attachSqliteDatabaseSuccess: "SQLite 資料庫「{name}」已附加", + attachSqliteDatabaseDesktopOnly: "附加 SQLite 資料庫檔案僅支援桌面端", dropDatabaseSuccess: "資料庫「{name}」已刪除", dropCollection: "刪除集合", confirmDropCollectionTitle: "刪除集合", diff --git a/apps/desktop/src/lib/__tests__/database/createDatabaseSql.spec.ts b/apps/desktop/src/lib/__tests__/database/createDatabaseSql.spec.ts new file mode 100644 index 0000000000..8521f30c5f --- /dev/null +++ b/apps/desktop/src/lib/__tests__/database/createDatabaseSql.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { attachedDatabaseNameFromPath, uniqueAttachedDatabaseName } from "@/lib/database/createDatabaseSql"; + +describe("SQLite attached database names", () => { + it("moves reserved aliases to the next available suffix", () => { + const baseName = attachedDatabaseNameFromPath("/tmp/temp.sqlite", "sqlite_database"); + + expect(uniqueAttachedDatabaseName(baseName, ["main"], ["main", "temp"])).toBe("temp_2"); + expect(uniqueAttachedDatabaseName("MAIN", [], ["main", "temp"])).toBe("MAIN_2"); + }); + + it("keeps checking existing aliases after a reserved-name collision", () => { + expect(uniqueAttachedDatabaseName("temp", ["temp_2", "temp_3"], ["main", "temp"])).toBe("temp_4"); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts b/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts index 8bd1d8f866..c1d64e44ab 100644 --- a/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts @@ -2,7 +2,13 @@ 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 { supportsSqlInListPaste, supportsTransaction } from "@/lib/database/databaseFeatureSupport"; +import { isSchemaAware, supportsSqlInListPaste, supportsTransaction } from "@/lib/database/databaseFeatureSupport"; + +describe("schema awareness", () => { + it("keeps SQLite database aliases separate from schema-capable databases", () => { + expect(isSchemaAware("sqlite")).toBe(false); + }); +}); describe("supportsTransaction", () => { it("returns true for supported database types", () => { @@ -105,13 +111,20 @@ describe("database namespace creation", () => { it("keeps non-database creation flows explicit", () => { expect(connectionNamespaceCreationTarget({ db_type: "dameng" })).toBe("schema"); expect(connectionNamespaceCreationTarget({ db_type: "duckdb" })).toBe("attach"); + expect(connectionNamespaceCreationTarget({ db_type: "sqlite" })).toBe("attach"); expect(connectionNamespaceCreationTarget({ db_type: "mongodb" })).toBe("special"); expect(connectionNamespaceCreationTarget({ db_type: "mongodb", driver_profile: "mongodb-legacy" })).toBeNull(); }); + it("hides persistent SQLite attachment for memory and SQLCipher connections", () => { + expect(connectionNamespaceCreationTarget({ db_type: "sqlite", host: ":memory:", password: "" })).toBeNull(); + expect(connectionNamespaceCreationTarget({ db_type: "sqlite", host: "/tmp/main.sqlite", password: "secret" })).toBeNull(); + expect(connectionNamespaceCreationTarget({ db_type: "sqlite", host: "/tmp/main.sqlite", password: "" })).toBe("attach"); + }); + it("hides creation for read-only, file-only, and unknown generic targets", () => { expect(connectionNamespaceCreationTarget({ db_type: "mysql", read_only: true })).toBeNull(); - expect(connectionNamespaceCreationTarget({ db_type: "sqlite" })).toBeNull(); + expect(connectionNamespaceCreationTarget({ db_type: "sqlite", read_only: true })).toBeNull(); expect(connectionNamespaceCreationTarget({ db_type: "jdbc" })).toBeNull(); expect(connectionNamespaceCreationTarget({ db_type: "oracle" })).toBeNull(); }); diff --git a/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts b/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts index 8c4ccdf154..65248d530c 100644 --- a/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { connectionQueryExecutionSchema, effectiveDatabaseTypeForConnection, inferJdbcDialect } from "@/lib/database/jdbcDialect"; +import { connectionObjectTreeNodeSchema, connectionQueryExecutionSchema, effectiveDatabaseTypeForConnection, inferJdbcDialect } from "@/lib/database/jdbcDialect"; describe("jdbc dialect inference", () => { it("detects InterSystems IRIS and Caché JDBC connections", () => { @@ -69,3 +69,9 @@ describe("query execution schema", () => { expect(connectionQueryExecutionSchema({ db_type: "jdbc", connection_string: "jdbc:databend://localhost:8000/default" }, "analytics", undefined, false)).toBe("analytics"); }); }); + +describe("object tree node schema", () => { + it("uses the SQLite database alias to qualify attached tables", () => { + expect(connectionObjectTreeNodeSchema({ db_type: "sqlite" }, "analytics")).toBe("analytics"); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/table/objectBrowserRows.spec.ts b/apps/desktop/src/lib/__tests__/table/objectBrowserRows.spec.ts new file mode 100644 index 0000000000..f0828d62b3 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/table/objectBrowserRows.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { buildObjectBrowserRows } from "@/lib/table/objectBrowserRows"; + +describe("buildObjectBrowserRows", () => { + it("preserves a resolved SQLite attached-database alias on every row", () => { + const rows = buildObjectBrowserRows({ + objects: [{ name: "events", object_type: "TABLE" }], + database: "analytics", + fallbackSchema: "analytics", + rowSchema: "analytics", + }); + + expect(rows).toHaveLength(1); + expect(rows[0]?.schema).toBe("analytics"); + }); + + it("keeps non-schema database rows unqualified when no row namespace is resolved", () => { + const rows = buildObjectBrowserRows({ + objects: [{ name: "events", object_type: "TABLE" }], + database: "app", + fallbackSchema: "app", + }); + + expect(rows[0]?.schema).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/table/tableSelectSql.spec.ts b/apps/desktop/src/lib/__tests__/table/tableSelectSql.spec.ts index f04eced004..88a951e593 100644 --- a/apps/desktop/src/lib/__tests__/table/tableSelectSql.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableSelectSql.spec.ts @@ -28,6 +28,12 @@ describe("qualifiedTableName — Doris/StarRocks multi-catalog", () => { }); }); +describe("qualifiedTableName — SQLite attached databases", () => { + it("qualifies tables with the attached database alias", () => { + expect(qualifiedTableName({ databaseType: "sqlite", schema: "analytics", tableName: "events" })).toBe('"analytics"."events"'); + }); +}); + describe("quoteTableIdentifier", () => { it("backtick-quotes mysql identifiers", () => { expect(quoteTableIdentifier("mysql", "orders")).toBe("`orders`"); diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 14737842e7..6dfc9adf16 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -193,6 +193,7 @@ export const buildSearchResultWhere = forward("buildSearchResultWhere"); export const buildRenameObjectSql = forward("buildRenameObjectSql"); export const buildCreateDatabaseSql = forward("buildCreateDatabaseSql"); export const buildDuckDbAttachDatabaseSql = forward("buildDuckDbAttachDatabaseSql"); +export const buildSqliteAttachDatabaseSql = forward("buildSqliteAttachDatabaseSql"); export const buildDropObjectSql = forward("buildDropObjectSql"); export const buildDropTableSql = forward("buildDropTableSql"); export const buildDropTableChildObjectSql = forward("buildDropTableChildObjectSql"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index fef1df75c2..fd9678f70d 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -849,6 +849,10 @@ export async function buildDuckDbAttachDatabaseSql(path: string, name: string): return post("/api/query/build-duckdb-attach-database-sql", { options: { path, name } }); } +export async function buildSqliteAttachDatabaseSql(path: string, name: string): Promise { + return post("/api/query/build-sqlite-attach-database-sql", { options: { path, name } }); +} + export async function buildDropObjectSql(options: DropObjectSqlOptions): Promise { return post("/api/query/build-drop-object-sql", { options }); } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 036a3f2780..6d6e062fe2 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -940,6 +940,10 @@ export async function buildDuckDbAttachDatabaseSql(path: string, name: string): return invoke("build_duckdb_attach_database_sql", { options: { path, name } }); } +export async function buildSqliteAttachDatabaseSql(path: string, name: string): Promise { + return invoke("build_sqlite_attach_database_sql", { options: { path, name } }); +} + export async function buildDropObjectSql(options: DropObjectSqlOptions): Promise { return invoke("build_drop_object_sql", { options }); } diff --git a/apps/desktop/src/lib/database/createDatabaseSql.ts b/apps/desktop/src/lib/database/createDatabaseSql.ts index 6051ce5db2..c829d8938c 100644 --- a/apps/desktop/src/lib/database/createDatabaseSql.ts +++ b/apps/desktop/src/lib/database/createDatabaseSql.ts @@ -26,18 +26,22 @@ export function buildDuckDbAttachDatabaseSql(path: string, name: string): Promis return api.buildDuckDbAttachDatabaseSql(path, name); } -export function duckDbAttachedDatabaseNameFromPath(path: string): string { +export function buildSqliteAttachDatabaseSql(path: string, name: string): Promise { + return api.buildSqliteAttachDatabaseSql(path, name); +} + +export function attachedDatabaseNameFromPath(path: string, fallbackName = "database"): string { const fileName = path.split(/[\\/]/).pop() ?? ""; const withoutExtension = fileName.replace(/\.[^.\\/]+$/, ""); const normalized = withoutExtension .trim() .replace(/[^\p{L}\p{N}_]+/gu, "_") .replace(/^_+|_+$/g, ""); - return normalized || "duckdb_database"; + return normalized || fallbackName; } -export function uniqueDuckDbAttachedDatabaseName(baseName: string, existingNames: string[]): string { - const existing = new Set(existingNames.map((name) => name.toLowerCase())); +export function uniqueAttachedDatabaseName(baseName: string, existingNames: string[], reservedNames: string[] = []): string { + const existing = new Set([...existingNames, ...reservedNames].map((name) => name.toLowerCase())); if (!existing.has(baseName.toLowerCase())) return baseName; for (let index = 2; index < Number.MAX_SAFE_INTEGER; index++) { const candidate = `${baseName}_${index}`; diff --git a/apps/desktop/src/lib/database/databaseNamespaceCreation.ts b/apps/desktop/src/lib/database/databaseNamespaceCreation.ts index 253afc6bb1..81d71afd6f 100644 --- a/apps/desktop/src/lib/database/databaseNamespaceCreation.ts +++ b/apps/desktop/src/lib/database/databaseNamespaceCreation.ts @@ -11,13 +11,13 @@ export interface DatabaseNamespaceCreationMatrixEntry { deferred?: string; } -type CreationConnection = Pick | undefined; +type CreationConnection = (Pick & Partial>) | undefined; // Keep creation target-specific: many products expose schemas, files, or provider-managed namespaces instead of a top-level database. export const DATABASE_NAMESPACE_CREATION_MATRIX = { mysql: { connection: "database" }, postgres: { connection: "database", database: "schema" }, - sqlite: { deferred: "file-backed; create a new connection/file instead" }, + sqlite: { connection: "attach" }, rqlite: { deferred: "single SQLite-compatible database per node" }, turso: { deferred: "remote libSQL database lifecycle is provider-managed" }, "cloudflare-d1": { deferred: "Cloudflare D1 database lifecycle is provider-managed" }, @@ -85,6 +85,9 @@ export const DATABASE_NAMESPACE_CREATION_MATRIX = { export function connectionNamespaceCreationTarget(connection: CreationConnection): ConnectionCreationTarget | null { if (!connection || connection.read_only) return null; if (connection.db_type === "mongodb" && connection.driver_profile === "mongodb-legacy") return null; + if (connection.db_type === "sqlite" && (connection.host?.trim().toLowerCase() === ":memory:" || Boolean(connection.password))) { + return null; + } const entry: DatabaseNamespaceCreationMatrixEntry = DATABASE_NAMESPACE_CREATION_MATRIX[connection.db_type]; return entry.connection ?? null; } diff --git a/apps/desktop/src/lib/database/jdbcDialect.ts b/apps/desktop/src/lib/database/jdbcDialect.ts index 1a92eb2f2c..31ce64efd8 100644 --- a/apps/desktop/src/lib/database/jdbcDialect.ts +++ b/apps/desktop/src/lib/database/jdbcDialect.ts @@ -101,6 +101,7 @@ export function connectionObjectTreeNodeSchema(connection: JdbcDialectConnection if (connectionUsesDatabaseObjectTreeMode(connection)) return undefined; if (schema) return schema; const type = effectiveDatabaseTypeForConnection(connection); + if (type === "sqlite") return database; return isSchemaAware(type) ? database : undefined; } diff --git a/apps/desktop/src/lib/table/objectBrowserRows.ts b/apps/desktop/src/lib/table/objectBrowserRows.ts index f153a49e2d..3feb1136aa 100644 --- a/apps/desktop/src/lib/table/objectBrowserRows.ts +++ b/apps/desktop/src/lib/table/objectBrowserRows.ts @@ -40,13 +40,13 @@ export function normalizeObjectBrowserType(type: string): ObjectBrowserRow["type return "TABLE"; } -export function buildObjectBrowserRows(options: { objects: ObjectInfo[]; database: string; fallbackSchema: string; needsSchema: boolean }): ObjectBrowserRow[] { +export function buildObjectBrowserRows(options: { objects: ObjectInfo[]; database: string; fallbackSchema: string; rowSchema?: string }): ObjectBrowserRow[] { const seen = new Map(); const rows = options.objects.flatMap((object) => { const name = normalizeDatabaseObjectName(object.name); if (!name) return []; const objectSchema = object.schema ? normalizeDatabaseObjectName(object.schema) : undefined; - const schema = objectSchema || (options.needsSchema ? options.fallbackSchema : undefined); + const schema = objectSchema || options.rowSchema; const type = normalizeObjectBrowserType(object.object_type); const signature = routineSignatureForDisplay(type, object.signature); const displayName = signature === undefined ? name : `${name}(${signature})`; diff --git a/apps/desktop/src/lib/table/tableSelectSql.ts b/apps/desktop/src/lib/table/tableSelectSql.ts index d88c57a0d3..7ad8c7f7d3 100644 --- a/apps/desktop/src/lib/table/tableSelectSql.ts +++ b/apps/desktop/src/lib/table/tableSelectSql.ts @@ -69,7 +69,7 @@ export function qualifiedTableName(options: Pick { + let sqlite_path = expand_tilde(&db_config.host); + db::sqlite::validate_persistent_attachments( + &sqlite_path, + &db_config.password, + !db_config.attached_databases.is_empty(), + )?; let extensions = db::sqlite::sqlite_extension_specs_from_url_params(db_config.url_params.as_deref()) .into_iter() .map(|mut extension| { @@ -1174,14 +1180,16 @@ impl AppState { extension }) .collect(); - PoolKind::Sqlite( - db::sqlite::connect_path_with_cipher_key_and_extensions( - &expand_tilde(&db_config.host), - &db_config.password, - extensions, - ) - .await?, + let pool = db::sqlite::connect_path_with_cipher_key_and_extensions( + &sqlite_path, + &db_config.password, + extensions, ) + .await?; + for attached in &db_config.attached_databases { + db::sqlite::attach_database(&pool, &attached.name, &expand_tilde(&attached.path))?; + } + PoolKind::Sqlite(pool) } DatabaseType::Rqlite => { let client = db::rqlite_driver::RqliteClient::new( @@ -4668,6 +4676,41 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[tokio::test] + async fn sqlite_connection_restores_attached_databases() { + let (state, dir) = test_app_state().await; + let main_path = dir.join("main.sqlite"); + let attached_path = dir.join("analytics.sqlite"); + drop(db::sqlite::connect_path_create_if_missing(main_path.to_str().unwrap()).await.unwrap()); + let attached = db::sqlite::connect_path_create_if_missing(attached_path.to_str().unwrap()).await.unwrap(); + db::sqlite::execute_query(&attached, "CREATE TABLE events(id INTEGER PRIMARY KEY);").await.unwrap(); + drop(attached); + + let mut config = mysql_config(None); + config.id = "sqlite-conn".to_string(); + config.name = "SQLite".to_string(); + config.db_type = DatabaseType::Sqlite; + config.host = main_path.to_string_lossy().to_string(); + config.port = 0; + config.password.clear(); + config.attached_databases.push(AttachedDatabaseConfig { + name: "analytics".to_string(), + path: attached_path.to_string_lossy().to_string(), + }); + state.configs.write().await.insert(config.id.clone(), config); + + state.get_or_create_pool("sqlite-conn", None).await.unwrap(); + let databases = schema::list_databases_core(&state, "sqlite-conn").await.unwrap(); + assert!(databases.iter().any(|database| database.name == "analytics")); + let tables = schema::list_tables_core(&state, "sqlite-conn", "analytics", "analytics", None, None, None, None) + .await + .unwrap(); + assert!(tables.iter().any(|table| table.name == "events")); + + state.connections.write().await.clear(); + let _ = std::fs::remove_dir_all(dir); + } + #[cfg(feature = "duckdb-bundled")] #[tokio::test] async fn duckdb_connection_test_supports_memory_bootstrap() { diff --git a/crates/dbx-core/src/db/sqlite.rs b/crates/dbx-core/src/db/sqlite.rs index 34ef2dc3d6..8b818117cb 100644 --- a/crates/dbx-core/src/db/sqlite.rs +++ b/crates/dbx-core/src/db/sqlite.rs @@ -39,6 +39,43 @@ impl SqliteHandle { } } +pub fn attach_database(pool: &SqliteHandle, name: &str, path: &str) -> Result<(), String> { + let name = name.trim(); + if name.is_empty() { + return Err("SQLite attached database name cannot be empty".to_string()); + } + if name.eq_ignore_ascii_case("main") || name.eq_ignore_ascii_case("temp") { + return Err(format!("SQLite database name \"{name}\" is reserved")); + } + if !is_memory_database_path(path) { + validate_file_path(path, is_network_path)?; + } + + let sql = format!("ATTACH DATABASE ?1 AS {}", sqlite_quote_ident(name)); + pool.with_connection(|conn| { + conn.execute(&sql, [path]).map(|_| ()).map_err(|e| format!("Failed to attach SQLite database \"{name}\": {e}")) + }) +} + +pub fn validate_persistent_attachments(main_path: &str, cipher_key: &str, has_attachments: bool) -> Result<(), String> { + if !has_attachments { + return Ok(()); + } + if is_memory_database_path(main_path) { + return Err( + "Persistent SQLite attachments are unavailable for an in-memory main database because reconnecting would discard its data." + .to_string(), + ); + } + if !cipher_key.is_empty() { + return Err( + "Persistent SQLite attachments are unavailable for SQLCipher connections because each attached database requires an explicit key." + .to_string(), + ); + } + Ok(()) +} + pub async fn connect_path(path: &str) -> Result { connect_path_with_options(path, false, None, Vec::new()).await } @@ -498,6 +535,18 @@ pub fn is_memory_database_path(path: &str) -> bool { mod tests { use super::*; + #[test] + fn persistent_attachments_require_a_file_backed_plaintext_main_database() { + assert!(validate_persistent_attachments("/tmp/main.sqlite", "", false).is_ok()); + assert!(validate_persistent_attachments("/tmp/main.sqlite", "", true).is_ok()); + + let memory_error = validate_persistent_attachments(":memory:", "", true).unwrap_err(); + assert!(memory_error.contains("in-memory main database"), "{memory_error}"); + + let cipher_error = validate_persistent_attachments("/tmp/main.sqlite", "secret", true).unwrap_err(); + assert!(cipher_error.contains("SQLCipher"), "{cipher_error}"); + } + #[tokio::test] async fn connect_path_supports_memory_database_across_statements() { let pool = connect_path(":memory:").await.expect("connect in-memory SQLite"); @@ -1078,22 +1127,113 @@ mod tests { assert_eq!(columns.candidates[0].name, "display_name"); assert_eq!(columns.candidates[0].data_type.as_deref(), Some("TEXT")); } + + #[tokio::test] + async fn attached_database_metadata_uses_attached_schema() { + let path = std::env::temp_dir().join(format!("dbx-sqlite-attach-{}.sqlite", uuid::Uuid::new_v4())); + { + let conn = Connection::open(&path).expect("create attached database"); + conn.execute_batch( + "CREATE TABLE parent(id INTEGER PRIMARY KEY); \ + CREATE TABLE child(id INTEGER PRIMARY KEY AUTOINCREMENT, parent_id INTEGER REFERENCES parent(id), source TEXT NOT NULL); \ + INSERT INTO child(parent_id, source) VALUES(NULL, 'attached'); \ + CREATE INDEX child_parent_idx ON child(parent_id); \ + CREATE TRIGGER child_touch AFTER INSERT ON child BEGIN UPDATE child SET parent_id = parent_id WHERE id = new.id; END;", + ) + .expect("create attached schema"); + } + + let pool = connect_path(":memory:").await.expect("connect primary database"); + attach_database(&pool, "analytics", 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"), + "child", + ); + pool.with_connection(|conn| { + let attached_source: String = conn + .query_row(&format!("SELECT source FROM {qualified_child}"), [], |row| row.get(0)) + .map_err(|error| error.to_string())?; + assert_eq!(attached_source, "attached"); + + conn.execute(&format!("UPDATE {qualified_child} SET source = 'updated'"), []) + .map_err(|error| error.to_string())?; + let main_source: String = conn + .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)) + .map_err(|error| error.to_string())?; + assert_eq!(main_source, "main"); + assert_eq!(updated_attached_source, "updated"); + Ok(()) + }) + .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") + .await + .expect("list attached tables") + .iter() + .any(|table| table.name == "child")); + + let columns = get_columns(&pool, "analytics", "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") + .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") + .await + .expect("list attached triggers") + .iter() + .any(|trigger| trigger.name == "child_touch")); + + drop(pool); + let _ = std::fs::remove_file(path); + } } -pub async fn list_databases(_pool: &SqliteHandle) -> Result, String> { - Ok(vec![DatabaseInfo { name: "main".to_string() }]) -} - -pub async fn list_tables(pool: &SqliteHandle, _schema: &str) -> Result, String> { +pub async fn list_databases(pool: &SqliteHandle) -> Result, String> { let pool = pool.clone(); tokio::task::spawn_blocking(move || { pool.with_connection(|conn| { - let mut stmt = conn - .prepare( - "SELECT name, type FROM sqlite_master \ - WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name", - ) - .map_err(|e| e.to_string())?; + let mut stmt = conn.prepare("PRAGMA database_list").map_err(|e| e.to_string())?; + let rows = stmt.query_map([], |row| row.get::<_, String>("name")).map_err(|e| e.to_string())?; + rows.filter_map(|row| match row { + Ok(name) if !name.eq_ignore_ascii_case("temp") => Some(Ok(DatabaseInfo { name })), + Ok(_) => None, + Err(error) => Some(Err(error)), + }) + .collect::, _>>() + .map_err(|e| e.to_string()) + }) + }) + .await + .map_err(|e| e.to_string())? +} + +pub async fn list_tables(pool: &SqliteHandle, schema: &str) -> Result, String> { + let pool = pool.clone(); + let schema = sqlite_schema_name(schema).to_string(); + tokio::task::spawn_blocking(move || { + pool.with_connection(|conn| { + let sql = format!( + "SELECT name, type FROM {}.sqlite_master \ + WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name", + sqlite_quote_ident(&schema) + ); + let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; let rows = stmt .query_map([], |row| { let table_type: String = row.get(1)?; @@ -1113,13 +1253,14 @@ pub async fn list_tables(pool: &SqliteHandle, _schema: &str) -> Result Result, String> { +pub async fn get_columns(pool: &SqliteHandle, schema: &str, table: &str) -> Result, String> { let pool = pool.clone(); + let schema = sqlite_schema_name(schema).to_string(); let table = table.to_string(); tokio::task::spawn_blocking(move || { - let sql = format!("PRAGMA table_info(\"{}\")", table.replace('"', "\"\"")); + let sql = format!("PRAGMA {}.table_info({})", sqlite_quote_ident(&schema), sqlite_quote_string(&table)); pool.with_connection(|conn| { - let autoincrement_columns = sqlite_autoincrement_pk_columns(conn, &table).unwrap_or_default(); + 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 .query_map([], |row| { @@ -1379,10 +1520,23 @@ fn sqlite_completion_like_pattern(request: &CompletionAssistantRequest) -> Strin } } -fn sqlite_quote_ident(value: &str) -> String { +pub(crate) fn sqlite_quote_ident(value: &str) -> String { format!("\"{}\"", value.replace('"', "\"\"")) } +pub(crate) fn sqlite_quote_schema_ident(value: &str) -> String { + sqlite_quote_ident(sqlite_schema_name(value)) +} + +fn sqlite_schema_name(value: &str) -> &str { + let value = value.trim(); + if value.is_empty() { + "main" + } else { + value + } +} + fn sqlite_quote_string(value: &str) -> String { format!("'{}'", value.replace('\'', "''")) } @@ -1391,11 +1545,10 @@ fn sqlite_quote_string(value: &str) -> String { /// are rowid-alias autoincrement primary keys (i.e. SQLite will assign a value when /// the column is omitted from an INSERT). Returns `None` only on connection / query /// errors; an unparseable build statement yields `Some(empty)`. -fn sqlite_autoincrement_pk_columns(conn: &Connection, table: &str) -> Option> { - let create_sql: Option = conn - .query_row("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?1", [table], |row| row.get(0)) - .ok() - .flatten(); +fn sqlite_autoincrement_pk_columns(conn: &Connection, schema: &str, table: &str) -> Option> { + let sql = + format!("SELECT sql FROM {}.sqlite_master WHERE type = 'table' AND name = ?1", sqlite_quote_ident(schema)); + let create_sql: Option = conn.query_row(&sql, [table], |row| row.get(0)).ok().flatten(); Some(parse_sqlite_autoincrement_pk_columns(create_sql.as_deref()?)) } @@ -1946,13 +2099,15 @@ fn is_sql_keyword(value: &str) -> bool { ) } -pub async fn list_indexes(pool: &SqliteHandle, _schema: &str, table: &str) -> Result, String> { +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 table = table.to_string(); tokio::task::spawn_blocking(move || { - let safe_table = table.replace('"', "\"\""); pool.with_connection(|conn| { - let mut stmt = conn.prepare(&format!("PRAGMA index_list(\"{safe_table}\")")).map_err(|e| e.to_string())?; + let mut stmt = conn + .prepare(&format!("PRAGMA {}.index_list({})", sqlite_quote_ident(&schema), sqlite_quote_string(&table))) + .map_err(|e| e.to_string())?; let idx_rows = stmt .query_map([], |row| { Ok(( @@ -1967,9 +2122,13 @@ pub async fn list_indexes(pool: &SqliteHandle, _schema: &str, table: &str) -> Re let mut indexes = Vec::new(); for (name, is_unique, origin) in idx_rows { - let safe_name = name.replace('"', "\"\""); - let mut col_stmt = - conn.prepare(&format!("PRAGMA index_info(\"{safe_name}\")")).map_err(|e| e.to_string())?; + let mut col_stmt = conn + .prepare(&format!( + "PRAGMA {}.index_info({})", + sqlite_quote_ident(&schema), + sqlite_quote_string(&name) + )) + .map_err(|e| e.to_string())?; let columns = col_stmt .query_map([], |row| row.get::<_, String>("name")) .map_err(|e| e.to_string())? @@ -1994,11 +2153,12 @@ pub async fn list_indexes(pool: &SqliteHandle, _schema: &str, table: &str) -> Re .map_err(|e| e.to_string())? } -pub async fn list_foreign_keys(pool: &SqliteHandle, _schema: &str, table: &str) -> Result, String> { +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 table = table.to_string(); tokio::task::spawn_blocking(move || { - let sql = format!("PRAGMA foreign_key_list(\"{}\")", table.replace('"', "\"\"")); + let sql = format!("PRAGMA {}.foreign_key_list({})", sqlite_quote_ident(&schema), sqlite_quote_string(&table)); pool.with_connection(|conn| { let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; let rows = stmt @@ -2021,14 +2181,17 @@ pub async fn list_foreign_keys(pool: &SqliteHandle, _schema: &str, table: &str) .map_err(|e| e.to_string())? } -pub async fn list_triggers(pool: &SqliteHandle, _schema: &str, table: &str) -> Result, String> { +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 table = table.to_string(); tokio::task::spawn_blocking(move || { pool.with_connection(|conn| { - let mut stmt = conn - .prepare("SELECT name, sql FROM sqlite_master WHERE type = 'trigger' AND tbl_name = ? ORDER BY name") - .map_err(|e| e.to_string())?; + let sql = format!( + "SELECT name, sql FROM {}.sqlite_master WHERE type = 'trigger' AND tbl_name = ? ORDER BY name", + sqlite_quote_ident(&schema) + ); + let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; let rows = stmt .query_map([table], |row| { let sql_text: Option = row.get("sql")?; diff --git a/crates/dbx-core/src/db_admin_sql.rs b/crates/dbx-core/src/db_admin_sql.rs index f0d2249fd7..753c0b3ded 100644 --- a/crates/dbx-core/src/db_admin_sql.rs +++ b/crates/dbx-core/src/db_admin_sql.rs @@ -69,6 +69,13 @@ pub struct DuckDbAttachDatabaseSqlOptions { pub name: String, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SqliteAttachDatabaseSqlOptions { + pub path: String, + pub name: String, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DropObjectSqlOptions { @@ -303,6 +310,14 @@ pub fn build_duckdb_attach_database_sql(options: DuckDbAttachDatabaseSqlOptions) ) } +pub fn build_sqlite_attach_database_sql(options: SqliteAttachDatabaseSqlOptions) -> String { + format!( + "ATTACH DATABASE {} AS {};", + quote_sql_string(&options.path), + quote_table_identifier(Some(DatabaseType::Sqlite), &options.name) + ) +} + pub fn build_create_user_sql(username: &str, password: &str, tablespace: &str) -> String { format!( "CREATE USER {} IDENTIFIED BY {} DEFAULT TABLESPACE {};", @@ -388,6 +403,7 @@ pub fn build_drop_table_child_object_sql(options: DropTableChildObjectSqlOptions | DatabaseType::Dameng | DatabaseType::OceanbaseOracle | DatabaseType::Iris + | DatabaseType::Sqlite ) ) && options.schema.as_deref().is_some_and(|schema| !schema.is_empty()) { @@ -1060,6 +1076,17 @@ mod tests { ); } + #[test] + fn builds_sqlite_attach_sql() { + assert_eq!( + build_sqlite_attach_database_sql(SqliteAttachDatabaseSqlOptions { + path: "/Users/me/O'Reilly data.sqlite".to_string(), + name: "report db".to_string(), + }), + "ATTACH DATABASE '/Users/me/O''Reilly data.sqlite' AS \"report db\";" + ); + } + #[test] fn builds_dameng_create_user_sql_with_escaped_values() { assert_eq!( @@ -1319,6 +1346,17 @@ mod tests { .unwrap(), "DROP INDEX \"public\".\"idx_orders_status\";" ); + assert_eq!( + build_drop_table_child_object_sql(DropTableChildObjectSqlOptions { + database_type: Some(DatabaseType::Sqlite), + object_type: TableChildObjectType::Index, + schema: Some("analytics".to_string()), + table_name: "orders".to_string(), + name: "idx_orders_status".to_string(), + }) + .unwrap(), + "DROP INDEX \"analytics\".\"idx_orders_status\";" + ); assert_eq!( build_drop_table_child_object_sql(DropTableChildObjectSqlOptions { database_type: Some(DatabaseType::Mysql), diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index e5f78bddb4..4199aa52f0 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -4938,7 +4938,7 @@ pub async fn get_table_ddl_core( } } PoolKind::Postgres(p) => pg_ddl(p, schema, table).await, - PoolKind::Sqlite(p) => sqlite_ddl(p, table).await, + PoolKind::Sqlite(p) => sqlite_ddl(p, schema, table).await, PoolKind::Rqlite(client) => db::rqlite_driver::table_ddl(client, table).await, PoolKind::CloudflareD1(client) => db::cloudflare_d1_driver::table_ddl(client, table).await, _ => Err("DDL not supported for this database type".to_string()), @@ -5220,9 +5220,10 @@ pub fn oracle_object_source_sql(schema: &str, name: &str, kind: &db::ObjectSourc } } -pub fn sqlite_object_source_sql(name: &str, kind: &db::ObjectSourceKind) -> String { +pub fn sqlite_object_source_sql(schema: &str, name: &str, kind: &db::ObjectSourceKind) -> String { format!( - "SELECT sql FROM sqlite_master WHERE type = {} AND name = {}", + "SELECT sql FROM {}.sqlite_master WHERE type = {} AND name = {}", + db::sqlite::sqlite_quote_schema_ident(schema), sql_string(sqlite_object_type(kind)), sql_string(name) ) @@ -5381,7 +5382,7 @@ async fn get_object_source_once( } PoolKind::Postgres(pool) => postgres_object_source(pool, schema, name, &object_type, signature).await?, PoolKind::Sqlite(pool) => first_string_cell( - db::sqlite::execute_query(pool, &sqlite_object_source_sql(name, &object_type)).await?, + db::sqlite::execute_query(pool, &sqlite_object_source_sql(schema, name, &object_type)).await?, )?, #[cfg(feature = "duckdb-bundled")] PoolKind::DuckDb(con) => { @@ -6209,15 +6210,14 @@ fn ensure_display_ddl_terminated(sql: String) -> String { } } -pub async fn sqlite_ddl(pool: &db::sqlite::SqliteHandle, table: &str) -> Result { +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 table = table.to_string(); tokio::task::spawn_blocking(move || { pool.with_connection(|conn| { - conn.query_row("SELECT sql FROM sqlite_master WHERE type='table' AND name=?1", [table], |row| { - row.get::<_, String>(0) - }) - .map_err(|e| e.to_string()) + 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()) }) }) .await diff --git a/crates/dbx-core/src/schema/providers/native.rs b/crates/dbx-core/src/schema/providers/native.rs index b407c870ce..ef38511e41 100644 --- a/crates/dbx-core/src/schema/providers/native.rs +++ b/crates/dbx-core/src/schema/providers/native.rs @@ -234,7 +234,7 @@ pub(in crate::schema) async fn table_ddl( } } PoolKind::Postgres(p) => super::super::pg_ddl(p, schema, table).await, - PoolKind::Sqlite(p) => super::super::sqlite_ddl(p, table).await, + PoolKind::Sqlite(p) => super::super::sqlite_ddl(p, schema, table).await, PoolKind::Rqlite(client) => db::rqlite_driver::table_ddl(client, table).await, PoolKind::Turso(client) => db::turso_driver::table_ddl(client, table).await, _ => Err("DDL not supported for this database type".to_string()), @@ -262,7 +262,11 @@ pub(in crate::schema) async fn object_source( } PoolKind::Sqlite(pool) => { let source = super::super::first_string_cell( - db::sqlite::execute_query(pool, &super::super::sqlite_object_source_sql(name, object_type)).await?, + db::sqlite::execute_query( + pool, + &super::super::sqlite_object_source_sql(schema, name, object_type), + ) + .await?, )?; Ok(Some(source)) } diff --git a/crates/dbx-core/src/sql_dialect/capabilities.rs b/crates/dbx-core/src/sql_dialect/capabilities.rs index 8121028d16..6d0c2f902c 100644 --- a/crates/dbx-core/src/sql_dialect/capabilities.rs +++ b/crates/dbx-core/src/sql_dialect/capabilities.rs @@ -56,6 +56,7 @@ pub fn is_schema_aware(database_type: DatabaseType) -> bool { | DatabaseType::Db2 | DatabaseType::Tdengine | DatabaseType::Xugu + | DatabaseType::Sqlite | DatabaseType::DuckDb | DatabaseType::Iris ) diff --git a/crates/dbx-core/src/sql_dialect/tests.rs b/crates/dbx-core/src/sql_dialect/tests.rs index 18992cffe6..bac84686b9 100644 --- a/crates/dbx-core/src/sql_dialect/tests.rs +++ b/crates/dbx-core/src/sql_dialect/tests.rs @@ -50,6 +50,7 @@ fn qualifies_schema_only_for_schema_aware_databases() { "\"DBX_TEST\".\"PRODUCTS\"" ); assert_eq!(qualified_table_name(Some(DatabaseType::Oscar), Some("SYSDBA"), "EMPLOYEE"), "\"SYSDBA\".\"EMPLOYEE\""); + assert_eq!(qualified_table_name(Some(DatabaseType::Sqlite), Some("analytics"), "users"), "\"analytics\".\"users\""); assert_eq!(qualified_table_name(Some(DatabaseType::Jdbc), Some("cbsdw_dwd"), "dwd_test_df"), "dwd_test_df"); assert_eq!(qualified_table_name(Some(DatabaseType::Iotdb), Some("root.test"), "device2"), "root.test.device2"); assert_eq!( diff --git a/crates/dbx-core/src/table_structure_sql/indexes.rs b/crates/dbx-core/src/table_structure_sql/indexes.rs index da183eb84d..f60bd53272 100644 --- a/crates/dbx-core/src/table_structure_sql/indexes.rs +++ b/crates/dbx-core/src/table_structure_sql/indexes.rs @@ -131,7 +131,11 @@ pub(super) fn build_drop_index_sql( } if matches!( dialect, - StructureDialect::Postgres | StructureDialect::Oracle | StructureDialect::Dameng | StructureDialect::Informix + StructureDialect::Postgres + | StructureDialect::Oracle + | StructureDialect::Dameng + | StructureDialect::Informix + | StructureDialect::Sqlite ) && schema.is_some_and(|schema| !schema.trim().is_empty()) { return format!("DROP INDEX {}.{};", quote_ident(dialect, schema.unwrap()), quote_ident(dialect, index_name)); @@ -200,15 +204,22 @@ pub(super) fn build_create_index_statements( let supports_where = capabilities.index_filter && matches!(dialect, StructureDialect::Postgres | StructureDialect::SqlServer | StructureDialect::Sqlite); let where_clause = if !filter.is_empty() && supports_where { format!(" WHERE {filter}") } else { String::new() }; + let index_name = if dialect == StructureDialect::Sqlite && schema.is_some_and(|schema| !schema.trim().is_empty()) { + format!("{}.{}", quote_ident(dialect, schema.unwrap()), quote_ident(dialect, &name)) + } else { + quote_ident(dialect, &name) + }; + // SQLite assigns an index to the database named by the qualified index. + // Its CREATE INDEX grammar does not allow a qualified table name. + let create_table = + if dialect == StructureDialect::Sqlite { quote_ident(dialect, table_name) } else { table.to_string() }; let create_sql = if dialect == StructureDialect::Postgres { format!( - "CREATE {unique}{type_prefix}INDEX {} ON {table}{using_clause} ({cols}){include_clause}{where_clause};", - quote_ident(dialect, &name) + "CREATE {unique}{type_prefix}INDEX {index_name} ON {create_table}{using_clause} ({cols}){include_clause}{where_clause};" ) } else { format!( - "CREATE {unique}{type_prefix}INDEX {}{using_clause} ON {table} ({cols}){include_clause}{where_clause}{comment_clause};", - quote_ident(dialect, &name) + "CREATE {unique}{type_prefix}INDEX {index_name}{using_clause} ON {create_table} ({cols}){include_clause}{where_clause}{comment_clause};" ) }; let mut statements = vec![create_sql]; diff --git a/crates/dbx-core/src/table_structure_sql/sqlite_rebuild.rs b/crates/dbx-core/src/table_structure_sql/sqlite_rebuild.rs index 6d89044a80..c4ce0c2406 100644 --- a/crates/dbx-core/src/table_structure_sql/sqlite_rebuild.rs +++ b/crates/dbx-core/src/table_structure_sql/sqlite_rebuild.rs @@ -129,9 +129,6 @@ fn build_change_plan(conn: &mut Connection, options: &TableStructureSqlOptions) return Err("SQLite table rebuild requires databaseType=sqlite.".to_string()); } let schema = options.schema.as_deref().map(str::trim).filter(|schema| !schema.is_empty()).unwrap_or("main"); - if !schema.eq_ignore_ascii_case("main") { - return Err("SQLite table rebuild currently supports the native main schema only.".to_string()); - } if options.table_name.trim().is_empty() { return Err("SQLite table name cannot be empty.".to_string()); } @@ -216,7 +213,7 @@ fn build_change_plan(conn: &mut Connection, options: &TableStructureSqlOptions) let mut generic_options = options.clone(); generic_options.database_type = Some(DatabaseType::Sqlite); - generic_options.schema = None; + generic_options.schema = (!schema.eq_ignore_ascii_case("main")).then(|| schema.to_string()); generic_options.triggers.clear(); for column in &mut generic_options.columns { if let Some(original) = &column.original { @@ -395,7 +392,7 @@ fn load_schema_snapshot(conn: &Connection, schema: &str, table_name: &str) -> Re drop(dependency_stmt); let schema_version: i64 = conn - .pragma_query_value(Some(rusqlite::DatabaseName::Main), "schema_version", |row| row.get(0)) + .query_row(&format!("PRAGMA {schema_ident}.schema_version"), [], |row| row.get(0)) .map_err(|error| format!("Failed to read SQLite schema version: {error}"))?; let autoincrement_sequence = if contains_sql_keyword(&table_sql, "autoincrement") { conn.query_row( @@ -481,8 +478,16 @@ fn build_rebuild_statements( replaced_index_names: &HashSet, ) -> Result, String> { let mut create_replacement = rewrite_declared_column_types(&snapshot.table_sql, type_changes)?; - let (_, body_end) = find_create_table_body_span(&create_replacement) + let (body_start, body_end) = find_create_table_body_span(&create_replacement) .ok_or_else(|| "SQLite CREATE TABLE statement could not be parsed safely.".to_string())?; + let without_rowid = has_without_rowid_clause(&create_replacement[body_end + 1..]); + if !snapshot.schema.eq_ignore_ascii_case("main") { + create_replacement = format!( + "CREATE TABLE {}{}", + qualified_name(&snapshot.schema, &snapshot.table_name), + &create_replacement[body_start..] + ); + } ensure_statement_terminated(&mut create_replacement); let backup_seed = u64::from_str_radix(&snapshot.revision[..15], 16) .map_err(|error| format!("Failed to derive SQLite backup table suffix: {error}"))?; @@ -508,7 +513,6 @@ fn build_rebuild_statements( let qualified_backup = qualified_name(&snapshot.schema, &backup_name); let qualified_source = qualified_name(&snapshot.schema, &source_name); - let without_rowid = has_without_rowid_clause(&create_replacement[body_end + 1..]); let visible_columns = snapshot.columns.iter().filter(|column| column.hidden == 0).collect::>(); let live_names = snapshot.columns.iter().map(|column| column.name.to_ascii_lowercase()).collect::>(); let rowid_alias = if without_rowid { @@ -581,19 +585,23 @@ fn build_rebuild_statements( )); } statements.push(format!("DROP TABLE {qualified_source};")); - statements.extend( - snapshot - .dependencies - .iter() - .filter(|dependency| { - dependency.kind != "index" || !replaced_index_names.contains(&dependency.name.to_ascii_lowercase()) - }) - .map(|dependency| { - let mut sql = dependency.sql.clone(); - ensure_statement_terminated(&mut sql); - sql - }), - ); + let dependency_statements = snapshot + .dependencies + .iter() + .filter(|dependency| { + dependency.kind != "index" || !replaced_index_names.contains(&dependency.name.to_ascii_lowercase()) + }) + .map(|dependency| -> Result { + let mut sql = if snapshot.schema.eq_ignore_ascii_case("main") { + dependency.sql.clone() + } else { + qualify_sqlite_create_object_sql(&dependency.sql, &dependency.kind, &snapshot.schema, &dependency.name)? + }; + ensure_statement_terminated(&mut sql); + Ok(sql) + }) + .collect::, _>>()?; + statements.extend(dependency_statements); Ok(statements) } @@ -963,7 +971,7 @@ fn parse_column_type_span(entry: &str) -> Option<(String, usize, usize)> { fn parse_identifier(value: &str, start: usize) -> Option<(String, usize, bool)> { let bytes = value.as_bytes(); let first = *bytes.get(start)?; - if matches!(first, b'"' | b'`' | b'[') { + if matches!(first, b'\'' | b'"' | b'`' | b'[') { let close = if first == b'[' { b']' } else { first }; let mut decoded = String::new(); let mut index = start + 1; @@ -1276,6 +1284,153 @@ fn sql_words_outside_comments_and_quotes(sql: &str) -> Vec { words } +fn qualify_sqlite_create_object_sql( + sql: &str, + kind: &str, + schema: &str, + expected_name: &str, +) -> Result { + let keyword = match kind.to_ascii_lowercase().as_str() { + "index" => "index", + "trigger" => "trigger", + _ => return Err(format!("Unsupported SQLite dependency kind \"{kind}\".")), + }; + let keyword_end = find_sql_keyword_end(sql, keyword) + .ok_or_else(|| format!("SQLite {kind} definition does not contain a {keyword} keyword."))?; + let mut object_start = skip_space_and_comments(sql, keyword_end) + .ok_or_else(|| format!("SQLite {kind} definition ended before the object name."))?; + + if let Some((word, word_end, quoted)) = parse_identifier(sql, object_start) { + if !quoted && word.eq_ignore_ascii_case("if") { + let not_start = skip_space_and_comments(sql, word_end) + .ok_or_else(|| format!("SQLite {kind} IF clause is incomplete."))?; + let (not_word, not_end, not_quoted) = + parse_identifier(sql, not_start).ok_or_else(|| format!("SQLite {kind} IF clause is incomplete."))?; + let exists_start = skip_space_and_comments(sql, not_end) + .ok_or_else(|| format!("SQLite {kind} IF NOT clause is incomplete."))?; + let (exists_word, exists_end, exists_quoted) = parse_identifier(sql, exists_start) + .ok_or_else(|| format!("SQLite {kind} IF NOT clause is incomplete."))?; + if not_quoted + || exists_quoted + || !not_word.eq_ignore_ascii_case("not") + || !exists_word.eq_ignore_ascii_case("exists") + { + return Err(format!("SQLite {kind} definition has an invalid IF NOT EXISTS clause.")); + } + object_start = skip_space_and_comments(sql, exists_end) + .ok_or_else(|| format!("SQLite {kind} definition ended before the object name."))?; + } + } + + let (first_name, first_end, first_quoted) = parse_identifier(sql, object_start) + .ok_or_else(|| format!("SQLite {kind} definition has an invalid object name."))?; + let mut actual_name = first_name.clone(); + let mut object_end = first_end; + let after_first = skip_space_and_comments(sql, first_end) + .ok_or_else(|| format!("SQLite {kind} definition has an invalid object name."))?; + if sql.as_bytes().get(after_first) == Some(&b'.') { + let second_start = skip_space_and_comments(sql, after_first + 1) + .ok_or_else(|| format!("SQLite {kind} definition has an incomplete qualified name."))?; + let (second_name, second_end, _) = parse_identifier(sql, second_start) + .ok_or_else(|| format!("SQLite {kind} definition has an incomplete qualified name."))?; + actual_name = second_name; + object_end = second_end; + } else if !first_quoted { + actual_name = first_name.rsplit_once('.').map_or(first_name.as_str(), |(_, name)| name).to_string(); + } + if !actual_name.eq_ignore_ascii_case(expected_name) { + return Err(format!( + "SQLite {kind} definition name \"{actual_name}\" does not match metadata name \"{expected_name}\"." + )); + } + + let mut qualified = sql.to_string(); + qualified.replace_range(object_start..object_end, &qualified_name(schema, expected_name)); + Ok(qualified) +} + +fn find_sql_keyword_end(sql: &str, keyword: &str) -> Option { + let bytes = sql.as_bytes(); + let mut mode = ScanMode::Normal; + let mut index = 0_usize; + while index < bytes.len() { + let byte = bytes[index]; + match mode { + ScanMode::Normal => match byte { + b'\'' => mode = ScanMode::SingleQuote, + b'"' => mode = ScanMode::DoubleQuote, + b'`' => mode = ScanMode::Backtick, + b'[' => mode = ScanMode::Bracket, + b'-' if bytes.get(index + 1) == Some(&b'-') => { + mode = ScanMode::LineComment; + index += 1; + } + b'/' if bytes.get(index + 1) == Some(&b'*') => { + mode = ScanMode::BlockComment; + index += 1; + } + _ if byte.is_ascii_alphabetic() || byte == b'_' => { + let start = index; + index += 1; + while index < bytes.len() && (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'_') { + index += 1; + } + if sql[start..index].eq_ignore_ascii_case(keyword) { + return Some(index); + } + continue; + } + _ => {} + }, + ScanMode::SingleQuote => { + if byte == b'\'' { + if bytes.get(index + 1) == Some(&b'\'') { + index += 1; + } else { + mode = ScanMode::Normal; + } + } + } + ScanMode::DoubleQuote => { + if byte == b'"' { + if bytes.get(index + 1) == Some(&b'"') { + index += 1; + } else { + mode = ScanMode::Normal; + } + } + } + ScanMode::Backtick => { + if byte == b'`' { + if bytes.get(index + 1) == Some(&b'`') { + index += 1; + } else { + mode = ScanMode::Normal; + } + } + } + ScanMode::Bracket => { + if byte == b']' { + mode = ScanMode::Normal; + } + } + ScanMode::LineComment => { + if byte == b'\n' { + mode = ScanMode::Normal; + } + } + ScanMode::BlockComment => { + if byte == b'*' && bytes.get(index + 1) == Some(&b'/') { + mode = ScanMode::Normal; + index += 1; + } + } + } + index += 1; + } + None +} + fn quote_ident(value: &str) -> String { format!("\"{}\"", value.replace('"', "\"\"")) } @@ -1479,6 +1634,221 @@ mod tests { assert_eq!(backup_data.rows[0], serde_json::json!(["text", "42"]).as_array().unwrap().clone()); } + #[tokio::test] + async fn rebuilds_attached_schema_without_touching_same_named_main_objects() { + let pool = db::sqlite::connect_path(":memory:").await.unwrap(); + pool.with_connection(|conn| { + conn.execute_batch( + "PRAGMA foreign_keys=ON; + ATTACH DATABASE ':memory:' AS analytics; + CREATE TABLE main.audit(message TEXT); + CREATE TABLE main.items(id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT NOT NULL); + CREATE INDEX main.idx_items_value ON items(value); + CREATE TRIGGER main.trg_items_ai AFTER INSERT ON items + BEGIN INSERT INTO audit VALUES ('main:' || NEW.value); END; + INSERT INTO main.items(value) VALUES ('11'); + DELETE FROM main.audit; + CREATE TABLE analytics.audit(message TEXT); + CREATE TABLE analytics.items(id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT NOT NULL); + CREATE INDEX analytics.idx_items_value ON items(value); + CREATE TRIGGER analytics.trg_items_ai AFTER INSERT ON items + BEGIN INSERT INTO audit VALUES ('analytics:' || NEW.value); END; + INSERT INTO analytics.items(id, value) VALUES (100, '42'); + DELETE FROM analytics.audit;", + ) + .map_err(|error| error.to_string()) + }) + .unwrap(); + + let mut options = type_change_options("items", "value", "TEXT", "INTEGER"); + options.schema = Some("analytics".to_string()); + options.columns.push(EditableStructureColumn { + id: "note".to_string(), + name: "note".to_string(), + data_type: "TEXT".to_string(), + is_nullable: true, + default_value: String::new(), + comment: String::new(), + is_primary_key: false, + extra: None, + original: None, + original_position: None, + marked_for_drop: false, + character_set: String::new(), + collation: String::new(), + }); + options.indexes.push(EditableStructureIndex { + id: "idx_items_note".to_string(), + name: "idx_items_note".to_string(), + columns: vec!["note".to_string()], + is_unique: false, + is_primary: false, + filter: String::new(), + index_type: String::new(), + included_columns: Vec::new(), + comment: String::new(), + original: None, + marked_for_drop: false, + }); + let preview = preview_sqlite_table_structure_change_with_pool(pool.clone(), options.clone()).await.unwrap(); + assert!(preview.warnings.is_empty(), "{:?}", preview.warnings); + assert!(preview.statements.iter().any(|sql| sql.starts_with("CREATE TABLE \"analytics\".\"items\""))); + assert!(preview.statements.iter().any(|sql| sql.starts_with("CREATE INDEX \"analytics\".\"idx_items_value\""))); + assert!(preview.statements.iter().any(|sql| sql.starts_with("CREATE TRIGGER \"analytics\".\"trg_items_ai\""))); + + // A main-schema change after preview must not invalidate an attached-schema revision. + db::sqlite::execute_query(&pool, "CREATE TABLE main.unrelated(id INTEGER);").await.unwrap(); + apply_sqlite_table_structure_change_with_pool(pool.clone(), options, &preview.schema_revision).await.unwrap(); + + let main_value = + db::sqlite::execute_query(&pool, "SELECT typeof(value), value FROM main.items;").await.unwrap(); + assert_eq!(main_value.rows[0], serde_json::json!(["text", "11"]).as_array().unwrap().clone()); + let attached_value = + db::sqlite::execute_query(&pool, "SELECT typeof(value), value FROM analytics.items;").await.unwrap(); + assert_eq!(attached_value.rows[0], serde_json::json!(["integer", 42]).as_array().unwrap().clone()); + let main_note = db::sqlite::execute_query( + &pool, + "SELECT count(*) FROM pragma_table_info('items', 'main') WHERE name='note';", + ) + .await + .unwrap(); + assert_eq!(main_note.rows[0][0], serde_json::json!(0)); + let attached_note = db::sqlite::execute_query( + &pool, + "SELECT count(*) FROM pragma_table_info('items', 'analytics') WHERE name='note';", + ) + .await + .unwrap(); + assert_eq!(attached_note.rows[0][0], serde_json::json!(1)); + let attached_note_index = db::sqlite::execute_query( + &pool, + "SELECT count(*) FROM analytics.sqlite_schema WHERE type='index' AND name='idx_items_note';", + ) + .await + .unwrap(); + assert_eq!(attached_note_index.rows[0][0], serde_json::json!(1)); + let main_note_index = db::sqlite::execute_query( + &pool, + "SELECT count(*) FROM main.sqlite_schema WHERE type='index' AND name='idx_items_note';", + ) + .await + .unwrap(); + assert_eq!(main_note_index.rows[0][0], serde_json::json!(0)); + + db::sqlite::execute_query(&pool, "INSERT INTO analytics.items(value) VALUES (7);").await.unwrap(); + let main_audit = db::sqlite::execute_query(&pool, "SELECT count(*) FROM main.audit;").await.unwrap(); + assert_eq!(main_audit.rows[0][0], serde_json::json!(0)); + let attached_audit = db::sqlite::execute_query(&pool, "SELECT message FROM analytics.audit;").await.unwrap(); + assert_eq!(attached_audit.rows[0][0], serde_json::json!("analytics:7")); + let attached_max_id = db::sqlite::execute_query(&pool, "SELECT max(id) FROM analytics.items;").await.unwrap(); + assert_eq!(attached_max_id.rows[0][0], serde_json::json!(101)); + + for schema in ["main", "analytics"] { + let dependencies = db::sqlite::execute_query( + &pool, + &format!( + "SELECT type, name FROM {schema}.sqlite_schema \ + WHERE name IN ('idx_items_value', 'trg_items_ai') ORDER BY type;" + ), + ) + .await + .unwrap(); + assert_eq!(dependencies.rows.len(), 2); + } + let main_backups = db::sqlite::execute_query( + &pool, + "SELECT count(*) FROM main.sqlite_schema WHERE type='table' AND name GLOB 'items_[0-9]*';", + ) + .await + .unwrap(); + assert_eq!(main_backups.rows[0][0], serde_json::json!(0)); + let attached_backups = db::sqlite::execute_query( + &pool, + "SELECT count(*) FROM analytics.sqlite_schema WHERE type='table' AND name GLOB 'items_[0-9]*';", + ) + .await + .unwrap(); + assert_eq!(attached_backups.rows[0][0], serde_json::json!(1)); + } + + #[tokio::test] + async fn rebuilds_keyword_named_attached_schema_with_single_quoted_dependencies() { + let pool = db::sqlite::connect_path(":memory:").await.unwrap(); + db::sqlite::execute_query( + &pool, + "ATTACH DATABASE ':memory:' AS \"select\"; + CREATE TABLE \"select\".audit(message TEXT); + CREATE TABLE \"select\".items(value TEXT NOT NULL); + CREATE INDEX \"select\".'idx weird' ON items(value); + CREATE TRIGGER \"select\".'trg weird' AFTER INSERT ON items + BEGIN INSERT INTO audit VALUES ('attached:' || NEW.value); END; + INSERT INTO \"select\".items VALUES ('42'); + DELETE FROM \"select\".audit;", + ) + .await + .unwrap(); + let definitions = db::sqlite::execute_query( + &pool, + "SELECT sql FROM \"select\".sqlite_schema + WHERE name IN ('idx weird', 'trg weird') ORDER BY type;", + ) + .await + .unwrap(); + assert_eq!(definitions.rows.len(), 2); + assert!(definitions.rows.iter().all(|row| row[0].as_str().unwrap().contains("'"))); + + let mut options = type_change_options("items", "value", "TEXT", "INTEGER"); + options.schema = Some("select".to_string()); + let preview = preview_sqlite_table_structure_change_with_pool(pool.clone(), options.clone()).await.unwrap(); + assert!(preview.warnings.is_empty(), "{:?}", preview.warnings); + assert!(preview.statements.iter().any(|sql| sql.starts_with("CREATE TABLE \"select\".\"items\""))); + assert!(preview.statements.iter().any(|sql| sql.starts_with("CREATE INDEX \"select\".\"idx weird\""))); + assert!(preview.statements.iter().any(|sql| sql.starts_with("CREATE TRIGGER \"select\".\"trg weird\""))); + + apply_sqlite_table_structure_change_with_pool(pool.clone(), options, &preview.schema_revision).await.unwrap(); + + let value = + db::sqlite::execute_query(&pool, "SELECT typeof(value), value FROM \"select\".items;").await.unwrap(); + assert_eq!(value.rows[0], serde_json::json!(["integer", 42]).as_array().unwrap().clone()); + db::sqlite::execute_query(&pool, "INSERT INTO \"select\".items VALUES (7);").await.unwrap(); + let audit = db::sqlite::execute_query(&pool, "SELECT message FROM \"select\".audit;").await.unwrap(); + assert_eq!(audit.rows[0][0], serde_json::json!("attached:7")); + let dependencies = db::sqlite::execute_query( + &pool, + "SELECT type, name FROM \"select\".sqlite_schema + WHERE name IN ('idx weird', 'trg weird') ORDER BY type;", + ) + .await + .unwrap(); + assert_eq!(dependencies.rows.len(), 2); + } + + #[tokio::test] + async fn attached_schema_change_after_preview_is_rejected_before_mutation() { + let pool = db::sqlite::connect_path(":memory:").await.unwrap(); + db::sqlite::execute_query( + &pool, + "ATTACH DATABASE ':memory:' AS analytics; + CREATE TABLE analytics.items(value TEXT NOT NULL); + INSERT INTO analytics.items VALUES ('42');", + ) + .await + .unwrap(); + let mut options = type_change_options("items", "value", "TEXT", "INTEGER"); + options.schema = Some("analytics".to_string()); + let preview = preview_sqlite_table_structure_change_with_pool(pool.clone(), options.clone()).await.unwrap(); + + db::sqlite::execute_query(&pool, "CREATE TABLE analytics.unrelated(id INTEGER);").await.unwrap(); + let error = apply_sqlite_table_structure_change_with_pool(pool.clone(), options, &preview.schema_revision) + .await + .unwrap_err(); + + assert!(error.contains("schema changed"), "{error}"); + let value = + db::sqlite::execute_query(&pool, "SELECT typeof(value), value FROM analytics.items;").await.unwrap(); + assert_eq!(value.rows[0], serde_json::json!(["text", "42"]).as_array().unwrap().clone()); + } + #[tokio::test] async fn unrelated_existing_foreign_key_violation_does_not_block_rebuild() { let pool = db::sqlite::connect_path(":memory:").await.unwrap(); diff --git a/crates/dbx-core/src/table_structure_sql/tests.rs b/crates/dbx-core/src/table_structure_sql/tests.rs index e9e349de78..7ec65f9381 100644 --- a/crates/dbx-core/src/table_structure_sql/tests.rs +++ b/crates/dbx-core/src/table_structure_sql/tests.rs @@ -1237,6 +1237,82 @@ fn warns_for_sqlite_unsafe_column_changes() { ); } +#[test] +fn qualifies_attached_sqlite_table_and_index_changes() { + let mut email = column("email"); + email.data_type = "text".to_string(); + let mut old_index = index("idx_users_old", &["email"]); + old_index.marked_for_drop = true; + old_index.original = Some(IndexInfo { + name: "idx_users_old".to_string(), + columns: vec!["email".to_string()], + is_unique: false, + is_primary: false, + filter: None, + index_type: None, + included_columns: None, + comment: None, + }); + let email_index = index("idx_users_email", &["email"]); + + let result = build_table_structure_change_sql(TableStructureSqlOptions { + database_type: Some(DatabaseType::Sqlite), + schema: Some("analytics".to_string()), + table_name: "users".to_string(), + columns: vec![email], + indexes: vec![old_index, email_index], + foreign_keys: Vec::new(), + triggers: Vec::new(), + table_comment: None, + original_table_comment: None, + }); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!( + result.statements, + vec![ + "ALTER TABLE \"analytics\".\"users\" ADD COLUMN \"email\" text;", + "DROP INDEX \"analytics\".\"idx_users_old\";", + "CREATE INDEX \"analytics\".\"idx_users_email\" ON \"users\" (\"email\");", + ] + ); + + let connection = rusqlite::Connection::open_in_memory().unwrap(); + connection + .execute_batch( + "ATTACH DATABASE ':memory:' AS analytics; + CREATE TABLE main.users(id INTEGER); + CREATE TABLE analytics.users(id INTEGER); + CREATE INDEX analytics.idx_users_old ON users(id);", + ) + .unwrap(); + connection.execute_batch(&result.statements.join("\n")).unwrap(); + let main_columns = connection + .prepare("PRAGMA main.table_info('users')") + .unwrap() + .query_map([], |row| row.get::<_, String>("name")) + .unwrap() + .collect::, _>>() + .unwrap(); + let attached_columns = connection + .prepare("PRAGMA analytics.table_info('users')") + .unwrap() + .query_map([], |row| row.get::<_, String>("name")) + .unwrap() + .collect::, _>>() + .unwrap(); + let attached_indexes = connection + .prepare("PRAGMA analytics.index_list('users')") + .unwrap() + .query_map([], |row| row.get::<_, String>("name")) + .unwrap() + .collect::, _>>() + .unwrap(); + assert_eq!(main_columns, vec!["id"]); + assert_eq!(attached_columns, vec!["id", "email"]); + assert_eq!(attached_indexes, vec!["idx_users_email"]); +} + #[test] fn builds_rqlite_changes_with_sqlite_dialect() { let mut email = column("email"); diff --git a/crates/dbx-core/src/table_structure_sql/util.rs b/crates/dbx-core/src/table_structure_sql/util.rs index a85881253a..f13b0c72ee 100644 --- a/crates/dbx-core/src/table_structure_sql/util.rs +++ b/crates/dbx-core/src/table_structure_sql/util.rs @@ -10,6 +10,7 @@ pub(super) fn qualified_table(dialect: StructureDialect, schema: Option<&str>, t | StructureDialect::SqlServer | StructureDialect::H2 | StructureDialect::Informix + | StructureDialect::Sqlite ) && schema.is_some_and(|schema| !schema.trim().is_empty()) { return format!("{}.{}", quote_ident(dialect, schema.unwrap()), quote_ident(dialect, table_name)); diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index ccd18fe6f1..5c637fb6c1 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -364,6 +364,7 @@ async fn main() { .route("/query/build-rename-object-sql", post(routes::query::build_rename_object_sql)) .route("/query/build-create-database-sql", post(routes::query::build_create_database_sql)) .route("/query/build-duckdb-attach-database-sql", post(routes::query::build_duckdb_attach_database_sql)) + .route("/query/build-sqlite-attach-database-sql", post(routes::query::build_sqlite_attach_database_sql)) .route("/query/build-drop-object-sql", post(routes::query::build_drop_object_sql)) .route("/query/build-drop-table-sql", post(routes::query::build_drop_table_sql)) .route("/query/build-drop-table-child-object-sql", post(routes::query::build_drop_table_child_object_sql)) diff --git a/crates/dbx-web/src/routes/connection.rs b/crates/dbx-web/src/routes/connection.rs index 9866f152a2..dd7f526a53 100644 --- a/crates/dbx-web/src/routes/connection.rs +++ b/crates/dbx-web/src/routes/connection.rs @@ -114,6 +114,14 @@ pub async fn connect_db( Json(body): Json, ) -> Result, AppError> { let config = body.config; + if config.db_type == dbx_core::models::connection::DatabaseType::Sqlite { + dbx_core::db::sqlite::validate_persistent_attachments( + &config.host, + &config.password, + !config.attached_databases.is_empty(), + ) + .map_err(AppError)?; + } let app = &state.app; let connection_id = config.id.clone(); let attempt = app.begin_connection_attempt_with_client_attempt(&connection_id, body.client_attempt).await; @@ -154,6 +162,14 @@ pub async fn connection_final_proxy_port( if !runtime_config.has_effective_transport_layers() { return Err(AppError("Connection has no configured transport layers".to_string())); } + if runtime_config.db_type == dbx_core::models::connection::DatabaseType::Sqlite { + dbx_core::db::sqlite::validate_persistent_attachments( + &runtime_config.host, + &runtime_config.password, + !runtime_config.attached_databases.is_empty(), + ) + .map_err(AppError)?; + } let app = &state.app; let connection_id = runtime_config.id.clone(); @@ -225,6 +241,16 @@ pub async fn save_connections( State(state): State>, Json(body): Json, ) -> Result, AppError> { + for config in &body.configs { + if config.db_type == dbx_core::models::connection::DatabaseType::Sqlite { + dbx_core::db::sqlite::validate_persistent_attachments( + &config.host, + &config.password, + !config.attached_databases.is_empty(), + ) + .map_err(AppError)?; + } + } state.app.storage.save_connections(&body.configs).await.map_err(AppError)?; let sync = sync_connection_configs(&state, &body.configs).await; remove_connection_pools_for_connection_ids(&state, &sync.connection_pool_ids_to_drop).await; @@ -322,18 +348,19 @@ async fn remove_connection_pools_for_connection_ids(state: &WebState, connection #[cfg(test)] mod tests { - #[cfg(feature = "mq-admin")] - use super::connect_db; use super::{ - disconnect_db, load_connections, save_connection_database_info, save_connections, test_connection, - test_connection_with_info, ConnectRequest, DisconnectRequest, SaveConnectionDatabaseInfoRequest, - SaveConnectionsRequest, + connect_db, connection_final_proxy_port, disconnect_db, load_connections, save_connection_database_info, + save_connections, test_connection, test_connection_with_info, ConnectRequest, DisconnectRequest, + SaveConnectionDatabaseInfoRequest, SaveConnectionsRequest, }; use crate::state::{LoginRateLimit, WebState}; use axum::extract::State; use axum::Json; use dbx_core::connection::{AppState, PoolKind}; - use dbx_core::models::connection::{ConnectionConfig, DatabaseConnectionInfo, DatabaseType}; + use dbx_core::models::connection::{ + AttachedDatabaseConfig, ConnectionConfig, DatabaseConnectionInfo, DatabaseType, ProxyTunnelConfig, ProxyType, + TransportLayerConfig, + }; use dbx_core::storage::Storage; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -457,6 +484,60 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[tokio::test] + async fn invalid_persistent_sqlite_attachments_do_not_replace_live_web_state() { + let (state, dir) = test_web_state().await; + let initial = sqlite_config("sqlite-memory", ":memory:"); + let pool = dbx_core::db::sqlite::connect_path(":memory:").await.unwrap(); + dbx_core::db::sqlite::execute_query( + &pool, + "CREATE TABLE retained(value TEXT); INSERT INTO retained VALUES ('yes');", + ) + .await + .unwrap(); + state.app.configs.write().await.insert(initial.id.clone(), initial.clone()); + state.app.connections.write().await.insert(initial.id.clone(), PoolKind::Sqlite(pool.clone())); + + let mut invalid = initial.clone(); + invalid.attached_databases.push(AttachedDatabaseConfig { + name: "analytics".to_string(), + path: dir.join("analytics.sqlite").to_string_lossy().to_string(), + }); + let connect_error = + connect_db(State(state.clone()), Json(ConnectRequest { config: invalid.clone(), client_attempt: None })) + .await + .unwrap_err(); + assert!(connect_error.0.contains("in-memory main database"), "{}", connect_error.0); + + invalid.transport_layers.push(TransportLayerConfig::Proxy(ProxyTunnelConfig { + id: "proxy".to_string(), + name: "Proxy".to_string(), + enabled: true, + proxy_type: ProxyType::Socks5, + host: "127.0.0.1".to_string(), + port: 1080, + username: String::new(), + password: String::new(), + profile_id: String::new(), + })); + let proxy_error = connection_final_proxy_port( + State(state.clone()), + Json(ConnectRequest { config: invalid, client_attempt: None }), + ) + .await + .unwrap_err(); + assert!(proxy_error.0.contains("in-memory main database"), "{}", proxy_error.0); + + assert!(state.app.connections.read().await.contains_key(&initial.id)); + assert_eq!(state.app.configs.read().await.get(&initial.id), Some(&initial)); + let retained = dbx_core::db::sqlite::execute_query(&pool, "SELECT value FROM retained;").await.unwrap(); + assert_eq!(retained.rows[0][0], serde_json::json!("yes")); + + drop(pool); + drop(state); + let _ = std::fs::remove_dir_all(dir); + } + #[cfg(feature = "mq-admin")] async fn spawn_pulsar_clusters_server() -> String { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/crates/dbx-web/src/routes/query.rs b/crates/dbx-web/src/routes/query.rs index 55ab107c06..a46f185f43 100644 --- a/crates/dbx-web/src/routes/query.rs +++ b/crates/dbx-web/src/routes/query.rs @@ -141,6 +141,12 @@ pub struct BuildDuckDbAttachDatabaseSqlRequest { pub options: dbx_core::db_admin_sql::DuckDbAttachDatabaseSqlOptions, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildSqliteAttachDatabaseSqlRequest { + pub options: dbx_core::db_admin_sql::SqliteAttachDatabaseSqlOptions, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct BuildDropObjectSqlRequest { @@ -597,6 +603,10 @@ pub async fn build_duckdb_attach_database_sql(Json(req): Json) -> Json { + Json(dbx_core::db_admin_sql::build_sqlite_attach_database_sql(req.options)) +} + pub async fn build_drop_object_sql(Json(req): Json) -> Json { Json(dbx_core::db_admin_sql::build_drop_object_sql(req.options)) } diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index d4f7665bf1..80cc9b1d98 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -162,18 +162,15 @@ async fn connect_agent_pool( #[cfg(test)] mod tests { - #[cfg(feature = "sqlite-sqlcipher")] - use super::connect_sqlite_from_config; - use super::{ - mark_mongo_legacy_driver, mongo_legacy_connect_params, MONGO_LEGACY_DRIVER_LABEL, MONGO_LEGACY_DRIVER_PROFILE, - }; - use dbx_core::models::connection::{ConnectionConfig, DatabaseType}; #[cfg(feature = "mq-admin")] - use { - super::{load_connection_configs, save_connection_configs}, - dbx_core::connection::{AppState, PoolKind}, - dbx_core::storage::Storage, + use super::load_connection_configs; + use super::{ + connect_sqlite_from_config, mark_mongo_legacy_driver, mongo_legacy_connect_params, save_connection_configs, + MONGO_LEGACY_DRIVER_LABEL, MONGO_LEGACY_DRIVER_PROFILE, }; + use dbx_core::connection::{AppState, PoolKind}; + use dbx_core::models::connection::{AttachedDatabaseConfig, ConnectionConfig, DatabaseType}; + use dbx_core::storage::Storage; fn mongodb_config() -> ConnectionConfig { ConnectionConfig { @@ -231,7 +228,6 @@ mod tests { } } - #[cfg(feature = "sqlite-sqlcipher")] fn sqlite_config(path: &std::path::Path, password: &str) -> ConnectionConfig { let mut config = mongodb_config(); config.id = "sqlite".to_string(); @@ -249,6 +245,91 @@ mod tests { config } + #[tokio::test] + async fn sqlite_connect_from_config_restores_attached_databases() { + let dir = std::env::temp_dir().join(format!("dbx-tauri-sqlite-attach-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let main_path = dir.join("main.sqlite"); + let attached_path = dir.join("analytics.sqlite"); + drop(dbx_core::db::sqlite::connect_path_create_if_missing(main_path.to_str().unwrap()).await.unwrap()); + let attached = + dbx_core::db::sqlite::connect_path_create_if_missing(attached_path.to_str().unwrap()).await.unwrap(); + dbx_core::db::sqlite::execute_query(&attached, "CREATE TABLE events(id INTEGER PRIMARY KEY);").await.unwrap(); + drop(attached); + + let mut config = sqlite_config(&main_path, ""); + config.attached_databases.push(AttachedDatabaseConfig { + name: "analytics".to_string(), + path: attached_path.to_string_lossy().to_string(), + }); + + let pool = connect_sqlite_from_config(&config).await.expect("open SQLite connection with attachments"); + let count = pool + .with_connection(|conn| { + conn.query_row( + "SELECT count(*) FROM analytics.sqlite_master WHERE type = 'table' AND name = 'events'", + [], + |row| row.get::<_, i64>(0), + ) + .map_err(|error| error.to_string()) + }) + .expect("query attached SQLite database"); + assert_eq!(count, 1); + + drop(pool); + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn sqlite_connect_from_config_rejects_sqlcipher_attachments_before_opening_files() { + let mut config = sqlite_config(std::path::Path::new("/missing/main.sqlite"), "secret"); + config.attached_databases.push(AttachedDatabaseConfig { + name: "analytics".to_string(), + path: "/missing/analytics.sqlite".to_string(), + }); + + let error = match connect_sqlite_from_config(&config).await { + Ok(_) => panic!("SQLCipher attachments must be rejected"), + Err(error) => error, + }; + assert!(error.contains("SQLCipher"), "{error}"); + } + + #[tokio::test] + async fn saving_memory_sqlite_attachments_keeps_the_live_pool_intact() { + let dir = std::env::temp_dir().join(format!("dbx-tauri-sqlite-memory-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let storage = Storage::open(&dir.join("storage.db")).await.unwrap(); + let state = AppState::new_with_plugin_dir(storage, dir.join("plugins")); + let initial = sqlite_config(std::path::Path::new(":memory:"), ""); + let pool = dbx_core::db::sqlite::connect_path(":memory:").await.unwrap(); + dbx_core::db::sqlite::execute_query( + &pool, + "CREATE TABLE retained(value TEXT); INSERT INTO retained VALUES ('yes');", + ) + .await + .unwrap(); + state.configs.write().await.insert(initial.id.clone(), initial.clone()); + state.connections.write().await.insert(initial.id.clone(), PoolKind::Sqlite(pool.clone())); + + let mut invalid = initial.clone(); + invalid.attached_databases.push(AttachedDatabaseConfig { + name: "analytics".to_string(), + path: dir.join("analytics.sqlite").to_string_lossy().to_string(), + }); + let error = save_connection_configs(&state, &[invalid]).await.unwrap_err(); + + assert!(error.contains("in-memory main database"), "{error}"); + assert!(state.connections.read().await.contains_key(&initial.id)); + assert_eq!(state.configs.read().await.get(&initial.id), Some(&initial)); + let retained = dbx_core::db::sqlite::execute_query(&pool, "SELECT value FROM retained;").await.unwrap(); + assert_eq!(retained.rows[0][0], serde_json::json!("yes")); + + drop(pool); + drop(state); + let _ = std::fs::remove_dir_all(dir); + } + #[cfg(feature = "mq-admin")] fn mq_config(id: &str, admin_url: &str) -> ConnectionConfig { let mut config = mongodb_config(); @@ -466,6 +547,15 @@ pub async fn save_connections(state: State<'_, Arc>, configs: Vec Result<(), String> { + for config in configs { + if config.db_type == DatabaseType::Sqlite { + db::sqlite::validate_persistent_attachments( + &config.host, + &config.password, + !config.attached_databases.is_empty(), + )?; + } + } state.storage.save_connections(configs).await?; let sync = sync_connection_configs(state, configs).await; remove_connection_pools_for_connection_ids(state, &sync.connection_pool_ids_to_drop).await; @@ -588,12 +678,18 @@ fn sqlite_extension_specs_from_config(config: &ConnectionConfig) -> Vec Result { - db::sqlite::connect_path_with_cipher_key_and_extensions( - &expand_tilde(&config.host), + let sqlite_path = expand_tilde(&config.host); + db::sqlite::validate_persistent_attachments(&sqlite_path, &config.password, !config.attached_databases.is_empty())?; + let pool = db::sqlite::connect_path_with_cipher_key_and_extensions( + &sqlite_path, &config.password, sqlite_extension_specs_from_config(config), ) - .await + .await?; + for attached in &config.attached_databases { + db::sqlite::attach_database(&pool, &attached.name, &expand_tilde(&attached.path))?; + } + Ok(pool) } #[tauri::command] @@ -960,6 +1056,13 @@ pub async fn connect_db( client_attempt: Option, ) -> Result { let config = config.canonicalized(); + if config.db_type == DatabaseType::Sqlite { + db::sqlite::validate_persistent_attachments( + &config.host, + &config.password, + !config.attached_databases.is_empty(), + )?; + } let id = config.id.clone(); let db_config = metadata_connection_config(&config); let attempt = state.begin_connection_attempt_with_client_attempt(&id, client_attempt).await; @@ -1260,6 +1363,13 @@ pub async fn connection_final_proxy_port( if !runtime_config.has_effective_transport_layers() { return Err("Connection has no configured transport layers".to_string()); } + if runtime_config.db_type == DatabaseType::Sqlite { + db::sqlite::validate_persistent_attachments( + &runtime_config.host, + &runtime_config.password, + !runtime_config.attached_databases.is_empty(), + )?; + } let connection_id = runtime_config.id.clone(); let db_config = metadata_connection_config(&runtime_config); diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index 62ac94a5b1..bc83e0bd4a 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -350,6 +350,13 @@ pub fn build_duckdb_attach_database_sql( Ok(dbx_core::db_admin_sql::build_duckdb_attach_database_sql(options)) } +#[tauri::command] +pub fn build_sqlite_attach_database_sql( + options: dbx_core::db_admin_sql::SqliteAttachDatabaseSqlOptions, +) -> Result { + Ok(dbx_core::db_admin_sql::build_sqlite_attach_database_sql(options)) +} + #[tauri::command] pub fn build_drop_object_sql(options: dbx_core::db_admin_sql::DropObjectSqlOptions) -> Result { Ok(dbx_core::db_admin_sql::build_drop_object_sql(options)) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d8295f0474..befc01a6bd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1166,6 +1166,7 @@ pub fn run() { commands::query::build_create_database_sql, #[cfg(feature = "duckdb-bundled")] commands::query::build_duckdb_attach_database_sql, + commands::query::build_sqlite_attach_database_sql, commands::query::build_drop_object_sql, commands::query::build_drop_table_sql, commands::query::build_drop_table_child_object_sql,