feat(metadata): persist and lazy-load table structure metadata
This commit is contained in:
parent
584848f732
commit
7be11f54c9
|
|
@ -1510,6 +1510,7 @@ async function onOpenObjectSource(table: SqlObjectNavigationTarget, initialEditi
|
|||
function onQueryEditorObjectSourceSaved() {
|
||||
const target = queryEditorObjectSourceTarget.value;
|
||||
if (!target) return;
|
||||
connectionStore.invalidateMetadataCache(target.connectionId, target.database, target.schema, target.name);
|
||||
connectionStore.invalidateCompletionCache(target.connectionId, target.database);
|
||||
contentAreaRef.value?.refreshQueryEditorCompletionCache();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,8 +47,8 @@ import { buildElasticsearchCompletionItemsFromContext, getElasticsearchCompletio
|
|||
import { buildMongoCompletionItemsFromContext, getMongoCompletionContext, getMongoCompletionResultValidFor, mongoCompletionNeedsCollections, mongoCompletionNeedsFields, shouldAutoOpenMongoCompletion, type MongoCompletionItem } from "@/lib/mongo/mongoCompletion";
|
||||
import { mergeSqlCompletionQualifierNames, resolveSqlCompletionRoutineLookupTarget, resolveSqlCompletionSchemaLookupDatabase, resolveSqlCompletionTableLookupTarget } from "@/lib/sql/sqlCompletionLookupTarget";
|
||||
import { usesOracleSessionCompletionColumns as shouldUseOracleSessionCompletionColumns } from "@/lib/sql/oracleCompletionSession";
|
||||
import { extractIdentifierDetailsAt, isSqlKeyword, matchTable, mergeSqlObjectNavigationType, splitQualifiedIdentifier, sqlObjectHoverDetail, sqlObjectNavigationTarget, type SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
|
||||
import { buildHoverTableSql, hoverTableMatchesScope, quoteQualifiedName, reformatHoverDdl, scopeHoverTables, type HoverTableScope } from "@/lib/editor/hoverTableSql";
|
||||
import { extractIdentifierDetailsAt, isSqlKeyword, matchTable, mergeSqlObjectNavigationType, splitQualifiedIdentifier, sqlObjectHoverDetail, sqlObjectNavigationSourceKind, sqlObjectNavigationTarget, type SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
|
||||
import { buildHoverTableSql, ddlForHoverPreview, hoverTableMatchesScope, quoteQualifiedName, reformatHoverDdl, scopeHoverTables, type HoverTableScope } from "@/lib/editor/hoverTableSql";
|
||||
import { lineColumnToOffset, parseSqlErrorLocation } from "@/lib/sql/sqlDiagnostics";
|
||||
import {
|
||||
DBX_TABLE_REFERENCE_MIME,
|
||||
|
|
@ -81,7 +81,8 @@ import type { StatementExecutionMarker } from "@/lib/tabs/tabPresentation";
|
|||
import { isSchemaAware, isSingleDatabase, supportsDatabaseNameCompletion, supportsDatabaseSchemaQualifier, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport";
|
||||
import { metadataSchemaForConnection, sqlSnippetDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { usesLocalOnlyEditorCompletionMetadata, usesOnDemandOnlyEditorColumnMetadata } from "@/lib/metadata/completionMetadataPolicy";
|
||||
import { loadTableMetadata, type TableMetadataLoadResult } from "@/lib/metadata/tableMetadataCache";
|
||||
import { loadObjectDdl } from "@/lib/metadata/objectDdlCache";
|
||||
import { loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
|
||||
import { queryContextObjectActions, queryContextObjectRoute, queryTableCandidateAtSqlPosition, resolveQueryContextCandidateDatabase, resolveQueryContextObjectTarget, type QueryContextObjectAction } from "@/lib/sql/queryCursorTableTarget";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
|
|
@ -428,8 +429,7 @@ const cachedInsertValueHintColumnsByTable = new Map<string, string[]>();
|
|||
const cachedForeignKeysByTable = new Map<string, SqlCompletionForeignKey[]>();
|
||||
const loadedColumnsByTable = new Set<string>();
|
||||
|
||||
// Hover tooltip uses the shared table metadata cache (loadTableMetadata)
|
||||
// which provides TTL, invalidation, and in-flight deduplication.
|
||||
// Hover tooltip shares the persisted object cache with the DDL and structure views.
|
||||
let hoverSqlHighlighter: SqlHighlighter | null = null;
|
||||
|
||||
function sqlCompletionDialectOptions() {
|
||||
|
|
@ -1892,15 +1892,22 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number)
|
|||
const hoverDatabase = hoverScope.database;
|
||||
const hoverSchema = hoverScope.schema ?? table.schema ?? "";
|
||||
const hoverQualifiedName = [hoverScope.catalog, hoverDatabase, hoverSchema, table.name].filter(Boolean).join(".");
|
||||
const objectMetadataRequest = {
|
||||
connectionId: props.connectionId,
|
||||
database: hoverDatabase,
|
||||
schema: hoverSchema,
|
||||
tableName: table.name,
|
||||
catalog: hoverScope.catalog,
|
||||
objectType: sqlObjectNavigationSourceKind(table),
|
||||
};
|
||||
let sqlContent: string | undefined;
|
||||
let metadataLoadFailed = false;
|
||||
|
||||
// Primary path: the backend's raw getTableDdl (SHOW CREATE TABLE, pg_ddl,
|
||||
// build_sqlserver_ddl, ...) is authoritative. Parse it into structured
|
||||
// fields and rebuild with vertical field alignment (name, type, extra,
|
||||
// default, nullable, comment), stripping charset/COLLATE noise.
|
||||
// The persisted display DDL is canonical across the full-page and hover
|
||||
// views. Hover only removes PostgreSQL's appended access-control tail.
|
||||
try {
|
||||
const rawDdl = await api.getTableDdl(props.connectionId, hoverDatabase, hoverSchema, table.name, undefined, hoverScope.catalog);
|
||||
const { ddl } = await loadObjectDdl(objectMetadataRequest);
|
||||
const rawDdl = ddlForHoverPreview(ddl);
|
||||
if (rawDdl && rawDdl.trim()) {
|
||||
sqlContent = reformatHoverDdl(rawDdl, quoteQualifiedName(hoverQualifiedName));
|
||||
}
|
||||
|
|
@ -1915,24 +1922,20 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number)
|
|||
let fullIndexes: IndexInfo[] = [];
|
||||
let tableComment: string | undefined;
|
||||
try {
|
||||
const result: TableMetadataLoadResult = await loadTableMetadata({
|
||||
connectionId: props.connectionId,
|
||||
database: hoverDatabase,
|
||||
schema: hoverSchema,
|
||||
tableName: table.name,
|
||||
databaseType: props.databaseType ?? "",
|
||||
catalog: hoverScope.catalog,
|
||||
});
|
||||
fullColumns = result.metadata.columns;
|
||||
fullIndexes = result.metadata.indexes;
|
||||
const [columnsResult, indexesResult] = await Promise.all([
|
||||
loadObjectMetadataFacet(objectMetadataRequest, "columns", () => api.getColumns(props.connectionId!, hoverDatabase, hoverSchema, table.name, hoverScope.catalog)),
|
||||
loadObjectMetadataFacet(objectMetadataRequest, "indexes", () => api.listIndexes(props.connectionId!, hoverDatabase, hoverSchema, table.name, hoverScope.catalog).catch(() => [])),
|
||||
]);
|
||||
fullColumns = columnsResult.value;
|
||||
fullIndexes = indexesResult.value;
|
||||
} catch (error) {
|
||||
metadataLoadFailed = true;
|
||||
console.warn(`[DBX] Failed to load table metadata for ${hoverDatabase}.${hoverSchema}.${table.name}:`, error);
|
||||
}
|
||||
if (!metadataLoadFailed) {
|
||||
try {
|
||||
const commentResult = await api.getTableComment(props.connectionId, hoverDatabase, hoverSchema, table.name, hoverScope.catalog);
|
||||
if (commentResult) tableComment = commentResult;
|
||||
const commentResult = await loadObjectMetadataFacet(objectMetadataRequest, "comment", () => api.getTableComment(props.connectionId!, hoverDatabase, hoverSchema, table.name, hoverScope.catalog));
|
||||
if (commentResult.value) tableComment = commentResult.value;
|
||||
} catch (error) {
|
||||
console.warn(`[DBX] Failed to load table comment for ${hoverDatabase}.${hoverSchema}.${table.name}:`, error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes";
|
|||
import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { formatSqlForDisplay, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { loadObjectDdl } from "@/lib/metadata/objectDdlCache";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import EditorSearchPanel from "@/components/editor/EditorSearchPanel.vue";
|
||||
|
|
@ -51,23 +51,38 @@ const ddlEditorContainer = ref<HTMLDivElement>();
|
|||
const ddlSearchPanelRef = ref<InstanceType<typeof EditorSearchPanel>>();
|
||||
const ddlEditorView = shallowRef<EditorView | null>(null);
|
||||
|
||||
/** Fetches the table DDL when the dialog opens. */
|
||||
async function loadDdl(force = false) {
|
||||
ddlError.value = "";
|
||||
ddlLoading.value = true;
|
||||
if (force) destroyDdlEditor();
|
||||
try {
|
||||
const schema = props.schema || props.database;
|
||||
const { ddl } = await loadObjectDdl(
|
||||
{
|
||||
connectionId: props.connectionId,
|
||||
database: props.database,
|
||||
schema,
|
||||
tableName: props.tableName,
|
||||
objectType: props.objectType,
|
||||
catalog: props.catalog,
|
||||
},
|
||||
{ force },
|
||||
);
|
||||
ddlContent.value = await formatSqlForDisplay(ddl, props.formatDialect ?? props.dialect, settingsStore.editorSettings.sqlFormatter);
|
||||
} catch (e: any) {
|
||||
ddlError.value = e?.message || String(e);
|
||||
} finally {
|
||||
ddlLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Loads the persisted table DDL when the dialog opens. */
|
||||
watch(
|
||||
() => props.open,
|
||||
async (open) => {
|
||||
if (!open) return;
|
||||
ddlContent.value = "";
|
||||
ddlError.value = "";
|
||||
ddlLoading.value = true;
|
||||
try {
|
||||
const schema = props.schema || props.database;
|
||||
const ddl = await api.getTableDisplayDdl(props.connectionId, props.database, schema, props.tableName, props.objectType, props.catalog);
|
||||
ddlContent.value = await formatSqlForDisplay(ddl, props.formatDialect ?? props.dialect, settingsStore.editorSettings.sqlFormatter);
|
||||
} catch (e: any) {
|
||||
ddlError.value = e?.message || String(e);
|
||||
} finally {
|
||||
ddlLoading.value = false;
|
||||
}
|
||||
await loadDdl();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
|
@ -171,21 +186,8 @@ onUnmounted(() => {
|
|||
});
|
||||
|
||||
function retry() {
|
||||
ddlError.value = "";
|
||||
ddlLoading.value = true;
|
||||
ddlContent.value = "";
|
||||
const schema = props.schema || props.database;
|
||||
api
|
||||
.getTableDisplayDdl(props.connectionId, props.database, schema, props.tableName, props.objectType)
|
||||
.then(async (ddl) => {
|
||||
ddlContent.value = await formatSqlForDisplay(ddl, props.formatDialect ?? props.dialect, settingsStore.editorSettings.sqlFormatter);
|
||||
})
|
||||
.catch((e: any) => {
|
||||
ddlError.value = e?.message || String(e);
|
||||
})
|
||||
.finally(() => {
|
||||
ddlLoading.value = false;
|
||||
});
|
||||
void loadDdl(true);
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
|
|
@ -218,6 +220,10 @@ function onClose() {
|
|||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="onClose">{{ t("common.close") }}</Button>
|
||||
<Button variant="outline" :disabled="ddlLoading" :title="t('structureEditor.refresh')" @click="loadDdl(true)">
|
||||
<RefreshCw class="h-4 w-4" />
|
||||
{{ t("structureEditor.refresh") }}
|
||||
</Button>
|
||||
<Button variant="outline" :disabled="!ddlContent" @click="copyDdlContent">
|
||||
<Clipboard class="h-4 w-4" />
|
||||
{{ t("grid.copyDdl") }}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ const mocks = vi.hoisted(() => ({
|
|||
listDataTypes: vi.fn(),
|
||||
buildTableStructureChangeSql: vi.fn(),
|
||||
updateEditorSettings: vi.fn(),
|
||||
loadObjectDdl: vi.fn(),
|
||||
invalidateObjectDdl: vi.fn(),
|
||||
loadObjectMetadataFacet: vi.fn(),
|
||||
invalidateTableMetadataCache: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
|
|
@ -184,6 +188,12 @@ vi.mock("@/stores/settingsStore", () => ({
|
|||
vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: { value: false } }) }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
|
||||
vi.mock("@/lib/sql/sqlHighlighter", () => ({ createShikiSqlHighlighter: vi.fn(async () => (sql: string) => sql) }));
|
||||
vi.mock("@/lib/metadata/objectDdlCache", () => ({
|
||||
loadObjectDdl: mocks.loadObjectDdl,
|
||||
invalidateObjectDdl: mocks.invalidateObjectDdl,
|
||||
}));
|
||||
vi.mock("@/lib/metadata/objectMetadataCache", () => ({ loadObjectMetadataFacet: mocks.loadObjectMetadataFacet }));
|
||||
vi.mock("@/lib/metadata/tableMetadataCache", () => ({ invalidateTableMetadataCache: mocks.invalidateTableMetadataCache }));
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
listDataTypes: mocks.listDataTypes,
|
||||
buildTableStructureChangeSql: mocks.buildTableStructureChangeSql,
|
||||
|
|
@ -254,6 +264,33 @@ async function mountEditor(databaseType: "dameng" | "oracle", isPrimaryKey = fal
|
|||
return root;
|
||||
}
|
||||
|
||||
async function mountLoadingEditor(initialTab: "columns" | "indexes" | "foreignKeys" | "triggers" | "ddl") {
|
||||
mocks.connection.db_type = "postgres";
|
||||
mocks.connection.name = "postgres";
|
||||
mocks.connection.driver_label = "postgres";
|
||||
mocks.ensureConnected.mockResolvedValue(undefined);
|
||||
mocks.listDataTypes.mockResolvedValue([]);
|
||||
mocks.buildTableStructureChangeSql.mockResolvedValue({ statements: [], warnings: [] });
|
||||
mocks.loadObjectDdl.mockResolvedValue({ ddl: "CREATE TABLE users (id bigint)", cacheStatus: "remote" });
|
||||
mocks.loadObjectMetadataFacet.mockImplementation(async (_request, facet: string) => ({ value: facet === "comment" ? "" : [], cacheStatus: "remote" }));
|
||||
|
||||
const root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
const app = createApp(TableStructureEditor, {
|
||||
connectionId: mocks.connection.id,
|
||||
database: "test",
|
||||
schema: "public",
|
||||
tableName: "users",
|
||||
initialTab,
|
||||
});
|
||||
mountedApps.push(app);
|
||||
app.mount(root);
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
return root;
|
||||
}
|
||||
|
||||
function columnCheckbox(root: HTMLElement, header: string): HTMLInputElement {
|
||||
const headerIndex = Array.from(root.querySelectorAll("thead th")).findIndex((cell) => cell.textContent?.trim() === header);
|
||||
if (headerIndex < 0) throw new Error(`Missing ${header} column`);
|
||||
|
|
@ -266,6 +303,9 @@ function columnCheckbox(root: HTMLElement, header: string): HTMLInputElement {
|
|||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.loadObjectDdl.mockResolvedValue({ ddl: "CREATE TABLE users (id bigint)", cacheStatus: "remote" });
|
||||
mocks.invalidateObjectDdl.mockResolvedValue(undefined);
|
||||
mocks.loadObjectMetadataFacet.mockResolvedValue({ value: [], cacheStatus: "remote" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -322,3 +362,25 @@ describe("TableStructureEditor primary key editing", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TableStructureEditor metadata loading", () => {
|
||||
it("opens the initial DDL tab without starting structure metadata loads", async () => {
|
||||
await mountLoadingEditor("ddl");
|
||||
|
||||
await vi.waitFor(() => expect(mocks.loadObjectDdl).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.loadObjectMetadataFacet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["columns", ["columns", "comment"]],
|
||||
["indexes", ["columns", "indexes", "comment"]],
|
||||
["foreignKeys", ["columns", "foreign-keys", "comment"]],
|
||||
["triggers", ["triggers", "comment"]],
|
||||
] as const)("loads only the required facets for the initial %s tab", async (initialTab, expectedFacets) => {
|
||||
await mountLoadingEditor(initialTab);
|
||||
|
||||
await vi.waitFor(() => expect(mocks.loadObjectMetadataFacet).toHaveBeenCalledTimes(expectedFacets.length));
|
||||
expect(mocks.loadObjectMetadataFacet.mock.calls.map((call) => call[1])).toEqual(expectedFacets);
|
||||
expect(mocks.loadObjectDdl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ import { copyToClipboard } from "@/lib/common/clipboard";
|
|||
import { formatSqlForDisplay, sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter";
|
||||
import { queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
import { invalidateObjectDdl, loadObjectDdl } from "@/lib/metadata/objectDdlCache";
|
||||
import { loadObjectMetadataFacet, type ObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
|
||||
import { invalidateTableMetadataCache } from "@/lib/metadata/tableMetadataCache";
|
||||
import { type BuildTableStructureChangeSqlOptions, type EditableStructureColumn, type EditableStructureForeignKey, type EditableStructureIndex, type EditableStructureTrigger } from "@/lib/table/tableStructureEditorSql";
|
||||
import { PRESET_FIELDS_TEMPLATE_ID, createTableColumnTemplateDrafts } from "@/lib/table/tableColumnTemplates";
|
||||
import { getMysqlDataTypeHelp } from "@/lib/table/mysqlDataTypeHelp";
|
||||
|
|
@ -138,6 +141,8 @@ const foreignKeysLoading = ref(false);
|
|||
const triggersLoading = ref(false);
|
||||
const ddlContent = ref("");
|
||||
const ddlLoading = ref(false);
|
||||
const loadedMetadataFacets = new Set<ObjectMetadataFacet>();
|
||||
let structureEditorReady = false;
|
||||
const ddlPreRef = ref<HTMLPreElement | null>(null);
|
||||
function onDdlKeydown(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "a") {
|
||||
|
|
@ -153,11 +158,21 @@ function onDdlKeydown(e: KeyboardEvent) {
|
|||
}
|
||||
const ddlFetched = ref(false);
|
||||
|
||||
async function fetchDdl() {
|
||||
if (!props.connectionId || !props.database || !props.tableName || ddlFetched.value || !tableMetadataCapabilities.value.ddl) return;
|
||||
function ddlRequest() {
|
||||
return {
|
||||
connectionId: props.connectionId,
|
||||
database: props.database,
|
||||
schema: metadataSchema.value,
|
||||
tableName: props.tableName,
|
||||
catalog: props.catalog,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchDdl(force = false) {
|
||||
if (!props.connectionId || !props.database || !props.tableName || (!force && ddlFetched.value) || !tableMetadataCapabilities.value.ddl) return;
|
||||
ddlLoading.value = true;
|
||||
try {
|
||||
const ddl = await api.getTableDisplayDdl(props.connectionId, props.database, metadataSchema.value, props.tableName, undefined, props.catalog);
|
||||
const { ddl } = await loadObjectDdl(ddlRequest(), { force });
|
||||
ddlContent.value = await formatSqlForDisplay(ddl, sqlFormatDialectForDbType(databaseType.value), settingsStore.editorSettings.sqlFormatter);
|
||||
ddlFetched.value = true;
|
||||
} catch (e: any) {
|
||||
|
|
@ -959,10 +974,10 @@ async function hydrateRestoredDraftFromDatabase() {
|
|||
let shouldRefreshPreview = false;
|
||||
try {
|
||||
await store.ensureConnected(connectionId);
|
||||
let nextColumns = await api.getColumns(connectionId, database, schema, tableName, catalog);
|
||||
let { value: nextColumns } = await loadObjectMetadataFacet({ connectionId, database, schema, tableName, catalog }, "columns", () => api.getColumns(connectionId, database, schema, tableName, catalog));
|
||||
if (databaseType.value === "manticoresearch" && tableMetadataCapabilities.value.ddl) {
|
||||
try {
|
||||
const ddl = await api.getTableDisplayDdl(connectionId, database, schema, tableName, undefined, catalog);
|
||||
const { ddl } = await loadObjectDdl({ connectionId, database, schema, tableName, catalog });
|
||||
ddlContent.value = await formatSqlForDisplay(ddl, sqlFormatDialectForDbType(databaseType.value), settingsStore.editorSettings.sqlFormatter);
|
||||
ddlFetched.value = true;
|
||||
nextColumns = applyManticoreDdlColumnExtras(nextColumns, ddl);
|
||||
|
|
@ -1190,6 +1205,7 @@ function resetState() {
|
|||
selectedColumnId.value = null;
|
||||
ddlContent.value = "";
|
||||
ddlFetched.value = false;
|
||||
loadedMetadataFacets.clear();
|
||||
newTableName.value = "";
|
||||
tableComment.value = "";
|
||||
originalTableComment.value = "";
|
||||
|
|
@ -1209,7 +1225,17 @@ async function reloadStructureFromDatabase() {
|
|||
triggers.value = [];
|
||||
triggersLoaded.value = false;
|
||||
}
|
||||
await loadStructure(false, visibleTableStructureRefreshScope(activeTab.value), true, { blockSecondaryMetadata: true });
|
||||
const refreshDdl = activeTab.value === "ddl";
|
||||
const metadataMatch = { connectionId: props.connectionId, database: props.database, schema: metadataSchema.value, tableName: props.tableName };
|
||||
invalidateTableMetadataCache(metadataMatch);
|
||||
await invalidateObjectDdl(ddlRequest());
|
||||
loadedMetadataFacets.clear();
|
||||
if (refreshDdl) {
|
||||
ddlFetched.value = false;
|
||||
await fetchDdl(true);
|
||||
} else {
|
||||
await loadStructure(false, visibleTableStructureRefreshScope(activeTab.value), true, { blockSecondaryMetadata: true, forceDdl: true, forceMetadata: true });
|
||||
}
|
||||
}
|
||||
|
||||
function setSecondaryMetadataLoading(scope: TableStructureRefreshScope, value: boolean) {
|
||||
|
|
@ -1218,6 +1244,15 @@ function setSecondaryMetadataLoading(scope: TableStructureRefreshScope, value: b
|
|||
if (scope.triggers && tableMetadataCapabilities.value.triggers) triggersLoading.value = value;
|
||||
}
|
||||
|
||||
function hasLoadedMetadataScope(scope: TableStructureRefreshScope): boolean {
|
||||
if (scope.columns && !loadedMetadataFacets.has("columns")) return false;
|
||||
if (scope.indexes && !loadedMetadataFacets.has("indexes")) return false;
|
||||
if (scope.foreignKeys && !loadedMetadataFacets.has("foreign-keys")) return false;
|
||||
if (scope.triggers && !loadedMetadataFacets.has("triggers")) return false;
|
||||
if (scope.tableComment && !loadedMetadataFacets.has("comment")) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function fetchTableCommentValue(connectionId: string, database: string, schema: string, tableName: string, catalog?: string): Promise<string | undefined> {
|
||||
try {
|
||||
return (await api.getTableComment(connectionId, database, schema, tableName, catalog)) || "";
|
||||
|
|
@ -1232,7 +1267,16 @@ async function fetchTableCommentValue(connectionId: string, database: string, sc
|
|||
}
|
||||
}
|
||||
|
||||
async function loadStructure(silent = false, scope: TableStructureRefreshScope = visibleTableStructureRefreshScope(activeTab.value), showErrors = true, options: { blockSecondaryMetadata?: boolean; preserveDraft?: boolean; damengLengthUnitsAfterSave?: ReadonlyMap<string, string> } = {}) {
|
||||
function loadCachedTableComment(request: ReturnType<typeof ddlRequest>, force = false): Promise<{ value: string | undefined; cacheStatus: "disk" | "remote" }> {
|
||||
return loadObjectMetadataFacet(request, "comment", () => fetchTableCommentValue(request.connectionId, request.database, request.schema, request.tableName, request.catalog), { force });
|
||||
}
|
||||
|
||||
async function loadStructure(
|
||||
silent = false,
|
||||
scope: TableStructureRefreshScope = visibleTableStructureRefreshScope(activeTab.value),
|
||||
showErrors = true,
|
||||
options: { blockSecondaryMetadata?: boolean; preserveDraft?: boolean; damengLengthUnitsAfterSave?: ReadonlyMap<string, string>; forceDdl?: boolean; forceMetadata?: boolean } = {},
|
||||
) {
|
||||
const connectionId = props.connectionId;
|
||||
const database = props.database;
|
||||
const catalog = props.catalog;
|
||||
|
|
@ -1248,17 +1292,31 @@ async function loadStructure(silent = false, scope: TableStructureRefreshScope =
|
|||
try {
|
||||
await store.ensureConnected(connectionId);
|
||||
|
||||
const columnsPromise = scope.columns ? api.getColumns(connectionId, database, schema, tableName, catalog) : Promise.resolve(undefined);
|
||||
const indexesPromise = scope.indexes ? (tableMetadataCapabilities.value.indexes ? api.listIndexes(connectionId, database, schema, tableName, catalog).catch(() => []) : Promise.resolve([])) : Promise.resolve(undefined);
|
||||
const foreignKeysPromise = scope.foreignKeys ? (tableMetadataCapabilities.value.foreignKeys ? api.listForeignKeys(connectionId, database, schema, tableName, catalog).catch(() => []) : Promise.resolve([])) : Promise.resolve(undefined);
|
||||
const triggersPromise = scope.triggers ? (tableMetadataCapabilities.value.triggers ? api.listTriggers(connectionId, database, schema, tableName, catalog).catch(() => []) : Promise.resolve([])) : Promise.resolve(undefined);
|
||||
const tableCommentPromise = scope.tableComment && structureCapabilities.value.comment ? fetchTableCommentValue(connectionId, database, schema, tableName, catalog) : Promise.resolve(undefined);
|
||||
const metadataRequest = ddlRequest();
|
||||
const forceMetadata = options.forceMetadata === true;
|
||||
const columnsPromise = scope.columns ? loadObjectMetadataFacet(metadataRequest, "columns", () => api.getColumns(connectionId, database, schema, tableName, catalog), { force: forceMetadata }).then((result) => result.value) : Promise.resolve(undefined);
|
||||
const indexesPromise = scope.indexes
|
||||
? tableMetadataCapabilities.value.indexes
|
||||
? loadObjectMetadataFacet(metadataRequest, "indexes", () => api.listIndexes(connectionId, database, schema, tableName, catalog).catch(() => []), { force: forceMetadata }).then((result) => result.value)
|
||||
: Promise.resolve([])
|
||||
: Promise.resolve(undefined);
|
||||
const foreignKeysPromise = scope.foreignKeys
|
||||
? tableMetadataCapabilities.value.foreignKeys
|
||||
? loadObjectMetadataFacet(metadataRequest, "foreign-keys", () => api.listForeignKeys(connectionId, database, schema, tableName, catalog).catch(() => []), { force: forceMetadata }).then((result) => result.value)
|
||||
: Promise.resolve([])
|
||||
: Promise.resolve(undefined);
|
||||
const triggersPromise = scope.triggers
|
||||
? tableMetadataCapabilities.value.triggers
|
||||
? loadObjectMetadataFacet(metadataRequest, "triggers", () => api.listTriggers(connectionId, database, schema, tableName, catalog).catch(() => []), { force: forceMetadata }).then((result) => result.value)
|
||||
: Promise.resolve([])
|
||||
: Promise.resolve(undefined);
|
||||
const tableCommentPromise = scope.tableComment && structureCapabilities.value.comment ? loadCachedTableComment(metadataRequest, forceMetadata).then((result) => result.value) : Promise.resolve(undefined);
|
||||
|
||||
let nextColumns = await columnsPromise;
|
||||
if (nextColumns) {
|
||||
if (databaseType.value === "manticoresearch" && tableMetadataCapabilities.value.ddl) {
|
||||
try {
|
||||
const ddl = await api.getTableDisplayDdl(connectionId, database, schema, tableName, undefined, catalog);
|
||||
const { ddl } = await loadObjectDdl({ connectionId, database, schema, tableName, catalog }, { force: options.forceDdl });
|
||||
ddlContent.value = await formatSqlForDisplay(ddl, sqlFormatDialectForDbType(databaseType.value), settingsStore.editorSettings.sqlFormatter);
|
||||
ddlFetched.value = true;
|
||||
nextColumns = applyManticoreDdlColumnExtras(nextColumns, ddl);
|
||||
|
|
@ -1272,6 +1330,7 @@ async function loadStructure(silent = false, scope: TableStructureRefreshScope =
|
|||
const nextColumnDrafts = createColumnDrafts(nextColumns, databaseType.value);
|
||||
const hydratedColumnDrafts = databaseType.value === "dameng" && options.damengLengthUnitsAfterSave ? restoreDamengLengthUnitsAfterSave(nextColumnDrafts, options.damengLengthUnitsAfterSave) : nextColumnDrafts;
|
||||
columns.value = applyStoredLocalColumnOrder(hydratedColumnDrafts);
|
||||
loadedMetadataFacets.add("columns");
|
||||
if (!options.preserveDraft) selectedColumnId.value = null;
|
||||
}
|
||||
|
||||
|
|
@ -1279,15 +1338,23 @@ async function loadStructure(silent = false, scope: TableStructureRefreshScope =
|
|||
if (nextTableComment !== undefined) {
|
||||
originalTableComment.value = nextTableComment;
|
||||
tableComment.value = nextTableComment;
|
||||
loadedMetadataFacets.add("comment");
|
||||
}
|
||||
const applySecondaryMetadata = async () => {
|
||||
const [nextIndexes, nextForeignKeys, nextTriggers] = await Promise.all([indexesPromise, foreignKeysPromise, triggersPromise]);
|
||||
if (requestId !== structureLoadRequestId) return;
|
||||
if (nextIndexes) indexes.value = createIndexDrafts(nextIndexes);
|
||||
if (nextForeignKeys) foreignKeys.value = createForeignKeyDrafts(nextForeignKeys);
|
||||
if (nextIndexes) {
|
||||
indexes.value = createIndexDrafts(nextIndexes);
|
||||
loadedMetadataFacets.add("indexes");
|
||||
}
|
||||
if (nextForeignKeys) {
|
||||
foreignKeys.value = createForeignKeyDrafts(nextForeignKeys);
|
||||
loadedMetadataFacets.add("foreign-keys");
|
||||
}
|
||||
if (nextTriggers) {
|
||||
triggers.value = createTriggerDrafts(nextTriggers);
|
||||
triggersLoaded.value = true;
|
||||
loadedMetadataFacets.add("triggers");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1327,7 +1394,7 @@ async function refreshStructureAfterSave(scope: TableStructureRefreshScope, dame
|
|||
console.warn("[DBX][structure-editor:post-save-refresh-failed]", e);
|
||||
} finally {
|
||||
postSaveRefreshing.value = false;
|
||||
if (activeTab.value === "ddl") void fetchDdl();
|
||||
if (activeTab.value === "ddl") void fetchDdl(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2249,6 +2316,11 @@ async function applyChanges() {
|
|||
? await api.applySqliteTableStructureChange(props.connectionId, props.database, structureChangeOptions(), sqliteSchemaRevision.value!)
|
||||
: await api.executeBatch(props.connectionId, props.database, pendingStatements.value, props.schema, queryTimeoutSecsForConnection(connection));
|
||||
await recordStructureHistory(sql, startedAt, true, result);
|
||||
if (!isCreateMode.value && props.tableName) {
|
||||
invalidateTableMetadataCache({ connectionId: props.connectionId, database: props.database, schema: metadataSchema.value, tableName: props.tableName });
|
||||
await invalidateObjectDdl(ddlRequest());
|
||||
loadedMetadataFacets.clear();
|
||||
}
|
||||
toast(t("structureEditor.saved"), 2500);
|
||||
pendingStatements.value = [];
|
||||
warnings.value = [];
|
||||
|
|
@ -2345,9 +2417,14 @@ onMounted(() => {
|
|||
// A restored draft owns its saved tab unless navigation explicitly requested another one.
|
||||
applyInitialStructureTab(false);
|
||||
applyInitialStructureTarget();
|
||||
}
|
||||
structureEditorReady = true;
|
||||
if (props.draft?.initialized) {
|
||||
void hydrateRestoredDraftFromDatabase().then(() => applyInitialStructureTarget());
|
||||
} else if (isCreateMode.value) {
|
||||
markDraftHydratedAndSync();
|
||||
} else if (activeTab.value === "ddl") {
|
||||
void fetchDdl();
|
||||
} else {
|
||||
void loadStructure(false, visibleTableStructureRefreshScope(activeTab.value), true, { blockSecondaryMetadata: true }).then(() => applyInitialStructureTarget());
|
||||
}
|
||||
|
|
@ -2521,11 +2598,17 @@ watch(refreshVersion, (version, previous) => {
|
|||
watch(
|
||||
activeTab,
|
||||
(tab) => {
|
||||
if (!structureEditorReady) return;
|
||||
if (tab === "ddl") {
|
||||
void fetchDdl();
|
||||
} else if (!isCreateMode.value && !props.draft?.initialized && !loading.value) {
|
||||
const scope = visibleTableStructureRefreshScope(tab);
|
||||
if (!hasLoadedMetadataScope(scope)) {
|
||||
void loadStructure(false, scope, true, { blockSecondaryMetadata: true }).then(() => applyInitialStructureTarget());
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
{ flush: "sync" },
|
||||
);
|
||||
|
||||
watch([activeTab, ddlLoading], ([tab, loading]) => {
|
||||
|
|
@ -2543,7 +2626,7 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
|
|||
<Database :class="[structureIconClass, 'text-muted-foreground']" />
|
||||
<span class="min-w-0 flex-1 truncate font-medium">{{ targetLabel || t("editor.noDatabase") }}</span>
|
||||
<Badge variant="outline">{{ connection?.driver_label || databaseType }}</Badge>
|
||||
<Button v-if="!isCreateMode" variant="ghost" size="sm" :class="structureToolbarButtonClass" :disabled="loading || saving" @click="reloadStructureFromDatabase">
|
||||
<Button v-if="!isCreateMode" variant="ghost" size="sm" :class="structureToolbarButtonClass" :disabled="loading || saving || ddlLoading" @click="reloadStructureFromDatabase">
|
||||
<RefreshCw :class="structureIconClass" />
|
||||
{{ t("structureEditor.refresh") }}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ describe("ContentArea external catalog wiring", () => {
|
|||
expect(openingTag(connectionTreeSource, "SidebarDdlViewDialog")).toContain(':catalog="sidebarDdlTarget.catalog"');
|
||||
});
|
||||
|
||||
it("forwards the DDL dialog catalog to the metadata API", () => {
|
||||
expect(ddlViewDialogSource).toMatch(/api\.getTableDisplayDdl\([\s\S]*?props\.objectType, props\.catalog\)/);
|
||||
it("forwards the DDL dialog catalog to the persistent DDL loader", () => {
|
||||
expect(ddlViewDialogSource).toMatch(/loadObjectDdl\([\s\S]*?objectType: props\.objectType,[\s\S]*?catalog: props\.catalog/);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ import { describe, expect, it } from "vitest";
|
|||
import { shouldLoadTableStructureTriggers, visibleTableStructureRefreshScope } from "@/lib/table/tableStructureMetadataLoading";
|
||||
|
||||
describe("table structure metadata loading", () => {
|
||||
it("does not request triggers while opening the default columns tab", () => {
|
||||
expect(visibleTableStructureRefreshScope("columns").triggers).toBe(false);
|
||||
});
|
||||
|
||||
it("requests triggers when the structure editor opens on the trigger tab", () => {
|
||||
expect(visibleTableStructureRefreshScope("triggers").triggers).toBe(true);
|
||||
it.each([
|
||||
["columns", { columns: true, indexes: false, foreignKeys: false, triggers: false, tableComment: true }],
|
||||
["indexes", { columns: true, indexes: true, foreignKeys: false, triggers: false, tableComment: true }],
|
||||
["foreignKeys", { columns: true, indexes: false, foreignKeys: true, triggers: false, tableComment: true }],
|
||||
["triggers", { columns: false, indexes: false, foreignKeys: false, triggers: true, tableComment: true }],
|
||||
["ddl", { columns: false, indexes: false, foreignKeys: false, triggers: false, tableComment: false }],
|
||||
] as const)("requests only the metadata required by the %s tab", (tab, expected) => {
|
||||
expect(visibleTableStructureRefreshScope(tab)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("loads trigger metadata once when the trigger tab becomes visible", () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { buildHoverTableSql, hoverTableMatchesScope, reformatHoverDdl, sanitizeHoverDdl, scopeHoverTables } from "@/lib/editor/hoverTableSql";
|
||||
import { buildHoverTableSql, ddlForHoverPreview, hoverTableMatchesScope, reformatHoverDdl, sanitizeHoverDdl, scopeHoverTables } from "@/lib/editor/hoverTableSql";
|
||||
import type { ColumnInfo, IndexInfo } from "@/types/database";
|
||||
|
||||
type ColumnOverride = Partial<ColumnInfo> & { name: string; data_type: string };
|
||||
|
|
@ -207,6 +207,27 @@ describe("buildHoverTableSql", () => {
|
|||
});
|
||||
|
||||
describe("reformatHoverDdl", () => {
|
||||
it("removes the PostgreSQL access-control tail from canonical display DDL", () => {
|
||||
const displayDdl = `CREATE TABLE "public"."users" (
|
||||
"id" bigint NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE "public"."users" OWNER TO "app_owner";
|
||||
|
||||
SET ROLE "app_owner";
|
||||
GRANT SELECT ON TABLE "public"."users" TO "reporter";
|
||||
RESET ROLE;`;
|
||||
|
||||
expect(ddlForHoverPreview(displayDdl)).toBe(`CREATE TABLE "public"."users" (
|
||||
"id" bigint NOT NULL
|
||||
);`);
|
||||
});
|
||||
|
||||
it("keeps non-PostgreSQL and structural companion statements unchanged", () => {
|
||||
const ddl = "CREATE TABLE t (id int);\nCREATE INDEX ix_t_id ON t (id);";
|
||||
expect(ddlForHoverPreview(ddl)).toBe(ddl);
|
||||
});
|
||||
|
||||
it("preserves sanitized raw MySQL DDL when table options are present", () => {
|
||||
const raw = `CREATE TABLE \`users\` (
|
||||
\`id\` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
|
|
|
|||
|
|
@ -106,6 +106,16 @@ export function sanitizeHoverDdl(ddl: string): string {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The persisted display DDL is the canonical object definition. Native
|
||||
* PostgreSQL appends owner and grant statements after the structural DDL;
|
||||
* quick-look hover keeps the structure and omits that access-control tail.
|
||||
*/
|
||||
export function ddlForHoverPreview(ddl: string): string {
|
||||
const accessTail = /\n\s*ALTER\s+TABLE\s+[^\n;]+\s+OWNER\s+TO\s+[^\n;]+;/i.exec(ddl);
|
||||
return accessTail ? ddl.slice(0, accessTail.index).trimEnd() : ddl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display fields of a single column line, prior to vertical alignment.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getTableDisplayDdl: vi.fn(),
|
||||
saveSchemaCache: vi.fn(),
|
||||
loadSchemaCache: vi.fn(),
|
||||
deleteSchemaCachePrefix: vi.fn(),
|
||||
persisted: new Map<string, unknown>(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => mocks);
|
||||
|
||||
import { invalidateObjectDdl, invalidateObjectDdlCache, loadObjectDdl, objectDdlCacheKey } from "@/lib/metadata/objectDdlCache";
|
||||
|
||||
const request = { connectionId: "c1", database: "app", schema: "public", tableName: "users", catalog: "analytics" } as const;
|
||||
|
||||
describe("objectDdlCache", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.persisted.clear();
|
||||
mocks.loadSchemaCache.mockImplementation(async (cacheKey: string) => mocks.persisted.get(cacheKey) ?? null);
|
||||
mocks.saveSchemaCache.mockImplementation(async (cacheKey: string, payload: unknown) => {
|
||||
mocks.persisted.set(cacheKey, payload);
|
||||
});
|
||||
mocks.deleteSchemaCachePrefix.mockImplementation(async (prefix: string) => {
|
||||
for (const cacheKey of mocks.persisted.keys()) {
|
||||
if (cacheKey === prefix || cacheKey.startsWith(prefix)) mocks.persisted.delete(cacheKey);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("returns persisted DDL without querying the database", async () => {
|
||||
mocks.loadSchemaCache.mockResolvedValue({ version: 1, cachedAt: new Date().toISOString(), ddl: "CREATE TABLE users (id int)" });
|
||||
|
||||
await expect(loadObjectDdl(request)).resolves.toEqual({ ddl: "CREATE TABLE users (id int)", cacheStatus: "disk" });
|
||||
expect(mocks.getTableDisplayDdl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists a remote cache miss", async () => {
|
||||
mocks.getTableDisplayDdl.mockResolvedValue("CREATE TABLE users (id bigint)");
|
||||
|
||||
await expect(loadObjectDdl(request)).resolves.toEqual({ ddl: "CREATE TABLE users (id bigint)", cacheStatus: "remote" });
|
||||
expect(mocks.saveSchemaCache).toHaveBeenCalledWith(objectDdlCacheKey(request), expect.objectContaining({ version: 1, ddl: "CREATE TABLE users (id bigint)" }));
|
||||
});
|
||||
|
||||
it("deduplicates concurrent remote loads", async () => {
|
||||
let release: (ddl: string) => void = () => {};
|
||||
mocks.getTableDisplayDdl.mockReturnValue(
|
||||
new Promise<string>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const first = loadObjectDdl(request);
|
||||
const second = loadObjectDdl(request);
|
||||
await vi.waitFor(() => expect(mocks.getTableDisplayDdl).toHaveBeenCalledTimes(1));
|
||||
|
||||
release("CREATE TABLE users (id int)");
|
||||
await expect(Promise.all([first, second])).resolves.toHaveLength(2);
|
||||
});
|
||||
|
||||
it("force refresh bypasses disk and overwrites it", async () => {
|
||||
mocks.loadSchemaCache.mockResolvedValue({ version: 1, cachedAt: new Date().toISOString(), ddl: "old ddl" });
|
||||
mocks.getTableDisplayDdl.mockResolvedValue("new ddl");
|
||||
|
||||
await expect(loadObjectDdl(request, { force: true })).resolves.toEqual({ ddl: "new ddl", cacheStatus: "remote" });
|
||||
expect(mocks.loadSchemaCache).not.toHaveBeenCalled();
|
||||
expect(mocks.saveSchemaCache).toHaveBeenCalledWith(objectDdlCacheKey(request), expect.objectContaining({ ddl: "new ddl" }));
|
||||
});
|
||||
|
||||
it("deletes the exact persisted entry", async () => {
|
||||
await invalidateObjectDdl(request);
|
||||
expect(mocks.deleteSchemaCachePrefix).toHaveBeenCalledWith(objectDdlCacheKey(request));
|
||||
});
|
||||
|
||||
it("reloads from the database after table-level persisted cache invalidation", async () => {
|
||||
mocks.getTableDisplayDdl.mockResolvedValueOnce("old ddl").mockResolvedValueOnce("new ddl");
|
||||
|
||||
await expect(loadObjectDdl(request)).resolves.toEqual({ ddl: "old ddl", cacheStatus: "remote" });
|
||||
await invalidateObjectDdlCache({ connectionId: request.connectionId, database: request.database, schema: request.schema, tableName: request.tableName });
|
||||
await expect(loadObjectDdl(request)).resolves.toEqual({ ddl: "new ddl", cacheStatus: "remote" });
|
||||
expect(mocks.getTableDisplayDdl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not persist an in-flight result across invalidation", async () => {
|
||||
let release: (ddl: string) => void = () => {};
|
||||
mocks.getTableDisplayDdl.mockReturnValue(
|
||||
new Promise<string>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const load = loadObjectDdl(request);
|
||||
await vi.waitFor(() => expect(mocks.getTableDisplayDdl).toHaveBeenCalledTimes(1));
|
||||
|
||||
await invalidateObjectDdlCache({ connectionId: request.connectionId, database: request.database, schema: request.schema });
|
||||
release("stale ddl");
|
||||
await expect(load).resolves.toEqual({ ddl: "stale ddl", cacheStatus: "remote" });
|
||||
expect(mocks.saveSchemaCache).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
saveSchemaCache: vi.fn(),
|
||||
loadSchemaCache: vi.fn(),
|
||||
deleteSchemaCachePrefix: vi.fn(),
|
||||
persisted: new Map<string, unknown>(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => mocks);
|
||||
|
||||
import { invalidateObjectMetadataCache, loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
|
||||
|
||||
const request = { connectionId: "c1", database: "app", schema: "public", tableName: "users", catalog: "analytics" } as const;
|
||||
|
||||
describe("objectMetadataCache", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.persisted.clear();
|
||||
mocks.loadSchemaCache.mockImplementation(async (cacheKey: string) => mocks.persisted.get(cacheKey) ?? null);
|
||||
mocks.saveSchemaCache.mockImplementation(async (cacheKey: string, payload: unknown) => {
|
||||
mocks.persisted.set(cacheKey, payload);
|
||||
});
|
||||
mocks.deleteSchemaCachePrefix.mockImplementation(async (prefix: string) => {
|
||||
for (const cacheKey of mocks.persisted.keys()) {
|
||||
if (cacheKey === prefix || cacheKey.startsWith(prefix)) mocks.persisted.delete(cacheKey);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("reads a facet from disk without invoking the loader", async () => {
|
||||
mocks.loadSchemaCache.mockResolvedValue({ version: 1, cachedAt: new Date().toISOString(), value: [{ name: "id" }] });
|
||||
const loader = vi.fn().mockResolvedValue([{ name: "remote" }]);
|
||||
|
||||
await expect(loadObjectMetadataFacet(request, "columns", loader)).resolves.toEqual({ value: [{ name: "id" }], cacheStatus: "disk" });
|
||||
expect(loader).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists a remote facet and force bypasses disk", async () => {
|
||||
mocks.loadSchemaCache.mockResolvedValue({ version: 1, cachedAt: new Date().toISOString(), value: ["old"] });
|
||||
const loader = vi.fn().mockResolvedValue(["new"]);
|
||||
|
||||
await expect(loadObjectMetadataFacet(request, "indexes", loader, { force: true })).resolves.toEqual({ value: ["new"], cacheStatus: "remote" });
|
||||
expect(loader).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.loadSchemaCache).not.toHaveBeenCalled();
|
||||
expect(mocks.saveSchemaCache).toHaveBeenCalledWith(expect.stringContaining("object-meta:v1:c1:app:public:users:analytics:indexes:"), expect.objectContaining({ value: ["new"] }));
|
||||
});
|
||||
|
||||
it("invalidates only the requested object's facets", async () => {
|
||||
await invalidateObjectMetadataCache(request);
|
||||
expect(mocks.deleteSchemaCachePrefix).toHaveBeenCalledWith("object-meta:v1:c1:app:public:users:");
|
||||
});
|
||||
|
||||
it("reloads a persisted facet after table-level invalidation", async () => {
|
||||
const initialLoader = vi.fn().mockResolvedValue(["old"]);
|
||||
const refreshedLoader = vi.fn().mockResolvedValue(["new"]);
|
||||
|
||||
await expect(loadObjectMetadataFacet(request, "indexes", initialLoader)).resolves.toEqual({ value: ["old"], cacheStatus: "remote" });
|
||||
await invalidateObjectMetadataCache({ connectionId: request.connectionId, database: request.database, schema: request.schema, tableName: request.tableName });
|
||||
await expect(loadObjectMetadataFacet(request, "indexes", refreshedLoader)).resolves.toEqual({ value: ["new"], cacheStatus: "remote" });
|
||||
expect(refreshedLoader).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
import * as api from "@/lib/backend/api";
|
||||
import type { ObjectSourceKind } from "@/types/database";
|
||||
import type { MetadataCacheInvalidation } from "./metadataResultCache";
|
||||
import { invalidateObjectMetadataCache } from "./objectMetadataCache";
|
||||
|
||||
const OBJECT_DDL_CACHE_PREFIX = "object-ddl:v1";
|
||||
const MAX_PERSISTED_DDL_CHARS = 5 * 1024 * 1024;
|
||||
|
||||
interface ObjectDdlCacheEnvelope {
|
||||
version: 1;
|
||||
cachedAt: string;
|
||||
ddl: string;
|
||||
}
|
||||
|
||||
interface InFlightDdlLoad {
|
||||
force: boolean;
|
||||
invalidated: boolean;
|
||||
promise: Promise<string>;
|
||||
}
|
||||
|
||||
export interface ObjectDdlRequest {
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema: string;
|
||||
tableName: string;
|
||||
objectType?: ObjectSourceKind;
|
||||
catalog?: string;
|
||||
}
|
||||
|
||||
export interface ObjectDdlLoadResult {
|
||||
ddl: string;
|
||||
cacheStatus: "disk" | "remote";
|
||||
}
|
||||
|
||||
const remoteLoads = new Map<string, InFlightDdlLoad>();
|
||||
const pendingInvalidations = new Map<string, Promise<void>>();
|
||||
|
||||
async function loadSchemaCacheSafe<T>(cacheKey: string): Promise<T | null> {
|
||||
try {
|
||||
return await api.loadSchemaCache<T>(cacheKey);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSchemaCacheSafe(cacheKey: string, payload: unknown): Promise<void> {
|
||||
try {
|
||||
await api.saveSchemaCache(cacheKey, payload);
|
||||
} catch {
|
||||
// Cache persistence is best effort and must not block DDL rendering.
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSchemaCachePrefixSafe(prefix: string): Promise<void> {
|
||||
try {
|
||||
await api.deleteSchemaCachePrefix(prefix);
|
||||
} catch {
|
||||
// Cache invalidation is best effort when running with a reduced backend.
|
||||
}
|
||||
}
|
||||
|
||||
function cacheSegment(value: string | undefined): string {
|
||||
return encodeURIComponent(value ?? "");
|
||||
}
|
||||
|
||||
export function objectDdlCacheKey(request: ObjectDdlRequest): string {
|
||||
return `${[OBJECT_DDL_CACHE_PREFIX, cacheSegment(request.connectionId), cacheSegment(request.database), cacheSegment(request.schema), cacheSegment(request.tableName), cacheSegment(request.catalog), cacheSegment(request.objectType ?? "TABLE")].join(":")}:`;
|
||||
}
|
||||
|
||||
function invalidationPrefix(match: MetadataCacheInvalidation): string {
|
||||
const parts = [OBJECT_DDL_CACHE_PREFIX];
|
||||
if (!match.connectionId) return `${OBJECT_DDL_CACHE_PREFIX}:`;
|
||||
parts.push(cacheSegment(match.connectionId ?? undefined));
|
||||
if (!match.database) return `${parts.join(":")}:`;
|
||||
parts.push(cacheSegment(match.database ?? undefined));
|
||||
if (!match.schema) return `${parts.join(":")}:`;
|
||||
parts.push(cacheSegment(match.schema ?? undefined));
|
||||
if (!match.tableName) return `${parts.join(":")}:`;
|
||||
parts.push(cacheSegment(match.tableName));
|
||||
return `${parts.join(":")}:`;
|
||||
}
|
||||
|
||||
function decodeCachedDdl(payload: unknown): string | null {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const envelope = payload as Partial<ObjectDdlCacheEnvelope>;
|
||||
if (envelope.version !== 1 || typeof envelope.cachedAt !== "string" || !Number.isFinite(Date.parse(envelope.cachedAt)) || typeof envelope.ddl !== "string") return null;
|
||||
return envelope.ddl;
|
||||
}
|
||||
|
||||
async function waitForPendingInvalidations(cacheKey: string): Promise<void> {
|
||||
const pending = [...pendingInvalidations.entries()].filter(([prefix]) => cacheKey.startsWith(prefix)).map(([, promise]) => promise);
|
||||
if (pending.length) await Promise.all(pending);
|
||||
}
|
||||
|
||||
async function loadRemoteDdl(request: ObjectDdlRequest, cacheKey: string, force: boolean): Promise<string> {
|
||||
const existing = remoteLoads.get(cacheKey);
|
||||
if (existing && (!force || existing.force)) return existing.promise;
|
||||
if (existing) {
|
||||
existing.invalidated = true;
|
||||
remoteLoads.delete(cacheKey);
|
||||
}
|
||||
|
||||
const entry: InFlightDdlLoad = { force, invalidated: false, promise: Promise.resolve("") };
|
||||
entry.promise = api
|
||||
.getTableDisplayDdl(request.connectionId, request.database, request.schema, request.tableName, request.objectType, request.catalog)
|
||||
.then(async (ddl) => {
|
||||
if (!entry.invalidated && ddl.length <= MAX_PERSISTED_DDL_CHARS) {
|
||||
const envelope: ObjectDdlCacheEnvelope = { version: 1, cachedAt: new Date().toISOString(), ddl };
|
||||
await saveSchemaCacheSafe(cacheKey, envelope);
|
||||
}
|
||||
return ddl;
|
||||
})
|
||||
.finally(() => {
|
||||
if (remoteLoads.get(cacheKey) === entry) remoteLoads.delete(cacheKey);
|
||||
});
|
||||
remoteLoads.set(cacheKey, entry);
|
||||
return entry.promise;
|
||||
}
|
||||
|
||||
export async function loadObjectDdl(request: ObjectDdlRequest, options?: { force?: boolean }): Promise<ObjectDdlLoadResult> {
|
||||
const cacheKey = objectDdlCacheKey(request);
|
||||
await waitForPendingInvalidations(cacheKey);
|
||||
|
||||
if (!options?.force) {
|
||||
const cached = decodeCachedDdl(await loadSchemaCacheSafe<unknown>(cacheKey));
|
||||
if (cached !== null) return { ddl: cached, cacheStatus: "disk" };
|
||||
}
|
||||
|
||||
return { ddl: await loadRemoteDdl(request, cacheKey, options?.force === true), cacheStatus: "remote" };
|
||||
}
|
||||
|
||||
export async function invalidateObjectDdlCache(match: MetadataCacheInvalidation): Promise<void> {
|
||||
const prefix = invalidationPrefix(match);
|
||||
for (const [cacheKey, entry] of remoteLoads) {
|
||||
if (!cacheKey.startsWith(prefix)) continue;
|
||||
entry.invalidated = true;
|
||||
remoteLoads.delete(cacheKey);
|
||||
}
|
||||
|
||||
const existing = pendingInvalidations.get(prefix);
|
||||
if (existing) return existing;
|
||||
const deletion = deleteSchemaCachePrefixSafe(prefix).finally(() => {
|
||||
if (pendingInvalidations.get(prefix) === deletion) pendingInvalidations.delete(prefix);
|
||||
});
|
||||
pendingInvalidations.set(prefix, deletion);
|
||||
await Promise.all([deletion, invalidateObjectMetadataCache(match)]);
|
||||
}
|
||||
|
||||
export async function invalidateObjectDdl(request: ObjectDdlRequest): Promise<void> {
|
||||
const cacheKey = objectDdlCacheKey(request);
|
||||
const entry = remoteLoads.get(cacheKey);
|
||||
if (entry) {
|
||||
entry.invalidated = true;
|
||||
remoteLoads.delete(cacheKey);
|
||||
}
|
||||
await Promise.all([
|
||||
deleteSchemaCachePrefixSafe(cacheKey),
|
||||
invalidateObjectMetadataCache({
|
||||
connectionId: request.connectionId,
|
||||
database: request.database,
|
||||
schema: request.schema,
|
||||
tableName: request.tableName,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
import * as api from "@/lib/backend/api";
|
||||
import type { MetadataCacheInvalidation } from "./metadataResultCache";
|
||||
import type { ObjectDdlRequest } from "./objectDdlCache";
|
||||
|
||||
const OBJECT_METADATA_CACHE_PREFIX = "object-meta:v1";
|
||||
const MAX_PERSISTED_METADATA_CHARS = 5 * 1024 * 1024;
|
||||
|
||||
interface ObjectMetadataCacheEnvelope<T> {
|
||||
version: 1;
|
||||
cachedAt: string;
|
||||
value: T;
|
||||
}
|
||||
|
||||
interface InFlightLoad<T> {
|
||||
force: boolean;
|
||||
invalidated: boolean;
|
||||
promise: Promise<T>;
|
||||
}
|
||||
|
||||
const inFlightLoads = new Map<string, InFlightLoad<unknown>>();
|
||||
const pendingInvalidations = new Map<string, Promise<void>>();
|
||||
|
||||
async function loadSchemaCacheSafe<T>(cacheKey: string): Promise<T | null> {
|
||||
try {
|
||||
return await api.loadSchemaCache<T>(cacheKey);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSchemaCacheSafe(cacheKey: string, payload: unknown): Promise<void> {
|
||||
try {
|
||||
await api.saveSchemaCache(cacheKey, payload);
|
||||
} catch {
|
||||
// Cache persistence is best effort and must not block metadata rendering.
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSchemaCachePrefixSafe(prefix: string): Promise<void> {
|
||||
try {
|
||||
await api.deleteSchemaCachePrefix(prefix);
|
||||
} catch {
|
||||
// Cache invalidation is best effort when running with a reduced backend.
|
||||
}
|
||||
}
|
||||
|
||||
export type ObjectMetadataFacet = "columns" | "indexes" | "foreign-keys" | "triggers" | "comment";
|
||||
|
||||
function cacheSegment(value: string | undefined): string {
|
||||
return encodeURIComponent(value ?? "");
|
||||
}
|
||||
|
||||
function facetKey(request: ObjectDdlRequest, facet: ObjectMetadataFacet): string {
|
||||
return `${[OBJECT_METADATA_CACHE_PREFIX, cacheSegment(request.connectionId), cacheSegment(request.database), cacheSegment(request.schema), cacheSegment(request.tableName), cacheSegment(request.catalog), facet].join(":")}:`;
|
||||
}
|
||||
|
||||
function invalidationPrefix(match: MetadataCacheInvalidation): string {
|
||||
const parts = [OBJECT_METADATA_CACHE_PREFIX];
|
||||
if (!match.connectionId) return `${OBJECT_METADATA_CACHE_PREFIX}:`;
|
||||
parts.push(cacheSegment(match.connectionId));
|
||||
if (!match.database) return `${parts.join(":")}:`;
|
||||
parts.push(cacheSegment(match.database));
|
||||
if (!match.schema) return `${parts.join(":")}:`;
|
||||
parts.push(cacheSegment(match.schema));
|
||||
if (!match.tableName) return `${parts.join(":")}:`;
|
||||
parts.push(cacheSegment(match.tableName));
|
||||
return `${parts.join(":")}:`;
|
||||
}
|
||||
|
||||
function decodeEnvelope<T>(payload: unknown): T | null {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const envelope = payload as Partial<ObjectMetadataCacheEnvelope<T>>;
|
||||
if (envelope.version !== 1 || typeof envelope.cachedAt !== "string" || !Number.isFinite(Date.parse(envelope.cachedAt)) || !("value" in envelope)) return null;
|
||||
return envelope.value === undefined ? null : envelope.value;
|
||||
}
|
||||
|
||||
async function waitForPendingInvalidations(cacheKey: string): Promise<void> {
|
||||
const pending = [...pendingInvalidations.entries()].filter(([prefix]) => cacheKey.startsWith(prefix)).map(([, promise]) => promise);
|
||||
if (pending.length) await Promise.all(pending);
|
||||
}
|
||||
|
||||
export async function loadObjectMetadataFacet<T>(request: ObjectDdlRequest, facet: ObjectMetadataFacet, loader: () => Promise<T>, options?: { force?: boolean }): Promise<{ value: T; cacheStatus: "disk" | "remote" }> {
|
||||
const cacheKey = facetKey(request, facet);
|
||||
await waitForPendingInvalidations(cacheKey);
|
||||
|
||||
if (!options?.force) {
|
||||
const cached = decodeEnvelope<T>(await loadSchemaCacheSafe<unknown>(cacheKey));
|
||||
if (cached !== null) return { value: cached, cacheStatus: "disk" };
|
||||
}
|
||||
|
||||
const existing = inFlightLoads.get(cacheKey);
|
||||
if (existing && (!options?.force || existing.force)) return { value: (await existing.promise) as T, cacheStatus: "remote" };
|
||||
if (existing) {
|
||||
existing.invalidated = true;
|
||||
inFlightLoads.delete(cacheKey);
|
||||
}
|
||||
|
||||
const entry: InFlightLoad<T> = { force: options?.force === true, invalidated: false, promise: Promise.resolve(undefined as T) };
|
||||
entry.promise = loader()
|
||||
.then(async (value) => {
|
||||
const serialized = JSON.stringify(value);
|
||||
if (!entry.invalidated && serialized.length <= MAX_PERSISTED_METADATA_CHARS) {
|
||||
const envelope: ObjectMetadataCacheEnvelope<T> = { version: 1, cachedAt: new Date().toISOString(), value };
|
||||
await saveSchemaCacheSafe(cacheKey, envelope);
|
||||
}
|
||||
return value;
|
||||
})
|
||||
.finally(() => {
|
||||
if (inFlightLoads.get(cacheKey) === entry) inFlightLoads.delete(cacheKey);
|
||||
});
|
||||
inFlightLoads.set(cacheKey, entry as InFlightLoad<unknown>);
|
||||
return { value: await entry.promise, cacheStatus: "remote" };
|
||||
}
|
||||
|
||||
export async function invalidateObjectMetadataCache(match: MetadataCacheInvalidation): Promise<void> {
|
||||
const prefix = invalidationPrefix(match);
|
||||
for (const [cacheKey, entry] of inFlightLoads) {
|
||||
if (!cacheKey.startsWith(prefix)) continue;
|
||||
entry.invalidated = true;
|
||||
inFlightLoads.delete(cacheKey);
|
||||
}
|
||||
|
||||
const existing = pendingInvalidations.get(prefix);
|
||||
if (existing) return existing;
|
||||
const deletion = deleteSchemaCachePrefixSafe(prefix).finally(() => {
|
||||
if (pendingInvalidations.get(prefix) === deletion) pendingInvalidations.delete(prefix);
|
||||
});
|
||||
pendingInvalidations.set(prefix, deletion);
|
||||
return deletion;
|
||||
}
|
||||
|
|
@ -9,15 +9,18 @@ export interface TableStructureRefreshScope {
|
|||
}
|
||||
|
||||
export function visibleTableStructureRefreshScope(activeTab: TableInfoTab): TableStructureRefreshScope {
|
||||
return {
|
||||
columns: true,
|
||||
indexes: true,
|
||||
foreignKeys: true,
|
||||
// Trigger definitions can contain large source bodies, so defer them until
|
||||
// the trigger editor is actually visible.
|
||||
triggers: activeTab === "triggers",
|
||||
tableComment: true,
|
||||
};
|
||||
switch (activeTab) {
|
||||
case "columns":
|
||||
return { columns: true, indexes: false, foreignKeys: false, triggers: false, tableComment: true };
|
||||
case "indexes":
|
||||
return { columns: true, indexes: true, foreignKeys: false, triggers: false, tableComment: true };
|
||||
case "foreignKeys":
|
||||
return { columns: true, indexes: false, foreignKeys: true, triggers: false, tableComment: true };
|
||||
case "triggers":
|
||||
return { columns: false, indexes: false, foreignKeys: false, triggers: true, tableComment: true };
|
||||
case "ddl":
|
||||
return { columns: false, indexes: false, foreignKeys: false, triggers: false, tableComment: false };
|
||||
}
|
||||
}
|
||||
|
||||
export const TRIGGERS_ONLY_REFRESH_SCOPE: TableStructureRefreshScope = {
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ import { createMetadataLoadTrace, logMetadataLoadTrace, MetadataLoadCoordinator,
|
|||
import type { MetadataScopeInput } from "@/lib/metadata/metadataLoadScope";
|
||||
import { MetadataResultCache, type MetadataCacheInvalidation } from "@/lib/metadata/metadataResultCache";
|
||||
import { invalidateTableMetadataCache } from "@/lib/metadata/tableMetadataCache";
|
||||
import { invalidateObjectDdlCache } from "@/lib/metadata/objectDdlCache";
|
||||
import { invalidateObjectBrowserRowsCache } from "@/lib/table/objectBrowserRowsCache";
|
||||
import { MetadataTaskLimiter } from "@/lib/metadata/metadataTaskLimiter";
|
||||
import { TreeNodeLoadRegistry, type TreeNodeLoadHandle } from "@/lib/metadata/treeNodeLoadHandle";
|
||||
|
|
@ -1517,16 +1518,20 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
function invalidateMetadataCachesForNode(node: TreeNode) {
|
||||
if (!node.connectionId) return;
|
||||
const tableName = node.tableName || (node.type === "table" || node.type === "view" || node.type === "materialized_view" || node.type === "mongo-collection" ? node.label : undefined);
|
||||
invalidateMetadataCaches({
|
||||
const match = {
|
||||
connectionId: node.connectionId,
|
||||
database: node.database || undefined,
|
||||
schema: node.schema || undefined,
|
||||
tableName,
|
||||
});
|
||||
};
|
||||
invalidateMetadataCaches(match);
|
||||
void invalidateObjectDdlCache(match);
|
||||
}
|
||||
|
||||
function invalidateMetadataCache(connectionId: string, database?: string, schema?: string, tableName?: string) {
|
||||
invalidateMetadataCaches({ connectionId, database, schema, tableName });
|
||||
const match = { connectionId, database, schema, tableName };
|
||||
invalidateMetadataCaches(match);
|
||||
void invalidateObjectDdlCache(match);
|
||||
}
|
||||
|
||||
function buildLoadMoreNode(parent: TreeNode, offset: number, pageSize: number): TreeNode {
|
||||
|
|
@ -2367,6 +2372,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
if (treeSelectionAnchorId.value && removedIds.has(treeSelectionAnchorId.value)) treeSelectionAnchorId.value = null;
|
||||
for (const id of removedIds) {
|
||||
invalidateCompletionCache(id);
|
||||
void invalidateObjectDdlCache({ connectionId: id });
|
||||
clearLoadedChildrenCache(id);
|
||||
void deleteTabResultSnapshotsForOwner(id);
|
||||
}
|
||||
|
|
@ -2391,6 +2397,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
clearConnectionIdentifierQuote(config.id);
|
||||
clearConnectionHealthCheck(config.id);
|
||||
invalidateCompletionCache(config.id);
|
||||
void invalidateObjectDdlCache({ connectionId: config.id });
|
||||
clearLoadedChildrenCache(config.id);
|
||||
const node = findConnectionNode(config.id);
|
||||
if (node?.isExpanded) {
|
||||
|
|
@ -5183,7 +5190,9 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
}
|
||||
|
||||
async function refreshObjectListTreeNode(connectionId: string, database: string, schema?: string, catalog?: string) {
|
||||
invalidateMetadataCaches({ connectionId, database, schema });
|
||||
const match = { connectionId, database, schema };
|
||||
invalidateMetadataCaches(match);
|
||||
void invalidateObjectDdlCache(match);
|
||||
const shouldRefreshSchemaNode = !!schema && !catalog;
|
||||
const node = shouldRefreshSchemaNode ? findNode(treeNodes.value, `${connectionId}:${database}:${schema}`) : null;
|
||||
if (node) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue