feat(schema-diff): preserve dialect-specific DDL semantics

This commit is contained in:
dbin0123 authored and GitHub committed 2026-07-29 03:47:19 +08:00
1 parent 820b8cd26f
commit 002a2c85c0
146 files changed
+36532 -1375

No files matched your search

@@ -0,0 +1,40 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
invoke: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: mocks.invoke,
}));
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(),
}));
describe("listDialectDataTypes backend adapters", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
it("uses the Tauri dialect command", async () => {
mocks.invoke.mockResolvedValue(["INTEGER", "TEXT"]);
const { listDialectDataTypes } = await import("@/lib/backend/tauri");
await expect(listDialectDataTypes("PostgreSQL")).resolves.toEqual(["INTEGER", "TEXT"]);
expect(mocks.invoke).toHaveBeenCalledWith("list_dialect_data_types", { dialectName: "PostgreSQL" });
});
it("uses the matching Web dialect route", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(["INTEGER", "TEXT"]),
});
vi.stubGlobal("fetch", fetchMock);
const { listDialectDataTypes } = await import("@/lib/backend/http");
await expect(listDialectDataTypes("PostgreSQL")).resolves.toEqual(["INTEGER", "TEXT"]);
expect(fetchMock).toHaveBeenCalledWith("/api/dialect/data-types?dialect_name=PostgreSQL");
});
});
+2
View File
@@ -175,6 +175,7 @@ export const listExtensions = forward("listExtensions");
export const listAvailableExtensions = forward("listAvailableExtensions");
export const prepareSchemaDiff = forward("prepareSchemaDiff");
export const generateSchemaSyncSql = forward("generateSchemaSyncSql");
export const listDialectDataTypes = forward("listDialectDataTypes");
// Query
export const executeQuery = forward("executeQuery");
@@ -182,6 +183,7 @@ export const executeMulti = forward("executeMulti");
export const executeMultiWithProgress = forward("executeMultiWithProgress");
export const executeBatch = forward("executeBatch");
export const executeScript = forward("executeScript");
export const executeScriptWith2pc = forward("executeScriptWith2pc");
export const executeInTransaction = forward("executeInTransaction");
export const beginManualTransaction = forward("beginManualTransaction");
export const executeInManualTransaction = forward("executeInManualTransaction");
+8
View File
@@ -776,6 +776,10 @@ export async function listAvailableExtensions(connectionId: string, database: st
return get(`/api/schema/available-extensions?${qs({ connection_id: connectionId, database })}`);
}
export async function listDialectDataTypes(dialectName: string): Promise<string[]> {
return get(`/api/dialect/data-types?${qs({ dialect_name: dialectName })}`);
}
// ---------------------------------------------------------------------------
// Query
// ---------------------------------------------------------------------------
@@ -867,6 +871,10 @@ export async function executeScript(connectionId: string, database: string, sql:
return post("/api/query/execute-script", { connectionId, database, sql, schema });
}
export async function executeScriptWith2pc(connectionId: string, database: string, statements: string[], schema?: string): Promise<any> {
return post("/api/query/execute-script-2pc", { connectionId, database, statements, schema });
}
export async function executeInTransaction(connectionId: string, database: string, statements: string[], schema?: string): Promise<QueryResult> {
return post("/api/query/execute-in-transaction", { connectionId, database, statements, schema });
}
+9
View File
@@ -44,6 +44,7 @@ import type {
SavedSqlLibrary,
SshConfigHostEntry,
TunnelProfile,
TransactionLog,
} from "@/types/database";
import { isTauriCommandUnavailable, normalizeConnectionTestResult } from "@/lib/connection/connectionDatabaseInfo";
import type { CollectionInfo } from "@/types/database";
@@ -999,6 +1000,10 @@ export async function executeScript(connectionId: string, database: string, sql:
return invoke("execute_script", { connectionId, database, sql, schema });
}
export async function executeScriptWith2pc(connectionId: string, database: string, statements: string[], schema?: string): Promise<TransactionLog> {
return invoke("execute_script_with_2pc", { connectionId, database, statements, schema });
}
export async function executeInTransaction(connectionId: string, database: string, statements: string[], schema?: string): Promise<QueryResult> {
return invoke("execute_in_transaction", { connectionId, database, statements, schema });
}
@@ -1284,6 +1289,10 @@ export async function prepareSchemaDiff(options: SchemaDiffPreparationOptions):
return invoke("prepare_schema_diff", { options });
}
export async function listDialectDataTypes(dialectName: string): Promise<string[]> {
return invoke("list_dialect_data_types", { dialectName });
}
export async function generateSchemaSyncSql(diffs: TableDiff[], databaseType: DatabaseType, targetSchema?: string, functionDiffs?: FunctionDiff[], sequenceDiffs?: SequenceDiff[], ruleDiffs?: RuleDiff[], ownerDiffs?: OwnerDiff[], cascadeDelete?: boolean): Promise<string> {
return invoke("generate_schema_sync_sql", {
diffs,
@@ -2,6 +2,15 @@ import type { ColumnInfo, DatabaseType, QueryResult } from "@/types/database";
export type DataCompareCellValue = QueryResult["rows"][number][number];
export type SamplingStrategy = "Random" | "ExtremeValues" | "Hybrid";
export interface DegradationThreshold {
fullCompareMaxRows: number;
sampleMaxRows: number;
sampleSize: number;
extremeSampleCount: number;
}
export interface DataCompareChangedCell {
column: string;
source: DataCompareCellValue;
@@ -57,6 +66,9 @@ export interface DataCompareFromTablesOptions {
columns: string[];
keyColumns: string[];
fetchBatchSize?: number;
degradationThreshold?: DegradationThreshold;
samplingStrategy?: SamplingStrategy;
enableChecksum?: boolean;
}
export interface DataCompareMissingTargetOptions {
@@ -70,6 +82,9 @@ export interface DataCompareMissingTargetOptions {
targetTable: string;
keyColumns: string[];
fetchBatchSize?: number;
degradationThreshold?: DegradationThreshold;
samplingStrategy?: SamplingStrategy;
enableChecksum?: boolean;
}
export interface DataCompareFromTablesPreparation extends DataComparePreparation {
+112
View File
@@ -0,0 +1,112 @@
import type { FieldMappingEntry } from "@/types/schemaDiff";
export interface FieldMappingPreset {
id: string;
label: string;
sourceDialect: string;
targetDialect: string;
mappings: FieldMappingEntry[];
}
export const FIELD_MAPPING_PRESETS: FieldMappingPreset[] = [
{
id: "mysql-to-dameng",
label: "MySQL → 达梦 (DM)",
sourceDialect: "mysql",
targetDialect: "dameng",
mappings: [
{ sourceType: "VARCHAR", targetType: "VARCHAR", paramStrategy: "preserve" },
{ sourceType: "CHAR", targetType: "CHAR", paramStrategy: "preserve" },
{ sourceType: "TEXT", targetType: "TEXT", paramStrategy: "strip" },
{ sourceType: "TINYTEXT", targetType: "TEXT", paramStrategy: "strip" },
{ sourceType: "MEDIUMTEXT", targetType: "TEXT", paramStrategy: "strip" },
{ sourceType: "LONGTEXT", targetType: "TEXT", paramStrategy: "strip" },
{ sourceType: "INT", targetType: "INT", paramStrategy: "preserve" },
{ sourceType: "BIGINT", targetType: "BIGINT", paramStrategy: "preserve" },
{ sourceType: "DECIMAL", targetType: "NUMERIC", paramStrategy: "preserve" },
{ sourceType: "FLOAT", targetType: "FLOAT", paramStrategy: "preserve" },
{ sourceType: "DOUBLE", targetType: "DOUBLE", paramStrategy: "preserve" },
{ sourceType: "DATE", targetType: "DATE", paramStrategy: "preserve" },
{ sourceType: "DATETIME", targetType: "TIMESTAMP", paramStrategy: "preserve" },
{ sourceType: "TIMESTAMP", targetType: "TIMESTAMP", paramStrategy: "preserve" },
{ sourceType: "BLOB", targetType: "BLOB", paramStrategy: "strip" },
{ sourceType: "JSON", targetType: "TEXT", paramStrategy: "strip" },
{ sourceType: "TINYINT", targetType: "TINYINT", paramStrategy: "preserve" },
{ sourceType: "SMALLINT", targetType: "SMALLINT", paramStrategy: "preserve" },
{ sourceType: "BOOLEAN", targetType: "BOOLEAN", paramStrategy: "preserve" },
],
},
{
id: "mysql-to-postgresql",
label: "MySQL → PostgreSQL",
sourceDialect: "mysql",
targetDialect: "postgresql",
mappings: [
{ sourceType: "VARCHAR", targetType: "VARCHAR", paramStrategy: "preserve" },
{ sourceType: "CHAR", targetType: "CHAR", paramStrategy: "preserve" },
{ sourceType: "TEXT", targetType: "TEXT", paramStrategy: "strip" },
{ sourceType: "TINYTEXT", targetType: "TEXT", paramStrategy: "strip" },
{ sourceType: "MEDIUMTEXT", targetType: "TEXT", paramStrategy: "strip" },
{ sourceType: "LONGTEXT", targetType: "TEXT", paramStrategy: "strip" },
{ sourceType: "INT", targetType: "INTEGER", paramStrategy: "preserve" },
{ sourceType: "BIGINT", targetType: "BIGINT", paramStrategy: "preserve" },
{ sourceType: "DECIMAL", targetType: "NUMERIC", paramStrategy: "preserve" },
{ sourceType: "FLOAT", targetType: "REAL", paramStrategy: "preserve" },
{ sourceType: "DOUBLE", targetType: "DOUBLE PRECISION", paramStrategy: "preserve" },
{ sourceType: "DATETIME", targetType: "TIMESTAMP", paramStrategy: "preserve" },
{ sourceType: "TIMESTAMP", targetType: "TIMESTAMP", paramStrategy: "preserve" },
{ sourceType: "BLOB", targetType: "BYTEA", paramStrategy: "strip" },
{ sourceType: "JSON", targetType: "JSONB", paramStrategy: "preserve" },
{ sourceType: "TINYINT", targetType: "SMALLINT", paramStrategy: "preserve" },
{ sourceType: "BOOLEAN", targetType: "BOOLEAN", paramStrategy: "preserve" },
],
},
{
id: "mysql-to-oracle",
label: "MySQL → Oracle",
sourceDialect: "mysql",
targetDialect: "oracle",
mappings: [
{ sourceType: "VARCHAR", targetType: "VARCHAR2", paramStrategy: "preserve" },
{ sourceType: "CHAR", targetType: "CHAR", paramStrategy: "preserve" },
{ sourceType: "TEXT", targetType: "CLOB", paramStrategy: "strip" },
{ sourceType: "TINYTEXT", targetType: "CLOB", paramStrategy: "strip" },
{ sourceType: "MEDIUMTEXT", targetType: "CLOB", paramStrategy: "strip" },
{ sourceType: "LONGTEXT", targetType: "CLOB", paramStrategy: "strip" },
{ sourceType: "INT", targetType: "NUMBER", paramStrategy: "preserve" },
{ sourceType: "BIGINT", targetType: "NUMBER", paramStrategy: "preserve" },
{ sourceType: "DECIMAL", targetType: "NUMBER", paramStrategy: "preserve" },
{ sourceType: "FLOAT", targetType: "BINARY_FLOAT", paramStrategy: "preserve" },
{ sourceType: "DOUBLE", targetType: "BINARY_DOUBLE", paramStrategy: "preserve" },
{ sourceType: "DATETIME", targetType: "TIMESTAMP", paramStrategy: "preserve" },
{ sourceType: "TIMESTAMP", targetType: "TIMESTAMP", paramStrategy: "preserve" },
{ sourceType: "BLOB", targetType: "BLOB", paramStrategy: "strip" },
{ sourceType: "JSON", targetType: "CLOB", paramStrategy: "strip" },
{ sourceType: "BOOLEAN", targetType: "NUMBER(1)", paramStrategy: "custom", customParams: "(1)" },
],
},
];
export function findPreset(sourceDialect: string, targetDialect: string): FieldMappingPreset | undefined {
// Look for exact forward match
const forward = FIELD_MAPPING_PRESETS.find((p) => p.sourceDialect === sourceDialect && p.targetDialect === targetDialect);
if (forward) return forward;
// Look for reverse match and auto-generate bidirectional preset
const reverse = FIELD_MAPPING_PRESETS.find((p) => p.sourceDialect === targetDialect && p.targetDialect === sourceDialect);
if (reverse) {
return {
id: `${reverse.id}-reverse`,
label: `${reverse.label.split(" → ").reverse().join(" → ")}`,
sourceDialect,
targetDialect,
mappings: reverse.mappings.map((m) => ({
sourceType: m.targetType,
targetType: m.sourceType,
paramStrategy: m.paramStrategy === "custom" ? "strip" : m.paramStrategy,
})),
};
}
return undefined;
}
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { buildDeployTxResult } from "@/lib/schema/deployTxResult";
const t = (key: string, params?: Record<string, any>) => {
const fallback: Record<string, string> = {
"diff.executeSuccess": "Executed successfully",
"diff.deployMixed": "Deployment partially completed. Some statements may already be applied ({executedCount}/{statementCount}). DDL may not be transactional.",
"diff.deployRolledBack": "All changes have been rolled back.",
"diff.deployFailed": "Deployment failed: {status}",
};
let msg = fallback[key] || key;
if (params) {
for (const [k, v] of Object.entries(params)) {
msg = msg.replace(`{${k}}`, String(v));
}
}
return msg;
};
describe("buildDeployTxResult", () => {
it("returns success for committed transaction", () => {
const result = buildDeployTxResult({ status: "committed", transaction_id: "tx1", executedCount: 2 }, t);
expect(result.success).toBe(true);
expect(result.status).toBe("committed");
expect(result.message).toBe("Executed successfully");
expect(result.executedCount).toBe(2);
});
it("returns failure with mixed status for partially committed", () => {
const result = buildDeployTxResult(
{
status: "mixed",
participants: [{ id: "1" }, { id: "2" }],
executedCount: 1,
statementCount: 2,
},
t,
);
expect(result.success).toBe(false);
expect(result.status).toBe("mixed");
expect(result.message).toContain("partially completed");
expect(result.message).toContain("1/2");
expect(result.message).toContain("may not be transactional");
expect(result.executedCount).toBe(1);
expect(result.statementCount).toBe(2);
});
it("returns failure with rolled_back status and error detail", () => {
const result = buildDeployTxResult({ status: "rolled_back", error: "syntax error near SELECT", executedCount: 0, statementCount: 2 }, t);
expect(result.success).toBe(false);
expect(result.status).toBe("rolled_back");
expect(result.message).toContain("rolled back");
expect(result.message).toContain("syntax error");
expect(result.executedCount).toBe(0);
expect(result.statementCount).toBe(2);
});
it("returns failure for unknown status", () => {
const result = buildDeployTxResult({ status: "unknown" }, t);
expect(result.success).toBe(false);
expect(result.status).toBe("unknown");
expect(result.message).toContain("unknown");
});
it("returns failure for null/undefined txLog", () => {
const result = buildDeployTxResult(null, t);
expect(result.success).toBe(false);
expect(result.status).toBe("unknown");
});
it("maps MySQL-style partial DDL failure (1 of 2 applied) for UI", () => {
const result = buildDeployTxResult(
{
status: "mixed",
executedCount: 1,
statementCount: 2,
error: "Statement 2 failed: table already exists",
metadata: { atomicity: "partial_effects_possible", ddl_atomic: false },
},
t,
);
expect(result.success).toBe(false);
expect(result.status).toBe("mixed");
expect(result.executedCount).toBe(1);
expect(result.statementCount).toBe(2);
expect(result.message).toContain("1/2");
expect(result.message).toMatch(/may already be applied|may not be transactional/i);
});
});
@@ -0,0 +1,61 @@
export interface DeployTxResult {
success: boolean;
status?: string;
message: string;
affectedRows?: number;
error?: string;
executedCount?: number;
statementCount?: number;
}
export function buildDeployTxResult(txLog: any, t: (key: string, params?: Record<string, any>) => string): DeployTxResult {
const status = txLog?.status;
const error = txLog?.error ?? txLog?.metadata?.error;
const executedCount = txLog?.executedCount ?? txLog?.executed_count;
const statementCount = txLog?.statementCount ?? txLog?.statement_count;
const affectedRows = txLog?.metadata?.affected_rows ?? txLog?.affectedRows;
if (status === "committed") {
return {
success: true,
status,
message: t("diff.executeSuccess"),
affectedRows,
executedCount,
statementCount,
};
}
if (status === "mixed") {
return {
success: false,
status,
message: t("diff.deployMixed", {
participants: txLog?.participants?.length ?? 0,
executedCount: executedCount ?? 0,
statementCount: statementCount ?? 0,
}),
error,
executedCount,
statementCount,
};
}
if (status === "rolled_back") {
const detail = error ? `: ${error}` : "";
return {
success: false,
status,
message: `${t("diff.deployRolledBack")}${detail}`,
error,
executedCount: executedCount ?? 0,
statementCount,
};
}
return {
success: false,
status: status || "unknown",
message: t("diff.deployFailed", { status: status || "unknown" }),
error,
executedCount,
statementCount,
};
}
+331 -4
View File
@@ -1,5 +1,111 @@
import type { ColumnInfo, IndexInfo, ForeignKeyInfo, TriggerInfo, FunctionInfo, SequenceInfo, RuleInfo, OwnerInfo, DatabaseType, TableInfo } from "@/types/database";
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";
name: string;
@@ -65,7 +171,7 @@ export interface OwnerDiff {
}
export interface TableDiff {
type: "added" | "removed" | "modified";
type: "added" | "removed" | "modified" | "renamed";
objectType?: "table" | "view";
name: string;
columns?: ColumnDiff[];
@@ -88,6 +194,11 @@ export interface TableSchemaDetail {
ddl?: string;
}
export interface FieldMappingEntry {
sourceType: string;
targetType: string;
}
export interface SchemaDiffPreparationOptions {
sourceTables: TableInfo[];
targetTables: TableInfo[];
@@ -106,8 +217,58 @@ export interface SchemaDiffPreparationOptions {
ignoreComments?: boolean;
cascadeDelete?: boolean;
compareColumnOrder?: boolean;
detectRenames?: boolean;
detectTableRenames?: boolean;
renameThreshold?: number;
enableRollback?: boolean;
batchPatterns?: string[];
sourceDialect?: string;
targetDialect?: string;
compatibilityThreshold?: number;
fieldMappings?: FieldMappingEntry[];
}
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[];
}
export interface MissingRollbackObject {
kind: string;
name: string;
table?: string;
reason: string;
}
export type RollbackCompleteness = "complete" | "incomplete";
export interface SchemaDiffPreparation {
diffs: TableDiff[];
functionDiffs?: FunctionDiff[];
@@ -115,6 +276,15 @@ export interface SchemaDiffPreparation {
ruleDiffs?: RuleDiff[];
ownerDiffs?: OwnerDiff[];
syncSql: string;
rollbackSyncSql?: string;
rollbackCompleteness?: RollbackCompleteness;
missingRollbackObjects?: MissingRollbackObject[];
renameCandidates?: RenameCandidate[];
rollbackGraph?: unknown;
compatibilityWarnings?: CompatibilityWarning[];
permissionDiffs?: PermissionDiff[];
permissionSyncSql?: string;
dependencyGraph?: DependencyGraph;
}
const MYSQL_LIKE_SCHEMA_DIFF_TARGET_TYPES = new Set<DatabaseType>(["mysql", "doris", "starrocks", "goldendb", "sundb", "databend", "gbase"]);
@@ -146,10 +316,17 @@ export interface SchemaDiffObject {
sourceDdl?: string;
targetDdl?: string;
deploySql?: string;
rollbackDdl?: string;
changes?: string[];
children?: SchemaDiffObject[];
/** Function arguments signature (for PostgreSQL overloaded functions) */
arguments?: string;
renameMetadata?: {
confirmed: boolean;
sourceName?: string;
targetName?: string;
score?: number;
};
}
export interface SchemaDiffGroup {
@@ -164,6 +341,7 @@ export interface SchemaDiffGroup {
export function getOperationType(diffType: string): DiffOperationType {
switch (diffType) {
case "modified":
case "renamed":
return "modify";
case "added":
return "create";
@@ -203,23 +381,27 @@ function buildSequenceDdl(seq: SequenceInfo): string {
return parts.join("\n");
}
export function convertToSchemaDiffObjects(tableDiffs: TableDiff[], functionDiffs: FunctionDiff[] = [], sequenceDiffs: SequenceDiff[] = [], ruleDiffs: RuleDiff[] = [], ownerDiffs: OwnerDiff[] = []): SchemaDiffObject[] {
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 obj: SchemaDiffObject = {
id: `table-${diff.name}`,
operationType: opType,
objectKind: diff.objectType === "view" ? "view" : "table",
name: diff.name,
sourceName: diff.type === "added" ? undefined : diff.name,
targetName: diff.type === "removed" ? undefined : diff.name,
targetName: diff.type === "removed" ? undefined : isRenamed ? newName : diff.name,
selected: opType !== "none",
sourceDdl: diff.ddl,
targetDdl: diff.targetDdl,
deploySql: diff.syncSql,
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: [
...(diff.columns?.map((c) => ({
id: `col-${diff.name}-${c.name}`,
@@ -324,6 +506,24 @@ export function convertToSchemaDiffObjects(tableDiffs: TableDiff[], functionDiff
});
}
// 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;
}
@@ -382,6 +582,133 @@ function generateDropSql(obj: SchemaDiffObject): string {
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.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;
@@ -23,6 +23,9 @@ export const POSTGRES_SCHEMA_DIFF_OPTIONS: SchemaDiffOptionItem[] = [
{ id: "cascadeDelete", labelKey: "schemaDiff.options.cascadeDelete", defaultChecked: false },
{ id: "sequenceLastValues", labelKey: "schemaDiff.options.sequenceLastValues", defaultChecked: true },
{ id: "compareColumnOrder", labelKey: "schemaDiff.options.compareColumnOrder", defaultChecked: false },
{ id: "detectRenames", labelKey: "schemaDiff.options.detectRenames", defaultChecked: false },
{ id: "detectTableRenames", labelKey: "schemaDiff.options.detectTableRenames", defaultChecked: false },
{ id: "enableRollback", labelKey: "schemaDiff.options.enableRollback", defaultChecked: false },
];
export const SCHEMA_DIFF_OPTIONS_BY_DB_TYPE: Record<string, SchemaDiffOptionItem[]> = {