mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(sqlite): normalize database context routing
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user