fix(sqlite): normalize database context routing

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