From d9594db3fa8f8376c367d57a2452f0cca0e694f7 Mon Sep 17 00:00:00 2001 From: lxk955 <164191365+lxk955@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:01:19 +0800 Subject: [PATCH] feat(sql-library): auto-associate database and retarget folders --- .../src/components/layout/SqlLibraryPanel.vue | 41 ++++++- .../savedSql/savedSqlExecutionTarget.spec.ts | 41 +++++++ .../savedSql/savedSqlFileName.spec.ts | 15 ++- .../savedSql/savedSqlImportTarget.spec.ts | 15 +++ .../lib/savedSql/savedSqlExecutionTarget.ts | 20 ++- .../src/lib/savedSql/savedSqlFileName.ts | 11 ++ .../src/lib/savedSql/savedSqlImportTarget.ts | 13 +- ...re.openSavedSqlDatabaseAssociation.spec.ts | 115 ++++++++++++++++++ apps/desktop/src/stores/queryStore.ts | 9 +- 9 files changed, 266 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/stores/__tests__/queryStore.openSavedSqlDatabaseAssociation.spec.ts diff --git a/apps/desktop/src/components/layout/SqlLibraryPanel.vue b/apps/desktop/src/components/layout/SqlLibraryPanel.vue index 320454af62..a87afedf1f 100644 --- a/apps/desktop/src/components/layout/SqlLibraryPanel.vue +++ b/apps/desktop/src/components/layout/SqlLibraryPanel.vue @@ -26,7 +26,7 @@ import { savedSqlFolderBranchFileCount } from "@/lib/savedSql/savedSqlFolderCoun import { collectSavedSqlDirectoryImportFiles } from "@/lib/savedSql/savedSqlDirectoryImport"; import { savedSqlBatchErrorMessage, savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors"; import { savedSqlDatabaseScopeKey } from "@/lib/savedSql/savedSqlDatabaseTree"; -import { ensureSqlExtension, nextAvailableSqlName } from "@/lib/savedSql/savedSqlFileName"; +import { ensureSqlExtension, extractCandidateDatabaseFromName, nextAvailableSqlName } from "@/lib/savedSql/savedSqlFileName"; import { savedSqlImportTarget } from "@/lib/savedSql/savedSqlImportTarget"; import { savedSqlExecutionTargetFromTab, type SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTarget"; import { uniqueSavedSqlExportFileName, exportSavedSqlFileContent } from "@/lib/savedSql/savedSqlExport"; @@ -228,11 +228,20 @@ async function importDirectoryIntoLibrary(targetFolder?: SavedSqlFolder) { const folderCache = new Map(); const takenNamesByScope = new Map>(); - const folderConnectionId = targetFolder?.connectionId ?? ""; + const folderConnectionId = targetFolder?.connectionId || connectionStore.activeConnectionId || ""; + const folderFiles = targetFolder ? savedSqlStore.filesInFolder(targetFolder.id) : []; + const folderFallbackDb = folderFiles.find((f) => f.database)?.database; + const activeTab = queryStore.tabs.find((tab) => tab.id === queryStore.activeTabId); + const activeTabDb = activeTab?.connectionId === folderConnectionId && activeTab.database ? activeTab.database : undefined; + const config = folderConnectionId ? connectionStore.getConfig(folderConnectionId) : undefined; + const defaultDb = config ? targetDefaultDatabase(config) || config.database : ""; + const fallbackDb = folderFallbackDb || activeTabDb || defaultDb || ""; for (const file of importFiles) { const sourceTarget = resolveExternalSqlFileTarget(file.path, (connectionId) => !!connectionStore.getConfig(connectionId), unassociatedExternalSqlFileTarget()); - const importTarget = savedSqlImportTarget(sourceTarget, targetFolder); + const candidateDb = extractCandidateDatabaseFromName(file.name) || (file.folderNames.length > 0 ? extractCandidateDatabaseFromName(file.folderNames[file.folderNames.length - 1]) : undefined); + const effectiveFallbackDb = candidateDb || fallbackDb; + const importTarget = savedSqlImportTarget(sourceTarget, targetFolder, effectiveFallbackDb); const folderId = await resolveImportedFolder(folderConnectionId, targetFolder?.id, file.folderNames, folderCache); const nameScopeKey = savedSqlImportNameScopeKey(importTarget, folderId); let takenNames = takenNamesByScope.get(nameScopeKey); @@ -349,6 +358,11 @@ function folderFileCount(folderId: string) { return savedSqlFolderBranchFileCount(folderId, savedSqlStore.allFolders, filesInFolder); } +function allFilesInFolderBranch(folderId: string): SavedSqlFile[] { + const branchFolderIds = [folderId, ...descendantFolders(folderId).map((f) => f.id)]; + return savedSqlStore.allFiles.filter((file) => file.folderId && branchFolderIds.includes(file.folderId)); +} + type SqlLibraryRow = { type: "folder"; folder: SavedSqlFolder; depth: number; folderIndex: number } | { type: "file"; file: SavedSqlFile; depth: number }; const visibleFolderRows = computed(() => { @@ -455,7 +469,15 @@ async function openNewQueryInFolder(folder?: SavedSqlFolder) { const connectionId = folder?.connectionId || connectionStore.activeConnectionId || connectionStore.connections[0]?.id; if (!connectionId) return; - const target = { connectionId, database: "" }; + const folderFiles = folder ? savedSqlStore.filesInFolder(folder.id) : []; + const folderFallbackDb = folderFiles.find((f) => f.database)?.database; + const activeTab = queryStore.tabs.find((tab) => tab.id === queryStore.activeTabId); + const activeTabDb = activeTab?.connectionId === connectionId && activeTab.database ? activeTab.database : undefined; + const config = connectionStore.getConfig(connectionId); + const defaultDb = config ? targetDefaultDatabase(config) || config.database : ""; + const database = folderFallbackDb || activeTabDb || defaultDb || ""; + + const target = { connectionId, database }; const filesInTargetFolder = folder ? savedSqlStore.filesInFolder(folder.id) : savedSqlStore.filesWithoutFolder(); const takenNames = new Set(filesInTargetFolder.filter((file) => savedSqlDatabaseScopeKey(file) === savedSqlDatabaseScopeKey(target)).map((file) => file.name)); const name = uniqueImportedName("new_query.sql", takenNames); @@ -464,7 +486,7 @@ async function openNewQueryInFolder(folder?: SavedSqlFolder) { connectionId, folderId: folder?.id, name, - database: "", + database, sql: "", }); const tabId = queryStore.openSavedSql(file); @@ -1005,7 +1027,7 @@ const contextMenuItems = computed(() => { // If there's selection, show batch delete option if (hasSelection.value) { - const selectedFiles = Array.from(selectedFileIds.value); + const selectedFiles = [...new Set([...Array.from(selectedFileIds.value), ...Array.from(selectedFolderIds.value).flatMap((folderId) => allFilesInFolderBranch(folderId).map((f) => f.id))])]; return [ { label: t("sqlLibrary.changeTarget", { count: selectedFiles.length }), @@ -1071,9 +1093,16 @@ const contextMenuItems = computed(() => { }, ]; } + const folderFiles = allFilesInFolderBranch(target.id); return [ { label: t("savedSql.newSubfolder"), action: () => openNewFolderInput(target.id), icon: FolderPlus }, { label: t("savedSql.newQuery"), action: () => openNewQueryInFolder(target), icon: FilePlus }, + { + label: t("sqlLibrary.changeTarget", { count: folderFiles.length }), + action: () => openChangeTarget(folderFiles.map((f) => f.id)), + icon: ArrowRightLeft, + disabled: folderFiles.length === 0, + }, { label: t("sqlLibrary.importIntoFolder"), action: () => importDirectoryIntoLibrary(target), icon: Download }, { label: t("sqlLibrary.exportFolder"), action: () => exportFolderContents(target), icon: Upload }, { label: "", separator: true }, diff --git a/apps/desktop/src/lib/__tests__/savedSql/savedSqlExecutionTarget.spec.ts b/apps/desktop/src/lib/__tests__/savedSql/savedSqlExecutionTarget.spec.ts index 96a0fbf91b..ad85ecdb30 100644 --- a/apps/desktop/src/lib/__tests__/savedSql/savedSqlExecutionTarget.spec.ts +++ b/apps/desktop/src/lib/__tests__/savedSql/savedSqlExecutionTarget.spec.ts @@ -56,4 +56,45 @@ describe("saved SQL execution targets", () => { catalog: "runtime_catalog", }); }); + + it("inherits currentTarget database when saved file database is empty", () => { + const unassociatedTarget = { connectionId: "saved-connection", database: "", schema: undefined, catalog: undefined }; + expect( + resolveSavedSqlExecutionTarget(unassociatedTarget, "saved", { + connectionId: "saved-connection", + database: "active_db", + schema: "active_schema", + catalog: "active_catalog", + }), + ).toEqual({ + connectionId: "saved-connection", + database: "active_db", + schema: "active_schema", + catalog: "active_catalog", + }); + }); + + it("extracts candidate database from file name when saved database is empty", () => { + const fileWithPrefix = { + connectionId: "saved-connection", + database: "", + name: "aisp_aikf - 全量呼入数据.sql", + }; + expect(resolveSavedSqlExecutionTarget(fileWithPrefix, "saved")).toEqual({ + connectionId: "saved-connection", + database: "aisp_aikf", + schema: undefined, + catalog: undefined, + }); + }); + + it("uses fallbackDatabase when saved file database is empty and no candidate exists", () => { + const emptyTarget = { connectionId: "saved-connection", database: "", name: "plain_query.sql" }; + expect(resolveSavedSqlExecutionTarget(emptyTarget, "saved", undefined, "default_db")).toEqual({ + connectionId: "saved-connection", + database: "default_db", + schema: undefined, + catalog: undefined, + }); + }); }); diff --git a/apps/desktop/src/lib/__tests__/savedSql/savedSqlFileName.spec.ts b/apps/desktop/src/lib/__tests__/savedSql/savedSqlFileName.spec.ts index dafe5fc1c9..f04c2fc2b5 100644 --- a/apps/desktop/src/lib/__tests__/savedSql/savedSqlFileName.spec.ts +++ b/apps/desktop/src/lib/__tests__/savedSql/savedSqlFileName.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { ensureSqlExtension, nextAvailableSqlName, stripSqlExtension } from "@/lib/savedSql/savedSqlFileName"; +import { ensureSqlExtension, extractCandidateDatabaseFromName, nextAvailableSqlName, stripSqlExtension } from "@/lib/savedSql/savedSqlFileName"; describe("savedSqlFileName", () => { it("appends .sql when missing", () => { @@ -17,6 +17,19 @@ describe("savedSqlFileName", () => { it("strips .sql extension case-insensitively", () => { expect(stripSqlExtension("report.SQL")).toBe("report"); }); + + it("extracts candidate database from prefix with hyphen separator", () => { + expect(extractCandidateDatabaseFromName("aisp_aikf - 全量呼入数据.sql")).toBe("aisp_aikf"); + expect(extractCandidateDatabaseFromName("prod_db - test_query.sql")).toBe("prod_db"); + expect(extractCandidateDatabaseFromName("my_db-report.sql")).toBe("my_db"); + expect(extractCandidateDatabaseFromName("F56 - ai稽核.sql")).toBe("F56"); + }); + + it("returns undefined when no candidate database prefix exists", () => { + expect(extractCandidateDatabaseFromName("query.sql")).toBeUndefined(); + expect(extractCandidateDatabaseFromName("plain_name")).toBeUndefined(); + expect(extractCandidateDatabaseFromName("最近10天转写.sql")).toBeUndefined(); + }); }); describe("nextAvailableSqlName", () => { diff --git a/apps/desktop/src/lib/__tests__/savedSql/savedSqlImportTarget.spec.ts b/apps/desktop/src/lib/__tests__/savedSql/savedSqlImportTarget.spec.ts index 6240ea8fdc..82bfc46cd3 100644 --- a/apps/desktop/src/lib/__tests__/savedSql/savedSqlImportTarget.spec.ts +++ b/apps/desktop/src/lib/__tests__/savedSql/savedSqlImportTarget.spec.ts @@ -37,4 +37,19 @@ describe("savedSqlImportTarget", () => { catalog: undefined, }); }); + + it("applies fallback database when source has no database", () => { + expect(savedSqlImportTarget({ connectionId: "", database: "" }, { connectionId: "conn-1" }, "fallback_db")).toEqual({ + connectionId: "conn-1", + database: "fallback_db", + catalog: undefined, + schema: undefined, + }); + expect(savedSqlImportTarget({ connectionId: "", database: "" }, undefined, "fallback_db")).toEqual({ + connectionId: "", + database: "fallback_db", + catalog: undefined, + schema: undefined, + }); + }); }); diff --git a/apps/desktop/src/lib/savedSql/savedSqlExecutionTarget.ts b/apps/desktop/src/lib/savedSql/savedSqlExecutionTarget.ts index 13e07edc1f..047824b2cc 100644 --- a/apps/desktop/src/lib/savedSql/savedSqlExecutionTarget.ts +++ b/apps/desktop/src/lib/savedSql/savedSqlExecutionTarget.ts @@ -1,4 +1,5 @@ import type { QueryTab, SavedSqlFile } from "@/types/database"; +import { extractCandidateDatabaseFromName } from "@/lib/savedSql/savedSqlFileName"; export type SavedSqlOpenTargetMode = "saved" | "current"; @@ -31,9 +32,24 @@ export function savedSqlExecutionTargetFromTab(tab: QueryTabTarget | undefined): }; } -export function resolveSavedSqlExecutionTarget(file: SavedSqlFileTarget, mode: SavedSqlOpenTargetMode, currentTarget?: SavedSqlExecutionTarget): SavedSqlExecutionTarget { +export function resolveSavedSqlExecutionTarget(file: SavedSqlFileTarget & { name?: string }, mode: SavedSqlOpenTargetMode, currentTarget?: SavedSqlExecutionTarget, fallbackDatabase?: string): SavedSqlExecutionTarget { if (mode === "current" && currentTarget) return { ...currentTarget }; - return savedSqlExecutionTargetFromFile(file); + const target = savedSqlExecutionTargetFromFile(file); + if (!target.database) { + if (currentTarget?.database && (!target.connectionId || target.connectionId === currentTarget.connectionId)) { + target.database = currentTarget.database; + if (!target.connectionId) target.connectionId = currentTarget.connectionId; + target.catalog ??= currentTarget.catalog; + target.schema ??= currentTarget.schema; + } else if (file.name) { + const candidate = extractCandidateDatabaseFromName(file.name); + if (candidate) target.database = candidate; + } + if (!target.database && fallbackDatabase) { + target.database = fallbackDatabase; + } + } + return target; } export function savedSqlDefaultTargetForWrite(currentTarget: SavedSqlExecutionTarget): SavedSqlFileTarget { diff --git a/apps/desktop/src/lib/savedSql/savedSqlFileName.ts b/apps/desktop/src/lib/savedSql/savedSqlFileName.ts index 30b78adde4..1e397ba6e2 100644 --- a/apps/desktop/src/lib/savedSql/savedSqlFileName.ts +++ b/apps/desktop/src/lib/savedSql/savedSqlFileName.ts @@ -51,3 +51,14 @@ export function nextAvailableSqlName(baseName: string, takenNames: ReadonlySet): ExternalSqlFileTarget { - if (!folder) return sourceTarget; +export function savedSqlImportTarget(sourceTarget: ExternalSqlFileTarget, folder?: Pick, fallbackDatabase?: string): ExternalSqlFileTarget { + if (!folder) { + return { + connectionId: sourceTarget.connectionId, + database: sourceTarget.database || fallbackDatabase || "", + catalog: sourceTarget.catalog, + schema: sourceTarget.schema, + }; + } return { connectionId: folder.connectionId, - database: sourceTarget.connectionId === folder.connectionId ? sourceTarget.database : "", + database: (sourceTarget.connectionId === folder.connectionId ? sourceTarget.database : "") || fallbackDatabase || "", catalog: sourceTarget.connectionId === folder.connectionId ? sourceTarget.catalog : undefined, schema: sourceTarget.connectionId === folder.connectionId ? sourceTarget.schema : undefined, }; diff --git a/apps/desktop/src/stores/__tests__/queryStore.openSavedSqlDatabaseAssociation.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.openSavedSqlDatabaseAssociation.spec.ts new file mode 100644 index 0000000000..6fd8d1dc02 --- /dev/null +++ b/apps/desktop/src/stores/__tests__/queryStore.openSavedSqlDatabaseAssociation.spec.ts @@ -0,0 +1,115 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useQueryStore } from "@/stores/queryStore"; +import { useConnectionStore } from "@/stores/connectionStore"; +import type { ConnectionConfig, SavedSqlFile } from "@/types/database"; + +const timestamp = "2026-09-01T00:00:00.000Z"; + +function installLocalStorage() { + const data = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => data.set(key, value), + removeItem: (key: string) => data.delete(key), + }); +} + +describe("queryStore openSavedSql automatic database association", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + installLocalStorage(); + setActivePinia(createPinia()); + }); + + it("associates candidate database from filename when file database is empty", () => { + const store = useQueryStore(); + const connStore = useConnectionStore(); + connStore.connections = [{ id: "conn-prod", name: "PROD", db_type: "mysql", database: "default_db" } as ConnectionConfig]; + + const file: SavedSqlFile = { + id: "saved-aisp", + connectionId: "conn-prod", + database: "", + name: "aisp_aikf - 全量呼入数据.sql", + sql: "SELECT 1;", + createdAt: timestamp, + updatedAt: timestamp, + }; + + const tabId = store.openSavedSql(file); + const tab = store.tabs.find((t) => t.id === tabId); + expect(tab).toBeDefined(); + expect(tab?.connectionId).toBe("conn-prod"); + expect(tab?.database).toBe("aisp_aikf"); + }); + + it("inherits current tab database when file database is empty and no prefix exists", () => { + const store = useQueryStore(); + const connStore = useConnectionStore(); + connStore.connections = [{ id: "conn-prod", name: "PROD", db_type: "mysql", database: "" } as ConnectionConfig]; + + // Create an active tab with connection and database + const activeTabId = store.createTab("conn-prod", "crm_active", "Active Tab", "query"); + store.activeTabId = activeTabId; + + const file: SavedSqlFile = { + id: "saved-plain", + connectionId: "conn-prod", + database: "", + name: "plain_query.sql", + sql: "SELECT 2;", + createdAt: timestamp, + updatedAt: timestamp, + }; + + const tabId = store.openSavedSql(file); + const tab = store.tabs.find((t) => t.id === tabId); + expect(tab).toBeDefined(); + expect(tab?.connectionId).toBe("conn-prod"); + expect(tab?.database).toBe("crm_active"); + }); + + it("falls back to connection default database when file database is empty and no active tab", () => { + const store = useQueryStore(); + const connStore = useConnectionStore(); + connStore.connections = [{ id: "conn-prod", name: "PROD", db_type: "mysql", database: "configured_default_db" } as ConnectionConfig]; + + const file: SavedSqlFile = { + id: "saved-plain-fallback", + connectionId: "conn-prod", + database: "", + name: "plain_query.sql", + sql: "SELECT 3;", + createdAt: timestamp, + updatedAt: timestamp, + }; + + const tabId = store.openSavedSql(file); + const tab = store.tabs.find((t) => t.id === tabId); + expect(tab).toBeDefined(); + expect(tab?.connectionId).toBe("conn-prod"); + expect(tab?.database).toBe("configured_default_db"); + }); + + it("preserves explicitly saved database on the file", () => { + const store = useQueryStore(); + const connStore = useConnectionStore(); + connStore.connections = [{ id: "conn-prod", name: "PROD", db_type: "mysql", database: "default_db" } as ConnectionConfig]; + + const file: SavedSqlFile = { + id: "saved-explicit", + connectionId: "conn-prod", + database: "explicit_db", + name: "aisp_aikf - query.sql", + sql: "SELECT 4;", + createdAt: timestamp, + updatedAt: timestamp, + }; + + const tabId = store.openSavedSql(file); + const tab = store.tabs.find((t) => t.id === tabId); + expect(tab?.database).toBe("explicit_db"); + }); +}); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index ac758830d4..206150f531 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -140,6 +140,7 @@ import type { SavedSqlFile } from "@/types/database"; import i18n, { currentLocale } from "@/i18n"; import { translateBackendError } from "@/i18n/backend-errors"; import type { SqlExecutionTargetContext } from "@/lib/database/sqlExecutionTargetRegistry"; +import { targetDefaultDatabase } from "@/lib/database/sqlExecutionTargetCapabilities"; import type { DriverProfileWorkspaceScope } from "@/lib/database/driverProfileExtensions"; import type { MultiDbExecutionTarget, MultiDbResultRunExecution } from "@/types/sqlExecution"; @@ -5457,8 +5458,12 @@ export const useQueryStore = defineStore("query", () => { function openSavedSql(file: SavedSqlFile, options: OpenSavedSqlOptions = {}) { const targetMode = options.targetMode ?? useSettingsStore().editorSettings.savedSqlOpenTargetMode; - const currentTarget = targetMode === "current" ? currentSavedSqlExecutionTarget() : undefined; - const target = resolveSavedSqlExecutionTarget(file, targetMode, currentTarget); + const currentTarget = currentSavedSqlExecutionTarget(); + const resolvedCurrent = targetMode === "current" ? currentTarget : undefined; + const effectiveConnectionId = file.connectionId || resolvedCurrent?.connectionId || currentTarget?.connectionId || ""; + const connection = effectiveConnectionId ? useConnectionStore().getConfig(effectiveConnectionId) : undefined; + const fallbackDb = connection ? targetDefaultDatabase(connection) || connection.database : undefined; + const target = resolveSavedSqlExecutionTarget(file, targetMode, resolvedCurrent ?? currentTarget, fallbackDb); const existing = tabs.value.find((tab) => tab.savedSqlId === file.id); if (existing) { persistSavedSqlEditorPosition(existing);