1390 lines
48 KiB
TypeScript
1390 lines
48 KiB
TypeScript
import type { ColumnInfo, IndexInfo, ForeignKeyInfo, TriggerInfo, FunctionInfo, SequenceInfo, RuleInfo, OwnerInfo, DatabaseType, TableInfo } from "@/types/database";
|
|
import type { SchemaDiffTableMapping } from "@/types/schemaDiff";
|
|
import { splitSqlStatementRanges } from "@/lib/sql/sqlStatementRanges";
|
|
|
|
const DIALECT_KIND_MAP: Record<string, string> = {
|
|
mysql: "mysql",
|
|
doris: "mysql",
|
|
starrocks: "mysql",
|
|
goldendb: "mysql",
|
|
sundb: "mysql",
|
|
databend: "mysql",
|
|
gbase: "mysql",
|
|
postgres: "postgres",
|
|
gaussdb: "postgres",
|
|
kwdb: "postgres",
|
|
opengauss: "postgres",
|
|
highgo: "postgres",
|
|
vastbase: "postgres",
|
|
kingbase: "postgres",
|
|
firebird: "postgres",
|
|
redshift: "postgres",
|
|
vertica: "postgres",
|
|
exasol: "postgres",
|
|
sqlite: "sqlite",
|
|
rqlite: "sqlite",
|
|
turso: "sqlite",
|
|
duckdb: "duckdb",
|
|
sqlserver: "sql_server",
|
|
access: "sql_server",
|
|
oracle: "oracle",
|
|
dameng: "oracle",
|
|
"oceanbase-oracle": "oracle",
|
|
iris: "oracle",
|
|
yashandb: "oracle",
|
|
xugu: "oracle",
|
|
h2: "h2",
|
|
clickhouse: "click_house",
|
|
manticoresearch: "manticore_search",
|
|
informix: "informix",
|
|
questdb: "questdb",
|
|
};
|
|
|
|
export function databaseTypeToDialectKind(dbType: DatabaseType): string {
|
|
return DIALECT_KIND_MAP[dbType] ?? "unsupported";
|
|
}
|
|
|
|
const DIALECT_ALIAS_MAP: Record<string, string> = {
|
|
access: "sql_server",
|
|
mssql: "sql_server",
|
|
"sql server": "sql_server",
|
|
postgresql: "postgres",
|
|
sqlite3: "sqlite",
|
|
"oceanbase-oracle": "oracle",
|
|
oceanbase: "oracle",
|
|
dameng: "oracle",
|
|
iris: "oracle",
|
|
yashandb: "oracle",
|
|
xugu: "oracle",
|
|
gaussdb: "postgres",
|
|
kwdb: "postgres",
|
|
opengauss: "postgres",
|
|
highgo: "postgres",
|
|
vastbase: "postgres",
|
|
kingbase: "postgres",
|
|
firebird: "postgres",
|
|
redshift: "postgres",
|
|
vertica: "postgres",
|
|
exasol: "postgres",
|
|
doris: "mysql",
|
|
starrocks: "mysql",
|
|
goldendb: "mysql",
|
|
sundb: "mysql",
|
|
databend: "mysql",
|
|
gbase: "mysql",
|
|
rqlite: "sqlite",
|
|
turso: "sqlite",
|
|
manticore: "manticore_search",
|
|
questdb: "questdb",
|
|
clickhouse: "click_house",
|
|
};
|
|
|
|
export function normalizeDialectKind(input: string): string {
|
|
const lower = input.trim().toLowerCase();
|
|
if (DIALECT_KIND_MAP[lower]) return DIALECT_KIND_MAP[lower];
|
|
if (DIALECT_ALIAS_MAP[lower]) return DIALECT_ALIAS_MAP[lower];
|
|
return lower;
|
|
}
|
|
|
|
function levenshteinDistance(a: string, b: string): number {
|
|
const m = a.length;
|
|
const n = b.length;
|
|
if (m === 0) return n;
|
|
if (n === 0) return m;
|
|
let prev = Array.from({ length: n + 1 }, (_, i) => i);
|
|
for (let i = 1; i <= m; i++) {
|
|
const curr = [i];
|
|
for (let j = 1; j <= n; j++) {
|
|
curr[j] = a[i - 1] === b[j - 1] ? prev[j - 1] : Math.min(prev[j], curr[j - 1], prev[j - 1]) + 1;
|
|
}
|
|
prev = curr;
|
|
}
|
|
return prev[n];
|
|
}
|
|
|
|
function nameSimilarity(a: string, b: string): number {
|
|
const maxLen = Math.max(a.length, b.length);
|
|
if (maxLen === 0) return 1;
|
|
return 1 - levenshteinDistance(a, b) / maxLen;
|
|
}
|
|
|
|
export interface ColumnDiff {
|
|
type: "added" | "removed" | "modified" | "renamed";
|
|
name: string;
|
|
source?: ColumnInfo;
|
|
target?: ColumnInfo;
|
|
changes?: string[];
|
|
addPosition?: "first" | { after: string };
|
|
}
|
|
|
|
export interface IndexDiff {
|
|
type: "added" | "removed" | "modified";
|
|
name: string;
|
|
source?: IndexInfo;
|
|
target?: IndexInfo;
|
|
changes?: string[];
|
|
}
|
|
|
|
export interface ForeignKeyDiff {
|
|
type: "added" | "removed" | "modified";
|
|
name: string;
|
|
source?: ForeignKeyInfo;
|
|
target?: ForeignKeyInfo;
|
|
changes?: string[];
|
|
}
|
|
|
|
export interface TriggerDiff {
|
|
type: "added" | "removed" | "modified";
|
|
name: string;
|
|
source?: TriggerInfo;
|
|
target?: TriggerInfo;
|
|
changes?: string[];
|
|
}
|
|
|
|
export interface FunctionDiff {
|
|
type: "added" | "removed" | "modified";
|
|
name: string;
|
|
source?: FunctionInfo;
|
|
target?: FunctionInfo;
|
|
changes?: string[];
|
|
}
|
|
|
|
export interface SequenceDiff {
|
|
type: "added" | "removed" | "modified";
|
|
name: string;
|
|
source?: SequenceInfo;
|
|
target?: SequenceInfo;
|
|
changes?: string[];
|
|
}
|
|
|
|
export interface RuleDiff {
|
|
type: "added" | "removed" | "modified";
|
|
name: string;
|
|
source?: RuleInfo;
|
|
target?: RuleInfo;
|
|
changes?: string[];
|
|
}
|
|
|
|
export interface OwnerDiff {
|
|
type: "added" | "removed" | "modified";
|
|
objectName: string;
|
|
source?: OwnerInfo;
|
|
target?: OwnerInfo;
|
|
changes?: string[];
|
|
}
|
|
|
|
export interface TableDiff {
|
|
type: "added" | "removed" | "modified" | "renamed";
|
|
objectType?: "table" | "view";
|
|
name: string;
|
|
targetName?: string;
|
|
columns?: ColumnDiff[];
|
|
indexes?: IndexDiff[];
|
|
foreignKeys?: ForeignKeyDiff[];
|
|
triggers?: TriggerDiff[];
|
|
ddl?: string;
|
|
targetDdl?: string;
|
|
sourceTableComment?: string | null;
|
|
targetTableComment?: string | null;
|
|
syncSql?: string;
|
|
}
|
|
|
|
export interface TableSchemaDetail {
|
|
name: string;
|
|
columns?: ColumnInfo[];
|
|
indexes?: IndexInfo[];
|
|
foreignKeys?: ForeignKeyInfo[];
|
|
triggers?: TriggerInfo[];
|
|
ddl?: string;
|
|
}
|
|
|
|
export interface FieldMappingEntry {
|
|
sourceType: string;
|
|
targetType: string;
|
|
paramStrategy?: "preserve" | "strip" | "custom";
|
|
customParams?: string;
|
|
}
|
|
|
|
export interface SchemaDiffPreparationOptions {
|
|
sourceTables: TableInfo[];
|
|
targetTables: TableInfo[];
|
|
sourceDetails: TableSchemaDetail[];
|
|
targetDetails: TableSchemaDetail[];
|
|
sourceFunctions?: FunctionInfo[];
|
|
targetFunctions?: FunctionInfo[];
|
|
sourceSequences?: SequenceInfo[];
|
|
targetSequences?: SequenceInfo[];
|
|
sourceRules?: RuleInfo[];
|
|
targetRules?: RuleInfo[];
|
|
sourceOwners?: OwnerInfo[];
|
|
targetOwners?: OwnerInfo[];
|
|
databaseType: DatabaseType;
|
|
targetSchema?: string;
|
|
ignoreComments?: boolean;
|
|
cascadeDelete?: boolean;
|
|
compareColumnOrder?: boolean;
|
|
ignoreTableNameCase?: boolean;
|
|
ignoreColumnNameCase?: boolean;
|
|
detectRenames?: boolean;
|
|
detectTableRenames?: boolean;
|
|
renameThreshold?: number;
|
|
enableRollback?: boolean;
|
|
batchPatterns?: string[];
|
|
sourceDialect?: string;
|
|
targetDialect?: string;
|
|
compatibilityThreshold?: number;
|
|
fieldMappings?: FieldMappingEntry[];
|
|
tableMappings?: SchemaDiffTableMapping[];
|
|
}
|
|
|
|
export interface RenameCandidate {
|
|
sourceName: string;
|
|
targetName: string;
|
|
score: number;
|
|
}
|
|
|
|
export interface CompatibilityWarning {
|
|
table: string;
|
|
column: string;
|
|
sourceType: string;
|
|
targetType: string;
|
|
risk: string;
|
|
message: string;
|
|
}
|
|
|
|
export interface PermissionDiff {
|
|
objectName: string;
|
|
permissionType: string;
|
|
sourcePermission: string | null;
|
|
targetPermission: string | null;
|
|
}
|
|
|
|
export interface DependencyNode {
|
|
tableName: string;
|
|
dependsOn: string[];
|
|
dependedBy: string[];
|
|
}
|
|
|
|
export interface DependencyGraph {
|
|
nodes: DependencyNode[];
|
|
}
|
|
|
|
interface RawSchemaDiffDependencyNode {
|
|
tableName?: unknown;
|
|
table_name?: unknown;
|
|
dependsOn?: unknown;
|
|
depends_on?: unknown;
|
|
dependedBy?: unknown;
|
|
depended_by?: unknown;
|
|
}
|
|
|
|
interface RawSchemaDiffDependencyGraph {
|
|
nodes?: RawSchemaDiffDependencyNode[] | Record<string, RawSchemaDiffDependencyNode>;
|
|
}
|
|
|
|
export function normalizeSchemaDiffDependencyGraph(graph: unknown): DependencyGraph | null {
|
|
if (!graph || typeof graph !== "object") return null;
|
|
|
|
const rawNodes = (graph as RawSchemaDiffDependencyGraph).nodes;
|
|
const entries = Array.isArray(rawNodes) ? rawNodes : rawNodes && typeof rawNodes === "object" ? Object.values(rawNodes) : [];
|
|
const nodes = entries.flatMap((node) => {
|
|
const tableName = typeof node.tableName === "string" ? node.tableName : typeof node.table_name === "string" ? node.table_name : "";
|
|
if (!tableName) return [];
|
|
|
|
const dependsOn = Array.isArray(node.dependsOn) ? node.dependsOn : Array.isArray(node.depends_on) ? node.depends_on : [];
|
|
const dependedBy = Array.isArray(node.dependedBy) ? node.dependedBy : Array.isArray(node.depended_by) ? node.depended_by : [];
|
|
return [
|
|
{
|
|
tableName,
|
|
dependsOn: dependsOn.filter((name): name is string => typeof name === "string"),
|
|
dependedBy: dependedBy.filter((name): name is string => typeof name === "string"),
|
|
},
|
|
];
|
|
});
|
|
|
|
return nodes.length > 0 ? { nodes } : null;
|
|
}
|
|
|
|
export interface MissingRollbackObject {
|
|
kind: string;
|
|
name: string;
|
|
table?: string;
|
|
reason: string;
|
|
}
|
|
|
|
export type RollbackCompleteness = "complete" | "incomplete";
|
|
|
|
export interface SchemaDiffPreparation {
|
|
diffs: TableDiff[];
|
|
functionDiffs?: FunctionDiff[];
|
|
sequenceDiffs?: SequenceDiff[];
|
|
ruleDiffs?: RuleDiff[];
|
|
ownerDiffs?: OwnerDiff[];
|
|
syncSql: string;
|
|
rollbackSyncSql?: string;
|
|
rollbackCompleteness?: RollbackCompleteness;
|
|
missingRollbackObjects?: MissingRollbackObject[];
|
|
renameCandidates?: RenameCandidate[];
|
|
rollbackGraph?: unknown;
|
|
compatibilityWarnings?: CompatibilityWarning[];
|
|
permissionDiffs?: PermissionDiff[];
|
|
permissionSyncSql?: string;
|
|
dependencyGraph?: DependencyGraph;
|
|
}
|
|
|
|
export interface SchemaSyncSqlPlan {
|
|
syncSql: string;
|
|
rollbackSyncSql?: string;
|
|
rollbackCompleteness: RollbackCompleteness;
|
|
missingRollbackObjects: MissingRollbackObject[];
|
|
}
|
|
|
|
export interface GenerateSchemaSyncPlanOptions {
|
|
databaseType: DatabaseType;
|
|
targetSchema?: string;
|
|
cascadeDelete?: boolean;
|
|
sourceDialect?: string;
|
|
fieldMappings?: FieldMappingEntry[];
|
|
enableRollback?: boolean;
|
|
}
|
|
|
|
const MYSQL_LIKE_SCHEMA_DIFF_TARGET_TYPES = new Set<DatabaseType>(["mysql", "doris", "starrocks", "goldendb", "sundb", "databend", "gbase"]);
|
|
|
|
export function schemaDiffDeployTargetSchema(databaseType: DatabaseType | undefined, targetDatabase: string, targetSchema?: string): string | undefined {
|
|
const schema = targetSchema?.trim();
|
|
if (schema) return schema;
|
|
|
|
const database = targetDatabase.trim();
|
|
if (databaseType && MYSQL_LIKE_SCHEMA_DIFF_TARGET_TYPES.has(databaseType) && database) {
|
|
return database;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
// Unified object type for UI display
|
|
export type DiffOperationType = "modify" | "create" | "delete" | "none";
|
|
export type DiffObjectKind = "table" | "view" | "function" | "sequence" | "rule" | "owner" | "column" | "index" | "trigger" | "foreignKey" | "tableOption";
|
|
|
|
export interface SchemaDiffObject {
|
|
id: string;
|
|
operationType: DiffOperationType;
|
|
objectKind: DiffObjectKind;
|
|
name: string;
|
|
sourceName?: string;
|
|
targetName?: string;
|
|
selected: boolean;
|
|
sourceDdl?: string;
|
|
targetDdl?: string;
|
|
deploySql?: string;
|
|
rollbackDdl?: string;
|
|
changes?: string[];
|
|
children?: SchemaDiffObject[];
|
|
parentId?: string;
|
|
parentName?: string;
|
|
/** Function arguments signature (for PostgreSQL overloaded functions) */
|
|
arguments?: string;
|
|
/** PROCEDURE vs FUNCTION when objectKind is function (for getObjectSource). */
|
|
routineType?: "PROCEDURE" | "FUNCTION";
|
|
renameMetadata?: {
|
|
confirmed: boolean;
|
|
sourceName?: string;
|
|
targetName?: string;
|
|
score?: number;
|
|
};
|
|
}
|
|
|
|
function tableObjectId(tableName: string): string {
|
|
return `table-${tableName}`;
|
|
}
|
|
|
|
type SchemaDiffChildKind = "column" | "index" | "foreignKey" | "trigger";
|
|
|
|
const SCHEMA_DIFF_CHILD_ID_PREFIX: Record<SchemaDiffChildKind, string> = {
|
|
column: "col",
|
|
index: "idx",
|
|
foreignKey: "fk",
|
|
trigger: "trg",
|
|
};
|
|
|
|
function diffChildObjectId(kind: SchemaDiffChildKind, tableName: string, childName: string, siblings: readonly { name: string }[], index: number): string {
|
|
const baseId = `${SCHEMA_DIFF_CHILD_ID_PREFIX[kind]}-${tableName}-${childName}`;
|
|
const duplicateCount = siblings.filter((sibling) => sibling.name === childName).length;
|
|
if (duplicateCount < 2) return baseId;
|
|
|
|
const occurrence = siblings.slice(0, index).filter((sibling) => sibling.name === childName).length;
|
|
return `${baseId}-${occurrence}`;
|
|
}
|
|
|
|
function findDiffChildIndex(kind: SchemaDiffChildKind, tableName: string, siblings: readonly { name: string }[] | undefined, objectId: string): number {
|
|
return (siblings ?? []).findIndex((sibling, index) => diffChildObjectId(kind, tableName, sibling.name, siblings ?? [], index) === objectId);
|
|
}
|
|
|
|
function tableOptionObjectId(tableName: string): string {
|
|
return `table-option-${tableName}`;
|
|
}
|
|
|
|
export interface SchemaDiffGroup {
|
|
operationType: DiffOperationType;
|
|
label: string;
|
|
count: number;
|
|
selectedCount: number;
|
|
expanded: boolean;
|
|
objects: SchemaDiffObject[];
|
|
}
|
|
|
|
export interface DestructiveSchemaDiffStatement {
|
|
action: "drop" | "truncate";
|
|
objectType: string;
|
|
statement: string;
|
|
}
|
|
|
|
function stripSqlCommentsAndStringLiterals(sql: string): string {
|
|
let result = "";
|
|
let index = 0;
|
|
let state: "plain" | "single" | "double" | "backtick" | "bracket" | "line" | "block" = "plain";
|
|
|
|
while (index < sql.length) {
|
|
const current = sql[index];
|
|
const next = sql[index + 1];
|
|
|
|
if (state === "line") {
|
|
if (current === "\n") {
|
|
state = "plain";
|
|
result += "\n";
|
|
} else {
|
|
result += " ";
|
|
}
|
|
index++;
|
|
continue;
|
|
}
|
|
|
|
if (state === "block") {
|
|
if (current === "*" && next === "/") {
|
|
result += " ";
|
|
state = "plain";
|
|
index += 2;
|
|
} else {
|
|
result += current === "\n" ? "\n" : " ";
|
|
index++;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (state === "single" || state === "double" || state === "backtick") {
|
|
result += current === "\n" ? "\n" : " ";
|
|
const delimiter = state === "single" ? "'" : state === "double" ? '"' : "`";
|
|
if (current === delimiter && next === delimiter) {
|
|
result += " ";
|
|
index += 2;
|
|
} else {
|
|
if (current === delimiter) state = "plain";
|
|
index++;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (state === "bracket") {
|
|
result += current === "\n" ? "\n" : " ";
|
|
if (current === "]" && next === "]") {
|
|
result += " ";
|
|
index += 2;
|
|
} else {
|
|
if (current === "]") state = "plain";
|
|
index++;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (current === "-" && next === "-") {
|
|
result += " ";
|
|
state = "line";
|
|
index += 2;
|
|
continue;
|
|
}
|
|
if (current === "#") {
|
|
result += " ";
|
|
state = "line";
|
|
index++;
|
|
continue;
|
|
}
|
|
if (current === "/" && next === "*") {
|
|
result += " ";
|
|
state = "block";
|
|
index += 2;
|
|
continue;
|
|
}
|
|
if (current === "'") {
|
|
result += " ";
|
|
state = "single";
|
|
index++;
|
|
continue;
|
|
}
|
|
if (current === '"') {
|
|
result += " ";
|
|
state = "double";
|
|
index++;
|
|
continue;
|
|
}
|
|
if (current === "`") {
|
|
result += " ";
|
|
state = "backtick";
|
|
index++;
|
|
continue;
|
|
}
|
|
if (current === "[") {
|
|
result += " ";
|
|
state = "bracket";
|
|
index++;
|
|
continue;
|
|
}
|
|
|
|
result += current;
|
|
index++;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
export function detectDestructiveSchemaDiffStatements(sql: string, databaseType?: DatabaseType): DestructiveSchemaDiffStatement[] {
|
|
const statements = splitSqlStatementRanges(sql, databaseType);
|
|
const destructive: DestructiveSchemaDiffStatement[] = [];
|
|
|
|
for (const range of statements) {
|
|
const statement = range.sql.trim().replace(/;\s*$/, "");
|
|
const normalized = stripSqlCommentsAndStringLiterals(statement).trim();
|
|
const topLevel = normalized.match(/^DROP\s+(?:TEMPORARY\s+)?(MATERIALIZED\s+VIEW|FOREIGN\s+TABLE|EVENT\s+TRIGGER|USER\s+MAPPING|OWNED\s+BY|[A-Z_]+)\b/i);
|
|
if (topLevel) {
|
|
destructive.push({ action: "drop", objectType: topLevel[1].toUpperCase(), statement });
|
|
continue;
|
|
}
|
|
|
|
if (/^TRUNCATE\b/i.test(normalized)) {
|
|
destructive.push({ action: "truncate", objectType: "TABLE", statement });
|
|
continue;
|
|
}
|
|
|
|
if (/^ALTER\b/i.test(normalized)) {
|
|
const dropClauses = normalized.matchAll(/\bDROP\s+(COLUMN|CONSTRAINT|INDEX|KEY|PRIMARY\s+KEY|FOREIGN\s+KEY|PARTITION|DEFAULT)\b/gi);
|
|
for (const match of dropClauses) {
|
|
destructive.push({ action: "drop", objectType: match[1].toUpperCase(), statement });
|
|
}
|
|
}
|
|
}
|
|
|
|
return destructive;
|
|
}
|
|
|
|
export function getOperationType(diffType: string): DiffOperationType {
|
|
switch (diffType) {
|
|
case "modified":
|
|
case "renamed":
|
|
return "modify";
|
|
case "added":
|
|
return "create";
|
|
case "removed":
|
|
return "delete";
|
|
default:
|
|
return "none";
|
|
}
|
|
}
|
|
|
|
export function getOperationLabel(operationType: DiffOperationType): string {
|
|
switch (operationType) {
|
|
case "modify":
|
|
return "diff.operationLabel.modify";
|
|
case "create":
|
|
return "diff.operationLabel.create";
|
|
case "delete":
|
|
return "diff.operationLabel.delete";
|
|
case "none":
|
|
return "diff.operationLabel.none";
|
|
}
|
|
}
|
|
|
|
function buildSequenceDdl(seq: SequenceInfo): string {
|
|
const parts = [`CREATE SEQUENCE ${seq.name}`];
|
|
if (seq.data_type) parts.push(` AS ${seq.data_type}`);
|
|
if (seq.start_value != null) parts.push(` START WITH ${seq.start_value}`);
|
|
if (seq.increment != null) parts.push(` INCREMENT BY ${seq.increment}`);
|
|
if (seq.min_value != null) parts.push(` MINVALUE ${seq.min_value}`);
|
|
if (seq.max_value != null) parts.push(` MAXVALUE ${seq.max_value}`);
|
|
else parts.push(` NO MAXVALUE`);
|
|
parts.push(` ${seq.cycle ? "" : "NO "}CYCLE`);
|
|
parts.push(`;`);
|
|
if (seq.last_value != null) {
|
|
parts.push(`SELECT setval('${seq.name}', ${seq.last_value});`);
|
|
}
|
|
return parts.join("\n");
|
|
}
|
|
|
|
export function convertToSchemaDiffObjects(tableDiffs: TableDiff[], functionDiffs: FunctionDiff[] = [], sequenceDiffs: SequenceDiff[] = [], ruleDiffs: RuleDiff[] = [], ownerDiffs: OwnerDiff[] = [], renameCandidates?: RenameCandidate[]): SchemaDiffObject[] {
|
|
const objects: SchemaDiffObject[] = [];
|
|
|
|
for (const diff of tableDiffs) {
|
|
const opType = getOperationType(diff.type);
|
|
const isRenamed = diff.type === "renamed";
|
|
const newName = isRenamed && renameCandidates ? (renameCandidates.find((rc) => rc.sourceName === diff.name)?.targetName ?? diff.name) : undefined;
|
|
const tableId = tableObjectId(diff.name);
|
|
const columns = diff.columns ?? [];
|
|
const indexes = diff.indexes ?? [];
|
|
const foreignKeys = diff.foreignKeys ?? [];
|
|
const triggers = diff.triggers ?? [];
|
|
const children: SchemaDiffObject[] =
|
|
opType === "modify"
|
|
? [
|
|
...columns.map((column, index) => ({
|
|
id: diffChildObjectId("column", diff.name, column.name, columns, index),
|
|
operationType: getOperationType(column.type),
|
|
objectKind: "column" as DiffObjectKind,
|
|
name: column.name,
|
|
sourceName: column.type === "added" ? undefined : column.name,
|
|
targetName: column.type === "removed" ? undefined : column.name,
|
|
selected: true,
|
|
changes: column.changes,
|
|
parentId: tableId,
|
|
parentName: diff.name,
|
|
})),
|
|
...indexes.map((index, childIndex) => ({
|
|
id: diffChildObjectId("index", diff.name, index.name, indexes, childIndex),
|
|
// A modified index is one DROP + CREATE deploy unit.
|
|
operationType: index.type === "modified" ? ("delete" as DiffOperationType) : getOperationType(index.type),
|
|
objectKind: "index" as DiffObjectKind,
|
|
name: index.name,
|
|
sourceName: index.type === "added" ? undefined : index.name,
|
|
targetName: index.type === "removed" ? undefined : index.name,
|
|
selected: true,
|
|
changes: index.changes,
|
|
parentId: tableId,
|
|
parentName: diff.name,
|
|
})),
|
|
...foreignKeys.map((foreignKey, index) => ({
|
|
id: diffChildObjectId("foreignKey", diff.name, foreignKey.name, foreignKeys, index),
|
|
// Modified foreign keys are also dropped before they are recreated.
|
|
operationType: foreignKey.type === "modified" ? ("delete" as DiffOperationType) : getOperationType(foreignKey.type),
|
|
objectKind: "foreignKey" as DiffObjectKind,
|
|
name: foreignKey.name,
|
|
sourceName: foreignKey.type === "added" ? undefined : foreignKey.name,
|
|
targetName: foreignKey.type === "removed" ? undefined : foreignKey.name,
|
|
selected: true,
|
|
changes: foreignKey.changes,
|
|
parentId: tableId,
|
|
parentName: diff.name,
|
|
})),
|
|
...triggers.map((trigger, index) => ({
|
|
id: diffChildObjectId("trigger", diff.name, trigger.name, triggers, index),
|
|
operationType: getOperationType(trigger.type),
|
|
objectKind: "trigger" as DiffObjectKind,
|
|
name: trigger.name,
|
|
sourceName: trigger.type === "added" ? undefined : trigger.name,
|
|
targetName: trigger.type === "removed" ? undefined : trigger.name,
|
|
selected: true,
|
|
changes: trigger.changes,
|
|
parentId: tableId,
|
|
parentName: diff.name,
|
|
})),
|
|
...(diff.sourceTableComment !== diff.targetTableComment
|
|
? [
|
|
{
|
|
id: tableOptionObjectId(diff.name),
|
|
operationType: "modify" as DiffOperationType,
|
|
objectKind: "tableOption" as DiffObjectKind,
|
|
name: "tableOption",
|
|
selected: true,
|
|
changes: [`comment: ${diff.targetTableComment ?? ""} -> ${diff.sourceTableComment ?? ""}`],
|
|
parentId: tableId,
|
|
parentName: diff.name,
|
|
},
|
|
]
|
|
: []),
|
|
]
|
|
: [];
|
|
|
|
const obj: SchemaDiffObject = {
|
|
id: tableId,
|
|
operationType: opType,
|
|
objectKind: diff.objectType === "view" ? "view" : "table",
|
|
name: diff.name,
|
|
sourceName: diff.type === "added" ? undefined : diff.name,
|
|
targetName: diff.type === "removed" ? undefined : isRenamed ? newName : (diff.targetName ?? diff.name),
|
|
selected: opType !== "none",
|
|
sourceDdl: diff.ddl,
|
|
targetDdl: diff.targetDdl,
|
|
deploySql: isRenamed && newName ? (diff.objectType === "view" ? `ALTER VIEW ${diff.name} RENAME TO ${newName};` : `RENAME TABLE ${diff.name} TO ${newName};`) : diff.syncSql,
|
|
changes: diff.columns?.flatMap((c) => c.changes || []),
|
|
renameMetadata: isRenamed && newName ? { confirmed: true, sourceName: diff.name, targetName: newName, score: renameCandidates?.find((rc) => rc.sourceName === diff.name)?.score } : undefined,
|
|
children: children.length > 0 ? children : undefined,
|
|
};
|
|
objects.push(obj);
|
|
}
|
|
|
|
for (const diff of functionDiffs) {
|
|
const args = diff.source?.arguments || diff.target?.arguments || "";
|
|
const functionType = (diff.source?.function_type || diff.target?.function_type || "").toUpperCase();
|
|
const routineType: "PROCEDURE" | "FUNCTION" = functionType.includes("PROC") ? "PROCEDURE" : "FUNCTION";
|
|
objects.push({
|
|
id: `func-${diff.name}-${args}`,
|
|
operationType: getOperationType(diff.type),
|
|
objectKind: "function",
|
|
name: diff.name,
|
|
arguments: args,
|
|
routineType,
|
|
sourceName: diff.type === "added" ? undefined : diff.name,
|
|
targetName: diff.type === "removed" ? undefined : diff.name,
|
|
selected: true,
|
|
sourceDdl: diff.source?.definition,
|
|
targetDdl: diff.target?.definition,
|
|
changes: diff.changes,
|
|
});
|
|
}
|
|
|
|
for (const diff of sequenceDiffs) {
|
|
objects.push({
|
|
id: `seq-${diff.name}`,
|
|
operationType: getOperationType(diff.type),
|
|
objectKind: "sequence",
|
|
name: diff.name,
|
|
sourceName: diff.type === "added" ? undefined : diff.name,
|
|
targetName: diff.type === "removed" ? undefined : diff.name,
|
|
selected: true,
|
|
sourceDdl: diff.source ? buildSequenceDdl(diff.source) : undefined,
|
|
targetDdl: diff.target ? buildSequenceDdl(diff.target) : undefined,
|
|
changes: diff.changes,
|
|
});
|
|
}
|
|
|
|
for (const diff of ruleDiffs) {
|
|
objects.push({
|
|
id: `rule-${diff.name}`,
|
|
operationType: getOperationType(diff.type),
|
|
objectKind: "rule",
|
|
name: diff.name,
|
|
sourceName: diff.type === "added" ? undefined : diff.name,
|
|
targetName: diff.type === "removed" ? undefined : diff.name,
|
|
selected: true,
|
|
changes: diff.changes,
|
|
});
|
|
}
|
|
|
|
for (const diff of ownerDiffs) {
|
|
objects.push({
|
|
id: `owner-${diff.objectName}`,
|
|
operationType: getOperationType(diff.type),
|
|
objectKind: "owner",
|
|
name: diff.objectName,
|
|
sourceName: diff.type === "added" ? undefined : diff.objectName,
|
|
targetName: diff.type === "removed" ? undefined : diff.objectName,
|
|
selected: true,
|
|
changes: diff.changes,
|
|
});
|
|
}
|
|
|
|
// Pre-mark rename candidates on diff objects (for UI display before user confirms)
|
|
if (renameCandidates && renameCandidates.length > 0) {
|
|
for (const rc of renameCandidates) {
|
|
for (const obj of objects) {
|
|
// Backend-detected renames: diff_type = "renamed", already has metadata set above
|
|
if (obj.renameMetadata) continue;
|
|
// Legacy: mark rename candidates on delete+create pairs (fallback for older backends)
|
|
if (obj.operationType === "delete" && obj.name === rc.sourceName) {
|
|
obj.renameMetadata = { confirmed: false, targetName: rc.targetName, score: rc.score };
|
|
}
|
|
if (obj.operationType === "create" && obj.name === rc.targetName) {
|
|
obj.renameMetadata = { confirmed: false, sourceName: rc.sourceName, score: rc.score };
|
|
obj.sourceName = rc.sourceName;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return objects;
|
|
}
|
|
|
|
export interface SelectedSchemaDiffInput {
|
|
diffs: TableDiff[];
|
|
functionDiffs: FunctionDiff[];
|
|
sequenceDiffs: SequenceDiff[];
|
|
ruleDiffs: RuleDiff[];
|
|
ownerDiffs: OwnerDiff[];
|
|
}
|
|
|
|
/** Project the original structured diff onto the current result-tree selection. */
|
|
export function selectSchemaDiffInput(result: SchemaDiffPreparation, objects: SchemaDiffObject[]): SelectedSchemaDiffInput {
|
|
const selectedIds = new Set(
|
|
flattenSchemaDiffObjects(objects)
|
|
.filter((object) => object.selected && object.operationType !== "none")
|
|
.map((object) => object.id),
|
|
);
|
|
|
|
const diffs = result.diffs.flatMap((diff): TableDiff[] => {
|
|
const tableObject = findSchemaDiffObject(objects, tableObjectId(diff.name));
|
|
if (!tableObject) return [];
|
|
|
|
const isAtomic = diff.type !== "modified" || diff.objectType === "view" || !tableObject.children?.length;
|
|
if (isAtomic) {
|
|
return tableObject.selected ? [{ ...diff, syncSql: undefined }] : [];
|
|
}
|
|
|
|
const columns = diff.columns?.filter((column, index) => selectedIds.has(diffChildObjectId("column", diff.name, column.name, diff.columns ?? [], index))) ?? [];
|
|
const indexes = diff.indexes?.filter((index, childIndex) => selectedIds.has(diffChildObjectId("index", diff.name, index.name, diff.indexes ?? [], childIndex))) ?? [];
|
|
const foreignKeys = diff.foreignKeys?.filter((foreignKey, index) => selectedIds.has(diffChildObjectId("foreignKey", diff.name, foreignKey.name, diff.foreignKeys ?? [], index))) ?? [];
|
|
const triggers = diff.triggers?.filter((trigger, index) => selectedIds.has(diffChildObjectId("trigger", diff.name, trigger.name, diff.triggers ?? [], index))) ?? [];
|
|
const includeTableOptions = selectedIds.has(tableOptionObjectId(diff.name));
|
|
|
|
if (columns.length === 0 && indexes.length === 0 && foreignKeys.length === 0 && triggers.length === 0 && !includeTableOptions) {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
{
|
|
...diff,
|
|
columns,
|
|
indexes,
|
|
foreignKeys,
|
|
triggers,
|
|
sourceTableComment: includeTableOptions ? diff.sourceTableComment : undefined,
|
|
targetTableComment: includeTableOptions ? diff.targetTableComment : undefined,
|
|
syncSql: undefined,
|
|
},
|
|
];
|
|
});
|
|
|
|
return {
|
|
diffs,
|
|
functionDiffs: (result.functionDiffs ?? []).filter((diff) => selectedIds.has(`func-${diff.name}-${diff.source?.arguments || diff.target?.arguments || ""}`)),
|
|
sequenceDiffs: (result.sequenceDiffs ?? []).filter((diff) => selectedIds.has(`seq-${diff.name}`)),
|
|
ruleDiffs: (result.ruleDiffs ?? []).filter((diff) => selectedIds.has(`rule-${diff.name}`)),
|
|
ownerDiffs: (result.ownerDiffs ?? []).filter((diff) => selectedIds.has(`owner-${diff.objectName}`)),
|
|
};
|
|
}
|
|
|
|
function projectSchemaDiffObjectSelection(object: SchemaDiffObject, focusedObjectId: string, selectDescendants: boolean, dependencyNames: ReadonlySet<string>): SchemaDiffObject {
|
|
const isFocused = object.id === focusedObjectId;
|
|
const isDependency = !object.parentId && dependencyNames.has(object.name);
|
|
const selectChildren = selectDescendants || isFocused || isDependency;
|
|
return {
|
|
...object,
|
|
selected: isFocused || selectDescendants || isDependency,
|
|
children: object.children?.map((child) => projectSchemaDiffObjectSelection(child, focusedObjectId, selectChildren, dependencyNames)),
|
|
};
|
|
}
|
|
|
|
function schemaDiffDependencyClosure(result: SchemaDiffPreparation, objects: SchemaDiffObject[], objectId: string): Set<string> {
|
|
const focusedObject = findSchemaDiffObject(objects, objectId);
|
|
if (!focusedObject) return new Set();
|
|
|
|
const rootObject = focusedObject.parentId ? findSchemaDiffObject(objects, focusedObject.parentId) : focusedObject;
|
|
if (!rootObject) return new Set();
|
|
|
|
const dependencyNodes = new Map((normalizeSchemaDiffDependencyGraph(result.dependencyGraph)?.nodes ?? []).map((node) => [node.tableName, node]));
|
|
const dependencies = new Set<string>();
|
|
const pending = [rootObject.name];
|
|
while (pending.length > 0) {
|
|
const name = pending.pop();
|
|
if (!name) continue;
|
|
for (const dependency of dependencyNodes.get(name)?.dependsOn ?? []) {
|
|
if (dependency === rootObject.name || dependencies.has(dependency)) continue;
|
|
dependencies.add(dependency);
|
|
pending.push(dependency);
|
|
}
|
|
}
|
|
return dependencies;
|
|
}
|
|
|
|
/** Project one focused tree object onto a fresh selection without mutating checkbox state. */
|
|
export function selectSchemaDiffInputForObject(result: SchemaDiffPreparation, objects: SchemaDiffObject[], objectId: string): SelectedSchemaDiffInput {
|
|
if (!findSchemaDiffObject(objects, objectId)) {
|
|
return { diffs: [], functionDiffs: [], sequenceDiffs: [], ruleDiffs: [], ownerDiffs: [] };
|
|
}
|
|
|
|
const dependencyNames = schemaDiffDependencyClosure(result, objects, objectId);
|
|
const focusedSelection = objects.map((object) => projectSchemaDiffObjectSelection(object, objectId, false, dependencyNames));
|
|
return selectSchemaDiffInput(result, focusedSelection);
|
|
}
|
|
|
|
export function buildDeploySqlForObjects(objects: SchemaDiffObject[]): string {
|
|
const selected = objects.filter((o) => {
|
|
return o.selected && o.operationType !== "none" && !o.parentId;
|
|
});
|
|
|
|
if (selected.length === 0) {
|
|
return "-- No objects selected";
|
|
}
|
|
|
|
const lines: string[] = [];
|
|
|
|
for (const obj of selected) {
|
|
if (obj.deploySql?.trim()) {
|
|
lines.push(obj.deploySql.trim());
|
|
lines.push("");
|
|
continue;
|
|
}
|
|
|
|
if (obj.operationType === "create") {
|
|
if (obj.sourceDdl) {
|
|
lines.push(`-- Create ${obj.objectKind}: ${obj.name}`);
|
|
lines.push(obj.sourceDdl);
|
|
lines.push("");
|
|
}
|
|
} else if (obj.operationType === "delete") {
|
|
lines.push(`-- Drop ${obj.objectKind}: ${obj.name}`);
|
|
const dropSql = generateDropSql(obj);
|
|
lines.push(dropSql);
|
|
lines.push("");
|
|
} else if (obj.operationType === "modify") {
|
|
if (obj.sourceDdl) {
|
|
lines.push(`-- Modify ${obj.objectKind}: ${obj.name}`);
|
|
lines.push(obj.sourceDdl);
|
|
lines.push("");
|
|
}
|
|
}
|
|
}
|
|
|
|
return lines.join("\n") || "-- No DDL available for selected objects";
|
|
}
|
|
|
|
function generateDropSql(obj: SchemaDiffObject): string {
|
|
const typeMap: Record<string, string> = {
|
|
table: "TABLE",
|
|
view: "VIEW",
|
|
function: "FUNCTION",
|
|
sequence: "SEQUENCE",
|
|
rule: "RULE",
|
|
owner: "OWNED BY",
|
|
};
|
|
const sqlType = typeMap[obj.objectKind] || obj.objectKind.toUpperCase();
|
|
return `DROP ${sqlType} IF EXISTS ${obj.name};`;
|
|
}
|
|
|
|
/** Detect column renames in raw SQL and replace DROP+ADD with RENAME COLUMN. */
|
|
export function injectColumnRenameSql(sql: string, diffs: TableDiff[], threshold: number, reverse = false): string {
|
|
if (!sql || !threshold) return sql;
|
|
|
|
// Build rename pairs: for each table, match removed columns with added columns by similarity
|
|
const replacements: { table: string; oldName: string; newName: string }[] = [];
|
|
for (const diff of diffs) {
|
|
if (!diff.columns || diff.type !== "modified") continue;
|
|
const removedCols = diff.columns.filter((c) => c.type === "removed");
|
|
const addedCols = diff.columns.filter((c) => c.type === "added");
|
|
if (removedCols.length === 0 || addedCols.length === 0) continue;
|
|
|
|
const used = new Set<string>();
|
|
for (const rc of removedCols) {
|
|
let best: (typeof addedCols)[0] | null = null;
|
|
let bestSim = 0;
|
|
for (const ac of addedCols) {
|
|
if (used.has(ac.name)) continue;
|
|
const sim = nameSimilarity(rc.name, ac.name);
|
|
if (sim > bestSim) {
|
|
bestSim = sim;
|
|
best = ac;
|
|
}
|
|
}
|
|
if (best && bestSim >= threshold) {
|
|
// rc = removed column (exists in source, NOT in target → needs to be ADDED to target)
|
|
// best = added column (exists in target, NOT in source → needs to be DROPPED from target)
|
|
// To sync target → source: rename target's "best.name" to source's "rc.name"
|
|
replacements.push({ table: diff.targetName ?? diff.name, oldName: best.name, newName: rc.name });
|
|
used.add(best.name);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (replacements.length === 0) return sql;
|
|
|
|
// Process each ALTER TABLE block
|
|
const lines = sql.split("\n");
|
|
const out: string[] = [];
|
|
let currentTable = "";
|
|
let inAlter = false;
|
|
let alterStart = -1;
|
|
const tableRenames = new Map<string, typeof replacements>();
|
|
|
|
for (const r of replacements) {
|
|
const list = tableRenames.get(r.table) || [];
|
|
list.push(r);
|
|
tableRenames.set(r.table, list);
|
|
}
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
const trimmed = line.trim();
|
|
|
|
// Detect ALTER TABLE <table>
|
|
const alterMatch = trimmed.match(/^ALTER\s+TABLE\s+(?:`[^`]+`\.)?`?(\w+)`?/i);
|
|
if (alterMatch && !inAlter) {
|
|
currentTable = alterMatch[1];
|
|
const renames = tableRenames.get(currentTable);
|
|
if (renames) {
|
|
inAlter = true;
|
|
alterStart = i;
|
|
continue; // skip current ALTER TABLE line, we'll rebuild it
|
|
}
|
|
}
|
|
|
|
if (!inAlter) {
|
|
out.push(line);
|
|
continue;
|
|
}
|
|
|
|
// Inside an ALTER TABLE block being rewritten
|
|
const endOfAlter = trimmed === ";" || trimmed.endsWith(";") || (i + 1 < lines.length && lines[i + 1].trim().toUpperCase().startsWith("ALTER")) || i === lines.length - 1;
|
|
|
|
if (endOfAlter) {
|
|
const renames = tableRenames.get(currentTable)!;
|
|
// Emit RENAME COLUMN statements
|
|
out.push(`-- Alter table: ${currentTable} (column renames detected)`);
|
|
for (const r of renames) {
|
|
const fromName = reverse ? r.newName : r.oldName;
|
|
const toName = reverse ? r.oldName : r.newName;
|
|
out.push(`ALTER TABLE ${currentTable} RENAME COLUMN ${fromName} TO ${toName};`);
|
|
}
|
|
// Collect remaining ALTER clauses (non-renamed columns)
|
|
const remaining: string[] = [];
|
|
for (let j = alterStart + 1; j <= i; j++) {
|
|
const l = lines[j].trim();
|
|
if (!l || l === ";") continue;
|
|
// Forward: ADD for newName, DROP for oldName. Reverse: ADD for oldName, DROP for newName.
|
|
const isAddRename = l.toUpperCase().startsWith("ADD COLUMN") && renames.some((r) => l.includes(reverse ? r.oldName : r.newName));
|
|
const isDropRename = l.toUpperCase().startsWith("DROP COLUMN") && renames.some((r) => l.includes(reverse ? r.newName : r.oldName));
|
|
if (isAddRename || isDropRename) continue;
|
|
// Clean trailing comma if next line is removed
|
|
let cleaned = l;
|
|
let nextIdx = j + 1;
|
|
while (nextIdx <= i) {
|
|
const nextLine = lines[nextIdx].trim();
|
|
if (!nextLine) {
|
|
nextIdx++;
|
|
continue;
|
|
}
|
|
const nextIsAddRename = nextLine.toUpperCase().startsWith("ADD COLUMN") && renames.some((r) => nextLine.includes(reverse ? r.oldName : r.newName));
|
|
const nextIsDropRename = nextLine.toUpperCase().startsWith("DROP COLUMN") && renames.some((r) => nextLine.includes(reverse ? r.newName : r.oldName));
|
|
if (nextIsAddRename || nextIsDropRename) {
|
|
cleaned = cleaned.replace(/,\s*$/, "");
|
|
}
|
|
break;
|
|
}
|
|
remaining.push(cleaned);
|
|
}
|
|
if (remaining.length > 0) {
|
|
const last = remaining[remaining.length - 1].replace(/,\s*$/, "").replace(/;\s*$/, "");
|
|
remaining[remaining.length - 1] = last;
|
|
out.push(`ALTER TABLE ${currentTable}`);
|
|
for (const r of remaining) {
|
|
out.push(` ${r}`);
|
|
}
|
|
out.push(";");
|
|
}
|
|
inAlter = false;
|
|
currentTable = "";
|
|
}
|
|
}
|
|
|
|
return out.join("\n").trim();
|
|
}
|
|
|
|
export interface ObjectTypeGroup {
|
|
kind: DiffObjectKind;
|
|
label: string;
|
|
objects: SchemaDiffObject[];
|
|
expanded: boolean;
|
|
selectedCount: number;
|
|
}
|
|
|
|
export interface OperationGroup {
|
|
operationType: DiffOperationType;
|
|
label: string;
|
|
count: number;
|
|
selectedCount: number;
|
|
expanded: boolean;
|
|
typeGroups: ObjectTypeGroup[];
|
|
}
|
|
|
|
export function flattenSchemaDiffObjects(objects: SchemaDiffObject[]): SchemaDiffObject[] {
|
|
return objects.flatMap((object) => [object, ...flattenSchemaDiffObjects(object.children ?? [])]);
|
|
}
|
|
|
|
export function findSchemaDiffObject(objects: SchemaDiffObject[], objectId: string): SchemaDiffObject | undefined {
|
|
return flattenSchemaDiffObjects(objects).find((object) => object.id === objectId);
|
|
}
|
|
|
|
export function setSchemaDiffObjectSelected(objects: SchemaDiffObject[], objectId: string, selected: boolean): boolean {
|
|
const object = findSchemaDiffObject(objects, objectId);
|
|
if (!object) return false;
|
|
|
|
const applySelection = (target: SchemaDiffObject) => {
|
|
target.selected = selected;
|
|
for (const child of target.children ?? []) applySelection(child);
|
|
};
|
|
applySelection(object);
|
|
|
|
if (object.parentId) {
|
|
const parent = findSchemaDiffObject(objects, object.parentId);
|
|
if (parent) {
|
|
const children = parent.children?.filter((child) => child.operationType !== "none") ?? [];
|
|
parent.selected = children.length > 0 && children.every((child) => child.selected);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** Apply one leaf selection while keeping generated DDL dependencies executable. */
|
|
export function setSchemaDiffObjectSelectedWithDependencies(objects: SchemaDiffObject[], result: SchemaDiffPreparation, objectId: string, selected: boolean): boolean {
|
|
const changed = setSchemaDiffObjectSelected(objects, objectId, selected);
|
|
const initialObject = findSchemaDiffObject(objects, objectId);
|
|
if (!changed || !initialObject?.parentId) return changed;
|
|
|
|
const visited = new Set<string>();
|
|
const apply = (id: string, value: boolean) => {
|
|
if (visited.has(`${id}:${value}`)) return;
|
|
visited.add(`${id}:${value}`);
|
|
|
|
const object = findSchemaDiffObject(objects, id);
|
|
if (!object?.parentId) return;
|
|
const tableObject = findSchemaDiffObject(objects, object.parentId);
|
|
const tableDiff = result.diffs.find((diff) => diff.name === tableObject?.name);
|
|
if (!tableObject || !tableDiff) return;
|
|
setSchemaDiffObjectSelected(objects, id, value);
|
|
|
|
const child = <Child extends { name: string }>(kind: SchemaDiffChildKind, collection: readonly Child[] | undefined, index: number) => {
|
|
const candidate = collection?.[index];
|
|
if (!candidate) return undefined;
|
|
|
|
const childId = diffChildObjectId(kind, tableObject.name, candidate.name, collection, index);
|
|
return tableObject.children?.find((candidateObject) => candidateObject.id === childId);
|
|
};
|
|
const applyColumn = (name: string, value: boolean) => {
|
|
const columnIndex = tableDiff.columns?.findIndex((column) => column.name === name) ?? -1;
|
|
const columnDiff = columnIndex < 0 ? undefined : tableDiff.columns?.[columnIndex];
|
|
const columnObject = columnIndex < 0 ? undefined : child("column", tableDiff.columns, columnIndex);
|
|
if (!columnDiff || !columnObject) return;
|
|
if (value && !["added", "renamed"].includes(columnDiff.type)) return;
|
|
apply(columnObject.id, value);
|
|
};
|
|
|
|
if (object.objectKind === "column") {
|
|
const columnIndex = findDiffChildIndex("column", tableObject.name, tableDiff.columns, object.id);
|
|
const columnDiff = columnIndex < 0 ? undefined : tableDiff.columns?.[columnIndex];
|
|
if (!columnDiff) return;
|
|
|
|
if (value && columnDiff.type === "added" && columnDiff.addPosition && typeof columnDiff.addPosition === "object") {
|
|
applyColumn(columnDiff.addPosition.after, true);
|
|
}
|
|
|
|
if (value && columnDiff.type === "removed") {
|
|
for (const [index, indexDiff] of (tableDiff.indexes ?? []).entries()) {
|
|
if (!["removed", "modified"].includes(indexDiff.type) || !indexDiff.target?.columns.includes(columnDiff.name)) continue;
|
|
const indexObject = child("index", tableDiff.indexes, index);
|
|
if (indexObject) apply(indexObject.id, true);
|
|
}
|
|
for (const [index, foreignKey] of (tableDiff.foreignKeys ?? []).entries()) {
|
|
if (!["removed", "modified"].includes(foreignKey.type) || foreignKey.target?.column !== columnDiff.name) continue;
|
|
const foreignKeyObject = child("foreignKey", tableDiff.foreignKeys, index);
|
|
if (foreignKeyObject) apply(foreignKeyObject.id, true);
|
|
}
|
|
}
|
|
|
|
if (!value && ["added", "renamed"].includes(columnDiff.type)) {
|
|
for (const [index, dependentColumn] of (tableDiff.columns ?? []).entries()) {
|
|
if (dependentColumn.type !== "added" || !dependentColumn.addPosition || typeof dependentColumn.addPosition !== "object" || dependentColumn.addPosition.after !== columnDiff.name) continue;
|
|
const dependentObject = child("column", tableDiff.columns, index);
|
|
if (dependentObject) apply(dependentObject.id, false);
|
|
}
|
|
for (const [index, indexDiff] of (tableDiff.indexes ?? []).entries()) {
|
|
const sourceColumns = [...(indexDiff.source?.columns ?? []), ...(indexDiff.source?.included_columns ?? [])];
|
|
if (!["added", "modified"].includes(indexDiff.type) || !sourceColumns.includes(columnDiff.name)) continue;
|
|
const indexObject = child("index", tableDiff.indexes, index);
|
|
if (indexObject) apply(indexObject.id, false);
|
|
}
|
|
for (const [index, foreignKey] of (tableDiff.foreignKeys ?? []).entries()) {
|
|
if (!["added", "modified"].includes(foreignKey.type) || foreignKey.source?.column !== columnDiff.name) continue;
|
|
const foreignKeyObject = child("foreignKey", tableDiff.foreignKeys, index);
|
|
if (foreignKeyObject) apply(foreignKeyObject.id, false);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (value && object.objectKind === "index") {
|
|
const indexIndex = findDiffChildIndex("index", tableObject.name, tableDiff.indexes, object.id);
|
|
const index = indexIndex < 0 ? undefined : tableDiff.indexes?.[indexIndex];
|
|
for (const columnName of [...(index?.source?.columns ?? []), ...(index?.source?.included_columns ?? [])]) {
|
|
applyColumn(columnName, true);
|
|
}
|
|
}
|
|
|
|
if (value && object.objectKind === "foreignKey") {
|
|
const foreignKeyIndex = findDiffChildIndex("foreignKey", tableObject.name, tableDiff.foreignKeys, object.id);
|
|
const foreignKey = foreignKeyIndex < 0 ? undefined : tableDiff.foreignKeys?.[foreignKeyIndex];
|
|
if (foreignKey?.source?.column) applyColumn(foreignKey.source.column, true);
|
|
}
|
|
};
|
|
|
|
visited.clear();
|
|
apply(objectId, selected);
|
|
return true;
|
|
}
|
|
|
|
export function selectedSchemaDiffObjects(objects: SchemaDiffObject[]): SchemaDiffObject[] {
|
|
return objects.flatMap((object) => {
|
|
const children = object.children?.filter((child) => child.operationType !== "none") ?? [];
|
|
if (children.length > 0) return children.filter((child) => child.selected);
|
|
return object.selected && object.operationType !== "none" ? [object] : [];
|
|
});
|
|
}
|
|
|
|
export function schemaDiffSelectionTargets(object: SchemaDiffObject): SchemaDiffObject[] {
|
|
const children = object.children?.filter((child) => child.operationType !== "none") ?? [];
|
|
return children.length > 0 ? children : [object];
|
|
}
|
|
|
|
export function schemaDiffObjectSelectionState(object: SchemaDiffObject): { checked: boolean; indeterminate: boolean } {
|
|
const targets = schemaDiffSelectionTargets(object);
|
|
const selectedCount = targets.filter((target) => target.selected).length;
|
|
return {
|
|
checked: targets.length > 0 && selectedCount === targets.length,
|
|
indeterminate: selectedCount > 0 && selectedCount < targets.length,
|
|
};
|
|
}
|
|
|
|
export function summarizeSchemaDiffOperations(objects: SchemaDiffObject[]): Record<DiffOperationType, number> {
|
|
const counts: Record<DiffOperationType, number> = { create: 0, modify: 0, delete: 0, none: 0 };
|
|
for (const object of selectedSchemaDiffObjects(objects)) {
|
|
counts[object.operationType]++;
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
export type SchemaDiffReviewAlert = "destructive" | "compatibility" | null;
|
|
|
|
export function schemaDiffReviewAlert(destructiveStatementCount: number, compatibilityWarningCount: number): SchemaDiffReviewAlert {
|
|
if (destructiveStatementCount > 0) return "destructive";
|
|
if (compatibilityWarningCount > 0) return "compatibility";
|
|
return null;
|
|
}
|
|
|
|
export function groupDiffObjects(objects: SchemaDiffObject[]): OperationGroup[] {
|
|
const groups: Record<DiffOperationType, Record<DiffObjectKind, SchemaDiffObject[]>> = {
|
|
modify: {
|
|
table: [],
|
|
view: [],
|
|
function: [],
|
|
sequence: [],
|
|
rule: [],
|
|
owner: [],
|
|
column: [],
|
|
index: [],
|
|
foreignKey: [],
|
|
trigger: [],
|
|
tableOption: [],
|
|
},
|
|
create: {
|
|
table: [],
|
|
view: [],
|
|
function: [],
|
|
sequence: [],
|
|
rule: [],
|
|
owner: [],
|
|
column: [],
|
|
index: [],
|
|
foreignKey: [],
|
|
trigger: [],
|
|
tableOption: [],
|
|
},
|
|
delete: {
|
|
table: [],
|
|
view: [],
|
|
function: [],
|
|
sequence: [],
|
|
rule: [],
|
|
owner: [],
|
|
column: [],
|
|
index: [],
|
|
foreignKey: [],
|
|
trigger: [],
|
|
tableOption: [],
|
|
},
|
|
none: {
|
|
table: [],
|
|
view: [],
|
|
function: [],
|
|
sequence: [],
|
|
rule: [],
|
|
owner: [],
|
|
column: [],
|
|
index: [],
|
|
foreignKey: [],
|
|
trigger: [],
|
|
tableOption: [],
|
|
},
|
|
};
|
|
|
|
// Classification is presence-based (DBeaver structure-compare semantics): an
|
|
// object that exists on both sides keeps operationType "modify" and carries
|
|
// its per-child operations in the drill-down, so only target-only objects
|
|
// ("delete") and source-only objects ("create") appear in those groups. A
|
|
// both-side table must never be re-bucketed as delete/create just because
|
|
// some of its columns or indexes differ.
|
|
for (const obj of objects) {
|
|
groups[obj.operationType][obj.objectKind].push(obj);
|
|
}
|
|
|
|
const order: DiffOperationType[] = ["modify", "create", "delete", "none"];
|
|
return order.map((opType) => {
|
|
const typeGroups: ObjectTypeGroup[] = [];
|
|
const kinds: DiffObjectKind[] = ["table", "view", "function", "sequence", "rule", "owner", "column", "index", "foreignKey", "trigger", "tableOption"];
|
|
|
|
for (const kind of kinds) {
|
|
const objs = groups[opType][kind];
|
|
if (objs.length > 0) {
|
|
typeGroups.push({
|
|
kind,
|
|
label: getObjectTypeLabel(kind),
|
|
objects: objs,
|
|
expanded: true,
|
|
selectedCount: objs.flatMap(schemaDiffSelectionTargets).filter((object) => object.selected).length,
|
|
});
|
|
}
|
|
}
|
|
|
|
const allObjects = Object.values(groups[opType]).flat();
|
|
const selectionTargets = allObjects.flatMap(schemaDiffSelectionTargets);
|
|
return {
|
|
operationType: opType,
|
|
label: getOperationLabel(opType),
|
|
count: selectionTargets.length,
|
|
selectedCount: selectionTargets.filter((object) => object.selected).length,
|
|
expanded: opType !== "none",
|
|
typeGroups,
|
|
};
|
|
});
|
|
}
|
|
|
|
function getObjectTypeLabel(kind: DiffObjectKind): string {
|
|
switch (kind) {
|
|
case "table":
|
|
return "diff.objectKindLabel.table";
|
|
case "view":
|
|
return "diff.objectKindLabel.view";
|
|
case "function":
|
|
return "diff.objectKindLabel.function";
|
|
case "sequence":
|
|
return "diff.objectKindLabel.sequence";
|
|
case "rule":
|
|
return "diff.objectKindLabel.rule";
|
|
case "owner":
|
|
return "diff.objectKindLabel.owner";
|
|
case "column":
|
|
return "diff.objectKindLabel.column";
|
|
case "index":
|
|
return "diff.objectKindLabel.index";
|
|
case "foreignKey":
|
|
return "diff.objectKindLabel.foreignKey";
|
|
case "trigger":
|
|
return "diff.objectKindLabel.trigger";
|
|
case "tableOption":
|
|
return "diff.objectKindLabel.tableOption";
|
|
default:
|
|
return kind;
|
|
}
|
|
}
|