diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 3906d6b255..b042bd0cd7 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -51,6 +51,7 @@ import { Switch } from "@/components/ui/switch"; import { cancelPendingSidebarDataOpen, runSidebarDataOpenImmediately, type SidebarDataOpenRequest } from "@/lib/sidebar/sidebarDataOpenCoordinator"; import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { codeMirrorSqlDialect } from "@/lib/database/jdbcDialect"; import { sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter"; @@ -1460,6 +1461,13 @@ function updateSidebarDangerDialogOption(event: Event) { void option.onChange?.(option.checked); } +function updateSidebarDangerDialogTextInput(value: string | number) { + const input = sidebarDangerDialogRequest.value?.textInput; + if (!input) return; + input.value = String(value); + void input.onInput?.(input.value); +} + function updateSidebarTreeItemDialogController(controller: Record | null) { sidebarTreeItemDialogController.value = controller; } @@ -2287,6 +2295,10 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes }); {{ sidebarDangerDialogRequest.option.hint }} + diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 613646f80d..3c3138187f 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -190,6 +190,7 @@ import { isLoadingStructurePreview, showEmptyTableConfirm, showTruncateTableConfirm, + showMysqlAutoIncrementConfirm, showRenameObjectDialog, renameObjectName, renameObjectError, @@ -200,6 +201,8 @@ import { emptyTablePreviewSql, truncateTablePreviewSql, truncateTableCascade, + mysqlAutoIncrementValue, + mysqlAutoIncrementPreviewSql, dropObjectPreviewSql, showDropObjectConfirm, dropTableChildObjectPreviewSql, @@ -462,7 +465,7 @@ const { confirmDropAllMongoIndexes, } = useSidebarDatabaseSpecificMutationRuntime({ activeNode, connectionStore }); -const { isTableNotView, supportsTruncate, canDropTableCascade, canTruncateTableCascade, refreshDropTablePreviewSql, refreshTruncateTablePreviewSql, dropTable, refreshTableList, confirmDropTable, emptyTable, confirmEmptyTable, truncateTable, confirmTruncateTable } = useSidebarTableMutationRuntime({ +const { isTableNotView, supportsTruncate, supportsMysqlAutoIncrement, canDropTableCascade, canTruncateTableCascade, refreshDropTablePreviewSql, refreshTruncateTablePreviewSql, dropTable, refreshTableList, confirmDropTable, emptyTable, confirmEmptyTable, truncateTable, confirmTruncateTable, mysqlAutoIncrement, refreshMysqlAutoIncrementPreviewSql, confirmMysqlAutoIncrement } = useSidebarTableMutationRuntime({ activeNode, releaseActiveNodeReference, connectionStore, @@ -3777,6 +3780,13 @@ function dangerRequest(request: Omit): Sid await onChange(checked); }; } + if (request.textInput?.onInput) { + const onInput = request.textInput.onInput; + request.textInput.onInput = async (value) => { + activateActionTarget(target); + await onInput(value); + }; + } routedRequest.target = target; sidebarDangerTarget.value = routedRequest.target; return routedRequest; @@ -3872,6 +3882,30 @@ routeDangerDialog(showTruncateTableConfirm, () => }), ); +routeDangerDialog(showMysqlAutoIncrementConfirm, () => + dangerRequest({ + title: t("contextMenu.mysqlAutoIncrementTitle"), + message: t("contextMenu.mysqlAutoIncrementMessage", { name: activeNode.value.label }), + detailsText: t("contextMenu.mysqlAutoIncrementNonemptyHint"), + get sql() { + return mysqlAutoIncrementPreviewSql.value; + }, + confirmLabel: t("contextMenu.mysqlAutoIncrement"), + textInput: { + value: mysqlAutoIncrementValue.value, + label: t("contextMenu.mysqlAutoIncrementValue"), + placeholder: "1", + inputMode: "numeric", + async onInput(value) { + mysqlAutoIncrementValue.value = value; + if (sidebarDangerTarget.value) activateActionTarget(sidebarDangerTarget.value); + await refreshMysqlAutoIncrementPreviewSql(); + }, + }, + confirm: confirmMysqlAutoIncrement, + }), +); + routeDangerDialog(showDropObjectConfirm, () => dangerRequest({ title: dropObjectConfirmTitle(), @@ -4898,6 +4932,9 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean { items.push({ label: t("contextMenu.duplicateStructure"), action: duplicateStructure, icon: CopyPlus }); // Keep menu copy aligned with keyboard copy so frozen multi-selection and single-row fallback stay compatible. items.push(...treeTableClipboardMenuItems(node)); + if (supportsMysqlAutoIncrement.value) { + items.push({ label: t("contextMenu.mysqlAutoIncrement"), action: mysqlAutoIncrement, icon: Gauge }); + } if (supportsTruncate.value) { destructiveActions.push({ label: truncateMenuLabel(t("contextMenu.truncateTable")), diff --git a/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts b/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts index 4475ce4239..51d15abad4 100644 --- a/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts +++ b/apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts @@ -32,6 +32,7 @@ export const structureDocCopyTitle = ref(""); export const isLoadingStructurePreview = ref(false); export const showEmptyTableConfirm = ref(false); export const showTruncateTableConfirm = ref(false); +export const showMysqlAutoIncrementConfirm = ref(false); export const showRenameObjectDialog = ref(false); export const renameObjectName = ref(""); export const renameObjectError = ref(""); @@ -42,6 +43,9 @@ export const batchDropCascade = ref(false); export const emptyTablePreviewSql = ref(""); export const truncateTablePreviewSql = ref(""); export const truncateTableCascade = ref(false); +export const mysqlAutoIncrementValue = ref("1"); +export const mysqlAutoIncrementPreviewSql = ref(""); +export const mysqlAutoIncrementPreviewKey = ref(""); export const dropObjectPreviewSql = ref(""); export const showDropObjectConfirm = ref(false); export const dropTableChildObjectPreviewSql = ref(""); @@ -145,6 +149,7 @@ const openFlags = [ showStructureDocCopyDialog, showEmptyTableConfirm, showTruncateTableConfirm, + showMysqlAutoIncrementConfirm, showDropObjectConfirm, showRenameObjectDialog, showDuplicateDialog, diff --git a/apps/desktop/src/composables/__tests__/useSidebarTableMutationRuntime.mysqlAutoIncrement.spec.ts b/apps/desktop/src/composables/__tests__/useSidebarTableMutationRuntime.mysqlAutoIncrement.spec.ts new file mode 100644 index 0000000000..b4bbce2466 --- /dev/null +++ b/apps/desktop/src/composables/__tests__/useSidebarTableMutationRuntime.mysqlAutoIncrement.spec.ts @@ -0,0 +1,137 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { shallowRef } from "vue"; +import type { TreeNode } from "@/types/database"; +import { mysqlAutoIncrementPreviewKey, mysqlAutoIncrementPreviewSql, mysqlAutoIncrementValue, showMysqlAutoIncrementConfirm, sidebarDangerTarget } from "@/components/sidebar/sidebarTreeDialogState"; + +const mocks = vi.hoisted(() => ({ + toast: vi.fn(), + buildMysqlAutoIncrementSql: vi.fn(), + ensureConnected: vi.fn(), + executeWithProductionGuard: vi.fn(), + refreshMutatedTableDataTabsForNode: vi.fn(), +})); + +vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) })); +vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) })); +vi.mock("@/lib/database/dbAdminSql", async (importOriginal) => ({ + ...(await importOriginal()), + buildMysqlAutoIncrementSql: mocks.buildMysqlAutoIncrementSql, +})); + +import { useSidebarTableMutationRuntime } from "@/composables/useSidebarTableMutationRuntime"; + +function tableNode(): TreeNode { + return { id: "mysql-1:sales:events", label: "events", type: "table", connectionId: "mysql-1", database: "sales", isExpanded: false }; +} + +function runtime(driverProfile: string | undefined = "mysql") { + const activeNode = shallowRef(tableNode()); + const connectionStore = { + getConfig: () => ({ id: "mysql-1", db_type: "mysql", driver_profile: driverProfile }), + ensureConnected: mocks.ensureConnected, + } as any; + const feature = useSidebarTableMutationRuntime({ + activeNode, + releaseActiveNodeReference: vi.fn(), + connectionStore, + currentDatabaseType: () => "mysql", + databaseTypeForNode: () => "mysql", + executeWithProductionGuard: mocks.executeWithProductionGuard, + closeDroppedTableObjectTabsForNode: vi.fn(), + refreshMutatedTableDataTabsForNode: mocks.refreshMutatedTableDataTabsForNode, + }); + return { ...feature, activeNode }; +} + +describe("useSidebarTableMutationRuntime MySQL AUTO_INCREMENT action", () => { + beforeEach(() => { + vi.clearAllMocks(); + sidebarDangerTarget.value = null; + showMysqlAutoIncrementConfirm.value = false; + mysqlAutoIncrementValue.value = "99"; + mysqlAutoIncrementPreviewSql.value = ""; + mysqlAutoIncrementPreviewKey.value = ""; + mocks.ensureConnected.mockResolvedValue(undefined); + mocks.buildMysqlAutoIncrementSql.mockResolvedValue("ALTER TABLE `sales`.`events` AUTO_INCREMENT = 1;"); + mocks.executeWithProductionGuard.mockResolvedValue(undefined); + mocks.refreshMutatedTableDataTabsForNode.mockResolvedValue(undefined); + }); + + it("opens with value 1 and builds only a preview", async () => { + const feature = runtime(); + + feature.mysqlAutoIncrement(); + await vi.waitFor(() => expect(mocks.buildMysqlAutoIncrementSql).toHaveBeenCalled()); + + expect(feature.supportsMysqlAutoIncrement.value).toBe(true); + expect(mysqlAutoIncrementValue.value).toBe("1"); + expect(showMysqlAutoIncrementConfirm.value).toBe(true); + expect(mocks.ensureConnected).not.toHaveBeenCalled(); + expect(mocks.executeWithProductionGuard).not.toHaveBeenCalled(); + expect(mocks.buildMysqlAutoIncrementSql).toHaveBeenCalledWith(expect.objectContaining({ driverProfile: "mysql", schema: "sales", value: "1" })); + }); + + it("canceling after open does not connect or execute", async () => { + const feature = runtime(); + + feature.mysqlAutoIncrement(); + await vi.waitFor(() => expect(mocks.buildMysqlAutoIncrementSql).toHaveBeenCalledTimes(1)); + showMysqlAutoIncrementConfirm.value = false; + + expect(mocks.ensureConnected).not.toHaveBeenCalled(); + expect(mocks.executeWithProductionGuard).not.toHaveBeenCalled(); + expect(mocks.refreshMutatedTableDataTabsForNode).not.toHaveBeenCalled(); + }); + + it("executes the validated preview through existing guards and refreshes open data tabs", async () => { + const feature = runtime(); + mysqlAutoIncrementValue.value = "18446744073709551615"; + mocks.buildMysqlAutoIncrementSql.mockResolvedValueOnce("ALTER TABLE `sales`.`events` AUTO_INCREMENT = 18446744073709551615;"); + await feature.refreshMysqlAutoIncrementPreviewSql(); + + await feature.confirmMysqlAutoIncrement(); + + expect(mocks.ensureConnected).toHaveBeenCalledWith("mysql-1"); + expect(mocks.executeWithProductionGuard).toHaveBeenCalledWith(tableNode(), mysqlAutoIncrementPreviewSql.value, { database: "sales", schema: undefined }); + expect(mocks.refreshMutatedTableDataTabsForNode).toHaveBeenCalledWith(tableNode()); + }); + + it("excludes MySQL-compatible profiles", () => { + expect(runtime("mariadb").supportsMysqlAutoIncrement.value).toBe(false); + expect(runtime("tidb").supportsMysqlAutoIncrement.value).toBe(false); + expect(runtime("oceanbase").supportsMysqlAutoIncrement.value).toBe(false); + expect(runtime("dolt").supportsMysqlAutoIncrement.value).toBe(false); + expect(runtime("goldendb").supportsMysqlAutoIncrement.value).toBe(false); + }); + + it("ignores out-of-order preview responses", async () => { + const resolvers: Array<(sql: string) => void> = []; + mocks.buildMysqlAutoIncrementSql.mockImplementation(() => new Promise((resolve) => resolvers.push(resolve))); + const feature = runtime(); + + mysqlAutoIncrementValue.value = "1"; + const first = feature.refreshMysqlAutoIncrementPreviewSql(); + mysqlAutoIncrementValue.value = "2"; + const second = feature.refreshMysqlAutoIncrementPreviewSql(); + resolvers[1]!("ALTER TABLE `sales`.`events` AUTO_INCREMENT = 2;"); + await second; + resolvers[0]!("ALTER TABLE `sales`.`events` AUTO_INCREMENT = 1;"); + await first; + + expect(mysqlAutoIncrementPreviewSql.value).toBe("ALTER TABLE `sales`.`events` AUTO_INCREMENT = 2;"); + }); + + it("rebuilds for the frozen confirmation target instead of executing another table's preview", async () => { + const feature = runtime(); + mysqlAutoIncrementValue.value = "7"; + await feature.refreshMysqlAutoIncrementPreviewSql(); + const orders = { ...tableNode(), id: "mysql-1:sales:orders", label: "orders" }; + sidebarDangerTarget.value = orders; + mocks.buildMysqlAutoIncrementSql.mockResolvedValueOnce("ALTER TABLE `sales`.`orders` AUTO_INCREMENT = 7;"); + + await feature.confirmMysqlAutoIncrement(); + + expect(mocks.buildMysqlAutoIncrementSql).toHaveBeenLastCalledWith(expect.objectContaining({ tableName: "orders", value: "7" })); + expect(mocks.executeWithProductionGuard).toHaveBeenCalledWith(orders, "ALTER TABLE `sales`.`orders` AUTO_INCREMENT = 7;", { database: "sales", schema: undefined }); + }); +}); diff --git a/apps/desktop/src/composables/useSidebarTableMutationRuntime.ts b/apps/desktop/src/composables/useSidebarTableMutationRuntime.ts index 0735fecd95..d984750960 100644 --- a/apps/desktop/src/composables/useSidebarTableMutationRuntime.ts +++ b/apps/desktop/src/composables/useSidebarTableMutationRuntime.ts @@ -4,9 +4,9 @@ import { useToast } from "@/composables/useToast"; import { useConnectionStore } from "@/stores/connectionStore"; import type { DatabaseType, TreeNode } from "@/types/database"; import { supportsTableTruncate } from "@/lib/database/databaseCapabilities"; -import { buildDropTableSql, buildEmptyTableSql, buildTruncateTableSql, supportsDropTableCascade, supportsTruncateTableCascade, type TableAdminSqlOptions } from "@/lib/database/dbAdminSql"; +import { buildDropTableSql, buildEmptyTableSql, buildMysqlAutoIncrementSql, buildTruncateTableSql, supportsDropTableCascade, supportsNativeMysqlAutoIncrement, supportsTruncateTableCascade, type MysqlAutoIncrementSqlOptions, type TableAdminSqlOptions } from "@/lib/database/dbAdminSql"; import { isSqlServerLinkedNode } from "@/lib/database/sqlServerLinkedServers"; -import { sidebarDangerTarget, showDropTableConfirm, showEmptyTableConfirm, showTruncateTableConfirm, dropTablePreviewSql, dropTableCascade, emptyTablePreviewSql, truncateTablePreviewSql, truncateTableCascade } from "@/components/sidebar/sidebarTreeDialogState"; +import { sidebarDangerTarget, showDropTableConfirm, showEmptyTableConfirm, showMysqlAutoIncrementConfirm, showTruncateTableConfirm, dropTablePreviewSql, dropTableCascade, emptyTablePreviewSql, mysqlAutoIncrementPreviewKey, mysqlAutoIncrementPreviewSql, mysqlAutoIncrementValue, truncateTablePreviewSql, truncateTableCascade } from "@/components/sidebar/sidebarTreeDialogState"; interface SidebarTableMutationRuntimeOptions { activeNode: ShallowRef; @@ -28,6 +28,7 @@ export function useSidebarTableMutationRuntime(options: SidebarTableMutationRunt const supportsTruncate = computed(() => supportsTableTruncate(currentDatabaseType())); const canDropTableCascade = computed(() => activeNode.value.type === "table" && supportsDropTableCascade(currentDatabaseType())); const canTruncateTableCascade = computed(() => activeNode.value.type === "table" && supportsTruncateTableCascade(currentDatabaseType())); + const supportsMysqlAutoIncrement = computed(() => activeNode.value.type === "table" && supportsNativeMysqlAutoIncrement(activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined)); function tableAdminSqlOptions(optionsOverride?: { cascade?: boolean }): TableAdminSqlOptions { const result: TableAdminSqlOptions = { @@ -72,6 +73,59 @@ export function useSidebarTableMutationRuntime(options: SidebarTableMutationRunt truncateTablePreviewSql.value = await buildTruncateTableSql(truncateTableSqlOptions()).catch(() => ""); } + function mysqlAutoIncrementSqlOptionsForNode(node: TreeNode): MysqlAutoIncrementSqlOptions { + const config = node.connectionId ? connectionStore.getConfig(node.connectionId) : undefined; + return { + databaseType: config?.db_type ?? databaseTypeForNode(node) ?? "mysql", + driverProfile: config?.driver_profile, + schema: node.database || node.schema, + tableName: node.label, + value: mysqlAutoIncrementValue.value, + }; + } + + function mysqlAutoIncrementPreviewKeyForNode(node: TreeNode, sqlOptions = mysqlAutoIncrementSqlOptionsForNode(node)): string { + return JSON.stringify([node.id, node.connectionId, node.database, sqlOptions.databaseType, sqlOptions.driverProfile?.trim().toLowerCase() || "", sqlOptions.schema || "", sqlOptions.tableName, sqlOptions.value]); + } + + async function refreshMysqlAutoIncrementPreviewSql() { + const node = activeNode.value; + const sqlOptions = mysqlAutoIncrementSqlOptionsForNode(node); + const previewKey = mysqlAutoIncrementPreviewKeyForNode(node, sqlOptions); + mysqlAutoIncrementPreviewSql.value = ""; + mysqlAutoIncrementPreviewKey.value = ""; + const sql = await buildMysqlAutoIncrementSql(sqlOptions).catch(() => ""); + if (previewKey !== mysqlAutoIncrementPreviewKeyForNode(activeNode.value)) return; + mysqlAutoIncrementPreviewSql.value = sql; + mysqlAutoIncrementPreviewKey.value = sql ? previewKey : ""; + } + + function mysqlAutoIncrement() { + if (!supportsMysqlAutoIncrement.value) return; + mysqlAutoIncrementValue.value = "1"; + mysqlAutoIncrementPreviewKey.value = ""; + void refreshMysqlAutoIncrementPreviewSql(); + showMysqlAutoIncrementConfirm.value = true; + } + + async function confirmMysqlAutoIncrement() { + const node = sidebarDangerTarget.value ?? activeNode.value; + if (!node.connectionId || !node.database) return; + try { + const config = connectionStore.getConfig(node.connectionId); + if (!supportsNativeMysqlAutoIncrement(config)) throw new Error("Setting AUTO_INCREMENT is supported only for native MySQL connections."); + await connectionStore.ensureConnected(node.connectionId); + const sqlOptions = mysqlAutoIncrementSqlOptionsForNode(node); + const previewKey = mysqlAutoIncrementPreviewKeyForNode(node, sqlOptions); + const sql = mysqlAutoIncrementPreviewKey.value === previewKey && mysqlAutoIncrementPreviewSql.value ? mysqlAutoIncrementPreviewSql.value : await buildMysqlAutoIncrementSql(sqlOptions); + await options.executeWithProductionGuard(node, sql, { database: node.database, schema: node.schema }); + toast(t("contextMenu.mysqlAutoIncrementSuccess", { name: node.label, value: mysqlAutoIncrementValue.value }), 3000); + await options.refreshMutatedTableDataTabsForNode(node); + } catch (error: any) { + toast(t("contextMenu.tableOperationFailed", { message: error?.message || String(error) }), 5000); + } + } + function dropTable() { dropTableCascade.value = false; void refreshDropTablePreviewSql(); @@ -144,6 +198,7 @@ export function useSidebarTableMutationRuntime(options: SidebarTableMutationRunt supportsTruncate, canDropTableCascade, canTruncateTableCascade, + supportsMysqlAutoIncrement, refreshDropTablePreviewSql, refreshTruncateTablePreviewSql, dropTable, @@ -153,5 +208,8 @@ export function useSidebarTableMutationRuntime(options: SidebarTableMutationRunt confirmEmptyTable, truncateTable, confirmTruncateTable, + mysqlAutoIncrement, + refreshMysqlAutoIncrementPreviewSql, + confirmMysqlAutoIncrement, }; } diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 21d51a0a58..f55fc9d6ca 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2680,6 +2680,11 @@ export default { dropTable: "Drop Table", emptyTable: "Empty Table", truncateTable: "Truncate Table", + mysqlAutoIncrement: "Set AUTO_INCREMENT", + mysqlAutoIncrementTitle: "Set MySQL AUTO_INCREMENT", + mysqlAutoIncrementMessage: 'Set the next AUTO_INCREMENT value requested for "{name}"?', + mysqlAutoIncrementNonemptyHint: "For a nonempty table, MySQL does not reuse occupied IDs. The effective next value remains at least MAX(id) + 1.", + mysqlAutoIncrementValue: "Requested next value", duplicateStructure: "Clone as New Table", renameObject: "Rename", renameObjectTitle: "Rename Object", @@ -2695,6 +2700,7 @@ export default { confirmEmptyTableMessage: 'Are you sure you want to delete all data from "{name}"?', confirmTruncateTableTitle: "Truncate Table", confirmTruncateTableMessage: 'Are you sure you want to truncate "{name}"? This will remove all rows.', + mysqlAutoIncrementSuccess: 'Requested AUTO_INCREMENT {value} for table "{name}"', dropTableSuccess: 'Table "{name}" dropped', editView: "Edit View", viewSource: "View Source", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 08d78d61f9..70ee87230c 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2604,6 +2604,11 @@ export default withEnglishFallback({ dropTable: "删除表", emptyTable: "清空数据", truncateTable: "截断表", + mysqlAutoIncrement: "设置自增起始值", + mysqlAutoIncrementTitle: "设置 MySQL 自增起始值", + mysqlAutoIncrementMessage: "确定要设置表「{name}」请求的下一个自增值吗?", + mysqlAutoIncrementNonemptyHint: "对于非空表,MySQL 不会复用已占用的 ID;实际下一个值不会低于 MAX(id) + 1。", + mysqlAutoIncrementValue: "请求的下一个值", duplicateStructure: "克隆为新表", renameObject: "重命名", renameObjectTitle: "重命名对象", @@ -2619,6 +2624,7 @@ export default withEnglishFallback({ confirmEmptyTableMessage: "确定要删除「{name}」中的所有数据吗?", confirmTruncateTableTitle: "截断表", confirmTruncateTableMessage: "确定要截断「{name}」吗?这将删除所有行。", + mysqlAutoIncrementSuccess: "已为表「{name}」请求自增值 {value}", dropTableSuccess: "表「{name}」已删除", editView: "编辑视图", viewSource: "查看源码", diff --git a/apps/desktop/src/lib/__tests__/database/dbAdminSql.spec.ts b/apps/desktop/src/lib/__tests__/database/dbAdminSql.spec.ts index f09da90710..3c03ac3f9c 100644 --- a/apps/desktop/src/lib/__tests__/database/dbAdminSql.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/dbAdminSql.spec.ts @@ -8,16 +8,55 @@ const apiMock = vi.hoisted(() => ({ listTriggers: vi.fn(), buildCreateTableSql: vi.fn(), buildDuplicateTableStructureSql: vi.fn(), + buildMysqlAutoIncrementSql: vi.fn(), })); vi.mock("@/lib/backend/api", () => apiMock); -import { buildDuplicateTableStructurePlan, collectDuplicateTableColumnComments, damengDropSchemaExecutionSchema, damengDuplicateTableCreateOptions, duplicateTableStructureRequiresScript, oracleDuplicateTableCreateOptions } from "@/lib/database/dbAdminSql"; +import { buildDuplicateTableStructurePlan, buildMysqlAutoIncrementSql, collectDuplicateTableColumnComments, damengDropSchemaExecutionSchema, damengDuplicateTableCreateOptions, duplicateTableStructureRequiresScript, oracleDuplicateTableCreateOptions, supportsNativeMysqlAutoIncrement } from "@/lib/database/dbAdminSql"; beforeEach(() => { vi.clearAllMocks(); }); +describe("MySQL AUTO_INCREMENT administration", () => { + it("is available only for explicit native MySQL connections", () => { + expect(supportsNativeMysqlAutoIncrement({ db_type: "mysql", driver_profile: undefined })).toBe(true); + expect(supportsNativeMysqlAutoIncrement({ db_type: "mysql", driver_profile: "" })).toBe(true); + expect(supportsNativeMysqlAutoIncrement({ db_type: "mysql", driver_profile: " MySQL " })).toBe(true); + + for (const connection of [ + { db_type: "jdbc", driver_profile: "mysql" }, + { db_type: "mysql", driver_profile: "mariadb" }, + { db_type: "mysql", driver_profile: "tidb" }, + { db_type: "mysql", driver_profile: "oceanbase" }, + { db_type: "mysql", driver_profile: "dolt" }, + { db_type: "goldendb", driver_profile: "goldendb" }, + { db_type: "mysql", driver_profile: "goldendb" }, + { db_type: "doris", driver_profile: "doris" }, + { db_type: "doris", driver_profile: "selectdb" }, + { db_type: "starrocks", driver_profile: "starrocks" }, + ] as const) { + expect(supportsNativeMysqlAutoIncrement(connection as any)).toBe(false); + } + }); + + it("keeps the counter as a decimal string across the frontend boundary", async () => { + apiMock.buildMysqlAutoIncrementSql.mockResolvedValue("ALTER TABLE `sales`.`events` AUTO_INCREMENT = 18446744073709551615;"); + + await expect( + buildMysqlAutoIncrementSql({ + databaseType: "mysql", + driverProfile: "mysql", + schema: "sales", + tableName: "events", + value: "18446744073709551615", + }), + ).resolves.toContain("18446744073709551615"); + expect(apiMock.buildMysqlAutoIncrementSql).toHaveBeenCalledWith(expect.objectContaining({ value: "18446744073709551615" })); + }); +}); + describe("collectDuplicateTableColumnComments", () => { it("preserves meaningful whitespace and excludes whitespace-only comments", () => { expect( diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index d354934bc7..5a228429c2 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -223,6 +223,7 @@ export const buildDropTableSql = forward("buildDropTableSql"); export const buildDropTableChildObjectSql = forward("buildDropTableChildObjectSql"); export const buildEmptyTableSql = forward("buildEmptyTableSql"); export const buildTruncateTableSql = forward("buildTruncateTableSql"); +export const buildMysqlAutoIncrementSql = forward("buildMysqlAutoIncrementSql"); export const buildDropDatabaseSql = forward("buildDropDatabaseSql"); export const buildCreateSchemaSql = forward("buildCreateSchemaSql"); export const buildUpdateDatabasePropertiesSql = forward("buildUpdateDatabasePropertiesSql"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 9dd794479d..55ddf61b83 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -159,7 +159,7 @@ import type { BuildEditableObjectSourceSqlInput, BuildRoutineRenameObjectSourceI import type { BuildViewDdlInput } from "@/lib/table/viewDdl"; import type { BuildRenameObjectSqlOptions } from "@/lib/table/objectRenameSql"; import type { CreateDatabaseSqlOptions } from "@/lib/database/createDatabaseSql"; -import type { DatabaseNameSqlOptions, DatabasePropertyEditSqlOptions, DropTableChildObjectSqlOptions, DropObjectSqlOptions, DuplicateTableStructureSqlOptions, CopyTableDataSqlOptions, SchemaNameSqlOptions, TableAdminSqlOptions } from "@/lib/database/dbAdminSql"; +import type { DatabaseNameSqlOptions, DatabasePropertyEditSqlOptions, DropTableChildObjectSqlOptions, DropObjectSqlOptions, DuplicateTableStructureSqlOptions, CopyTableDataSqlOptions, MysqlAutoIncrementSqlOptions, SchemaNameSqlOptions, TableAdminSqlOptions } from "@/lib/database/dbAdminSql"; import type { BuildDatabaseSqlExportOptions, BuildExportInsertStatementsOptions } from "@/lib/export/databaseExport"; import { loadBrowserAppState, saveBrowserAppState } from "@/lib/backend/browserAppStateStorage"; import type { DataCompareFromTablesOptions, DataCompareFromTablesPreparation, DataCompareSyncPlan, DataCompareSyncPlanOptions, DataComparePreparation, DataComparePreparationOptions } from "@/lib/dataGrid/dataCompare"; @@ -1238,6 +1238,10 @@ export async function buildTruncateTableSql(options: TableAdminSqlOptions): Prom return post("/api/query/build-truncate-table-sql", { options }); } +export async function buildMysqlAutoIncrementSql(options: MysqlAutoIncrementSqlOptions): Promise { + return post("/api/query/build-mysql-auto-increment-sql", { options }); +} + export async function buildDropDatabaseSql(options: DatabaseNameSqlOptions): Promise { return post("/api/query/build-drop-database-sql", { options }); } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index b5d4598571..4e29027a04 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -88,7 +88,7 @@ import type { BuildEditableObjectSourceSqlInput, BuildRoutineRenameObjectSourceI import type { BuildViewDdlInput } from "@/lib/table/viewDdl"; import type { BuildRenameObjectSqlOptions } from "@/lib/table/objectRenameSql"; import type { CreateDatabaseSqlOptions } from "@/lib/database/createDatabaseSql"; -import type { DatabaseNameSqlOptions, DatabasePropertyEditSqlOptions, DropTableChildObjectSqlOptions, DropObjectSqlOptions, DuplicateTableStructureSqlOptions, CopyTableDataSqlOptions, SchemaNameSqlOptions, TableAdminSqlOptions } from "@/lib/database/dbAdminSql"; +import type { DatabaseNameSqlOptions, DatabasePropertyEditSqlOptions, DropTableChildObjectSqlOptions, DropObjectSqlOptions, DuplicateTableStructureSqlOptions, CopyTableDataSqlOptions, MysqlAutoIncrementSqlOptions, SchemaNameSqlOptions, TableAdminSqlOptions } from "@/lib/database/dbAdminSql"; import type { BuildDatabaseSqlExportOptions, BuildExportInsertStatementsOptions } from "@/lib/export/databaseExport"; export interface SshPromptResolution { @@ -1418,6 +1418,10 @@ export async function buildTruncateTableSql(options: TableAdminSqlOptions): Prom return invoke("build_truncate_table_sql", { options }); } +export async function buildMysqlAutoIncrementSql(options: MysqlAutoIncrementSqlOptions): Promise { + return invoke("build_mysql_auto_increment_sql", { options }); +} + export async function buildDropDatabaseSql(options: DatabaseNameSqlOptions): Promise { return invoke("build_drop_database_sql", { options }); } diff --git a/apps/desktop/src/lib/database/dbAdminSql.ts b/apps/desktop/src/lib/database/dbAdminSql.ts index c42c0bea46..7328450351 100644 --- a/apps/desktop/src/lib/database/dbAdminSql.ts +++ b/apps/desktop/src/lib/database/dbAdminSql.ts @@ -1,4 +1,4 @@ -import type { ColumnInfo, DatabaseObjectType, DatabaseType, ForeignKeyInfo, IndexInfo, TriggerInfo } from "@/types/database"; +import type { ColumnInfo, ConnectionConfig, DatabaseObjectType, DatabaseType, ForeignKeyInfo, IndexInfo, TriggerInfo } from "@/types/database"; import * as api from "@/lib/backend/api"; import { createColumnDrafts, createForeignKeyDrafts, createIndexDrafts, createTriggerDrafts } from "@/lib/table/tableStructureEditorState"; import type { BuildTableStructureChangeSqlOptions } from "@/lib/table/tableStructureEditorSql"; @@ -18,6 +18,14 @@ export interface TableAdminSqlOptions { cascade?: boolean; } +export interface MysqlAutoIncrementSqlOptions { + databaseType: DatabaseType; + driverProfile?: string | null; + schema?: string | null; + tableName: string; + value: string; +} + export type TableChildObjectType = "COLUMN" | "INDEX" | "FOREIGN_KEY" | "TRIGGER"; export interface DropTableChildObjectSqlOptions { @@ -278,6 +286,16 @@ export function buildTruncateTableSql(options: TableAdminSqlOptions): Promise { + return api.buildMysqlAutoIncrementSql(options); +} + +export function supportsNativeMysqlAutoIncrement(connection: Pick | undefined): boolean { + if (connection?.db_type !== "mysql") return false; + const profile = connection.driver_profile?.trim().toLowerCase(); + return !profile || profile === "mysql"; +} + const DROP_TABLE_CASCADE_DATABASE_TYPES: readonly DatabaseType[] = ["postgres", "redshift", "gaussdb", "kwdb", "kingbase", "highgo", "uxdb", "vastbase", "opengauss"]; const TRUNCATE_TABLE_CASCADE_DATABASE_TYPES: readonly DatabaseType[] = ["postgres", "gaussdb", "kwdb", "kingbase", "highgo", "uxdb", "vastbase", "opengauss"]; diff --git a/apps/desktop/src/lib/sidebar/sidebarDangerDialog.ts b/apps/desktop/src/lib/sidebar/sidebarDangerDialog.ts index ce62efd671..7f02d38aad 100644 --- a/apps/desktop/src/lib/sidebar/sidebarDangerDialog.ts +++ b/apps/desktop/src/lib/sidebar/sidebarDangerDialog.ts @@ -12,6 +12,14 @@ export interface SidebarDangerDialogProgress { total: number; } +export interface SidebarDangerDialogTextInput { + value: string; + label: string; + placeholder?: string; + inputMode?: "text" | "numeric"; + onInput?: (value: string) => void | Promise; +} + export interface SidebarDangerDialogRequest { target: SidebarActionTarget; title: string; @@ -24,5 +32,6 @@ export interface SidebarDangerDialogRequest { closeOnConfirm?: boolean; progress?: SidebarDangerDialogProgress; option?: SidebarDangerDialogOption; + textInput?: SidebarDangerDialogTextInput; confirm: () => void | Promise; } diff --git a/crates/dbx-core/src/db_admin_sql.rs b/crates/dbx-core/src/db_admin_sql.rs index 1170f41caa..db99a61f47 100644 --- a/crates/dbx-core/src/db_admin_sql.rs +++ b/crates/dbx-core/src/db_admin_sql.rs @@ -101,6 +101,19 @@ pub struct TableAdminSqlOptions { pub cascade: Option, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MysqlAutoIncrementSqlOptions { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub database_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub driver_profile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub schema: Option, + pub table_name: String, + pub value: String, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DropTableChildObjectSqlOptions { @@ -501,6 +514,35 @@ pub fn build_truncate_table_sql(options: TableAdminSqlOptions) -> String { } } +pub fn build_mysql_auto_increment_sql(options: MysqlAutoIncrementSqlOptions) -> Result { + let profile = options.driver_profile.as_deref().map(str::trim).unwrap_or_default(); + if options.database_type != Some(DatabaseType::Mysql) + || (!profile.is_empty() && !profile.eq_ignore_ascii_case("mysql")) + { + return Err("Setting AUTO_INCREMENT is supported only for native MySQL connections.".to_string()); + } + + let value = options.value.as_str(); + if value.is_empty() + || !value.bytes().all(|byte| byte.is_ascii_digit()) + || value.starts_with('0') + || value.parse::().is_err() + { + return Err("AUTO_INCREMENT must be a decimal integer from 1 to 18446744073709551615.".to_string()); + } + + let table = if options.schema.as_deref().is_some_and(|schema| !schema.is_empty()) { + format!( + "{}.{}", + quote_rename_identifier(options.database_type, options.schema.as_deref().unwrap()), + quote_rename_identifier(options.database_type, &options.table_name) + ) + } else { + quote_rename_identifier(options.database_type, &options.table_name) + }; + Ok(format!("ALTER TABLE {table} AUTO_INCREMENT = {value};")) +} + fn supports_truncate_table_cascade(database_type: Option) -> bool { matches!( database_type, @@ -1392,6 +1434,56 @@ mod tests { ); } + #[test] + fn builds_mysql_auto_increment_sql_and_rejects_invalid_values() { + let options = MysqlAutoIncrementSqlOptions { + database_type: Some(DatabaseType::Mysql), + driver_profile: Some("mysql".to_string()), + schema: Some("sales`archive".to_string()), + table_name: "order`items".to_string(), + value: "18446744073709551615".to_string(), + }; + assert_eq!( + build_mysql_auto_increment_sql(options), + Ok("ALTER TABLE `sales``archive`.`order``items` AUTO_INCREMENT = 18446744073709551615;".to_string()) + ); + + for value in ["", "0", "01", " 1", "1 ", "+1", "-1", "1; DROP TABLE users", "18446744073709551616"] { + assert!( + build_mysql_auto_increment_sql(MysqlAutoIncrementSqlOptions { + database_type: Some(DatabaseType::Mysql), + driver_profile: None, + schema: None, + table_name: "events".to_string(), + value: value.to_string(), + }) + .is_err(), + "value {value:?} should be rejected" + ); + } + + for (database_type, driver_profile) in [ + (Some(DatabaseType::Postgres), None), + (Some(DatabaseType::Jdbc), Some("mysql".to_string())), + (Some(DatabaseType::Mysql), Some("mariadb".to_string())), + (Some(DatabaseType::Mysql), Some("tidb".to_string())), + (Some(DatabaseType::Mysql), Some("oceanbase".to_string())), + (Some(DatabaseType::Mysql), Some("dolt".to_string())), + (Some(DatabaseType::Goldendb), Some("goldendb".to_string())), + (Some(DatabaseType::Doris), Some("selectdb".to_string())), + (Some(DatabaseType::StarRocks), Some("starrocks".to_string())), + ] { + assert!(build_mysql_auto_increment_sql(MysqlAutoIncrementSqlOptions { + database_type, + driver_profile, + schema: None, + table_name: "events".to_string(), + value: "1".to_string(), + }) + .is_err()); + } + } + #[test] fn builds_drop_object_database_and_schema_sql() { assert_eq!( diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index c4e40f316b..ed427eeacf 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -474,6 +474,7 @@ async fn main() { .route("/query/build-drop-table-child-object-sql", post(routes::query::build_drop_table_child_object_sql)) .route("/query/build-empty-table-sql", post(routes::query::build_empty_table_sql)) .route("/query/build-truncate-table-sql", post(routes::query::build_truncate_table_sql)) + .route("/query/build-mysql-auto-increment-sql", post(routes::query::build_mysql_auto_increment_sql)) .route("/query/build-drop-database-sql", post(routes::query::build_drop_database_sql)) .route("/query/build-create-schema-sql", post(routes::query::build_create_schema_sql)) .route("/query/build-update-database-properties-sql", post(routes::query::build_update_database_properties_sql)) diff --git a/crates/dbx-web/src/routes/query.rs b/crates/dbx-web/src/routes/query.rs index 24f44c2b4b..2d81a06713 100644 --- a/crates/dbx-web/src/routes/query.rs +++ b/crates/dbx-web/src/routes/query.rs @@ -173,6 +173,12 @@ pub struct BuildTableAdminSqlRequest { pub options: dbx_core::db_admin_sql::TableAdminSqlOptions, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildMysqlAutoIncrementSqlRequest { + pub options: dbx_core::db_admin_sql::MysqlAutoIncrementSqlOptions, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct BuildDropTableChildObjectSqlRequest { @@ -723,6 +729,12 @@ pub async fn build_truncate_table_sql(Json(req): Json Json(dbx_core::db_admin_sql::build_truncate_table_sql(req.options)) } +pub async fn build_mysql_auto_increment_sql( + Json(req): Json, +) -> Result, AppError> { + dbx_core::db_admin_sql::build_mysql_auto_increment_sql(req.options).map(Json).map_err(AppError::from) +} + pub async fn build_drop_database_sql(Json(req): Json) -> Json { Json(dbx_core::db_admin_sql::build_drop_database_sql(req.options)) } diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index 2ab477b8bd..a651cca57d 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -501,6 +501,13 @@ pub fn build_truncate_table_sql(options: dbx_core::db_admin_sql::TableAdminSqlOp Ok(dbx_core::db_admin_sql::build_truncate_table_sql(options)) } +#[tauri::command] +pub fn build_mysql_auto_increment_sql( + options: dbx_core::db_admin_sql::MysqlAutoIncrementSqlOptions, +) -> Result { + dbx_core::db_admin_sql::build_mysql_auto_increment_sql(options) +} + #[tauri::command] pub fn build_drop_database_sql(options: dbx_core::db_admin_sql::DatabaseNameSqlOptions) -> Result { Ok(dbx_core::db_admin_sql::build_drop_database_sql(options)) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b82ba153dc..91cabae4d2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1711,6 +1711,7 @@ pub fn run() { commands::query::build_drop_table_child_object_sql, commands::query::build_empty_table_sql, commands::query::build_truncate_table_sql, + commands::query::build_mysql_auto_increment_sql, commands::query::build_drop_database_sql, commands::query::build_create_schema_sql, commands::query::build_update_database_properties_sql,