// @vitest-environment happy-dom import { createApp, nextTick, type App } from "vue"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ connection: { id: "structure-test", name: "Dameng", db_type: "dameng", driver_label: "Dameng", }, ensureConnected: vi.fn(), executeQuery: vi.fn(), listDataTypes: vi.fn(), buildTableStructureChangeSql: vi.fn(), previewSqliteTableStructureChange: vi.fn(), buildMysqlAutoIncrementSql: vi.fn(), updateEditorSettings: vi.fn(), loadObjectDdl: vi.fn(), invalidateObjectDdl: vi.fn(), loadObjectMetadataFacet: vi.fn(), invalidateObjectMetadataCache: vi.fn(), invalidateTableMetadataCache: vi.fn(), getTablePartitionStatus: vi.fn(), getTableOwner: vi.fn(), buildTableOwnerChangeSql: vi.fn(), editorSettings: { structureEditorDensity: "compact", sqlFormatter: {}, tableColumnTemplateFields: [], generateSqlQuoteIdentifiers: true, }, toast: vi.fn(), })); vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) })); vi.mock("@lucide/vue", async () => { const { defineComponent, h } = await import("vue"); const Icon = defineComponent({ name: "Icon", setup: () => () => h("span") }); return { AlertTriangle: Icon, Check: Icon, ChevronDown: Icon, ChevronUp: Icon, ClipboardList: Icon, Copy: Icon, Database: Icon, Info: Icon, Keyboard: Icon, KeyRound: Icon, ListChevronsUpDown: Icon, Loader2: Icon, Maximize2: Icon, Plus: Icon, RefreshCw: Icon, Rows3: Icon, Save: Icon, Search: Icon, Settings: Icon, SlidersHorizontal: Icon, Trash2: Icon, UserRound: Icon, X: Icon, }; }); vi.mock("@/components/ui/button", async () => { const { defineComponent, h } = await import("vue"); return { Button: defineComponent({ name: "Button", inheritAttrs: false, setup: (_props, { attrs, slots }) => () => h("button", attrs, slots.default?.()), }), }; }); vi.mock("@/components/ui/input", async () => { const { defineComponent, h } = await import("vue"); return { Input: defineComponent({ name: "Input", inheritAttrs: false, props: { modelValue: { type: [String, Number], default: "" } }, emits: ["update:modelValue"], setup: (props, { attrs, emit }) => () => h("input", { ...attrs, value: props.modelValue, onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value), }), }), }; }); vi.mock("@/components/ui/badge", async () => { const { defineComponent, h } = await import("vue"); return { Badge: defineComponent({ name: "Badge", inheritAttrs: false, setup: (_props, { attrs, slots }) => () => h("span", attrs, slots.default?.()), }), }; }); vi.mock("@/components/ui/tabs", async () => { const { defineComponent, h } = await import("vue"); const Div = defineComponent({ inheritAttrs: false, setup: (_props, { attrs, slots }) => () => h("div", attrs, slots.default?.()), }); const Button = defineComponent({ inheritAttrs: false, setup: (_props, { attrs, slots }) => () => h("button", attrs, slots.default?.()), }); return { Tabs: Div, TabsContent: Div, TabsList: Div, TabsTrigger: Button }; }); vi.mock("@/components/ui/dropdown-menu", async () => { const { defineComponent, h } = await import("vue"); const Div = defineComponent({ inheritAttrs: false, setup: (_props, { attrs, slots }) => () => h("div", attrs, slots.default?.()), }); const Button = defineComponent({ inheritAttrs: false, setup: (_props, { attrs, slots }) => () => h("button", attrs, slots.default?.()), }); return { DropdownMenu: Div, DropdownMenuCheckboxItem: Div, DropdownMenuContent: Div, DropdownMenuItem: Button, DropdownMenuTrigger: Div }; }); vi.mock("@/components/ui/popover", async () => { const { defineComponent, h } = await import("vue"); const Div = defineComponent({ inheritAttrs: false, setup: (_props, { attrs, slots }) => () => h("div", attrs, slots.default?.()), }); return { Popover: Div, PopoverContent: Div, PopoverTrigger: Div }; }); vi.mock("@/components/ui/tooltip", async () => { const { defineComponent, h } = await import("vue"); const Div = defineComponent({ inheritAttrs: false, setup: (_props, { attrs, slots }) => () => h("div", attrs, slots.default?.()), }); return { Tooltip: Div, TooltipContent: Div, TooltipTrigger: Div }; }); vi.mock("@/components/ui/searchable-select", async () => { const { defineComponent, h } = await import("vue"); return { SearchableSelect: defineComponent({ name: "SearchableSelect", inheritAttrs: false, props: { modelValue: { type: String, default: "" }, options: { type: Array, default: () => [] }, allowCustom: { type: Boolean, default: false }, trimCustom: { type: Boolean, default: true }, }, emits: ["update:modelValue"], setup: (props, { attrs, emit }) => () => h("button", { ...attrs, type: "button", "data-searchable-select": "true", "data-model-value": props.modelValue, "data-options": JSON.stringify(props.options), "data-allow-custom": String(props.allowCustom), "data-trim-custom": String(props.trimCustom), onClick: () => emit("update:modelValue", "custom_domain"), }), }), }; }); vi.mock("@/components/ui/select", async () => { const { defineComponent, h } = await import("vue"); const Div = defineComponent({ inheritAttrs: false, setup: (_props, { attrs, slots }) => () => h("div", attrs, slots.default?.()), }); return { Select: Div, SelectContent: Div, SelectItem: Div, SelectTrigger: Div, SelectValue: Div }; }); vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => ({ ensureConnected: mocks.ensureConnected, getConfig: (connectionId: string) => (connectionId === mocks.connection.id ? mocks.connection : undefined), }), })); vi.mock("@/stores/productionSafetyStore", () => ({ useProductionSafetyStore: () => ({ requestConfirmation: vi.fn() }) })); vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({ tableStructureRefreshVersion: () => 0 }) })); vi.mock("@/stores/historyStore", () => ({ useHistoryStore: () => ({ add: vi.fn() }) })); vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: mocks.editorSettings, updateEditorSettings: mocks.updateEditorSettings, }), })); vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: { value: false }, themePalette: { value: "default" } }) })); vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) })); vi.mock("@/lib/backend/platform", () => ({ isMacOS: () => true })); vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true })); 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, invalidateObjectMetadataCache: mocks.invalidateObjectMetadataCache })); vi.mock("@/lib/metadata/tableMetadataCache", () => ({ invalidateTableMetadataCache: mocks.invalidateTableMetadataCache })); vi.mock("@/lib/backend/api", () => ({ executeQuery: mocks.executeQuery, listDataTypes: mocks.listDataTypes, buildTableStructureChangeSql: mocks.buildTableStructureChangeSql, previewSqliteTableStructureChange: mocks.previewSqliteTableStructureChange, buildMysqlAutoIncrementSql: mocks.buildMysqlAutoIncrementSql, buildTableOwnerChangeSql: mocks.buildTableOwnerChangeSql, getTablePartitionStatus: mocks.getTablePartitionStatus, getTableOwner: mocks.getTableOwner, })); import TableStructureEditor from "@/components/structure/TableStructureEditor.vue"; const mountedApps: App[] = []; function draft(isPrimaryKey = false, identity?: { seed: number; increment: number }, columnName = "id", foreignKeyRefTable?: string) { const isNullable = identity ? false : !isPrimaryKey; return { initialized: true, activeTab: "columns" as const, newTableName: "", tableComment: "", originalTableComment: "", columns: [ { id: "existing:id", name: columnName, dataType: "INT", isNullable, defaultValue: "", comment: "", isPrimaryKey, extra: identity ? { autoIncrement: true, identity: { ...identity } } : {}, original: { name: columnName, data_type: "INT", is_nullable: isNullable, column_default: null, is_primary_key: isPrimaryKey, extra: identity ? `IDENTITY(${identity.seed}, ${identity.increment})` : null, comment: null, }, originalPosition: 0, markedForDrop: false, }, ], indexes: [], foreignKeys: foreignKeyRefTable ? [ { id: "existing:fk", name: "FK_T_9649", column: columnName, refSchema: "", refTable: foreignKeyRefTable, refColumn: "ID", onUpdate: "", onDelete: "", original: { name: "FK_T_9649", column: columnName, ref_schema: null, ref_table: foreignKeyRefTable, ref_column: "ID", on_update: null, on_delete: null, }, markedForDrop: false, }, ] : [], triggers: [], }; } function draftWithColumns(count: number) { const value = draft(); value.columns = Array.from({ length: count }, (_, index) => ({ id: `existing:field_${index}`, name: `field_${index}`, dataType: "varchar(255)", isNullable: true, defaultValue: "", comment: "", isPrimaryKey: false, extra: {}, original: { name: `field_${index}`, data_type: "varchar(255)", is_nullable: true, column_default: null, is_primary_key: false, extra: null, comment: null, }, originalPosition: index, markedForDrop: false, })); return value; } async function mountEditor( databaseType: "mysql" | "sqlserver" | "postgres" | "sqlite" | "oracle" | "oceanbase-oracle" | "iris" | "dameng" | "duckdb" | "informix" | "clickhouse", isPrimaryKey = false, options: { database?: string; dynamicTypes?: string[]; identity?: { seed: number; increment: number }; tableName?: string; columnName?: string; foreignKeyRefTable?: string; draftOverride?: ReturnType; onDraftUpdate?: (value: unknown) => void } = {}, ) { mocks.connection.db_type = databaseType; mocks.connection.name = databaseType; mocks.connection.driver_label = databaseType; mocks.ensureConnected.mockResolvedValue(undefined); mocks.listDataTypes.mockResolvedValue(options.dynamicTypes ?? []); mocks.buildTableStructureChangeSql.mockResolvedValue({ statements: [], warnings: [] }); const root = document.createElement("div"); document.body.append(root); const app = createApp(TableStructureEditor, { connectionId: mocks.connection.id, database: options.database ?? "test", schema: "SYSDBA", tableName: options.tableName ?? "users", draft: options.draftOverride ?? draft(isPrimaryKey, options.identity, options.columnName, options.foreignKeyRefTable), "onUpdate:draft": options.onDraftUpdate, }); mountedApps.push(app); app.mount(root); await nextTick(); await Promise.resolve(); await nextTick(); return root; } 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"; 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" ? tableComment : facet === "owner" ? owner : [], 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, rowIndex = 0): HTMLInputElement { const headerIndex = Array.from(root.querySelectorAll("thead th")).findIndex((cell) => cell.textContent?.trim() === header); if (headerIndex < 0) throw new Error(`Missing ${header} column`); const row = root.querySelector(`[data-column-row-index="${rowIndex}"]`); const cell = row?.querySelectorAll("td")[headerIndex]; const checkbox = cell?.querySelector('input[type="checkbox"]'); if (!checkbox) throw new Error(`Missing ${header} checkbox`); return checkbox; } function columnPropertyCheckbox(root: HTMLElement, title: string): HTMLInputElement { const checkbox = root.querySelector(`[data-column-row-index="0"] label[title="${title}"] input[type="checkbox"]`); if (!checkbox) throw new Error(`Missing ${title} checkbox`); return checkbox; } function buttonWithText(root: HTMLElement, text: string): HTMLButtonElement { const button = Array.from(root.querySelectorAll("button")).find((item) => item.textContent?.includes(text)); if (!button) throw new Error(`Missing ${text} button`); return button; } beforeEach(() => { vi.clearAllMocks(); mocks.editorSettings.generateSqlQuoteIdentifiers = true; mocks.previewSqliteTableStructureChange.mockResolvedValue({ statements: [], warnings: [], schemaRevision: "sqlite-revision" }); mocks.loadObjectDdl.mockResolvedValue({ ddl: "CREATE TABLE users (id bigint)", cacheStatus: "remote" }); mocks.invalidateObjectDdl.mockResolvedValue(undefined); mocks.invalidateObjectMetadataCache.mockResolvedValue(undefined); mocks.loadObjectMetadataFacet.mockImplementation(async (_request, facet: string) => ({ value: facet === "owner" ? "app_user" : [], cacheStatus: "remote" })); mocks.getTableOwner.mockResolvedValue("app_user"); mocks.executeQuery.mockResolvedValue({ columns: ["user", "host", "plugin"], rows: [ ["app_user", "LOGIN", ""], ["reporting_role", "ROLE", ""], ], }); mocks.buildTableOwnerChangeSql.mockResolvedValue({ statements: [], warnings: [] }); // TableStructureEditor probes the partition status for PostgreSQL tables // (PR #6361); a resolved non-partitioned result keeps metadata loads on the // original facet expectations unchanged. mocks.getTablePartitionStatus.mockResolvedValue({ isPartitionedParent: false, isPartition: false }); }); afterEach(() => { vi.useRealTimers(); for (const app of mountedApps.splice(0)) app.unmount(); document.body.innerHTML = ""; }); describe("TableStructureEditor primary key editing", () => { it("allows enabling identity on an existing Dameng integer column", async () => { const root = await mountEditor("dameng"); const identity = columnPropertyCheckbox(root, "structureEditor.identity"); const nullable = columnCheckbox(root, "structureEditor.nullable"); expect(identity.disabled).toBe(false); expect(nullable.checked).toBe(true); identity.checked = true; identity.dispatchEvent(new Event("change", { bubbles: true })); await nextTick(); expect(identity.checked).toBe(true); expect(nullable.checked).toBe(false); expect(Array.from(root.querySelectorAll('[data-column-row-index="0"] input[type="number"]')).every((input) => !input.disabled)).toBe(true); await vi.waitFor(() => expect(mocks.buildTableStructureChangeSql).toHaveBeenCalled()); expect(mocks.buildTableStructureChangeSql).toHaveBeenLastCalledWith( expect.objectContaining({ columns: [ expect.objectContaining({ isNullable: false, extra: expect.objectContaining({ autoIncrement: true, identity: { seed: 1, increment: 1 } }), }), ], }), ); }); it("allows disabling existing Dameng identity while keeping its parameters read-only", async () => { const root = await mountEditor("dameng", false, { identity: { seed: 10, increment: 2 } }); const identity = columnPropertyCheckbox(root, "structureEditor.identity"); const identityInputs = Array.from(root.querySelectorAll('[data-column-row-index="0"] input[type="number"]')); expect(identity.disabled).toBe(false); expect(identity.checked).toBe(true); expect(identityInputs).toHaveLength(2); expect(identityInputs.every((input) => input.disabled)).toBe(true); identity.checked = false; identity.dispatchEvent(new Event("change", { bubbles: true })); await nextTick(); expect(identity.checked).toBe(false); await vi.waitFor(() => expect(mocks.buildTableStructureChangeSql).toHaveBeenCalled()); expect(mocks.buildTableStructureChangeSql).toHaveBeenLastCalledWith( expect.objectContaining({ columns: [expect.objectContaining({ extra: expect.objectContaining({ autoIncrement: false, identity: undefined }) })], }), ); }); it("enables the primary-key checkbox for an existing Dameng column and makes it not null", async () => { const root = await mountEditor("dameng"); const primaryKey = columnCheckbox(root, "structureEditor.primaryKey"); const nullable = columnCheckbox(root, "structureEditor.nullable"); expect(primaryKey.disabled).toBe(false); expect(nullable.checked).toBe(true); primaryKey.checked = true; primaryKey.dispatchEvent(new Event("change", { bubbles: true })); await nextTick(); expect(primaryKey.checked).toBe(true); expect(nullable.checked).toBe(false); await vi.waitFor(() => expect(mocks.buildTableStructureChangeSql).toHaveBeenCalled()); expect(mocks.buildTableStructureChangeSql).toHaveBeenLastCalledWith( expect.objectContaining({ columns: [expect.objectContaining({ isPrimaryKey: true, isNullable: false })], }), ); }); it("enables a primary key on an existing keyless Oracle column and makes it not null", async () => { const root = await mountEditor("oracle"); const primaryKey = columnCheckbox(root, "structureEditor.primaryKey"); const nullable = columnCheckbox(root, "structureEditor.nullable"); expect(primaryKey.disabled).toBe(false); expect(nullable.checked).toBe(true); primaryKey.checked = true; primaryKey.dispatchEvent(new Event("change", { bubbles: true })); await nextTick(); expect(primaryKey.checked).toBe(true); expect(nullable.checked).toBe(false); await vi.waitFor(() => expect(mocks.buildTableStructureChangeSql).toHaveBeenCalled()); expect(mocks.buildTableStructureChangeSql).toHaveBeenLastCalledWith( expect.objectContaining({ columns: [expect.objectContaining({ isPrimaryKey: true, isNullable: false })], }), ); }); it("keeps all primary-key checkboxes enabled while composing a new Oracle key", async () => { const root = await mountEditor("oracle"); buttonWithText(root, "structureEditor.addColumn").click(); await nextTick(); const firstPrimaryKey = columnCheckbox(root, "structureEditor.primaryKey", 0); const secondPrimaryKey = columnCheckbox(root, "structureEditor.primaryKey", 1); expect(firstPrimaryKey.disabled).toBe(false); expect(secondPrimaryKey.disabled).toBe(false); firstPrimaryKey.checked = true; firstPrimaryKey.dispatchEvent(new Event("change", { bubbles: true })); await nextTick(); expect(secondPrimaryKey.disabled).toBe(false); secondPrimaryKey.checked = true; secondPrimaryKey.dispatchEvent(new Event("change", { bubbles: true })); await nextTick(); await vi.waitFor(() => expect(mocks.buildTableStructureChangeSql).toHaveBeenLastCalledWith( expect.objectContaining({ columns: [expect.objectContaining({ isPrimaryKey: true }), expect.objectContaining({ isPrimaryKey: true })], }), ), ); }); it("keeps an existing Oracle primary key and all replacement choices disabled", async () => { const root = await mountEditor("oracle", true); buttonWithText(root, "structureEditor.addColumn").click(); await nextTick(); expect(columnCheckbox(root, "structureEditor.primaryKey", 0).disabled).toBe(true); expect(columnCheckbox(root, "structureEditor.primaryKey", 1).disabled).toBe(true); }); it.each(["oceanbase-oracle", "iris"] as const)("keeps primary-key creation disabled for existing %s tables", async (databaseType) => { const root = await mountEditor(databaseType); expect(columnCheckbox(root, "structureEditor.primaryKey").disabled).toBe(true); }); it("allows an existing Dameng primary key to be cleared", async () => { const root = await mountEditor("dameng", true); const primaryKey = columnCheckbox(root, "structureEditor.primaryKey"); expect(primaryKey.disabled).toBe(false); expect(primaryKey.checked).toBe(true); primaryKey.checked = false; primaryKey.dispatchEvent(new Event("change", { bubbles: true })); await nextTick(); expect(primaryKey.checked).toBe(false); await vi.waitFor(() => expect(mocks.buildTableStructureChangeSql).toHaveBeenCalled()); expect(mocks.buildTableStructureChangeSql).toHaveBeenLastCalledWith( expect.objectContaining({ columns: [expect.objectContaining({ isPrimaryKey: false })], }), ); }); it("keeps the current SQL preview visible while a newer preview is loading", async () => { const root = await mountEditor("dameng", true); const primaryKey = columnCheckbox(root, "structureEditor.primaryKey"); const nullable = columnCheckbox(root, "structureEditor.nullable"); const firstSql = "ALTER TABLE users DROP CONSTRAINT users_pkey;"; mocks.buildTableStructureChangeSql.mockResolvedValueOnce({ statements: [firstSql], warnings: [] }); primaryKey.checked = false; primaryKey.dispatchEvent(new Event("change", { bubbles: true })); await vi.waitFor(() => expect(root.textContent).toContain(firstSql)); let resolveLatestPreview!: (value: { statements: string[]; warnings: string[] }) => void; mocks.buildTableStructureChangeSql.mockImplementationOnce( () => new Promise((resolve) => { resolveLatestPreview = resolve; }), ); nullable.checked = true; nullable.dispatchEvent(new Event("change", { bubbles: true })); await nextTick(); expect(buttonWithText(root, "structureEditor.copySql").disabled).toBe(true); expect(buttonWithText(root, "structureEditor.apply").disabled).toBe(true); await vi.waitFor(() => expect(mocks.buildTableStructureChangeSql).toHaveBeenCalledTimes(2)); expect(root.textContent).toContain(firstSql); expect(root.textContent).not.toContain("structureEditor.noChanges"); resolveLatestPreview({ statements: ["ALTER TABLE users ALTER COLUMN id DROP NOT NULL;"], warnings: [] }); await vi.waitFor(() => expect(root.textContent).toContain("ALTER TABLE users ALTER COLUMN id DROP NOT NULL;")); expect(buttonWithText(root, "structureEditor.copySql").disabled).toBe(false); expect(buttonWithText(root, "structureEditor.apply").disabled).toBe(false); }); it.each([ ["postgres", "public"], ["sqlite", "main"], ] as const)("omits safe identifier quotes from generated %s SQL when quoting is disabled", async (databaseType, schema) => { mocks.editorSettings.generateSqlQuoteIdentifiers = false; const root = await mountEditor(databaseType); mocks.buildTableStructureChangeSql.mockResolvedValueOnce({ statements: [`ALTER TABLE "${schema}"."demo_table" ADD "abc" VARCHAR(20) DEFAULT 'quoted value'`], warnings: [] }); buttonWithText(root, "structureEditor.addColumn").click(); await vi.waitFor(() => expect(root.textContent).toContain(`ALTER TABLE ${schema}.demo_table ADD abc VARCHAR(20) DEFAULT 'quoted value'`)); expect(root.textContent).not.toContain(`"${schema}"."demo_table"`); }); it("keeps case-sensitive Oracle identifiers quoted when quoting is disabled (#9649)", async () => { mocks.editorSettings.generateSqlQuoteIdentifiers = false; const root = await mountEditor("oracle", false, { tableName: "T_9649", columnName: "cName" }); mocks.buildTableStructureChangeSql.mockResolvedValueOnce({ statements: ['ALTER TABLE "DBX_TEST"."T_9649" MODIFY ("cName" VARCHAR2(120 BYTE))', 'ALTER TABLE "DBX_TEST"."T_9649" ADD ("cNabcs" clob)', 'COMMENT ON COLUMN "DBX_TEST"."T_9649"."cName" IS \'中文名\''], warnings: [], }); buttonWithText(root, "structureEditor.addColumn").click(); await vi.waitFor(() => expect(root.textContent).toContain('MODIFY ("cName" VARCHAR2(120 BYTE))')); expect(root.textContent).toContain('ADD ("cNabcs" clob)'); expect(root.textContent).toContain("COMMENT ON COLUMN DBX_TEST.T_9649.\"cName\" IS '中文名'"); expect(root.textContent).not.toContain("CNAME"); }); it("keeps a quoted Oracle table name when quoting is disabled (#9649)", async () => { mocks.editorSettings.generateSqlQuoteIdentifiers = false; const root = await mountEditor("oracle", false, { tableName: "t_9649_lower", columnName: "ID" }); mocks.buildTableStructureChangeSql.mockResolvedValueOnce({ statements: ['ALTER TABLE "DBX_TEST"."t_9649_lower" MODIFY ("ID" NUMBER(12))'], warnings: [] }); buttonWithText(root, "structureEditor.addColumn").click(); await vi.waitFor(() => expect(root.textContent).toContain('ALTER TABLE DBX_TEST."t_9649_lower" MODIFY (ID NUMBER(12))')); expect(root.textContent).not.toContain("T_9649_LOWER"); }); it("keeps case-sensitive foreign key referenced names quoted when quoting is disabled (#9649)", async () => { mocks.editorSettings.generateSqlQuoteIdentifiers = false; const root = await mountEditor("oracle", false, { tableName: "T_9649", columnName: "ID", foreignKeyRefTable: "cRefTab" }); mocks.buildTableStructureChangeSql.mockResolvedValueOnce({ statements: ['ALTER TABLE "DBX_TEST"."T_9649" ADD CONSTRAINT "FK_T_9649" FOREIGN KEY ("ID") REFERENCES "DBX_TEST"."cRefTab"("ID")'], warnings: [], }); buttonWithText(root, "structureEditor.addColumn").click(); await vi.waitFor(() => expect(root.textContent).toContain('REFERENCES DBX_TEST."cRefTab"(ID)')); expect(root.textContent).not.toContain("CREFTAB"); }); it("still folds plain Oracle identifiers when quoting is disabled (#8997)", async () => { mocks.editorSettings.generateSqlQuoteIdentifiers = false; const root = await mountEditor("oracle", false, { tableName: "TEST", columnName: "ID" }); mocks.buildTableStructureChangeSql.mockResolvedValueOnce({ statements: ['ALTER TABLE "SYSTEM"."TEST" ADD ("NEW_COL" VARCHAR2(20))'], warnings: [] }); buttonWithText(root, "structureEditor.addColumn").click(); await vi.waitFor(() => expect(root.textContent).toContain("ALTER TABLE SYSTEM.TEST ADD (NEW_COL VARCHAR2(20))")); expect(root.textContent).not.toContain('"NEW_COL"'); }); it("keeps SQLite rebuild SQL aligned with the guarded apply plan", async () => { mocks.editorSettings.generateSqlQuoteIdentifiers = false; const root = await mountEditor("sqlite"); const preview = `ALTER TABLE "main"."demo_table" RENAME COLUMN "old_name" TO "new_name"`; mocks.previewSqliteTableStructureChange.mockResolvedValueOnce({ statements: [preview], warnings: [], schemaRevision: "sqlite-revision" }); root.querySelector('[data-searchable-select="true"]')?.click(); await vi.waitFor(() => expect(mocks.previewSqliteTableStructureChange).toHaveBeenCalled()); expect(root.textContent).toContain(preview); }); it("debounces SQL preview generation while editing a column name", async () => { vi.useFakeTimers(); const root = await mountEditor("dameng"); const nameInput = root.querySelector("[data-column-name-input]"); if (!nameInput) throw new Error("Missing column name input"); nameInput.value = "user"; nameInput.dispatchEvent(new Event("input", { bubbles: true })); await nextTick(); await vi.advanceTimersByTimeAsync(200); nameInput.value = "user_id"; nameInput.dispatchEvent(new Event("input", { bubbles: true })); await nextTick(); await vi.advanceTimersByTimeAsync(299); expect(mocks.buildTableStructureChangeSql).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); await Promise.resolve(); await nextTick(); expect(mocks.buildTableStructureChangeSql).toHaveBeenCalledTimes(1); expect(mocks.buildTableStructureChangeSql).toHaveBeenLastCalledWith( expect.objectContaining({ columns: [expect.objectContaining({ name: "user_id" })], }), ); }); }); describe("TableStructureEditor data type options", () => { it("keeps dynamic Dameng types first and deduplicates fallback types case-insensitively", async () => { const root = await mountEditor("dameng", false, { database: "dynamic-types-5275", dynamicTypes: ["VARCHAR", "CUSTOM_DM_TYPE", "NUMBER"], }); const picker = root.querySelector('[data-searchable-select="true"]'); if (!picker) throw new Error("Missing data type picker"); await vi.waitFor(() => expect(JSON.parse(picker.dataset.options ?? "[]").slice(0, 3)).toEqual(["VARCHAR", "CUSTOM_DM_TYPE", "NUMBER"])); const options = JSON.parse(picker.dataset.options ?? "[]") as string[]; expect(options.filter((option) => option.toLowerCase() === "varchar")).toEqual(["VARCHAR"]); expect(options.filter((option) => option.toLowerCase() === "number")).toEqual(["NUMBER"]); expect(options).toContain("longvarchar"); }); it("continues to accept manually entered Dameng data types", async () => { const root = await mountEditor("dameng", false, { database: "manual-type-5275" }); const picker = root.querySelector('[data-searchable-select="true"]'); if (!picker) throw new Error("Missing data type picker"); expect(picker.dataset.allowCustom).toBe("true"); picker.click(); await vi.waitFor(() => expect(mocks.buildTableStructureChangeSql).toHaveBeenLastCalledWith(expect.objectContaining({ columns: [expect.objectContaining({ dataType: "custom_domain" })] }))); }); }); describe("TableStructureEditor action column", () => { it("keeps the add shortcut tooltip and exposes row shortcuts without per-row tooltip components", async () => { const root = await mountEditor("dameng"); expect(root.textContent).not.toContain("settings.shortcutsTab"); expect(root.querySelector("[data-field-shortcut-hints]")).toBeNull(); const addTooltip = root.querySelector("[data-add-column-shortcut-tooltip]"); const copyButton = root.querySelector("[data-copy-column-shortcut-button]"); const deleteButton = root.querySelector("[data-delete-column-shortcut-button]"); expect(addTooltip?.getAttribute("delay-duration")).toBe("500"); expect(root.querySelector("[data-add-column-shortcut-content]")?.textContent).toBe("Shift+Enter"); // The add control already renders its label, so that hint stays a bare shortcut. expect(root.querySelector("[data-add-column-shortcut-content]")?.textContent).not.toContain("structureEditor.addColumn"); // The icon-only copy/delete controls name the action next to the shortcut in native titles (#9870). expect(root.querySelector("[data-copy-column-shortcut-tooltip]")).toBeNull(); expect(root.querySelector("[data-delete-column-shortcut-tooltip]")).toBeNull(); expect(copyButton?.getAttribute("title")).toBe("structureEditor.copyColumn ⌘/Ctrl+D"); expect(copyButton?.getAttribute("aria-keyshortcuts")).toBe("Control+D Meta+D"); expect(deleteButton?.getAttribute("title")).toBe("structureEditor.drop ⌘/Ctrl+Del"); expect(deleteButton?.getAttribute("aria-keyshortcuts")).toBe("Control+Delete Meta+Delete"); }); it("names the delete control after the action it currently performs", async () => { const root = await mountEditor("dameng"); const persistedDelete = root.querySelector('button[aria-label="structureEditor.drop"]'); if (!persistedDelete) throw new Error("Missing delete button for the persisted column"); persistedDelete.click(); await vi.waitFor(() => expect(root.querySelector("[data-delete-column-shortcut-button]")?.getAttribute("title")).toBe("structureEditor.restore")); buttonWithText(root, "structureEditor.addColumn").click(); await vi.waitFor(() => expect(root.querySelector('[data-column-row-index="1"]')).not.toBeNull()); const addedRow = root.querySelector('[data-column-row-index="1"]'); expect(addedRow?.querySelector('button[aria-label="structureEditor.remove"]')).not.toBeNull(); expect(addedRow?.querySelector("[data-delete-column-shortcut-button]")?.getAttribute("title")).toBe("structureEditor.remove ⌘/Ctrl+Del"); }); it("adds a field below the focused input on Shift+Enter", async () => { const root = await mountEditor("dameng"); const sourceInput = root.querySelector('[data-column-row-index="0"] [data-column-name-input]'); if (!sourceInput) throw new Error("Missing source column name input"); sourceInput.focus(); sourceInput.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter", shiftKey: true })); await vi.waitFor(() => expect(root.querySelector('[data-column-row-index="1"]')).not.toBeNull()); const addedInput = root.querySelector('[data-column-row-index="1"] [data-column-name-input]'); expect(addedInput?.value).toBe(""); expect(document.activeElement).toBe(addedInput); }); it("copies the field below the focused input on Mod+D", async () => { const root = await mountEditor("dameng"); const sourceInput = root.querySelector('[data-column-row-index="0"] [data-column-name-input]'); if (!sourceInput) throw new Error("Missing source column name input"); sourceInput.focus(); sourceInput.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "d", ctrlKey: true })); await vi.waitFor(() => expect(root.querySelector('[data-column-row-index="1"]')).not.toBeNull()); const copiedInput = root.querySelector('[data-column-row-index="1"] [data-column-name-input]'); expect(copiedInput?.value).toBe(sourceInput.value); expect(document.activeElement).toBe(copiedInput); }); it.each([ { label: "Ctrl+Delete", event: { key: "Delete", ctrlKey: true } }, { label: "Cmd+Delete", event: { key: "Backspace", metaKey: true } }, ])("removes a focused unsaved field on $label", async ({ event }) => { const root = await mountEditor("dameng"); buttonWithText(root, "structureEditor.addColumn").click(); await vi.waitFor(() => expect(root.querySelector('[data-column-row-index="1"]')).not.toBeNull()); const addedInput = root.querySelector('[data-column-row-index="1"] [data-column-name-input]'); if (!addedInput) throw new Error("Missing added column name input"); addedInput.focus(); addedInput.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, ...event })); await vi.waitFor(() => expect(root.querySelector('[data-column-row-index="1"]')).toBeNull()); expect(root.querySelector('[data-column-row-index="0"]')).not.toBeNull(); }); it("marks a focused persisted field for deletion on Mod+Delete", async () => { const root = await mountEditor("dameng"); const sourceInput = root.querySelector('[data-column-row-index="0"] [data-column-name-input]'); if (!sourceInput) throw new Error("Missing source column name input"); sourceInput.focus(); sourceInput.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Delete", ctrlKey: true })); await vi.waitFor(() => expect(root.querySelector('button[aria-label="structureEditor.restore"]')).not.toBeNull()); }); it("keeps Ctrl+Backspace available for editing field text", async () => { const root = await mountEditor("dameng"); buttonWithText(root, "structureEditor.addColumn").click(); await vi.waitFor(() => expect(root.querySelector('[data-column-row-index="1"]')).not.toBeNull()); const addedInput = root.querySelector('[data-column-row-index="1"] [data-column-name-input]'); if (!addedInput) throw new Error("Missing added column name input"); addedInput.focus(); addedInput.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Backspace", ctrlKey: true })); await nextTick(); expect(root.querySelector('[data-column-row-index="1"]')).not.toBeNull(); }); it("keeps Cmd+Backspace available for editing non-empty field text", async () => { const root = await mountEditor("dameng"); buttonWithText(root, "structureEditor.addColumn").click(); await vi.waitFor(() => expect(root.querySelector('[data-column-row-index="1"]')).not.toBeNull()); const addedInput = root.querySelector('[data-column-row-index="1"] [data-column-name-input]'); if (!addedInput) throw new Error("Missing added column name input"); addedInput.value = "name"; addedInput.focus(); addedInput.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Backspace", metaKey: true })); await nextTick(); expect(root.querySelector('[data-column-row-index="1"]')).not.toBeNull(); }); it("widens the ordinal indicator for a two-digit primary-key row", async () => { const root = await mountEditor("dameng"); const addColumn = buttonWithText(root, "structureEditor.addColumn"); for (let index = 0; index < 9; index += 1) { addColumn.click(); await nextTick(); } const primaryKey = columnCheckbox(root, "structureEditor.primaryKey", 9); primaryKey.checked = true; primaryKey.dispatchEvent(new Event("change", { bubbles: true })); await nextTick(); const actionIndicator = root.querySelector('[data-column-row-index="9"] td:first-child > div > div:first-child'); expect(Number.parseFloat(actionIndicator?.style.width ?? "0")).toBeGreaterThan(28); }); }); describe("TableStructureEditor local column order notice", () => { it.each(["sqlserver", "postgres", "sqlite", "oracle", "dameng", "duckdb", "informix"] as const)("does not show the reorder notice when adding a %s column", async (databaseType) => { const root = await mountEditor(databaseType); const addColumnButton = Array.from(root.querySelectorAll("button")).find((button) => button.textContent?.includes("structureEditor.addColumn")); if (!addColumnButton) throw new Error("Missing add column button"); addColumnButton.click(); await nextTick(); expect(mocks.toast).not.toHaveBeenCalled(); }); }); describe("TableStructureEditor horizontal scrolling", () => { it("shows a fixed scrollbar for overflowing columns and syncs thumb dragging", async () => { const root = await mountEditor("postgres"); const scroller = root.querySelector(".structure-table-scroller"); if (!scroller) throw new Error("Missing structure table scroller"); expect(scroller.classList.contains("flow-mode")).toBe(true); Object.defineProperties(scroller, { clientWidth: { configurable: true, value: 400 }, scrollWidth: { configurable: true, value: 1200 }, scrollLeft: { configurable: true, value: 200, writable: true }, }); scroller.dispatchEvent(new Event("scroll")); await nextTick(); await nextTick(); const track = root.querySelector(".structure-horizontal-scrollbar"); const thumb = root.querySelector(".structure-horizontal-scrollbar__thumb"); if (!track || !thumb) throw new Error("Missing fixed horizontal scrollbar"); expect(Number.parseFloat(thumb.style.width)).toBeCloseTo(100 / 3); expect(Number.parseFloat(thumb.style.left)).toBeCloseTo(100 / 6); track.getBoundingClientRect = () => DOMRect.fromRect({ width: 300, height: 10 }); document.body.style.userSelect = "text"; track.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 60, isPrimary: true })); window.dispatchEvent(new PointerEvent("pointermove", { clientX: 160, isPrimary: true })); expect(scroller.scrollLeft).toBeCloseTo(600); expect(document.body.style.userSelect).toBe("none"); window.dispatchEvent(new PointerEvent("pointerup", { isPrimary: true })); expect(document.body.style.userSelect).toBe("text"); }); }); describe("TableStructureEditor vertical scrolling", () => { it.each(["mysql", "postgres", "sqlserver", "oracle", "sqlite", "dameng", "clickhouse"] as const)("virtualizes large %s field lists and moves the rendered window while scrolling", async (databaseType) => { const root = await mountEditor(databaseType, false, { draftOverride: draftWithColumns(200) }); const scroller = root.querySelector(".structure-table-scroller"); if (!scroller) throw new Error("Missing structure table scroller"); Object.defineProperties(scroller, { clientHeight: { configurable: true, value: 330 }, scrollHeight: { configurable: true, value: 6630 }, scrollTop: { configurable: true, value: 0, writable: true }, }); scroller.dispatchEvent(new Event("scroll")); await nextTick(); expect(root.querySelectorAll("[data-column-row-index]").length).toBeLessThan(40); expect(root.querySelector('[data-column-row-index="0"]')).not.toBeNull(); expect(root.querySelector('[data-column-row-index="100"]')).toBeNull(); scroller.scrollTop = 3300; scroller.dispatchEvent(new Event("scroll")); await vi.waitFor(() => expect(root.querySelector('[data-column-row-index="100"][data-column-row-active="true"]')).not.toBeNull()); expect(root.querySelectorAll('[data-column-row-active="true"]')).not.toHaveLength(0); }); it("keeps the shared context menu targeted to a recycled field row", async () => { const root = await mountEditor("mysql", false, { draftOverride: draftWithColumns(200) }); const scroller = root.querySelector(".structure-table-scroller"); if (!scroller) throw new Error("Missing structure table scroller"); Object.defineProperties(scroller, { clientHeight: { configurable: true, value: 330 }, scrollHeight: { configurable: true, value: 6630 }, scrollTop: { configurable: true, value: 3300, writable: true }, }); scroller.dispatchEvent(new Event("scroll")); await vi.waitFor(() => expect(root.querySelector('[data-column-row-index="100"][data-column-row-active="true"]')).not.toBeNull()); const targetRow = root.querySelector('[data-column-row-index="100"]'); targetRow?.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, button: 2, clientX: 20, clientY: 20 })); await nextTick(); const contextMenu = document.body.querySelector("[data-dbx-context-menu]"); if (!contextMenu) throw new Error("Missing shared column context menu"); buttonWithText(contextMenu, "structureEditor.copyColumn").click(); await vi.waitFor(() => expect(root.querySelector('[data-column-row-index="101"][data-new-column-row="true"]')).not.toBeNull()); }); it("uses scheduled updates for small scrolls and synchronously catches jumps beyond the virtual buffer", async () => { const root = await mountEditor("mysql", false, { draftOverride: draftWithColumns(200) }); const scroller = root.querySelector(".structure-table-scroller"); if (!scroller) throw new Error("Missing structure table scroller"); Object.defineProperties(scroller, { clientHeight: { configurable: true, value: 330 }, scrollHeight: { configurable: true, value: 6630 }, scrollTop: { configurable: true, value: 0, writable: true }, }); const requestAnimationFrameSpy = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 1); try { expect(root.querySelector('[data-column-row-index="30"]')).toBeNull(); scroller.scrollTop = 66; scroller.dispatchEvent(new Event("scroll")); await nextTick(); expect(root.querySelector('[data-column-row-index="30"]')).toBeNull(); scroller.scrollTop = 660; scroller.dispatchEvent(new Event("scroll")); await nextTick(); expect(root.querySelector('[data-column-row-index="30"][data-column-row-active="true"]')).not.toBeNull(); expect(root.querySelectorAll('[data-column-row-active="true"]')).not.toHaveLength(0); } finally { requestAnimationFrameSpy.mockRestore(); } }); it("virtualizes PostgreSQL geometry rows with precomputed item sizes", async () => { const value = draftWithColumns(80); value.columns[0].dataType = "geometry(GEOMETRY,4326)"; value.columns[0].original!.data_type = "geometry(GEOMETRY,4326)"; const root = await mountEditor("postgres", false, { draftOverride: value }); expect(root.querySelectorAll("[data-column-row-index]").length).toBeLessThan(80); expect(root.querySelector('[data-column-row-index="0"]')).not.toBeNull(); }); it("persists rapid scroll events once after scrolling settles", async () => { const onDraftUpdate = vi.fn(); const root = await mountEditor("mysql", false, { draftOverride: draftWithColumns(200), onDraftUpdate }); const scroller = root.querySelector(".structure-table-scroller"); if (!scroller) throw new Error("Missing structure table scroller"); Object.defineProperties(scroller, { clientHeight: { configurable: true, value: 330 }, scrollHeight: { configurable: true, value: 6630 }, scrollTop: { configurable: true, value: 0, writable: true }, }); const baselineDraft = onDraftUpdate.mock.calls[onDraftUpdate.mock.calls.length - 1]?.[0] as ReturnType | undefined; expect(baselineDraft).toBeDefined(); onDraftUpdate.mockClear(); vi.useFakeTimers(); try { for (const scrollTop of [300, 600, 900]) { scroller.scrollTop = scrollTop; scroller.dispatchEvent(new Event("scroll")); } await nextTick(); expect(onDraftUpdate).not.toHaveBeenCalled(); vi.advanceTimersByTime(180); await nextTick(); expect(onDraftUpdate).toHaveBeenCalledTimes(1); expect(onDraftUpdate.mock.calls[0]?.[0]).toMatchObject({ scrollPositions: { columns: { scrollTop: 900, scrollLeft: 0 } } }); expect(onDraftUpdate.mock.calls[0]?.[0].columns).toBe(baselineDraft?.columns); expect(onDraftUpdate.mock.calls[0]?.[0].indexes).toBe(baselineDraft?.indexes); } finally { vi.useRealTimers(); } }); it("shows a fixed scrollbar for overflowing fields and syncs thumb dragging", async () => { const root = await mountEditor("postgres"); const scroller = root.querySelector(".structure-table-scroller"); if (!scroller) throw new Error("Missing structure table scroller"); Object.defineProperties(scroller, { clientHeight: { configurable: true, value: 200 }, scrollHeight: { configurable: true, value: 800 }, scrollTop: { configurable: true, value: 0, writable: true }, }); scroller.dispatchEvent(new Event("scroll")); await vi.waitFor(() => expect(root.querySelector(".structure-vertical-scrollbar__thumb")).not.toBeNull()); const track = root.querySelector(".structure-vertical-scrollbar")!; const thumb = root.querySelector(".structure-vertical-scrollbar__thumb")!; await vi.waitFor(() => expect(Number.parseFloat(thumb.style.height)).toBeCloseTo(25)); expect(Number.parseFloat(thumb.style.height)).toBeCloseTo(25); expect(Number.parseFloat(thumb.style.top)).toBeCloseTo(0); track.getBoundingClientRect = () => DOMRect.fromRect({ width: 10, height: 200 }); document.body.style.userSelect = "text"; track.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0, clientY: 50, isPrimary: true })); window.dispatchEvent(new PointerEvent("pointermove", { clientY: 125, isPrimary: true })); expect(scroller.scrollTop).toBeCloseTo(400); expect(document.body.style.userSelect).toBe("none"); window.dispatchEvent(new PointerEvent("pointerup", { isPrimary: true })); expect(document.body.style.userSelect).toBe(""); }); it("does not render the vertical scrollbar when the editor opens on the DDL tab", async () => { const root = await mountLoadingEditor("ddl"); const scroller = root.querySelector(".structure-table-scroller"); if (!scroller) throw new Error("Missing structure table scroller"); Object.defineProperties(scroller, { clientHeight: { configurable: true, value: 200 }, scrollHeight: { configurable: true, value: 800 }, }); scroller.dispatchEvent(new Event("scroll")); await nextTick(); await nextTick(); expect(root.querySelector(".structure-vertical-scrollbar")).toBeNull(); }); }); describe("TableStructureEditor metadata loading", () => { 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(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([ ["columns", ["columns", "indexes", "comment", "owner"]], ["indexes", ["columns", "indexes", "comment", "owner"]], ["foreignKeys", ["columns", "indexes", "foreign-keys", "comment", "owner"]], ["triggers", ["triggers", "comment", "owner"]], ] 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]).sort()).toEqual([...expectedFacets].sort()); expect(mocks.loadObjectDdl).not.toHaveBeenCalled(); }); it("preserves exact PostgreSQL owner names and includes an owner change in the SQL preview", async () => { mocks.loadObjectMetadataFacet.mockImplementation(async (_request, facet: string) => ({ value: facet === "owner" ? " app_user " : [], cacheStatus: "remote" })); mocks.executeQuery.mockResolvedValue({ columns: ["user", "host", "plugin"], rows: [ [" app_user ", "LOGIN", ""], ["reporting_role", "ROLE", ""], ], }); mocks.buildTableOwnerChangeSql.mockImplementation(async (options: { owner: string; originalOwner: string }) => ({ statements: options.owner === options.originalOwner ? [] : [`ALTER TABLE "public"."users" OWNER TO "${options.owner}";`], warnings: [], })); const root = await mountLoadingEditor("columns", " app_user "); const ownerSelect = await vi.waitFor(() => { const select = root.querySelector("[data-owner-select]"); expect(select?.dataset.modelValue).toBe(" app_user "); expect(JSON.parse(select?.dataset.options ?? "[]")).toEqual([" app_user ", "reporting_role"]); expect(select?.dataset.allowCustom).toBe("true"); expect(select?.dataset.trimCustom).toBe("false"); return select!; }); ownerSelect.click(); await vi.waitFor(() => expect(mocks.buildTableOwnerChangeSql).toHaveBeenLastCalledWith(expect.objectContaining({ owner: "custom_domain", originalOwner: " app_user ", schema: "public", tableName: "users" }))); }); it("loads the PostgreSQL primary index name before showing a missing-name warning", async () => { mocks.connection.db_type = "postgres"; mocks.connection.name = "postgres"; mocks.connection.driver_label = "postgres"; mocks.ensureConnected.mockResolvedValue(undefined); mocks.listDataTypes.mockResolvedValue([]); const warning = "Could not determine the existing PostgreSQL primary key constraint name. Refresh the table structure and try again."; mocks.buildTableStructureChangeSql.mockResolvedValue({ statements: [], warnings: [warning] }); mocks.loadObjectMetadataFacet.mockImplementation(async (_request, facet: string) => ({ value: facet === "columns" ? [ { name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true }, { name: "asdas", data_type: "integer", is_nullable: true, column_default: null, is_primary_key: false }, ] : facet === "comment" ? "" : 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: "test", initialTab: "columns", }); mountedApps.push(app); app.mount(root); await vi.waitFor(() => expect(root.querySelector('[data-column-row-index="0"]')).not.toBeNull()); expect(mocks.loadObjectMetadataFacet.mock.calls.map((call) => call[1]).sort()).toEqual(["columns", "indexes", "comment", "owner"].sort()); const primaryKey = columnCheckbox(root, "structureEditor.primaryKey"); primaryKey.checked = false; primaryKey.dispatchEvent(new Event("change", { bubbles: true })); await vi.waitFor(() => expect(root.textContent).toContain(warning)); expect(buttonWithText(root, "structureEditor.apply").disabled).toBe(true); expect(mocks.buildTableStructureChangeSql).toHaveBeenLastCalledWith(expect.objectContaining({ indexes: [] })); }); it("backfills the PostgreSQL primary index name for a restored columns-only draft", async () => { 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.loadObjectMetadataFacet.mockImplementation(async (_request, facet: string) => ({ value: facet === "indexes" ? [ { name: "test_pk", columns: ["id"], is_unique: true, is_primary: true, }, ] : facet === "comment" ? "" : 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: "test", draft: { ...draft(true), loadedMetadataFacets: ["columns", "comment"], }, }); mountedApps.push(app); app.mount(root); await vi.waitFor(() => expect(mocks.loadObjectMetadataFacet).toHaveBeenCalledTimes(2)); expect(mocks.loadObjectMetadataFacet.mock.calls.map((call) => call[1]).sort()).toEqual(["indexes", "owner"]); const primaryKey = columnCheckbox(root, "structureEditor.primaryKey"); primaryKey.checked = false; primaryKey.dispatchEvent(new Event("change", { bubbles: true })); await vi.waitFor(() => expect(mocks.buildTableStructureChangeSql).toHaveBeenLastCalledWith( expect.objectContaining({ indexes: [expect.objectContaining({ name: "test_pk", isPrimary: true, original: expect.objectContaining({ name: "test_pk", is_primary: true }) })], }), ), ); }); it("loads index metadata when an initialized column draft opens the indexes tab", async () => { 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.loadObjectMetadataFacet.mockImplementation(async (_request, facet: string) => ({ value: facet === "indexes" ? [ { name: "idx_users_id", columns: ["id"], is_unique: false, is_primary: false, }, ] : facet === "comment" ? "" : 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", initialTab: "indexes", draft: { ...draft(), loadedMetadataFacets: ["columns", "comment"], }, }); mountedApps.push(app); app.mount(root); await nextTick(); await Promise.resolve(); await nextTick(); await vi.waitFor(() => expect(mocks.loadObjectMetadataFacet).toHaveBeenCalledTimes(2)); expect(mocks.loadObjectMetadataFacet.mock.calls.map((call) => call[1]).sort()).toEqual(["indexes", "owner"]); await vi.waitFor(() => expect(root.querySelector('[data-index-row-index="0"]')).not.toBeNull()); }); });