diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index c70cd8b71f..b23596e886 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; 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, ChevronUp, Copy, Database, Info, KeyRound, ListChevronsUpDown, Loader2, Maximize2, Plus, RefreshCw, Save, Search, Settings, SlidersHorizontal, Trash2, X } from "@lucide/vue"; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; @@ -40,14 +41,16 @@ import { canAddTableStructureColumn, getTableStructureCapabilities, hasLocalTabl import { orderedColumnIndexes, uniqueDataGridColumnOrderKeys } from "@/lib/dataGrid/dataGridColumnOrder"; import { loadTableDataGridColumnOrder, notifyTableDataGridColumnOrderChanged, removeTableDataGridColumnOrder, saveTableDataGridColumnOrder, tableDataGridColumnOrderScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage"; import { connectionObjectTreeQuerySchema, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; -import type { ConstraintInfo, TableInfoTab, TableStructureEditorDraft, TableStructureEditorTarget, TableStructureEditorViewport } from "@/types/database"; +import type { ColumnInfo, ConstraintInfo, TableInfo, TableInfoTab, TableStructureEditorDraft, TableStructureEditorTarget, TableStructureEditorViewport } from "@/types/database"; import { applyManticoreDdlColumnExtras, buildStructureTargetLabel, canEditStructuredTriggerDraft, canEditManticoreColumnProperties, + cloneColumnDraftAsNew, combineDataTypeForDatabase, combineDataTypeForDatabaseWithLengthUnit, + createCopiedColumnDrafts, createColumnDrafts, createForeignKeyDrafts, createIndexDrafts, @@ -76,6 +79,7 @@ import { restoreCharacterLengthUnitsAfterSave, sameStructureIndexType, splitDataType, + tableStructureIdentifierComparisonKey, toColumnNames, } from "@/lib/table/tableStructureEditorState"; import { CREATE_DATABASE_CHARSET_OPTIONS, createDatabaseCollationOptionsForCharset, fallbackCreateDatabaseCharsetMetadata, normalizeCreateDatabaseCharsetKey, parseCreateDatabaseCharsetMetadata } from "@/lib/database/createDatabaseCharsetOptions"; @@ -192,6 +196,16 @@ async function fetchDdl(force = false) { } const errorMessage = ref(""); const columns = ref([]); +const copyColumnsDialogOpen = ref(false); +const copySourceTables = ref([]); +const copySourceTableName = ref(""); +const copySourceColumns = ref([]); +const copySourceColumnSearch = ref(""); +const selectedCopySourceColumnNames = ref([]); +const copySourceTablesLoading = ref(false); +const copySourceColumnsLoading = ref(false); +const copySourceError = ref(""); +let copySourceColumnsRequestId = 0; const indexes = ref([]); const pendingStatements = ref([]); const warnings = ref([]); @@ -1583,6 +1597,107 @@ function onColumnRowActivate(column: EditableStructureColumn) { selectedColumnId.value = column.id; } +function normalizedColumnSearch(value: string): string { + return value.trim().toLowerCase(); +} + +const copyableSourceColumns = computed(() => { + const databaseInfo = connection.value?.database_info; + const existingNames = new Set(columns.value.filter((column) => !column.markedForDrop).map((column) => tableStructureIdentifierComparisonKey(column.name, databaseType.value, databaseInfo))); + return copySourceColumns.value.map((column) => ({ + column, + alreadyExists: existingNames.has(tableStructureIdentifierComparisonKey(column.name, databaseType.value, databaseInfo)), + })); +}); + +const filteredCopyableSourceColumns = computed(() => { + const search = normalizedColumnSearch(copySourceColumnSearch.value); + if (!search) return copyableSourceColumns.value; + return copyableSourceColumns.value.filter(({ column }) => [column.name, column.data_type, column.comment ?? ""].some((value) => normalizedColumnSearch(value).includes(search))); +}); + +const copyableSourceColumnNames = computed(() => copyableSourceColumns.value.filter(({ alreadyExists }) => !alreadyExists).map(({ column }) => column.name)); +const selectedCopySourceColumns = computed(() => { + const selected = new Set(selectedCopySourceColumnNames.value); + return copyableSourceColumns.value.filter(({ column, alreadyExists }) => !alreadyExists && selected.has(column.name)).map(({ column }) => column); +}); +const allCopyableSourceColumnsSelected = computed(() => copyableSourceColumnNames.value.length > 0 && copyableSourceColumnNames.value.every((name) => selectedCopySourceColumnNames.value.includes(name))); + +async function openCopyColumnsDialog() { + if (!canAddColumn.value || !props.connectionId || !props.database) return; + copyColumnsDialogOpen.value = true; + copySourceTableName.value = ""; + copySourceColumns.value = []; + copySourceColumnSearch.value = ""; + selectedCopySourceColumnNames.value = []; + copySourceError.value = ""; + copySourceColumnsRequestId++; + copySourceColumnsLoading.value = false; + copySourceTables.value = []; + copySourceTablesLoading.value = true; + try { + await store.ensureConnected(props.connectionId); + const databaseInfo = connection.value?.database_info; + const currentTableIdentifierKey = tableStructureIdentifierComparisonKey(props.tableName, databaseType.value, databaseInfo); + copySourceTables.value = (await api.listTables(props.connectionId, props.database, metadataSchema.value, undefined, undefined, undefined, undefined, props.catalog)).filter((table) => { + const tableType = table.table_type.toUpperCase(); + return tableType !== "VIEW" && tableType !== "MATERIALIZED_VIEW" && (isCreateMode.value || tableStructureIdentifierComparisonKey(table.name, databaseType.value, databaseInfo) !== currentTableIdentifierKey); + }); + } catch (error: any) { + copySourceTables.value = []; + copySourceError.value = error?.message || String(error); + } finally { + copySourceTablesLoading.value = false; + } +} + +async function loadCopySourceColumns(tableName: string) { + copySourceTableName.value = tableName; + copySourceColumns.value = []; + copySourceColumnSearch.value = ""; + selectedCopySourceColumnNames.value = []; + copySourceError.value = ""; + if (!tableName || !props.connectionId || !props.database) return; + const requestId = ++copySourceColumnsRequestId; + copySourceColumnsLoading.value = true; + try { + const sourceColumns = await api.getColumns(props.connectionId, props.database, metadataSchema.value, tableName, props.catalog); + if (requestId !== copySourceColumnsRequestId) return; + copySourceColumns.value = sourceColumns; + selectedCopySourceColumnNames.value = copyableSourceColumns.value.filter(({ alreadyExists }) => !alreadyExists).map(({ column }) => column.name); + } catch (error: any) { + if (requestId !== copySourceColumnsRequestId) return; + copySourceError.value = error?.message || String(error); + } finally { + if (requestId === copySourceColumnsRequestId) copySourceColumnsLoading.value = false; + } +} + +function toggleCopySourceColumns() { + selectedCopySourceColumnNames.value = allCopyableSourceColumnsSelected.value ? [] : [...copyableSourceColumnNames.value]; +} + +function applyCopiedColumns() { + const copiedColumns = createCopiedColumnDrafts(selectedCopySourceColumns.value, databaseType.value, uuid); + if (!copiedColumns.length) return; + const insertAt = resolveInsertColumnIndex(columns.value, selectedColumnId.value); + columns.value.splice(insertAt, 0, ...copiedColumns); + selectedColumnId.value = copiedColumns[copiedColumns.length - 1]?.id ?? selectedColumnId.value; + if (usesLocalTableColumnOrder.value) persistLocalColumnOrder(false); + copyColumnsDialogOpen.value = false; +} + +async function copyColumn(column: EditableStructureColumn) { + if (!canAddColumn.value || column.markedForDrop) return; + const sourceIndex = columns.value.findIndex((item) => item.id === column.id); + if (sourceIndex < 0) return; + const copiedColumn = cloneColumnDraftAsNew(column, uuid); + columns.value.splice(sourceIndex + 1, 0, copiedColumn); + selectedColumnId.value = copiedColumn.id; + if (usesLocalTableColumnOrder.value) persistLocalColumnOrder(false); + await focusColumnNameInput(copiedColumn.id); +} + async function addColumn() { if (!canAddColumn.value) return; activeTab.value = "columns"; @@ -2898,6 +3013,10 @@ watch([activeTab, ddlLoading], ([tab, loading]) => { {{ t("structureEditor.addColumn") }} + + + + + + + {{ t("structureEditor.copyColumnsTitle") }} + + +
+ + +
+ + {{ t("common.loading") }} +
+
+ {{ copySourceError }} +
+
+ {{ t("structureEditor.copyColumnsNoSourceTables") }} +
+ +
+ + + + + +
+
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 167e2967dc..ff1a9db7c3 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -3445,6 +3445,21 @@ export default { triggers: "Triggers", ddl: "DDL", addColumn: "Add Column", + copyColumns: "Copy Fields from Another Table", + copyColumn: "Copy Column", + copyColumnsTitle: "Copy Columns from Another Table", + copyColumnsSourceTable: "Source table", + copyColumnsSelectSourceTable: "Select a source table", + copyColumnsSelectFields: "Select fields", + copyColumnsSearchFields: "Search fields or types", + copyColumnsSelectAll: "Select all", + copyColumnsClearSelection: "Clear selection", + copyColumnsNoSourceTables: "No other tables are available in this schema.", + copyColumnsNoFields: "The selected table has no fields.", + copyColumnsNoMatchingFields: "No fields match the search.", + copyColumnsAlreadyExists: "Already exists", + copyColumnsCancel: "Cancel", + copyColumnsApply: "Copy {count} field(s)", columnTemplates: "Insert Preset Fields", configureColumnTemplates: "Configure Preset Fields", presetFieldsTemplate: "Preset Field Set", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index ffe7ebb51b..e0123bca2f 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -3387,6 +3387,21 @@ export default withEnglishFallback({ triggers: "Disparadores", ddl: "DDL", addColumn: "Agregar columna", + copyColumns: "Copiar campos de otra tabla", + copyColumn: "Copiar columna", + copyColumnsTitle: "Copiar columnas de otra tabla", + copyColumnsSourceTable: "Tabla de origen", + copyColumnsSelectSourceTable: "Selecciona una tabla de origen", + copyColumnsSelectFields: "Selecciona campos", + copyColumnsSearchFields: "Buscar campos o tipos", + copyColumnsSelectAll: "Seleccionar todo", + copyColumnsClearSelection: "Borrar selección", + copyColumnsNoSourceTables: "No hay otras tablas disponibles en este esquema.", + copyColumnsNoFields: "La tabla seleccionada no tiene campos.", + copyColumnsNoMatchingFields: "Ningún campo coincide con la búsqueda.", + copyColumnsAlreadyExists: "Ya existe", + copyColumnsCancel: "Cancelar", + copyColumnsApply: "Copiar {count} campo(s)", columnTemplates: "Insertar campos predefinidos", configureColumnTemplates: "Configurar campos predefinidos", presetFieldsTemplate: "Conjunto de campos predefinidos", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 264a7a08d4..2ee9c8d33c 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -3385,6 +3385,21 @@ export default withEnglishFallback({ triggers: "Trigger", ddl: "DDL", addColumn: "Aggiungi Colonna", + copyColumns: "Copia campi da un'altra tabella", + copyColumn: "Copia colonna", + copyColumnsTitle: "Copia colonne da un'altra tabella", + copyColumnsSourceTable: "Tabella di origine", + copyColumnsSelectSourceTable: "Seleziona una tabella di origine", + copyColumnsSelectFields: "Seleziona campi", + copyColumnsSearchFields: "Cerca campi o tipi", + copyColumnsSelectAll: "Seleziona tutto", + copyColumnsClearSelection: "Cancella selezione", + copyColumnsNoSourceTables: "Non sono disponibili altre tabelle in questo schema.", + copyColumnsNoFields: "La tabella selezionata non ha campi.", + copyColumnsNoMatchingFields: "Nessun campo corrisponde alla ricerca.", + copyColumnsAlreadyExists: "Già esistente", + copyColumnsCancel: "Annulla", + copyColumnsApply: "Copia {count} campi", columnTemplates: "Inserisci campi predefiniti", configureColumnTemplates: "Configura campi predefiniti", presetFieldsTemplate: "Set di campi predefiniti", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index a33f49280c..03632d64a5 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -3446,6 +3446,21 @@ export default withEnglishFallback({ triggers: "トリガー", ddl: "DDL", addColumn: "列を追加", + copyColumns: "別のテーブルから列をコピー", + copyColumn: "列をコピー", + copyColumnsTitle: "別のテーブルから列をコピー", + copyColumnsSourceTable: "コピー元テーブル", + copyColumnsSelectSourceTable: "コピー元テーブルを選択", + copyColumnsSelectFields: "フィールドを選択", + copyColumnsSearchFields: "フィールドまたは型を検索", + copyColumnsSelectAll: "すべて選択", + copyColumnsClearSelection: "選択を解除", + copyColumnsNoSourceTables: "このスキーマには他に選択可能なテーブルがありません。", + copyColumnsNoFields: "選択したテーブルにはフィールドがありません。", + copyColumnsNoMatchingFields: "検索に一致するフィールドはありません。", + copyColumnsAlreadyExists: "既に存在します", + copyColumnsCancel: "キャンセル", + copyColumnsApply: "{count} 個のフィールドをコピー", columnTemplates: "プリセット列を挿入", configureColumnTemplates: "プリセット列を設定", presetFieldsTemplate: "プリセット列セット", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index be4d9ff0d8..3ae1ac56d2 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -3263,6 +3263,21 @@ export default withEnglishFallback({ triggers: "트리거", ddl: "DDL", addColumn: "컬럼 추가", + copyColumns: "다른 테이블에서 필드 복사", + copyColumn: "컬럼 복사", + copyColumnsTitle: "다른 테이블에서 컬럼 복사", + copyColumnsSourceTable: "원본 테이블", + copyColumnsSelectSourceTable: "원본 테이블 선택", + copyColumnsSelectFields: "필드 선택", + copyColumnsSearchFields: "필드 또는 유형 검색", + copyColumnsSelectAll: "모두 선택", + copyColumnsClearSelection: "선택 해제", + copyColumnsNoSourceTables: "이 스키마에 선택할 수 있는 다른 테이블이 없습니다.", + copyColumnsNoFields: "선택한 테이블에 필드가 없습니다.", + copyColumnsNoMatchingFields: "검색과 일치하는 필드가 없습니다.", + copyColumnsAlreadyExists: "이미 존재함", + copyColumnsCancel: "취소", + copyColumnsApply: "{count}개 필드 복사", columnTemplates: "미리 정의된 필드 삽입", configureColumnTemplates: "미리 정의된 필드 설정", presetFieldsTemplate: "미리 정의된 필드 세트", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 6c912de98c..f160346615 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -3387,6 +3387,21 @@ export default withEnglishFallback({ triggers: "Triggers", ddl: "DDL", addColumn: "Adicionar coluna", + copyColumns: "Copiar campos de outra tabela", + copyColumn: "Copiar coluna", + copyColumnsTitle: "Copiar colunas de outra tabela", + copyColumnsSourceTable: "Tabela de origem", + copyColumnsSelectSourceTable: "Selecione uma tabela de origem", + copyColumnsSelectFields: "Selecione campos", + copyColumnsSearchFields: "Pesquisar campos ou tipos", + copyColumnsSelectAll: "Selecionar tudo", + copyColumnsClearSelection: "Limpar seleção", + copyColumnsNoSourceTables: "Não há outras tabelas disponíveis neste esquema.", + copyColumnsNoFields: "A tabela selecionada não tem campos.", + copyColumnsNoMatchingFields: "Nenhum campo corresponde à pesquisa.", + copyColumnsAlreadyExists: "Já existe", + copyColumnsCancel: "Cancelar", + copyColumnsApply: "Copiar {count} campo(s)", columnTemplates: "Inserir campos predefinidos", configureColumnTemplates: "Configurar campos predefinidos", presetFieldsTemplate: "Conjunto de campos predefinidos", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 94eb4168e8..048ab04fbb 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -3433,6 +3433,21 @@ export default withEnglishFallback({ triggers: "触发器", ddl: "DDL", addColumn: "新增字段", + copyColumns: "复制其他表字段", + copyColumn: "复制字段", + copyColumnsTitle: "从其他表复制字段", + copyColumnsSourceTable: "源表", + copyColumnsSelectSourceTable: "请选择源表", + copyColumnsSelectFields: "选择字段", + copyColumnsSearchFields: "搜索字段或类型", + copyColumnsSelectAll: "全选", + copyColumnsClearSelection: "取消全选", + copyColumnsNoSourceTables: "当前模式下没有其他可选表。", + copyColumnsNoFields: "所选表没有字段。", + copyColumnsNoMatchingFields: "没有匹配的字段。", + copyColumnsAlreadyExists: "已存在", + copyColumnsCancel: "取消", + copyColumnsApply: "复制 {count} 个字段", columnTemplates: "插入预设字段", configureColumnTemplates: "配置预设字段", presetFieldsTemplate: "预设字段组合", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 047cddb94a..e67811e95f 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -3147,6 +3147,21 @@ export default withEnglishFallback({ triggers: "觸發器", ddl: "DDL", addColumn: "新增欄位", + copyColumns: "複製其他資料表欄位", + copyColumn: "複製欄位", + copyColumnsTitle: "從其他資料表複製欄位", + copyColumnsSourceTable: "來源資料表", + copyColumnsSelectSourceTable: "請選擇來源資料表", + copyColumnsSelectFields: "選擇欄位", + copyColumnsSearchFields: "搜尋欄位或類型", + copyColumnsSelectAll: "全選", + copyColumnsClearSelection: "取消全選", + copyColumnsNoSourceTables: "目前結構描述中沒有其他可選資料表。", + copyColumnsNoFields: "所選資料表沒有欄位。", + copyColumnsNoMatchingFields: "沒有符合搜尋的欄位。", + copyColumnsAlreadyExists: "已存在", + copyColumnsCancel: "取消", + copyColumnsApply: "複製 {count} 個欄位", columnTemplates: "插入預設欄位", configureColumnTemplates: "設定預設欄位", presetFieldsTemplate: "預設欄位組合", diff --git a/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts b/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts index 1ab4a96704..4161ce688c 100644 --- a/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; import { canEditStructuredTriggerDraft, + cloneColumnDraftAsNew, combineDataTypeForDatabase, combineDataTypeForDatabaseWithLengthUnit, + createCopiedColumnDrafts, createColumnDrafts, createTriggerDrafts, dataTypeLengthInputValue, @@ -23,9 +25,24 @@ import { resolveInsertColumnIndex, restoreCharacterLengthUnitsAfterSave, splitDataType, + tableStructureIdentifierComparisonKey, } from "@/lib/table/tableStructureEditorState"; describe("tableStructureEditorState", () => { + it("keeps quoted mixed-case identifiers distinct when detecting copied-column duplicates", () => { + const postgresNames = new Set([tableStructureIdentifierComparisonKey("Foo", "postgres")]); + expect(postgresNames.has(tableStructureIdentifierComparisonKey("foo", "postgres"))).toBe(false); + + const oracleNames = new Set([tableStructureIdentifierComparisonKey("Foo", "oracle")]); + expect(oracleNames.has(tableStructureIdentifierComparisonKey("FOO", "oracle"))).toBe(false); + + const mysqlNames = new Set([tableStructureIdentifierComparisonKey("Foo", "mysql")]); + expect(mysqlNames.has(tableStructureIdentifierComparisonKey("foo", "mysql"))).toBe(true); + + const jdbcNames = new Set([tableStructureIdentifierComparisonKey("Foo", "jdbc", { unquotedIdentifierCase: "lower", quotedIdentifierCase: "mixed" })]); + expect(jdbcNames.has(tableStructureIdentifierComparisonKey("foo", "jdbc", { unquotedIdentifierCase: "lower", quotedIdentifierCase: "mixed" }))).toBe(false); + }); + 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"); @@ -95,6 +112,81 @@ describe("tableStructureEditorState", () => { expect(dataTypeLengthInputValue("kingbase", columns[0]?.dataType ?? "")).toBe("255"); }); + it("turns copied metadata into new column drafts instead of existing columns", () => { + const copied = createCopiedColumnDrafts( + [ + { + name: "display_name", + data_type: "varchar", + is_nullable: false, + column_default: "'anonymous'", + is_primary_key: true, + extra: "auto_increment", + comment: "Visible name", + character_maximum_length: 255, + }, + ], + "mysql", + () => "copied-column", + ); + + expect(copied).toEqual([ + expect.objectContaining({ + id: "new:copied-column", + name: "display_name", + dataType: "varchar(255)", + defaultValue: "'anonymous'", + comment: "Visible name", + isPrimaryKey: false, + extra: {}, + }), + ]); + expect(copied[0]?.original).toBeUndefined(); + expect(copied[0]?.originalPosition).toBeUndefined(); + }); + + it("clones an editable field into an independent new column", () => { + const [source] = createColumnDrafts( + [ + { + name: "status", + data_type: "enum", + enum_values: ["draft", "published"], + is_nullable: false, + column_default: "'draft'", + is_primary_key: false, + extra: "on update current_timestamp", + comment: "Publication status", + }, + ], + "mysql", + ); + const copied = cloneColumnDraftAsNew(source!, () => "copied-column"); + + expect(copied).toMatchObject({ + id: "new:copied-column", + name: "status", + enumValues: ["draft", "published"], + extra: { onUpdateCurrentTimestamp: true }, + markedForDrop: false, + }); + expect(copied.original).toBeUndefined(); + expect(copied.originalPosition).toBeUndefined(); + + source!.isPrimaryKey = true; + source!.extra.autoIncrement = true; + source!.extra.identity = { generation: "ALWAYS" }; + const copiedKeyColumn = cloneColumnDraftAsNew(source!, () => "copied-key-column"); + expect(copiedKeyColumn.isPrimaryKey).toBe(false); + expect(copiedKeyColumn.extra.autoIncrement).toBeUndefined(); + expect(copiedKeyColumn.extra.identity).toBeUndefined(); + + copied.enumValues?.push("archived"); + copied.extra.onUpdateCurrentTimestamp = false; + expect(source?.enumValues).toEqual(["draft", "published"]); + expect(source?.extra.onUpdateCurrentTimestamp).toBe(true); + }); + it("parses Kingbase SQLServer compatibility identity metadata", () => { expect(parseExtraToColumnExtra("identity(10, 2)", "kingbase")).toEqual({ autoIncrement: true, diff --git a/apps/desktop/src/lib/table/tableStructureEditorState.ts b/apps/desktop/src/lib/table/tableStructureEditorState.ts index eebbf6bac6..ef3591632d 100644 --- a/apps/desktop/src/lib/table/tableStructureEditorState.ts +++ b/apps/desktop/src/lib/table/tableStructureEditorState.ts @@ -1,10 +1,46 @@ -import type { ColumnInfo, DatabaseType, ForeignKeyInfo, IndexInfo, TriggerInfo } from "@/types/database.ts"; +import type { ColumnInfo, DatabaseConnectionInfo, DatabaseType, ForeignKeyInfo, IndexInfo, TriggerInfo } from "@/types/database.ts"; import type { ColumnExtra, EditableStructureColumn, EditableStructureForeignKey, EditableStructureIndex, EditableStructureTrigger } from "@/lib/table/tableStructureEditorSql.ts"; export function hasExistingColumnTypeChange(columns: readonly EditableStructureColumn[]): boolean { return columns.some((column) => !!column.original && !column.markedForDrop && column.dataType !== column.original.data_type); } +type TableStructureIdentifierCaseInfo = Pick; + +const LOWER_UNQUOTED_MIXED_QUOTED_DATABASES = new Set(["postgres", "redshift", "opengauss", "gaussdb", "highgo", "uxdb"]); +const UPPER_UNQUOTED_MIXED_QUOTED_DATABASES = new Set(["oracle", "oceanbase-oracle", "dameng", "kingbase", "vastbase", "goldendb", "yashandb"]); + +function defaultTableStructureIdentifierCaseInfo(databaseType?: DatabaseType): Required { + if (databaseType && LOWER_UNQUOTED_MIXED_QUOTED_DATABASES.has(databaseType)) { + return { unquotedIdentifierCase: "lower", quotedIdentifierCase: "mixed" }; + } + if (databaseType && UPPER_UNQUOTED_MIXED_QUOTED_DATABASES.has(databaseType)) { + return { unquotedIdentifierCase: "upper", quotedIdentifierCase: "mixed" }; + } + if (databaseType === "clickhouse") { + return { unquotedIdentifierCase: "mixed", quotedIdentifierCase: "mixed" }; + } + return { unquotedIdentifierCase: "lower", quotedIdentifierCase: "lower" }; +} + +function applyIdentifierCase(value: string, identifierCase: NonNullable): string { + if (identifierCase === "lower") return value.toLowerCase(); + if (identifierCase === "upper") return value.toUpperCase(); + return value; +} + +export function tableStructureIdentifierComparisonKey(name: string, databaseType?: DatabaseType, databaseInfo?: TableStructureIdentifierCaseInfo): string { + const value = name.trim(); + const defaults = defaultTableStructureIdentifierCaseInfo(databaseType); + const unquotedIdentifierCase = databaseInfo?.unquotedIdentifierCase ?? defaults.unquotedIdentifierCase; + const quotedIdentifierCase = databaseInfo?.quotedIdentifierCase ?? defaults.quotedIdentifierCase; + const normalizedUnquoted = applyIdentifierCase(value, unquotedIdentifierCase); + + if (unquotedIdentifierCase === "mixed") return `exact:${value}`; + if (quotedIdentifierCase === "mixed" && value !== normalizedUnquoted) return `quoted:${value}`; + return `unquoted:${normalizedUnquoted}`; +} + const POSTGRES_SERIAL_PSEUDO_TYPES = new Set(["smallserial", "serial", "bigserial"]); function withPostgresArrayTypes(types: readonly string[]): string[] { @@ -758,6 +794,45 @@ export function createColumnDrafts(columns: ColumnInfo[], databaseType?: Databas }); } +/** + * Turns another table's metadata into columns that will be added to the table + * currently being edited. Unlike createColumnDrafts(), these must not retain + * original metadata: the SQL builder uses that metadata to identify existing + * columns that should be altered. + */ +export function createCopiedColumnDrafts(columns: ColumnInfo[], databaseType: DatabaseType | undefined, createId: () => string): EditableStructureColumn[] { + return createColumnDrafts(columns, databaseType).map(({ original: _original, originalPosition: _originalPosition, isPrimaryKey: _isPrimaryKey, extra, ...column }) => ({ + ...column, + id: `new:${createId()}`, + isPrimaryKey: false, + extra: copyableColumnExtra(extra), + })); +} + +/** Copy only field-local extras; keys and generated-value state are table-level concerns. */ +function copyableColumnExtra(extra: ColumnExtra): ColumnExtra { + const { autoIncrement: _autoIncrement, identity: _identity, ...copyableExtra } = extra; + return copyableExtra; +} + +/** Clone an editable field as a new column, without linking it to persisted metadata or key state. */ +export function cloneColumnDraftAsNew(column: EditableStructureColumn, createId: () => string): EditableStructureColumn { + return { + id: `new:${createId()}`, + name: column.name, + dataType: column.dataType, + enumValues: column.enumValues ? [...column.enumValues] : undefined, + isNullable: column.isNullable, + defaultValue: column.defaultValue, + comment: column.comment, + isPrimaryKey: false, + characterSet: column.characterSet, + collation: column.collation, + extra: copyableColumnExtra(column.extra), + markedForDrop: false, + }; +} + function existingColumnIdName(id: string): string | undefined { const prefix = "existing:"; return id.startsWith(prefix) ? id.slice(prefix.length) : undefined;