fix(saved-sql): 修复 SQL 库连接关联丢失与历史文件不可见

* fix: 持久化并显示 SQL 库连接关联

* fix: 补充 SQL 库关联状态翻译

* chore: 修复数据标签页策略格式

---------

Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
This commit is contained in:
zipg 2026-08-05 23:58:32 +08:00 committed by GitHub
parent 580b70e1d4
commit 445b6473f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 336 additions and 115 deletions

View File

@ -56,7 +56,7 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { openQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile";
import { rememberExternalSqlFileTarget, resolveExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
import { externalSqlFileOpenErrorMessage, readBrowserSqlFile, sqlFileTitleFromPath } from "@/lib/sql/sqlFileOpen";
import type { ConnectionConfig, ObjectSourceKind, QueryTab, SavedSqlFile } from "@/types/database";
import type { ConnectionConfig, ObjectSourceKind, QueryTab } from "@/types/database";
import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink";
import {
isBrowserReloadShortcut,
@ -848,16 +848,13 @@ async function saveExternalSqlPath(tab: QueryTab, options: { closeAfterSave?: bo
}
}
function savedSqlTargetForSave(tab: QueryTab, existing?: SavedSqlFile) {
return savedSqlDefaultTargetForWrite(
{
connectionId: tab.connectionId,
database: tab.database,
schema: tab.schema,
catalog: tab.catalog,
},
existing,
);
function savedSqlTargetForSave(tab: QueryTab) {
return savedSqlDefaultTargetForWrite({
connectionId: tab.connectionId,
database: tab.database,
schema: tab.schema,
catalog: tab.catalog,
});
}
async function saveTabForCloseAll(tabId: string): Promise<boolean> {
@ -876,7 +873,7 @@ async function saveTabForCloseAll(tabId: string): Promise<boolean> {
if (await saveExternalSqlPath(tab)) return !queryStore.isTabDirty(tab);
const existing = tab.savedSqlId ? savedSqlStore.getFile(tab.savedSqlId) : undefined;
const target = savedSqlTargetForSave(tab, existing);
const target = savedSqlTargetForSave(tab);
try {
const saved = await savedSqlStore.saveFile({
id: existing?.id,
@ -943,7 +940,7 @@ async function handleSaveTab(tabId: string) {
}
const existing = tab.savedSqlId ? savedSqlStore.getFile(tab.savedSqlId) : undefined;
if (existing) {
const target = savedSqlTargetForSave(tab, existing);
const target = savedSqlTargetForSave(tab);
const updated = await savedSqlStore.saveFile({
id: existing.id,
connectionId: target.connectionId,
@ -979,7 +976,7 @@ async function openSaveSqlDialog() {
if (await saveExternalSqlPath(tab)) return;
const existing = tab.savedSqlId ? savedSqlStore.getFile(tab.savedSqlId) : undefined;
if (existing) {
const target = savedSqlTargetForSave(tab, existing);
const target = savedSqlTargetForSave(tab);
const updated = await savedSqlStore.saveFile({
id: existing.id,
connectionId: target.connectionId,
@ -1073,8 +1070,7 @@ async function confirmSaveSqlToLibrary() {
const name = saveSqlName.value.trim();
if (!tab || !tab.sql.trim() || !name) return;
try {
const existing = tab.savedSqlId ? savedSqlStore.getFile(tab.savedSqlId) : undefined;
const target = savedSqlTargetForSave(tab, existing);
const target = savedSqlTargetForSave(tab);
const saved = await savedSqlStore.saveFile({
id: tab.savedSqlId,
connectionId: target.connectionId,

View File

@ -41,18 +41,14 @@ type DropPosition = "before" | "after" | "inside";
const activeConnectionIds = computed(() => new Set(connectionStore.connections.map((c) => c.id)));
const searchText = ref("");
const searchQuery = computed(() => searchText.value.trim().toLowerCase());
const orphanedIds = computed(() => savedSqlStore.orphanedFileIds(activeConnectionIds.value));
// Sort mode: "folder" (default tree structure) or "date" (flat list by update date)
const sortMode = ref<"folder" | "date">("folder");
function isConnectionVisible(connectionId: string) {
return activeConnectionIds.value.has(connectionId);
}
function getConnectionLabel(connectionId: string) {
if (!connectionId) return t("sqlLibrary.unassociated");
const conn = connectionStore.connections.find((c) => c.id === connectionId);
return conn?.name || connectionId;
return conn?.name || t("sqlLibrary.deletedConnection");
}
function folderPath(folder: SavedSqlFolder) {
@ -180,13 +176,13 @@ async function exportFolderContents(folder?: SavedSqlFolder) {
if (folder) {
await writeFolder(folder, rootDir);
} else {
for (const libraryFolder of savedSqlStore.allFolders.filter((item) => isConnectionVisible(item.connectionId) && !item.parentFolderId)) {
for (const libraryFolder of savedSqlStore.allFolders.filter((item) => !item.parentFolderId)) {
const folderDir = await join(rootDir, sanitizeFileSystemSegment(libraryFolder.name));
await mkdir(folderDir, { recursive: true });
await writeFolder(libraryFolder, folderDir);
}
const unfiled = savedSqlStore.filesWithoutFolder().filter((file) => !orphanedIds.value.has(file.id));
const unfiled = savedSqlStore.filesWithoutFolder();
if (unfiled.length > 0) {
const unfiledDir = await join(rootDir, sanitizeFileSystemSegment(t("sqlLibrary.unfiled")));
await mkdir(unfiledDir, { recursive: true });
@ -239,7 +235,7 @@ async function importDirectoryIntoLibrary(targetFolder?: SavedSqlFolder) {
return;
}
const takenNames = new Set((targetFolder ? savedSqlStore.filesInFolder(targetFolder.id) : savedSqlStore.filesWithoutFolder()).filter((file) => !orphanedIds.value.has(file.id)).map((file) => file.name));
const takenNames = new Set((targetFolder ? savedSqlStore.filesInFolder(targetFolder.id) : savedSqlStore.filesWithoutFolder()).map((file) => file.name));
for (const path of sqlPaths) {
const content = await api.readExternalSqlFile(path);
@ -320,11 +316,11 @@ function folderMatchesQuery(folder: SavedSqlFolder) {
const q = searchQuery.value;
if (!q) return true;
if (folder.name.toLowerCase().includes(q)) return true;
return savedSqlStore.filesInFolder(folder.id).some((file) => !orphanedIds.value.has(file.id) && fileMatchesQuery(file));
return savedSqlStore.filesInFolder(folder.id).some((file) => fileMatchesQuery(file));
}
function childFolders(parentFolderId?: string) {
return savedSqlStore.allFolders.filter((folder) => isConnectionVisible(folder.connectionId) && (folder.parentFolderId || "") === (parentFolderId || ""));
return savedSqlStore.allFolders.filter((folder) => (folder.parentFolderId || "") === (parentFolderId || ""));
}
function descendantFolders(parentFolderId: string): SavedSqlFolder[] {
@ -340,15 +336,11 @@ function folderBranchMatchesQuery(folder: SavedSqlFolder) {
function filesInFolder(folderId: string) {
const folder = savedSqlStore.allFolders.find((item) => item.id === folderId);
const includeAllFilesForMatchedFolder = !!folder && !!searchQuery.value && folder.name.toLowerCase().includes(searchQuery.value);
return savedSqlStore
.filesInFolder(folderId)
.filter((file) => !orphanedIds.value.has(file.id))
.filter((file) => includeAllFilesForMatchedFolder || fileMatchesQuery(file));
return savedSqlStore.filesInFolder(folderId).filter((file) => includeAllFilesForMatchedFolder || fileMatchesQuery(file));
}
function folderFileCount(folderId: string) {
const visibleFolders = savedSqlStore.allFolders.filter((folder) => isConnectionVisible(folder.connectionId));
return savedSqlFolderBranchFileCount(folderId, visibleFolders, filesInFolder);
return savedSqlFolderBranchFileCount(folderId, savedSqlStore.allFolders, filesInFolder);
}
type SqlLibraryRow = { type: "folder"; folder: SavedSqlFolder; depth: number; folderIndex: number } | { type: "file"; file: SavedSqlFile; depth: number };
@ -373,19 +365,11 @@ const visibleFolderRows = computed<SqlLibraryRow[]>(() => {
return rows;
});
const visibleFiles = computed(() =>
savedSqlStore
.filesWithoutFolder()
.filter((file) => !orphanedIds.value.has(file.id))
.filter((file) => fileMatchesQuery(file)),
);
const visibleFiles = computed(() => savedSqlStore.filesWithoutFolder().filter((file) => fileMatchesQuery(file)));
// Flat list sorted by updatedAt (descending) - combines all folders and files
const itemsByDate = computed(() => {
const allFolders = savedSqlStore.allFolders
.filter((folder) => isConnectionVisible(folder.connectionId))
.filter((folder) => folderBranchMatchesQuery(folder))
.map((folder) => ({ type: "folder" as const, item: folder, updatedAt: folder.updatedAt }));
const allFolders = savedSqlStore.allFolders.filter((folder) => folderBranchMatchesQuery(folder)).map((folder) => ({ type: "folder" as const, item: folder, updatedAt: folder.updatedAt }));
const allFiles = [...savedSqlStore.allFolders.flatMap((folder) => filesInFolder(folder.id)), ...visibleFiles.value].map((file) => ({ type: "file" as const, item: file, updatedAt: file.updatedAt }));
@ -429,14 +413,7 @@ async function openNewQueryInFolder(folder?: SavedSqlFolder) {
const connectionId = folder?.connectionId || connectionStore.activeConnectionId || connectionStore.connections[0]?.id;
if (!connectionId) return;
const takenNames = folder
? new Set(savedSqlStore.filesInFolder(folder.id).map((f) => f.name))
: new Set(
savedSqlStore
.filesWithoutFolder()
.filter((file) => !orphanedIds.value.has(file.id))
.map((f) => f.name),
);
const takenNames = folder ? new Set(savedSqlStore.filesInFolder(folder.id).map((f) => f.name)) : new Set(savedSqlStore.filesWithoutFolder().map((f) => f.name));
const name = uniqueImportedName("new_query.sql", takenNames);
const file = await savedSqlStore.saveFile({
connectionId,
@ -805,18 +782,16 @@ const contextTarget = ref<SavedSqlFolder | SavedSqlFile | "panel" | null>(null);
function folderMoveMenuItems(fileIds: string[]): CtxMenuItem[] {
const files = [...new Set(fileIds)].map((id) => savedSqlStore.getFile(id)).filter((file): file is SavedSqlFile => Boolean(file));
const allInUnfiled = files.length > 0 && files.every((file) => !file.folderId);
const folderItems = savedSqlStore.allFoldersTreeOrder
.filter((folder) => isConnectionVisible(folder.connectionId))
.map((folder) => ({
label: folderPath(folder),
action: () =>
moveFilesToFolder(
files.map((file) => file.id),
folder.id,
),
disabled: files.every((file) => file.folderId === folder.id),
icon: FolderClosed,
}));
const folderItems = savedSqlStore.allFoldersTreeOrder.map((folder) => ({
label: folderPath(folder),
action: () =>
moveFilesToFolder(
files.map((file) => file.id),
folder.id,
),
disabled: files.every((file) => file.folderId === folder.id),
icon: FolderClosed,
}));
return [
{

View File

@ -30,7 +30,6 @@ vi.mock("@/lib/sqlFile/sqlFileFolders", async () => {
function emptySavedSqlStore() {
return {
allFiles: [] as any[],
orphanedFileIds: vi.fn().mockReturnValue(new Set<string>()),
getFile: vi.fn().mockReturnValue(undefined),
};
}
@ -661,7 +660,6 @@ describe("useQuickOpen", () => {
const fileMap = new Map(files.map((f) => [f.id, f]));
return {
allFiles: files,
orphanedFileIds: vi.fn().mockReturnValue(new Set<string>()),
getFile: vi.fn().mockImplementation((id: string) => fileMap.get(id)),
};
}
@ -713,7 +711,7 @@ describe("useQuickOpen", () => {
expect(sqlItems[0].sqlFileId).toBe("f1");
});
it("excludes orphaned SQL library files", () => {
it("includes SQL library files whose connection was deleted", () => {
const mockConnStore = {
connections: [{ id: "conn1", name: "Active", type: "mssql" }],
treeNodes: [],
@ -724,16 +722,24 @@ describe("useQuickOpen", () => {
{ id: "f1", name: "active_query.sql", connectionId: "conn1", updatedAt: "2024-01-01T00:00:00.000Z" },
{ id: "f2", name: "orphaned_query.sql", connectionId: "deleted_conn", updatedAt: "2024-01-02T00:00:00.000Z" },
];
const store = savedSqlStoreWithFiles(files);
store.orphanedFileIds = vi.fn().mockReturnValue(new Set(["f2"]));
vi.mocked(useSavedSqlStore).mockReturnValue(store as any);
vi.mocked(useSavedSqlStore).mockReturnValue(savedSqlStoreWithFiles(files) as any);
const { filteredItems, setQuery } = useQuickOpen();
setQuery("query");
const sqlItems = filteredItems.value.filter((item) => item.type === "sql_library_file");
expect(sqlItems).toHaveLength(1);
expect(sqlItems[0].label).toBe("active_query.sql");
expect(sqlItems).toHaveLength(2);
expect(sqlItems.find((item) => item.label === "orphaned_query.sql")?.description).toBe("Connection deleted");
});
it("labels SQL library files without a connection as unassociated", () => {
vi.mocked(useConnectionStore).mockReturnValue({ connections: [], treeNodes: [] } as any);
vi.mocked(useSavedSqlStore).mockReturnValue(savedSqlStoreWithFiles([{ id: "f1", name: "draft.sql", connectionId: "", updatedAt: "2024-01-01T00:00:00.000Z" }]) as any);
const { filteredItems, setQuery } = useQuickOpen();
setQuery("draft");
expect(filteredItems.value.find((item) => item.type === "sql_library_file")?.description).toBe("Unassociated");
});
});

View File

@ -7,6 +7,7 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore";
import * as api from "@/lib/backend/api";
import type { SqlFileEntry } from "@/lib/backend/api";
import { getSqlFileFolderPaths, sqlFileFoldersVersion } from "@/lib/sqlFile/sqlFileFolders";
import i18n from "@/i18n";
const REMOTE_SEARCH_DEBOUNCE_MS = 180;
const REMOTE_SEARCH_MIN_QUERY_LENGTH = 2;
@ -118,25 +119,22 @@ export function useQuickOpen() {
let sqlFilesLoadGeneration = 0;
function getConnectionLabel(connectionId: string): string {
if (!connectionId) return i18n.global.t("sqlLibrary.unassociated");
const conn = connectionStore.connections.find((c) => c.id === connectionId);
return conn?.name || connectionId;
return conn?.name || i18n.global.t("sqlLibrary.deletedConnection");
}
const sqlLibraryAllItems = computed<QuickOpenItem[]>(() => {
const activeConnectionIds = new Set(connectionStore.connections.map((c) => c.id));
const orphanedIds = savedSqlStore.orphanedFileIds(activeConnectionIds);
return savedSqlStore.allFiles
.filter((file) => !orphanedIds.has(file.id))
.map((file) => ({
id: `sqllib-${file.id}`,
type: "sql_library_file" as const,
label: file.name,
description: getConnectionLabel(file.connectionId),
connectionId: file.connectionId,
connectionName: getConnectionLabel(file.connectionId),
sqlFileId: file.id,
searchText: `${file.name} ${getConnectionLabel(file.connectionId)}`,
}));
return savedSqlStore.allFiles.map((file) => ({
id: `sqllib-${file.id}`,
type: "sql_library_file" as const,
label: file.name,
description: getConnectionLabel(file.connectionId),
connectionId: file.connectionId,
connectionName: getConnectionLabel(file.connectionId),
sqlFileId: file.id,
searchText: `${file.name} ${getConnectionLabel(file.connectionId)}`,
}));
});
const sqlLibraryRecentItems = computed<QuickOpenItem[]>(() => {

View File

@ -151,6 +151,8 @@ export default {
},
sqlLibrary: {
title: "SQL Library",
unassociated: "Unassociated",
deletedConnection: "Connection deleted",
openInCurrentDatabase: "Open in Current Database",
empty: "No saved queries yet",
emptyFolder: "Empty folder",

View File

@ -153,6 +153,8 @@ export default withEnglishFallback({
},
sqlLibrary: {
title: "Biblioteca SQL",
unassociated: "Sin asociación",
deletedConnection: "Conexión eliminada",
openInCurrentDatabase: "Abrir en la base de datos actual",
empty: "Sin consultas guardadas",
emptyFolder: "Carpeta vacía",

View File

@ -152,6 +152,8 @@ export default withEnglishFallback({
},
sqlLibrary: {
title: "Libreria SQL",
unassociated: "Non associato",
deletedConnection: "Connessione eliminata",
openInCurrentDatabase: "Apri nel database corrente",
empty: "Nessuna query salvata",
emptyFolder: "Cartella vuota",

View File

@ -153,6 +153,8 @@ export default withEnglishFallback({
},
sqlLibrary: {
title: "SQLライブラリ",
unassociated: "未関連付け",
deletedConnection: "削除された接続",
openInCurrentDatabase: "現在のデータベースで開く",
empty: "保存されたクエリはまだありません",
emptyFolder: "空のフォルダ",

View File

@ -148,6 +148,8 @@ export default withEnglishFallback({
},
sqlLibrary: {
title: "SQL 라이브러리",
unassociated: "연결되지 않음",
deletedConnection: "삭제된 연결",
openInCurrentDatabase: "현재 데이터베이스에서 열기",
empty: "아직 저장된 쿼리가 없습니다",
emptyFolder: "빈 폴더",

View File

@ -153,6 +153,8 @@ export default withEnglishFallback({
},
sqlLibrary: {
title: "Biblioteca SQL",
unassociated: "Sem associação",
deletedConnection: "Conexão excluída",
openInCurrentDatabase: "Abrir no banco de dados atual",
empty: "Nenhuma consulta salva",
emptyFolder: "Pasta vazia",

View File

@ -153,6 +153,8 @@ export default withEnglishFallback({
},
sqlLibrary: {
title: "SQL 库",
unassociated: "未关联",
deletedConnection: "连接已删除",
openInCurrentDatabase: "在当前库中打开",
empty: "暂无已保存的查询",
emptyFolder: "空文件夹",

View File

@ -153,6 +153,8 @@ export default withEnglishFallback({
},
sqlLibrary: {
title: "SQL 庫",
unassociated: "未關聯",
deletedConnection: "連線已刪除",
openInCurrentDatabase: "在目前資料庫中開啟",
empty: "暫無已儲存的查詢",
emptyFolder: "空資料夾",

View File

@ -40,16 +40,17 @@ describe("saved SQL execution targets", () => {
expect(savedSqlExecutionTargetFromTab(undefined)).toBeUndefined();
});
it("preserves the saved default target when an existing file is updated", () => {
it("uses the current execution target when a file is saved", () => {
expect(
savedSqlDefaultTargetForWrite(
{
connectionId: "runtime-connection",
database: "runtime_database",
schema: "runtime_schema",
},
savedTarget,
),
).toEqual(savedTarget);
savedSqlDefaultTargetForWrite({
connectionId: "runtime-connection",
database: "runtime_database",
schema: "runtime_schema",
}),
).toEqual({
connectionId: "runtime-connection",
database: "runtime_database",
schema: "runtime_schema",
});
});
});

View File

@ -35,8 +35,7 @@ export function resolveSavedSqlExecutionTarget(file: SavedSqlFileTarget, mode: S
return savedSqlExecutionTargetFromFile(file);
}
export function savedSqlDefaultTargetForWrite(currentTarget: SavedSqlExecutionTarget, existingFile?: SavedSqlFileTarget): SavedSqlFileTarget {
if (existingFile) return savedSqlExecutionTargetFromFile(existingFile);
export function savedSqlDefaultTargetForWrite(currentTarget: SavedSqlExecutionTarget): SavedSqlFileTarget {
return {
connectionId: currentTarget.connectionId,
database: currentTarget.database,

View File

@ -103,6 +103,10 @@ interface OpenSavedSqlOptions {
targetMode?: SavedSqlOpenTargetMode;
}
interface UpdateExecutionTargetOptions {
persistSavedSqlTarget?: boolean;
}
type DroppedTableObjectType = "TABLE" | "VIEW" | "MATERIALIZED_VIEW";
interface DroppedTableObjectTarget {
@ -2568,12 +2572,13 @@ export const useQueryStore = defineStore("query", () => {
}
function applySavedSqlExecutionTarget(tab: QueryTab, target: SavedSqlExecutionTarget) {
updateConnection(tab.id, target.connectionId, target.database);
const options = { persistSavedSqlTarget: false };
updateConnection(tab.id, target.connectionId, target.database, options);
if (tab.catalog !== target.catalog || tab.database !== target.database) {
if (tab.catalog !== undefined || target.catalog !== undefined) updateCatalog(tab.id, target.catalog, target.database);
else updateDatabase(tab.id, target.database);
if (tab.catalog !== undefined || target.catalog !== undefined) updateCatalog(tab.id, target.catalog, target.database, options);
else updateDatabase(tab.id, target.database, options);
}
updateSchema(tab.id, target.schema);
updateSchema(tab.id, target.schema, options);
}
function openSavedSql(file: SavedSqlFile, options: OpenSavedSqlOptions = {}) {
@ -2653,7 +2658,19 @@ export const useQueryStore = defineStore("query", () => {
tabs.value = orderPinnedFirst(tabs.value, (item) => !!item.pinned);
}
function updateDatabase(id: string, database: string) {
function persistSavedSqlExecutionTarget(tab: QueryTab, options: UpdateExecutionTargetOptions) {
if (options.persistSavedSqlTarget === false || tab.mode !== "query" || !tab.savedSqlId) return;
const savedSqlStore = useSavedSqlStore();
void savedSqlStore
.updateFileExecutionTarget(tab.savedSqlId, {
connectionId: tab.connectionId,
database: tab.database,
schema: tab.schema,
})
.catch((error) => console.warn("[DBX][saved-sql:target:error]", error));
}
function updateDatabase(id: string, database: string, options: UpdateExecutionTargetOptions = {}) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab || tab.database === database) return;
rollbackTabTransaction(tab);
@ -2668,9 +2685,10 @@ export const useQueryStore = defineStore("query", () => {
tab.resultSortedSql = undefined;
clearExplain(tab);
tab.tableMeta = undefined;
persistSavedSqlExecutionTarget(tab, options);
}
function updateCatalog(id: string, catalog: string | undefined, database: string) {
function updateCatalog(id: string, catalog: string | undefined, database: string, options: UpdateExecutionTargetOptions = {}) {
const tab = tabs.value.find((candidate) => candidate.id === id);
if (!tab || (tab.catalog === catalog && tab.database === database)) return;
rollbackTabTransaction(tab);
@ -2686,9 +2704,10 @@ export const useQueryStore = defineStore("query", () => {
tab.resultSortedSql = undefined;
clearExplain(tab);
tab.tableMeta = undefined;
persistSavedSqlExecutionTarget(tab, options);
}
function updateSchema(id: string, schema: string | undefined) {
function updateSchema(id: string, schema: string | undefined, options: UpdateExecutionTargetOptions = {}) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab || tab.schema === schema) return;
rollbackTabTransaction(tab);
@ -2703,9 +2722,10 @@ export const useQueryStore = defineStore("query", () => {
}
tab.schema = schema;
if (tab.mode === "objects") tab.objectBrowser = { ...tab.objectBrowser, schema, viewport: undefined };
persistSavedSqlExecutionTarget(tab, options);
}
function updateConnection(id: string, connectionId: string, database = "") {
function updateConnection(id: string, connectionId: string, database = "", options: UpdateExecutionTargetOptions = {}) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab || tab.connectionId === connectionId) return;
rollbackTabTransaction(tab, { resetAutoCommit: true });
@ -2723,6 +2743,7 @@ export const useQueryStore = defineStore("query", () => {
tab.resultSortedSql = undefined;
clearExplain(tab);
tab.tableMeta = undefined;
persistSavedSqlExecutionTarget(tab, options);
}
function clearInvalidDataTabSortState(tab: QueryTab, columns: NonNullable<QueryTab["tableMeta"]>["columns"]): boolean {

View File

@ -25,6 +25,12 @@ interface SaveFileInput {
sql: string;
}
interface SavedSqlExecutionTargetInput {
connectionId: string;
database: string;
schema?: string;
}
function nowIso() {
return new Date().toISOString();
}
@ -95,6 +101,9 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
let pendingSync: Promise<void> | null = null;
let initFromStoragePromise: Promise<void> | null = null;
const pendingFolderCreates = new Map<string, Promise<SavedSqlFolder>>();
const fileTargetRevisions = new Map<string, number>();
const pendingFileTargetSaves = new Map<string, Promise<SavedSqlFile | undefined>>();
const persistedFileTargets = new Map<string, SavedSqlExecutionTargetInput & { updatedAt: string }>();
const version = ref(0);
function bumpVersion() {
@ -256,6 +265,79 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
return saved;
}
function updateFileExecutionTarget(id: string, target: SavedSqlExecutionTargetInput): Promise<SavedSqlFile | undefined> {
const existing = getFile(id);
if (!existing) return Promise.resolve(undefined);
if (existing.connectionId === target.connectionId && existing.database === target.database && existing.schema === target.schema) {
return Promise.resolve(existing);
}
if (!persistedFileTargets.has(id)) {
persistedFileTargets.set(id, {
connectionId: existing.connectionId,
database: existing.database,
schema: existing.schema,
updatedAt: existing.updatedAt,
});
}
const revision = (fileTargetRevisions.get(id) ?? 0) + 1;
fileTargetRevisions.set(id, revision);
files.value = files.value.map((file) => (file.id === id ? { ...file, ...target, updatedAt: nowIso() } : file));
bumpVersion();
const previousSave = pendingFileTargetSaves.get(id) ?? Promise.resolve(undefined);
const save = previousSave
.catch(() => undefined)
.then(async () => {
if (fileTargetRevisions.get(id) !== revision) return getFile(id);
const loaded = await ensureFileContent(id);
if (!loaded || fileTargetRevisions.get(id) !== revision) return getFile(id);
const candidate: SavedSqlFile = {
...loaded,
...target,
sqlLoaded: true,
updatedAt: nowIso(),
};
files.value = files.value.map((file) => (file.id === id ? candidate : file));
bumpVersion();
try {
const saved = await api.saveSavedSqlFile(candidate);
persistedFileTargets.set(id, {
connectionId: saved.connectionId,
database: saved.database,
schema: saved.schema,
updatedAt: saved.updatedAt,
});
if (fileTargetRevisions.get(id) !== revision) return getFile(id);
const current = getFile(id);
const persisted = { ...saved, sql: current?.sql ?? saved.sql, sqlLoaded: current?.sqlLoaded ?? true };
files.value = files.value.map((file) => (file.id === id ? persisted : file));
bumpVersion();
await syncToLocalDirectory();
return persisted;
} catch (error) {
if (fileTargetRevisions.get(id) === revision) {
const persistedTarget = persistedFileTargets.get(id);
if (persistedTarget) files.value = files.value.map((file) => (file.id === id ? { ...file, ...persistedTarget } : file));
bumpVersion();
}
throw error;
}
});
pendingFileTargetSaves.set(id, save);
const cleanup = () => {
if (pendingFileTargetSaves.get(id) !== save) return;
pendingFileTargetSaves.delete(id);
persistedFileTargets.delete(id);
};
void save.then(cleanup, cleanup);
return save;
}
async function renameFile(id: string, name: string) {
const existing = getFile(id);
if (!existing) return;
@ -555,10 +637,6 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
return allFiles.value.filter((f) => !f.folderId);
}
function orphanedFileIds(activeConnectionIds: Set<string>) {
return new Set(files.value.filter((f) => !activeConnectionIds.has(f.connectionId)).map((f) => f.id));
}
return {
folders,
files,
@ -574,6 +652,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
renameFolder,
deleteFolder,
saveFile,
updateFileExecutionTarget,
renameFile,
recordFileUsage,
deleteFile,
@ -588,6 +667,5 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
allFiles,
filesInFolder,
filesWithoutFolder,
orphanedFileIds,
};
});

View File

@ -441,7 +441,7 @@ test("hydrating saved SQL content preserves its restored runtime target", async
}
});
test("changing a saved SQL tab target does not rebind its saved default", async () => {
test("changing a saved SQL tab target updates its saved default", async () => {
const restoreStorage = installMemoryStorage();
try {
setActivePinia(createPinia());
@ -466,9 +466,11 @@ test("changing a saved SQL tab target does not rebind its saved default", async
store.updateConnection(tabId, "runtime-connection", "runtime_database");
store.updateSchema(tabId, "runtime_schema");
assert.equal(savedSqlStore.getFile(file.id)?.connectionId, "saved-connection");
assert.equal(savedSqlStore.getFile(file.id)?.database, "saved_database");
assert.equal(savedSqlStore.getFile(file.id)?.schema, "saved_schema");
await waitFor(() => savedSqlStore.getFile(file.id)?.schema === "runtime_schema");
assert.equal(savedSqlStore.getFile(file.id)?.connectionId, "runtime-connection");
assert.equal(savedSqlStore.getFile(file.id)?.database, "runtime_database");
assert.equal(savedSqlStore.getFile(file.id)?.schema, "runtime_schema");
} finally {
await nextTick();
restoreStorage();

View File

@ -136,6 +136,135 @@ test("saved SQL summaries load file content on demand", async () => {
assert.equal(apiMock.loadSavedSqlFile.mock.calls.length, 1);
});
test("changing a saved SQL execution target persists the latest target", async () => {
const file: SavedSqlFile = {
id: "sql-target",
connectionId: "conn-1",
name: "query.sql",
database: "db-1",
schema: "public",
sql: "SELECT 1;",
sqlLoaded: true,
createdAt: "2026-06-27T00:00:00.000Z",
updatedAt: "2026-06-27T00:00:00.000Z",
};
apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files: [file] });
const store = useSavedSqlStore();
await store.initFromStorage();
await store.updateFileExecutionTarget("sql-target", {
connectionId: "conn-2",
database: "db-2",
schema: "app",
});
const saved = apiMock.saveSavedSqlFile.mock.calls.at(-1)?.[0];
assert.equal(saved?.connectionId, "conn-2");
assert.equal(saved?.database, "db-2");
assert.equal(saved?.schema, "app");
assert.equal(saved?.sql, file.sql);
assert.equal(saved?.name, file.name);
assert.deepEqual(
{
connectionId: store.getFile("sql-target")?.connectionId,
database: store.getFile("sql-target")?.database,
schema: store.getFile("sql-target")?.schema,
},
{ connectionId: "conn-2", database: "db-2", schema: "app" },
);
});
test("rapid saved SQL target changes persist only the latest target", async () => {
const file: SavedSqlFile = {
id: "sql-target",
connectionId: "conn-1",
name: "query.sql",
database: "db-1",
sql: "SELECT 1;",
sqlLoaded: true,
createdAt: "2026-06-27T00:00:00.000Z",
updatedAt: "2026-06-27T00:00:00.000Z",
};
apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files: [file] });
apiMock.saveSavedSqlFile.mockImplementation(async (value) => value);
const store = useSavedSqlStore();
await store.initFromStorage();
const first = store.updateFileExecutionTarget("sql-target", { connectionId: "conn-2", database: "db-2" });
const second = store.updateFileExecutionTarget("sql-target", { connectionId: "conn-3", database: "db-3" });
await Promise.all([first, second]);
assert.equal(apiMock.saveSavedSqlFile.mock.calls.length, 1);
assert.equal(apiMock.saveSavedSqlFile.mock.calls.at(-1)?.[0].connectionId, "conn-3");
assert.equal(apiMock.saveSavedSqlFile.mock.calls.at(-1)?.[0].database, "db-3");
assert.equal(store.getFile("sql-target")?.connectionId, "conn-3");
});
test("saved SQL target changes roll back when persistence fails", async () => {
const file: SavedSqlFile = {
id: "sql-target",
connectionId: "conn-1",
name: "query.sql",
database: "db-1",
schema: "public",
sql: "SELECT 1;",
sqlLoaded: true,
createdAt: "2026-06-27T00:00:00.000Z",
updatedAt: "2026-06-27T00:00:00.000Z",
};
apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files: [file] });
apiMock.saveSavedSqlFile.mockRejectedValueOnce(new Error("disk full"));
const store = useSavedSqlStore();
await store.initFromStorage();
await assert.rejects(store.updateFileExecutionTarget("sql-target", { connectionId: "conn-2", database: "db-2", schema: "app" }), /disk full/);
assert.deepEqual(
{
connectionId: store.getFile("sql-target")?.connectionId,
database: store.getFile("sql-target")?.database,
schema: store.getFile("sql-target")?.schema,
},
{ connectionId: "conn-1", database: "db-1", schema: "public" },
);
});
test("empty and deleted-connection SQL files remain in the library", async () => {
const files: SavedSqlFile[] = [
{
id: "unassociated",
connectionId: "",
name: "unassociated.sql",
database: "",
sql: "SELECT 1;",
sqlLoaded: true,
createdAt: "2026-06-27T00:00:00.000Z",
updatedAt: "2026-06-27T00:00:00.000Z",
},
{
id: "deleted",
connectionId: "deleted-connection",
name: "deleted.sql",
database: "",
sql: "SELECT 2;",
sqlLoaded: true,
createdAt: "2026-06-27T00:00:00.000Z",
updatedAt: "2026-06-27T00:00:00.000Z",
},
];
apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files });
const store = useSavedSqlStore();
await store.initFromStorage();
assert.deepEqual(
store.allFiles.map((item) => item.id),
["deleted", "unassociated"],
);
});
test("saving an existing SQL file without folderId keeps its folder", async () => {
const file: SavedSqlFile = {
id: "sql-1",