mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 04:47:00 +00:00
feat(sql-library): auto-associate database and retarget folders
This commit is contained in:
@@ -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<string, SavedSqlFolder>();
|
||||
const takenNamesByScope = new Map<string, Set<string>>();
|
||||
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<SqlLibraryRow[]>(() => {
|
||||
@@ -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<CtxMenuItem[]>(() => {
|
||||
|
||||
// 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<CtxMenuItem[]>(() => {
|
||||
},
|
||||
];
|
||||
}
|
||||
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 },
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -51,3 +51,14 @@ export function nextAvailableSqlName(baseName: string, takenNames: ReadonlySet<s
|
||||
while (normalizedTakenNames.has(format(index).toLocaleLowerCase())) index++;
|
||||
return format(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a candidate database name from a filename or folder name when users name
|
||||
* files like `dbname - query.sql` or organize queries with database prefixes.
|
||||
*/
|
||||
export function extractCandidateDatabaseFromName(name: string): string | undefined {
|
||||
const stripped = stripSqlExtension(name).trim();
|
||||
const match = /^([a-zA-Z0-9_]+)\s*[-–—]\s*.+/.exec(stripped);
|
||||
if (match && match[1]) return match[1];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import type { SavedSqlFolder } from "@/types/database";
|
||||
import type { ExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
|
||||
|
||||
export function savedSqlImportTarget(sourceTarget: ExternalSqlFileTarget, folder?: Pick<SavedSqlFolder, "connectionId">): ExternalSqlFileTarget {
|
||||
if (!folder) return sourceTarget;
|
||||
export function savedSqlImportTarget(sourceTarget: ExternalSqlFileTarget, folder?: Pick<SavedSqlFolder, "connectionId">, 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,
|
||||
};
|
||||
|
||||
+115
@@ -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<string, string>();
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user