diff --git a/apps/desktop/src/components/transfer/__tests__/queryResultTransferValues.spec.ts b/apps/desktop/src/components/transfer/__tests__/queryResultTransferValues.spec.ts index 0faff1fac7..5aab387c68 100644 --- a/apps/desktop/src/components/transfer/__tests__/queryResultTransferValues.spec.ts +++ b/apps/desktop/src/components/transfer/__tests__/queryResultTransferValues.spec.ts @@ -22,6 +22,12 @@ describe("query result transfer value literals", () => { expect(queryResultTransferSqlLiteral("0x0001fe", "postgres", "timestamp", "bytea", "sqlserver")).toBe("decode('0001fe', 'hex')"); }); + it("maps uniqueidentifier and uuid to target types appropriately", () => { + expect(queryResultTransferTargetType("uniqueidentifier", "postgres", "sqlserver")).toBe("uuid"); + expect(queryResultTransferTargetType("uuid", "sqlserver", "postgres")).toBe("uniqueidentifier"); + expect(queryResultTransferTargetType("uniqueidentifier", "mysql", "sqlserver")).toBe("char(36)"); + }); + it("fails closed for an unverified binary dialect", () => { expect(queryResultTransferSqlLiteral("0x0001fe", "hive", "binary", "BINARY")).toBeUndefined(); }); diff --git a/apps/desktop/src/components/transfer/queryResultTransferValues.ts b/apps/desktop/src/components/transfer/queryResultTransferValues.ts index a915b1283a..dbe4d89a97 100644 --- a/apps/desktop/src/components/transfer/queryResultTransferValues.ts +++ b/apps/desktop/src/components/transfer/queryResultTransferValues.ts @@ -260,6 +260,12 @@ export function queryResultTransferTargetType(sourceType: string | undefined, da if (databaseType === "h2" || databaseType === "db2") return "BLOB"; return "BLOB"; } + if (/^(?:uuid|uniqueidentifier)$/.test(type)) { + if (POSTGRES_TYPES.has(databaseType)) return "uuid"; + if (databaseType === "sqlserver") return "uniqueidentifier"; + if (databaseType === "mysql") return "char(36)"; + return textTargetType(databaseType); + } return textTargetType(databaseType); } diff --git a/apps/desktop/src/lib/__tests__/diff/fieldMappingPresets.spec.ts b/apps/desktop/src/lib/__tests__/diff/fieldMappingPresets.spec.ts new file mode 100644 index 0000000000..09938c41eb --- /dev/null +++ b/apps/desktop/src/lib/__tests__/diff/fieldMappingPresets.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { findPreset } from "@/lib/fieldMappingPresets"; + +describe("fieldMappingPresets", () => { + it("contains sqlserver-to-postgresql preset", () => { + const preset = findPreset("sqlserver", "postgresql"); + expect(preset).toBeDefined(); + expect(preset?.id).toBe("sqlserver-to-postgresql"); + + const uuidMapping = preset?.mappings.find((m) => m.sourceType === "UNIQUEIDENTIFIER"); + expect(uuidMapping).toBeDefined(); + expect(uuidMapping?.targetType).toBe("UUID"); + expect(uuidMapping?.paramStrategy).toBe("strip"); + }); + + it("supports postgres alias for postgresql", () => { + const preset = findPreset("sqlserver", "postgres"); + expect(preset).toBeDefined(); + expect(preset?.id).toBe("sqlserver-to-postgresql"); + }); + + it("supports reverse match postgresql to sqlserver", () => { + const preset = findPreset("postgresql", "sqlserver"); + expect(preset).toBeDefined(); + expect(preset?.id).toBe("sqlserver-to-postgresql-reverse"); + + const uuidMapping = preset?.mappings.find((m) => m.sourceType === "UUID"); + expect(uuidMapping).toBeDefined(); + expect(uuidMapping?.targetType).toBe("UNIQUEIDENTIFIER"); + }); + + it("supports reverse match postgres to sqlserver", () => { + const preset = findPreset("postgres", "sqlserver"); + expect(preset).toBeDefined(); + expect(preset?.id).toBe("sqlserver-to-postgresql-reverse"); + }); +}); diff --git a/apps/desktop/src/lib/fieldMappingPresets.ts b/apps/desktop/src/lib/fieldMappingPresets.ts index 9199c26909..72f3e67f0f 100644 --- a/apps/desktop/src/lib/fieldMappingPresets.ts +++ b/apps/desktop/src/lib/fieldMappingPresets.ts @@ -85,15 +85,61 @@ export const FIELD_MAPPING_PRESETS: FieldMappingPreset[] = [ { sourceType: "BOOLEAN", targetType: "NUMBER(1)", paramStrategy: "custom", customParams: "(1)" }, ], }, + { + id: "sqlserver-to-postgresql", + label: "SQL Server → PostgreSQL", + sourceDialect: "sqlserver", + targetDialect: "postgresql", + mappings: [ + { sourceType: "VARCHAR", targetType: "VARCHAR", paramStrategy: "preserve" }, + { sourceType: "NVARCHAR", targetType: "VARCHAR", paramStrategy: "preserve" }, + { sourceType: "CHAR", targetType: "CHAR", paramStrategy: "preserve" }, + { sourceType: "NCHAR", targetType: "CHAR", paramStrategy: "preserve" }, + { sourceType: "TEXT", targetType: "TEXT", paramStrategy: "strip" }, + { sourceType: "NTEXT", targetType: "TEXT", paramStrategy: "strip" }, + { sourceType: "INT", targetType: "INTEGER", paramStrategy: "preserve" }, + { sourceType: "BIGINT", targetType: "BIGINT", paramStrategy: "preserve" }, + { sourceType: "SMALLINT", targetType: "SMALLINT", paramStrategy: "preserve" }, + { sourceType: "TINYINT", targetType: "SMALLINT", paramStrategy: "preserve" }, + { sourceType: "BIT", targetType: "BOOLEAN", paramStrategy: "strip" }, + { sourceType: "DECIMAL", targetType: "NUMERIC", paramStrategy: "preserve" }, + { sourceType: "NUMERIC", targetType: "NUMERIC", paramStrategy: "preserve" }, + { sourceType: "MONEY", targetType: "NUMERIC", paramStrategy: "strip" }, + { sourceType: "SMALLMONEY", targetType: "NUMERIC", paramStrategy: "strip" }, + { sourceType: "FLOAT", targetType: "DOUBLE PRECISION", paramStrategy: "strip" }, + { sourceType: "REAL", targetType: "REAL", paramStrategy: "strip" }, + { sourceType: "DATE", targetType: "DATE", paramStrategy: "preserve" }, + { sourceType: "DATETIME", targetType: "TIMESTAMP", paramStrategy: "preserve" }, + { sourceType: "DATETIME2", targetType: "TIMESTAMP", paramStrategy: "preserve" }, + { sourceType: "SMALLDATETIME", targetType: "TIMESTAMP", paramStrategy: "strip" }, + { sourceType: "DATETIMEOFFSET", targetType: "TIMESTAMPTZ", paramStrategy: "preserve" }, + { sourceType: "TIME", targetType: "TIME", paramStrategy: "preserve" }, + { sourceType: "UNIQUEIDENTIFIER", targetType: "UUID", paramStrategy: "strip" }, + { sourceType: "VARBINARY", targetType: "BYTEA", paramStrategy: "strip" }, + { sourceType: "BINARY", targetType: "BYTEA", paramStrategy: "strip" }, + { sourceType: "IMAGE", targetType: "BYTEA", paramStrategy: "strip" }, + { sourceType: "XML", targetType: "XML", paramStrategy: "strip" }, + ], + }, ]; +function normalizeDialect(dialect: string): string { + const d = dialect.trim().toLowerCase(); + if (d === "postgres" || d === "postgresql") return "postgresql"; + if (d === "sqlserver" || d === "mssql") return "sqlserver"; + return d; +} + export function findPreset(sourceDialect: string, targetDialect: string): FieldMappingPreset | undefined { + const src = normalizeDialect(sourceDialect); + const tgt = normalizeDialect(targetDialect); + // Look for exact forward match - const forward = FIELD_MAPPING_PRESETS.find((p) => p.sourceDialect === sourceDialect && p.targetDialect === targetDialect); + const forward = FIELD_MAPPING_PRESETS.find((p) => p.sourceDialect === src && p.targetDialect === tgt); 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); + const reverse = FIELD_MAPPING_PRESETS.find((p) => p.sourceDialect === tgt && p.targetDialect === src); if (reverse) { return { id: `${reverse.id}-reverse`, diff --git a/crates/dbx-sql-dialect/src/sql_dialect/ddl_profile.rs b/crates/dbx-sql-dialect/src/sql_dialect/ddl_profile.rs index 4668b04a33..dc2efd051c 100644 --- a/crates/dbx-sql-dialect/src/sql_dialect/ddl_profile.rs +++ b/crates/dbx-sql-dialect/src/sql_dialect/ddl_profile.rs @@ -363,6 +363,11 @@ const SQLITE_TYPE_MAP: &[TypeMapEntry] = &[ TypeMapEntry { source_base: "JSON", target_template: "TEXT" }, ]; +const POSTGRES_TYPE_MAP: &[TypeMapEntry] = &[ + TypeMapEntry { source_base: "UNIQUEIDENTIFIER", target_template: "UUID" }, + TypeMapEntry { source_base: "GUID", target_template: "UUID" }, +]; + // --------------------------------------------------------------------------- // Profile families (shared shapes; registration is the only DatabaseType match) // --------------------------------------------------------------------------- @@ -469,7 +474,7 @@ fn postgres_family(db: DatabaseType) -> DdlDialectProfile { owner_alter_template: Some(OWNER_ALTER), drop_table_template: DROP_TABLE, lock_timeout_sql: Some("SET lock_timeout = '3s';"), - type_map: &[], + type_map: POSTGRES_TYPE_MAP, } } diff --git a/crates/dbx-sql-dialect/src/sql_dialect/descriptor.rs b/crates/dbx-sql-dialect/src/sql_dialect/descriptor.rs index 0f20f8cb58..2978c63ef0 100644 --- a/crates/dbx-sql-dialect/src/sql_dialect/descriptor.rs +++ b/crates/dbx-sql-dialect/src/sql_dialect/descriptor.rs @@ -870,6 +870,220 @@ impl TypeMappingMatrix { requires_cast: false, }); } + (DialectKind::SqlServer, DialectKind::Postgres) => { + rules.push(TypeConversionRule { + source_type: "UNIQUEIDENTIFIER".into(), + target_type: "UUID".into(), + precision_loss: false, + requires_cast: true, + }); + rules.push(TypeConversionRule { + source_type: "BIT".into(), + target_type: "BOOLEAN".into(), + precision_loss: false, + requires_cast: true, + }); + rules.push(TypeConversionRule { + source_type: "TINYINT".into(), + target_type: "SMALLINT".into(), + precision_loss: false, + requires_cast: false, + }); + rules.push(TypeConversionRule { + source_type: "SMALLINT".into(), + target_type: "SMALLINT".into(), + precision_loss: false, + requires_cast: false, + }); + rules.push(TypeConversionRule { + source_type: "INT".into(), + target_type: "INTEGER".into(), + precision_loss: false, + requires_cast: false, + }); + rules.push(TypeConversionRule { + source_type: "BIGINT".into(), + target_type: "BIGINT".into(), + precision_loss: false, + requires_cast: false, + }); + rules.push(TypeConversionRule { + source_type: "REAL".into(), + target_type: "REAL".into(), + precision_loss: false, + requires_cast: false, + }); + rules.push(TypeConversionRule { + source_type: "FLOAT".into(), + target_type: "DOUBLE PRECISION".into(), + precision_loss: false, + requires_cast: false, + }); + rules.push(TypeConversionRule { + source_type: "MONEY".into(), + target_type: "NUMERIC(19,4)".into(), + precision_loss: false, + requires_cast: false, + }); + rules.push(TypeConversionRule { + source_type: "SMALLMONEY".into(), + target_type: "NUMERIC(10,4)".into(), + precision_loss: false, + requires_cast: false, + }); + rules.push(TypeConversionRule { + source_type: "NVARCHAR(MAX)".into(), + target_type: "TEXT".into(), + precision_loss: false, + requires_cast: false, + }); + rules.push(TypeConversionRule { + source_type: "VARCHAR(MAX)".into(), + target_type: "TEXT".into(), + precision_loss: false, + requires_cast: false, + }); + rules.push(TypeConversionRule { + source_type: "NTEXT".into(), + target_type: "TEXT".into(), + precision_loss: false, + requires_cast: false, + }); + rules.push(TypeConversionRule { + source_type: "TEXT".into(), + target_type: "TEXT".into(), + precision_loss: false, + requires_cast: false, + }); + rules.push(TypeConversionRule { + source_type: "VARBINARY(MAX)".into(), + target_type: "BYTEA".into(), + precision_loss: false, + requires_cast: false, + }); + rules.push(TypeConversionRule { + source_type: "IMAGE".into(), + target_type: "BYTEA".into(), + precision_loss: false, + requires_cast: false, + }); + rules.push(TypeConversionRule { + source_type: "ROWVERSION".into(), + target_type: "BYTEA".into(), + precision_loss: false, + requires_cast: false, + }); + rules.push(TypeConversionRule { + source_type: "DATETIME".into(), + target_type: "TIMESTAMP".into(), + precision_loss: false, + requires_cast: false, + }); + rules.push(TypeConversionRule { + source_type: "DATETIME2".into(), + target_type: "TIMESTAMP".into(), + precision_loss: false, + requires_cast: false, + }); + rules.push(TypeConversionRule { + source_type: "SMALLDATETIME".into(), + target_type: "TIMESTAMP".into(), + precision_loss: false, + requires_cast: false, + }); + rules.push(TypeConversionRule { + source_type: "DATETIMEOFFSET".into(), + target_type: "TIMESTAMPTZ".into(), + precision_loss: false, + requires_cast: false, + }); + rules.push(TypeConversionRule { + source_type: "XML".into(), + target_type: "XML".into(), + precision_loss: false, + requires_cast: false, + }); + } + (DialectKind::Postgres, DialectKind::SqlServer) => { + rules.push(TypeConversionRule { + source_type: "UUID".into(), + target_type: "UNIQUEIDENTIFIER".into(), + precision_loss: false, + requires_cast: true, + }); + rules.push(TypeConversionRule { + source_type: "BOOLEAN".into(), + target_type: "BIT".into(), + precision_loss: false, + requires_cast: true, + }); + rules.push(TypeConversionRule { + source_type: "SMALLINT".into(), + target_type: "SMALLINT".into(), + precision_loss: false, + requires_cast: false, + }); + rules.push(TypeConversionRule { + source_type: "INTEGER".into(), + target_type: "INT".into(), + precision_loss: false, + requires_cast: false, + }); + rules.push(TypeConversionRule { + source_type: "BIGINT".into(), + target_type: "BIGINT".into(), + precision_loss: false, + requires_cast: false, + }); + rules.push(TypeConversionRule { + source_type: "REAL".into(), + target_type: "REAL".into(), + precision_loss: false, + requires_cast: false, + }); + rules.push(TypeConversionRule { + source_type: "DOUBLE PRECISION".into(), + target_type: "FLOAT".into(), + precision_loss: false, + requires_cast: false, + }); + rules.push(TypeConversionRule { + source_type: "TEXT".into(), + target_type: "NVARCHAR(MAX)".into(), + precision_loss: false, + requires_cast: false, + }); + rules.push(TypeConversionRule { + source_type: "BYTEA".into(), + target_type: "VARBINARY(MAX)".into(), + precision_loss: false, + requires_cast: false, + }); + rules.push(TypeConversionRule { + source_type: "TIMESTAMP".into(), + target_type: "DATETIME2".into(), + precision_loss: false, + requires_cast: false, + }); + rules.push(TypeConversionRule { + source_type: "TIMESTAMPTZ".into(), + target_type: "DATETIMEOFFSET".into(), + precision_loss: false, + requires_cast: false, + }); + rules.push(TypeConversionRule { + source_type: "JSONB".into(), + target_type: "NVARCHAR(MAX)".into(), + precision_loss: false, + requires_cast: false, + }); + rules.push(TypeConversionRule { + source_type: "JSON".into(), + target_type: "NVARCHAR(MAX)".into(), + precision_loss: false, + requires_cast: false, + }); + } _ => {} } rules @@ -1146,4 +1360,12 @@ mod tests { assert!(!desc.supports_schemas); assert!(!desc.supports_partitioning); } + + #[test] + fn sqlserver_to_postgres_mapping() { + let matrix = TypeMappingMatrix::for_dialects(DialectKind::SqlServer, DialectKind::Postgres); + let (converted, requires_cast) = matrix.convert_type("uniqueidentifier"); + assert_eq!(converted, "UUID"); + assert!(requires_cast); + } } diff --git a/crates/dbx-sql-dialect/src/sql_dialect/inference.rs b/crates/dbx-sql-dialect/src/sql_dialect/inference.rs index db0c6bcccd..29101c6a7a 100644 --- a/crates/dbx-sql-dialect/src/sql_dialect/inference.rs +++ b/crates/dbx-sql-dialect/src/sql_dialect/inference.rs @@ -196,6 +196,8 @@ impl TypeInferenceEngine for DefaultTypeInferenceEngine { ("boolean", "bool"), ("timestamp", "datetime"), ("datetime", "timestamp"), + ("uniqueidentifier", "uuid"), + ("uuid", "uniqueidentifier"), ]; if exact_matches.contains(&(source_lower.as_str(), target_lower.as_str())) { return SCORE_ALIAS; diff --git a/crates/dbx-sql-dialect/src/sql_dialect/type_rewrite.rs b/crates/dbx-sql-dialect/src/sql_dialect/type_rewrite.rs index 589eccc7c5..245c7c3943 100644 --- a/crates/dbx-sql-dialect/src/sql_dialect/type_rewrite.rs +++ b/crates/dbx-sql-dialect/src/sql_dialect/type_rewrite.rs @@ -620,4 +620,13 @@ mod tests { assert_eq!(normalize_len_params("(unclosed"), "(unclosed"); assert_eq!(normalize_len_params(")"), ")"); } + + #[test] + fn postgres_rewrites_uniqueidentifier_to_uuid() { + let target = DatabaseType::Postgres; + let source = Some(DialectKind::SqlServer); + assert_eq!(rewrite_column_type("uniqueidentifier", target, source), "UUID"); + assert_eq!(rewrite_column_type("UNIQUEIDENTIFIER", target, source), "UUID"); + assert_eq!(rewrite_column_type("uniqueidentifier", target, None), "UUID"); + } } diff --git a/crates/dbx-sql-schema/src/schema_diff.rs b/crates/dbx-sql-schema/src/schema_diff.rs index ac33bba9f3..91b9832ae8 100644 --- a/crates/dbx-sql-schema/src/schema_diff.rs +++ b/crates/dbx-sql-schema/src/schema_diff.rs @@ -3219,6 +3219,19 @@ fn column_types_equal_for_dialects( if source_type.eq_ignore_ascii_case(target_type) { return true; } + match (source_dialect, target_dialect) { + (Some(DialectKind::SqlServer), Some(DialectKind::Postgres)) + if source_type.eq_ignore_ascii_case("uniqueidentifier") && target_type.eq_ignore_ascii_case("uuid") => + { + return true; + } + (Some(DialectKind::Postgres), Some(DialectKind::SqlServer)) + if source_type.eq_ignore_ascii_case("uuid") && target_type.eq_ignore_ascii_case("uniqueidentifier") => + { + return true; + } + _ => {} + } if source_dialect != Some(DialectKind::Mysql) || target_dialect != Some(DialectKind::Mysql) { return false; } @@ -3268,6 +3281,8 @@ fn column_type_similarity_score(source_type: &str, target_type: &str) -> f64 { ("boolean", "bool"), ("timestamp", "datetime"), ("datetime", "timestamp"), + ("uniqueidentifier", "uuid"), + ("uuid", "uniqueidentifier"), ]; if exact_matches.contains(&(s.as_str(), t.as_str())) { return 1.0; @@ -15738,4 +15753,22 @@ mod tests { assert!(sql.contains("CREATE OR REPLACE FORCE VIEW DBX_TGT.V_NEW"), "{sql}"); assert!(!sql.contains("DBX_TEST.V_NEW"), "{sql}"); } + + #[test] + fn uniqueidentifier_and_uuid_cross_dialect_equivalence() { + assert!(column_types_equal_for_dialects( + "uniqueidentifier", + "uuid", + Some(DialectKind::SqlServer), + Some(DialectKind::Postgres), + )); + assert!(column_types_equal_for_dialects( + "uuid", + "uniqueidentifier", + Some(DialectKind::Postgres), + Some(DialectKind::SqlServer), + )); + assert_eq!(column_type_similarity_score("uniqueidentifier", "uuid"), 1.0); + assert_eq!(column_type_similarity_score("uuid", "uniqueidentifier"), 1.0); + } }