fix(structure): improve mysql auto-increment editing and field shortcuts

This commit is contained in:
zipg authored and GitHub committed 2026-08-27 18:21:52 +08:00
1 parent 214bf86a6f
commit 4fed29fba2
17 files changed
+285 -78

No files matched your search

@@ -45,6 +45,7 @@ vi.mock("@lucide/vue", async () => {
ListChevronsUpDown: Icon,
Loader2: Icon,
Maximize2: Icon,
Pencil: Icon,
Plus: Icon,
RefreshCw: Icon,
Save: Icon,
@@ -277,7 +278,7 @@ function draft(autoIncrement = false, counter?: { value?: string; originalValue?
};
}
async function mountEditor(autoIncrement = false, counter?: { value?: string; originalValue?: string }) {
async function mountEditor(autoIncrement = false, counter?: { value?: string; originalValue?: string }, draftOverride?: ReturnType<typeof draft>) {
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.listDataTypes.mockResolvedValue([]);
mocks.buildTableStructureChangeSql.mockResolvedValue({ statements: [], warnings: [] });
@@ -289,7 +290,7 @@ async function mountEditor(autoIncrement = false, counter?: { value?: string; or
database: "test",
schema: "test",
tableName: "users",
draft: draft(autoIncrement, counter),
draft: draftOverride ?? draft(autoIncrement, counter),
});
mountedApps.push(app);
mountedEditor = app.mount(root) as unknown as { applyChanges: () => Promise<boolean> };
@@ -357,6 +358,59 @@ describe("TableStructureEditor MySQL AUTO_INCREMENT counter", () => {
const input = root.querySelector<HTMLInputElement>("[data-mysql-auto-increment-counter]");
expect(input?.value).toBe("10");
expect(input?.disabled).toBe(false);
expect(input?.classList.contains("structure-grid-control")).toBe(false);
expect(input?.classList.contains("font-mono")).toBe(true);
expect(input?.closest("td")).not.toBeNull();
expect(root.querySelector("[data-mysql-auto-increment-editor-trigger]")?.getAttribute("title")).toContain("structureEditor.editMysqlAutoIncrementValue");
});
it("loads an editable blank counter when auto-increment is checked before saving", async () => {
mocks.getMysqlTableAutoIncrement.mockResolvedValueOnce(null);
const root = await mountEditor(false);
expect(root.querySelector("[data-mysql-auto-increment-editor-trigger]")).toBeNull();
const autoIncrementLabel = Array.from(root.querySelectorAll("label")).find((label) => label.textContent?.includes("structureEditor.autoIncrement"));
const checkbox = autoIncrementLabel?.querySelector<HTMLInputElement>('input[type="checkbox"]');
expect(checkbox).toBeTruthy();
checkbox!.checked = true;
checkbox!.dispatchEvent(new Event("change", { bubbles: true }));
await vi.waitFor(() => expect(mocks.getMysqlTableAutoIncrement).toHaveBeenCalledWith("structure-charset-test", "test", "users"));
const input = root.querySelector<HTMLInputElement>("[data-mysql-auto-increment-counter]");
await vi.waitFor(() => expect(input?.disabled).toBe(false));
expect(input?.value).toBe("");
});
it("keeps a restored dirty blank-counter draft when the server has no counter", async () => {
mocks.getMysqlTableAutoIncrement.mockResolvedValueOnce(null);
const restoredDraft = draft(false, { value: "500", originalValue: "" });
restoredDraft.columns[0].extra.autoIncrement = true;
const root = await mountEditor(false, undefined, restoredDraft);
const input = root.querySelector<HTMLInputElement>("[data-mysql-auto-increment-counter]");
await vi.waitFor(() => expect(input?.value).toBe("500"));
});
it("accepts unsigned bigint digits and rejects non-decimal input without rewriting it", async () => {
const root = await mountEditor(true);
const input = root.querySelector<HTMLInputElement>("[data-mysql-auto-increment-counter]")!;
await vi.waitFor(() => expect(input.value).toBe("10"));
expect(input.getAttribute("inputmode")).toBe("numeric");
expect(input.getAttribute("pattern")).toBe("[0-9]*");
const maxUnsignedBigint = "18446744073709551615";
input.value = maxUnsignedBigint;
input.dispatchEvent(new Event("input", { bubbles: true }));
await vi.waitFor(() => expect(mocks.buildMysqlAutoIncrementSql).toHaveBeenCalledWith(expect.objectContaining({ value: maxUnsignedBigint })));
mocks.buildMysqlAutoIncrementSql.mockClear();
for (const invalidValue of ["abc", "-1", "+1", "1.5", "1e3"]) {
input.value = invalidValue;
input.dispatchEvent(new Event("input", { bubbles: true }));
expect(input.value).toBe(maxUnsignedBigint);
}
await new Promise((resolve) => setTimeout(resolve, 400));
expect(mocks.buildMysqlAutoIncrementSql).not.toHaveBeenCalled();
});
it("refreshes a restored clean counter draft from the server", async () => {
@@ -631,16 +631,25 @@ describe("TableStructureEditor data type options", () => {
});
describe("TableStructureEditor action column", () => {
it("shows the field shortcut hints in the toolbar", async () => {
it("moves delayed shortcut hints onto the add, copy, and delete controls", async () => {
const root = await mountEditor("dameng");
expect(root.textContent).toContain("settings.shortcutsTab");
expect(root.textContent).toContain("Shift+Enter");
expect(root.textContent).toContain("⌘/Ctrl+D");
expect(root.querySelector("[data-field-shortcut-hints]")?.className).toContain("flex-col");
expect(root.querySelector("[data-field-shortcut-hints]")?.className).toContain("w-56");
expect(root.querySelector("[data-field-shortcut-hints]")?.className).toContain("!bg-secondary");
expect(root.querySelector("[data-field-shortcut-hints] kbd")?.className).toContain("text-primary");
expect(root.textContent).not.toContain("settings.shortcutsTab");
expect(root.querySelector("[data-field-shortcut-hints]")).toBeNull();
const addTooltip = root.querySelector<HTMLElement>("[data-add-column-shortcut-tooltip]");
const copyTooltip = root.querySelector<HTMLElement>("[data-copy-column-shortcut-tooltip]");
const deleteTooltip = root.querySelector<HTMLElement>("[data-delete-column-shortcut-tooltip]");
expect(addTooltip?.getAttribute("delay-duration")).toBe("500");
expect(copyTooltip?.getAttribute("delay-duration")).toBe("500");
expect(deleteTooltip?.getAttribute("delay-duration")).toBe("500");
expect(root.querySelector("[data-add-column-shortcut-content]")?.textContent).toBe("Shift+Enter");
expect(root.querySelector("[data-copy-column-shortcut-content]")?.textContent).toBe("⌘/Ctrl+D");
expect(root.querySelector("[data-delete-column-shortcut-content]")?.textContent?.trim()).toBe("⌘/Ctrl+Del");
expect(root.querySelector("[data-add-column-shortcut-content]")?.textContent).not.toContain("structureEditor.addColumn");
expect(root.querySelector("[data-copy-column-shortcut-content]")?.textContent).not.toContain("structureEditor.copyColumn");
expect(copyTooltip?.querySelector("button")?.hasAttribute("title")).toBe(false);
expect(deleteTooltip?.querySelector("button")?.hasAttribute("title")).toBe(false);
});
it("adds a field below the focused input on Shift+Enter", async () => {
@@ -671,6 +680,63 @@ describe("TableStructureEditor action column", () => {
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<HTMLInputElement>('[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<HTMLInputElement>('[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<HTMLInputElement>('[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<HTMLInputElement>('[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");
@@ -7,7 +7,7 @@ import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { AlertTriangle, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Copy, Database, Info, Keyboard, KeyRound, ListChevronsUpDown, Loader2, Maximize2, Plus, RefreshCw, Save, Search, Settings, SlidersHorizontal, Trash2, UserRound, X } from "@lucide/vue";
import { AlertTriangle, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Copy, Database, Info, KeyRound, ListChevronsUpDown, Loader2, Maximize2, Pencil, Plus, RefreshCw, Save, Search, Settings, SlidersHorizontal, Trash2, UserRound, X } from "@lucide/vue";
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -334,6 +334,7 @@ const structureDensityValues: StructureEditorDensity[] = ["compact", "standard",
const STRUCTURE_COLUMNS_WIDTHS_STORAGE_KEY = "dbx-structure-editor-column-widths";
const STRUCTURE_INDEX_COLUMNS_WIDTHS_STORAGE_KEY = "dbx-structure-editor-index-column-widths";
const STRUCTURE_SQL_PREVIEW_COLLAPSED_STORAGE_KEY = "dbx-structure-editor-sql-preview-collapsed";
const FIELD_SHORTCUT_TOOLTIP_DELAY_MS = 500;
const STRUCTURE_COLUMN_WIDTH_COUNT = 12;
const STRUCTURE_INDEX_COLUMN_WIDTH_COUNT = 9;
const PERSISTED_STRUCTURE_INDEX_COLUMN_WIDTHS = new Set([0, 1, 6]);
@@ -944,6 +945,24 @@ const supportsTableOwner = computed(() => !isCreateMode.value && databaseType.va
const canEditMysqlAutoIncrement = computed(() => canEditMysqlAutoIncrementCounter(connection.value, isCreateMode.value, columns.value));
const canBuildMysqlAutoIncrement = computed(() => canEditMysqlAutoIncrement.value && !mysqlAutoIncrementLoading.value && !mysqlAutoIncrementLoadError.value && originalMysqlAutoIncrementValue.value !== undefined);
const supportsMysqlEngine = computed(() => supportsMysqlTableEngine(connection.value));
const hasPersistedMysqlAutoIncrementColumn = computed(() => columns.value.some((column) => !column.markedForDrop && (column.original?.extra ?? "").toLowerCase().includes("auto_increment")));
function isMysqlAutoIncrementCounterColumn(column: EditableStructureColumn): boolean {
return canEditMysqlAutoIncrement.value && !column.markedForDrop && column.extra.autoIncrement === true;
}
function setMysqlAutoIncrement(column: EditableStructureColumn, checked: boolean) {
column.extra.autoIncrement = checked;
if (checked && originalMysqlAutoIncrementValue.value === undefined && !mysqlAutoIncrementLoading.value) {
void loadMysqlAutoIncrementCounter(true);
}
}
function onMysqlAutoIncrementInput(event: Event) {
const input = event.target as HTMLInputElement;
if (/^\d*$/.test(input.value)) {
mysqlAutoIncrementValue.value = input.value;
return;
}
input.value = mysqlAutoIncrementValue.value ?? "";
}
const tableOwnerOptions = computed(() => {
const owner = tableOwner.value;
if (!owner || tableOwnerRoles.value.includes(owner)) return tableOwnerRoles.value;
@@ -1661,7 +1680,8 @@ async function loadMysqlAutoIncrementCounter(preserveDraft = false) {
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);
const server = value === null && !hasPersistedMysqlAutoIncrementColumn.value ? "" : value;
const draft = refreshMysqlAutoIncrementCounterDraft(server, { value: mysqlAutoIncrementValue.value, originalValue: originalMysqlAutoIncrementValue.value }, preserveDraft);
originalMysqlAutoIncrementValue.value = draft.originalValue;
mysqlAutoIncrementValue.value = draft.value;
} catch (error: any) {
@@ -3383,6 +3403,15 @@ function isShiftEnterShortcut(event: KeyboardEvent): boolean {
return !event.isComposing && event.key === "Enter" && event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey;
}
function isPlainModDeleteShortcut(event: KeyboardEvent): boolean {
if (isPlainModShortcut(event, "delete")) return true;
if (!event.metaKey || event.ctrlKey || !isPlainModShortcut(event, "backspace")) return false;
// ⌘⌫ is macOS "delete to beginning of line" while editing text; only treat it as a
// field delete when the focused input is empty so normal text editing keeps working.
const target = event.target;
return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement ? target.value === "" : true;
}
function onStructureEditorKeydown(event: KeyboardEvent) {
const focusedColumn = activeTab.value === "columns" ? focusedEditableColumn(event.target) : undefined;
if (focusedColumn && isShiftEnterShortcut(event) && canAddColumn.value) {
@@ -3397,6 +3426,13 @@ function onStructureEditorKeydown(event: KeyboardEvent) {
void copyColumn(focusedColumn);
return;
}
if (focusedColumn && isPlainModDeleteShortcut(event) && (!focusedColumn.original || canDropColumn(focusedColumn))) {
event.preventDefault();
event.stopPropagation();
if (focusedColumn.original) toggleDropColumn(focusedColumn);
else removeNewColumn(focusedColumn);
return;
}
if (isPlainModShortcut(event, "f")) {
event.preventDefault();
event.stopPropagation();
@@ -3750,26 +3786,6 @@ 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" />
@@ -3874,31 +3890,14 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
{{ columnSearchMatchCount }}
</button>
</div>
<Button v-if="activeTab === 'columns'" size="sm" :class="structureToolbarButtonClass" :disabled="!canAddColumn" @click="addColumn">
<Plus :class="structureIconClass" />
{{ t("structureEditor.addColumn") }}
</Button>
<Tooltip v-if="activeTab === 'columns'" :delay-duration="0">
<Tooltip v-if="activeTab === 'columns'" :delay-duration="FIELD_SHORTCUT_TOOLTIP_DELAY_MS" data-add-column-shortcut-tooltip>
<TooltipTrigger as-child>
<Button type="button" size="sm" variant="ghost" :class="[structureToolbarButtonClass, 'text-muted-foreground']" :aria-label="t('settings.shortcutsTab')">
<Keyboard :class="structureIconClass" />
{{ t("settings.shortcutsTab") }}
<Button size="sm" :class="structureToolbarButtonClass" :disabled="!canAddColumn" @click="addColumn">
<Plus :class="structureIconClass" />
{{ t("structureEditor.addColumn") }}
</Button>
</TooltipTrigger>
<TooltipContent
data-field-shortcut-hints
side="bottom"
class="flex w-56 max-w-none flex-col items-stretch gap-1.5 border border-primary/30 !bg-secondary !text-secondary-foreground shadow-lg data-open:!animate-none data-[state=delayed-open]:!animate-none data-closed:!animate-none [&>svg]:!bg-secondary [&>svg]:!fill-secondary"
>
<div class="flex items-center justify-between gap-4">
<span class="whitespace-nowrap"><span class="text-primary">↓</span> {{ t("structureEditor.addColumn") }}</span>
<kbd class="min-w-5 shrink-0 whitespace-nowrap rounded border border-primary/30 bg-background px-1.5 py-0.5 text-center font-mono text-[11px] leading-none text-primary shadow-xs">Shift+Enter</kbd>
</div>
<div class="flex items-center justify-between gap-4">
<span class="whitespace-nowrap"><span class="text-primary">↓</span> {{ t("structureEditor.copyColumn") }}</span>
<kbd class="min-w-5 shrink-0 whitespace-nowrap rounded border border-primary/30 bg-background px-1.5 py-0.5 text-center font-mono text-[11px] leading-none text-primary shadow-xs">⌘/Ctrl+D</kbd>
</div>
</TooltipContent>
<TooltipContent side="bottom" class="font-mono font-medium" data-add-column-shortcut-content>Shift+Enter</TooltipContent>
</Tooltip>
<Button v-if="activeTab === 'columns'" size="sm" variant="outline" :class="structureToolbarButtonClass" :disabled="!canAddColumn" @click="openCopyColumnsDialog">
<Copy :class="structureIconClass" />
@@ -4005,25 +4004,28 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
>
<ListChevronsUpDown :class="structureIconClass" />
</Button>
<Button variant="ghost" size="icon" :class="structureActionButtonClass" :disabled="!canAddColumn || column.markedForDrop" :title="t('structureEditor.copyColumn')" :aria-label="t('structureEditor.copyColumn')" @click.stop="copyColumn(column)">
<Copy :class="structureIconClass" />
</Button>
<Button
v-if="column.original"
variant="ghost"
size="icon"
:class="structureActionButtonClass"
:disabled="!canDropColumn(column)"
:title="column.markedForDrop ? t('structureEditor.restore') : t('structureEditor.drop')"
:aria-label="column.markedForDrop ? t('structureEditor.restore') : t('structureEditor.drop')"
@click.stop="toggleDropColumn(column)"
>
<RefreshCw v-if="column.markedForDrop" :class="structureIconClass" />
<Trash2 v-else :class="structureIconClass" />
</Button>
<Button v-else variant="ghost" size="icon" :class="structureActionButtonClass" :title="t('structureEditor.remove')" :aria-label="t('structureEditor.remove')" @click.stop="removeNewColumn(column)">
<X :class="structureIconClass" />
</Button>
<Tooltip :delay-duration="FIELD_SHORTCUT_TOOLTIP_DELAY_MS" data-copy-column-shortcut-tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" :class="structureActionButtonClass" :disabled="!canAddColumn || column.markedForDrop" :aria-label="t('structureEditor.copyColumn')" @click.stop="copyColumn(column)">
<Copy :class="structureIconClass" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" class="font-mono font-medium" data-copy-column-shortcut-content>⌘/Ctrl+D</TooltipContent>
</Tooltip>
<Tooltip :delay-duration="FIELD_SHORTCUT_TOOLTIP_DELAY_MS" data-delete-column-shortcut-tooltip>
<TooltipTrigger as-child>
<Button v-if="column.original" variant="ghost" size="icon" :class="structureActionButtonClass" :disabled="!canDropColumn(column)" :aria-label="column.markedForDrop ? t('structureEditor.restore') : t('structureEditor.drop')" @click.stop="toggleDropColumn(column)">
<RefreshCw v-if="column.markedForDrop" :class="structureIconClass" />
<Trash2 v-else :class="structureIconClass" />
</Button>
<Button v-else variant="ghost" size="icon" :class="structureActionButtonClass" :aria-label="t('structureEditor.remove')" @click.stop="removeNewColumn(column)">
<X :class="structureIconClass" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" class="font-mono font-medium" data-delete-column-shortcut-content>
{{ column.markedForDrop ? t("structureEditor.restore") : "⌘/Ctrl+Del" }}
</TooltipContent>
</Tooltip>
</div>
</div>
</td>
@@ -4208,9 +4210,43 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
<!-- MySQL: AUTO_INCREMENT + ON UPDATE CURRENT_TIMESTAMP -->
<template v-else-if="structureDialect === 'mysql'">
<label :class="[structurePropertyLabelClass, 'shrink-0 pr-1']" :title="t('structureEditor.autoIncrement')">
<input v-model="column.extra.autoIncrement" type="checkbox" :class="[structureCheckboxClass, 'shrink-0']" />
<input :checked="column.extra.autoIncrement" type="checkbox" :class="[structureCheckboxClass, 'shrink-0']" @change="setMysqlAutoIncrement(column, ($event.target as HTMLInputElement).checked)" />
<span>{{ t("structureEditor.autoIncrement") }}</span>
</label>
<Popover v-if="isMysqlAutoIncrementCounterColumn(column)">
<PopoverTrigger as-child>
<Button
variant="ghost"
size="icon"
:class="[structureIconButtonClass, 'mr-1 shrink-0']"
:title="t('structureEditor.editMysqlAutoIncrementValue', { value: mysqlAutoIncrementValue || '—' })"
:aria-label="t('structureEditor.editMysqlAutoIncrementValue', { value: mysqlAutoIncrementValue || '—' })"
data-mysql-auto-increment-editor-trigger
>
<Loader2 v-if="mysqlAutoIncrementLoading" :class="[structureIconClass, 'animate-spin text-muted-foreground']" />
<AlertTriangle v-else-if="mysqlAutoIncrementLoadError" :class="[structureIconClass, 'text-destructive']" />
<Pencil v-else :class="structureIconClass" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" class="w-80 space-y-2 p-3">
<label class="block text-xs font-medium text-foreground">{{ t("structureEditor.mysqlAutoIncrementNextValue") }}</label>
<Input
:model-value="mysqlAutoIncrementValue"
inputmode="numeric"
pattern="[0-9]*"
autocomplete="off"
data-mysql-auto-increment-counter
:aria-label="t('structureEditor.mysqlAutoIncrementNextValue')"
:placeholder="mysqlAutoIncrementLoading ? t('common.loading') : '—'"
:title="mysqlAutoIncrementLoadError || undefined"
class="w-full font-mono"
:disabled="mysqlAutoIncrementLoading || !!mysqlAutoIncrementLoadError || originalMysqlAutoIncrementValue === undefined || saving"
@input.capture="onMysqlAutoIncrementInput"
/>
<p v-if="mysqlAutoIncrementLoadError" class="text-xs text-destructive">{{ mysqlAutoIncrementLoadError }}</p>
<p class="text-xs leading-5 text-muted-foreground">{{ t("contextMenu.mysqlAutoIncrementNonemptyHint") }}</p>
</PopoverContent>
</Popover>
<label :class="[structurePropertyLabelClass, 'flex-1 basis-0']" :title="t('structureEditor.onUpdateCurrentTimestamp')">
<input v-model="column.extra.onUpdateCurrentTimestamp" type="checkbox" :class="[structureCheckboxClass, 'shrink-0']" />
<span class="min-w-0 truncate">{{ t("structureEditor.onUpdateCurrentTimestamp") }}</span>
+2
View File
@@ -3988,6 +3988,8 @@ export default {
sqliteRebuildNotice: "SQLite retains an inert data snapshot as a backup, then rebuilds the table in one transaction and forcibly CASTs changed columns. Unrepresentable values may become 0/0.0; failures roll back.",
extendedProperties: "Extended",
autoIncrement: "Auto Increment",
mysqlAutoIncrementNextValue: "Next auto-increment value",
editMysqlAutoIncrementValue: "Edit next auto-increment value (current: {value})",
onUpdateCurrentTimestamp: "Auto Update Time",
identity: "Identity",
identityGeneration: "Generation",
+2
View File
@@ -3803,6 +3803,8 @@ export default withEnglishFallback({
sqliteRebuildNotice: "SQLite conserva una instantánea de datos inerte como copia de seguridad, reconstruye la tabla en una sola transacción y fuerza CAST en las columnas modificadas. Los valores no representables pueden convertirse en 0/0.0; si ocurre un error, se revierte todo.",
extendedProperties: "Extendido",
autoIncrement: "Auto Incremento",
mysqlAutoIncrementNextValue: "Siguiente valor de autoincremento",
editMysqlAutoIncrementValue: "Editar el siguiente valor de autoincremento (actual: {value})",
onUpdateCurrentTimestamp: "Actualización Auto",
identity: "Identidad",
identityGeneration: "Generación",
+2
View File
@@ -3801,6 +3801,8 @@ export default withEnglishFallback({
sqliteRebuildNotice: "SQLite conserva uno snapshot di dati inerte come backup, ricostruisce la tabella in un'unica transazione e forza CAST sulle colonne modificate. I valori non rappresentabili possono diventare 0/0.0; in caso di errore, tutte le modifiche vengono annullate.",
extendedProperties: "Esteso",
autoIncrement: "Auto Incremento",
mysqlAutoIncrementNextValue: "Valore di auto incremento successivo",
editMysqlAutoIncrementValue: "Modifica il valore di auto incremento successivo (attuale: {value})",
onUpdateCurrentTimestamp: "Aggiorna Ora Automaticamente",
identity: "Identità",
identityGeneration: "Generazione",
+2
View File
@@ -3863,6 +3863,8 @@ export default withEnglishFallback({
sqliteRebuildNotice: "SQLite は制約が動作しないデータスナップショットをバックアップとして保持し、1 つのトランザクション内でテーブルを再構築して、変更された列を CAST で強制変換します。表現できない値は 0/0.0 になる場合があり、失敗時はすべてロールバックされます。",
extendedProperties: "拡張",
autoIncrement: "自動採番",
mysqlAutoIncrementNextValue: "次の自動採番値",
editMysqlAutoIncrementValue: "次の自動採番値を編集(現在:{value})",
onUpdateCurrentTimestamp: "自動更新時刻",
identity: "ID列",
identityGeneration: "生成",
+2
View File
@@ -3752,6 +3752,8 @@ export default withEnglishFallback({
sqliteRebuildNotice: "SQLite는 백업으로 비활성 데이터 스냅샷을 유지한 다음 하나의 트랜잭션에서 테이블을 재구성하고 변경된 컬럼을 강제로 CAST합니다. 표현할 수 없는 값은 0/0.0이 될 수 있으며, 실패 시 롤백됩니다.",
extendedProperties: "확장",
autoIncrement: "자동 증가",
mysqlAutoIncrementNextValue: "다음 자동 증가 값",
editMysqlAutoIncrementValue: "다음 자동 증가 값 편집(현재: {value})",
onUpdateCurrentTimestamp: "수정 시간 자동 갱신",
identity: "ID",
identityGeneration: "생성",
+2
View File
@@ -3803,6 +3803,8 @@ export default withEnglishFallback({
sqliteRebuildNotice: "O SQLite mantém um snapshot de dados inerte como backup, reconstrói a tabela em uma única transação e força CAST nas colunas alteradas. Valores não representáveis podem se tornar 0/0.0; em caso de falha, todas as alterações são revertidas.",
extendedProperties: "Estendidas",
autoIncrement: "Auto incremento",
mysqlAutoIncrementNextValue: "Próximo valor de incremento automático",
editMysqlAutoIncrementValue: "Editar o próximo valor de incremento automático (atual: {value})",
onUpdateCurrentTimestamp: "Atualizar hora automaticamente",
identity: "Identidade",
identityGeneration: "Geração",
+2
View File
@@ -3973,6 +3973,8 @@ export default withEnglishFallback({
sqliteRebuildNotice: "SQLite 会保留一张不带活动约束的数据快照作为备份表,再在同一事务内重建原表,使用 CAST 强制转换类型已修改的字段;无法表示的值可能变为 0/0.0,执行失败则整体回滚。",
extendedProperties: "扩展属性",
autoIncrement: "自增",
mysqlAutoIncrementNextValue: "下一个自增值",
editMysqlAutoIncrementValue: "编辑下一个自增值(当前:{value})",
onUpdateCurrentTimestamp: "自动更新时间",
identity: "标识列",
identityGeneration: "生成方式",
+2
View File
@@ -3543,6 +3543,8 @@ export default withEnglishFallback({
sqliteRebuildNotice: "SQLite 會保留一張不含作用中約束的資料快照作為備份表,再於同一交易內重建原表,並使用 CAST 強制轉換已修改型別的欄位;無法表示的值可能變成 0/0.0,執行失敗則整體回復。",
extendedProperties: "擴充屬性",
autoIncrement: "自動遞增",
mysqlAutoIncrementNextValue: "下一個自動遞增值",
editMysqlAutoIncrementValue: "編輯下一個自動遞增值(目前:{value})",
onUpdateCurrentTimestamp: "自動更新時間",
identity: "識別欄位",
identityGeneration: "產生方式",
@@ -37,9 +37,10 @@ describe("MySQL table AUTO_INCREMENT counter", () => {
});
});
it("is editable only for existing native-MySQL tables with an active existing auto-increment column", () => {
it("is editable only for existing native-MySQL tables with an active auto-increment column draft", () => {
const nativeMysql = { db_type: "mysql", driver_profile: "mysql" } as any;
expect(canEditMysqlAutoIncrementCounter(nativeMysql, false, [existingColumn(true)])).toBe(true);
expect(canEditMysqlAutoIncrementCounter(nativeMysql, false, [{ ...existingColumn(true), original: undefined }])).toBe(true);
expect(canEditMysqlAutoIncrementCounter(nativeMysql, true, [existingColumn(true)])).toBe(false);
expect(canEditMysqlAutoIncrementCounter(nativeMysql, false, [existingColumn(false)])).toBe(false);
expect(canEditMysqlAutoIncrementCounter(nativeMysql, false, [{ ...existingColumn(true), markedForDrop: true }])).toBe(false);
@@ -0,0 +1,21 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getMysqlTableAutoIncrement } from "@/lib/backend/http";
describe("MySQL AUTO_INCREMENT web API", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("loads the table counter from the schema endpoint", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: vi.fn().mockResolvedValue("42"),
});
vi.stubGlobal("fetch", fetchMock);
await expect(getMysqlTableAutoIncrement("connection 1", "sales/db", "order items")).resolves.toBe("42");
expect(fetchMock).toHaveBeenCalledWith("/api/schema/mysql/auto-increment?connection_id=connection+1&database=sales%2Fdb&table=order+items");
});
});
+2 -2
View File
@@ -847,8 +847,8 @@ export async function getTableComment(_connectionId: string, _database: string,
throw new Error("Table comment lookup is not available in the web backend");
}
export async function getMysqlTableAutoIncrement(_connectionId: string, _database: string, _table: string): Promise<string | null> {
throw new Error("MySQL AUTO_INCREMENT metadata is not available in the web backend");
export async function getMysqlTableAutoIncrement(connectionId: string, database: string, table: string): Promise<string | null> {
return get(`/api/schema/mysql/auto-increment?${qs({ connection_id: connectionId, database, table })}`);
}
export async function listObjects(connectionId: string, database: string, schema: string, objectTypes?: (SidebarObjectKind | "EVENT")[], filter?: string, limit?: number, offset?: number, catalog?: string, tableNameFilter?: TableNameFilter): Promise<ObjectInfo[]> {
@@ -20,7 +20,7 @@ export function refreshMysqlAutoIncrementCounterDraft(serverValue: string | null
export function canEditMysqlAutoIncrementCounter(connection: Pick<ConnectionConfig, "db_type" | "driver_profile"> | undefined, isCreateMode: boolean, columns: readonly EditableStructureColumn[]): boolean {
if (isCreateMode || !supportsNativeMysqlAutoIncrement(connection)) return false;
return columns.some((column) => !!column.original && !column.markedForDrop && column.extra.autoIncrement === true);
return columns.some((column) => !column.markedForDrop && column.extra.autoIncrement === true);
}
export interface BuildMysqlAutoIncrementCounterStatementOptions extends Omit<MysqlAutoIncrementSqlOptions, "databaseType" | "value"> {