fix(saved-sql): 修复 SQL 库连接关联丢失与历史文件不可见
* fix: 持久化并显示 SQL 库连接关联 * fix: 补充 SQL 库关联状态翻译 * chore: 修复数据标签页策略格式 --------- Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
This commit is contained in:
parent
580b70e1d4
commit
445b6473f4
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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[]>(() => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -153,6 +153,8 @@ export default withEnglishFallback({
|
|||
},
|
||||
sqlLibrary: {
|
||||
title: "SQLライブラリ",
|
||||
unassociated: "未関連付け",
|
||||
deletedConnection: "削除された接続",
|
||||
openInCurrentDatabase: "現在のデータベースで開く",
|
||||
empty: "保存されたクエリはまだありません",
|
||||
emptyFolder: "空のフォルダ",
|
||||
|
|
|
|||
|
|
@ -148,6 +148,8 @@ export default withEnglishFallback({
|
|||
},
|
||||
sqlLibrary: {
|
||||
title: "SQL 라이브러리",
|
||||
unassociated: "연결되지 않음",
|
||||
deletedConnection: "삭제된 연결",
|
||||
openInCurrentDatabase: "현재 데이터베이스에서 열기",
|
||||
empty: "아직 저장된 쿼리가 없습니다",
|
||||
emptyFolder: "빈 폴더",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -153,6 +153,8 @@ export default withEnglishFallback({
|
|||
},
|
||||
sqlLibrary: {
|
||||
title: "SQL 库",
|
||||
unassociated: "未关联",
|
||||
deletedConnection: "连接已删除",
|
||||
openInCurrentDatabase: "在当前库中打开",
|
||||
empty: "暂无已保存的查询",
|
||||
emptyFolder: "空文件夹",
|
||||
|
|
|
|||
|
|
@ -153,6 +153,8 @@ export default withEnglishFallback({
|
|||
},
|
||||
sqlLibrary: {
|
||||
title: "SQL 庫",
|
||||
unassociated: "未關聯",
|
||||
deletedConnection: "連線已刪除",
|
||||
openInCurrentDatabase: "在目前資料庫中開啟",
|
||||
empty: "暫無已儲存的查詢",
|
||||
emptyFolder: "空資料夾",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue