diff --git a/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts b/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts index d13eb728b4..64ceeea99a 100644 --- a/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts @@ -34,6 +34,173 @@ import { } from "@/lib/table/tableStructureEditorState"; describe("tableStructureEditorState", () => { + describe("DuckDB type parameters", () => { + it.each([ + "TINYINT", + "INT1", + "SMALLINT", + "INT2", + "SHORT", + "INT16", + "INTEGER", + "INT", + "INT4", + "SIGNED", + "INTEGRAL", + "INT32", + "BIGINT", + "INT8", + "LONG", + "OID", + "INT64", + "HUGEINT", + "INT128", + "UTINYINT", + "UINT8", + "USMALLINT", + "UINT16", + "UINTEGER", + "UINT32", + "UBIGINT", + "UINT64", + "UHUGEINT", + "UINT128", + "REAL", + "FLOAT4", + "DOUBLE", + "DOUBLE PRECISION", + "FLOAT8", + "BOOLEAN", + "BOOL", + "LOGICAL", + "BLOB", + "BYTEA", + "BINARY", + "VARBINARY", + "BIT", + "BITSTRING", + "VARINT", + "BIGNUM", + "DATE", + "TIME", + "TIME WITHOUT TIME ZONE", + "TIME WITH TIME ZONE", + "TIMETZ", + "TIMESTAMPTZ", + "TIMESTAMP WITH TIME ZONE", + "TIMESTAMP_S", + "TIMESTAMP_MS", + "TIMESTAMP_NS", + "UUID", + "GUID", + "JSON", + "INTERVAL", + ])("does not populate, expose, or save modifiers for %s", (baseType) => { + expect(getDefaultLengthForType("duckdb", baseType)).toBe(""); + expect(isDataTypeLengthDisabled("duckdb", baseType)).toBe(true); + expect(dataTypeLengthInputValue("duckdb", `${baseType}(11)`)).toBe(""); + expect(combineDataTypeForDatabase("duckdb", baseType, "11")).toBe(baseType); + expect(combineDataTypeForDatabase("duckdb", baseType.toLowerCase(), "10,2")).toBe(baseType.toLowerCase()); + }); + + it("uses FLOAT mantissa precision instead of MySQL precision and scale", () => { + expect(getDefaultLengthForType("duckdb", "FLOAT")).toBe(""); + expect(isDataTypeLengthDisabled("duckdb", "FLOAT")).toBe(false); + for (const precision of ["1", "24", "25", "53"]) { + expect(combineDataTypeForDatabase("duckdb", "FLOAT", precision)).toBe(`FLOAT(${precision})`); + expect(dataTypeLengthInputValue("duckdb", `FLOAT(${precision})`)).toBe(precision); + } + for (const invalid of ["0", "54", "-1", "1.5", "10,2", "abc", "999999999999999999999999"]) { + expect(combineDataTypeForDatabase("duckdb", "FLOAT", invalid)).toBe("FLOAT"); + expect(dataTypeLengthInputValue("duckdb", `FLOAT(${invalid})`)).toBe(""); + } + }); + + it.each([ + ["DECIMAL", "10,2"], + ["NUMERIC", "38,0"], + ["DEC", "12,3"], + ["VARCHAR", "255"], + ["CHAR", "1"], + ["CHARACTER", "20"], + ["CHARACTER VARYING", "20"], + ["BPCHAR", "20"], + ["STRING", "20"], + ["TEXT", "20"], + ["NVARCHAR", "20"], + ["TIMESTAMP", "9"], + ["DATETIME", "3"], + ["TIMESTAMP_US", "3"], + ])("preserves supported %s parameters", (baseType, params) => { + expect(isDataTypeLengthDisabled("duckdb", baseType)).toBe(false); + expect(combineDataTypeForDatabase("duckdb", baseType, params)).toBe(`${baseType}(${params})`); + expect(dataTypeLengthInputValue("duckdb", `${baseType}(${params})`)).toBe(params); + }); + + it("normalizes stale scalar metadata without rewriting compound or user-defined types", () => { + const cases = [ + ["INTEGER(11)", "INTEGER"], + ["FLOAT(10,2)", "FLOAT"], + ["FLOAT(53)", "FLOAT(53)"], + ["DECIMAL(12,3)", "DECIMAL(12,3)"], + ["VARCHAR(42)", "VARCHAR(42)"], + ["DECIMAL(10,2)[]", "DECIMAL(10,2)[]"], + ["INTEGER[3]", "INTEGER[3]"], + ["STRUCT(id INTEGER, price DECIMAL(10,2))", "STRUCT(id INTEGER, price DECIMAL(10,2))"], + ["MAP(VARCHAR, DECIMAL(10,2))", "MAP(VARCHAR, DECIMAL(10,2))"], + ["UNION(id INTEGER, name VARCHAR)", "UNION(id INTEGER, name VARCHAR)"], + ["ENUM('a', 'b')", "ENUM('a', 'b')"], + ["custom_type(10)", "custom_type(10)"], + ["main.INTEGER(11)", "main.INTEGER(11)"], + ['"INTEGER"(11)', '"INTEGER"(11)'], + ]; + for (const [dataType, expected] of cases) { + const [draft] = createColumnDrafts([{ name: "value", data_type: dataType!, is_nullable: true, column_default: null, is_primary_key: false }], "duckdb"); + expect(draft?.dataType).toBe(expected); + expect(draft?.original?.data_type).toBe(expected); + expect(combineDataTypeForDatabase("duckdb", dataType!, "")).toBe(expected); + } + }); + + it("retains temporal qualifiers instead of confusing them with length", () => { + expect(dataTypeBaseInputValue("duckdb", "TIMESTAMP(6) WITH TIME ZONE")).toBe("TIMESTAMP WITH TIME ZONE"); + expect(dataTypeLengthInputValue("duckdb", "TIMESTAMP(6) WITH TIME ZONE")).toBe(""); + expect(combineDataTypeForDatabase("duckdb", "TIMESTAMP(6) WITH TIME ZONE", "")).toBe("TIMESTAMP WITH TIME ZONE"); + expect(combineDataTypeForDatabase("duckdb", "TIME(6) WITHOUT TIME ZONE", "")).toBe("TIME WITHOUT TIME ZONE"); + expect(dataTypeBaseInputValue("duckdb", "TIMESTAMP(3) WITHOUT TIME ZONE")).toBe("TIMESTAMP WITHOUT TIME ZONE"); + expect(dataTypeLengthInputValue("duckdb", "TIMESTAMP(3) WITHOUT TIME ZONE")).toBe("3"); + expect(combineDataTypeForDatabase("duckdb", "TIMESTAMP WITHOUT TIME ZONE", "3")).toBe("TIMESTAMP(3) WITHOUT TIME ZONE"); + }); + + it("hydrates supported metadata precision without restoring fixed-type lengths", () => { + const columns = [ + { data_type: "INTEGER", numeric_precision: 32 }, + { data_type: "BINARY", character_maximum_length: 255 }, + { data_type: "DECIMAL", numeric_precision: 12, numeric_scale: 3 }, + { data_type: "VARCHAR", character_maximum_length: 64 }, + ].map((column, index) => ({ name: `value_${index}`, is_nullable: true, column_default: null, is_primary_key: false, ...column })); + expect(createColumnDrafts(columns, "duckdb").map((column) => column.dataType)).toEqual(["INTEGER", "BINARY", "DECIMAL(12,3)", "VARCHAR(64)"]); + }); + + it("keeps defaults and other dialects unchanged", () => { + expect(defaultNewColumnDataType("duckdb")).toBe("TEXT"); + expect(getDefaultLengthForType("duckdb", "DECIMAL")).toBe("10,0"); + expect(getDefaultLengthForType("duckdb", "VARCHAR")).toBe("255"); + expect(getDefaultLengthForType("mysql", "INTEGER")).toBe("11"); + expect(getDefaultLengthForType("mysql", "FLOAT")).toBe("10,2"); + expect(combineDataTypeForDatabase("mysql", "INTEGER", "11")).toBe("INTEGER(11)"); + expect(combineDataTypeForDatabase("mysql", "FLOAT", "10,2")).toBe("FLOAT(10,2)"); + expect(combineDataTypeForDatabase("postgres", "INTEGER", "11")).toBe("INTEGER"); + expect(getDefaultLengthForType("postgres", "INTEGER")).toBe("11"); + expect(getDefaultLengthForType("sqlite", "INTEGER")).toBe(""); + expect(combineDataTypeForDatabase("sqlite", "INTEGER", "11")).toBe("INTEGER(11)"); + expect(isDataTypeLengthDisabled("duckdb", "custom_type")).toBe(false); + expect(combineDataTypeForDatabase("duckdb", "custom_type", "10")).toBe("custom_type(10)"); + expect(combineDataTypeForDatabase("duckdb", "DECIMAL", "39,0")).toBe("DECIMAL(39,0)"); + expect(combineDataTypeForDatabase("duckdb", "VARCHAR", "-1")).toBe("VARCHAR(-1)"); + }); + }); + it("keeps quoted mixed-case identifiers distinct when detecting copied-column duplicates", () => { const postgresNames = new Set([tableStructureIdentifierComparisonKey("Foo", "postgres")]); expect(postgresNames.has(tableStructureIdentifierComparisonKey("foo", "postgres"))).toBe(false); diff --git a/apps/desktop/src/lib/table/tableStructureEditorState.ts b/apps/desktop/src/lib/table/tableStructureEditorState.ts index 5a75e2ede7..f9a0e5217d 100644 --- a/apps/desktop/src/lib/table/tableStructureEditorState.ts +++ b/apps/desktop/src/lib/table/tableStructureEditorState.ts @@ -630,6 +630,68 @@ export const SQLSERVER_TYPE_LENGTHS: Record = { export const DEFAULT_TYPE_LENGTH_DISABLES: string[] = []; +const DUCKDB_TYPE_LENGTH_DISABLES = new Set([ + "tinyint", + "int1", + "smallint", + "int2", + "short", + "int16", + "integer", + "int", + "int4", + "signed", + "integral", + "int32", + "bigint", + "int8", + "long", + "oid", + "int64", + "hugeint", + "int128", + "utinyint", + "uint8", + "usmallint", + "uint16", + "uinteger", + "uint32", + "ubigint", + "uint64", + "uhugeint", + "uint128", + "real", + "float4", + "double", + "double precision", + "float8", + "boolean", + "bool", + "logical", + "blob", + "bytea", + "binary", + "varbinary", + "bit", + "bitstring", + "varint", + "bignum", + "date", + "time", + "time without time zone", + "time with time zone", + "timetz", + "timestamptz", + "timestamp with time zone", + "timestamp_s", + "timestamp_ms", + "timestamp_ns", + "uuid", + "guid", + "json", + "interval", +]); + export const POSTGRES_TYPE_LENGTH_DISABLES: string[] = [ "bigint", "int8", @@ -895,6 +957,10 @@ const NUMERIC_PRECISION_METADATA_TYPES = new Set(["decimal", "number", "numeric" const XUGU_SINGLE_PRECISION_METADATA_TYPES = new Set(["bit", "time", "time with time zone", "timestamp", "timestamp with time zone", "varbit"]); function columnDataTypeForEditor(column: ColumnInfo, databaseType?: DatabaseType): string { + if (databaseType === "duckdb") { + const normalized = normalizeDuckdbDataType(column.data_type); + if (normalized !== column.data_type) return normalized; + } const parsed = splitDataTypeForDatabase(databaseType, column.data_type); if (parsed.params) return column.data_type; @@ -1194,6 +1260,10 @@ export function splitDataType(raw: string): { baseType: string; params: string } } function splitDataTypeForDatabase(dbType: DatabaseType | undefined, raw: string): { baseType: string; params: string } { + if (dbType === "duckdb") { + const parsed = splitDuckdbScalarDataType(raw); + if (parsed) return parsed; + } if (dbType === "xugu") { const match = raw.trim().match(/^(TIME|TIMESTAMP)\s*\(([^()]*)\)\s+WITH\s+TIME\s+ZONE$/i); if (match) { @@ -1315,23 +1385,49 @@ export function combineDataType(baseType: string, params: string): string { } export function combineDataTypeForDatabase(dbType: DatabaseType | undefined, baseType: string, params: string): string { + if (dbType === "duckdb") baseType = normalizeDuckdbDataType(baseType); if (isDataTypeLengthDisabled(dbType, baseType)) { return baseType; } const normalizedParams = normalizeDataTypeParams(dbType, baseType, params); const mysqlType = combineMysqlNumericAttributeType(dbType, baseType, normalizedParams); if (mysqlType) return mysqlType; - const xuguTemporalType = combineXuguTemporalType(baseType, normalizedParams, dbType); - if (xuguTemporalType) return xuguTemporalType; + const qualifiedTemporalType = combineQualifiedTemporalType(baseType, normalizedParams, dbType); + if (qualifiedTemporalType) return qualifiedTemporalType; return combineDataType(baseType, normalizedParams); } export function dataTypeLengthInputValue(dbType: DatabaseType | undefined, rawDataType: string): string { const parsed = splitDataTypeForDatabase(dbType, rawDataType); + if (dbType === "duckdb") { + return isDataTypeLengthDisabled(dbType, parsed.baseType) ? "" : normalizeDataTypeParams(dbType, parsed.baseType, parsed.params); + } return isDataTypeLengthDisabled(dbType, parsed.baseType) ? "" : splitDataTypeLengthParams(dbType, rawDataType).length; } -function combineXuguTemporalType(baseType: string, params: string, dbType: DatabaseType | undefined): string | null { +function splitDuckdbScalarDataType(raw: string): { baseType: string; params: string } | null { + const match = raw.trim().match(/^([a-z][a-z0-9_\s]*?)\s*\(([^()]*)\)(?:\s+(WITH(?:OUT)?\s+TIME\s+ZONE))?$/i); + if (!match) return null; + const baseType = match[1]!.trim(); + if (match[3] && !/^(time|timestamp)$/i.test(baseType)) return null; + return { baseType: match[3] ? `${baseType} ${match[3]}` : baseType, params: match[2]!.trim() }; +} + +function normalizeDuckdbDataType(raw: string): string { + const parsed = splitDuckdbScalarDataType(raw); + if (!parsed?.params) return raw; + if (isDataTypeLengthDisabled("duckdb", parsed.baseType)) return parsed.baseType; + if (parsed.baseType.toLowerCase() === "float") { + return combineDataType(parsed.baseType, normalizeDataTypeParams("duckdb", parsed.baseType, parsed.params)); + } + return raw; +} + +function combineQualifiedTemporalType(baseType: string, params: string, dbType: DatabaseType | undefined): string | null { + if (dbType === "duckdb") { + const match = baseType.trim().match(/^(TIMESTAMP)\s+WITHOUT\s+TIME\s+ZONE$/i); + return match ? (params ? `${match[1]}(${params}) WITHOUT TIME ZONE` : baseType.trim()) : null; + } if (dbType !== "xugu") return null; const match = baseType.trim().match(/^(TIME|TIMESTAMP)\s+WITH\s+TIME\s+ZONE$/i); if (!match) return null; @@ -1341,6 +1437,9 @@ function combineXuguTemporalType(baseType: string, params: string, dbType: Datab export function normalizeDataTypeParams(dbType: DatabaseType | undefined, baseType: string, params: string): string { const p = params.trim(); if (!p) return ""; + if (dbType === "duckdb" && baseType.trim().toLowerCase() === "float") { + return /^\d+$/.test(p) && Number(p) >= 1 && Number(p) <= 53 ? p : ""; + } if (!isTemporalPrecisionType(dbType, baseType)) return p; return isValidTemporalPrecision(dbType, baseType, p) ? p : ""; } @@ -1416,6 +1515,7 @@ export interface DataTypeDefaultOptions { export function getDefaultLengthForType(_dbType: DatabaseType | undefined, baseType: string, options: DataTypeDefaultOptions = {}): string { const key = baseType.trim().toLowerCase(); + if (_dbType === "duckdb" && (key === "float" || isDataTypeLengthDisabled(_dbType, baseType))) return ""; if (_dbType === "mysql" && options.omitMysqlDeprecatedDefaults && isMysqlDeprecatedDefaultParameterType(key)) return ""; if (_dbType === "sqlite" || _dbType === "rqlite" || _dbType === "turso") { return ""; @@ -1493,7 +1593,9 @@ function isMysqlDeprecatedDefaultParameterType(baseType: string): boolean { export function isDataTypeLengthDisabled(_dbType: DatabaseType | undefined, baseType: string): boolean { const key = baseType.trim().toLowerCase(); - if (_dbType === "questdb") { + if (_dbType === "duckdb") { + return DUCKDB_TYPE_LENGTH_DISABLES.has(key.replace(/\s+/g, " ")); + } else if (_dbType === "questdb") { return key !== "geohash" && key !== "decimal"; } else if (_dbType === "manticoresearch") { return key !== "bit" && key !== "float_vector"; diff --git a/crates/dbx-sql/src/table_structure_sql/column_format.rs b/crates/dbx-sql/src/table_structure_sql/column_format.rs index 6df5526f29..014f07f5d6 100644 --- a/crates/dbx-sql/src/table_structure_sql/column_format.rs +++ b/crates/dbx-sql/src/table_structure_sql/column_format.rs @@ -254,6 +254,12 @@ fn manticore_column_type(column: &EditableStructureColumn) -> String { pub(super) fn normalize_column_data_type(dialect: StructureDialect, data_type: &str) -> String { let trimmed = data_type.trim(); + #[cfg(feature = "duckdb-sidecar")] + if dialect == StructureDialect::DuckDb { + if let Some(normalized) = normalize_duckdb_type_modifiers(trimmed) { + return normalized; + } + } let Some(open_index) = trimmed.find('(') else { return trimmed.to_string(); }; @@ -300,6 +306,99 @@ pub(super) fn normalize_column_data_type(dialect: StructureDialect, data_type: & trimmed.to_string() } +#[cfg(feature = "duckdb-sidecar")] +fn normalize_duckdb_type_modifiers(data_type: &str) -> Option { + let (raw_base, remainder) = data_type.split_once('(')?; + let (raw_params, suffix) = remainder.split_once(')')?; + let params = raw_params.trim(); + if params.is_empty() || params.contains('(') { + return None; + } + let mut base_type = raw_base.trim().to_string(); + let suffix = suffix.split_whitespace().collect::>().join(" "); + if !suffix.is_empty() { + if !matches!(base_type.to_ascii_lowercase().as_str(), "time" | "timestamp") + || !matches!(suffix.to_ascii_lowercase().as_str(), "with time zone" | "without time zone") + { + return None; + } + base_type.push(' '); + base_type.push_str(&suffix); + } + let normalized = base_type.split_whitespace().collect::>().join(" ").to_ascii_lowercase(); + if matches!( + normalized.as_str(), + "tinyint" + | "int1" + | "smallint" + | "int2" + | "short" + | "int16" + | "integer" + | "int" + | "int4" + | "signed" + | "integral" + | "int32" + | "bigint" + | "int8" + | "long" + | "oid" + | "int64" + | "hugeint" + | "int128" + | "utinyint" + | "uint8" + | "usmallint" + | "uint16" + | "uinteger" + | "uint32" + | "ubigint" + | "uint64" + | "uhugeint" + | "uint128" + | "real" + | "float4" + | "double" + | "double precision" + | "float8" + | "boolean" + | "bool" + | "logical" + | "blob" + | "bytea" + | "binary" + | "varbinary" + | "bit" + | "bitstring" + | "varint" + | "bignum" + | "date" + | "time" + | "time without time zone" + | "time with time zone" + | "timetz" + | "timestamptz" + | "timestamp with time zone" + | "timestamp_s" + | "timestamp_ms" + | "timestamp_ns" + | "uuid" + | "guid" + | "json" + | "interval" + ) { + return Some(base_type); + } + if normalized == "float" + && (!params.bytes().all(|byte| byte.is_ascii_digit()) + || !params.parse::().is_ok_and(|precision| (1..=53).contains(&precision))) + { + return Some(base_type); + } + None +} + fn is_oracle_lengthless_type(base_type: &str) -> bool { let normalized = base_type.split_whitespace().collect::>().join(" ").to_ascii_lowercase(); matches!( @@ -533,3 +632,188 @@ pub(super) fn is_mysql_timestamp_type(data_type: &str) -> bool { }; base_type.eq_ignore_ascii_case("timestamp") } + +#[cfg(all(test, feature = "duckdb-sidecar"))] +mod duckdb_type_parameter_tests { + use super::*; + use crate::table_structure_sql::{build_create_table_sql, build_table_structure_change_sql}; + + #[test] + fn removes_unsupported_scalar_modifiers() { + for base_type in [ + "TINYINT", + "INT1", + "SMALLINT", + "INT2", + "SHORT", + "INT16", + "INTEGER", + "INT", + "INT4", + "SIGNED", + "INTEGRAL", + "INT32", + "BIGINT", + "INT8", + "LONG", + "OID", + "INT64", + "HUGEINT", + "INT128", + "UTINYINT", + "UINT8", + "USMALLINT", + "UINT16", + "UINTEGER", + "UINT32", + "UBIGINT", + "UINT64", + "UHUGEINT", + "UINT128", + "REAL", + "FLOAT4", + "DOUBLE", + "DOUBLE PRECISION", + "FLOAT8", + "BOOLEAN", + "BOOL", + "LOGICAL", + "BLOB", + "BYTEA", + "BINARY", + "VARBINARY", + "BIT", + "BITSTRING", + "VARINT", + "BIGNUM", + "DATE", + "TIME", + "TIME WITHOUT TIME ZONE", + "TIME WITH TIME ZONE", + "TIMETZ", + "TIMESTAMPTZ", + "TIMESTAMP WITH TIME ZONE", + "TIMESTAMP_S", + "TIMESTAMP_MS", + "TIMESTAMP_NS", + "UUID", + "GUID", + "JSON", + "INTERVAL", + ] { + for params in ["11", "10,2"] { + let data_type = format!("{base_type}({params})"); + assert_eq!(normalize_column_data_type(StructureDialect::DuckDb, &data_type), base_type); + } + } + assert_eq!(normalize_column_data_type(StructureDialect::DuckDb, " integer (11) "), "integer"); + assert_eq!( + normalize_column_data_type(StructureDialect::DuckDb, "TIMESTAMP(6) WITH TIME ZONE"), + "TIMESTAMP WITH TIME ZONE" + ); + assert_eq!( + normalize_column_data_type(StructureDialect::DuckDb, "TIME(6) WITHOUT TIME ZONE"), + "TIME WITHOUT TIME ZONE" + ); + } + + #[test] + fn preserves_float_mantissa_bits_and_drops_invalid_float_parameters() { + for precision in ["1", "24", "25", "53"] { + let data_type = format!("FLOAT({precision})"); + assert_eq!(normalize_column_data_type(StructureDialect::DuckDb, &data_type), data_type); + } + for invalid in ["0", "54", "-1", "1.5", "10,2", "abc", "999999999999999999999999"] { + assert_eq!(normalize_column_data_type(StructureDialect::DuckDb, &format!("FLOAT({invalid})")), "FLOAT"); + } + } + + #[test] + fn preserves_supported_compound_unknown_and_other_dialect_types() { + for data_type in [ + "INTEGER", + "DECIMAL(10,2)", + "NUMERIC(38,0)", + "DEC(12,3)", + "VARCHAR(255)", + "CHAR(1)", + "BPCHAR(20)", + "CHARACTER VARYING(20)", + "CHARACTER(20)", + "TEXT(20)", + "STRING(20)", + "NVARCHAR(20)", + "TIMESTAMP(9)", + "DATETIME(3)", + "TIMESTAMP_US(3)", + "TIMESTAMP(3) WITHOUT TIME ZONE", + "DECIMAL(10,2)[]", + "INTEGER[3]", + "INTEGER(11)[]", + "STRUCT(id INTEGER, price DECIMAL(10,2))", + "MAP(VARCHAR, DECIMAL(10,2))", + "UNION(id INTEGER, name VARCHAR)", + "ENUM('a', 'b')", + "custom_type(10)", + "main.INTEGER(11)", + "\"INTEGER\"(11)", + "INTEGER(", + "INTEGER()", + "INTEGER(11) trailing", + "DECIMAL(39,0)", + "VARCHAR(-1)", + ] { + assert_eq!(normalize_column_data_type(StructureDialect::DuckDb, data_type), data_type); + } + for dialect in [StructureDialect::Mysql, StructureDialect::Postgres, StructureDialect::Sqlite] { + for data_type in ["INTEGER(11)", "FLOAT(10,2)", "DECIMAL(10,2)", "VARCHAR(255)"] { + assert_eq!(normalize_column_data_type(dialect, data_type), data_type); + } + } + } + + #[test] + fn normalizes_create_and_add_column_save_paths() { + let options: TableStructureSqlOptions = serde_json::from_value(serde_json::json!({ + "databaseType": "duckdb", "tableName": "issue_9980", + "columns": [ + {"id": "new:id", "name": "id", "dataType": "INTEGER(11)", "isNullable": true}, + {"id": "new:ratio", "name": "ratio", "dataType": "FLOAT(10,2)", "isNullable": true}, + {"id": "new:amount", "name": "amount", "dataType": "DECIMAL(10,2)", "isNullable": true}, + {"id": "new:label", "name": "label", "dataType": "VARCHAR(255)", "isNullable": true} + ] + })) + .unwrap(); + let created = build_create_table_sql(options.clone()); + assert!(created.warnings.is_empty(), "{:?}", created.warnings); + assert_eq!(created.statements, vec![ + "CREATE TABLE \"issue_9980\" (\n \"id\" INTEGER,\n \"ratio\" FLOAT,\n \"amount\" DECIMAL(10,2),\n \"label\" VARCHAR(255)\n);" + ]); + let altered = build_table_structure_change_sql(options); + assert!(altered.warnings.is_empty(), "{:?}", altered.warnings); + assert_eq!( + altered.statements, + vec![ + "ALTER TABLE \"issue_9980\" ADD COLUMN \"id\" INTEGER;", + "ALTER TABLE \"issue_9980\" ADD COLUMN \"ratio\" FLOAT;", + "ALTER TABLE \"issue_9980\" ADD COLUMN \"amount\" DECIMAL(10,2);", + "ALTER TABLE \"issue_9980\" ADD COLUMN \"label\" VARCHAR(255);", + ] + ); + } + + #[test] + fn retains_existing_column_edit_boundary() { + let options = serde_json::from_value(serde_json::json!({ + "databaseType": "duckdb", "tableName": "issue_9980", + "column": { + "id": "existing:id", "name": "id", "dataType": "INTEGER(11)", "isNullable": true, + "original": {"name": "id", "data_type": "SMALLINT", "is_nullable": true, "column_default": null} + } + })) + .unwrap(); + let altered = crate::table_structure_sql::build_single_column_alter_sql(options); + assert!(altered.statements.is_empty()); + assert_eq!(altered.warnings, vec!["Editing existing columns is not supported for duckdb yet."]); + } +}