feat(structure): keep newly created tables open for editing
This commit is contained in:
18 files changed
+476
-10
No files matched your search
@@ -4371,9 +4371,13 @@ onUnmounted(() => {
|
||||
}
|
||||
"
|
||||
@structure-editor-saved="
|
||||
(tabId: string, commentChanged: boolean) => {
|
||||
(tabId: string, commentChanged: boolean, createdTableName?: string) => {
|
||||
const tab = queryStore.tabs.find((candidate) => candidate.id === tabId);
|
||||
if (!tab) return;
|
||||
if (createdTableName) {
|
||||
tab.structureTableName = createdTableName;
|
||||
tab.title = t('structureEditor.editTabTitle', { tableName: createdTableName });
|
||||
}
|
||||
onStructureEditorSaved(
|
||||
async () => {
|
||||
await onReloadData(tabId);
|
||||
@@ -4387,6 +4391,7 @@ onUnmounted(() => {
|
||||
tableName: tab.structureTableName || '',
|
||||
},
|
||||
commentChanged,
|
||||
!!createdTableName,
|
||||
);
|
||||
}
|
||||
"
|
||||
|
||||
@@ -2865,7 +2865,7 @@ defineExpose({
|
||||
<div class="flex-1 min-h-0">
|
||||
<TableStructureEditor
|
||||
ref="tableStructureEditorRef"
|
||||
:key="activeTab.id"
|
||||
:key="`${activeTab.id}-${activeTab.structureTableName || 'new'}`"
|
||||
:connection-id="activeTab.connectionId"
|
||||
:database="activeTab.database"
|
||||
:catalog="activeTab.catalog"
|
||||
@@ -2876,7 +2876,7 @@ defineExpose({
|
||||
:initial-target="activeTab.structureInitialTarget"
|
||||
:draft="activeTab.structureDraft"
|
||||
@update:draft="(draft) => (activeTab.structureDraft = draft)"
|
||||
@saved="(commentChanged) => emit('structureEditorSaved', activeTab.id, commentChanged)"
|
||||
@saved="(commentChanged, createdTableName) => emit('structureEditorSaved', activeTab.id, commentChanged, createdTableName)"
|
||||
@close="emit('structureEditorClose', activeTab.id)"
|
||||
@open-settings="(initialTab, initialSection) => emit('openSettings', initialTab, initialSection)"
|
||||
@view-data="onHandleStructureViewData"
|
||||
|
||||
@@ -105,7 +105,7 @@ export interface ContentAreaSurfaceEmits {
|
||||
objectBrowserSearchChange: [tabId: string, query: string];
|
||||
objectBrowserFilterChange: [tabId: string, filter: ObjectBrowserFilter];
|
||||
addObjectTableToAi: [tabId: string, tables: Array<{ name: string; schema?: string }>];
|
||||
structureEditorSaved: [tabId: string, commentChanged: boolean];
|
||||
structureEditorSaved: [tabId: string, commentChanged: boolean, createdTableName?: string];
|
||||
structureEditorClose: [tabId: string];
|
||||
previewStatement: [tabId: string, range: StatementRange | null];
|
||||
focusStatement: [tabId: string, range: StatementRange | null];
|
||||
|
||||
@@ -126,6 +126,7 @@ import {
|
||||
toColumnNames,
|
||||
copySourceColumnDetails,
|
||||
matchesCopySourceColumnSearch,
|
||||
foldCreatedTableName,
|
||||
} from "@/lib/table/tableStructureEditorState";
|
||||
import { CREATE_DATABASE_CHARSET_OPTIONS, createDatabaseCollationOptionsForCharset, fallbackCreateDatabaseCharsetMetadata, normalizeCreateDatabaseCharsetKey, parseCreateDatabaseCharsetMetadata } from "@/lib/database/createDatabaseCharsetOptions";
|
||||
import type { CreateDatabaseCharsetMetadata } from "@/lib/database/createDatabaseCharsetOptions";
|
||||
@@ -194,7 +195,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:draft": [draft: TableStructureEditorDraft | undefined];
|
||||
saved: [commentChanged: boolean];
|
||||
saved: [commentChanged: boolean, createdTableName?: string];
|
||||
close: [];
|
||||
openSettings: [initialTab?: string, initialSection?: string];
|
||||
/** Jump from the DDL view to the table's data tab (issue #6724). */
|
||||
@@ -4414,7 +4415,7 @@ async function applyChanges() {
|
||||
await invalidateObjectDdl(ddlRequest());
|
||||
loadedMetadataFacets.clear();
|
||||
}
|
||||
toast(t("structureEditor.saved"), 2500);
|
||||
toast(t(isCreateMode.value ? "structureEditor.created" : "structureEditor.saved"), 2500);
|
||||
sqlPreviewPending.value = false;
|
||||
sqlPreviewLoading.value = false;
|
||||
pendingStatements.value = [];
|
||||
@@ -4428,8 +4429,11 @@ async function applyChanges() {
|
||||
ddlDraft.value = null;
|
||||
if (isCreateMode.value) {
|
||||
clearDraft();
|
||||
emit("saved", tableComment.value !== originalTableComment.value);
|
||||
emit("close");
|
||||
// The created tab must query the name the server actually stored: plain
|
||||
// Oracle names are created unquoted and stored upper-folded, plain
|
||||
// Informix-family names lower-folded, and every quoted name keeps its
|
||||
// exact spelling — mirror the DDL generator's quoting rule here.
|
||||
emit("saved", tableComment.value !== originalTableComment.value, foldCreatedTableName(newTableName.value, databaseType.value));
|
||||
} else {
|
||||
// Refresh persisted keys after successful renames/additions before metadata reloads.
|
||||
persistLocalColumnOrder(false);
|
||||
|
||||
Vendored
+374
@@ -0,0 +1,374 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, nextTick, type App } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Regression: after creating a table the editor converts itself into the edit
|
||||
// tab of the new table, so the emitted created name must be the name the
|
||||
// server actually stored. The CREATE DDL emits plain Oracle identifiers
|
||||
// unquoted (stored upper-folded) and plain Informix-family identifiers
|
||||
// unquoted (stored lower-folded); dialects that quote new names keep the
|
||||
// as-typed spelling. Emitting the as-typed name on a folding dialect left the
|
||||
// converted edit tab querying a case that matches nothing.
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
connection: {
|
||||
id: "structure-create-mode",
|
||||
name: "Oracle",
|
||||
db_type: "oracle",
|
||||
driver_label: "Oracle",
|
||||
},
|
||||
ensureConnected: vi.fn(),
|
||||
executeQuery: vi.fn(),
|
||||
executeBatch: vi.fn(),
|
||||
listDataTypes: vi.fn(),
|
||||
buildTableStructureChangeSql: vi.fn(),
|
||||
buildCreateTableSql: 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,
|
||||
ClipboardList: 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,
|
||||
Rows3: 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 }) =>
|
||||
() =>
|
||||
h("span", attrs),
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("@/components/ui/tabs", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const Div = defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup:
|
||||
(_props, { attrs }) =>
|
||||
() =>
|
||||
h("div", attrs),
|
||||
});
|
||||
const TabsContent = defineComponent({
|
||||
name: "MockTabsContent",
|
||||
inheritAttrs: false,
|
||||
setup:
|
||||
(_props, { attrs }) =>
|
||||
() =>
|
||||
h("div", attrs),
|
||||
});
|
||||
const TabsTrigger = defineComponent({
|
||||
name: "MockTabsTrigger",
|
||||
inheritAttrs: false,
|
||||
props: { value: { type: String, required: true } },
|
||||
setup:
|
||||
(props, { attrs }) =>
|
||||
() =>
|
||||
h("button", { ...attrs, type: "button", "data-tab-trigger": props.value }),
|
||||
});
|
||||
return { Tabs: Div, 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 }) =>
|
||||
() =>
|
||||
h("div", attrs),
|
||||
});
|
||||
return { DropdownMenu: Div, DropdownMenuCheckboxItem: Div, DropdownMenuContent: Div, DropdownMenuItem: Div, DropdownMenuTrigger: Div };
|
||||
});
|
||||
vi.mock("@/components/ui/popover", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const Div = defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup:
|
||||
(_props, { attrs }) =>
|
||||
() =>
|
||||
h("div", attrs),
|
||||
});
|
||||
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 }) =>
|
||||
() =>
|
||||
h("div", attrs),
|
||||
});
|
||||
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 }) =>
|
||||
() =>
|
||||
h("div", attrs),
|
||||
});
|
||||
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", generateSqlQuoteIdentifiers: true },
|
||||
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,
|
||||
buildCreateTableSql: mocks.buildCreateTableSql,
|
||||
buildMysqlAutoIncrementSql: mocks.buildMysqlAutoIncrementSql,
|
||||
buildTableOwnerChangeSql: mocks.buildTableOwnerChangeSql,
|
||||
getTablePartitionStatus: mocks.getTablePartitionStatus,
|
||||
getTableOwner: mocks.getTableOwner,
|
||||
}));
|
||||
|
||||
import TableStructureEditor from "@/components/structure/TableStructureEditor.vue";
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
async function settle() {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
type SavedPayload = { commentChanged: boolean; createdTableName?: string };
|
||||
|
||||
/**
|
||||
* Mount the editor in create mode, run the generated CREATE batch to
|
||||
* completion, and return what the `saved` event carried — the exact string
|
||||
* the converted edit tab would be assigned as its table name.
|
||||
*/
|
||||
async function createTableAndCaptureSaved(dbType: string, newTableName: string): Promise<SavedPayload> {
|
||||
const previousDbType = mocks.connection.db_type;
|
||||
mocks.connection.db_type = dbType;
|
||||
let saved: SavedPayload | undefined;
|
||||
try {
|
||||
const root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
const app = createApp(TableStructureEditor, {
|
||||
connectionId: mocks.connection.id,
|
||||
database: "test",
|
||||
tableName: "",
|
||||
draft: {
|
||||
dirty: true,
|
||||
activeTab: "columns",
|
||||
newTableName,
|
||||
tableComment: "",
|
||||
originalTableComment: "",
|
||||
columns: [{ id: "new:id", name: "id", dataType: "number", isNullable: false, defaultValue: "", comment: "", isPrimaryKey: false, extra: {}, markedForDrop: false }],
|
||||
indexes: [],
|
||||
foreignKeys: [],
|
||||
triggers: [],
|
||||
initialized: true,
|
||||
},
|
||||
onSaved: (commentChanged: boolean, createdName?: string) => {
|
||||
saved = { commentChanged, createdTableName: createdName };
|
||||
},
|
||||
});
|
||||
mountedApps.push(app);
|
||||
const editor = app.mount(root) as unknown as { applyChanges: () => Promise<boolean> };
|
||||
|
||||
// The SQL preview is debounced (300ms), and a hydrated draft schedules a
|
||||
// second refresh on mount; poll the apply entry point — its guard returns
|
||||
// false without side effects while the preview is still pending.
|
||||
await vi.waitFor(() => expect(mocks.buildCreateTableSql).toHaveBeenCalled(), { timeout: 3000 });
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
const applied = await editor.applyChanges();
|
||||
expect(applied).toBe(true);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
expect(mocks.executeBatch).toHaveBeenCalled();
|
||||
await settle();
|
||||
} finally {
|
||||
mocks.connection.db_type = previousDbType;
|
||||
}
|
||||
expect(saved).toBeDefined();
|
||||
return saved!;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
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.buildCreateTableSql.mockResolvedValue({ statements: ["CREATE TABLE MyTable (id number)"], warnings: [] });
|
||||
mocks.loadObjectDdl.mockResolvedValue({ ddl: "CREATE TABLE MyTable (id number)", cacheStatus: "remote" });
|
||||
mocks.loadObjectMetadataFacet.mockImplementation(async (_request: unknown, facet: string) => ({
|
||||
value: facet === "comment" ? "" : [],
|
||||
cacheStatus: "remote",
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("TableStructureEditor created-table name write-back", () => {
|
||||
it("emits the upper-folded storage name for a plain Oracle table", async () => {
|
||||
const saved = await createTableAndCaptureSaved("oracle", "MyTable");
|
||||
expect(saved.createdTableName).toBe("MYTABLE");
|
||||
});
|
||||
|
||||
it("emits the lower-folded storage name for a plain Informix-family table", async () => {
|
||||
const saved = await createTableAndCaptureSaved("informix", "MyTable");
|
||||
expect(saved.createdTableName).toBe("mytable");
|
||||
});
|
||||
|
||||
it("emits the as-typed name unchanged on dialects that quote new identifiers", async () => {
|
||||
const postgres = await createTableAndCaptureSaved("postgres", "MyTable");
|
||||
expect(postgres.createdTableName).toBe("MyTable");
|
||||
|
||||
const mysql = await createTableAndCaptureSaved("mysql", "MyTable");
|
||||
expect(mysql.createdTableName).toBe("MyTable");
|
||||
});
|
||||
|
||||
it("keeps a quoting-required Oracle name exactly as typed", async () => {
|
||||
const saved = await createTableAndCaptureSaved("oracle", "my table");
|
||||
expect(saved.createdTableName).toBe("my table");
|
||||
});
|
||||
});
|
||||
@@ -384,14 +384,14 @@ export function useNavigationTargets(dialogs: { showFieldLineageDialog: { value:
|
||||
await openTableTarget(target);
|
||||
}
|
||||
|
||||
async function onStructureEditorSaved(reloadData: () => Promise<void>, toast: (msg: string, duration?: number) => void, context: { connectionId: string; database: string; schema?: string; catalog?: string; tableName: string }, commentChanged?: boolean) {
|
||||
async function onStructureEditorSaved(reloadData: () => Promise<void>, toast: (msg: string, duration?: number) => void, context: { connectionId: string; database: string; schema?: string; catalog?: string; tableName: string }, commentChanged?: boolean, createdTable = false) {
|
||||
if (!context.tableName) {
|
||||
try {
|
||||
await connectionStore.refreshObjectListTreeNode(context.connectionId, context.database, context.schema || undefined);
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
if (commentChanged) {
|
||||
if (commentChanged || createdTable) {
|
||||
try {
|
||||
await connectionStore.refreshObjectListTreeNode(context.connectionId, context.database, context.schema || undefined);
|
||||
} catch {}
|
||||
|
||||
@@ -4620,6 +4620,7 @@ export default withEnglishFallback({
|
||||
apply: "Dəyişiklikləri tətbiq et",
|
||||
ready: "Hazırdır",
|
||||
saved: "Cədvəlin quruluşu yeniləndi",
|
||||
created: "Cədvəl uğurla yaradıldı",
|
||||
copySql: "SQL-i kopyala",
|
||||
sqliteRebuildNotice: "SQLite verilənlərin dəyişməz anlıq surətini ehtiyat nüsxə kimi saxlayır, sonra cədvəli bir tranzaksiyada yenidən qurur və dəyişdirilmiş sütunlara məcburi CAST tətbiq edir. Təmsil oluna bilməyən dəyərlər 0/0.0 ola bilər; uğursuzluq zamanı dəyişikliklər geri qaytarılır.",
|
||||
ddlEditNotice: "Redaktə edilmiş DDL yazıldığı kimi skript olaraq icra olunur — cari quruluşla fərqləri müqayisə edilmir. Mövcud cədvəl üçün onu ALTER (və ya DROP + CREATE) əmrləri kimi yenidən yazın.",
|
||||
|
||||
@@ -5091,6 +5091,7 @@ export default {
|
||||
apply: "Apply Changes",
|
||||
ready: "Ready",
|
||||
saved: "Table structure updated",
|
||||
created: "Table created successfully",
|
||||
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.",
|
||||
|
||||
@@ -4580,6 +4580,7 @@ export default withEnglishFallback({
|
||||
apply: "Aplicar cambios",
|
||||
ready: "Listo",
|
||||
saved: "Estructura de tabla actualizada",
|
||||
created: "Tabla creada correctamente",
|
||||
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).",
|
||||
|
||||
@@ -4577,6 +4577,7 @@ export default withEnglishFallback({
|
||||
apply: "Applica Modifiche",
|
||||
ready: "Pronto",
|
||||
saved: "Struttura tabella aggiornata",
|
||||
created: "Tabella creata correttamente",
|
||||
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).",
|
||||
|
||||
@@ -4638,6 +4638,7 @@ export default withEnglishFallback({
|
||||
apply: "変更を適用",
|
||||
ready: "準備完了",
|
||||
saved: "テーブル構造を更新しました",
|
||||
created: "テーブルを作成しました",
|
||||
copySql: "SQLをコピー",
|
||||
sqliteRebuildNotice: "SQLite は制約が動作しないデータスナップショットをバックアップとして保持し、1 つのトランザクション内でテーブルを再構築して、変更された列を CAST で強制変換します。表現できない値は 0/0.0 になる場合があり、失敗時はすべてロールバックされます。",
|
||||
ddlEditNotice: "編集した DDL は記述どおりのスクリプトとして実行され、現在の構造との差分は取られません。既存のテーブルには ALTER(または DROP + CREATE)文として書き換えてください。",
|
||||
|
||||
@@ -4564,6 +4564,7 @@ export default withEnglishFallback({
|
||||
apply: "변경 사항 적용",
|
||||
ready: "준비됨",
|
||||
saved: "테이블 구조가 업데이트되었습니다",
|
||||
created: "테이블이 성공적으로 생성되었습니다",
|
||||
copySql: "SQL 복사",
|
||||
sqliteRebuildNotice: "SQLite는 백업으로 비활성 데이터 스냅샷을 유지한 다음 하나의 트랜잭션에서 테이블을 재구성하고 변경된 컬럼을 강제로 CAST합니다. 표현할 수 없는 값은 0/0.0이 될 수 있으며, 실패 시 롤백됩니다.",
|
||||
ddlEditNotice: "편집한 DDL은 작성한 그대로 스크립트로 실행되며 현재 구조와 비교되지 않습니다. 기존 테이블에는 ALTER(또는 DROP + CREATE) 문으로 직접 다시 작성하세요.",
|
||||
|
||||
@@ -4579,6 +4579,7 @@ export default withEnglishFallback({
|
||||
apply: "Aplicar alterações",
|
||||
ready: "Pronto",
|
||||
saved: "Estrutura da tabela atualizada",
|
||||
created: "Tabela criada com sucesso",
|
||||
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).",
|
||||
|
||||
@@ -4524,6 +4524,7 @@ export default withEnglishFallback({
|
||||
apply: "Değişiklikleri Uygula",
|
||||
ready: "Hazır",
|
||||
saved: "Tablo yapısı güncellendi",
|
||||
created: "Tablo başarıyla oluşturuldu",
|
||||
copySql: "SQL Kopyala",
|
||||
sqliteRebuildNotice: "SQLite önce yedek olarak hareketsiz bir veri anlık görüntüsü tutar, ardından tabloyu tek bir işlemde yeniden oluşturur ve değişen sütunları zorla CAST eder. Gösterilemeyen değerler 0/0.0 olabilir; hatalar geri alınır.",
|
||||
ddlEditNotice: "Düzenlenen DDL, tam olarak yazıldığı gibi bir betik olarak çalışır — geçerli yapıyla karşılaştırılmaz. Mevcut bir tablo için bunu ALTER (ya da DROP + CREATE) ifadeleri olarak yazın.",
|
||||
|
||||
@@ -5064,6 +5064,7 @@ export default withEnglishFallback({
|
||||
apply: "应用变更",
|
||||
ready: "可执行",
|
||||
saved: "表结构已更新",
|
||||
created: "新建表成功",
|
||||
copySql: "复制 SQL",
|
||||
sqliteRebuildNotice: "SQLite 会保留一张不带活动约束的数据快照作为备份表,再在同一事务内重建原表,使用 CAST 强制转换类型已修改的字段;无法表示的值可能变为 0/0.0,执行失败则整体回滚。",
|
||||
ddlEditNotice: "编辑后的 DDL 会作为脚本原样执行,不会与当前表结构做差异比较。对已存在的表,请自行改写为 ALTER(或 DROP + CREATE)语句。",
|
||||
|
||||
@@ -4313,6 +4313,7 @@ export default withEnglishFallback({
|
||||
apply: "套用變更",
|
||||
ready: "可執行",
|
||||
saved: "資料表結構已更新",
|
||||
created: "資料表建立成功",
|
||||
copySql: "複製 SQL",
|
||||
sqliteRebuildNotice: "SQLite 會保留一張不含作用中約束的資料快照作為備份表,再於同一交易內重建原表,並使用 CAST 強制轉換已修改型別的欄位;無法表示的值可能變成 0/0.0,執行失敗則整體回復。",
|
||||
ddlEditNotice: "編輯後的 DDL 會作為腳本原樣執行,不會與目前表結構做差異比較。對已存在的資料表,請自行改寫為 ALTER(或 DROP + CREATE)陳述式。",
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
structureColumnCommentsForCopy,
|
||||
structureColumnNamesForCopy,
|
||||
tableStructureIdentifierComparisonKey,
|
||||
foldCreatedTableName,
|
||||
} from "@/lib/table/tableStructureEditorState";
|
||||
|
||||
describe("tableStructureEditorState", () => {
|
||||
@@ -215,6 +216,40 @@ describe("tableStructureEditorState", () => {
|
||||
expect(jdbcNames.has(tableStructureIdentifierComparisonKey("foo", "jdbc", { unquotedIdentifierCase: "lower", quotedIdentifierCase: "mixed" }))).toBe(false);
|
||||
});
|
||||
|
||||
describe("foldCreatedTableName", () => {
|
||||
it("folds plain Oracle names to upper case and trims surrounding whitespace", () => {
|
||||
expect(foldCreatedTableName("MyTable", "oracle")).toBe("MYTABLE");
|
||||
expect(foldCreatedTableName(" MyTable ", "oracle")).toBe("MYTABLE");
|
||||
expect(foldCreatedTableName("order_items$", "oracle")).toBe("ORDER_ITEMS$");
|
||||
expect(foldCreatedTableName("t#1", "oracle")).toBe("T#1");
|
||||
});
|
||||
|
||||
it("keeps Oracle names that the DDL generator must quote unchanged", () => {
|
||||
// A leading digit, spaces or special characters force quoting, and a
|
||||
// quoted identifier keeps its exact spelling on the server.
|
||||
expect(foldCreatedTableName("1MyTable", "oracle")).toBe("1MyTable");
|
||||
expect(foldCreatedTableName("my table", "oracle")).toBe("my table");
|
||||
expect(foldCreatedTableName("МойТable", "oracle")).toBe("МойТable");
|
||||
expect(foldCreatedTableName("", "oracle")).toBe("");
|
||||
});
|
||||
|
||||
it("folds plain Informix-family names to lower case and keeps quoted ones unchanged", () => {
|
||||
expect(foldCreatedTableName("MyTable", "informix")).toBe("mytable");
|
||||
expect(foldCreatedTableName(" MyTable ", "informix")).toBe("mytable");
|
||||
expect(foldCreatedTableName("_t1$", "informix")).toBe("_t1$");
|
||||
expect(foldCreatedTableName("1MyTable", "informix")).toBe("1MyTable");
|
||||
expect(foldCreatedTableName("my table", "informix")).toBe("my table");
|
||||
});
|
||||
|
||||
it("preserves names exactly on dialects that quote new identifiers", () => {
|
||||
for (const databaseType of ["postgres", "mysql", "sqlserver", "dameng", "oceanbase-oracle", undefined] as const) {
|
||||
expect(foldCreatedTableName("MyTable", databaseType)).toBe("MyTable");
|
||||
expect(foldCreatedTableName(" MyTable ", databaseType)).toBe("MyTable");
|
||||
expect(foldCreatedTableName("1MyTable", databaseType)).toBe("1MyTable");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps existing Oracle trigger drafts read-only until full source editing is available", () => {
|
||||
const [existing] = createTriggerDrafts([{ name: "ORDERS_AUDIT", timing: "AFTER EACH ROW", event: "INSERT OR UPDATE", statement: "BEGIN NULL; END;" }]);
|
||||
if (!existing) throw new Error("expected an existing trigger draft");
|
||||
|
||||
@@ -105,6 +105,44 @@ export function tableStructureIdentifierComparisonKey(name: string, databaseType
|
||||
return `unquoted:${normalizedUnquoted}`;
|
||||
}
|
||||
|
||||
/** Plain-identifier rule for newly created Oracle names: an ASCII letter first,
|
||||
* then letters/digits/`_`/`$`/`#`. Anything else is emitted quoted by the DDL
|
||||
* generator and keeps its exact spelling. */
|
||||
function isPlainOracleCreateIdentifier(name: string): boolean {
|
||||
if (!/^[A-Za-z]/.test(name)) return false;
|
||||
return /^[A-Za-z0-9_$#]*$/.test(name.slice(1));
|
||||
}
|
||||
|
||||
/** Plain-identifier rule for newly created Informix-family names: an ASCII
|
||||
* letter or `_` first, then letters/digits/`_`/`$`. */
|
||||
function isPlainInformixCreateIdentifier(name: string): boolean {
|
||||
if (!/^[A-Za-z_]/.test(name)) return false;
|
||||
return /^[A-Za-z0-9_$]*$/.test(name.slice(1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage name of a newly created table, mirroring how the CREATE DDL
|
||||
* generator quotes new identifiers. Plain (unquoted) Oracle names fold to
|
||||
* upper case on the server; plain Informix-family names fold to lower case.
|
||||
* Every other dialect quotes new names — or does not fold them — so the
|
||||
* as-typed spelling is preserved exactly. Names the DDL would have to quote
|
||||
* (leading digit, special characters, spaces) keep their exact spelling on
|
||||
* the folding dialects too. Boundary: a reserved-word name typed in mixed
|
||||
* case is quoted by the generator and keeps its spelling, while this helper
|
||||
* still folds it — accepted because the reserved-word vocabulary lives with
|
||||
* the SQL builder, not the frontend.
|
||||
*/
|
||||
export function foldCreatedTableName(name: string, databaseType?: DatabaseType): string {
|
||||
const value = name.trim();
|
||||
if (databaseType === "oracle") {
|
||||
return isPlainOracleCreateIdentifier(value) ? value.toUpperCase() : value;
|
||||
}
|
||||
if (databaseType === "informix") {
|
||||
return isPlainInformixCreateIdentifier(value) ? value.toLowerCase() : value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const POSTGRES_SERIAL_PSEUDO_TYPES = new Set(["smallserial", "serial", "bigserial"]);
|
||||
|
||||
function withPostgresArrayTypes(types: readonly string[]): string[] {
|
||||
|
||||
Reference in new issue
Block a user