feat(structure): copy columns between tables
This commit is contained in:
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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "プリセット列セット",
|
||||
|
||||
@@ -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: "미리 정의된 필드 세트",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "预设字段组合",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user