feat(sqlite): support attached database schema changes

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-07-20 10:51:16 +08:00
1 parent 890fd70c05
commit 85836f8e95
41 files changed
+1219 -129

No files matched your search

@@ -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))));
@@ -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();
@@ -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<string, (columns: unknown[]) => void>();
@@ -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,
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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: "コレクションを削除",
+3
View File
@@ -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",
+3
View File
@@ -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: "删除集合",
+3
View File
@@ -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: "刪除集合",
@@ -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");
});
});
@@ -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();
});
@@ -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");
});
});
@@ -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();
});
});
@@ -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`");
+1
View File
@@ -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");
+4
View File
@@ -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<string> {
return post("/api/query/build-sqlite-attach-database-sql", { options: { path, name } });
}
export async function buildDropObjectSql(options: DropObjectSqlOptions): Promise<string> {
return post("/api/query/build-drop-object-sql", { options });
}
+4
View File
@@ -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<string> {
return invoke("build_sqlite_attach_database_sql", { options: { path, name } });
}
export async function buildDropObjectSql(options: DropObjectSqlOptions): Promise<string> {
return invoke("build_drop_object_sql", { options });
}
@@ -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<string> {
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}`;
@@ -11,13 +11,13 @@ export interface DatabaseNamespaceCreationMatrixEntry {
deferred?: string;
}
type CreationConnection = Pick<ConnectionConfig, "db_type" | "driver_profile" | "read_only"> | undefined;
type CreationConnection = (Pick<ConnectionConfig, "db_type" | "driver_profile" | "read_only"> & Partial<Pick<ConnectionConfig, "host" | "password">>) | 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;
}
@@ -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;
}
@@ -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<string, number>();
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})`;
+1 -1
View File
@@ -69,7 +69,7 @@ export function qualifiedTableName(options: Pick<BuildTableSelectSqlOptions, "da
}
return quoteTableIdentifier(databaseType, tableName);
}
if (isSchemaAware(databaseType) && !usesDatabaseObjectTreeMode(databaseType) && schema) {
if ((isSchemaAware(databaseType) || databaseType === "sqlite") && !usesDatabaseObjectTreeMode(databaseType) && schema) {
if (databaseType === "sqlserver") {
const linked = parseSqlServerLinkedSchema(schema);
if (linked) return sqlServerLinkedTableName(linked, tableName);