feat(structure): copy columns between tables
This commit is contained in:
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;
|
||||
|
||||
Reference in new issue
Block a user