fix(duckdb): omit unsupported column type modifiers

Closes #9980
This commit is contained in:
t8y2 committed 2026-09-23 01:54:09 +08:00
1 parent be741259ae
commit 864b1e3c2f
3 files changed
+557 -4

No files matched your search

@@ -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);
@@ -630,6 +630,68 @@ export const SQLSERVER_TYPE_LENGTHS: Record<string, string> = {
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";