diff --git a/src/components/diff/SchemaDiffDialog.vue b/src/components/diff/SchemaDiffDialog.vue index 0e93cfee23..a11d06d71a 100644 --- a/src/components/diff/SchemaDiffDialog.vue +++ b/src/components/diff/SchemaDiffDialog.vue @@ -19,7 +19,7 @@ import { generateSyncSql, type TableDiff, } from "@/lib/schemaDiff"; -import { sqlMetadataRefreshScope } from "@/lib/sqlMetadataRefresh"; +import { sqlMetadataRefreshTarget } from "@/lib/sqlMetadataRefresh"; import { useToast } from "@/composables/useToast"; import { Loader2, Copy, Play, GitCompareArrows, ArrowLeftRight } from "lucide-vue-next"; @@ -286,11 +286,11 @@ async function executeSql() { } const failed = syncErrors.value.length; if (failed === 0) { - const scope = sqlMetadataRefreshScope(sql); - if (scope === "connection") { + const refreshTarget = sqlMetadataRefreshTarget(sql, targetSchema.value); + if (refreshTarget.scope === "connection") { await store.loadDatabases(targetConnectionId.value, { force: true }); - } else if (scope === "database") { - await store.refreshObjectListTreeNode(targetConnectionId.value, targetDatabase.value, targetSchema.value); + } else if (refreshTarget.scope === "database") { + await store.refreshObjectListTreeNode(targetConnectionId.value, targetDatabase.value, refreshTarget.schema); } toast(t("diff.syncSuccess"), 2000); open.value = false; diff --git a/src/composables/useSqlExecution.ts b/src/composables/useSqlExecution.ts index 78e9e6251a..672eaf8473 100644 --- a/src/composables/useSqlExecution.ts +++ b/src/composables/useSqlExecution.ts @@ -5,7 +5,7 @@ import { useHistoryStore } from "@/stores/historyStore"; import { useConnectionStore } from "@/stores/connectionStore"; import { useToast } from "@/composables/useToast"; import { classifySqlActivityKind } from "@/lib/historyActivityKind"; -import { sqlMetadataRefreshScope } from "@/lib/sqlMetadataRefresh"; +import { sqlMetadataRefreshTarget } from "@/lib/sqlMetadataRefresh"; import type { ConnectionConfig, QueryTab } from "@/types/database"; const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER|UPDATE|MERGE|REPLACE)\b/i; @@ -82,11 +82,11 @@ export function useSqlExecution(deps: { affected_rows: success ? tab.result?.affected_rows : undefined, }); if (success) { - const scope = sqlMetadataRefreshScope(sql); - if (scope === "connection") { + const refreshTarget = sqlMetadataRefreshTarget(sql, tab.schema); + if (refreshTarget.scope === "connection") { await connectionStore.loadDatabases(tab.connectionId, { force: true }); - } else if (scope === "database") { - await connectionStore.refreshObjectListTreeNode(tab.connectionId, tab.database, tab.schema); + } else if (refreshTarget.scope === "database") { + await connectionStore.refreshObjectListTreeNode(tab.connectionId, tab.database, refreshTarget.schema); } } } diff --git a/src/lib/schemaTreeCache.ts b/src/lib/schemaTreeCache.ts new file mode 100644 index 0000000000..ccc97ebd4e --- /dev/null +++ b/src/lib/schemaTreeCache.ts @@ -0,0 +1,45 @@ +export const SCHEMA_TREE_CACHE_TTL_MS = 15 * 60 * 1000; + +export interface SchemaTreeCacheEnvelope { + version: 1; + cachedAt: string; + children: T; +} + +export interface DecodedSchemaTreeCache { + children: T; + isStale: boolean; +} + +export function encodeSchemaTreeCache(children: T, nowMs = Date.now()): SchemaTreeCacheEnvelope { + return { + version: 1, + cachedAt: new Date(nowMs).toISOString(), + children, + }; +} + +export function decodeSchemaTreeCache( + payload: unknown, + nowMs = Date.now(), + ttlMs = SCHEMA_TREE_CACHE_TTL_MS, +): DecodedSchemaTreeCache | null { + if (Array.isArray(payload)) { + return { children: payload as T, isStale: true }; + } + + if (!payload || typeof payload !== "object") return null; + + const envelope = payload as Partial>; + if (envelope.version !== 1 || !Array.isArray(envelope.children) || typeof envelope.cachedAt !== "string") { + return null; + } + + const cachedAtMs = Date.parse(envelope.cachedAt); + if (!Number.isFinite(cachedAtMs)) return null; + + return { + children: envelope.children as T, + isStale: nowMs - cachedAtMs >= ttlMs, + }; +} diff --git a/src/lib/sqlMetadataRefresh.ts b/src/lib/sqlMetadataRefresh.ts index eb1c305030..d48fa9b75e 100644 --- a/src/lib/sqlMetadataRefresh.ts +++ b/src/lib/sqlMetadataRefresh.ts @@ -1,8 +1,17 @@ export type SqlMetadataRefreshScope = "none" | "connection" | "database"; +export type SqlMetadataRefreshTarget = + | { scope: "none" } + | { scope: "connection" } + | { scope: "database"; schema?: string }; -const DATABASE_DDL_RE = /\b(CREATE|DROP)\s+(DATABASE|SCHEMA)\b/i; +const DATABASE_DDL_RE = /\b(CREATE|DROP)\s+DATABASE\b/i; +const SCHEMA_DDL_RE = /\b(CREATE|DROP)\s+SCHEMA\b/i; const OBJECT_DDL_RE = /\b(CREATE|ALTER|DROP|RENAME)\s+(OR\s+REPLACE\s+)?(((GLOBAL|LOCAL)\s+)?TEMP(ORARY)?\s+)?(MATERIALIZED\s+)?(TABLE|VIEW|INDEX|SEQUENCE|PROCEDURE|FUNCTION|TRIGGER|TYPE)\b/i; +const OBJECT_NAME_DDL_RE = + /\b(?:CREATE|ALTER|DROP|RENAME)\s+(?:OR\s+REPLACE\s+)?(?:(?:(?:GLOBAL|LOCAL)\s+)?TEMP(?:ORARY)?\s+)?(?:MATERIALIZED\s+)?(?:TABLE|VIEW|SEQUENCE|PROCEDURE|FUNCTION|TRIGGER|TYPE)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?((?:"[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][\w$]*)\s*\.\s*(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][\w$]*))/i; +const INDEX_TABLE_DDL_RE = + /\bCREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][\w$]*)\s+ON\s+((?:"[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][\w$]*)\s*\.\s*(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][\w$]*))/i; function stripSqlMetadataComments(sql: string): string { return sql @@ -12,8 +21,57 @@ function stripSqlMetadataComments(sql: string): string { } export function sqlMetadataRefreshScope(sql: string): SqlMetadataRefreshScope { - const cleaned = stripSqlMetadataComments(sql); - if (DATABASE_DDL_RE.test(cleaned)) return "connection"; - if (OBJECT_DDL_RE.test(cleaned)) return "database"; - return "none"; + return sqlMetadataRefreshTarget(sql).scope; +} + +function splitSqlMetadataStatements(sql: string): string[] { + return stripSqlMetadataComments(sql) + .split(";") + .map((stmt) => stmt.trim()) + .filter(Boolean); +} + +function unquoteIdentifier(identifier: string): string { + const trimmed = identifier.trim(); + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("`") && trimmed.endsWith("`")) || + (trimmed.startsWith("[") && trimmed.endsWith("]")) + ) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +function schemaFromQualifiedName(qualifiedName: string): string | undefined { + const schema = qualifiedName.split(".")[0]?.trim(); + return schema ? unquoteIdentifier(schema) : undefined; +} + +function schemaFromObjectDdl(statement: string): string | undefined { + const match = statement.match(INDEX_TABLE_DDL_RE) || statement.match(OBJECT_NAME_DDL_RE); + return match?.[1] ? schemaFromQualifiedName(match[1]) : undefined; +} + +export function sqlMetadataRefreshTarget(sql: string, activeSchema?: string): SqlMetadataRefreshTarget { + const statements = splitSqlMetadataStatements(sql); + if (statements.some((stmt) => DATABASE_DDL_RE.test(stmt))) return { scope: "connection" }; + + const schemaTargets = new Set(); + let hasDatabaseRefresh = false; + + for (const statement of statements) { + if (SCHEMA_DDL_RE.test(statement)) { + hasDatabaseRefresh = true; + continue; + } + if (!OBJECT_DDL_RE.test(statement)) continue; + hasDatabaseRefresh = true; + const schema = schemaFromObjectDdl(statement) || activeSchema; + if (schema) schemaTargets.add(schema); + } + + if (!hasDatabaseRefresh) return { scope: "none" }; + if (schemaTargets.size === 1) return { scope: "database", schema: [...schemaTargets][0] }; + return { scope: "database" }; } diff --git a/src/stores/connectionStore.ts b/src/stores/connectionStore.ts index b998efdd95..1b97b7e542 100644 --- a/src/stores/connectionStore.ts +++ b/src/stores/connectionStore.ts @@ -32,6 +32,7 @@ import { expandCachedObjectBrowserNodes, objectGroupRefreshParentId, } from "@/lib/tableTree"; +import { decodeSchemaTreeCache, encodeSchemaTreeCache } from "@/lib/schemaTreeCache"; import { useSavedSqlStore } from "@/stores/savedSqlStore"; const PINNED_TREE_NODES_STORAGE_KEY = "dbx-pinned-tree-nodes"; @@ -41,6 +42,11 @@ interface LoadTreeOptions { force?: boolean; } +interface PersistedTreeChildrenLoadResult { + hit: boolean; + isStale: boolean; +} + function redisDbLabel(db: number, loadedKeyCount?: number, totalKeyCount?: number): string { if (totalKeyCount == null) return `db${db}`; return `db${db} (${loadedKeyCount ?? 0}/${totalKeyCount})`; @@ -113,6 +119,7 @@ export const useConnectionStore = defineStore("connection", () => { } | null>(null); const sidebarLayout = ref(emptyLayout()); let layoutPersistTimer: ReturnType | null = null; + const staleTreeRefreshIds = new Set(); function startEditing(id: string) { editingConnectionId.value = id; @@ -331,10 +338,17 @@ export const useConnectionStore = defineStore("connection", () => { return parts.map((part) => encodeURIComponent(part)).join(":"); } - async function loadPersistedTreeChildren(node: TreeNode, cacheKey: string): Promise { - const children = await api.loadSchemaCache(cacheKey).catch(() => null); - if (!children) return false; - const normalizedChildren = expandCachedObjectBrowserNodes(children); + function refreshStaleTreeNode(node: TreeNode) { + if (staleTreeRefreshIds.has(node.id)) return; + staleTreeRefreshIds.add(node.id); + void loadTreeNodeChildren(node, { force: true }).finally(() => staleTreeRefreshIds.delete(node.id)); + } + + async function loadPersistedTreeChildren(node: TreeNode, cacheKey: string): Promise { + const payload = await api.loadSchemaCache(cacheKey).catch(() => null); + const decoded = decodeSchemaTreeCache(payload); + if (!decoded) return { hit: false, isStale: false }; + const normalizedChildren = expandCachedObjectBrowserNodes(decoded.children); setChildren( node, node.type === "connection" && node.connectionId @@ -342,11 +356,11 @@ export const useConnectionStore = defineStore("connection", () => { : normalizedChildren, ); node.isExpanded = true; - return true; + return { hit: true, isStale: decoded.isStale }; } async function savePersistedTreeChildren(cacheKey: string, children: TreeNode[]) { - await api.saveSchemaCache(cacheKey, children).catch(() => undefined); + await api.saveSchemaCache(cacheKey, encodeSchemaTreeCache(children)).catch(() => undefined); } function useCachedChildren(node: TreeNode, options?: LoadTreeOptions): boolean { @@ -628,7 +642,13 @@ export const useConnectionStore = defineStore("connection", () => { if (config?.db_type === "dameng" || config?.db_type === "oracle") { const effectiveDb = config.database || ""; const cacheKey = schemaCacheKey(connectionId, effectiveDb, "schemas"); - if (!options?.force && (await loadPersistedTreeChildren(node, cacheKey))) return; + if (!options?.force) { + const cached = await loadPersistedTreeChildren(node, cacheKey); + if (cached.hit) { + if (cached.isStale) refreshStaleTreeNode(node); + return; + } + } const schemas = await api.listSchemas(connectionId, effectiveDb); const visibleSchemas = filterVisibleDatabaseNames(schemas, config?.visible_databases); const schemaNodes: TreeNode[] = visibleSchemas.map((s) => ({ @@ -645,7 +665,13 @@ export const useConnectionStore = defineStore("connection", () => { await savePersistedTreeChildren(cacheKey, schemaNodes); } else { const cacheKey = schemaCacheKey(connectionId, "databases"); - if (!options?.force && (await loadPersistedTreeChildren(node, cacheKey))) return; + if (!options?.force) { + const cached = await loadPersistedTreeChildren(node, cacheKey); + if (cached.hit) { + if (cached.isStale) refreshStaleTreeNode(node); + return; + } + } const databases = await api.listDatabases(connectionId); const visibleNames = filterVisibleDatabaseNames( databases.map((database) => database.name), @@ -796,7 +822,13 @@ export const useConnectionStore = defineStore("connection", () => { await ensureConnected(connectionId); if (useCachedChildren(node, options)) return; const cacheKey = schemaCacheKey(connectionId, database, "schemas"); - if (!options?.force && (await loadPersistedTreeChildren(node, cacheKey))) return; + if (!options?.force) { + const cached = await loadPersistedTreeChildren(node, cacheKey); + if (cached.hit) { + if (cached.isStale) refreshStaleTreeNode(node); + return; + } + } const schemas = await api.listSchemas(connectionId, database); const children = schemas.map((s) => ({ @@ -829,7 +861,13 @@ export const useConnectionStore = defineStore("connection", () => { await ensureConnected(connectionId); if (useCachedChildren(node, options)) return; const cacheKey = schemaCacheKey(connectionId, database, "sqlserver-objects"); - if (!options?.force && (await loadPersistedTreeChildren(node, cacheKey))) return; + if (!options?.force) { + const cached = await loadPersistedTreeChildren(node, cacheKey); + if (cached.hit) { + if (cached.isStale) refreshStaleTreeNode(node); + return; + } + } const [schemas, defaultSchemaObjects] = await Promise.all([ api.listSchemas(connectionId, database), @@ -856,7 +894,13 @@ export const useConnectionStore = defineStore("connection", () => { await ensureConnected(connectionId); if (useCachedChildren(node, options)) return; const cacheKey = schemaCacheKey(connectionId, database, schema || "", "objects"); - if (!options?.force && (await loadPersistedTreeChildren(node, cacheKey))) return; + if (!options?.force) { + const cached = await loadPersistedTreeChildren(node, cacheKey); + if (cached.hit) { + if (cached.isStale) refreshStaleTreeNode(node); + return; + } + } const querySchema = schema || database; const config = getConfig(connectionId); diff --git a/tests/schemaTreeCache.test.ts b/tests/schemaTreeCache.test.ts new file mode 100644 index 0000000000..f074f38d11 --- /dev/null +++ b/tests/schemaTreeCache.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + SCHEMA_TREE_CACHE_TTL_MS, + decodeSchemaTreeCache, + encodeSchemaTreeCache, +} from "../src/lib/schemaTreeCache.ts"; + +const children = [{ id: "conn:db", label: "db", type: "database" }]; +const now = Date.parse("2026-05-17T10:00:00.000Z"); + +test("wraps tree children with a cache timestamp", () => { + assert.deepEqual(encodeSchemaTreeCache(children, now), { + version: 1, + cachedAt: "2026-05-17T10:00:00.000Z", + children, + }); +}); + +test("treats recent schema tree cache envelopes as fresh", () => { + const payload = encodeSchemaTreeCache(children, now); + + assert.deepEqual(decodeSchemaTreeCache(payload, now + SCHEMA_TREE_CACHE_TTL_MS - 1), { + children, + isStale: false, + }); +}); + +test("treats expired schema tree cache envelopes as stale", () => { + const payload = encodeSchemaTreeCache(children, now); + + assert.deepEqual(decodeSchemaTreeCache(payload, now + SCHEMA_TREE_CACHE_TTL_MS + 1), { + children, + isStale: true, + }); +}); + +test("keeps legacy array cache readable but stale", () => { + assert.deepEqual(decodeSchemaTreeCache(children, now), { + children, + isStale: true, + }); +}); + +test("rejects invalid schema tree cache payloads", () => { + assert.equal(decodeSchemaTreeCache({ version: 1, cachedAt: "bad", children: "nope" }, now), null); +}); diff --git a/tests/sqlMetadataRefresh.test.ts b/tests/sqlMetadataRefresh.test.ts index 80f620f296..be72f9beda 100644 --- a/tests/sqlMetadataRefresh.test.ts +++ b/tests/sqlMetadataRefresh.test.ts @@ -1,10 +1,15 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { sqlMetadataRefreshScope } from "../src/lib/sqlMetadataRefresh.ts"; +import { sqlMetadataRefreshScope, sqlMetadataRefreshTarget } from "../src/lib/sqlMetadataRefresh.ts"; test("database DDL refreshes the connection database list", () => { assert.equal(sqlMetadataRefreshScope("CREATE DATABASE app;"), "connection"); - assert.equal(sqlMetadataRefreshScope("drop schema public cascade;"), "connection"); + assert.deepEqual(sqlMetadataRefreshTarget("DROP DATABASE app;"), { scope: "connection" }); +}); + +test("schema DDL refreshes the selected database tree", () => { + assert.equal(sqlMetadataRefreshScope("drop schema public cascade;"), "database"); + assert.deepEqual(sqlMetadataRefreshTarget("CREATE SCHEMA analytics;"), { scope: "database" }); }); test("object DDL refreshes the selected database tree", () => { @@ -19,8 +24,43 @@ test("object DDL refreshes the selected database tree", () => { ); }); +test("qualified object DDL refreshes the matching schema node", () => { + assert.deepEqual(sqlMetadataRefreshTarget("CREATE TABLE public.users (id int);"), { + scope: "database", + schema: "public", + }); + assert.deepEqual(sqlMetadataRefreshTarget('ALTER TABLE "Sales".orders ADD COLUMN name text;'), { + scope: "database", + schema: "Sales", + }); + assert.deepEqual(sqlMetadataRefreshTarget("DROP VIEW `reporting`.active_users;"), { + scope: "database", + schema: "reporting", + }); + assert.deepEqual(sqlMetadataRefreshTarget("CREATE INDEX idx_users_email ON public.users (email);"), { + scope: "database", + schema: "public", + }); +}); + +test("unqualified object DDL can use the active schema as the refresh target", () => { + assert.deepEqual(sqlMetadataRefreshTarget("ALTER TABLE users ADD COLUMN name text;", "public"), { + scope: "database", + schema: "public", + }); + assert.deepEqual(sqlMetadataRefreshTarget("CREATE SCHEMA analytics;", "public"), { scope: "database" }); +}); + +test("multiple object DDL schemas fall back to refreshing the database tree", () => { + assert.deepEqual( + sqlMetadataRefreshTarget("CREATE TABLE public.users (id int); CREATE TABLE audit.events (id int);"), + { scope: "database" }, + ); +}); + test("data-only SQL does not refresh metadata trees", () => { assert.equal(sqlMetadataRefreshScope("SELECT * FROM users;"), "none"); assert.equal(sqlMetadataRefreshScope("INSERT INTO users VALUES (1);"), "none"); assert.equal(sqlMetadataRefreshScope("TRUNCATE TABLE users;"), "none"); + assert.deepEqual(sqlMetadataRefreshTarget("SELECT * FROM users;"), { scope: "none" }); });