perf(diagram): load only FK closure for focused table diagrams
This commit is contained in:
1 parent
f84a1fa965
commit
b9e2eb89af
16 files changed
+1096
-196
No files matched your search
@@ -179,7 +179,7 @@ function connectionIconType(id: string) {
|
||||
{{ t("diagram.copyJoinSql") }}
|
||||
</Button>
|
||||
|
||||
<Button v-if="focusTableName && tablesCount > 0" variant="outline" size="sm" class="h-8 px-2 text-xs" @click="emit('toggle-show-all-tables')">
|
||||
<Button v-if="focusTableName && tablesCount > 0" :disabled="loadingDiagram" variant="outline" size="sm" class="h-8 px-2 text-xs" @click="emit('toggle-show-all-tables')">
|
||||
{{ showAllTables ? t("diagram.relatedTables") : t("diagram.allTables") }}
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
<script lang="ts">
|
||||
// Reopening a cached closure also reuses its namespace choices, avoiding discovery requests.
|
||||
const diagramNamespaceSnapshots = new Map<string, { databases: string[]; schemas: string[]; defaultSchema: string }>();
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, markRaw, nextTick, onMounted, onUnmounted, provide, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
@@ -15,6 +20,9 @@ import "@vue-flow/minimap/dist/style.css";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useGraphStore } from "@/lib/diagram/graph-store";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { loadFocusedDiagram, diagramIdentityKey, diagramIndexes, type FocusedDiagramLoadRequest } from "@/lib/diagram/diagramMetadataLoader";
|
||||
import { focusedDiagramCacheKey, getFocusedDiagramCache, loadFocusedDiagramCached } from "@/lib/diagram/diagramMetadataCache";
|
||||
import { saveFocusedDiagramState } from "@/lib/diagram/draft-storage";
|
||||
import { DIAGRAM_SQL_TYPES, isSchemaAware as isSchemaAwareDatabase } from "@/lib/database/databaseCapabilities";
|
||||
import { databaseOptionsForConnection, fetchNamespaceOptionsForConnection } from "@/composables/useDatabaseOptions";
|
||||
import { buildDiagramJoinSql, buildDiagramRelationships, diagramTableId, filterDiagramTables, mergeRelationshipsWithInferred, normalizeCustomDiagramRelationship, type CustomDiagramRelationship, type DiagramPosition, type DiagramTable, isDraftTable, needsDiagramSync } from "@/lib/diagram/erDiagram";
|
||||
@@ -138,9 +146,9 @@ function deepCloneJson<T>(obj: T): T {
|
||||
|
||||
function captureHistorySnapshot(): HistorySnapshot {
|
||||
const diagramNodes = visibleTables.value.map((table) => ({
|
||||
id: table.name,
|
||||
id: getTableKey(table),
|
||||
type: "table",
|
||||
position: positions.value[table.name] || { x: 0, y: 0 },
|
||||
position: positions.value[getTableKey(table)] || { x: 0, y: 0 },
|
||||
data: { table },
|
||||
}));
|
||||
return {
|
||||
@@ -163,9 +171,9 @@ function recordHistory() {
|
||||
|
||||
function syncGraphStoreFromPositions() {
|
||||
const diagramNodes = visibleTables.value.map((table) => ({
|
||||
id: table.name,
|
||||
id: getTableKey(table),
|
||||
type: "table",
|
||||
position: positions.value[table.name] || { x: 0, y: 0 },
|
||||
position: positions.value[getTableKey(table)] || { x: 0, y: 0 },
|
||||
data: { table },
|
||||
}));
|
||||
graphStore.setNodes(diagramNodes);
|
||||
@@ -209,10 +217,14 @@ function handleRedo() {
|
||||
if (restored) applyHistorySnapshot(restored);
|
||||
}
|
||||
|
||||
function canLayoutLayer(layer: { tableNames: string[] }): boolean {
|
||||
return loadedDiagramScope.value !== "focused" || layer.tableNames.every((name) => tables.value.some((table) => getTableKey(table) === name || (table.name === name && table.schema === selectedSchemas.value[0])));
|
||||
}
|
||||
|
||||
function fitAllFreeLayers() {
|
||||
const heights = tableHeightsMap();
|
||||
for (const layer of layerStore.layers) {
|
||||
if ((layer.layoutMode ?? "auto") === "free") {
|
||||
if (canLayoutLayer(layer) && (layer.layoutMode ?? "auto") === "free") {
|
||||
sizeLayerToFit(layer, positions.value, heights);
|
||||
}
|
||||
}
|
||||
@@ -222,7 +234,7 @@ function fitAllFreeLayers() {
|
||||
function fitAllLayers() {
|
||||
const heights = tableHeightsMap();
|
||||
for (const layer of layerStore.layers) {
|
||||
sizeLayerToFit(layer, positions.value, heights);
|
||||
if (canLayoutLayer(layer)) sizeLayerToFit(layer, positions.value, heights);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,6 +287,7 @@ const props = defineProps<{
|
||||
prefillConnectionId?: string;
|
||||
prefillDatabase?: string;
|
||||
prefillSchema?: string;
|
||||
prefillCatalog?: string;
|
||||
focusTableName?: string;
|
||||
focusTableNames?: string[];
|
||||
}>();
|
||||
@@ -314,6 +327,16 @@ const totalTableCount = ref(0);
|
||||
const failedTableCount = ref(0);
|
||||
const positions = ref<Record<string, DiagramPosition>>({});
|
||||
const showAllTables = ref(false);
|
||||
type DiagramLoadMode = "focused" | "full";
|
||||
const loadedDiagramScope = ref<DiagramLoadMode>("full");
|
||||
let persistedTableSnapshot: DiagramTable[] = [];
|
||||
let diagramLoadGeneration = 0;
|
||||
let initializationGeneration = 0;
|
||||
let namespaceGeneration = 0;
|
||||
const focusedRoots = computed(() => {
|
||||
const names = props.focusTableNames?.length ? props.focusTableNames : props.focusTableName ? [props.focusTableName] : [];
|
||||
return names.filter((name) => name.trim()).map((name) => ({ name, schema: selectedSchemas.value[0] || schema.value || database.value }));
|
||||
});
|
||||
const diagramMode = ref<"table" | "engineering">("table");
|
||||
|
||||
const FIT_VIEW_OPTIONS = { padding: 0.15, duration: 150, minZoom: 0.05, maxZoom: 2 } as const;
|
||||
@@ -404,12 +427,22 @@ const isSchemaAware = computed(() => isSchemaAwareDatabase(selectedConnection.va
|
||||
|
||||
const isAllSchemasSelected = computed(() => isSchemaAware.value && schemas.value.length > 0 && schemas.value.every((s) => selectedSchemas.value.includes(s)));
|
||||
|
||||
const isMultiSchema = computed(() => isSchemaAware.value && (selectedSchemas.value.length > 1 || isAllSchemasSelected.value));
|
||||
const isMultiSchema = computed(() => new Set(tables.value.map((table) => table.schema ?? "")).size > 1 || (isSchemaAware.value && (selectedSchemas.value.length > 1 || isAllSchemasSelected.value)));
|
||||
|
||||
const tableNameCounts = computed(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const table of tables.value) counts.set(table.name, (counts.get(table.name) ?? 0) + 1);
|
||||
return counts;
|
||||
});
|
||||
|
||||
function getTableKey(table: { name: string; schema?: string }): string {
|
||||
return diagramTableId(table, isMultiSchema.value);
|
||||
}
|
||||
|
||||
function canUseUnqualifiedTableName(table: DiagramTable): boolean {
|
||||
return tableNameCounts.value.get(table.name) === 1 && (loadedDiagramScope.value !== "focused" || !table.schema || table.schema === selectedSchemas.value[0]);
|
||||
}
|
||||
|
||||
const effectiveSchemaKey = computed(() => {
|
||||
// Non-schema-aware drivers historically persisted with the database name
|
||||
// in this slot (loadSchemas assigned schema = database); keep legacy keys.
|
||||
@@ -431,33 +464,11 @@ const tableMap = computed(() => {
|
||||
return map;
|
||||
});
|
||||
|
||||
const allRelationships = computed(() => buildDiagramRelationships(tables.value, customRelationships.value, isMultiSchema.value));
|
||||
|
||||
const relatedTableNames = computed(() => {
|
||||
const selected = props.focusTableNames?.filter((name) => name.trim());
|
||||
if (selected && selected.length > 1) return new Set(selected);
|
||||
const focus = props.focusTableName;
|
||||
const names = new Set<string>();
|
||||
if (!focus) return names;
|
||||
names.add(focus);
|
||||
for (const relationship of allRelationships.value) {
|
||||
if (relationship.sourceTable === focus) names.add(relationship.targetTable);
|
||||
if (relationship.targetTable === focus) names.add(relationship.sourceTable);
|
||||
}
|
||||
return names;
|
||||
});
|
||||
|
||||
const visibleTables = computed(() => {
|
||||
const filtered = filterDiagramTables(
|
||||
tables.value.filter((table) => !table.pendingDrop),
|
||||
tableSearch.value,
|
||||
);
|
||||
if ((props.focusTableNames?.length ?? 0) > 1 && !showAllTables.value && !tableSearch.value.trim()) {
|
||||
return filtered.filter((table) => relatedTableNames.value.has(table.name) || relatedTableNames.value.has(getTableKey(table)));
|
||||
}
|
||||
if (props.focusTableName && !showAllTables.value && !tableSearch.value.trim()) {
|
||||
return filtered.filter((table) => relatedTableNames.value.has(table.name) || relatedTableNames.value.has(getTableKey(table)));
|
||||
}
|
||||
return filtered;
|
||||
});
|
||||
|
||||
@@ -619,7 +630,9 @@ function handleLayerChanged() {
|
||||
}
|
||||
|
||||
async function applyAutoLayout(options?: { skipHistory?: boolean }) {
|
||||
const generation = diagramLoadGeneration;
|
||||
await nextTick();
|
||||
if (!open.value || generation !== diagramLoadGeneration) return;
|
||||
const layoutTables = canvasVisibleTables.value;
|
||||
if (layoutTables.length === 0) return;
|
||||
|
||||
@@ -642,8 +655,9 @@ async function applyAutoLayout(options?: { skipHistory?: boolean }) {
|
||||
}));
|
||||
|
||||
// Only pass visible layers into ELK so hidden layers are not reflowed
|
||||
const layoutLayers = layerStore.layers.filter((l) => l.visible);
|
||||
const layoutLayers = layerStore.layers.filter((l) => l.visible && canLayoutLayer(l));
|
||||
const result = await computeLayoutWithLayers(diagramNodes, diagramEdges, layoutLayers);
|
||||
if (!open.value || generation !== diagramLoadGeneration) return;
|
||||
|
||||
// Freeze positions for tables in hidden layers
|
||||
const nextPositions: Record<string, DiagramPosition> = { ...positions.value };
|
||||
@@ -744,6 +758,11 @@ const canSyncStructure = computed(() => supportsTableStructureEditing(selectedCo
|
||||
function persistDraftAndLayers() {
|
||||
if (!connectionId.value || !database.value) return;
|
||||
const schemaKey = effectiveSchemaKey.value;
|
||||
if (loadedDiagramScope.value === "focused") {
|
||||
saveFocusedDiagramState(tables.value, persistedTableSnapshot, positions.value, layerStore.toJSON(), connectionId.value, database.value, schemaKey, selectedSchemas.value[0]);
|
||||
persistedTableSnapshot = deepCloneJson(tables.value);
|
||||
return;
|
||||
}
|
||||
saveDraftTables(tables.value, connectionId.value, database.value, schemaKey);
|
||||
saveLiveTablePatches(tables.value, connectionId.value, database.value, schemaKey);
|
||||
savePersistedLayers(layerStore.toJSON(), connectionId.value, database.value, schemaKey);
|
||||
@@ -976,7 +995,7 @@ function handleInspectorRemoveRelationship(id: string) {
|
||||
|
||||
async function handleSyncedDraftTables(_names: string[]) {
|
||||
toast(t("diagram.syncToDatabase"), 2000);
|
||||
await loadDiagram();
|
||||
await loadDiagram({ force: true });
|
||||
}
|
||||
|
||||
function syncVueFlowNodes() {
|
||||
@@ -1290,25 +1309,26 @@ async function copyJoinSql() {
|
||||
|
||||
async function loadDatabases(id: string) {
|
||||
if (!id) return;
|
||||
const generation = namespaceGeneration;
|
||||
const active = () => open.value && generation === namespaceGeneration && id === connectionId.value;
|
||||
loadingDatabases.value = true;
|
||||
databases.value = [];
|
||||
try {
|
||||
await store.ensureConnected(id);
|
||||
if (!active()) return;
|
||||
const config = store.getConfig(id);
|
||||
if (config?.db_type === "dameng") {
|
||||
// 达梦的"数据库"概念对应 schema,使用 fetchNamespaceOptionsForConnection
|
||||
databases.value = await fetchNamespaceOptionsForConnection(id, config);
|
||||
} else {
|
||||
const dbs = await api.listDatabases(id);
|
||||
databases.value = databaseOptionsForConnection(
|
||||
dbs.map((db) => db.name),
|
||||
config,
|
||||
);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
const names =
|
||||
config?.db_type === "dameng"
|
||||
? await fetchNamespaceOptionsForConnection(id, config)
|
||||
: databaseOptionsForConnection(
|
||||
(await api.listDatabases(id)).map((db) => db.name),
|
||||
config,
|
||||
);
|
||||
if (active()) databases.value = names;
|
||||
} catch (error: any) {
|
||||
if (active()) toast(error?.message || String(error), 5000);
|
||||
} finally {
|
||||
loadingDatabases.value = false;
|
||||
if (active()) loadingDatabases.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1322,60 +1342,72 @@ async function loadSchemas() {
|
||||
selectedSchemas.value = [database.value];
|
||||
return;
|
||||
}
|
||||
|
||||
const id = connectionId.value;
|
||||
const db = database.value;
|
||||
const catalog = props.prefillCatalog;
|
||||
const generation = namespaceGeneration;
|
||||
const active = () => open.value && generation === namespaceGeneration && id === connectionId.value && db === database.value && catalog === props.prefillCatalog;
|
||||
loadingSchemas.value = true;
|
||||
try {
|
||||
const names = await api.listSchemas(connectionId.value, database.value);
|
||||
const names = await api.listSchemas(id, db);
|
||||
if (!active()) return;
|
||||
schemas.value = names;
|
||||
if (props.prefillSchema && names.includes(props.prefillSchema)) {
|
||||
schema.value = props.prefillSchema;
|
||||
selectedSchemas.value = [props.prefillSchema];
|
||||
} else {
|
||||
const defaultSchema = names.includes("public") ? "public" : (names[0] ?? "");
|
||||
schema.value = defaultSchema;
|
||||
selectedSchemas.value = defaultSchema ? [defaultSchema] : [];
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
const defaultSchema = props.prefillSchema && names.includes(props.prefillSchema) ? props.prefillSchema : names.includes("public") ? "public" : (names[0] ?? "");
|
||||
schema.value = defaultSchema;
|
||||
selectedSchemas.value = defaultSchema ? [defaultSchema] : [];
|
||||
} catch (error: any) {
|
||||
if (active()) toast(error?.message || String(error), 5000);
|
||||
} finally {
|
||||
loadingSchemas.value = false;
|
||||
if (active()) loadingSchemas.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setConnection(id: string) {
|
||||
namespaceGeneration++;
|
||||
connectionId.value = id;
|
||||
database.value = "";
|
||||
schema.value = "";
|
||||
selectedSchemas.value = [];
|
||||
tables.value = [];
|
||||
persistedTableSnapshot = [];
|
||||
customRelationships.value = [];
|
||||
positions.value = {};
|
||||
matchConfirms.value = [];
|
||||
matchIgnores.value = [];
|
||||
matchResult.value = { relationships: [], conflicts: [], pending: [], stats: { total: 0, high: 0, medium: 0 } };
|
||||
const generation = namespaceGeneration;
|
||||
await loadDatabases(id);
|
||||
if (!open.value || generation !== namespaceGeneration || id !== connectionId.value) return;
|
||||
if (databases.value.length === 1) {
|
||||
await setDatabase(databases.value[0]);
|
||||
}
|
||||
}
|
||||
|
||||
async function setDatabase(value: string) {
|
||||
namespaceGeneration++;
|
||||
database.value = value;
|
||||
tables.value = [];
|
||||
persistedTableSnapshot = [];
|
||||
selectedSchemas.value = [];
|
||||
customRelationships.value = [];
|
||||
positions.value = {};
|
||||
matchConfirms.value = [];
|
||||
matchIgnores.value = [];
|
||||
matchResult.value = { relationships: [], conflicts: [], pending: [], stats: { total: 0, high: 0, medium: 0 } };
|
||||
const id = connectionId.value;
|
||||
const generation = namespaceGeneration;
|
||||
await loadSchemas();
|
||||
if (!open.value || generation !== namespaceGeneration || id !== connectionId.value || value !== database.value) return;
|
||||
diagramNamespaceSnapshots.set(JSON.stringify([id, value, props.prefillCatalog]), { databases: [...databases.value], schemas: [...schemas.value], defaultSchema: schema.value });
|
||||
if (diagramReady.value) await loadDiagram();
|
||||
}
|
||||
|
||||
async function setSchema(value: string) {
|
||||
namespaceGeneration++;
|
||||
schema.value = value;
|
||||
selectedSchemas.value = value ? [value] : [];
|
||||
tables.value = [];
|
||||
persistedTableSnapshot = [];
|
||||
customRelationships.value = [];
|
||||
positions.value = {};
|
||||
matchConfirms.value = [];
|
||||
@@ -1385,9 +1417,11 @@ async function setSchema(value: string) {
|
||||
}
|
||||
|
||||
async function setSelectedSchemas(values: string[]) {
|
||||
namespaceGeneration++;
|
||||
selectedSchemas.value = [...values];
|
||||
schema.value = values.length === 1 ? values[0] : "";
|
||||
tables.value = [];
|
||||
persistedTableSnapshot = [];
|
||||
customRelationships.value = [];
|
||||
positions.value = {};
|
||||
matchConfirms.value = [];
|
||||
@@ -1396,135 +1430,156 @@ async function setSelectedSchemas(values: string[]) {
|
||||
if (diagramReady.value) await loadDiagram();
|
||||
}
|
||||
|
||||
async function loadTableDiagramData(tableName: string, querySchema: string, tableComment?: string | null): Promise<DiagramTable> {
|
||||
try {
|
||||
const [columns, foreignKeys, indexes] = await Promise.all([
|
||||
api.getColumns(connectionId.value, database.value, querySchema, tableName),
|
||||
api.listForeignKeys(connectionId.value, database.value, querySchema, tableName).catch(() => []),
|
||||
api.listIndexes(connectionId.value, database.value, querySchema, tableName).catch(() => []),
|
||||
]);
|
||||
return {
|
||||
name: tableName,
|
||||
schema: querySchema,
|
||||
comment: tableComment ?? null,
|
||||
columns,
|
||||
foreignKeys,
|
||||
indexes: indexes.map((index) => ({
|
||||
id: index.name,
|
||||
name: index.name,
|
||||
columns: [...index.columns],
|
||||
isUnique: index.is_unique,
|
||||
isPrimary: index.is_primary,
|
||||
filter: index.filter ?? "",
|
||||
indexType: index.index_type ?? "",
|
||||
includedColumns: index.included_columns ?? [],
|
||||
comment: index.comment ?? "",
|
||||
markedForDrop: false,
|
||||
original: index,
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
failedTableCount.value += 1;
|
||||
console.warn(`[diagram] failed to load table metadata: ${querySchema}.${tableName}`, e);
|
||||
return { name: tableName, schema: querySchema, comment: tableComment ?? null, columns: [], foreignKeys: [], indexes: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDiagram() {
|
||||
if (!diagramReady.value) return;
|
||||
|
||||
loadingDiagram.value = true;
|
||||
tables.value = [];
|
||||
positions.value = {};
|
||||
loadedTableCount.value = 0;
|
||||
totalTableCount.value = 0;
|
||||
failedTableCount.value = 0;
|
||||
try {
|
||||
await store.ensureConnected(connectionId.value);
|
||||
const targetSchemas = isSchemaAware.value ? selectedSchemas.value : [database.value];
|
||||
|
||||
const schemaTableLists = await Promise.all(
|
||||
targetSchemas.map(async (sc) => {
|
||||
async function loadFullDiagram(request: FocusedDiagramLoadRequest, targetSchemas: string[], active: () => boolean): Promise<DiagramTable[]> {
|
||||
const { connectionId: id, database: db, catalog } = request;
|
||||
const lists = await Promise.all(
|
||||
targetSchemas.map(async (sc) => {
|
||||
try {
|
||||
const list = await api.listTables(id, db, sc, undefined, undefined, undefined, undefined, catalog);
|
||||
return list.filter((table) => table.table_type !== "VIEW" && table.table_type !== "MATERIALIZED_VIEW").map((table) => ({ name: table.name, schema: sc, comment: table.comment ?? null }));
|
||||
} catch (error) {
|
||||
console.warn(`[diagram] failed to list tables for schema ${sc}`, error);
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
const baseTables = lists.flat().sort((a, b) => a.schema.localeCompare(b.schema) || a.name.localeCompare(b.name));
|
||||
if (active()) totalTableCount.value = baseTables.length;
|
||||
const result: DiagramTable[] = [];
|
||||
for (let index = 0; index < baseTables.length; index += METADATA_BATCH_SIZE) {
|
||||
if (!active()) return result;
|
||||
const batch = await Promise.all(
|
||||
baseTables.slice(index, index + METADATA_BATCH_SIZE).map(async (table) => {
|
||||
try {
|
||||
const list = await api.listTables(connectionId.value, database.value, sc);
|
||||
// Keep the table comment so the card can render the table description.
|
||||
return list.filter((table) => table.table_type !== "VIEW" && table.table_type !== "MATERIALIZED_VIEW").map((table) => ({ name: table.name, schema: sc, comment: table.comment ?? null }));
|
||||
} catch (e) {
|
||||
console.warn(`[diagram] failed to list tables for schema ${sc}`, e);
|
||||
return [];
|
||||
const [columns, foreignKeys, indexes] = await Promise.all([api.getColumns(id, db, table.schema, table.name, catalog), api.listForeignKeys(id, db, table.schema, table.name, catalog).catch(() => []), api.listIndexes(id, db, table.schema, table.name, catalog).catch(() => [])]);
|
||||
return { ...table, columns, foreignKeys, indexes: diagramIndexes(indexes) };
|
||||
} catch (error) {
|
||||
if (active()) failedTableCount.value += 1;
|
||||
console.warn(`[diagram] failed to load table metadata: ${table.schema}.${table.name}`, error);
|
||||
return { ...table, columns: [], foreignKeys: [], indexes: [] };
|
||||
}
|
||||
}),
|
||||
);
|
||||
const baseTables = schemaTableLists.flat().sort((a, b) => {
|
||||
if (a.schema !== b.schema) return a.schema.localeCompare(b.schema);
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
totalTableCount.value = baseTables.length;
|
||||
result.push(...batch);
|
||||
if (active()) loadedTableCount.value = result.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const loadedTables: DiagramTable[] = [];
|
||||
for (let index = 0; index < baseTables.length; index += METADATA_BATCH_SIZE) {
|
||||
const batch = baseTables.slice(index, index + METADATA_BATCH_SIZE);
|
||||
const batchTables = await Promise.all(batch.map((item) => loadTableDiagramData(item.name, item.schema, item.comment)));
|
||||
loadedTables.push(...batchTables);
|
||||
loadedTableCount.value = loadedTables.length;
|
||||
}
|
||||
function diagramContextKey(): string {
|
||||
return JSON.stringify([connectionId.value, database.value, props.prefillCatalog, schema.value, selectedSchemas.value, focusedRoots.value]);
|
||||
}
|
||||
|
||||
tables.value = loadedTables;
|
||||
const schemaKey = effectiveSchemaKey.value;
|
||||
const drafts = loadDraftTables(connectionId.value, database.value, schemaKey);
|
||||
const liveKeys = new Set(loadedTables.map((t) => getTableKey(t)));
|
||||
const pendingDrafts = drafts.filter((d) => !liveKeys.has(getTableKey(d)) && !liveKeys.has(d.name));
|
||||
if (pendingDrafts.length) {
|
||||
tables.value = [...loadedTables, ...pendingDrafts];
|
||||
}
|
||||
const livePatches = loadLiveTablePatches(connectionId.value, database.value, schemaKey);
|
||||
tables.value = applyLiveTablePatches(tables.value, livePatches);
|
||||
saveDraftTables(tables.value.filter(isDraftTable), connectionId.value, database.value, schemaKey);
|
||||
saveLiveTablePatches(tables.value, connectionId.value, database.value, schemaKey);
|
||||
const savedLayers = loadPersistedLayers(connectionId.value, database.value, schemaKey);
|
||||
if (savedLayers.length) {
|
||||
layerStore.loadLayers(savedLayers);
|
||||
if (isMultiSchema.value) {
|
||||
const rawNameCounts = new Map<string, number>();
|
||||
for (const table of tables.value) {
|
||||
rawNameCounts.set(table.name, (rawNameCounts.get(table.name) ?? 0) + 1);
|
||||
}
|
||||
for (const layer of layerStore.layers) {
|
||||
layer.tableNames = layer.tableNames.map((name) => {
|
||||
if (!name.includes(".")) {
|
||||
const matchedTable = tables.value.find((t) => t.name === name);
|
||||
if (matchedTable && rawNameCounts.get(name) === 1 && matchedTable.schema) {
|
||||
return `${matchedTable.schema}.${matchedTable.name}`;
|
||||
}
|
||||
}
|
||||
return name;
|
||||
});
|
||||
}
|
||||
async function loadDiagram(options: { mode?: DiagramLoadMode; force?: boolean } = {}): Promise<void> {
|
||||
if (!diagramReady.value) return;
|
||||
const generation = ++diagramLoadGeneration;
|
||||
const context = diagramContextKey();
|
||||
const active = () => open.value && generation === diagramLoadGeneration && context === diagramContextKey();
|
||||
const mode = options.mode ?? (focusedRoots.value.length && !showAllTables.value ? "focused" : "full");
|
||||
if (tables.value.length === 0) loadedDiagramScope.value = mode;
|
||||
const request: FocusedDiagramLoadRequest = {
|
||||
connectionId: connectionId.value,
|
||||
database: database.value,
|
||||
catalog: props.prefillCatalog,
|
||||
roots: focusedRoots.value,
|
||||
concurrency: METADATA_BATCH_SIZE,
|
||||
onProgress: (loaded, discovered) => {
|
||||
if (!active()) return;
|
||||
loadedTableCount.value = loaded;
|
||||
totalTableCount.value = discovered;
|
||||
},
|
||||
};
|
||||
const key = focusedDiagramCacheKey(request);
|
||||
const cached = mode === "focused" && !options.force ? getFocusedDiagramCache(key) : undefined;
|
||||
// Check memory before connecting or showing the remote loading state.
|
||||
loadingDiagram.value = !cached;
|
||||
loadedTableCount.value = cached?.length ?? 0;
|
||||
totalTableCount.value = cached?.length ?? 0;
|
||||
failedTableCount.value = 0;
|
||||
try {
|
||||
let liveTables = cached;
|
||||
if (!liveTables) {
|
||||
const { connectionId: id, database: db, catalog } = request;
|
||||
if (mode === "focused") {
|
||||
liveTables = await loadFocusedDiagramCached(
|
||||
key,
|
||||
async () => {
|
||||
await store.ensureConnected(id);
|
||||
const result = await loadFocusedDiagram(request, {
|
||||
getColumns: (table) => api.getColumns(id, db, table.schema, table.name, catalog),
|
||||
listForeignKeys: (table) => api.listForeignKeys(id, db, table.schema, table.name, catalog),
|
||||
listIndexes: (table) => api.listIndexes(id, db, table.schema, table.name, catalog),
|
||||
getTableComment: (table) => api.getTableComment(id, db, table.schema, table.name, catalog),
|
||||
});
|
||||
return result.tables;
|
||||
},
|
||||
{ force: options.force },
|
||||
);
|
||||
} else {
|
||||
const targetSchemas = isSchemaAware.value ? [...selectedSchemas.value] : [db];
|
||||
await store.ensureConnected(id);
|
||||
if (!active()) return;
|
||||
liveTables = await loadFullDiagram(request, targetSchemas, active);
|
||||
}
|
||||
}
|
||||
loadCustomRelationships();
|
||||
loadMatchData();
|
||||
updateRelationshipDraftDefaults();
|
||||
showAllTables.value = false;
|
||||
if (failedTableCount.value > 0) {
|
||||
toast(t("diagram.partialError", { count: failedTableCount.value }), 5000);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
if (!active()) return;
|
||||
loadedTableCount.value = totalTableCount.value = liveTables.length;
|
||||
await applyLoadedDiagramTables(liveTables, mode, active);
|
||||
if (active() && failedTableCount.value > 0) toast(t("diagram.partialError", { count: failedTableCount.value }), 5000);
|
||||
} catch (error: any) {
|
||||
if (active()) toast(error?.message || String(error), 5000);
|
||||
} finally {
|
||||
loadingDiagram.value = false;
|
||||
}
|
||||
if (tables.value.length > 0) {
|
||||
await nextTick();
|
||||
await restoreOrInitializeLayout();
|
||||
graphStore.clearHistory();
|
||||
} else {
|
||||
syncVueFlowNodes();
|
||||
if (active()) loadingDiagram.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyLoadedDiagramTables(liveTables: DiagramTable[], mode: DiagramLoadMode, active: () => boolean) {
|
||||
const schemaKey = effectiveSchemaKey.value;
|
||||
const drafts = loadDraftTables(connectionId.value, database.value, schemaKey);
|
||||
const identity = (table: DiagramTable) => diagramIdentityKey({ ...table, schema: table.schema ?? selectedSchemas.value[0] ?? schema.value });
|
||||
const liveKeys = new Set(liveTables.map(identity));
|
||||
const pendingDrafts = drafts.filter((draft) => !liveKeys.has(identity(draft)));
|
||||
const patches = loadLiveTablePatches(connectionId.value, database.value, schemaKey);
|
||||
const nextTables = applyLiveTablePatches([...liveTables, ...pendingDrafts], patches, selectedSchemas.value.length === 1 ? selectedSchemas.value[0] : undefined);
|
||||
tables.value = nextTables;
|
||||
loadedDiagramScope.value = mode;
|
||||
showAllTables.value = mode === "full" && focusedRoots.value.length > 0;
|
||||
positions.value = {};
|
||||
// Metadata reconciliation must not be mistaken for an edit to a saved live patch.
|
||||
persistedTableSnapshot = deepCloneJson(nextTables);
|
||||
if (mode === "full") {
|
||||
saveDraftTables(nextTables, connectionId.value, database.value, schemaKey);
|
||||
saveLiveTablePatches(nextTables, connectionId.value, database.value, schemaKey);
|
||||
} else if (pendingDrafts.length !== drafts.length) {
|
||||
// Only remove drafts whose exact identities were confirmed live by this load.
|
||||
saveDraftTables(pendingDrafts, connectionId.value, database.value, schemaKey);
|
||||
}
|
||||
layerStore.loadLayers(loadPersistedLayers(connectionId.value, database.value, schemaKey));
|
||||
if (isMultiSchema.value) {
|
||||
for (const layer of layerStore.layers) {
|
||||
layer.tableNames = layer.tableNames.map((name) => {
|
||||
const matches = tables.value.filter((table) => table.name === name && (mode !== "focused" || !table.schema || table.schema === selectedSchemas.value[0]));
|
||||
return matches.length === 1 ? getTableKey(matches[0]) : name;
|
||||
});
|
||||
}
|
||||
}
|
||||
loadCustomRelationships();
|
||||
loadMatchData();
|
||||
updateRelationshipDraftDefaults();
|
||||
loadingDiagram.value = false;
|
||||
await nextTick();
|
||||
if (!active()) return;
|
||||
await restoreOrInitializeLayout(active);
|
||||
if (active()) graphStore.clearHistory();
|
||||
}
|
||||
|
||||
async function handleToggleShowAllTables(): Promise<void> {
|
||||
if (loadingDiagram.value || !focusedRoots.value.length) return;
|
||||
await loadDiagram({ mode: showAllTables.value ? "focused" : "full" });
|
||||
}
|
||||
|
||||
/** Restore saved table positions, or run layer-aware LTR layout when none exist. */
|
||||
async function restoreOrInitializeLayout() {
|
||||
async function restoreOrInitializeLayout(active: () => boolean = () => true) {
|
||||
const schemaKey = effectiveSchemaKey.value;
|
||||
const tableKeys = tables.value.map((t) => getTableKey(t));
|
||||
const tableNames = tables.value.map((t) => t.name);
|
||||
@@ -1534,7 +1589,7 @@ async function restoreOrInitializeLayout() {
|
||||
const next: Record<string, DiagramPosition> = {};
|
||||
for (const table of tables.value) {
|
||||
const key = getTableKey(table);
|
||||
const pos = savedPositions[key] || savedPositions[table.name];
|
||||
const pos = savedPositions[key] || (canUseUnqualifiedTableName(table) ? savedPositions[table.name] : undefined);
|
||||
if (pos) next[key] = { ...pos };
|
||||
}
|
||||
positions.value = next;
|
||||
@@ -1547,13 +1602,13 @@ async function restoreOrInitializeLayout() {
|
||||
return;
|
||||
}
|
||||
|
||||
await applyInitialLayerAwareLayout();
|
||||
await applyInitialLayerAwareLayout(active);
|
||||
}
|
||||
|
||||
/** Place tables that have no saved position into their layer (or canvas grid). */
|
||||
function fillMissingTablePositions() {
|
||||
const heights = tableHeightsMap();
|
||||
const missing = tables.value.filter((t) => !positions.value[getTableKey(t)] && !positions.value[t.name]);
|
||||
const missing = tables.value.filter((t) => !positions.value[getTableKey(t)]);
|
||||
if (missing.length === 0) return;
|
||||
|
||||
for (const table of missing) {
|
||||
@@ -1596,8 +1651,9 @@ function fillMissingTablePositions() {
|
||||
}
|
||||
}
|
||||
|
||||
async function applyInitialLayerAwareLayout() {
|
||||
async function applyInitialLayerAwareLayout(active: () => boolean = () => true) {
|
||||
await nextTick();
|
||||
if (!active()) return;
|
||||
const layoutTables = canvasVisibleTables.value;
|
||||
if (layoutTables.length === 0) {
|
||||
syncVueFlowNodes();
|
||||
@@ -1606,7 +1662,7 @@ async function applyInitialLayerAwareLayout() {
|
||||
|
||||
// Free layers with no member positions cannot preserve relative layout — treat as auto for init.
|
||||
const layersForLayout = layerStore.layers
|
||||
.filter((l) => l.visible)
|
||||
.filter((l) => l.visible && canLayoutLayer(l))
|
||||
.map((l) => {
|
||||
const hasMemberPos = l.tableNames.some((name) => positions.value[name]);
|
||||
if ((l.layoutMode ?? "auto") === "free" && !hasMemberPos) {
|
||||
@@ -1616,7 +1672,7 @@ async function applyInitialLayerAwareLayout() {
|
||||
});
|
||||
|
||||
const result = computeLtrAutoLayout({
|
||||
tables: layoutTables,
|
||||
tables: layoutTables.map((table) => ({ ...table, name: getTableKey(table) })),
|
||||
positions: positions.value,
|
||||
layers: layersForLayout,
|
||||
paneWidth: diagramPaneWidth(),
|
||||
@@ -1632,7 +1688,7 @@ async function applyInitialLayerAwareLayout() {
|
||||
if (result.positions[table.name] && !nextPositions[key]) {
|
||||
nextPositions[key] = result.positions[table.name];
|
||||
}
|
||||
if (result.positions[key] && !nextPositions[table.name]) {
|
||||
if (result.positions[key] && !nextPositions[table.name] && canUseUnqualifiedTableName(table)) {
|
||||
nextPositions[table.name] = result.positions[key];
|
||||
}
|
||||
}
|
||||
@@ -1660,10 +1716,17 @@ function requestRefreshDiagram() {
|
||||
|
||||
function confirmRefreshDiagram() {
|
||||
showRefreshConfirm.value = false;
|
||||
void loadDiagram();
|
||||
void loadDiagram({ force: true });
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
namespaceGeneration++;
|
||||
const generation = ++initializationGeneration;
|
||||
diagramLoadGeneration++;
|
||||
loadingDiagram.value = false;
|
||||
loadingDatabases.value = false;
|
||||
loadingSchemas.value = false;
|
||||
persistedTableSnapshot = [];
|
||||
connectionId.value = "";
|
||||
database.value = "";
|
||||
schema.value = "";
|
||||
@@ -1687,9 +1750,26 @@ async function initialize() {
|
||||
graphStore.setNodes([]);
|
||||
graphStore.setEdges([]);
|
||||
|
||||
if (props.prefillConnectionId && props.prefillDatabase && (props.focusTableName || props.focusTableNames?.length)) {
|
||||
const names = props.focusTableNames?.length ? props.focusTableNames : [props.focusTableName!];
|
||||
const namespace = diagramNamespaceSnapshots.get(JSON.stringify([props.prefillConnectionId, props.prefillDatabase, props.prefillCatalog]));
|
||||
const rootSchema = props.prefillSchema || namespace?.defaultSchema || props.prefillDatabase;
|
||||
const request = { connectionId: props.prefillConnectionId, database: props.prefillDatabase, catalog: props.prefillCatalog, roots: names.map((name) => ({ name, schema: rootSchema })) };
|
||||
if (namespace && getFocusedDiagramCache(focusedDiagramCacheKey(request))) {
|
||||
connectionId.value = request.connectionId;
|
||||
database.value = request.database;
|
||||
schema.value = rootSchema;
|
||||
databases.value = [...namespace.databases];
|
||||
schemas.value = [...namespace.schemas];
|
||||
selectedSchemas.value = [rootSchema];
|
||||
await loadDiagram();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (props.prefillConnectionId) {
|
||||
connectionId.value = props.prefillConnectionId;
|
||||
await loadDatabases(props.prefillConnectionId);
|
||||
if (!open.value || generation !== initializationGeneration) return;
|
||||
const initialDatabase = props.prefillDatabase && databases.value.includes(props.prefillDatabase) ? props.prefillDatabase : props.prefillDatabase || databases.value[0] || "";
|
||||
if (initialDatabase) await setDatabase(initialDatabase);
|
||||
return;
|
||||
@@ -2051,6 +2131,22 @@ function handleKeyup(e: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
diagramContextKey,
|
||||
() => {
|
||||
diagramLoadGeneration++;
|
||||
loadingDiagram.value = false;
|
||||
},
|
||||
{ flush: "sync" },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [props.prefillConnectionId, props.prefillDatabase, props.prefillSchema, props.prefillCatalog, props.focusTableName, JSON.stringify(props.focusTableNames)],
|
||||
() => {
|
||||
if (open.value) void initialize();
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
open,
|
||||
(value) => {
|
||||
@@ -2058,6 +2154,10 @@ watch(
|
||||
void initialize();
|
||||
void setupFullscreenListeners();
|
||||
} else {
|
||||
diagramLoadGeneration++;
|
||||
initializationGeneration++;
|
||||
namespaceGeneration++;
|
||||
loadingDiagram.value = false;
|
||||
teardownFullscreenListeners();
|
||||
if (diagramOwnedFullscreen) {
|
||||
void exitFullscreen();
|
||||
@@ -2108,6 +2208,9 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
diagramLoadGeneration++;
|
||||
initializationGeneration++;
|
||||
namespaceGeneration++;
|
||||
window.removeEventListener("keydown", handleKeydown);
|
||||
window.removeEventListener("keyup", handleKeyup);
|
||||
if (engineeringViewportFrame !== null) {
|
||||
@@ -2156,7 +2259,7 @@ onUnmounted(() => {
|
||||
:show-match-panel="showMatchPanel"
|
||||
:show-layers-panel="showLayersPanel"
|
||||
:show-all-tables="showAllTables"
|
||||
:focus-table-name="focusTableName ?? ''"
|
||||
:focus-table-name="focusedRoots[0]?.name ?? ''"
|
||||
:generated-join-sql="generatedJoinSql"
|
||||
:is-fullscreen="isFullscreen"
|
||||
:draft-table-count="draftTableCount"
|
||||
@@ -2171,7 +2274,7 @@ onUnmounted(() => {
|
||||
@toggle-match-panel="handleToggleMatchPanel"
|
||||
@toggle-layers-panel="showLayersPanel = !showLayersPanel"
|
||||
@copy-join-sql="copyJoinSql"
|
||||
@toggle-show-all-tables="showAllTables = !showAllTables"
|
||||
@toggle-show-all-tables="handleToggleShowAllTables"
|
||||
@export-format="exportDiagram"
|
||||
@refresh="requestRefreshDiagram"
|
||||
@zoom-out="zoomOut"
|
||||
@@ -2200,7 +2303,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<ResizerHandle v-if="showLayersPanel && diagramReady" @resize="handleLeftResize" />
|
||||
<div class="min-h-0 flex-1 overflow-hidden">
|
||||
<div v-if="loadingDiagram" class="h-full flex items-center justify-center text-sm text-muted-foreground">
|
||||
<div v-if="loadingDiagram && tables.length === 0" class="h-full flex items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ loadingText }}
|
||||
</div>
|
||||
|
||||
@@ -82,6 +82,7 @@ afterEach(() => {
|
||||
function mountToolbar() {
|
||||
const search = ref("");
|
||||
const updates: string[] = [];
|
||||
const refreshes: number[] = [];
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const app = createApp(
|
||||
@@ -100,7 +101,7 @@ function mountToolbar() {
|
||||
loadingDatabases: false,
|
||||
loadingSchemas: false,
|
||||
loadingDiagram: false,
|
||||
diagramReady: false,
|
||||
diagramReady: true,
|
||||
tablesCount: 0,
|
||||
relationshipsCount: 0,
|
||||
customRelationshipCount: 0,
|
||||
@@ -116,13 +117,14 @@ function mountToolbar() {
|
||||
updates.push(value);
|
||||
search.value = value;
|
||||
},
|
||||
onRefresh: () => refreshes.push(Date.now()),
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.mount(host);
|
||||
mountedApps.push({ app, host });
|
||||
return { host, search, updates };
|
||||
return { host, search, updates, refreshes };
|
||||
}
|
||||
|
||||
describe("DiagramToolbar", () => {
|
||||
@@ -142,4 +144,16 @@ describe("DiagramToolbar", () => {
|
||||
expect(search.value).toBe("user");
|
||||
expect(input.value).toBe("user");
|
||||
});
|
||||
|
||||
it("renders exactly one existing Refresh control and emits one refresh action", async () => {
|
||||
const { host, refreshes } = mountToolbar();
|
||||
await nextTick();
|
||||
|
||||
const refreshButtons = host.querySelectorAll<HTMLButtonElement>('button[title="diagram.refresh"]');
|
||||
expect(refreshButtons).toHaveLength(1);
|
||||
refreshButtons[0].click();
|
||||
await nextTick();
|
||||
|
||||
expect(refreshes).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createApp, nextTick, reactive, type App } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearFocusedDiagramCacheForTests } from "@/lib/diagram/diagramMetadataCache";
|
||||
import { loadDraftTables, loadLiveTablePatches, loadPersistedLayers, loadPersistedPositions, saveDraftTables, saveLiveTablePatches, savePersistedLayers, savePersistedPositions } from "@/lib/diagram/draft-storage";
|
||||
import type { DiagramTable } from "@/lib/diagram/erDiagram";
|
||||
import type { ColumnInfo, ForeignKeyInfo, IndexInfo } from "@/types/database";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
ensureConnected: vi.fn(async () => {}),
|
||||
toast: vi.fn(),
|
||||
listDatabases: vi.fn(async () => [{ name: "db" }]),
|
||||
listSchemas: vi.fn(async () => ["public", "other"]),
|
||||
listTables: vi.fn(async () => ["A", "B", "C", "unrelated"].map((name) => ({ name, table_type: "TABLE" }))),
|
||||
getColumns: vi.fn(async () => [{ name: "id", data_type: "int", is_nullable: false, is_primary_key: true, column_default: null, extra: null }]),
|
||||
listForeignKeys: vi.fn(async (_id: string, _db: string, _schema: string, name: string): Promise<ForeignKeyInfo[]> => (name === "A" || name === "B" ? [{ name: "fk", column: "id", ref_table: name === "A" ? "B" : "C", ref_column: "id" }] : [])),
|
||||
listForeignKeysForDatabase: vi.fn(async () => ({})),
|
||||
listIndexes: vi.fn(async () => []),
|
||||
getTableComment: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("@/lib/backend/api", () => mocks);
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
|
||||
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => ({ connections: [{ id: "c", db_type: "postgres" }], getConfig: () => ({ id: "c", db_type: "postgres" }), ensureConnected: mocks.ensureConnected }) }));
|
||||
vi.mock("@vue-flow/core", async () => {
|
||||
const { ref } = await import("vue");
|
||||
return {
|
||||
VueFlow: {},
|
||||
useVueFlow: () => {
|
||||
const nodes = ref([]);
|
||||
const edges = ref([]);
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
setNodes: (value: []) => {
|
||||
nodes.value = value;
|
||||
},
|
||||
setEdges: (value: []) => {
|
||||
edges.value = value;
|
||||
},
|
||||
fitView: vi.fn(),
|
||||
getViewport: () => ({ x: 0, y: 0, zoom: 1 }),
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../DiagramInspector.vue", () => ({ default: {} }));
|
||||
vi.mock("../DiagramSyncDialog.vue", () => ({ default: {} }));
|
||||
vi.mock("../CreateDraftTableDialog.vue", () => ({ default: {} }));
|
||||
vi.mock("../TableNode.vue", () => ({ default: {} }));
|
||||
vi.mock("../LayerNode.vue", () => ({ default: {} }));
|
||||
vi.mock("../RelationshipEdge.vue", () => ({ default: {} }));
|
||||
vi.mock("../MatchPanel.vue", () => ({ default: {} }));
|
||||
vi.mock("../DiagramToolbar.vue", () => ({ default: {} }));
|
||||
vi.mock("../LayerPanel.vue", () => ({ default: {} }));
|
||||
import SchemaDiagramDialog from "../SchemaDiagramDialog.vue";
|
||||
|
||||
const mounted: App[] = [];
|
||||
async function settle() {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((a, b) => {
|
||||
resolve = a;
|
||||
reject = b;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function mountDialog(focused = true, overrides: { focusTableNames?: string[]; prefillSchema?: string } = {}) {
|
||||
const props = reactive({ open: true, prefillConnectionId: "c", prefillDatabase: "db", prefillSchema: "public", prefillCatalog: "catalog", focusTableName: focused ? "A" : "", ...overrides });
|
||||
// Exercise the actual SFC setup, watchers, loaders and layout/persistence with the canvas renderer omitted.
|
||||
const app = createApp({ ...SchemaDiagramDialog, render: () => null }, props);
|
||||
app.use(createPinia());
|
||||
const instance = app.mount(document.createElement("div"));
|
||||
mounted.push(app);
|
||||
const state = instance.$.setupState as any;
|
||||
await settle();
|
||||
return { app, state, props };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearFocusedDiagramCacheForTests();
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const app of mounted.splice(0)) app.unmount();
|
||||
});
|
||||
|
||||
describe("diagram loading integration", () => {
|
||||
it("loads the recursive closure without listing tables and passes catalog", async () => {
|
||||
const { state } = await mountDialog();
|
||||
expect(state.tables.map((table: DiagramTable) => table.name)).toEqual(["A", "B", "C"]);
|
||||
expect(state.visibleTables).toHaveLength(3);
|
||||
expect(mocks.listTables).not.toHaveBeenCalled();
|
||||
expect(mocks.getColumns).toHaveBeenCalledWith("c", "db", "public", "C", "catalog");
|
||||
expect(mocks.listForeignKeys).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.listForeignKeysForDatabase).not.toHaveBeenCalled();
|
||||
expect(state.loadingDiagram).toBe(false);
|
||||
});
|
||||
|
||||
it("reopens from cache without any remote metadata or connection requests", async () => {
|
||||
await mountDialog();
|
||||
vi.clearAllMocks();
|
||||
const { state } = await mountDialog();
|
||||
expect(state.tables).toHaveLength(3);
|
||||
expect(state.loadedTableCount).toBe(3);
|
||||
expect(state.totalTableCount).toBe(3);
|
||||
expect(state.loadingDiagram).toBe(false);
|
||||
for (const method of [mocks.getColumns, mocks.listForeignKeys, mocks.listIndexes, mocks.getTableComment, mocks.listTables, mocks.listDatabases, mocks.listSchemas, mocks.ensureConnected]) expect(method).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retains full-schema loading when opened without a root", async () => {
|
||||
const { state } = await mountDialog(false);
|
||||
expect(mocks.listTables).toHaveBeenCalledTimes(1);
|
||||
expect(state.tables).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("loads a deduplicated union of multiple roots", async () => {
|
||||
const { state } = await mountDialog(true, { focusTableNames: ["A", "B", "unrelated"] });
|
||||
expect(state.tables.map((table: DiagramTable) => table.name)).toEqual(["A", "B", "C", "unrelated"]);
|
||||
expect(mocks.getColumns).toHaveBeenCalledTimes(4);
|
||||
expect(mocks.listTables).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses cached default-schema roots when no schema was prefilled", async () => {
|
||||
await mountDialog(true, { prefillSchema: undefined });
|
||||
vi.clearAllMocks();
|
||||
expect((await mountDialog(true, { prefillSchema: undefined })).state.tables).toHaveLength(3);
|
||||
expect(mocks.listSchemas).not.toHaveBeenCalled();
|
||||
expect(mocks.getColumns).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("initial metadata failure leaves an empty diagram and retries without a partial cache", async () => {
|
||||
mocks.listIndexes.mockRejectedValueOnce(new Error("indexes offline"));
|
||||
const { state } = await mountDialog();
|
||||
expect(state.tables).toEqual([]);
|
||||
expect(state.loadingDiagram).toBe(false);
|
||||
expect(mocks.toast).toHaveBeenCalledWith("indexes offline", 5000);
|
||||
vi.clearAllMocks();
|
||||
expect((await mountDialog()).state.tables).toHaveLength(3);
|
||||
expect(mocks.getColumns).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("Show all tables loads full metadata and switching back reuses the focused cache", async () => {
|
||||
const { state } = await mountDialog();
|
||||
await state.handleToggleShowAllTables();
|
||||
expect(state.showAllTables).toBe(true);
|
||||
expect(state.tables).toHaveLength(4);
|
||||
expect(mocks.listTables).toHaveBeenCalledTimes(1);
|
||||
vi.clearAllMocks();
|
||||
await state.handleToggleShowAllTables();
|
||||
expect(state.showAllTables).toBe(false);
|
||||
expect(state.tables).toHaveLength(3);
|
||||
expect(mocks.getColumns).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("confirmation bypasses cache while failure keeps the previous diagram and cache", async () => {
|
||||
const { state } = await mountDialog();
|
||||
const oldTables = state.tables;
|
||||
const pending = deferred<never>();
|
||||
mocks.listForeignKeys.mockImplementationOnce(() => pending.promise);
|
||||
state.requestRefreshDiagram();
|
||||
expect(state.showRefreshConfirm).toBe(true);
|
||||
state.confirmRefreshDiagram();
|
||||
await settle();
|
||||
expect(state.tables).toBe(oldTables);
|
||||
expect(state.loadingDiagram).toBe(true);
|
||||
pending.reject(new Error("offline"));
|
||||
await settle();
|
||||
expect(state.tables).toBe(oldTables);
|
||||
expect(state.loadingDiagram).toBe(false);
|
||||
expect(mocks.toast).toHaveBeenCalledWith("offline", 5000);
|
||||
vi.clearAllMocks();
|
||||
const reopened = await mountDialog();
|
||||
expect(reopened.state.tables).toHaveLength(3);
|
||||
expect(mocks.getColumns).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("successful refresh replaces the cached closure", async () => {
|
||||
const { state } = await mountDialog();
|
||||
mocks.listForeignKeys.mockResolvedValueOnce([]);
|
||||
state.confirmRefreshDiagram();
|
||||
await settle();
|
||||
expect(state.tables.map((table: DiagramTable) => table.name)).toEqual(["A"]);
|
||||
vi.clearAllMocks();
|
||||
expect((await mountDialog()).state.tables).toHaveLength(1);
|
||||
expect(mocks.getColumns).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refresh fetches and applies the latest columns, indexes, and foreign keys before replacing the cache", async () => {
|
||||
const { state } = await mountDialog();
|
||||
vi.clearAllMocks();
|
||||
const refreshedColumns: ColumnInfo[] = [
|
||||
{ name: "id", data_type: "int", is_nullable: false, is_primary_key: true, column_default: null, extra: null },
|
||||
{ name: "refreshed_column", data_type: "text", is_nullable: true, is_primary_key: false, column_default: null, extra: null },
|
||||
];
|
||||
const refreshedIndexes: IndexInfo[] = [{ name: "ux_refreshed", columns: ["refreshed_column"], is_unique: true, is_primary: false }];
|
||||
const refreshedForeignKeys: ForeignKeyInfo[] = [{ name: "fk_refreshed", column: "id", ref_table: "D", ref_column: "id" }];
|
||||
mocks.getColumns.mockResolvedValueOnce(refreshedColumns);
|
||||
mocks.listIndexes.mockResolvedValueOnce(refreshedIndexes);
|
||||
mocks.listForeignKeys.mockResolvedValueOnce(refreshedForeignKeys);
|
||||
|
||||
state.confirmRefreshDiagram();
|
||||
await settle();
|
||||
|
||||
expect(mocks.getColumns).toHaveBeenCalledWith("c", "db", "public", "A", "catalog");
|
||||
expect(mocks.listIndexes).toHaveBeenCalledWith("c", "db", "public", "A", "catalog");
|
||||
expect(mocks.listForeignKeys).toHaveBeenCalledWith("c", "db", "public", "A", "catalog");
|
||||
expect(mocks.listForeignKeysForDatabase).not.toHaveBeenCalled();
|
||||
const refreshedRoot = state.tables.find((table: DiagramTable) => table.name === "A") as DiagramTable;
|
||||
expect(refreshedRoot.columns.map((column) => column.name)).toEqual(["id", "refreshed_column"]);
|
||||
expect(refreshedRoot.indexes?.map((index) => index.name)).toEqual(["ux_refreshed"]);
|
||||
expect(refreshedRoot.foreignKeys).toEqual(refreshedForeignKeys);
|
||||
expect(state.visibleRelationships).toEqual([expect.objectContaining({ name: "fk_refreshed", sourceTable: "A", targetTable: "D" })]);
|
||||
|
||||
vi.clearAllMocks();
|
||||
const reopened = await mountDialog();
|
||||
const cachedRoot = reopened.state.tables.find((table: DiagramTable) => table.name === "A") as DiagramTable;
|
||||
expect(cachedRoot.columns.map((column) => column.name)).toEqual(["id", "refreshed_column"]);
|
||||
expect(cachedRoot.indexes?.map((index) => index.name)).toEqual(["ux_refreshed"]);
|
||||
expect(cachedRoot.foreignKeys).toEqual(refreshedForeignKeys);
|
||||
for (const method of [mocks.getColumns, mocks.listIndexes, mocks.listForeignKeys, mocks.listForeignKeysForDatabase]) expect(method).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("qualifies same-name nodes from different schemas", async () => {
|
||||
mocks.listForeignKeys.mockResolvedValueOnce([{ name: "fk", column: "id", ref_table: "A", ref_schema: "other", ref_column: "id" }]);
|
||||
mocks.listForeignKeys.mockResolvedValueOnce([]);
|
||||
const { state } = await mountDialog();
|
||||
expect(state.isMultiSchema).toBe(true);
|
||||
expect(state.tables.map((table: DiagramTable) => state.getTableKey(table))).toEqual(["other.A", "public.A"]);
|
||||
expect(state.visibleRelationships[0]).toMatchObject({ sourceTable: "public.A", targetTable: "other.A" });
|
||||
expect(state.positions["public.A"]).toBeDefined();
|
||||
expect(state.positions["other.A"]).toBeDefined();
|
||||
expect(state.positions["public.A"]).not.toEqual(state.positions["other.A"]);
|
||||
});
|
||||
|
||||
it("a late response cannot replace a newer schema result or its loading state", async () => {
|
||||
const { state } = await mountDialog();
|
||||
const pending = deferred<never[]>();
|
||||
mocks.getColumns.mockImplementationOnce(() => pending.promise);
|
||||
const old = state.loadDiagram({ force: true });
|
||||
await settle();
|
||||
await state.setSchema("other");
|
||||
expect(state.tables.every((table: DiagramTable) => table.schema === "other")).toBe(true);
|
||||
const current = state.tables;
|
||||
pending.resolve([]);
|
||||
await old;
|
||||
expect(state.tables).toBe(current);
|
||||
expect(state.loadingDiagram).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores a pending result after closing the dialog", async () => {
|
||||
const { state } = await mountDialog();
|
||||
const pending = deferred<never[]>();
|
||||
mocks.getColumns.mockImplementationOnce(() => pending.promise);
|
||||
const load = state.loadDiagram({ force: true });
|
||||
await settle();
|
||||
state.open = false;
|
||||
await settle();
|
||||
const old = state.tables;
|
||||
pending.resolve([]);
|
||||
await load;
|
||||
expect(state.tables).toBe(old);
|
||||
expect(state.loadingDiagram).toBe(false);
|
||||
});
|
||||
|
||||
it("opening, dragging, editing, and refreshing preserves unrelated persisted data", async () => {
|
||||
const draft: DiagramTable = { name: "draft_elsewhere", schema: "public", origin: "draft", columns: [], foreignKeys: [] };
|
||||
saveDraftTables([draft], "c", "db", "public");
|
||||
saveLiveTablePatches([{ ...draft, name: "unrelated", origin: "live", pendingDrop: true }], "c", "db", "public");
|
||||
savePersistedPositions({ unrelated: { x: 900, y: 800 } }, "c", "db", "public");
|
||||
savePersistedLayers([{ id: "layer", name: "saved", color: "red", visible: true, collapsed: false, tableNames: ["unrelated"] }], "c", "db", "public");
|
||||
const { state } = await mountDialog();
|
||||
await state.handleNodeDragStop({ node: { id: "A", type: "table", position: { x: 12, y: 34 } } });
|
||||
const root = state.tables.find((table: DiagramTable) => table.name === "A") as DiagramTable;
|
||||
const pendingColumn: ColumnInfo = { name: "pending_note", data_type: "text", is_nullable: true, is_primary_key: false, column_default: null, extra: null };
|
||||
state.handleInspectorUpdateTable({ ...root, columns: [...root.columns, pendingColumn], pendingColumnNames: [pendingColumn.name] });
|
||||
await state.loadDiagram({ force: true });
|
||||
expect(loadPersistedPositions("c", "db", "public").A).toEqual({ x: 12, y: 34 });
|
||||
expect(
|
||||
loadLiveTablePatches("c", "db", "public")
|
||||
.find((patch) => patch.tableName === "A")
|
||||
?.pendingColumns.map((column) => column.name),
|
||||
).toEqual(["pending_note"]);
|
||||
expect(loadDraftTables("c", "db", "public").map((table) => table.name)).toContain("draft_elsewhere");
|
||||
expect(loadLiveTablePatches("c", "db", "public").find((patch) => patch.tableName === "unrelated")?.pendingDrop).toBe(true);
|
||||
expect(loadPersistedPositions("c", "db", "public").unrelated).toEqual({ x: 900, y: 800 });
|
||||
expect(loadPersistedLayers("c", "db", "public")[0].tableNames).toContain("unrelated");
|
||||
});
|
||||
});
|
||||
@@ -284,6 +284,7 @@ watch(
|
||||
:prefill-connection-id="dialogs.diagramPrefillConnectionId.value"
|
||||
:prefill-database="dialogs.diagramPrefillDatabase.value"
|
||||
:prefill-schema="dialogs.diagramPrefillSchema.value"
|
||||
:prefill-catalog="dialogs.diagramPrefillCatalog.value"
|
||||
:focus-table-name="dialogs.diagramFocusTableName.value"
|
||||
:focus-table-names="dialogs.diagramFocusTableNames.value"
|
||||
@open-target="emit('openDiagramTarget', $event)"
|
||||
|
||||
@@ -3225,6 +3225,7 @@ function openDiagram(row: ObjectBrowserRow) {
|
||||
connectionStore.diagramSource = {
|
||||
connectionId: props.connection.id,
|
||||
database: props.database,
|
||||
catalog: props.catalog,
|
||||
schema: row.schema || selectedSchema.value,
|
||||
tableName: row.type === "TABLE" ? row.name : undefined,
|
||||
};
|
||||
|
||||
@@ -75,6 +75,7 @@ const sqlFilePrefillPreview = ref<SqlFilePreview>();
|
||||
const diagramPrefillConnectionId = ref("");
|
||||
const diagramPrefillDatabase = ref("");
|
||||
const diagramPrefillSchema = ref("");
|
||||
const diagramPrefillCatalog = ref<string>();
|
||||
const diagramFocusTableName = ref("");
|
||||
const diagramFocusTableNames = ref<string[]>([]);
|
||||
const docsPrefillConnectionId = ref("");
|
||||
@@ -257,6 +258,7 @@ export function useDialogSources() {
|
||||
diagramPrefillConnectionId.value = v.connectionId;
|
||||
diagramPrefillDatabase.value = v.database;
|
||||
diagramPrefillSchema.value = v.schema ?? "";
|
||||
diagramPrefillCatalog.value = v.catalog;
|
||||
diagramFocusTableName.value = v.tableName ?? "";
|
||||
diagramFocusTableNames.value = v.tableNames ?? (v.tableName ? [v.tableName] : []);
|
||||
showDiagramDialog.value = true;
|
||||
@@ -668,6 +670,7 @@ export function useDialogSources() {
|
||||
diagramPrefillConnectionId,
|
||||
diagramPrefillDatabase,
|
||||
diagramPrefillSchema,
|
||||
diagramPrefillCatalog,
|
||||
diagramFocusTableName,
|
||||
diagramFocusTableNames,
|
||||
docsPrefillConnectionId,
|
||||
|
||||
@@ -82,6 +82,7 @@ export function useSidebarTreeToolRuntime(options: SidebarTreeToolRuntimeOptions
|
||||
connectionStore.diagramSource = {
|
||||
connectionId: node.connectionId,
|
||||
database: node.database,
|
||||
catalog: node.catalog,
|
||||
schema: node.schema,
|
||||
tableName: tableNames?.length ? tableNames[0] : node.type === "table" ? node.label : undefined,
|
||||
tableNames,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { applyLiveTablePatches, loadDraftTables, loadLiveTablePatches, loadPersistedLayers, loadPersistedPositions, saveDraftTables, saveFocusedDiagramState, saveLiveTablePatches, savePersistedLayers, savePersistedPositions } from "../draft-storage";
|
||||
import type { DiagramTable } from "../erDiagram";
|
||||
|
||||
const context = ["c", "db", "public"] as const;
|
||||
const live = (name: string, schema = "public"): DiagramTable => ({ name, schema, columns: [], foreignKeys: [] });
|
||||
const draft = (name: string): DiagramTable => ({ ...live(name), origin: "draft" });
|
||||
beforeEach(() => {
|
||||
const values = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => values.set(key, value) });
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("focused persistence", () => {
|
||||
it("opening, moving, and refreshing a subset preserves unrelated drafts, patches, layers, and positions", () => {
|
||||
saveDraftTables([draft("unrelated_draft")], ...context);
|
||||
saveLiveTablePatches([{ ...live("unrelated"), pendingDrop: true }], ...context);
|
||||
savePersistedPositions({ unrelated: { x: 100, y: 200 } }, ...context);
|
||||
const layers = [{ id: "layer", name: "saved", tableNames: ["root", "unrelated"], color: "red", visible: true, collapsed: false }];
|
||||
savePersistedLayers(layers, ...context);
|
||||
const root = live("root");
|
||||
for (const positions of [{}, { root: { x: 12, y: 34 } }, {}]) saveFocusedDiagramState([root], [root], positions, [{ ...layers[0], tableNames: ["root"] }], ...context);
|
||||
expect(loadDraftTables(...context).map((table) => table.name)).toEqual(["unrelated_draft"]);
|
||||
expect(loadLiveTablePatches(...context)[0].tableName).toBe("unrelated");
|
||||
expect(loadPersistedPositions(...context)).toEqual({ unrelated: { x: 100, y: 200 }, root: { x: 12, y: 34 } });
|
||||
expect(loadPersistedLayers(...context)[0].tableNames).toEqual(["root", "unrelated"]);
|
||||
});
|
||||
|
||||
it("upserts and removes only the edited live patch, keeping same-name patches in other schemas", () => {
|
||||
const root = live("root");
|
||||
saveLiveTablePatches(
|
||||
[
|
||||
{ ...live("root", "other"), pendingDrop: true },
|
||||
{ ...live("unrelated"), pendingDrop: true },
|
||||
],
|
||||
...context,
|
||||
);
|
||||
const edited = { ...root, pendingDrop: true };
|
||||
saveFocusedDiagramState([edited], [root], {}, [], ...context);
|
||||
expect(loadLiveTablePatches(...context)).toHaveLength(3);
|
||||
saveFocusedDiagramState([root], [edited], {}, [], ...context);
|
||||
expect(loadLiveTablePatches(...context).map((patch) => [patch.schema, patch.tableName])).toEqual([
|
||||
["other", "root"],
|
||||
["public", "unrelated"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates, renames and deletes only the affected draft", () => {
|
||||
saveDraftTables([draft("unrelated")], ...context);
|
||||
const first = draft("new");
|
||||
const renamed = draft("renamed");
|
||||
saveFocusedDiagramState([first], [], {}, [], ...context);
|
||||
saveFocusedDiagramState([renamed], [first], {}, [], ...context);
|
||||
expect(loadDraftTables(...context).map((table) => table.name)).toEqual(["unrelated", "renamed"]);
|
||||
saveFocusedDiagramState([], [renamed], {}, [], ...context);
|
||||
expect(loadDraftTables(...context).map((table) => table.name)).toEqual(["unrelated"]);
|
||||
});
|
||||
|
||||
it("retains unloaded membership even when the visible layer is removed", () => {
|
||||
savePersistedLayers([{ id: "layer", name: "saved", tableNames: ["root", "unrelated"], color: "red", visible: true, collapsed: false }], ...context);
|
||||
saveFocusedDiagramState([live("root")], [live("root")], {}, [], ...context);
|
||||
expect(loadPersistedLayers(...context)[0].tableNames).toEqual(["unrelated"]);
|
||||
});
|
||||
|
||||
it("applies qualified and legacy patches only to their schema", () => {
|
||||
const tables = [live("root"), live("root", "other")];
|
||||
const patches = [{ tableName: "root", pendingColumns: [], pendingDrop: true }];
|
||||
expect(applyLiveTablePatches(tables, patches, "public").map((table) => !!table.pendingDrop)).toEqual([true, false]);
|
||||
expect(applyLiveTablePatches(tables, [{ ...patches[0], schema: "other" }]).map((table) => !!table.pendingDrop)).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it("cross-schema edits preserve legacy state belonging to an unloaded root-schema table", () => {
|
||||
const stored = { ...live("same"), schema: undefined, pendingDrop: true };
|
||||
saveLiveTablePatches([stored], ...context);
|
||||
savePersistedPositions({ same: { x: 100, y: 200 } }, ...context);
|
||||
savePersistedLayers([{ id: "layer", name: "saved", tableNames: ["same"], color: "red", visible: true, collapsed: false }], ...context);
|
||||
const other = live("same", "other");
|
||||
saveFocusedDiagramState([{ ...other, pendingDrop: true }], [other], { same: { x: 1, y: 2 }, "other.same": { x: 3, y: 4 } }, [], ...context);
|
||||
expect(loadLiveTablePatches(...context)).toHaveLength(2);
|
||||
expect(loadPersistedPositions(...context).same).toEqual({ x: 100, y: 200 });
|
||||
expect(loadPersistedLayers(...context)[0].tableNames).toEqual(["same"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearFocusedDiagramCacheForTests, DIAGRAM_METADATA_CACHE_TTL_MS, focusedDiagramCacheKey, getFocusedDiagramCache, invalidateFocusedDiagramCache, loadFocusedDiagramCached, setFocusedDiagramCache } from "../diagramMetadataCache";
|
||||
import type { DiagramTable } from "../erDiagram";
|
||||
|
||||
const tables = (name = "A"): DiagramTable[] => [{ name, schema: "public", columns: [], foreignKeys: [], indexes: [] }];
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearFocusedDiagramCacheForTests();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(0);
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("five minute closure cache", () => {
|
||||
it("loads once within TTL and expires exactly at five minutes", async () => {
|
||||
const loader = vi.fn(async () => tables());
|
||||
await loadFocusedDiagramCached("key", loader);
|
||||
vi.advanceTimersByTime(DIAGRAM_METADATA_CACHE_TTL_MS - 1);
|
||||
await loadFocusedDiagramCached("key", loader);
|
||||
expect(loader).toHaveBeenCalledTimes(1);
|
||||
vi.advanceTimersByTime(1);
|
||||
await loadFocusedDiagramCached("key", loader);
|
||||
expect(loader).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("deduplicates concurrent callers and gives each a separate snapshot", async () => {
|
||||
const pending = deferred<DiagramTable[]>();
|
||||
const loader = vi.fn(() => pending.promise);
|
||||
const a = loadFocusedDiagramCached("key", loader);
|
||||
const b = loadFocusedDiagramCached("key", loader);
|
||||
pending.resolve(tables());
|
||||
const [first, second] = await Promise.all([a, b]);
|
||||
expect(loader).toHaveBeenCalledTimes(1);
|
||||
first[0].name = "edited";
|
||||
expect(second[0].name).toBe("A");
|
||||
expect(getFocusedDiagramCache("key")?.[0].name).toBe("A");
|
||||
});
|
||||
|
||||
it("clones on storing and reading, including nested metadata", () => {
|
||||
const original = tables();
|
||||
setFocusedDiagramCache("key", original);
|
||||
original[0].name = "changed";
|
||||
const read = getFocusedDiagramCache("key")!;
|
||||
read[0].foreignKeys.push({ name: "fk" } as never);
|
||||
expect(getFocusedDiagramCache("key")).toEqual(tables());
|
||||
});
|
||||
|
||||
it("does not cache errors and retries the next call", async () => {
|
||||
const loader = vi.fn().mockRejectedValueOnce(new Error("offline")).mockResolvedValue(tables());
|
||||
await expect(loadFocusedDiagramCached("key", loader)).rejects.toThrow("offline");
|
||||
expect(getFocusedDiagramCache("key")).toBeUndefined();
|
||||
await loadFocusedDiagramCached("key", loader);
|
||||
expect(loader).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("force bypasses cache, joins forced loads, and replaces only after success", async () => {
|
||||
setFocusedDiagramCache("key", tables("old"));
|
||||
const pending = deferred<DiagramTable[]>();
|
||||
const loader = vi.fn(() => pending.promise);
|
||||
const a = loadFocusedDiagramCached("key", loader, { force: true });
|
||||
const b = loadFocusedDiagramCached("key", loader, { force: true });
|
||||
expect(await loadFocusedDiagramCached("key", loader)).toEqual(tables("old"));
|
||||
pending.resolve(tables("fresh"));
|
||||
await Promise.all([a, b]);
|
||||
expect(loader).toHaveBeenCalledTimes(1);
|
||||
expect(getFocusedDiagramCache("key")).toEqual(tables("fresh"));
|
||||
});
|
||||
|
||||
it("preserves valid cache after failed forced refresh", async () => {
|
||||
setFocusedDiagramCache("key", tables("old"));
|
||||
await expect(
|
||||
loadFocusedDiagramCached(
|
||||
"key",
|
||||
async () => {
|
||||
throw new Error("offline");
|
||||
},
|
||||
{ force: true },
|
||||
),
|
||||
).rejects.toThrow("offline");
|
||||
expect(getFocusedDiagramCache("key")).toEqual(tables("old"));
|
||||
});
|
||||
|
||||
it("an older ordinary load cannot overwrite a newer forced result", async () => {
|
||||
const pending = deferred<DiagramTable[]>();
|
||||
const old = loadFocusedDiagramCached("key", () => pending.promise);
|
||||
await loadFocusedDiagramCached("key", async () => tables("fresh"), { force: true });
|
||||
pending.resolve(tables("old"));
|
||||
await old;
|
||||
expect(getFocusedDiagramCache("key")).toEqual(tables("fresh"));
|
||||
});
|
||||
|
||||
it("invalidating in-flight work prevents later cache commits", async () => {
|
||||
const pending = deferred<DiagramTable[]>();
|
||||
const load = loadFocusedDiagramCached("key", () => pending.promise);
|
||||
invalidateFocusedDiagramCache();
|
||||
pending.resolve(tables());
|
||||
await load;
|
||||
expect(getFocusedDiagramCache("key")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keys isolate connection, database, catalog, schemas, names, and root sets", () => {
|
||||
const base = {
|
||||
connectionId: "c",
|
||||
database: "db",
|
||||
roots: [
|
||||
{ schema: "public", name: "A" },
|
||||
{ schema: "public", name: "B" },
|
||||
],
|
||||
};
|
||||
const key = focusedDiagramCacheKey(base);
|
||||
expect(focusedDiagramCacheKey({ ...base, roots: [...base.roots].reverse().concat(base.roots) })).toBe(key);
|
||||
for (const change of [{ connectionId: "other" }, { database: "other" }, { catalog: "other" }, { roots: [{ schema: "other", name: "A" }] }, { roots: [base.roots[0]] }, { roots: [{ schema: "public", name: "a" }] }]) {
|
||||
expect(focusedDiagramCacheKey({ ...base, ...change })).not.toBe(key);
|
||||
}
|
||||
expect(focusedDiagramCacheKey({ ...base, roots: [{ schema: "a.b", name: "c" }] })).not.toBe(focusedDiagramCacheKey({ ...base, roots: [{ schema: "a", name: "b.c" }] }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { diagramIdentityKey, loadFocusedDiagram, type DiagramMetadataAdapter, type FocusedDiagramLoadRequest } from "../diagramMetadataLoader";
|
||||
import type { ForeignKeyInfo } from "@/types/database";
|
||||
|
||||
const request = (names = ["A"]): FocusedDiagramLoadRequest => ({ connectionId: "c", database: "db", roots: names.map((name) => ({ schema: "public", name })) });
|
||||
const fk = (name: string, schema?: string): ForeignKeyInfo => ({ name: "fk", column: "id", ref_table: name, ref_schema: schema, ref_column: "id", on_update: "NO ACTION", on_delete: "NO ACTION" });
|
||||
function adapter(graph: Record<string, ForeignKeyInfo[]> = {}): DiagramMetadataAdapter {
|
||||
return { getColumns: vi.fn(async () => []), listIndexes: vi.fn(async () => []), listForeignKeys: vi.fn(async (table) => graph[`${table.schema}.${table.name}`] ?? []), getTableComment: vi.fn(async () => "comment") };
|
||||
}
|
||||
|
||||
describe("focused outbound metadata traversal", () => {
|
||||
it.each([
|
||||
["leaf", {}, ["A"]],
|
||||
["chain", { "public.A": [fk("B")], "public.B": [fk("C")] }, ["A", "B", "C"]],
|
||||
["branch", { "public.A": [fk("C"), fk("B")] }, ["A", "B", "C"]],
|
||||
["cycle", { "public.A": [fk("B")], "public.B": [fk("A")] }, ["A", "B"]],
|
||||
["self reference", { "public.A": [fk("A")] }, ["A"]],
|
||||
["composite rows", { "public.A": [fk("B"), fk("B")] }, ["A", "B"]],
|
||||
] as const)("loads %s once without requesting unrelated tables", async (_name, graph, expected) => {
|
||||
const metadata = adapter(graph);
|
||||
const result = await loadFocusedDiagram(request(), metadata);
|
||||
expect(result.tables.map((table) => table.name)).toEqual(expected);
|
||||
for (const method of [metadata.getColumns, metadata.listForeignKeys, metadata.listIndexes, metadata.getTableComment]) expect(method).toHaveBeenCalledTimes(expected.length);
|
||||
expect(result.rootKeys).toEqual([diagramIdentityKey({ schema: "public", name: "A" })]);
|
||||
});
|
||||
|
||||
it("unions roots, deduplicates them, and preserves cross-schema and case identities", async () => {
|
||||
const metadata = adapter({ "public.A": [fk("B"), fk("A", "other")], "public.B": [fk("A"), fk("a")] });
|
||||
const result = await loadFocusedDiagram(request(["B", "A", "A"]), metadata);
|
||||
expect(result.tables.map((table) => [table.schema, table.name])).toEqual([
|
||||
["other", "A"],
|
||||
["public", "A"],
|
||||
["public", "B"],
|
||||
["public", "a"],
|
||||
]);
|
||||
expect(metadata.getColumns).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it.each(["getColumns", "listForeignKeys", "listIndexes"] as const)("rejects required %s failures", async (method) => {
|
||||
const metadata = adapter();
|
||||
metadata[method] = vi.fn().mockRejectedValue(new Error("offline"));
|
||||
await expect(loadFocusedDiagram(request(), metadata)).rejects.toThrow("offline");
|
||||
});
|
||||
|
||||
it("allows optional comment failures", async () => {
|
||||
const metadata = adapter();
|
||||
metadata.getTableComment = vi.fn().mockRejectedValue(new Error("unsupported"));
|
||||
expect((await loadFocusedDiagram(request(), metadata)).tables[0].comment).toBeNull();
|
||||
});
|
||||
|
||||
it("bounds table concurrency and reports a growing discovered count", async () => {
|
||||
const metadata = adapter({ "public.A": [fk("B"), fk("C"), fk("D"), fk("E")], "public.B": [fk("F")] });
|
||||
let running = 0;
|
||||
let peak = 0;
|
||||
metadata.getColumns = vi.fn(async () => {
|
||||
running++;
|
||||
peak = Math.max(peak, running);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
running--;
|
||||
return [];
|
||||
});
|
||||
const onProgress = vi.fn();
|
||||
await loadFocusedDiagram({ ...request(), concurrency: 2, onProgress }, metadata);
|
||||
expect(peak).toBe(2);
|
||||
expect(onProgress.mock.calls).toEqual([
|
||||
[0, 1],
|
||||
[1, 5],
|
||||
[3, 6],
|
||||
[5, 6],
|
||||
[6, 6],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { DiagramTable } from "./erDiagram";
|
||||
import { diagramIdentityKey, normalizedDiagramRoots, type FocusedDiagramLoadRequest } from "./diagramMetadataLoader";
|
||||
|
||||
export const DIAGRAM_METADATA_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
interface CacheEntry {
|
||||
cachedAt: number;
|
||||
expiresAt: number;
|
||||
tables: DiagramTable[];
|
||||
}
|
||||
interface PendingLoad {
|
||||
force: boolean;
|
||||
promise: Promise<DiagramTable[]>;
|
||||
}
|
||||
const entries = new Map<string, CacheEntry>();
|
||||
const inFlight = new Map<string, PendingLoad>();
|
||||
// Metadata is JSON data; this also accepts Vue reactive snapshots without proxy cloning errors.
|
||||
const clone = (tables: DiagramTable[]): DiagramTable[] => JSON.parse(JSON.stringify(tables));
|
||||
|
||||
export function focusedDiagramCacheKey(request: FocusedDiagramLoadRequest): string {
|
||||
return JSON.stringify({ version: 1, connectionId: request.connectionId, database: request.database, catalog: request.catalog ?? "", roots: normalizedDiagramRoots(request.roots).map(diagramIdentityKey), traversal: "outbound-fk" });
|
||||
}
|
||||
|
||||
export function getFocusedDiagramCache(key: string, now = Date.now()): DiagramTable[] | undefined {
|
||||
const entry = entries.get(key);
|
||||
if (!entry) return undefined;
|
||||
if (now >= entry.expiresAt) {
|
||||
entries.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return clone(entry.tables);
|
||||
}
|
||||
|
||||
export function setFocusedDiagramCache(key: string, tables: DiagramTable[], now = Date.now()): void {
|
||||
entries.set(key, { cachedAt: now, expiresAt: now + DIAGRAM_METADATA_CACHE_TTL_MS, tables: clone(tables) });
|
||||
}
|
||||
|
||||
export async function loadFocusedDiagramCached(key: string, loader: () => Promise<DiagramTable[]>, options: { force?: boolean } = {}): Promise<DiagramTable[]> {
|
||||
if (!options.force) {
|
||||
const cached = getFocusedDiagramCache(key);
|
||||
if (cached) return cached;
|
||||
}
|
||||
const existing = inFlight.get(key);
|
||||
if (existing && (!options.force || existing.force)) return clone(await existing.promise);
|
||||
const pending: PendingLoad = { force: !!options.force, promise: Promise.resolve([]) };
|
||||
pending.promise = Promise.resolve()
|
||||
.then(loader)
|
||||
.then((tables) => {
|
||||
// A forced request can supersede an older ordinary request without deleting its valid snapshot.
|
||||
if (inFlight.get(key) === pending) setFocusedDiagramCache(key, tables);
|
||||
return tables;
|
||||
})
|
||||
.finally(() => {
|
||||
if (inFlight.get(key) === pending) inFlight.delete(key);
|
||||
});
|
||||
inFlight.set(key, pending);
|
||||
return clone(await pending.promise);
|
||||
}
|
||||
|
||||
export function invalidateFocusedDiagramCache(match: (key: string) => boolean = () => true): void {
|
||||
for (const key of entries.keys()) if (match(key)) entries.delete(key);
|
||||
for (const key of inFlight.keys()) if (match(key)) inFlight.delete(key);
|
||||
}
|
||||
|
||||
export function clearFocusedDiagramCacheForTests(): void {
|
||||
invalidateFocusedDiagramCache();
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { ColumnInfo, ForeignKeyInfo, IndexInfo } from "@/types/database";
|
||||
import type { DiagramTable } from "./erDiagram";
|
||||
|
||||
export interface DiagramTableIdentity {
|
||||
schema: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface FocusedDiagramLoadRequest {
|
||||
connectionId: string;
|
||||
database: string;
|
||||
catalog?: string;
|
||||
roots: DiagramTableIdentity[];
|
||||
concurrency?: number;
|
||||
onProgress?: (loaded: number, discovered: number) => void;
|
||||
}
|
||||
|
||||
export interface FocusedDiagramLoadResult {
|
||||
tables: DiagramTable[];
|
||||
rootKeys: string[];
|
||||
}
|
||||
|
||||
export interface DiagramMetadataAdapter {
|
||||
getColumns(table: DiagramTableIdentity): Promise<ColumnInfo[]>;
|
||||
listForeignKeys(table: DiagramTableIdentity): Promise<ForeignKeyInfo[]>;
|
||||
listIndexes(table: DiagramTableIdentity): Promise<IndexInfo[]>;
|
||||
getTableComment?(table: DiagramTableIdentity): Promise<string | null>;
|
||||
}
|
||||
|
||||
/** JSON tuples preserve case and cannot collide on dots in quoted identifiers. */
|
||||
export function diagramIdentityKey(table: { schema?: string; name: string }): string {
|
||||
return JSON.stringify([table.schema ?? "", table.name]);
|
||||
}
|
||||
|
||||
export function normalizedDiagramRoots(roots: DiagramTableIdentity[]): DiagramTableIdentity[] {
|
||||
return [...new Map(roots.filter((root) => root.name.trim()).map((root) => [diagramIdentityKey(root), { ...root }])).values()].sort(compareIdentities);
|
||||
}
|
||||
|
||||
function compareIdentities(a: DiagramTableIdentity, b: DiagramTableIdentity): number {
|
||||
return a.schema < b.schema ? -1 : a.schema > b.schema ? 1 : a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
||||
}
|
||||
|
||||
/** Keep the same editable index representation used by the structure inspector. */
|
||||
export function diagramIndexes(indexes: IndexInfo[]): DiagramTable["indexes"] {
|
||||
return indexes.map((index) => ({
|
||||
id: index.name,
|
||||
name: index.name,
|
||||
columns: [...index.columns],
|
||||
isUnique: index.is_unique,
|
||||
isPrimary: index.is_primary,
|
||||
filter: index.filter ?? "",
|
||||
indexType: index.index_type ?? "",
|
||||
includedColumns: index.included_columns ?? [],
|
||||
comment: index.comment ?? "",
|
||||
markedForDrop: false,
|
||||
original: index,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Bounded breadth-first outbound traversal. Required metadata errors reject the closure. */
|
||||
export async function loadFocusedDiagram(request: FocusedDiagramLoadRequest, metadata: DiagramMetadataAdapter): Promise<FocusedDiagramLoadResult> {
|
||||
const roots = normalizedDiagramRoots(request.roots);
|
||||
const rootKeys = roots.map(diagramIdentityKey);
|
||||
const discovered = new Set(rootKeys);
|
||||
const pending = [...roots];
|
||||
const tables: DiagramTable[] = [];
|
||||
const concurrency = Number.isFinite(request.concurrency) ? Math.max(1, Math.floor(request.concurrency!)) : 4;
|
||||
request.onProgress?.(0, discovered.size);
|
||||
while (pending.length) {
|
||||
const batch = pending.splice(0, concurrency);
|
||||
const batchTables = await Promise.all(
|
||||
batch.map(async (table) => {
|
||||
const [columns, foreignKeys, indexes, comment] = await Promise.all([metadata.getColumns(table), metadata.listForeignKeys(table), metadata.listIndexes(table), metadata.getTableComment?.(table).catch(() => null) ?? null]);
|
||||
return { ...table, columns, foreignKeys, indexes: diagramIndexes(indexes), comment };
|
||||
}),
|
||||
);
|
||||
for (const table of batchTables) {
|
||||
tables.push(table);
|
||||
for (const fk of table.foreignKeys) {
|
||||
const target = { schema: fk.ref_schema || table.schema, name: fk.ref_table };
|
||||
const key = diagramIdentityKey(target);
|
||||
if (!discovered.has(key)) {
|
||||
discovered.add(key);
|
||||
pending.push(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
request.onProgress?.(tables.length, discovered.size);
|
||||
}
|
||||
tables.sort((a, b) => compareIdentities({ schema: a.schema ?? "", name: a.name }, { schema: b.schema ?? "", name: b.name }));
|
||||
return { tables, rootKeys };
|
||||
}
|
||||
@@ -3,9 +3,11 @@ import type { ColumnInfo } from "@/types/database";
|
||||
import type { DiagramPosition, DiagramTable } from "./erDiagram";
|
||||
import { isLiveTable, needsDiagramSync } from "./erDiagram";
|
||||
import type { DiagramLayer } from "@/types/diagram";
|
||||
import { diagramIdentityKey } from "./diagramMetadataLoader";
|
||||
|
||||
export interface LiveTablePatch {
|
||||
tableName: string;
|
||||
schema?: string;
|
||||
pendingColumns: ColumnInfo[];
|
||||
droppedColumnNames?: string[];
|
||||
pendingDrop?: boolean;
|
||||
@@ -123,6 +125,7 @@ export function loadLiveTablePatches(connectionId: string, database: string, sch
|
||||
const droppedColumnNames = sanitizeStringList(patch.droppedColumnNames);
|
||||
const next: LiveTablePatch = {
|
||||
tableName: patch.tableName,
|
||||
schema: patch.schema,
|
||||
pendingColumns: pendingColumns.map((col) => ({ ...col })),
|
||||
};
|
||||
if (droppedColumnNames.length) next.droppedColumnNames = droppedColumnNames;
|
||||
@@ -134,6 +137,10 @@ export function loadLiveTablePatches(connectionId: string, database: string, sch
|
||||
export function saveLiveTablePatches(tables: DiagramTable[], connectionId: string, database: string, schema: string): void {
|
||||
if (!connectionId || !database) return;
|
||||
const key = storageKey("live-patches", connectionId, database, schema);
|
||||
safeLocalStorageSet(key, JSON.stringify(liveTablePatches(tables)));
|
||||
}
|
||||
|
||||
function liveTablePatches(tables: DiagramTable[]): LiveTablePatch[] {
|
||||
const patches: LiveTablePatch[] = [];
|
||||
for (const table of tables) {
|
||||
if (!isLiveTable(table) || !needsDiagramSync(table)) continue;
|
||||
@@ -143,22 +150,58 @@ export function saveLiveTablePatches(tables: DiagramTable[], connectionId: strin
|
||||
if (pendingColumns.length === 0 && droppedColumnNames.length === 0 && !table.pendingDrop) continue;
|
||||
const patch: LiveTablePatch = {
|
||||
tableName: table.name,
|
||||
schema: table.schema,
|
||||
pendingColumns: pendingColumns.map((col) => ({ ...col })),
|
||||
};
|
||||
if (droppedColumnNames.length) patch.droppedColumnNames = droppedColumnNames;
|
||||
if (table.pendingDrop) patch.pendingDrop = true;
|
||||
patches.push(patch);
|
||||
}
|
||||
safeLocalStorageSet(key, JSON.stringify(patches));
|
||||
return patches;
|
||||
}
|
||||
|
||||
/** Persist only edits since the last applied snapshot, never the absent part of a focused schema. */
|
||||
export function saveFocusedDiagramState(tables: DiagramTable[], previousTables: DiagramTable[], positions: Record<string, DiagramPosition>, layers: DiagramLayer[], connectionId: string, database: string, schema: string, legacySchema = schema): void {
|
||||
if (!connectionId || !database) return;
|
||||
const previous = new Map(previousTables.map((table) => [diagramIdentityKey(table), table]));
|
||||
const current = new Map(tables.map((table) => [diagramIdentityKey(table), table]));
|
||||
const changed = new Set([...previous.keys(), ...current.keys()].filter((key) => JSON.stringify(previous.get(key)) !== JSON.stringify(current.get(key))));
|
||||
if (changed.size) {
|
||||
const drafts = loadDraftTables(connectionId, database, schema).filter((table) => !changed.has(diagramIdentityKey(table)));
|
||||
saveDraftTables([...drafts, ...tables.filter((table) => changed.has(diagramIdentityKey(table)))], connectionId, database, schema);
|
||||
const oldPatches = loadLiveTablePatches(connectionId, database, schema).filter(
|
||||
(patch) =>
|
||||
![...changed].some((key) => {
|
||||
const table = current.get(key) ?? previous.get(key)!;
|
||||
return patch.tableName === table.name && (patch.schema === table.schema || (patch.schema === undefined && (!table.schema || table.schema === legacySchema)));
|
||||
}),
|
||||
);
|
||||
const newPatches = liveTablePatches(tables.filter((table) => changed.has(diagramIdentityKey(table))));
|
||||
safeLocalStorageSet(storageKey("live-patches", connectionId, database, schema), JSON.stringify([...oldPatches, ...newPatches]));
|
||||
}
|
||||
const loadedNames = new Set([...previousTables, ...tables].flatMap((table) => (!table.schema || table.schema === legacySchema ? [table.name, table.schema ? `${table.schema}.${table.name}` : table.name] : [`${table.schema}.${table.name}`])));
|
||||
const scopedPositions = Object.fromEntries(Object.entries(positions).filter(([name]) => loadedNames.has(name)));
|
||||
savePersistedPositions({ ...loadPersistedPositions(connectionId, database, schema), ...scopedPositions }, connectionId, database, schema);
|
||||
const mergedLayers = layers.map((layer) => ({ ...layer, tableNames: [...layer.tableNames] }));
|
||||
for (const stored of loadPersistedLayers(connectionId, database, schema)) {
|
||||
const unloadedMembers = stored.tableNames.filter((name) => !loadedNames.has(name));
|
||||
const next = mergedLayers.find((layer) => layer.id === stored.id);
|
||||
if (next) next.tableNames = [...new Set([...next.tableNames, ...unloadedMembers])];
|
||||
else if (unloadedMembers.length || stored.tableNames.length === 0) mergedLayers.push({ ...stored, tableNames: unloadedMembers });
|
||||
}
|
||||
savePersistedLayers(mergedLayers, connectionId, database, schema);
|
||||
}
|
||||
|
||||
/** Merge saved pending adds/drops onto live tables loaded from DB metadata. */
|
||||
export function applyLiveTablePatches(tables: DiagramTable[], patches: LiveTablePatch[]): DiagramTable[] {
|
||||
export function applyLiveTablePatches(tables: DiagramTable[], patches: LiveTablePatch[], legacySchema?: string): DiagramTable[] {
|
||||
if (patches.length === 0) return tables;
|
||||
const byName = new Map(patches.map((p) => [p.tableName, p]));
|
||||
const byName = new Map(patches.map((p) => [diagramIdentityKey({ name: p.tableName, schema: p.schema ?? legacySchema }), p]));
|
||||
const nameCounts = new Map<string, number>();
|
||||
for (const table of tables) nameCounts.set(table.name, (nameCounts.get(table.name) ?? 0) + 1);
|
||||
return tables.map((table) => {
|
||||
if (!isLiveTable(table)) return table;
|
||||
const patch = byName.get(table.name);
|
||||
const unambiguous = nameCounts.get(table.name) === 1;
|
||||
const patch = byName.get(diagramIdentityKey(table)) ?? (unambiguous ? byName.get(diagramIdentityKey({ name: table.name })) : undefined);
|
||||
if (!patch) return table;
|
||||
|
||||
const existing = new Set(table.columns.map((c) => c.name.toLowerCase()));
|
||||
|
||||
@@ -641,6 +641,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
catalog?: string;
|
||||
tableName?: string;
|
||||
tableNames?: string[];
|
||||
} | null>(null);
|
||||
|
||||
+2
-5
@@ -15,10 +15,7 @@ describe("diagram refresh confirmation wiring", () => {
|
||||
assert.match(dialogSource, /function requestRefreshDiagram\(\)/);
|
||||
assert.match(dialogSource, /showRefreshConfirm\.value = true/);
|
||||
assert.match(dialogSource, /@refresh="requestRefreshDiagram"/);
|
||||
assert.match(
|
||||
dialogSource,
|
||||
/DangerConfirmDialog[^>]*v-model:open="showRefreshConfirm"[^>]*@confirm="confirmRefreshDiagram"/,
|
||||
);
|
||||
assert.match(dialogSource, /function confirmRefreshDiagram\(\)[\s\S]*?void loadDiagram\(\)/);
|
||||
assert.match(dialogSource, /DangerConfirmDialog[^>]*v-model:open="showRefreshConfirm"[^>]*@confirm="confirmRefreshDiagram"/);
|
||||
assert.match(dialogSource, /function confirmRefreshDiagram\(\)[\s\S]*?void loadDiagram\(\{ force: true \}\)/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user