feat(structure): support editing table ddl

This commit is contained in:
onenewcode
2026-09-02 14:17:49 +08:00
committed by GitHub
parent 3b2df3814b
commit b45300e9bc
12 changed files with 677 additions and 26 deletions
@@ -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, KeyRound, ListChevronsUpDown, Loader2, Maximize2, Pencil, 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, RotateCcw, 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";
@@ -27,6 +27,7 @@ import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect
import { useToast } from "@/composables/useToast";
import { type SqlHighlighter, createShikiSqlHighlighter } from "@/lib/sql/sqlHighlighter";
import { joinSqlStatementsForScript } from "@/lib/sql/sqlBatchScript";
import { splitSqlStatementRanges } from "@/lib/sql/sqlStatementRanges";
import { copyToClipboard } from "@/lib/common/clipboard";
import { formatSqlForDisplay, sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter";
import { queryTimeoutSecsForConcurrentIndex, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
@@ -175,9 +176,33 @@ let ddlEditorScrollCleanup: (() => void) | null = null;
const loadedMetadataFacets = new Set<ObjectMetadataFacet>();
let structureEditorReady = false;
const ddlFetched = ref(false);
/** User-edited DDL script; `null` means untouched (the loaded DDL is shown verbatim). */
const ddlDraft = ref<string | null>(null);
/**
* DDL is only editable for an existing table whose DDL actually loaded — the
* create-table flow has no DDL tab, and an empty baseline only renders the
* "no records" placeholder, which must never become executable text.
*/
const ddlEditingEnabled = computed(() => !isCreateMode.value && !!ddlContent.value.trim());
const ddlDirty = computed(() => ddlDraft.value !== null && ddlDraft.value.trim() !== ddlContent.value.trim());
function ddlEditorDocument(): string {
return ddlContent.value || t("structureEditor.emptyReadonly");
return ddlDraft.value ?? (ddlContent.value || t("structureEditor.emptyReadonly"));
}
function resetDdlDraft() {
ddlDraft.value = null;
updateDdlEditorContent(ddlEditorDocument());
}
/** Statements the edited DDL script will execute, split like the SQL editor does. */
function ddlDraftStatements(): string[] {
const script = ddlDraft.value;
if (!script) return [];
return splitSqlStatementRanges(script, databaseType.value)
.map((statement) => statement.sql.trim())
.filter((statement) => statement.length > 0);
}
function destroyDdlEditor() {
@@ -188,13 +213,21 @@ function destroyDdlEditor() {
ddlEditorView.value = null;
}
/** Set while we replace the document ourselves, so the listener below only records real user edits. */
let applyingDdlDocument = false;
function updateDdlEditorContent(content: string): boolean {
const view = ddlEditorView.value;
if (!view) return false;
if (view.state.doc.toString() !== content) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: content },
});
applyingDdlDocument = true;
try {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: content },
});
} finally {
applyingDdlDocument = false;
}
}
return true;
}
@@ -212,7 +245,11 @@ async function initDdlEditor(content: string) {
if (!container) return;
const existingView = ddlEditorView.value;
if (existingView?.dom.parentElement === container) {
// Read-only is baked into the state, so a view built while the DDL was still
// empty (placeholder text) can only be reused while its editability still
// matches — otherwise the tab would stay stuck uneditable after real DDL
// arrived, and rebuilding is the only way to swap the extension.
if (existingView?.dom.parentElement === container && existingView.state.readOnly === !ddlEditingEnabled.value) {
updateDdlEditorContent(content);
existingView.focus();
return;
@@ -260,7 +297,11 @@ async function initDdlEditor(content: string) {
WebkitUserSelect: "text",
},
}),
EditorState.readOnly.of(true),
EditorView.updateListener.of((update) => {
if (!update.docChanged || applyingDdlDocument || !ddlEditingEnabled.value) return;
ddlDraft.value = update.state.doc.toString();
}),
EditorState.readOnly.of(!ddlEditingEnabled.value),
],
});
const editorView = new EditorView({ state, parent: container });
@@ -1280,8 +1321,12 @@ function onStructureContentScroll(tab: TableInfoTab, event: Event) {
function createCurrentDraft(initialized = true): TableStructureEditorDraft {
return {
dirty: hasPendingStructureChanges(),
dirty: hasPendingStructureChanges() || ddlDirty.value,
activeTab: activeTab.value as TableStructureEditorDraft["activeTab"],
ddlDraft: ddlDraft.value,
// Only carried alongside an actual edit: without a draft the baseline is
// refetched, and copying every table's DDL into every draft is pure weight.
ddlContent: ddlDraft.value === null ? undefined : ddlContent.value,
newTableName: newTableName.value,
tableComment: tableComment.value,
originalTableComment: originalTableComment.value,
@@ -1316,6 +1361,13 @@ function restoreDraft(draft: TableStructureEditorDraft) {
restoringDraft = true;
draftHydrated = false;
activeTab.value = draft.activeTab || "columns";
// Restore the DDL baseline alongside the edit, otherwise the restored script
// would read as dirty (or clean) against the wrong reference text.
if (draft.ddlContent) {
ddlContent.value = draft.ddlContent;
ddlFetched.value = true;
}
ddlDraft.value = draft.ddlDraft ?? null;
newTableName.value = draft.newTableName || "";
tableComment.value = draft.tableComment || "";
originalTableComment.value = draft.originalTableComment || "";
@@ -1529,7 +1581,7 @@ function scheduleSqlPreviewRefresh() {
}
sqlPreviewRequestId++;
deferredSqlPreviewRefresh = false;
if (!hasPendingStructureChanges()) {
if (!hasPendingStructureChanges() && !ddlDirty.value) {
pendingStatements.value = [];
warnings.value = [];
sqliteSchemaRevision.value = undefined;
@@ -1538,10 +1590,15 @@ function scheduleSqlPreviewRefresh() {
return;
}
sqlPreviewPending.value = true;
if (hydratingRestoredDraft || needsColumnDraftMetadataHydration()) return;
if (!isCreateMode.value && secondaryMetadataLoading.value) {
deferredSqlPreviewRefresh = true;
return;
// An edited DDL script is previewed by splitting the text the user typed, so
// none of the column-draft/metadata gates below apply to it — waiting on them
// would leave the preview pending with nothing left to trigger it.
if (!ddlDirty.value) {
if (hydratingRestoredDraft || needsColumnDraftMetadataHydration()) return;
if (!isCreateMode.value && secondaryMetadataLoading.value) {
deferredSqlPreviewRefresh = true;
return;
}
}
sqlPreviewDebounceTimer = setTimeout(() => {
sqlPreviewDebounceTimer = undefined;
@@ -1579,6 +1636,18 @@ async function refreshSqlPreview() {
sqlPreviewPending.value = false;
return;
}
if (ddlDirty.value) {
// An edited DDL script runs verbatim — it is never diffed against the
// current structure — so it cannot be merged with the generated ALTERs.
// Refuse to guess which one the user meant instead of executing both.
const conflictsWithStructureDraft = hasPendingStructureChanges();
pendingStatements.value = conflictsWithStructureDraft ? [] : ddlDraftStatements();
warnings.value = conflictsWithStructureDraft ? [t("structureEditor.ddlEditConflictsWithStructure")] : [];
sqliteSchemaRevision.value = undefined;
sqlPreviewLoading.value = false;
sqlPreviewPending.value = false;
return;
}
if (!hasPendingStructureChanges()) {
pendingStatements.value = [];
warnings.value = [];
@@ -1690,6 +1759,7 @@ function resetState() {
triggersLoaded.value = false;
clearColumnSelection();
ddlContent.value = "";
ddlDraft.value = null;
ddlFetched.value = false;
loadedMetadataFacets.clear();
newTableName.value = "";
@@ -1740,6 +1810,9 @@ async function reloadStructureFromDatabase() {
invalidateTableMetadataCache(metadataMatch);
await invalidateObjectDdl(ddlRequest());
loadedMetadataFacets.clear();
// Reloading from the database discards drafts (triggers/constraints above do
// the same), so an edited DDL script must not survive as a stale overlay.
ddlDraft.value = null;
if (refreshDdl) {
ddlFetched.value = false;
await Promise.all([fetchDdl(true), loadTableOwner(true), loadTableOwnerRoles(), loadMysqlTableEngine(true)]);
@@ -3384,9 +3457,12 @@ async function copyPreviewSql() {
}
async function copyDdlContent() {
if (!ddlContent.value.trim()) return;
// Copy what the editor actually shows, so an edited script is not silently
// replaced by the database's original DDL.
const ddl = ddlDraft.value ?? ddlContent.value;
if (!ddl.trim()) return;
try {
await copyToClipboard(ddlContent.value);
await copyToClipboard(ddl);
toast(t("contextMenu.ddlCopied"), 2000);
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
@@ -3423,7 +3499,10 @@ async function applyChanges() {
}
saving.value = true;
errorMessage.value = "";
const refreshScope = captureStructureRefreshScope();
// A hand-written DDL script can change anything about the table, and the
// structure draft it was applied from is clean, so the change-derived scope
// would be empty: reload every facet instead of leaving the tabs stale.
const refreshScope = ddlDirty.value ? { columns: true, indexes: true, foreignKeys: true, constraints: true, triggers: true, tableComment: true } : captureStructureRefreshScope();
// Plan A guard: concurrent builds only run with a long-enough query timeout
// (a cancelled build leaves an INVALID index behind), and are blocked
// up-front when a same-name INVALID index already exists.
@@ -3460,9 +3539,12 @@ async function applyChanges() {
const configuredTimeoutSecs = queryTimeoutSecsForConnection(connection, settingsStore.editorSettings.globalQueryTimeoutSecs);
const executionTimeoutSecs = queryTimeoutSecsForConcurrentIndex(configuredTimeoutSecs, hasConcurrentIndexBuild);
try {
const result = hasSqliteTypeChange.value
? await api.applySqliteTableStructureChange(props.connectionId, props.database, structureChangeOptions(), sqliteSchemaRevision.value!)
: await api.executeBatch(props.connectionId, props.database, pendingStatements.value, props.schema, executionTimeoutSecs);
// An edited DDL script is always executed as the batch it previews as; the
// SQLite rebuild path would rebuild from the structure draft instead.
const result =
hasSqliteTypeChange.value && !ddlDirty.value
? await api.applySqliteTableStructureChange(props.connectionId, props.database, structureChangeOptions(), sqliteSchemaRevision.value!)
: await api.executeBatch(props.connectionId, props.database, pendingStatements.value, props.schema, executionTimeoutSecs);
await recordStructureHistory(sql, startedAt, true, result);
if (!isCreateMode.value && props.tableName) {
const metadataMatch = { connectionId: props.connectionId, database: props.database, schema: metadataSchema.value, tableName: props.tableName };
@@ -3479,6 +3561,7 @@ async function applyChanges() {
sqliteSchemaRevision.value = undefined;
ddlFetched.value = false;
ddlContent.value = "";
ddlDraft.value = null;
if (isCreateMode.value) {
clearDraft();
emit("saved", tableComment.value !== originalTableComment.value);
@@ -3784,6 +3867,7 @@ watch(
mysqlTableEngineLoading,
mysqlTableEngineLoadError,
tableOwner,
ddlDraft,
columns,
indexes,
foreignKeys,
@@ -3872,6 +3956,30 @@ watch([activeTab, loading, secondaryMetadataLoading], () => void loadActiveTable
watch([activeTab, loading, ddlLoading, ddlContent], ([tab, structureIsLoading, ddlIsLoading]) => {
if (tab === "ddl" && !structureIsLoading && !ddlIsLoading) scheduleDdlEditorInit();
});
// The DDL pane lives in a reka-ui TabsContent that stays mounted across tab
// switches via force-mount (see the TabsContent in the template), so the
// historical "revisit renders blank" race is closed at the mount level. Two
// windows remain for this watch: the pane still mounts one tick *after* the
// tab becomes active (Presence flips asynchronously), so a single `nextTick`
// guess (scheduleDdlEditorInit) can run before the container exists; and the
// loading branch swaps the container node. Drive creation off the container
// ref itself, like useDataGridCellDetail/DataGrid do for their editors.
watch(
ddlEditorContainer,
(container) => {
if (!container) {
// The container only goes away on real unmount or when the loading
// branch swaps it out: drop the view rather than leaving it attached
// to a detached node.
destroyDdlEditor();
return;
}
if (activeTab.value !== "ddl" || loading.value || ddlLoading.value) return;
void initDdlEditor(ddlEditorDocument());
},
{ flush: "post" },
);
</script>
<template>
@@ -3969,7 +4077,10 @@ watch([activeTab, loading, ddlLoading, ddlContent], ([tab, structureIsLoading, d
<Tabs v-model="activeTab" class="flex h-full min-h-0 flex-col">
<div class="flex shrink-0 items-center justify-between gap-2 border-b px-2 py-[var(--structure-header-py)]">
<TabsList>
<TabsTrigger v-if="tableMetadataCapabilities.ddl && !isCreateMode" value="ddl">DDL</TabsTrigger>
<TabsTrigger v-if="tableMetadataCapabilities.ddl && !isCreateMode" value="ddl">
DDL
<span v-if="ddlDirty" class="ml-1 h-1.5 w-1.5 rounded-full bg-primary" :title="t('structureEditor.ddlEditNotice')" data-ddl-dirty-indicator></span>
</TabsTrigger>
<TabsTrigger v-if="tableMetadataCapabilities.columns" value="columns">{{ t("structureEditor.columns") }}</TabsTrigger>
<TabsTrigger v-if="tableMetadataCapabilities.indexes" value="indexes">{{ t("structureEditor.indexes") }}</TabsTrigger>
<TabsTrigger v-if="tableMetadataCapabilities.foreignKeys" value="foreignKeys">{{ t("structureEditor.foreignKeys") }}</TabsTrigger>
@@ -4765,10 +4876,16 @@ watch([activeTab, loading, ddlLoading, ddlContent], ([tab, structureIsLoading, d
{{ t("common.loading") }}
</div>
<template v-else>
<Button v-if="ddlContent && !ddlSearchOpen" variant="outline" size="sm" class="absolute right-3 top-3 z-10 h-7 gap-1 px-2" :title="t('grid.copyDdl')" @click="copyDdlContent">
<Copy class="h-3.5 w-3.5" />
{{ t("grid.copyDdl") }}
</Button>
<div v-if="ddlContent && !ddlSearchOpen" class="absolute right-3 top-3 z-10 flex items-center gap-1.5">
<Button v-if="ddlDirty" variant="outline" size="sm" class="h-7 gap-1 px-2" :title="t('structureEditor.resetDdl')" @click="resetDdlDraft">
<RotateCcw class="h-3.5 w-3.5" />
{{ t("structureEditor.resetDdl") }}
</Button>
<Button variant="outline" size="sm" class="h-7 gap-1 px-2" :title="t('grid.copyDdl')" @click="copyDdlContent">
<Copy class="h-3.5 w-3.5" />
{{ t("grid.copyDdl") }}
</Button>
</div>
<div ref="ddlEditorContainer" class="structure-ddl-editor h-full min-h-full min-w-0 w-full"></div>
<EditorSearchPanel v-if="ddlEditorView" ref="ddlSearchPanelRef" :view="ddlEditorView" @open="ddlSearchOpen = true" @close="ddlSearchOpen = false" />
</template>
@@ -4807,6 +4924,10 @@ watch([activeTab, loading, ddlLoading, ddlContent], ([tab, structureIsLoading, d
</div>
</div>
<div v-if="!sqlPreviewCollapsed" class="min-h-0 flex-1 overflow-auto p-2.5" :aria-busy="sqlPreviewPending || sqlPreviewLoading">
<div v-if="ddlDirty" class="mb-2 flex gap-1.5 rounded-md border border-primary/40 bg-primary/10 px-[var(--structure-cell-px)] py-[var(--structure-cell-py)] text-[length:var(--structure-font-size)] text-primary">
<Info :class="[structureIconClass, 'mt-0.5 shrink-0']" />
<span>{{ t("structureEditor.ddlEditNotice") }}</span>
</div>
<div v-if="hasSqliteTypeChange" class="mb-2 flex gap-1.5 rounded-md border border-primary/40 bg-primary/10 px-[var(--structure-cell-px)] py-[var(--structure-cell-py)] text-[length:var(--structure-font-size)] text-primary">
<Info :class="[structureIconClass, 'mt-0.5 shrink-0']" />
<span>{{ t("structureEditor.sqliteRebuildNotice") }}</span>
@@ -4,15 +4,37 @@ import { describe, expect, it } from "vitest";
const source = readFileSync(new URL("../TableStructureEditor.vue", import.meta.url), "utf8");
describe("TableStructureEditor DDL search wiring", () => {
it("uses a read-only CodeMirror viewer and the shared search panel", () => {
it("uses a CodeMirror editor and the shared search panel", () => {
expect(source).toContain('import EditorSearchPanel from "@/components/editor/EditorSearchPanel.vue";');
expect(source).toContain('key: "Mod-f"');
expect(source).toContain('else if (activeTab.value === "ddl") ddlSearchPanelRef.value?.openSearch();');
expect(source).toContain("EditorState.readOnly.of(true)");
expect(source).not.toContain("ddlPreRef");
expect(source).not.toContain("onDdlKeydown");
});
it("keeps the DDL editable only where an executable script makes sense", () => {
// Editable for an existing table with loaded DDL; the create-table flow has
// no DDL tab and an empty baseline only renders a placeholder string, which
// must never become editable (and so never executable) text.
expect(source).toContain("EditorState.readOnly.of(!ddlEditingEnabled.value)");
expect(source).toMatch(/const ddlEditingEnabled = computed\(\(\) => !isCreateMode\.value && .*ddlContent\.value\.trim\(\)\)/);
expect(source).not.toContain("EditorState.readOnly.of(true)");
});
it("records user edits into ddlDraft without echoing its own document writes", () => {
expect(source).toContain("if (!update.docChanged || applyingDdlDocument || !ddlEditingEnabled.value) return;");
expect(source).toContain("ddlDraft.value = update.state.doc.toString();");
expect(source).toContain("applyingDdlDocument = true;");
});
it("initializes the editor from the container ref so a delayed tab mount still renders", () => {
// Regression guard for the blank DDL tab: reka-ui's TabsContent mounts its
// slot one tick after the tab becomes active, so a lone nextTick guess runs
// while ddlEditorContainer is still undefined.
expect(source).toMatch(/watch\(\s*ddlEditorContainer,/);
expect(source).toContain('if (activeTab.value !== "ddl" || loading.value || ddlLoading.value) return;');
});
it("refreshes and disposes the DDL editor across its component lifecycle", () => {
expect(source).toContain("if (force) destroyDdlEditor();");
expect(source).toContain("observeDdlEditorScroll(editorView);");
@@ -0,0 +1,480 @@
// @vitest-environment happy-dom
import { createApp, nextTick, type App } from "vue";
import { EditorView } from "@codemirror/view";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const TABLE_DDL = "CREATE TABLE `users` (\n `id` bigint NOT NULL AUTO_INCREMENT,\n `email` varchar(255) DEFAULT NULL,\n PRIMARY KEY (`id`)\n) ENGINE=InnoDB";
const mocks = vi.hoisted(() => ({
connection: {
id: "structure-ddl-tab",
name: "MySQL",
db_type: "mysql",
driver_label: "MySQL",
},
ensureConnected: vi.fn(),
executeQuery: vi.fn(),
executeBatch: vi.fn(),
listDataTypes: vi.fn(),
buildTableStructureChangeSql: vi.fn(),
buildMysqlAutoIncrementSql: vi.fn(),
buildTableOwnerChangeSql: vi.fn(),
getTablePartitionStatus: vi.fn(),
getTableOwner: vi.fn(),
updateEditorSettings: vi.fn(),
loadObjectDdl: vi.fn(),
invalidateObjectDdl: vi.fn(),
loadObjectMetadataFacet: vi.fn(),
invalidateObjectMetadataCache: vi.fn(),
invalidateTableMetadataCache: vi.fn(),
toast: vi.fn(),
}));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@lucide/vue", async () => {
const { defineComponent, h } = await import("vue");
const Icon = defineComponent({ name: "Icon", setup: () => () => h("span") });
return {
AlertTriangle: Icon,
Check: Icon,
ChevronDown: Icon,
ChevronLeft: Icon,
ChevronRight: Icon,
ChevronUp: Icon,
Copy: Icon,
Database: Icon,
Info: Icon,
Keyboard: Icon,
KeyRound: Icon,
ListChevronsUpDown: Icon,
Loader2: Icon,
Maximize2: Icon,
Pencil: Icon,
Plus: Icon,
RefreshCw: Icon,
RotateCcw: Icon,
Save: Icon,
Search: Icon,
Settings: Icon,
SlidersHorizontal: Icon,
Trash2: Icon,
UserRound: Icon,
X: Icon,
};
});
vi.mock("@/components/ui/button", async () => {
const { defineComponent, h } = await import("vue");
return {
Button: defineComponent({
name: "Button",
inheritAttrs: false,
setup:
(_props, { attrs, slots }) =>
() =>
h("button", attrs, slots.default?.()),
}),
};
});
vi.mock("@/components/ui/input", async () => {
const { defineComponent, h } = await import("vue");
return {
Input: defineComponent({
name: "Input",
inheritAttrs: false,
props: { modelValue: { type: [String, Number], default: "" } },
emits: ["update:modelValue"],
setup:
(props, { attrs, emit }) =>
() =>
h("input", {
...attrs,
value: props.modelValue,
onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value),
}),
}),
};
});
vi.mock("@/components/ui/badge", async () => {
const { defineComponent, h } = await import("vue");
return {
Badge: defineComponent({
name: "Badge",
inheritAttrs: false,
setup:
(_props, { attrs, slots }) =>
() =>
h("span", attrs, slots.default?.()),
}),
};
});
// Tabs mock that reproduces the one detail this regression depends on: reka-ui's
// TabsContent renders its slot through Presence, and `usePresence` awaits a
// `nextTick` before dispatching MOUNT. So the pane (and every template ref
// inside it) mounts one tick *after* the tab became active. A mock that renders
// panes unconditionally — like the other structure specs use — cannot see the
// bug at all.
vi.mock("@/components/ui/tabs", async () => {
const { computed, defineComponent, h, inject, nextTick, provide, ref, watch } = await import("vue");
const TabsSelectKey = Symbol("tabs:select");
const TabsActiveKey = Symbol("tabs:active");
const Div = defineComponent({
inheritAttrs: false,
setup:
(_props, { attrs, slots }) =>
() =>
h("div", attrs, slots.default?.()),
});
const Tabs = defineComponent({
name: "MockTabs",
inheritAttrs: false,
props: { modelValue: { type: String, default: "" } },
emits: ["update:modelValue"],
setup: (props, { attrs, slots, emit }) => {
provide(TabsSelectKey, (value: string) => emit("update:modelValue", value));
provide(
TabsActiveKey,
computed(() => props.modelValue),
);
return () => h("div", attrs, slots.default?.());
},
});
const TabsContent = defineComponent({
name: "MockTabsContent",
inheritAttrs: false,
props: { value: { type: String, required: true } },
setup: (props, { attrs, slots }) => {
const active = inject<{ value: string }>(TabsActiveKey, ref(""));
const selected = computed(() => active.value === props.value);
const present = ref(selected.value);
watch(selected, async (isSelected) => {
if (!isSelected) {
present.value = false;
return;
}
await nextTick();
present.value = true;
});
return () => h("div", attrs, present.value ? slots.default?.() : undefined);
},
});
const TabsTrigger = defineComponent({
name: "MockTabsTrigger",
inheritAttrs: false,
props: { value: { type: String, required: true } },
setup: (props, { attrs, slots }) => {
const select = inject<(value: string) => void>(TabsSelectKey, () => {});
return () => h("button", { ...attrs, type: "button", "data-tab-trigger": props.value, onClick: () => select(props.value) }, slots.default?.());
},
});
return { Tabs, TabsContent, TabsList: Div, TabsTrigger };
});
vi.mock("@/components/ui/dropdown-menu", async () => {
const { defineComponent, h } = await import("vue");
const Div = defineComponent({
inheritAttrs: false,
setup:
(_props, { attrs, slots }) =>
() =>
h("div", attrs, slots.default?.()),
});
const Button = defineComponent({
inheritAttrs: false,
setup:
(_props, { attrs, slots }) =>
() =>
h("button", attrs, slots.default?.()),
});
return { DropdownMenu: Div, DropdownMenuCheckboxItem: Div, DropdownMenuContent: Div, DropdownMenuItem: Button, DropdownMenuTrigger: Div };
});
vi.mock("@/components/ui/popover", async () => {
const { defineComponent, h } = await import("vue");
const Div = defineComponent({
inheritAttrs: false,
setup:
(_props, { attrs, slots }) =>
() =>
h("div", attrs, slots.default?.()),
});
return { Popover: Div, PopoverContent: Div, PopoverTrigger: Div };
});
vi.mock("@/components/ui/tooltip", async () => {
const { defineComponent, h } = await import("vue");
const Div = defineComponent({
inheritAttrs: false,
setup:
(_props, { attrs, slots }) =>
() =>
h("div", attrs, slots.default?.()),
});
return { Tooltip: Div, TooltipContent: Div, TooltipTrigger: Div };
});
vi.mock("@/components/ui/searchable-select", async () => {
const { defineComponent, h } = await import("vue");
return {
SearchableSelect: defineComponent({
name: "SearchableSelect",
inheritAttrs: false,
props: { modelValue: { type: String, default: "" } },
emits: ["update:modelValue"],
setup:
(props, { attrs }) =>
() =>
h("button", { ...attrs, type: "button", "data-model-value": props.modelValue }),
}),
};
});
vi.mock("@/components/ui/select", async () => {
const { defineComponent, h } = await import("vue");
const Div = defineComponent({
inheritAttrs: false,
setup:
(_props, { attrs, slots }) =>
() =>
h("div", attrs, slots.default?.()),
});
return { Select: Div, SelectContent: Div, SelectItem: Div, SelectTrigger: Div, SelectValue: Div };
});
vi.mock("@/components/editor/EditorSearchPanel.vue", async () => {
const { defineComponent, h } = await import("vue");
return {
default: defineComponent({
name: "MockEditorSearchPanel",
setup: () => ({ openSearch: () => false, closeSearch: () => false }),
render: () => h("div", { "data-editor-search-panel": "true" }),
}),
};
});
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
ensureConnected: mocks.ensureConnected,
getConfig: (connectionId: string) => (connectionId === mocks.connection.id ? mocks.connection : undefined),
}),
}));
vi.mock("@/stores/productionSafetyStore", () => ({ useProductionSafetyStore: () => ({ requestConfirmation: vi.fn() }) }));
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({ tableStructureRefreshVersion: () => 0 }) }));
vi.mock("@/stores/historyStore", () => ({ useHistoryStore: () => ({ add: vi.fn() }) }));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({
editorSettings: { structureEditorDensity: "compact", sqlFormatter: {}, tableColumnTemplateFields: [], fontSize: 13, fontFamily: "monospace", theme: "default" },
updateEditorSettings: mocks.updateEditorSettings,
}),
}));
vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: { value: false }, themePalette: { value: "pearl" } }) }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
vi.mock("@/lib/sql/sqlHighlighter", () => ({ createShikiSqlHighlighter: vi.fn(async () => (sql: string) => sql) }));
vi.mock("@/lib/sql/sqlFormatter", () => ({
formatSqlForDisplay: vi.fn(async (sql: string) => sql),
sqlFormatDialectForDbType: vi.fn(() => "mysql"),
}));
vi.mock("@/lib/editor/editorThemes", () => ({ loadEditorTheme: vi.fn(async () => []), editorFontTheme: vi.fn(() => []) }));
vi.mock("@/lib/metadata/objectDdlCache", () => ({
loadObjectDdl: mocks.loadObjectDdl,
invalidateObjectDdl: mocks.invalidateObjectDdl,
}));
vi.mock("@/lib/metadata/objectMetadataCache", () => ({ loadObjectMetadataFacet: mocks.loadObjectMetadataFacet, invalidateObjectMetadataCache: mocks.invalidateObjectMetadataCache }));
vi.mock("@/lib/metadata/tableMetadataCache", () => ({ invalidateTableMetadataCache: mocks.invalidateTableMetadataCache }));
vi.mock("@/lib/backend/api", () => ({
executeQuery: mocks.executeQuery,
executeBatch: mocks.executeBatch,
listDataTypes: mocks.listDataTypes,
buildTableStructureChangeSql: mocks.buildTableStructureChangeSql,
buildMysqlAutoIncrementSql: mocks.buildMysqlAutoIncrementSql,
buildTableOwnerChangeSql: mocks.buildTableOwnerChangeSql,
getTablePartitionStatus: mocks.getTablePartitionStatus,
getTableOwner: mocks.getTableOwner,
}));
import TableStructureEditor from "@/components/structure/TableStructureEditor.vue";
const mountedApps: App[] = [];
let lastDraft: Record<string, unknown> | undefined;
async function mountStructureEditor() {
const root = document.createElement("div");
document.body.append(root);
const app = createApp(TableStructureEditor, {
connectionId: mocks.connection.id,
database: "test",
tableName: "users",
initialTab: "columns",
"onUpdate:draft": (draft: Record<string, unknown> | undefined) => {
lastDraft = draft;
},
});
mountedApps.push(app);
app.mount(root);
// The tab list lives inside the `v-else` of the loading branch, so every tab
// must be driven from a fully settled editor: a straggling metadata load
// would otherwise unmount the whole pane between query and click.
await vi.waitFor(
() => {
expect(root.querySelector('[data-tab-trigger="ddl"]')).not.toBeNull();
expect(buttonWithText(root, "structureEditor.addColumn").disabled).toBe(false);
expect(root.textContent).toContain("structureEditor.noChanges");
},
{ timeout: 3000 },
);
await settle();
return root;
}
/** Let every already-queued load/preview microtask land before touching the DOM. */
async function settle() {
for (let i = 0; i < 30; i++) {
await nextTick();
await Promise.resolve();
}
}
async function clickTab(root: HTMLElement, tab: string) {
await vi.waitFor(
() => {
const trigger = root.querySelector<HTMLButtonElement>(`[data-tab-trigger="${tab}"]`);
expect(trigger).not.toBeNull();
trigger!.click();
},
{ timeout: 3000 },
);
}
function buttonWithText(root: HTMLElement, text: string): HTMLButtonElement {
const button = Array.from(root.querySelectorAll("button")).find((item) => item.textContent?.includes(text));
if (!button) throw new Error(`Missing ${text} button`);
return button as HTMLButtonElement;
}
function ddlEditorText(root: HTMLElement): string {
return root.querySelector(".structure-ddl-editor .cm-content")?.textContent ?? "";
}
/** The live CodeMirror view behind the DDL pane, so edits go through real transactions. */
function ddlEditorView(root: HTMLElement): EditorView {
const dom = root.querySelector<HTMLElement>(".structure-ddl-editor .cm-editor");
const view = dom ? EditorView.findFromDOM(dom) : null;
if (!view) throw new Error("Missing DDL CodeMirror view");
return view;
}
async function openDdlTab(root: HTMLElement) {
await clickTab(root, "ddl");
await vi.waitFor(() => expect(ddlEditorText(root)).toContain("CREATE TABLE"), { timeout: 3000 });
}
async function editDdl(root: HTMLElement, script: string) {
const view = ddlEditorView(root);
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: script } });
await nextTick();
}
beforeEach(() => {
vi.clearAllMocks();
lastDraft = undefined;
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.executeQuery.mockResolvedValue({ columns: [], rows: [] });
mocks.executeBatch.mockResolvedValue({ rowsAffected: 0 });
mocks.listDataTypes.mockResolvedValue([]);
mocks.getTablePartitionStatus.mockResolvedValue({ isPartitionedParent: false, isPartition: false });
mocks.getTableOwner.mockResolvedValue("");
mocks.buildTableOwnerChangeSql.mockResolvedValue({ statements: [], warnings: [] });
mocks.buildTableStructureChangeSql.mockResolvedValue({ statements: [], warnings: [] });
mocks.loadObjectDdl.mockResolvedValue({ ddl: TABLE_DDL, cacheStatus: "remote" });
mocks.loadObjectMetadataFacet.mockImplementation(async (_request: unknown, facet: string) => ({
value:
facet === "comment"
? ""
: facet === "columns"
? [
{ name: "id", data_type: "bigint", nullable: false, default_value: null, comment: "" },
{ name: "email", data_type: "varchar(255)", nullable: true, default_value: null, comment: "" },
]
: [],
cacheStatus: "remote",
}));
});
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
describe("TableStructureEditor DDL tab", () => {
it("keeps the DDL rendered when the tab is left and revisited", async () => {
// Regression for #7818/#7778: the second visit re-mounts the pane one tick
// after the tab activates, and nothing re-fetches (the DDL is already
// cached), so an init tied to a single nextTick left the tab blank forever.
const root = await mountStructureEditor();
await openDdlTab(root);
await clickTab(root, "columns");
await vi.waitFor(() => expect(root.querySelector(".structure-ddl-editor")).toBeNull(), { timeout: 3000 });
await settle();
await openDdlTab(root);
expect(ddlEditorText(root)).toContain("CREATE TABLE");
expect(ddlEditorText(root)).toContain("AUTO_INCREMENT");
// The revisit must not have refetched: the fix has to work off cached DDL.
expect(mocks.loadObjectDdl).toHaveBeenCalledTimes(1);
});
it("executes an edited DDL script as the previewed batch", async () => {
const root = await mountStructureEditor();
await openDdlTab(root);
await editDdl(root, "ALTER TABLE `users` ADD COLUMN `nickname` varchar(64);\nALTER TABLE `users` ADD INDEX `idx_email` (`email`);");
await vi.waitFor(() => expect(root.textContent).toContain("ALTER TABLE `users` ADD COLUMN `nickname` varchar(64)"), { timeout: 3000 });
expect(root.textContent).toContain("structureEditor.ddlEditNotice");
await vi.waitFor(() => expect(buttonWithText(root, "structureEditor.apply").disabled).toBe(false), { timeout: 3000 });
buttonWithText(root, "structureEditor.apply").click();
await vi.waitFor(() => expect(mocks.executeBatch).toHaveBeenCalledTimes(1), { timeout: 3000 });
expect(mocks.executeBatch.mock.calls[0][2]).toEqual(["ALTER TABLE `users` ADD COLUMN `nickname` varchar(64)", "ALTER TABLE `users` ADD INDEX `idx_email` (`email`)"]);
// The structure builder must not have contributed statements to that batch.
expect(mocks.buildTableStructureChangeSql).not.toHaveBeenCalled();
});
it("carries the edited script in the draft so the tab reports unsaved work", async () => {
const root = await mountStructureEditor();
await openDdlTab(root);
await editDdl(root, "ALTER TABLE `users` ADD COLUMN `nickname` varchar(64);");
await vi.waitFor(() => expect(lastDraft?.ddlDraft).toContain("ADD COLUMN `nickname`"), { timeout: 3000 });
// `dirty` is what the tab-close guard reads: DDL edits must count as unsaved.
expect(lastDraft?.dirty).toBe(true);
expect(lastDraft?.ddlContent).toContain("CREATE TABLE");
expect(root.querySelector("[data-ddl-dirty-indicator]")).not.toBeNull();
});
it("restores the database DDL and drops the pending batch on reset", async () => {
const root = await mountStructureEditor();
await openDdlTab(root);
await editDdl(root, "DROP TABLE `users`;");
await vi.waitFor(() => expect(root.textContent).toContain("structureEditor.ddlEditNotice"), { timeout: 3000 });
buttonWithText(root, "structureEditor.resetDdl").click();
await vi.waitFor(() => expect(root.textContent).toContain("structureEditor.noChanges"), { timeout: 3000 });
expect(root.textContent).not.toContain("structureEditor.ddlEditNotice");
expect(ddlEditorText(root)).toContain("CREATE TABLE");
expect(buttonWithText(root, "structureEditor.apply").disabled).toBe(true);
});
it("refuses to save when the DDL script and the structure tabs were both edited", async () => {
const root = await mountStructureEditor();
buttonWithText(root, "structureEditor.addColumn").click();
await nextTick();
await openDdlTab(root);
await editDdl(root, "ALTER TABLE `users` ADD COLUMN `nickname` varchar(64);");
await vi.waitFor(() => expect(root.textContent).toContain("structureEditor.ddlEditConflictsWithStructure"), { timeout: 3000 });
expect(buttonWithText(root, "structureEditor.apply").disabled).toBe(true);
buttonWithText(root, "structureEditor.apply").click();
await nextTick();
expect(mocks.executeBatch).not.toHaveBeenCalled();
});
});
+3
View File
@@ -4090,6 +4090,9 @@ export default {
saved: "Table structure updated",
copySql: "Copy SQL",
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.",
ddlEditNotice: "The edited DDL runs as a script exactly as written — it is not diffed against the current structure. For an existing table, rewrite it as ALTER (or DROP + CREATE) statements.",
ddlEditConflictsWithStructure: "The DDL script and the structure tabs were both edited. Apply or discard one of them before saving.",
resetDdl: "Restore database DDL",
extendedProperties: "Extended",
autoIncrement: "Auto Increment",
mysqlAutoIncrementNextValue: "Next auto-increment value",
+3
View File
@@ -3897,6 +3897,9 @@ export default withEnglishFallback({
saved: "Estructura de tabla actualizada",
copySql: "Copiar SQL",
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.",
ddlEditNotice: "El DDL editado se ejecuta como un script tal cual: no se compara con la estructura actual. Para una tabla existente, reescríbelo como sentencias ALTER (o DROP + CREATE).",
ddlEditConflictsWithStructure: "Se editaron a la vez el script DDL y las pestañas de estructura. Aplica o descarta uno de los dos antes de guardar.",
resetDdl: "Restaurar el DDL de la base de datos",
extendedProperties: "Extendido",
autoIncrement: "Auto Incremento",
mysqlAutoIncrementNextValue: "Siguiente valor de autoincremento",
+3
View File
@@ -3895,6 +3895,9 @@ export default withEnglishFallback({
saved: "Struttura tabella aggiornata",
copySql: "Copia SQL",
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.",
ddlEditNotice: "Il DDL modificato viene eseguito come script esattamente come è scritto: non viene confrontato con la struttura attuale. Per una tabella esistente, riscrivilo come istruzioni ALTER (o DROP + CREATE).",
ddlEditConflictsWithStructure: "Sono stati modificati sia lo script DDL sia le schede della struttura. Applica o annulla uno dei due prima di salvare.",
resetDdl: "Ripristina il DDL del database",
extendedProperties: "Esteso",
autoIncrement: "Auto Incremento",
mysqlAutoIncrementNextValue: "Valore di auto incremento successivo",
+3
View File
@@ -3957,6 +3957,9 @@ export default withEnglishFallback({
saved: "テーブル構造を更新しました",
copySql: "SQLをコピー",
sqliteRebuildNotice: "SQLite は制約が動作しないデータスナップショットをバックアップとして保持し、1 つのトランザクション内でテーブルを再構築して、変更された列を CAST で強制変換します。表現できない値は 0/0.0 になる場合があり、失敗時はすべてロールバックされます。",
ddlEditNotice: "編集した DDL は記述どおりのスクリプトとして実行され、現在の構造との差分は取られません。既存のテーブルには ALTER(または DROP + CREATE)文として書き換えてください。",
ddlEditConflictsWithStructure: "DDL スクリプトと構造タブの両方が編集されています。保存する前にどちらかを適用または破棄してください。",
resetDdl: "データベースの DDL に戻す",
extendedProperties: "拡張",
autoIncrement: "自動採番",
mysqlAutoIncrementNextValue: "次の自動採番値",
+3
View File
@@ -3855,6 +3855,9 @@ export default withEnglishFallback({
saved: "테이블 구조가 업데이트되었습니다",
copySql: "SQL 복사",
sqliteRebuildNotice: "SQLite는 백업으로 비활성 데이터 스냅샷을 유지한 다음 하나의 트랜잭션에서 테이블을 재구성하고 변경된 컬럼을 강제로 CAST합니다. 표현할 수 없는 값은 0/0.0이 될 수 있으며, 실패 시 롤백됩니다.",
ddlEditNotice: "편집한 DDL은 작성한 그대로 스크립트로 실행되며 현재 구조와 비교되지 않습니다. 기존 테이블에는 ALTER(또는 DROP + CREATE) 문으로 직접 다시 작성하세요.",
ddlEditConflictsWithStructure: "DDL 스크립트와 구조 탭이 모두 편집되었습니다. 저장하기 전에 둘 중 하나를 적용하거나 취소하세요.",
resetDdl: "데이터베이스 DDL 복원",
extendedProperties: "확장",
autoIncrement: "자동 증가",
mysqlAutoIncrementNextValue: "다음 자동 증가 값",
+3
View File
@@ -3897,6 +3897,9 @@ export default withEnglishFallback({
saved: "Estrutura da tabela atualizada",
copySql: "Copiar SQL",
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.",
ddlEditNotice: "O DDL editado é executado como script exatamente como foi escrito — não é comparado com a estrutura atual. Para uma tabela existente, reescreva-o como instruções ALTER (ou DROP + CREATE).",
ddlEditConflictsWithStructure: "O script DDL e as abas de estrutura foram editados ao mesmo tempo. Aplique ou descarte um deles antes de salvar.",
resetDdl: "Restaurar o DDL do banco de dados",
extendedProperties: "Estendidas",
autoIncrement: "Auto incremento",
mysqlAutoIncrementNextValue: "Próximo valor de incremento automático",
+3
View File
@@ -4074,6 +4074,9 @@ export default withEnglishFallback({
saved: "表结构已更新",
copySql: "复制 SQL",
sqliteRebuildNotice: "SQLite 会保留一张不带活动约束的数据快照作为备份表,再在同一事务内重建原表,使用 CAST 强制转换类型已修改的字段;无法表示的值可能变为 0/0.0,执行失败则整体回滚。",
ddlEditNotice: "编辑后的 DDL 会作为脚本原样执行,不会与当前表结构做差异比较。对已存在的表,请自行改写为 ALTER(或 DROP + CREATE)语句。",
ddlEditConflictsWithStructure: "DDL 脚本与结构页签同时被修改。请先应用或放弃其中一边再保存。",
resetDdl: "恢复数据库 DDL",
extendedProperties: "扩展属性",
autoIncrement: "自增",
mysqlAutoIncrementNextValue: "下一个自增值",
+3
View File
@@ -3636,6 +3636,9 @@ export default withEnglishFallback({
saved: "資料表結構已更新",
copySql: "複製 SQL",
sqliteRebuildNotice: "SQLite 會保留一張不含作用中約束的資料快照作為備份表,再於同一交易內重建原表,並使用 CAST 強制轉換已修改型別的欄位;無法表示的值可能變成 0/0.0,執行失敗則整體回復。",
ddlEditNotice: "編輯後的 DDL 會作為腳本原樣執行,不會與目前表結構做差異比較。對已存在的資料表,請自行改寫為 ALTER(或 DROP + CREATE)陳述式。",
ddlEditConflictsWithStructure: "DDL 腳本與結構頁籤同時被修改。請先套用或放棄其中一邊再儲存。",
resetDdl: "還原資料庫 DDL",
extendedProperties: "擴充屬性",
autoIncrement: "自動遞增",
mysqlAutoIncrementNextValue: "下一個自動遞增值",
+4
View File
@@ -1072,6 +1072,10 @@ export interface TableStructureEditorTarget {
export interface TableStructureEditorDraft {
dirty?: boolean;
activeTab: TableInfoTab;
/** DDL as loaded from the database — the baseline `ddlDraft` is compared against. */
ddlContent?: string;
/** Edited DDL script, or null/undefined when the DDL tab was left untouched. */
ddlDraft?: string | null;
newTableName: string;
tableComment: string;
originalTableComment: string;