fix(saved-sql): scope SQL names and guard target persistence

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-17 22:35:56 +08:00
1 parent d1a2d51351
commit 58c1435d55
8 files changed
+715 -73

No files matched your search

+6
View File
@@ -2743,18 +2743,23 @@ async function changeActiveConnection(tabId: string, connectionId: string) {
if (!connection) return;
const initialDatabase = resolveDefaultDatabase(connection, []);
queryStore.updateConnection(tab.id, connectionId, initialDatabase);
let isCurrentTarget = queryStore.createExecutionTargetGuard(tab.id);
if (tab.externalSqlPath) rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId, database: initialDatabase, catalog: undefined, schema: undefined });
connectionStore.activeConnectionId = connectionId;
try {
await connectionStore.ensureConnected(connectionId);
if (!isCurrentTarget()) return;
const options = await getDatabaseOptions(connectionId);
if (!isCurrentTarget()) return;
const database = resolveDefaultDatabase(connection, options);
queryStore.updateDatabase(tab.id, database);
isCurrentTarget = queryStore.createExecutionTargetGuard(tab.id);
if (tab.externalSqlPath) rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId, database, catalog: undefined, schema: undefined });
if (connection.default_schema || connection.db_type === "oracle") {
try {
// A configured default wins. Otherwise Oracle returns the session's current schema first.
const orderedSchemas = connection.default_schema ? [] : await api.listSchemas(connectionId, database);
if (!isCurrentTarget()) return;
const schema = schemaAfterConnectionSwitch(connection.db_type, orderedSchemas, connection.default_schema);
const latestTab = queryStore.tabs.find((candidate) => candidate.id === tab.id);
if (schema && latestTab && latestTab.connectionId === connectionId) {
@@ -2766,6 +2771,7 @@ async function changeActiveConnection(tabId: string, connectionId: string) {
}
}
} catch (e: any) {
if (!isCurrentTarget()) return;
toast(
t("connection.connectFailed", {
message: translateBackendError(t, e),
@@ -21,10 +21,11 @@ import { focusSidebarRenameInput } from "@/lib/sidebar/sidebarRenameFocus";
import { savedSqlFolderBranchFileCount } from "@/lib/savedSql/savedSqlFolderCounts";
import { collectSavedSqlDirectoryImportFiles } from "@/lib/savedSql/savedSqlDirectoryImport";
import { savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors";
import { savedSqlDatabaseScopeKey } from "@/lib/savedSql/savedSqlDatabaseTree";
import { ensureSqlExtension, stripSqlExtension } from "@/lib/savedSql/savedSqlFileName";
import { savedSqlImportTarget } from "@/lib/savedSql/savedSqlImportTarget";
import { savedSqlExecutionTargetFromTab, type SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTarget";
import { exportSavedSqlFileContent } from "@/lib/savedSql/savedSqlExport";
import { uniqueSavedSqlExportFileName, exportSavedSqlFileContent } from "@/lib/savedSql/savedSqlExport";
import { orderedListRangeAnchorIndex, orderedListSelectionIntent } from "@/lib/selection/orderedListSelection";
import { resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
import type { SavedSqlFile, SavedSqlFolder } from "@/types/database";
@@ -96,6 +97,10 @@ function uniqueImportedName(name: string, takenNames: Set<string>) {
}
}
function savedSqlImportNameScopeKey(target: Pick<SavedSqlFile, "connectionId" | "catalog" | "database">, folderId?: string) {
return JSON.stringify([savedSqlDatabaseScopeKey(target), folderId || null]);
}
async function exportSingleFile(file: SavedSqlFile) {
try {
const loadedFile = await savedSqlStore.ensureFileContent(file.id);
@@ -136,10 +141,11 @@ async function exportFolderContents(folder?: SavedSqlFolder) {
await mkdir(childDir, { recursive: true });
await writeFolder(child, childDir);
}
const exportedNames = new Set<string>();
for (const file of savedSqlStore.filesInFolder(libraryFolder.id)) {
const loadedFile = await savedSqlStore.ensureFileContent(file.id);
if (!loadedFile) continue;
const filePath = await join(dir, sanitizeFileSystemSegment(ensureSqlExtension(file.name)));
const filePath = await join(dir, uniqueSavedSqlExportFileName(file.name, exportedNames));
await writeTextFile(filePath, loadedFile.sql);
}
};
@@ -157,10 +163,11 @@ async function exportFolderContents(folder?: SavedSqlFolder) {
if (unfiled.length > 0) {
const unfiledDir = await join(rootDir, sanitizeFileSystemSegment(t("sqlLibrary.unfiled")));
await mkdir(unfiledDir, { recursive: true });
const exportedNames = new Set<string>();
for (const file of unfiled) {
const loadedFile = await savedSqlStore.ensureFileContent(file.id);
if (!loadedFile) continue;
const filePath = await join(unfiledDir, sanitizeFileSystemSegment(ensureSqlExtension(file.name)));
const filePath = await join(unfiledDir, uniqueSavedSqlExportFileName(file.name, exportedNames));
await writeTextFile(filePath, loadedFile.sql);
}
}
@@ -218,18 +225,19 @@ async function importDirectoryIntoLibrary(targetFolder?: SavedSqlFolder) {
}
const folderCache = new Map<string, SavedSqlFolder>();
const takenNamesByFolder = new Map<string, Set<string>>();
const takenNamesByScope = new Map<string, Set<string>>();
const folderConnectionId = targetFolder?.connectionId ?? "";
for (const file of importFiles) {
const sourceTarget = resolveExternalSqlFileTarget(file.path, (connectionId) => !!connectionStore.getConfig(connectionId), unassociatedExternalSqlFileTarget());
const importTarget = savedSqlImportTarget(sourceTarget, targetFolder);
const folderId = await resolveImportedFolder(folderConnectionId, targetFolder?.id, file.folderNames, folderCache);
const folderKey = folderId || "";
let takenNames = takenNamesByFolder.get(folderKey);
const nameScopeKey = savedSqlImportNameScopeKey(importTarget, folderId);
let takenNames = takenNamesByScope.get(nameScopeKey);
if (!takenNames) {
takenNames = new Set((folderId ? savedSqlStore.filesInFolder(folderId) : savedSqlStore.filesWithoutFolder()).map((savedFile) => savedFile.name));
takenNamesByFolder.set(folderKey, takenNames);
const filesInTargetFolder = folderId ? savedSqlStore.filesInFolder(folderId) : savedSqlStore.filesWithoutFolder();
takenNames = new Set(filesInTargetFolder.filter((savedFile) => savedSqlDatabaseScopeKey(savedFile) === savedSqlDatabaseScopeKey(importTarget)).map((savedFile) => savedFile.name));
takenNamesByScope.set(nameScopeKey, takenNames);
}
const path = file.path;
const content = await api.readExternalSqlFile(path, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb));
@@ -445,7 +453,9 @@ 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().map((f) => f.name));
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);
try {
const file = await savedSqlStore.saveFile({
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { uniqueSavedSqlExportFileName } from "@/lib/savedSql/savedSqlExport";
describe("saved SQL export names", () => {
it.each([
["report:daily.sql", "report:daily.sql", "report_daily (2).sql"],
["report_daily (2).sql", "report:daily.sql", "REPORT:DAILY.SQL"],
["report/daily", "report\\daily", "report_daily.sql"],
])("keeps all contents when sanitized names and generated suffixes collide: %j", (...names) => {
const taken = new Set<string>();
const output = new Map<string, string>();
names.forEach((name, index) => {
const path = uniqueSavedSqlExportFileName(name, taken);
expect(path).not.toMatch(/[<>:"/\\|?*]/);
output.set(path.toLocaleLowerCase(), `SELECT ${index};`);
});
expect([...output.values()]).toEqual(["SELECT 0;", "SELECT 1;", "SELECT 2;"]);
});
it("preserves a safe filename and adds suffixes only for collisions", () => {
const taken = new Set<string>();
expect(uniqueSavedSqlExportFileName("Report.SQL", taken)).toBe("Report.SQL");
expect(uniqueSavedSqlExportFileName("report", taken)).toBe("report (2).sql");
expect(uniqueSavedSqlExportFileName("report", taken)).toBe("report (3).sql");
});
});
@@ -1,5 +1,5 @@
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { ensureSqlExtension } from "@/lib/savedSql/savedSqlFileName";
import { ensureSqlExtension, stripSqlExtension } from "@/lib/savedSql/savedSqlFileName";
export function savedSqlExportFileName(name: string): string {
return (
@@ -9,6 +9,19 @@ export function savedSqlExportFileName(name: string): string {
);
}
export function uniqueSavedSqlExportFileName(name: string, takenNames: Set<string>): string {
const normalized = savedSqlExportFileName(name);
const occupied = new Set([...takenNames].map((taken) => taken.toLocaleLowerCase()));
const base = stripSqlExtension(normalized);
let candidate = normalized;
let counter = 2;
while (occupied.has(candidate.toLocaleLowerCase())) {
candidate = `${base} (${counter++}).sql`;
}
takenNames.add(candidate);
return candidate;
}
export async function exportSavedSqlFileContent(sql: string, fileName: string): Promise<"saved" | "cancelled"> {
const defaultFileName = savedSqlExportFileName(fileName);
if (isTauriRuntime()) {
+31 -2
View File
@@ -4,6 +4,8 @@ import { isRedisMonitorCommand, startRedisMonitor } from "@/lib/redis/redisMonit
import { uuid } from "@/lib/common/utils";
import { computed, markRaw, nextTick, onScopeDispose, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useToast } from "@/composables/useToast";
import { savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors";
import { sanitizeTabPageUiState } from "@/lib/tabs/tabUiState";
import type { BatchSqlExecution, ConnectionConfig, DatabaseType, IndexInfo, NacosConfigEditorViewport, ObjectBrowserFilter, ObjectBrowserViewport, ObjectSource, ObjectSourceKind, QueryResult, QueryResultSourceColumnRef, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database";
import { orderPinnedFirst } from "@/lib/app/pinnedItems";
@@ -4945,17 +4947,43 @@ export const useQueryStore = defineStore("query", () => {
return true;
}
const savedSqlTargetRequests = new WeakMap<QueryTab, number>();
let savedSqlTargetPersistenceActive = true;
onScopeDispose(() => {
savedSqlTargetPersistenceActive = false;
});
function createExecutionTargetGuard(id: string): () => boolean {
const tab = tabs.value.find((candidate) => candidate.id === id);
const target = savedSqlExecutionTargetFromTab(tab);
if (!tab || !target) return () => false;
const revision = savedSqlTargetRequests.get(tab);
const savedSqlId = tab.savedSqlId;
return () =>
savedSqlTargetPersistenceActive && tabs.value.includes(tab) && savedSqlTargetRequests.get(tab) === revision && tab.savedSqlId === savedSqlId && tab.connectionId === target.connectionId && tab.database === target.database && tab.catalog === target.catalog && tab.schema === target.schema;
}
function persistSavedSqlExecutionTarget(tab: QueryTab, options: UpdateExecutionTargetOptions) {
const revision = (savedSqlTargetRequests.get(tab) ?? 0) + 1;
savedSqlTargetRequests.set(tab, revision);
if (options.persistSavedSqlTarget === false || tab.mode !== "query" || !tab.savedSqlId) return;
const savedSqlStore = useSavedSqlStore();
const savedSqlId = tab.savedSqlId;
void savedSqlStore
.updateFileExecutionTarget(tab.savedSqlId, {
.updateFileExecutionTarget(savedSqlId, {
connectionId: tab.connectionId,
database: tab.database,
catalog: tab.catalog,
schema: tab.schema,
})
.catch((error) => console.warn("[DBX][saved-sql:target:error]", error));
.catch((error) => {
console.warn("[DBX][saved-sql:target:error]", error);
// A failed older request must not undo a newer target selection.
if (!savedSqlTargetPersistenceActive || savedSqlTargetRequests.get(tab) !== revision || tab.savedSqlId !== savedSqlId || !tabs.value.includes(tab)) return;
const saved = savedSqlStore.getFile(savedSqlId);
if (saved) applySavedSqlExecutionTarget(tab, saved);
useToast().toast(i18n.global.t("savedSql.saveFailed", { message: savedSqlErrorMessage(error, i18n.global.t) }), 5000);
});
}
function updateDatabase(id: string, database: string, options: UpdateExecutionTargetOptions = {}) {
@@ -8711,6 +8739,7 @@ export const useQueryStore = defineStore("query", () => {
hydrateSavedSqlTabs,
togglePinnedTab,
reorderTab,
createExecutionTargetGuard,
updateDatabase,
updateCatalog,
updateSchema,
+55 -49
View File
@@ -45,8 +45,7 @@ interface SavedSqlNameScope {
}
interface PendingSavedSqlName {
ownerId: string;
count: number;
owners: Map<string, number>;
}
export class SavedSqlNameConflictError extends Error {
@@ -106,9 +105,8 @@ function savedSqlNameKey(name: string): string {
return ensureSqlExtension(name).toLocaleLowerCase();
}
function savedSqlNameScopeKey(file: Pick<SavedSqlNameScope, "connectionId" | "folderId">): string {
// Execution targets do not change the SQL library's folder-scoped names.
return JSON.stringify(["library-folder", file.connectionId, file.folderId || null]);
function savedSqlNameScopeKey(file: Pick<SavedSqlNameScope, "connectionId" | "catalog" | "database" | "folderId">): string {
return JSON.stringify(["library-folder", savedSqlDatabaseScopeKey(file), file.folderId || null]);
}
function savedSqlNameIdentity(file: SavedSqlNameScope): string {
@@ -156,6 +154,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
const fileTargetRevisions = new Map<string, number>();
const pendingFileTargetSaves = new Map<string, Promise<SavedSqlFile | undefined>>();
const persistedFileTargets = new Map<string, SavedSqlExecutionTargetInput & { updatedAt: string }>();
const persistedFileTargetNameReleases = new Map<string, () => void>();
const pendingNamesByScope = new Map<string, Map<string, PendingSavedSqlName>>();
const version = ref(0);
@@ -218,11 +217,13 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
return files.value.find((file) => file.id === id);
}
function reserveFileName(file: SavedSqlNameScope): () => void {
function reserveFileName(file: SavedSqlNameScope, options: { allowExisting?: boolean } = {}): () => void {
const scopeKey = savedSqlNameScopeKey(file);
const nameKey = savedSqlNameKey(file.name);
const persistedConflict = files.value.some((candidate) => candidate.id !== file.id && savedSqlNameScopeKey(candidate) === scopeKey && savedSqlNameKey(candidate.name) === nameKey);
if (persistedConflict) throw new SavedSqlNameConflictError(file.name);
if (!options.allowExisting) {
const persistedConflict = files.value.some((candidate) => candidate.id !== file.id && savedSqlNameScopeKey(candidate) === scopeKey && savedSqlNameKey(candidate.name) === nameKey);
if (persistedConflict) throw new SavedSqlNameConflictError(file.name);
}
let pendingNames = pendingNamesByScope.get(scopeKey);
if (!pendingNames) {
@@ -230,9 +231,9 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
pendingNamesByScope.set(scopeKey, pendingNames);
}
const pending = pendingNames.get(nameKey);
if (pending && pending.ownerId !== file.id) throw new SavedSqlNameConflictError(file.name);
if (pending) pending.count++;
else pendingNames.set(nameKey, { ownerId: file.id, count: 1 });
if (!options.allowExisting && pending && [...pending.owners.keys()].some((ownerId) => ownerId !== file.id)) throw new SavedSqlNameConflictError(file.name);
if (pending) pending.owners.set(file.id, (pending.owners.get(file.id) ?? 0) + 1);
else pendingNames.set(nameKey, { owners: new Map([[file.id, 1]]) });
let released = false;
return () => {
@@ -240,9 +241,11 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
released = true;
const currentNames = pendingNamesByScope.get(scopeKey);
const current = currentNames?.get(nameKey);
if (!current || current.ownerId !== file.id) return;
current.count--;
if (current.count <= 0) currentNames?.delete(nameKey);
const count = current?.owners.get(file.id);
if (!current || count === undefined) return;
if (count > 1) current.owners.set(file.id, count - 1);
else current.owners.delete(file.id);
if (current.owners.size === 0) currentNames?.delete(nameKey);
if (currentNames?.size === 0) pendingNamesByScope.delete(scopeKey);
};
}
@@ -363,6 +366,19 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
}
}
function setPersistedFileTarget(file: SavedSqlFile) {
const releaseName = reserveFileName(file, { allowExisting: true });
persistedFileTargetNameReleases.get(file.id)?.();
persistedFileTargetNameReleases.set(file.id, releaseName);
persistedFileTargets.set(file.id, {
connectionId: file.connectionId,
catalog: normalizedCatalog(file.catalog),
database: file.database,
schema: file.schema,
updatedAt: file.updatedAt,
});
}
function updateFileExecutionTarget(id: string, target: SavedSqlExecutionTargetInput): Promise<SavedSqlFile | undefined> {
const existing = getFile(id);
if (!existing) return Promise.resolve(undefined);
@@ -372,13 +388,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
}
if (!persistedFileTargets.has(id)) {
persistedFileTargets.set(id, {
connectionId: existing.connectionId,
catalog: normalizedCatalog(existing.catalog),
database: existing.database,
schema: existing.schema,
updatedAt: existing.updatedAt,
});
setPersistedFileTarget(existing);
}
const revision = (fileTargetRevisions.get(id) ?? 0) + 1;
fileTargetRevisions.set(id, revision);
@@ -391,38 +401,32 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
.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,
...normalizedTarget,
sqlLoaded: true,
updatedAt: nowIso(),
};
files.value = files.value.map((file) => (file.id === id ? candidate : file));
bumpVersion();
const persistedTarget = persistedFileTargets.get(id);
const persistedNameIdentity = persistedTarget
? savedSqlNameIdentity({
...candidate,
connectionId: persistedTarget.connectionId,
catalog: persistedTarget.catalog,
database: persistedTarget.database,
})
: savedSqlNameIdentity(candidate);
let releaseName: (() => void) | undefined;
try {
const loaded = await ensureFileContent(id);
if (!loaded || fileTargetRevisions.get(id) !== revision) return getFile(id);
const candidate: SavedSqlFile = {
...loaded,
...normalizedTarget,
sqlLoaded: true,
updatedAt: nowIso(),
};
files.value = files.value.map((file) => (file.id === id ? candidate : file));
bumpVersion();
const persistedTarget = persistedFileTargets.get(id);
const persistedNameIdentity = persistedTarget
? savedSqlNameIdentity({
...candidate,
connectionId: persistedTarget.connectionId,
catalog: persistedTarget.catalog,
database: persistedTarget.database,
})
: savedSqlNameIdentity(candidate);
if (persistedNameIdentity !== savedSqlNameIdentity(candidate)) releaseName = reserveFileName(candidate);
const saved = normalizeSavedSqlFile(await api.saveSavedSqlFile(candidate));
persistedFileTargets.set(id, {
connectionId: saved.connectionId,
catalog: normalizedCatalog(saved.catalog),
database: saved.database,
schema: saved.schema,
updatedAt: saved.updatedAt,
});
setPersistedFileTarget(saved);
if (fileTargetRevisions.get(id) !== revision) return getFile(id);
const current = getFile(id);
const persisted = { ...saved, sql: current?.sql ?? saved.sql, sqlLoaded: current?.sqlLoaded ?? true };
@@ -447,6 +451,8 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
if (pendingFileTargetSaves.get(id) !== save) return;
pendingFileTargetSaves.delete(id);
persistedFileTargets.delete(id);
persistedFileTargetNameReleases.get(id)?.();
persistedFileTargetNameReleases.delete(id);
};
void save.then(cleanup, cleanup);
return save;