feat(schema-diff): preserve dialect-specific DDL semantics
This commit is contained in:
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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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[]> = {
|
||||
|
||||
Reference in new issue
Block a user