feat(sqlite): support attached database schema changes
This commit is contained in:
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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "コレクションを削除",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "删除集合",
|
||||
|
||||
@@ -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`");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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})`;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user