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

@@ -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;