diff --git a/apps/desktop/src/components/structure/TableStructureEditor.charsetCollation.spec.ts b/apps/desktop/src/components/structure/TableStructureEditor.charsetCollation.spec.ts index ed4656e528..105ef4d4a7 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.charsetCollation.spec.ts +++ b/apps/desktop/src/components/structure/TableStructureEditor.charsetCollation.spec.ts @@ -8,12 +8,16 @@ const mocks = vi.hoisted(() => ({ id: "structure-charset-test", name: "MySQL", db_type: "mysql", + driver_profile: "mysql", driver_label: "MySQL", }, ensureConnected: vi.fn(), executeQuery: vi.fn(), listDataTypes: vi.fn(), buildTableStructureChangeSql: vi.fn(), + buildMysqlAutoIncrementSql: vi.fn(), + getMysqlTableAutoIncrement: vi.fn(), + executeBatch: vi.fn(), updateEditorSettings: vi.fn(), loadObjectDdl: vi.fn(), invalidateObjectDdl: vi.fn(), @@ -222,19 +226,25 @@ vi.mock("@/lib/backend/api", () => ({ executeQuery: mocks.executeQuery, listDataTypes: mocks.listDataTypes, buildTableStructureChangeSql: mocks.buildTableStructureChangeSql, + buildMysqlAutoIncrementSql: mocks.buildMysqlAutoIncrementSql, + getMysqlTableAutoIncrement: mocks.getMysqlTableAutoIncrement, + executeBatch: mocks.executeBatch, })); import TableStructureEditor from "@/components/structure/TableStructureEditor.vue"; const mountedApps: App[] = []; +let mountedEditor: { applyChanges: () => Promise } | undefined; -function draft() { +function draft(autoIncrement = false, counter?: { value?: string; originalValue?: string }) { return { initialized: true, activeTab: "columns" as const, newTableName: "", tableComment: "", originalTableComment: "", + mysqlAutoIncrementValue: counter?.value, + originalMysqlAutoIncrementValue: counter?.originalValue, columns: [ { id: "existing:id", @@ -246,14 +256,14 @@ function draft() { isPrimaryKey: false, characterSet: "utf8mb3", collation: "utf8mb3_uca1400_ai_ci", - extra: "", + extra: { autoIncrement }, original: { name: "id", data_type: "VARCHAR", is_nullable: true, column_default: null, is_primary_key: false, - extra: null, + extra: autoIncrement ? "auto_increment" : null, comment: null, }, originalPosition: 0, @@ -266,7 +276,7 @@ function draft() { }; } -async function mountEditor() { +async function mountEditor(autoIncrement = false, counter?: { value?: string; originalValue?: string }) { mocks.ensureConnected.mockResolvedValue(undefined); mocks.listDataTypes.mockResolvedValue([]); mocks.buildTableStructureChangeSql.mockResolvedValue({ statements: [], warnings: [] }); @@ -278,10 +288,10 @@ async function mountEditor() { database: "test", schema: "test", tableName: "users", - draft: draft(), + draft: draft(autoIncrement, counter), }); mountedApps.push(app); - app.mount(root); + mountedEditor = app.mount(root) as unknown as { applyChanges: () => Promise }; await nextTick(); await Promise.resolve(); await nextTick(); @@ -303,9 +313,13 @@ beforeEach(() => { mocks.connection.db_type = "mysql"; mocks.connection.name = "MySQL"; mocks.connection.driver_label = "MySQL"; + mocks.connection.driver_profile = "mysql"; mocks.loadObjectDdl.mockResolvedValue({ ddl: "CREATE TABLE users (id varchar(255))", cacheStatus: "remote" }); mocks.invalidateObjectDdl.mockResolvedValue(undefined); mocks.loadObjectMetadataFacet.mockResolvedValue({ value: [], cacheStatus: "remote" }); + mocks.getMysqlTableAutoIncrement.mockResolvedValue("10"); + mocks.buildMysqlAutoIncrementSql.mockImplementation(async ({ value }: { value: string }) => `ALTER TABLE \`test\`.\`users\` AUTO_INCREMENT = ${value};`); + mocks.executeBatch.mockResolvedValue({ affected_rows: 0 }); }); afterEach(() => { @@ -333,3 +347,98 @@ describe("TableStructureEditor charset/collation column width", () => { expect(triggerClass.split(",")).not.toEqual(expect.arrayContaining(["w-28"])); }); }); + +describe("TableStructureEditor MySQL AUTO_INCREMENT counter", () => { + it("loads and renders the server value as a decimal string only for an existing auto-increment column", async () => { + const root = await mountEditor(true); + await vi.waitFor(() => expect(mocks.getMysqlTableAutoIncrement).toHaveBeenCalledWith("structure-charset-test", "test", "users")); + + const input = root.querySelector("[data-mysql-auto-increment-counter]"); + expect(input?.value).toBe("10"); + expect(input?.disabled).toBe(false); + }); + + it("refreshes a restored clean counter draft from the server", async () => { + mocks.getMysqlTableAutoIncrement.mockResolvedValueOnce("30"); + const root = await mountEditor(true, { value: "10", originalValue: "10" }); + + await vi.waitFor(() => expect(root.querySelector("[data-mysql-auto-increment-counter]")?.value).toBe("30")); + expect(mocks.buildMysqlAutoIncrementSql).not.toHaveBeenCalled(); + }); + + it("keeps a restored dirty value while rebasing its server baseline", async () => { + mocks.getMysqlTableAutoIncrement.mockResolvedValueOnce("15"); + const root = await mountEditor(true, { value: "20", originalValue: "10" }); + const input = root.querySelector("[data-mysql-auto-increment-counter]")!; + + await vi.waitFor(() => expect(input.value).toBe("20")); + await vi.waitFor(() => expect(mocks.buildMysqlAutoIncrementSql).toHaveBeenCalledWith(expect.objectContaining({ value: "20" }))); + mocks.buildMysqlAutoIncrementSql.mockClear(); + input.value = "15"; + input.dispatchEvent(new Event("input", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 400)); + expect(mocks.buildMysqlAutoIncrementSql).not.toHaveBeenCalled(); + }); + + it("keeps NULL or failed optional metadata from enabling a counter edit", async () => { + mocks.getMysqlTableAutoIncrement.mockResolvedValueOnce(null); + const nullRoot = await mountEditor(true); + await vi.waitFor(() => expect(mocks.getMysqlTableAutoIncrement).toHaveBeenCalled()); + expect(nullRoot.querySelector("[data-mysql-auto-increment-counter]")?.disabled).toBe(true); + + for (const app of mountedApps.splice(0)) app.unmount(); + document.body.innerHTML = ""; + mocks.getMysqlTableAutoIncrement.mockRejectedValueOnce(new Error("permission denied")); + const errorRoot = await mountEditor(true); + await vi.waitFor(() => expect(errorRoot.querySelector("[data-mysql-auto-increment-counter]")?.title).toBe("permission denied")); + expect(mocks.buildMysqlAutoIncrementSql).not.toHaveBeenCalled(); + + const structureStatement = "ALTER TABLE `test`.`users` COMMENT = 'ordinary edit';"; + mocks.buildTableStructureChangeSql.mockResolvedValue({ statements: [structureStatement], warnings: [] }); + const commentInput = Array.from(errorRoot.querySelectorAll("input")).find((input) => !input.hasAttribute("data-mysql-auto-increment-counter"))!; + commentInput.value = "ordinary edit"; + commentInput.dispatchEvent(new Event("input", { bubbles: true })); + await vi.waitFor(() => expect(mocks.buildTableStructureChangeSql).toHaveBeenCalled()); + await vi.waitFor(() => expect(Array.from(errorRoot.querySelectorAll("button")).find((button) => button.textContent?.includes("structureEditor.apply"))?.disabled).toBe(false)); + await expect(mountedEditor?.applyChanges()).resolves.toBe(true); + expect(mocks.executeBatch.mock.calls[mocks.executeBatch.mock.calls.length - 1]?.[2]).toEqual([structureStatement]); + expect(mocks.buildMysqlAutoIncrementSql).not.toHaveBeenCalled(); + }); + + it("appends validated counter DDL and preserves the draft when execution fails", async () => { + const root = await mountEditor(true); + await vi.waitFor(() => expect(root.querySelector("[data-mysql-auto-increment-counter]")?.value).toBe("10")); + const input = root.querySelector("[data-mysql-auto-increment-counter]")!; + input.value = "9007199254740993"; + input.dispatchEvent(new Event("input", { bubbles: true })); + await vi.waitFor(() => expect(mocks.buildMysqlAutoIncrementSql).toHaveBeenCalledWith(expect.objectContaining({ value: "9007199254740993" }))); + + mocks.executeBatch.mockRejectedValueOnce(new Error("ALTER command denied")); + await expect(mountedEditor?.applyChanges()).resolves.toBe(false); + await vi.waitFor(() => expect(root.textContent).toContain("ALTER command denied")); + expect(input.value).toBe("9007199254740993"); + }); + + it("does not treat the requested value as persisted when the post-save metadata refresh fails", async () => { + const persistedColumn = draft(true).columns[0]!.original; + mocks.loadObjectMetadataFacet.mockImplementation(async (_request, facet: string) => ({ + value: facet === "columns" ? [persistedColumn] : [], + cacheStatus: "remote", + })); + const root = await mountEditor(true); + await vi.waitFor(() => expect(root.querySelector("[data-mysql-auto-increment-counter]")?.value).toBe("10")); + const input = root.querySelector("[data-mysql-auto-increment-counter]")!; + input.value = "20"; + input.dispatchEvent(new Event("input", { bubbles: true })); + await vi.waitFor(() => expect(mocks.buildMysqlAutoIncrementSql).toHaveBeenCalledWith(expect.objectContaining({ value: "20" }))); + const metadataCallsBeforeSave = mocks.getMysqlTableAutoIncrement.mock.calls.length; + mocks.getMysqlTableAutoIncrement.mockRejectedValueOnce(new Error("refresh denied")); + mocks.buildMysqlAutoIncrementSql.mockClear(); + + await expect(mountedEditor?.applyChanges()).resolves.toBe(true); + await vi.waitFor(() => expect(mocks.getMysqlTableAutoIncrement.mock.calls.length).toBeGreaterThan(metadataCallsBeforeSave)); + await vi.waitFor(() => expect(root.querySelector("[data-mysql-auto-increment-counter]")?.title).toBe("refresh denied")); + expect(root.querySelector("[data-mysql-auto-increment-counter]")?.value).toBe("20"); + expect(mocks.buildMysqlAutoIncrementSql).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index aacb729736..7c0d702d08 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -32,6 +32,7 @@ import { invalidateObjectDdl, loadObjectDdl } from "@/lib/metadata/objectDdlCach import { invalidateObjectMetadataCache, 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 { buildMysqlAutoIncrementCounterStatement, canEditMysqlAutoIncrementCounter, refreshMysqlAutoIncrementCounterDraft } from "@/lib/table/mysqlAutoIncrementCounter"; import { PRESET_FIELDS_TEMPLATE_ID, createTableColumnTemplateDrafts } from "@/lib/table/tableColumnTemplates"; import { getMysqlDataTypeHelp } from "@/lib/table/mysqlDataTypeHelp"; import { getPostgresDataTypeHelp, gaussdbMTypeDisplayName } from "@/lib/table/postgresDataTypeHelp"; @@ -912,6 +913,10 @@ const canAddColumn = computed(() => canAddTableStructureColumn(databaseType.valu const newTableName = ref(""); const tableComment = ref(""); const originalTableComment = ref(""); +const mysqlAutoIncrementValue = ref(); +const originalMysqlAutoIncrementValue = ref(); +const mysqlAutoIncrementLoading = ref(false); +const mysqlAutoIncrementLoadError = ref(""); const tableOwner = ref(""); const originalTableOwner = ref(""); const tableOwnerLoading = ref(false); @@ -920,6 +925,8 @@ const tableOwnerRoles = ref([]); const tableOwnerRolesLoading = ref(false); const tableOwnerRolesLoadError = ref(""); const supportsTableOwner = computed(() => !isCreateMode.value && databaseType.value === "postgres"); +const canEditMysqlAutoIncrement = computed(() => canEditMysqlAutoIncrementCounter(connection.value, isCreateMode.value, columns.value)); +const canBuildMysqlAutoIncrement = computed(() => canEditMysqlAutoIncrement.value && !mysqlAutoIncrementLoading.value && !mysqlAutoIncrementLoadError.value && originalMysqlAutoIncrementValue.value !== undefined); const tableOwnerOptions = computed(() => { const owner = tableOwner.value; if (!owner || tableOwnerRoles.value.includes(owner)) return tableOwnerRoles.value; @@ -942,6 +949,7 @@ let sqlPreviewRequestId = 0; let structureLoadRequestId = 0; let tableOwnerLoadRequestId = 0; let tableOwnerRolesLoadRequestId = 0; +let mysqlAutoIncrementLoadRequestId = 0; let dataTypeOptionsRequestId = 0; let sqlPreviewDebounceTimer: ReturnType | undefined; let deferredSqlPreviewRefresh = false; @@ -1127,6 +1135,8 @@ function createCurrentDraft(initialized = true): TableStructureEditorDraft { newTableName: newTableName.value, tableComment: tableComment.value, originalTableComment: originalTableComment.value, + mysqlAutoIncrementValue: mysqlAutoIncrementValue.value, + originalMysqlAutoIncrementValue: originalMysqlAutoIncrementValue.value, tableOwner: tableOwner.value, originalTableOwner: originalTableOwner.value, columns: cloneDraftValue(columns.value), @@ -1157,6 +1167,8 @@ function restoreDraft(draft: TableStructureEditorDraft) { newTableName.value = draft.newTableName || ""; tableComment.value = draft.tableComment || ""; originalTableComment.value = draft.originalTableComment || ""; + mysqlAutoIncrementValue.value = draft.mysqlAutoIncrementValue; + originalMysqlAutoIncrementValue.value = draft.originalMysqlAutoIncrementValue; tableOwner.value = draft.tableOwner || ""; originalTableOwner.value = draft.originalTableOwner || ""; columns.value = cloneDraftValue(draft.columns || []); @@ -1243,7 +1255,15 @@ function hasPendingStructureChanges(): boolean { return !!newTableName.value.trim() || !!tableComment.value.trim() || columns.value.length > 0 || indexes.value.length > 0 || foreignKeys.value.length > 0 || triggers.value.length > 0; } const scope = captureStructureRefreshScope(); - return scope.columns || scope.indexes || scope.foreignKeys || scope.triggers || scope.tableComment || (supportsTableOwner.value && tableOwner.value.trim() !== originalTableOwner.value.trim()); + return ( + scope.columns || + scope.indexes || + scope.foreignKeys || + scope.triggers || + scope.tableComment || + (canBuildMysqlAutoIncrement.value && mysqlAutoIncrementValue.value !== originalMysqlAutoIncrementValue.value) || + (supportsTableOwner.value && tableOwner.value.trim() !== originalTableOwner.value.trim()) + ); } function clearSqlPreviewState() { @@ -1426,18 +1446,30 @@ async function refreshSqlPreview() { sqlPreviewLoading.value = true; const options = structureChangeOptions(); try { - const result = isCreateMode.value ? await api.buildCreateTableSql(options) : hasSqliteTypeChange.value ? await api.previewSqliteTableStructureChange(props.connectionId, props.database, options) : await api.buildTableStructureChangeSql(options); - const ownerResult = supportsTableOwner.value - ? await api.buildTableOwnerChangeSql({ - databaseType: databaseType.value, - schema: metadataSchema.value, - tableName: props.tableName || "", - owner: tableOwner.value, - originalOwner: originalTableOwner.value, - }) - : { statements: [], warnings: [] }; + const [result, ownerResult, mysqlAutoIncrementStatement] = await Promise.all([ + isCreateMode.value ? api.buildCreateTableSql(options) : hasSqliteTypeChange.value ? api.previewSqliteTableStructureChange(props.connectionId, props.database, options) : api.buildTableStructureChangeSql(options), + supportsTableOwner.value + ? api.buildTableOwnerChangeSql({ + databaseType: databaseType.value, + schema: metadataSchema.value, + tableName: props.tableName || "", + owner: tableOwner.value, + originalOwner: originalTableOwner.value, + }) + : Promise.resolve({ statements: [], warnings: [] }), + buildMysqlAutoIncrementCounterStatement({ + enabled: canBuildMysqlAutoIncrement.value, + originalValue: originalMysqlAutoIncrementValue.value, + value: mysqlAutoIncrementValue.value, + databaseType: databaseType.value, + driverProfile: connection.value?.driver_profile, + schema: props.schema || props.database, + tableName: props.tableName || "", + buildSql: api.buildMysqlAutoIncrementSql, + }), + ]); if (requestId !== sqlPreviewRequestId) return; - pendingStatements.value = [...result.statements, ...ownerResult.statements]; + pendingStatements.value = [...result.statements, ...ownerResult.statements, ...(mysqlAutoIncrementStatement ? [mysqlAutoIncrementStatement] : [])]; warnings.value = [...result.warnings, ...ownerResult.warnings]; sqliteSchemaRevision.value = "schemaRevision" in result && typeof result.schemaRevision === "string" ? result.schemaRevision : undefined; } catch (e: any) { @@ -1505,6 +1537,11 @@ function resetState() { newTableName.value = ""; tableComment.value = ""; originalTableComment.value = ""; + mysqlAutoIncrementValue.value = undefined; + originalMysqlAutoIncrementValue.value = undefined; + mysqlAutoIncrementLoadRequestId += 1; + mysqlAutoIncrementLoading.value = false; + mysqlAutoIncrementLoadError.value = ""; tableOwner.value = ""; originalTableOwner.value = ""; tableOwnerLoadRequestId += 1; @@ -1578,6 +1615,32 @@ function loadCachedTableComment(request: ReturnType, force = return loadObjectMetadataFacet(request, "comment", () => fetchTableCommentValue(request.connectionId, request.database, request.schema, request.tableName, request.catalog), { force }); } +async function loadMysqlAutoIncrementCounter(preserveDraft = false) { + const requestId = ++mysqlAutoIncrementLoadRequestId; + if (!canEditMysqlAutoIncrement.value || !props.connectionId || !props.database || !props.tableName) { + mysqlAutoIncrementValue.value = undefined; + originalMysqlAutoIncrementValue.value = undefined; + mysqlAutoIncrementLoading.value = false; + mysqlAutoIncrementLoadError.value = ""; + return; + } + mysqlAutoIncrementLoading.value = true; + mysqlAutoIncrementLoadError.value = ""; + try { + await store.ensureConnected(props.connectionId); + const value = await api.getMysqlTableAutoIncrement(props.connectionId, props.database, props.tableName); + if (requestId !== mysqlAutoIncrementLoadRequestId) return; + const draft = refreshMysqlAutoIncrementCounterDraft(value, { value: mysqlAutoIncrementValue.value, originalValue: originalMysqlAutoIncrementValue.value }, preserveDraft); + originalMysqlAutoIncrementValue.value = draft.originalValue; + mysqlAutoIncrementValue.value = draft.value; + } catch (error: any) { + if (requestId !== mysqlAutoIncrementLoadRequestId) return; + mysqlAutoIncrementLoadError.value = error?.message || String(error); + } finally { + if (requestId === mysqlAutoIncrementLoadRequestId) mysqlAutoIncrementLoading.value = false; + } +} + async function loadTableOwner(force = false, preserveDraft = false) { const connectionId = props.connectionId; const database = props.database; @@ -1712,6 +1775,8 @@ async function loadStructure( if (!options.preserveDraft) clearColumnSelection(); } + await loadMysqlAutoIncrementCounter(options.preserveDraft === true); + const nextTableComment = await tableCommentPromise; if (nextTableComment !== undefined) { originalTableComment.value = nextTableComment; @@ -1792,6 +1857,7 @@ async function refreshStructureAfterSave(scope: TableStructureRefreshScope, char console.warn("[DBX][structure-editor:post-save-refresh-failed]", e); } finally { postSaveRefreshing.value = false; + if (mysqlAutoIncrementValue.value !== originalMysqlAutoIncrementValue.value) scheduleSqlPreviewRefresh(); if (activeTab.value === "ddl") void fetchDdl(true); } } @@ -3291,6 +3357,7 @@ onMounted(() => { if (props.draft?.initialized) { void hydrateRestoredDraftFromDatabase().then(() => { applyInitialStructureTarget(); + void loadMysqlAutoIncrementCounter(true); void loadActiveTableStructureMetadataIfNeeded(); }); } else if (isCreateMode.value) { @@ -3313,6 +3380,7 @@ onActivated(() => { applyInitialStructureTarget(); void hydrateRestoredDraftFromDatabase().then(() => { applyInitialStructureTarget(); + void loadMysqlAutoIncrementCounter(true); void loadActiveTableStructureMetadataIfNeeded(); }); } @@ -3422,7 +3490,25 @@ watch([() => props.connectionId, () => props.database, databaseType], () => { }); watch( - [isCreateMode, () => props.connectionId, () => props.database, databaseType, () => props.schema, () => props.tableName, newTableName, tableComment, tableOwner, columns, indexes, foreignKeys, triggers], + [ + isCreateMode, + () => props.connectionId, + () => props.database, + databaseType, + () => props.schema, + () => props.tableName, + newTableName, + tableComment, + mysqlAutoIncrementValue, + originalMysqlAutoIncrementValue, + mysqlAutoIncrementLoading, + mysqlAutoIncrementLoadError, + tableOwner, + columns, + indexes, + foreignKeys, + triggers, + ], () => { scheduleSqlPreviewRefresh(); syncDraftToParent(); @@ -3539,6 +3625,26 @@ watch([activeTab, ddlLoading], ([tab, loading]) => { +
+ + + + + + + {{ mysqlAutoIncrementLoadError }} + +
+