feat(mysql): edit table auto-increment counter

Closes #7065
This commit is contained in:
t8y2 committed 2026-08-25 01:24:09 +00:00
1 parent 44dc2a88f8
commit 277386ec81
13 files changed
+513 -32

No files matched your search

@@ -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<boolean> } | 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<boolean> };
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<HTMLInputElement>("[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<HTMLInputElement>("[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<HTMLInputElement>("[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<HTMLInputElement>("[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<HTMLInputElement>("[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<HTMLInputElement>("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<HTMLInputElement>("[data-mysql-auto-increment-counter]")?.value).toBe("10"));
const input = root.querySelector<HTMLInputElement>("[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<HTMLInputElement>("[data-mysql-auto-increment-counter]")?.value).toBe("10"));
const input = root.querySelector<HTMLInputElement>("[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<HTMLInputElement>("[data-mysql-auto-increment-counter]")?.title).toBe("refresh denied"));
expect(root.querySelector<HTMLInputElement>("[data-mysql-auto-increment-counter]")?.value).toBe("20");
expect(mocks.buildMysqlAutoIncrementSql).not.toHaveBeenCalled();
});
});
@@ -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<string>();
const originalMysqlAutoIncrementValue = ref<string>();
const mysqlAutoIncrementLoading = ref(false);
const mysqlAutoIncrementLoadError = ref("");
const tableOwner = ref("");
const originalTableOwner = ref("");
const tableOwnerLoading = ref(false);
@@ -920,6 +925,8 @@ const tableOwnerRoles = ref<string[]>([]);
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<typeof setTimeout> | 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<typeof ddlRequest>, 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]) => {
</Tooltip>
</div>
<div v-if="canEditMysqlAutoIncrement" class="flex shrink-0 items-center gap-2">
<label class="shrink-0 font-medium text-muted-foreground">AUTO_INCREMENT</label>
<Input
v-model="mysqlAutoIncrementValue"
inputmode="numeric"
autocomplete="off"
data-mysql-auto-increment-counter
:placeholder="mysqlAutoIncrementLoading ? t('common.loading') : '—'"
:title="mysqlAutoIncrementLoadError || undefined"
:class="[structureMonoControlClass, 'max-w-[220px]']"
:disabled="mysqlAutoIncrementLoading || !!mysqlAutoIncrementLoadError || originalMysqlAutoIncrementValue === undefined || saving"
/>
<Tooltip v-if="mysqlAutoIncrementLoadError">
<TooltipTrigger as-child>
<AlertTriangle :class="[structureIconClass, 'shrink-0 text-destructive']" />
</TooltipTrigger>
<TooltipContent>{{ mysqlAutoIncrementLoadError }}</TooltipContent>
</Tooltip>
</div>
<div v-if="supportsTableOwner" class="flex shrink-0 items-center gap-2">
<label class="flex shrink-0 items-center gap-1 font-medium text-muted-foreground">
<UserRound :class="structureIconClass" />