feat(metadata): persist and lazy-load table structure metadata
This commit is contained in:
1 parent
584848f732
commit
7be11f54c9
15 files changed
+747
-89
No files matched your search
+62
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user