mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
feat(structure): support editing table ddl
This commit is contained in:
@@ -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>
|
||||
|
||||
Vendored
+24
-2
@@ -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);");
|
||||
|
||||
+480
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "次の自動採番値",
|
||||
|
||||
@@ -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: "다음 자동 증가 값",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "下一个自增值",
|
||||
|
||||
@@ -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: "下一個自動遞增值",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user