diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 39ac9baca0..9f67027704 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -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, ); } " diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 2e234c2416..4280e07102 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -2865,7 +2865,7 @@ defineExpose({
]; - 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]; diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index 09b74067ca..000bc7aab8 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -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); diff --git a/apps/desktop/src/components/structure/__tests__/TableStructureEditor.createModeSavedName.spec.ts b/apps/desktop/src/components/structure/__tests__/TableStructureEditor.createModeSavedName.spec.ts new file mode 100644 index 0000000000..d8122a2ac0 --- /dev/null +++ b/apps/desktop/src/components/structure/__tests__/TableStructureEditor.createModeSavedName.spec.ts @@ -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 { + 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 }; + + // 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"); + }); +}); diff --git a/apps/desktop/src/composables/useNavigationTargets.ts b/apps/desktop/src/composables/useNavigationTargets.ts index 1e0befeb26..ec12b249ac 100644 --- a/apps/desktop/src/composables/useNavigationTargets.ts +++ b/apps/desktop/src/composables/useNavigationTargets.ts @@ -384,14 +384,14 @@ export function useNavigationTargets(dialogs: { showFieldLineageDialog: { value: await openTableTarget(target); } - async function onStructureEditorSaved(reloadData: () => Promise, toast: (msg: string, duration?: number) => void, context: { connectionId: string; database: string; schema?: string; catalog?: string; tableName: string }, commentChanged?: boolean) { + async function onStructureEditorSaved(reloadData: () => Promise, 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 {} diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 6f9c444487..8b15e502ff 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -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.", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 588e7992ee..dc6e9ce441 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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.", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 7265775667..ab5d7d915b 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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).", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index e06cd1e713..b3d0a0cf5c 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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).", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index cd1f1d676f..3dd78a2232 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -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)文として書き換えてください。", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 4c2531b9c0..ace537e867 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -4564,6 +4564,7 @@ export default withEnglishFallback({ apply: "변경 사항 적용", ready: "준비됨", saved: "테이블 구조가 업데이트되었습니다", + created: "테이블이 성공적으로 생성되었습니다", copySql: "SQL 복사", sqliteRebuildNotice: "SQLite는 백업으로 비활성 데이터 스냅샷을 유지한 다음 하나의 트랜잭션에서 테이블을 재구성하고 변경된 컬럼을 강제로 CAST합니다. 표현할 수 없는 값은 0/0.0이 될 수 있으며, 실패 시 롤백됩니다.", ddlEditNotice: "편집한 DDL은 작성한 그대로 스크립트로 실행되며 현재 구조와 비교되지 않습니다. 기존 테이블에는 ALTER(또는 DROP + CREATE) 문으로 직접 다시 작성하세요.", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 2c0146ba5a..ad04a9cb52 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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).", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index cabbea2bbb..09be9c9ac7 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -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.", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 3f49415086..13df05c2ac 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -5064,6 +5064,7 @@ export default withEnglishFallback({ apply: "应用变更", ready: "可执行", saved: "表结构已更新", + created: "新建表成功", copySql: "复制 SQL", sqliteRebuildNotice: "SQLite 会保留一张不带活动约束的数据快照作为备份表,再在同一事务内重建原表,使用 CAST 强制转换类型已修改的字段;无法表示的值可能变为 0/0.0,执行失败则整体回滚。", ddlEditNotice: "编辑后的 DDL 会作为脚本原样执行,不会与当前表结构做差异比较。对已存在的表,请自行改写为 ALTER(或 DROP + CREATE)语句。", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 18f07423f4..7fc2fb4e67 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -4313,6 +4313,7 @@ export default withEnglishFallback({ apply: "套用變更", ready: "可執行", saved: "資料表結構已更新", + created: "資料表建立成功", copySql: "複製 SQL", sqliteRebuildNotice: "SQLite 會保留一張不含作用中約束的資料快照作為備份表,再於同一交易內重建原表,並使用 CAST 強制轉換已修改型別的欄位;無法表示的值可能變成 0/0.0,執行失敗則整體回復。", ddlEditNotice: "編輯後的 DDL 會作為腳本原樣執行,不會與目前表結構做差異比較。對已存在的資料表,請自行改寫為 ALTER(或 DROP + CREATE)陳述式。", diff --git a/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts b/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts index 64ceeea99a..f6a3932309 100644 --- a/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts @@ -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"); diff --git a/apps/desktop/src/lib/table/tableStructureEditorState.ts b/apps/desktop/src/lib/table/tableStructureEditorState.ts index f9a0e5217d..90dcc0a9f9 100644 --- a/apps/desktop/src/lib/table/tableStructureEditorState.ts +++ b/apps/desktop/src/lib/table/tableStructureEditorState.ts @@ -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[] {