feat(structure): copy columns between tables

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-16 11:39:44 +08:00
1 parent 1d8771a846
commit bdee3a843c
11 files changed
+479 -2

No files matched your search

@@ -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<EditableStructureColumn[]>([]);
const copyColumnsDialogOpen = ref(false);
const copySourceTables = ref<TableInfo[]>([]);
const copySourceTableName = ref("");
const copySourceColumns = ref<ColumnInfo[]>([]);
const copySourceColumnSearch = ref("");
const selectedCopySourceColumnNames = ref<string[]>([]);
const copySourceTablesLoading = ref(false);
const copySourceColumnsLoading = ref(false);
const copySourceError = ref("");
let copySourceColumnsRequestId = 0;
const indexes = ref<EditableStructureIndex[]>([]);
const pendingStatements = ref<string[]>([]);
const warnings = ref<string[]>([]);
@@ -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]) => {
<Plus :class="structureIconClass" />
{{ t("structureEditor.addColumn") }}
</Button>
<Button v-if="activeTab === 'columns'" size="sm" variant="outline" :class="structureToolbarButtonClass" :disabled="!canAddColumn" @click="openCopyColumnsDialog">
<Copy :class="structureIconClass" />
{{ t("structureEditor.copyColumns") }}
</Button>
<Button v-if="isCreateMode && activeTab === 'columns'" size="sm" variant="outline" :class="structureToolbarButtonClass" :disabled="!canAddColumn" @click="applyColumnTemplate(PRESET_FIELDS_TEMPLATE_ID)">
<Copy :class="structureIconClass" />
{{ t("structureEditor.columnTemplates") }}
@@ -3290,6 +3409,9 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
>
<ListChevronsUpDown :class="structureIconClass" />
</Button>
<Button variant="ghost" size="icon" :class="structureActionButtonClass" :disabled="!canAddColumn || column.markedForDrop" :title="t('structureEditor.copyColumn')" :aria-label="t('structureEditor.copyColumn')" @click.stop="copyColumn(column)">
<Copy :class="structureIconClass" />
</Button>
<Button
v-if="column.original"
variant="ghost"
@@ -3624,6 +3746,74 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
{{ t("structureEditor.apply") }}
</Button>
</div>
<Dialog v-model:open="copyColumnsDialogOpen">
<DialogContent class="max-w-xl">
<DialogHeader>
<DialogTitle>{{ t("structureEditor.copyColumnsTitle") }}</DialogTitle>
</DialogHeader>
<div class="space-y-3 overflow-hidden">
<label class="grid gap-1.5 text-sm font-medium">
{{ t("structureEditor.copyColumnsSourceTable") }}
<select
:value="copySourceTableName"
class="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="copySourceTablesLoading || copySourceTables.length === 0"
@change="loadCopySourceColumns(($event.target as HTMLSelectElement).value)"
>
<option value="" disabled>{{ t("structureEditor.copyColumnsSelectSourceTable") }}</option>
<option v-for="table in copySourceTables" :key="table.name" :value="table.name">{{ table.name }}</option>
</select>
</label>
<div v-if="copySourceTablesLoading" class="flex items-center gap-2 py-5 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
</div>
<div v-else-if="copySourceError" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{{ copySourceError }}
</div>
<div v-else-if="copySourceTables.length === 0" class="rounded-md border border-dashed px-3 py-5 text-center text-sm text-muted-foreground">
{{ t("structureEditor.copyColumnsNoSourceTables") }}
</div>
<template v-else-if="copySourceTableName">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium">{{ t("structureEditor.copyColumnsSelectFields") }}</span>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="copySourceColumnsLoading || copyableSourceColumnNames.length === 0" @click="toggleCopySourceColumns">
{{ allCopyableSourceColumnsSelected ? t("structureEditor.copyColumnsClearSelection") : t("structureEditor.copyColumnsSelectAll") }}
</Button>
</div>
<Input v-model="copySourceColumnSearch" :placeholder="t('structureEditor.copyColumnsSearchFields')" />
<div v-if="copySourceColumnsLoading" class="flex items-center gap-2 py-5 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
</div>
<div v-else-if="copySourceColumns.length === 0" class="rounded-md border border-dashed px-3 py-5 text-center text-sm text-muted-foreground">
{{ t("structureEditor.copyColumnsNoFields") }}
</div>
<div v-else-if="filteredCopyableSourceColumns.length === 0" class="rounded-md border border-dashed px-3 py-5 text-center text-sm text-muted-foreground">
{{ t("structureEditor.copyColumnsNoMatchingFields") }}
</div>
<div v-else class="max-h-72 overflow-y-auto rounded-md border">
<label v-for="{ column, alreadyExists } in filteredCopyableSourceColumns" :key="column.name" class="flex cursor-pointer items-center gap-2 border-b px-3 py-2 last:border-b-0 hover:bg-muted/50" :class="alreadyExists ? 'cursor-not-allowed opacity-60' : ''">
<input v-model="selectedCopySourceColumnNames" type="checkbox" :value="column.name" :disabled="alreadyExists" class="size-4 rounded border-input" />
<span class="min-w-0 flex-1 truncate font-mono text-sm">{{ column.name }}</span>
<span class="shrink-0 text-xs text-muted-foreground">{{ column.data_type }}</span>
<Badge v-if="alreadyExists" variant="secondary" class="shrink-0 text-[10px]">{{ t("structureEditor.copyColumnsAlreadyExists") }}</Badge>
</label>
</div>
</template>
</div>
<DialogFooter>
<Button variant="outline" @click="copyColumnsDialogOpen = false">{{ t("structureEditor.copyColumnsCancel") }}</Button>
<Button :disabled="copySourceColumnsLoading || selectedCopySourceColumns.length === 0" @click="applyCopiedColumns">
{{ t("structureEditor.copyColumnsApply", { count: selectedCopySourceColumns.length }) }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
+15
View File
@@ -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",
+15
View File
@@ -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",
+15
View File
@@ -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",
+15
View File
@@ -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: "プリセット列セット",
+15
View File
@@ -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: "미리 정의된 필드 세트",
+15
View File
@@ -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",
+15
View File
@@ -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: "预设字段组合",
+15
View File
@@ -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: "預設欄位組合",
@@ -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,
@@ -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<DatabaseConnectionInfo, "unquotedIdentifierCase" | "quotedIdentifierCase">;
const LOWER_UNQUOTED_MIXED_QUOTED_DATABASES = new Set<DatabaseType>(["postgres", "redshift", "opengauss", "gaussdb", "highgo", "uxdb"]);
const UPPER_UNQUOTED_MIXED_QUOTED_DATABASES = new Set<DatabaseType>(["oracle", "oceanbase-oracle", "dameng", "kingbase", "vastbase", "goldendb", "yashandb"]);
function defaultTableStructureIdentifierCaseInfo(databaseType?: DatabaseType): Required<TableStructureIdentifierCaseInfo> {
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<DatabaseConnectionInfo["unquotedIdentifierCase"]>): 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;