diff --git a/apps/desktop/src/components/structure/TableStructureEditor.primaryKey.spec.ts b/apps/desktop/src/components/structure/TableStructureEditor.primaryKey.spec.ts index 2714104d68..0c30a285d9 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.primaryKey.spec.ts +++ b/apps/desktop/src/components/structure/TableStructureEditor.primaryKey.spec.ts @@ -216,7 +216,7 @@ vi.mock("@/stores/settingsStore", () => ({ updateEditorSettings: mocks.updateEditorSettings, }), })); -vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: { value: false } }) })); +vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: { value: false }, themePalette: { value: "default" } }) })); vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) })); vi.mock("@/lib/sql/sqlHighlighter", () => ({ createShikiSqlHighlighter: vi.fn(async () => (sql: string) => sql) })); vi.mock("@/lib/metadata/objectDdlCache", () => ({ @@ -301,7 +301,7 @@ async function mountEditor(databaseType: "sqlserver" | "postgres" | "sqlite" | " return root; } -async function mountLoadingEditor(initialTab: "columns" | "indexes" | "foreignKeys" | "triggers" | "ddl", owner = "app_user") { +async function mountLoadingEditor(initialTab: "columns" | "indexes" | "foreignKeys" | "triggers" | "ddl", owner = "app_user", tableComment = "") { mocks.connection.db_type = "postgres"; mocks.connection.name = "postgres"; mocks.connection.driver_label = "postgres"; @@ -309,7 +309,7 @@ async function mountLoadingEditor(initialTab: "columns" | "indexes" | "foreignKe 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" ? "" : facet === "owner" ? owner : [], cacheStatus: "remote" })); + mocks.loadObjectMetadataFacet.mockImplementation(async (_request, facet: string) => ({ value: facet === "comment" ? tableComment : facet === "owner" ? owner : [], cacheStatus: "remote" })); const root = document.createElement("div"); document.body.append(root); @@ -802,12 +802,81 @@ describe("TableStructureEditor horizontal scrolling", () => { }); describe("TableStructureEditor metadata loading", () => { - it("opens the initial DDL tab while loading only the table owner", async () => { - await mountLoadingEditor("ddl"); + it("opens the initial DDL tab with its always-visible table comment", async () => { + const root = await mountLoadingEditor("ddl", "app_user", "Application users"); await vi.waitFor(() => expect(mocks.loadObjectDdl).toHaveBeenCalledTimes(1)); - await vi.waitFor(() => expect(mocks.loadObjectMetadataFacet).toHaveBeenCalledTimes(1)); - expect(mocks.loadObjectMetadataFacet.mock.calls.map((call) => call[1])).toEqual(["owner"]); + await vi.waitFor(() => expect(mocks.loadObjectMetadataFacet).toHaveBeenCalledTimes(2)); + expect(mocks.loadObjectMetadataFacet.mock.calls.map((call) => call[1]).sort()).toEqual(["comment", "owner"]); + expect(root.querySelector('input[placeholder="structureEditor.tableCommentPlaceholder"]')?.value).toBe("Application users"); + }); + + it.each([ + ["backfills a clean draft", "", "", "Application users"], + ["preserves a dirty draft", "Local edit", "Old comment", "Local edit"], + ])("%s when restored DDL metadata loads the table comment", async (_case, tableComment, originalTableComment, expectedComment) => { + mocks.loadObjectMetadataFacet.mockImplementation(async (_request, facet: string) => ({ value: facet === "comment" ? "Application users" : facet === "owner" ? "app_user" : [], 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", + draft: { + ...draft(), + activeTab: "ddl", + tableComment, + originalTableComment, + loadedMetadataFacets: [], + }, + }); + mountedApps.push(app); + app.mount(root); + + const commentInput = () => root.querySelector('input[placeholder="structureEditor.tableCommentPlaceholder"]'); + await vi.waitFor(() => expect(commentInput()?.value).toBe(expectedComment)); + }); + + it("refreshes the SQL preview when a delayed table comment updates the restored draft baseline", async () => { + const commentSql = "COMMENT ON TABLE users IS 'Local edit';"; + let resolveComment!: (result: { value: string; cacheStatus: "remote" }) => void; + const commentResult = new Promise<{ value: string; cacheStatus: "remote" }>((resolve) => { + resolveComment = resolve; + }); + mocks.loadObjectMetadataFacet.mockImplementation(async (_request, facet: string) => { + if (facet === "comment") return commentResult; + return { value: facet === "owner" ? "app_user" : [], cacheStatus: "remote" }; + }); + mocks.buildTableStructureChangeSql.mockImplementation(async (options) => ({ + statements: options.tableComment === options.originalTableComment ? [] : [commentSql], + warnings: [], + })); + + const root = document.createElement("div"); + document.body.append(root); + const app = createApp(TableStructureEditor, { + connectionId: mocks.connection.id, + database: "test", + schema: "public", + tableName: "users", + draft: { + ...draft(), + activeTab: "ddl", + tableComment: "Local edit", + originalTableComment: "Old comment", + loadedMetadataFacets: [], + }, + }); + mountedApps.push(app); + app.mount(root); + + await vi.waitFor(() => expect(mocks.buildTableStructureChangeSql).toHaveBeenLastCalledWith(expect.objectContaining({ tableComment: "Local edit", originalTableComment: "Old comment" }))); + await vi.waitFor(() => expect(root.textContent).toContain(commentSql)); + resolveComment({ value: "Local edit", cacheStatus: "remote" }); + await vi.waitFor(() => expect(root.textContent).not.toContain(commentSql)); + expect(root.textContent).toContain("structureEditor.noChanges"); }); it.each([ diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index 866f94dcc7..aaa3db3e74 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -1146,6 +1146,8 @@ function isManticoreJsonColumn(column: EditableStructureColumn): boolean { let sqlPreviewRequestId = 0; let structureLoadRequestId = 0; +let tableCommentLoadRequestId = 0; +let tableCommentLoadPromise: Promise | null = null; let tableOwnerLoadRequestId = 0; let tableOwnerRolesLoadRequestId = 0; let mysqlAutoIncrementLoadRequestId = 0; @@ -1834,7 +1836,8 @@ async function reloadStructureFromDatabase() { ddlDraft.value = null; if (refreshDdl) { ddlFetched.value = false; - await Promise.all([fetchDdl(true), loadTableOwner(true), loadTableOwnerRoles(), loadMysqlTableEngine(true)]); + await Promise.all([fetchDdl(true), loadVisibleTableComment(true), loadTableOwner(true), loadTableOwnerRoles(), loadMysqlTableEngine(true)]); + markDraftHydratedAndSync(); } else { await Promise.all([loadStructure(false, visibleTableStructureRefreshScope(activeTab.value), true, { blockSecondaryMetadata: true, forceDdl: true, forceMetadata: true }), loadTableOwner(true), loadTableOwnerRoles(), loadMysqlTableEngine(true)]); } @@ -1871,6 +1874,40 @@ function loadCachedTableComment(request: ReturnType, force = return loadObjectMetadataFacet(request, "comment", () => fetchTableCommentValue(request.connectionId, request.database, request.schema, request.tableName, request.catalog), { force }); } +async function loadVisibleTableComment(force = false, preserveDraft = false) { + const connectionId = props.connectionId; + const database = props.database; + const schema = metadataSchema.value; + const tableName = props.tableName; + const catalog = props.catalog; + if (!structureCapabilities.value.comment || !connectionId || !database || !tableName) return; + if (!force && loadedMetadataFacets.has("comment")) return; + if (!force && tableCommentLoadPromise) return tableCommentLoadPromise; + + const requestId = ++tableCommentLoadRequestId; + const loadPromise = (async () => { + try { + await store.ensureConnected(connectionId); + const { value } = await loadCachedTableComment({ connectionId, database, schema, tableName, catalog }, force); + if (requestId !== tableCommentLoadRequestId) return; + if (connectionId !== props.connectionId || database !== props.database || schema !== metadataSchema.value || tableName !== props.tableName || (catalog || "") !== (props.catalog || "")) return; + if (value === undefined) return; + const hasCommentDraft = tableComment.value !== originalTableComment.value; + originalTableComment.value = value; + if (!preserveDraft || !hasCommentDraft) tableComment.value = value; + loadedMetadataFacets.add("comment"); + } catch (error) { + if (requestId === tableCommentLoadRequestId) console.warn("[DBX][structure-editor:comment-metadata-failed]", error); + } + })(); + tableCommentLoadPromise = loadPromise; + try { + await loadPromise; + } finally { + if (tableCommentLoadPromise === loadPromise) tableCommentLoadPromise = null; + } +} + async function loadMysqlAutoIncrementCounter(preserveDraft = false) { const requestId = ++mysqlAutoIncrementLoadRequestId; if (!canEditMysqlAutoIncrement.value || !props.connectionId || !props.database || !props.tableName) { @@ -3737,7 +3774,7 @@ onMounted(() => { } else if (isCreateMode.value) { markDraftHydratedAndSync(); } else if (activeTab.value === "ddl") { - void fetchDdl(); + void Promise.all([fetchDdl(), loadVisibleTableComment()]).then(markDraftHydratedAndSync); } else { void loadStructure(false, visibleTableStructureRefreshScope(activeTab.value), true, { blockSecondaryMetadata: true }).then(() => applyInitialStructureTarget()); } @@ -3879,6 +3916,7 @@ watch( () => props.tableName, newTableName, tableComment, + originalTableComment, mysqlAutoIncrementValue, originalMysqlAutoIncrementValue, mysqlAutoIncrementLoading, @@ -3963,7 +4001,7 @@ watch(refreshVersion, (version, previous) => { async function loadActiveTableStructureMetadataIfNeeded() { if (!structureEditorReady || isCreateMode.value) return; if (activeTab.value === "ddl") { - await fetchDdl(); + await Promise.all([ddlLoading.value ? Promise.resolve() : fetchDdl(), loadVisibleTableComment(false, true)]); return; } if (loading.value || secondaryMetadataLoading.value) return; diff --git a/apps/desktop/src/components/structure/__tests__/TableStructureEditor.ddlTabLifecycle.spec.ts b/apps/desktop/src/components/structure/__tests__/TableStructureEditor.ddlTabLifecycle.spec.ts index 38636162dd..01b4e6042b 100644 --- a/apps/desktop/src/components/structure/__tests__/TableStructureEditor.ddlTabLifecycle.spec.ts +++ b/apps/desktop/src/components/structure/__tests__/TableStructureEditor.ddlTabLifecycle.spec.ts @@ -211,6 +211,14 @@ import TableStructureEditor from "@/components/structure/TableStructureEditor.vu const DDL = "CREATE TABLE users (id bigint);"; let app: App | undefined; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + function buttonWithText(root: HTMLElement, text: string): HTMLButtonElement { const button = Array.from(root.querySelectorAll("button")).find((item) => item.textContent?.trim() === text); if (!button) throw new Error(`Missing ${text} button`); @@ -276,4 +284,72 @@ describe("TableStructureEditor DDL tab lifecycle", () => { await vi.waitFor(() => expect(root.querySelector(".cm-content")?.textContent).toContain(DDL), { timeout: 1000 }); expect(mocks.loadObjectDdl).toHaveBeenCalledTimes(1); }); + + it("does not restart the new tab's metadata load when a stale DDL request resolves", async () => { + const root = document.createElement("div"); + document.body.append(root); + app = createApp(TableStructureEditor, { + connectionId: mocks.connection.id, + database: "test", + schema: "public", + tableName: "users", + initialTab: "triggers", + }); + app.mount(root); + + await vi.waitFor(() => expect(mocks.loadObjectMetadataFacet.mock.calls.some((call) => call[1] === "triggers")).toBe(true)); + + const ddlResult = deferred<{ ddl: string; cacheStatus: "remote" }>(); + const columnsResult = deferred>(); + mocks.loadObjectDdl.mockImplementationOnce(() => ddlResult.promise); + mocks.loadObjectMetadataFacet.mockImplementation(async (_request, facet: string) => ({ + value: facet === "columns" ? await columnsResult.promise : facet === "comment" ? "Application users" : facet === "owner" ? "app_user" : [], + cacheStatus: "remote", + })); + + selectTab(buttonWithText(root, "DDL")); + await vi.waitFor(() => expect(mocks.loadObjectDdl).toHaveBeenCalledTimes(1)); + selectTab(buttonWithText(root, "structureEditor.columns")); + await vi.waitFor(() => expect(mocks.loadObjectMetadataFacet.mock.calls.filter((call) => call[1] === "columns")).toHaveLength(1)); + + ddlResult.resolve({ ddl: DDL, cacheStatus: "remote" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const columnLoadsAfterDdl = mocks.loadObjectMetadataFacet.mock.calls.filter((call) => call[1] === "columns").length; + columnsResult.resolve([{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true }]); + + expect(columnLoadsAfterDdl).toBe(1); + }); + + it("force-refreshes both DDL and the visible table comment", async () => { + let tableComment = "Old comment"; + mocks.loadObjectMetadataFacet.mockImplementation(async (_request, facet: string) => ({ + value: facet === "columns" ? [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true }] : facet === "comment" ? tableComment : facet === "owner" ? "app_user" : [], + cacheStatus: "remote", + })); + + const root = document.createElement("div"); + document.body.append(root); + app = createApp(TableStructureEditor, { + connectionId: mocks.connection.id, + database: "test", + schema: "public", + tableName: "users", + initialTab: "ddl", + }); + app.mount(root); + + const commentInput = () => root.querySelector('input[placeholder="structureEditor.tableCommentPlaceholder"]'); + await vi.waitFor(() => expect(commentInput()?.value).toBe("Old comment")); + await vi.waitFor(() => expect(root.querySelector(".cm-content")?.textContent).toContain(DDL), { timeout: 5000 }); + + tableComment = "Updated comment"; + mocks.loadObjectDdl.mockResolvedValue({ ddl: "CREATE TABLE users (id bigint, name text);", cacheStatus: "remote" }); + buttonWithText(root, "structureEditor.refresh").click(); + + await vi.waitFor(() => expect(commentInput()?.value).toBe("Updated comment")); + await vi.waitFor(() => expect(root.querySelector(".cm-content")?.textContent).toContain("name text"), { timeout: 5000 }); + const commentCalls = mocks.loadObjectMetadataFacet.mock.calls.filter((call) => call[1] === "comment"); + expect(commentCalls).toHaveLength(2); + expect(commentCalls.at(-1)?.[3]).toEqual({ force: true }); + }); }); diff --git a/apps/desktop/src/lib/__tests__/table/tableStructureMetadataLoading.spec.ts b/apps/desktop/src/lib/__tests__/table/tableStructureMetadataLoading.spec.ts index 80817a55b5..7483cc8be3 100644 --- a/apps/desktop/src/lib/__tests__/table/tableStructureMetadataLoading.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableStructureMetadataLoading.spec.ts @@ -8,7 +8,7 @@ describe("table structure metadata loading", () => { ["foreignKeys", { columns: true, indexes: false, foreignKeys: true, constraints: false, triggers: false, tableComment: true }], ["constraints", { columns: false, indexes: false, foreignKeys: false, constraints: true, triggers: false, tableComment: true }], ["triggers", { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: true, tableComment: true }], - ["ddl", { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: false, tableComment: false }], + ["ddl", { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: false, tableComment: true }], ] as const)("requests only the metadata required by the %s tab", (tab, expected) => { expect(visibleTableStructureRefreshScope(tab)).toEqual(expected); }); diff --git a/apps/desktop/src/lib/table/tableStructureMetadataLoading.ts b/apps/desktop/src/lib/table/tableStructureMetadataLoading.ts index 66ae9f5b6e..e1a1dbe936 100644 --- a/apps/desktop/src/lib/table/tableStructureMetadataLoading.ts +++ b/apps/desktop/src/lib/table/tableStructureMetadataLoading.ts @@ -23,7 +23,7 @@ export function visibleTableStructureRefreshScope(activeTab: TableInfoTab): Tabl case "triggers": return { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: true, tableComment: true }; case "ddl": - return { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: false, tableComment: false }; + return { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: false, tableComment: true }; } }