diff --git a/agents/drivers/xugu/data_types_live_test.go b/agents/drivers/xugu/data_types_live_test.go new file mode 100644 index 0000000000..2166bc1a34 --- /dev/null +++ b/agents/drivers/xugu/data_types_live_test.go @@ -0,0 +1,144 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "os" + "strconv" + "strings" + "testing" + "time" +) + +// TestLiveXuguSelectableDataTypes verifies the table-editor choices through the +// same database/sql + go-xugu-driver path used by the agent. It is intentionally +// opt-in because CI does not provide a XuguDB service. +func TestLiveXuguSelectableDataTypes(t *testing.T) { + if os.Getenv("XUGU_LIVE_TEST") != "1" { + t.Skip("set XUGU_LIVE_TEST=1 with XUGU_LIVE_* connection settings to run the XuguDB integration test") + } + params := connectParams{ + Host: os.Getenv("XUGU_LIVE_HOST"), + Port: defaultXuguPort, + Database: os.Getenv("XUGU_LIVE_DATABASE"), + Username: os.Getenv("XUGU_LIVE_USERNAME"), + Password: os.Getenv("XUGU_LIVE_PASSWORD"), + } + if port, err := strconv.Atoi(os.Getenv("XUGU_LIVE_PORT")); err == nil && port > 0 { + params.Port = port + } + if params.Host == "" || params.Database == "" || params.Username == "" || params.Password == "" { + t.Skip("XUGU_LIVE_HOST, XUGU_LIVE_DATABASE, XUGU_LIVE_USERNAME, and XUGU_LIVE_PASSWORD are required") + } + db, err := openDB(params) + if err != nil { + t.Skipf("live XuguDB is unavailable: %v", err) + } + defer db.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + assertLiveXuguDataTypeCatalog(t, ctx, db) + + type dataTypeCase struct { + name string + definition string + valueSQL string + } + tests := []dataTypeCase{ + {name: "tinyint", definition: "TINYINT", valueSQL: "7"}, + {name: "double", definition: "DOUBLE", valueSQL: "1.25"}, + {name: "datetime", definition: "DATETIME", valueSQL: "'2025-06-20 15:16:25'"}, + {name: "datetime_tz", definition: "DATETIME WITH TIME ZONE", valueSQL: "'2025-06-21 17:16:25+08:00'"}, + {name: "time_tz", definition: "TIME(3) WITH TIME ZONE", valueSQL: "'17:30:29.123+08:00'"}, + {name: "timestamp_tz", definition: "TIMESTAMP(6) WITH TIME ZONE", valueSQL: "'2025-06-21 17:16:25.123456+08:00'"}, + {name: "interval_year", definition: "INTERVAL YEAR", valueSQL: "2025"}, + {name: "interval_year_month", definition: "INTERVAL YEAR TO MONTH", valueSQL: "'2025-11'"}, + {name: "interval_month", definition: "INTERVAL MONTH", valueSQL: "1000"}, + {name: "interval_day", definition: "INTERVAL DAY", valueSQL: "1996"}, + {name: "interval_day_hour", definition: "INTERVAL DAY TO HOUR", valueSQL: "'66666 23'"}, + {name: "interval_hour", definition: "INTERVAL HOUR", valueSQL: "5200"}, + {name: "interval_day_minute", definition: "INTERVAL DAY TO MINUTE", valueSQL: "'9999 23:56'"}, + {name: "interval_hour_minute", definition: "INTERVAL HOUR TO MINUTE", valueSQL: "'888:26'"}, + {name: "interval_minute", definition: "INTERVAL MINUTE", valueSQL: "12345"}, + {name: "interval_day_second", definition: "INTERVAL DAY TO SECOND", valueSQL: "'2222 12:56:24.233'"}, + {name: "interval_hour_second", definition: "INTERVAL HOUR TO SECOND", valueSQL: "'9999999:59:59.999999'"}, + {name: "interval_minute_second", definition: "INTERVAL MINUTE TO SECOND", valueSQL: "'9999999:59.999999'"}, + {name: "interval_second", definition: "INTERVAL SECOND", valueSQL: "1999.9898"}, + {name: "guid", definition: "GUID", valueSQL: "SYS_GUID()"}, + {name: "rowid", definition: "ROWID", valueSQL: "NULL"}, + {name: "json", definition: "JSON", valueSQL: `' {"driver":"ok"}'`}, + {name: "bit", definition: "BIT", valueSQL: "B'1'"}, + {name: "varbit", definition: "VARBIT", valueSQL: "B'1010'"}, + {name: "integer_array", definition: "INTEGER[]", valueSQL: "ARRAY[1, 2, 3]"}, + {name: "double_array", definition: "DOUBLE[]", valueSQL: "ARRAY[1.25, 2.5]"}, + {name: "char_array", definition: "CHAR[]", valueSQL: "ARRAY['a', 'b']"}, + {name: "clob_array", definition: "CLOB[]", valueSQL: "ARRAY['first', 'second']"}, + } + + for index, test := range tests { + t.Run(test.name, func(t *testing.T) { + tableName := fmt.Sprintf("DBX_DATA_TYPE_%02d", index) + qualifiedTable := quoteIdentifier(params.Username) + "." + quoteIdentifier(tableName) + _, _ = db.ExecContext(ctx, "DROP TABLE "+qualifiedTable) + defer func() { _, _ = db.ExecContext(context.Background(), "DROP TABLE "+qualifiedTable) }() + + if _, err := db.ExecContext(ctx, "CREATE TABLE "+qualifiedTable+" (\"TYPE_VALUE\" "+test.definition+")"); err != nil { + t.Fatalf("create %s column through go-xugu-driver: %v", test.definition, err) + } + if _, err := db.ExecContext(ctx, "INSERT INTO "+qualifiedTable+" VALUES ("+test.valueSQL+")"); err != nil { + t.Fatalf("insert %s value through go-xugu-driver: %v", test.definition, err) + } + var value any + if err := db.QueryRowContext(ctx, "SELECT \"TYPE_VALUE\" FROM "+qualifiedTable).Scan(&value); err != nil { + t.Fatalf("read %s value through go-xugu-driver: %v", test.definition, err) + } + }) + } +} + +func assertLiveXuguDataTypeCatalog(t *testing.T, ctx context.Context, db queryer) { + t.Helper() + rows, err := db.QueryContext(ctx, "SHOW DATA_TYPES") + if err != nil { + t.Fatalf("SHOW DATA_TYPES through go-xugu-driver: %v", err) + } + defer rows.Close() + columns, err := rows.Columns() + if err != nil { + t.Fatalf("read SHOW DATA_TYPES columns: %v", err) + } + domainNameIndex := -1 + for index, column := range columns { + if strings.EqualFold(column, "DOMAIN_NAME") { + domainNameIndex = index + break + } + } + if domainNameIndex < 0 { + t.Fatalf("SHOW DATA_TYPES does not expose DOMAIN_NAME: %v", columns) + } + found := make(map[string]bool) + for rows.Next() { + values, err := scanRow(rows, len(columns)) + if err != nil { + t.Fatalf("scan SHOW DATA_TYPES: %v", err) + } + if name, ok := values[domainNameIndex].(string); ok { + found[strings.ToUpper(strings.TrimSpace(name))] = true + } + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate SHOW DATA_TYPES: %v", err) + } + for _, required := range []string{"TINYINT", "DOUBLE", "DATETIME", "DATETIME WITH TIME ZONE", "TIME WITH TIME ZONE", "TIMESTAMP WITH TIME ZONE", "INTERVAL DAY TO SECOND", "GUID", "ROWID", "JSON", "BIT", "VARBIT"} { + if !found[required] { + t.Fatalf("SHOW DATA_TYPES is missing %q; returned %v", required, found) + } + } +} + +type queryer interface { + QueryContext(context.Context, string, ...any) (*sql.Rows, error) +} diff --git a/agents/drivers/xugu/main.go b/agents/drivers/xugu/main.go index 07b3ca1ed2..e0c6ba05bf 100644 --- a/agents/drivers/xugu/main.go +++ b/agents/drivers/xugu/main.go @@ -183,33 +183,61 @@ WHERE s.DB_ID = CURRENT_DB_ID ORDER BY p.SUBPARTI_NO` var xuguDataTypes = []string{ - "BOOLEAN", - "INTEGER", - "SMALLINT", "BIGINT", - "FLOAT", - "NUMERIC", - "CHAR", - "VARCHAR", - "CLOB", - "DATE", - "TIME", - "TIMESTAMP", "BINARY", - "VARBINARY", + "BIT", "BLOB", - "XML", "BOOL", - "INT", - "SHORT", - "LONGINT", - "LONG", - "REAL", + "BOOLEAN", + "CHAR", + "CHAR[]", + "CLOB", + "CLOB[]", + "DATE", + "DATETIME", + "DATETIME WITH TIME ZONE", "DECIMAL", - "TEXT", + "DOUBLE", + "DOUBLE[]", + "FLOAT", + "GUID", + "INT", + "INTEGER", + "INTEGER[]", + "INTERVAL DAY", + "INTERVAL DAY TO HOUR", + "INTERVAL DAY TO MINUTE", + "INTERVAL DAY TO SECOND", + "INTERVAL HOUR", + "INTERVAL HOUR TO MINUTE", + "INTERVAL HOUR TO SECOND", + "INTERVAL MINUTE", + "INTERVAL MINUTE TO SECOND", + "INTERVAL MONTH", + "INTERVAL SECOND", + "INTERVAL YEAR", + "INTERVAL YEAR TO MONTH", + "JSON", + "LONG", + "LONGINT", "NCHAR", + "NUMERIC", "NVARCHAR", "NVARCHAR2", + "REAL", + "ROWID", + "SHORT", + "SMALLINT", + "TEXT", + "TIME", + "TIME WITH TIME ZONE", + "TIMESTAMP", + "TIMESTAMP WITH TIME ZONE", + "TINYINT", + "VARBINARY", + "VARBIT", + "VARCHAR", + "XML", } type request struct { @@ -4682,17 +4710,31 @@ func columnTypeDDL(column columnInfo) string { if column.CharacterMaximumLength != nil { return fmt.Sprintf("%s(%d)", dataType, *column.CharacterMaximumLength) } - if column.NumericPrecision != nil && column.NumericScale != nil { - return fmt.Sprintf("%s(%d,%d)", dataType, *column.NumericPrecision, *column.NumericScale) + if column.NumericPrecision != nil { + if column.NumericScale != nil { + return fmt.Sprintf("%s(%d,%d)", dataType, *column.NumericPrecision, *column.NumericScale) + } + return xuguSingleParameterTypeDDL(dataType, *column.NumericPrecision) } return dataType } +func xuguSingleParameterTypeDDL(dataType string, parameter int) string { + switch dataType { + case "TIME WITH TIME ZONE": + return fmt.Sprintf("TIME(%d) WITH TIME ZONE", parameter) + case "TIMESTAMP WITH TIME ZONE": + return fmt.Sprintf("TIMESTAMP(%d) WITH TIME ZONE", parameter) + default: + return fmt.Sprintf("%s(%d)", dataType, parameter) + } +} + func decodeXuguScale(dataType string, scale *int) (*int, *int, *int) { if scale == nil || *scale < 0 { return nil, nil, nil } - upper := strings.ToUpper(dataType) + upper := strings.Join(strings.Fields(strings.ToUpper(dataType)), " ") if strings.Contains(upper, "CHAR") || strings.Contains(upper, "BINARY") { length := *scale return nil, nil, &length @@ -4702,6 +4744,10 @@ func decodeXuguScale(dataType string, scale *int) (*int, *int, *int) { numericScale := *scale % 65536 return &precision, &numericScale, nil } + if upper == "BIT" || upper == "VARBIT" || upper == "TIME" || upper == "TIME WITH TIME ZONE" || upper == "TIMESTAMP" || upper == "TIMESTAMP WITH TIME ZONE" { + precision := *scale + return &precision, nil, nil + } return nil, nil, nil } diff --git a/agents/drivers/xugu/main_test.go b/agents/drivers/xugu/main_test.go index 3d699f8e8c..6fb039d400 100644 --- a/agents/drivers/xugu/main_test.go +++ b/agents/drivers/xugu/main_test.go @@ -361,11 +361,21 @@ func TestListDataTypesReturnsXuguTypes(t *testing.T) { if err := json.Unmarshal(data, &result); err != nil { t.Fatal(err) } - for _, want := range []string{"INTEGER", "VARCHAR", "NUMERIC", "INT"} { + for _, want := range []string{ + "INTEGER", "VARCHAR", "NUMERIC", "INT", + "TINYINT", "DOUBLE", "DATETIME", "DATETIME WITH TIME ZONE", "TIME WITH TIME ZONE", "TIMESTAMP WITH TIME ZONE", + "INTERVAL YEAR", "INTERVAL DAY TO SECOND", "GUID", "ROWID", "JSON", "BIT", "VARBIT", + "INTEGER[]", "DOUBLE[]", "CHAR[]", "CLOB[]", + } { if !contains(result, want) { t.Fatalf("expected data type %q in %v", want, result) } } + for _, pseudoType := range []string{"NULL", `"NULL"`, "ARRAY", "ROWVERSION", "POINT", "LSEG", "LINE", "BOX", "PATH", "POLYGON", "CIRCLE"} { + if contains(result, pseudoType) { + t.Fatalf("pseudo/internal type %q must not be offered as a regular column type: %v", pseudoType, result) + } + } } func TestEmptyResultSlicesMarshalAsArrays(t *testing.T) { @@ -1802,6 +1812,44 @@ func TestDecodeXuguScale(t *testing.T) { if precision != nil || scale != nil || length == nil || *length != 128 { t.Fatalf("unexpected char scale decode: precision=%v scale=%v length=%v", precision, scale, length) } + + for _, test := range []struct { + dataType string + value int + }{ + {dataType: "BIT", value: 8}, + {dataType: "VARBIT", value: 64}, + {dataType: "TIME", value: 3}, + {dataType: "TIME WITH TIME ZONE", value: 3}, + {dataType: "TIMESTAMP", value: 6}, + {dataType: "TIMESTAMP WITH TIME ZONE", value: 6}, + } { + precision, scale, length = decodeXuguScale(test.dataType, &test.value) + if precision == nil || *precision != test.value || scale != nil || length != nil { + t.Fatalf("unexpected %s scale decode: precision=%v scale=%v length=%v", test.dataType, precision, scale, length) + } + } + +} + +func TestColumnTypeDDLPreservesXuguSingleParameters(t *testing.T) { + for _, test := range []struct { + dataType string + precision int + want string + }{ + {dataType: "BIT", precision: 8, want: "BIT(8)"}, + {dataType: "VARBIT", precision: 64, want: "VARBIT(64)"}, + {dataType: "TIME", precision: 3, want: "TIME(3)"}, + {dataType: "TIME WITH TIME ZONE", precision: 3, want: "TIME(3) WITH TIME ZONE"}, + {dataType: "TIMESTAMP", precision: 6, want: "TIMESTAMP(6)"}, + {dataType: "TIMESTAMP WITH TIME ZONE", precision: 6, want: "TIMESTAMP(6) WITH TIME ZONE"}, + } { + column := columnInfo{DataType: test.dataType, NumericPrecision: &test.precision} + if got := columnTypeDDL(column); got != test.want { + t.Fatalf("columnTypeDDL(%s, %d) = %q, want %q", test.dataType, test.precision, got, test.want) + } + } } func TestNormalizeXuguColumnTypeUsesVaryingFlag(t *testing.T) { diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index 91de7c0dad..02a82d9522 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -56,6 +56,7 @@ import { createForeignKeyDrafts, createIndexDrafts, createTriggerDrafts, + dataTypeBaseInputValue, dataTypeLengthInputValue, dataTypeLengthUnitValue, defaultNewColumnDataType, @@ -79,7 +80,6 @@ import { resolveInsertColumnIndex, restoreCharacterLengthUnitsAfterSave, sameStructureIndexType, - splitDataType, tableStructureIdentifierComparisonKey, toColumnNames, } from "@/lib/table/tableStructureEditorState"; @@ -891,13 +891,13 @@ const targetLabel = computed(() => buildStructureTargetLabel(connection.value?.n function isManticoreTextColumn(column: EditableStructureColumn): boolean { if (databaseType.value !== "manticoresearch") return false; - const baseType = splitDataType(column.dataType).baseType.trim().toLowerCase(); + const baseType = dataTypeBaseInputValue(databaseType.value, column.dataType).trim().toLowerCase(); return baseType === "text" || baseType === "string"; } function isManticoreJsonColumn(column: EditableStructureColumn): boolean { if (databaseType.value !== "manticoresearch") return false; - return splitDataType(column.dataType).baseType.trim().toLowerCase() === "json"; + return dataTypeBaseInputValue(databaseType.value, column.dataType).trim().toLowerCase() === "json"; } let sqlPreviewRequestId = 0; @@ -2061,14 +2061,14 @@ function removeMysqlEnumValue(column: EditableStructureColumn, index: number) { } function updateColumnDataTypeLength(column: EditableStructureColumn, value: string | number) { - const baseType = splitDataType(column.dataType).baseType; + const baseType = dataTypeBaseInputValue(databaseType.value, column.dataType); column.dataType = combineDataTypeForDatabaseWithLengthUnit(databaseType.value, baseType, String(value), dataTypeLengthUnitValue(databaseType.value, column.dataType)); syncSqlServerIdentityForDataType(column); syncDamengIdentityForDataType(column); } function updateColumnDataTypeLengthUnit(column: EditableStructureColumn, value: unknown) { - const baseType = splitDataType(column.dataType).baseType; + const baseType = dataTypeBaseInputValue(databaseType.value, column.dataType); const unit = value === "__default" ? "" : String(value ?? ""); column.dataType = combineDataTypeForDatabaseWithLengthUnit(databaseType.value, baseType, dataTypeLengthInputValue(databaseType.value, column.dataType), unit); syncSqlServerIdentityForDataType(column); @@ -2377,7 +2377,7 @@ function isColumnLengthDisabled(column: EditableStructureColumn): boolean { if (isColumnTypeDisabled(column)) { return true; } - const baseType = splitDataType(column.dataType).baseType.trim().toLowerCase(); + const baseType = dataTypeBaseInputValue(databaseType.value, column.dataType).trim().toLowerCase(); return isDataTypeLengthDisabled(databaseType.value, baseType); } @@ -3320,7 +3320,7 @@ watch([activeTab, ddlLoading], ([tab, loading]) => { { :trigger-class="[structureMonoControlClass, 'w-full']" @update:model-value="(v: string) => updateColumnDataType(column, v)" /> - + diff --git a/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts b/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts index 4161ce688c..6d56017b87 100644 --- a/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts @@ -7,6 +7,7 @@ import { createCopiedColumnDrafts, createColumnDrafts, createTriggerDrafts, + dataTypeBaseInputValue, dataTypeLengthInputValue, dataTypeLengthUnitValue, DATA_TYPE_OPTIONS, @@ -410,6 +411,55 @@ describe("tableStructureEditorState", () => { expect(defaultNewColumnDataType("clickhouse")).toBe("String"); }); + it("offers the Xugu types that can be used by the table editor", () => { + for (const dataType of ["TINYINT", "DOUBLE", "DATETIME", "DATETIME WITH TIME ZONE", "TIME WITH TIME ZONE", "TIMESTAMP WITH TIME ZONE", "INTERVAL YEAR", "INTERVAL DAY TO SECOND", "GUID", "ROWID", "JSON", "BIT", "VARBIT", "INTEGER[]", "DOUBLE[]", "CHAR[]", "CLOB[]"]) { + expect(DATA_TYPE_OPTIONS.xugu).toContain(dataType); + } + for (const pseudoType of ["NULL", '"NULL"', "ARRAY", "ROWVERSION", "POINT", "LSEG", "LINE", "BOX", "PATH", "POLYGON", "CIRCLE"]) { + expect(DATA_TYPE_OPTIONS.xugu).not.toContain(pseudoType); + } + }); + + it("keeps Xugu type parameters within syntax the generic editor can emit", () => { + for (const fixedType of ["GUID", "ROWID", "JSON", "XML", "BLOB", "CLOB", "INTEGER[]", "INTERVAL DAY TO SECOND", "DATETIME WITH TIME ZONE"]) { + expect(isDataTypeLengthDisabled("xugu", fixedType)).toBe(true); + expect(combineDataTypeForDatabase("xugu", fixedType, "12")).toBe(fixedType); + } + for (const parameterizedType of ["VARCHAR", "BINARY", "BIT", "VARBIT", "NUMERIC", "TIME", "TIME WITH TIME ZONE", "TIMESTAMP", "TIMESTAMP WITH TIME ZONE"]) { + expect(isDataTypeLengthDisabled("xugu", parameterizedType)).toBe(false); + } + expect(combineDataTypeForDatabase("xugu", "TIME", "3")).toBe("TIME(3)"); + expect(combineDataTypeForDatabase("xugu", "TIME", "4")).toBe("TIME"); + expect(combineDataTypeForDatabase("xugu", "TIMESTAMP", "6")).toBe("TIMESTAMP(6)"); + expect(combineDataTypeForDatabase("xugu", "TIMESTAMP", "7")).toBe("TIMESTAMP"); + expect(combineDataTypeForDatabase("xugu", "TIME WITH TIME ZONE", "3")).toBe("TIME(3) WITH TIME ZONE"); + expect(combineDataTypeForDatabase("xugu", "TIMESTAMP WITH TIME ZONE", "6")).toBe("TIMESTAMP(6) WITH TIME ZONE"); + expect(combineDataTypeForDatabase("xugu", "TIMESTAMP WITH TIME ZONE", "7")).toBe("TIMESTAMP WITH TIME ZONE"); + expect(dataTypeBaseInputValue("xugu", "TIMESTAMP(6) WITH TIME ZONE")).toBe("TIMESTAMP WITH TIME ZONE"); + expect(dataTypeLengthInputValue("xugu", "TIMESTAMP(6) WITH TIME ZONE")).toBe("6"); + + // Xugu-only constraints must not change other database profiles. + expect(isDataTypeLengthDisabled("mysql", "json")).toBe(false); + expect(isDataTypeLengthDisabled("oracle", "clob")).toBe(false); + }); + + it("round-trips Xugu single-parameter metadata through the editor", () => { + const columns = createColumnDrafts( + [ + { name: "flags", data_type: "BIT", is_nullable: true, column_default: null, is_primary_key: false, extra: null, numeric_precision: 8 }, + { name: "bits", data_type: "VARBIT", is_nullable: true, column_default: null, is_primary_key: false, extra: null, numeric_precision: 64 }, + { name: "local_time", data_type: "TIME", is_nullable: true, column_default: null, is_primary_key: false, extra: null, numeric_precision: 3 }, + { name: "created_at", data_type: "TIMESTAMP", is_nullable: true, column_default: null, is_primary_key: false, extra: null, numeric_precision: 6 }, + { name: "created_at_tz", data_type: "TIMESTAMP WITH TIME ZONE", is_nullable: true, column_default: null, is_primary_key: false, extra: null, numeric_precision: 6 }, + { name: "local_time_tz", data_type: "TIME WITH TIME ZONE", is_nullable: true, column_default: null, is_primary_key: false, extra: null, numeric_precision: 3 }, + ], + "xugu", + ); + + expect(columns.map((column) => column.dataType)).toEqual(["BIT(8)", "VARBIT(64)", "TIME(3)", "TIMESTAMP(6)", "TIMESTAMP(6) WITH TIME ZONE", "TIME(3) WITH TIME ZONE"]); + expect(columns.map((column) => column.original?.data_type)).toEqual(columns.map((column) => column.dataType)); + }); + it("requires a SQLite rebuild only for a retained existing column type change", () => { const [column] = createColumnDrafts( [ diff --git a/apps/desktop/src/lib/table/tableStructureEditorState.ts b/apps/desktop/src/lib/table/tableStructureEditorState.ts index ef3591632d..ffe6a38fb4 100644 --- a/apps/desktop/src/lib/table/tableStructureEditorState.ts +++ b/apps/desktop/src/lib/table/tableStructureEditorState.ts @@ -371,7 +371,63 @@ export const DATA_TYPE_OPTIONS: Record = { "interval day to second", ], questdb: ["boolean", "ipv4", "byte", "short", "char", "int", "float", "symbol", "varchar", "string", "long", "date", "timestamp", "timestamp_ns", "double", "uuid", "binary", "long256", "geohash", "array", "interval", "decimal"], - xugu: ["BOOLEAN", "INTEGER", "SMALLINT", "BIGINT", "FLOAT", "NUMERIC", "CHAR", "VARCHAR", "CLOB", "DATE", "TIME", "TIMESTAMP", "BINARY", "VARBINARY", "BLOB", "XML", "BOOL", "INT", "SHORT", "LONGINT", "LONG", "REAL", "DECIMAL", "TEXT", "NCHAR", "NVARCHAR", "NVARCHAR2"], + xugu: [ + "BIGINT", + "BINARY", + "BIT", + "BLOB", + "BOOL", + "BOOLEAN", + "CHAR", + "CHAR[]", + "CLOB", + "CLOB[]", + "DATE", + "DATETIME", + "DATETIME WITH TIME ZONE", + "DECIMAL", + "DOUBLE", + "DOUBLE[]", + "FLOAT", + "GUID", + "INT", + "INTEGER", + "INTEGER[]", + "INTERVAL DAY", + "INTERVAL DAY TO HOUR", + "INTERVAL DAY TO MINUTE", + "INTERVAL DAY TO SECOND", + "INTERVAL HOUR", + "INTERVAL HOUR TO MINUTE", + "INTERVAL HOUR TO SECOND", + "INTERVAL MINUTE", + "INTERVAL MINUTE TO SECOND", + "INTERVAL MONTH", + "INTERVAL SECOND", + "INTERVAL YEAR", + "INTERVAL YEAR TO MONTH", + "JSON", + "LONG", + "LONGINT", + "NCHAR", + "NUMERIC", + "NVARCHAR", + "NVARCHAR2", + "REAL", + "ROWID", + "SHORT", + "SMALLINT", + "TEXT", + "TIME", + "TIME WITH TIME ZONE", + "TIMESTAMP", + "TIMESTAMP WITH TIME ZONE", + "TINYINT", + "VARBINARY", + "VARBIT", + "VARCHAR", + "XML", + ], duckdb: ["BOOLEAN", "TINYINT", "SMALLINT", "INTEGER", "BIGINT", "HUGEINT", "UTINYINT", "USMALLINT", "UINTEGER", "UBIGINT", "FLOAT", "DOUBLE", "DECIMAL", "VARCHAR", "TEXT", "BLOB", "DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ", "INTERVAL", "UUID", "JSON"], h2: ["BOOLEAN", "TINYINT", "SMALLINT", "INTEGER", "BIGINT", "IDENTITY", "DECIMAL", "NUMERIC", "REAL", "DOUBLE", "FLOAT", "CHAR", "CHARACTER", "VARCHAR", "VARCHAR_IGNORECASE", "CLOB", "BINARY", "VARBINARY", "BLOB", "DATE", "TIME", "TIMESTAMP", "TIMESTAMP WITH TIME ZONE", "UUID", "ARRAY", "JSON"], }; @@ -559,6 +615,8 @@ export const POSTGRES_TYPE_LENGTH_DISABLES: string[] = [ export const ORACLE_LIKE_TYPE_LENGTH_DISABLES: string[] = ["binary_double", "binary_float", "bigint", "boolean", "bool", "byte", "date", "double", "double precision", "float", "integer", "int", "long", "long raw", "nclob", "real", "smallint", "text", "tinyint"]; +const XUGU_TYPE_LENGTH_DISABLES = new Set([...ORACLE_LIKE_TYPE_LENGTH_DISABLES, "blob", "clob", "datetime", "datetime with time zone", "guid", "json", "longint", "rowid", "short", "xml"]); + export const SQLSERVER_TYPE_LENGTH_DISABLES: string[] = ["bigint", "bit", "date", "datetime", "image", "int", "integer", "money", "ntext", "real", "smalldatetime", "smallint", "smallmoney", "sql_variant", "text", "timestamp", "tinyint", "uniqueidentifier", "xml"]; export function parseExtraToColumnExtra(extra: string | null | undefined, databaseType?: DatabaseType): ColumnExtra { @@ -753,9 +811,10 @@ function columnDefaultForEditor(column: ColumnInfo, databaseType?: DatabaseType) const CHARACTER_LENGTH_METADATA_TYPES = new Set(["binary", "char", "character", "character varying", "nchar", "nvarchar", "nvarchar2", "varbinary", "varchar", "varchar2"]); 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 { - const parsed = splitDataType(column.data_type); + const parsed = splitDataTypeForDatabase(databaseType, column.data_type); if (parsed.params) return column.data_type; const baseType = parsed.baseType.trim().replace(/\s+/g, " "); @@ -763,6 +822,11 @@ function columnDataTypeForEditor(column: ColumnInfo, databaseType?: DatabaseType if (CHARACTER_LENGTH_METADATA_TYPES.has(normalized) && Number.isInteger(column.character_maximum_length) && Number(column.character_maximum_length) > 0) { return combineDataTypeForDatabase(databaseType, baseType, String(column.character_maximum_length)); } + if (databaseType === "xugu" && XUGU_SINGLE_PRECISION_METADATA_TYPES.has(normalized) && Number.isInteger(column.numeric_precision)) { + const precision = Number(column.numeric_precision); + const minimum = normalized === "bit" || normalized === "varbit" ? 1 : 0; + if (precision >= minimum) return combineDataTypeForDatabase(databaseType, baseType, String(precision)); + } if (NUMERIC_PRECISION_METADATA_TYPES.has(normalized) && Number.isInteger(column.numeric_precision) && Number(column.numeric_precision) > 0) { const scale = Number.isInteger(column.numeric_scale) && Number(column.numeric_scale) >= 0 ? `,${column.numeric_scale}` : ""; return combineDataTypeForDatabase(databaseType, baseType, `${column.numeric_precision}${scale}`); @@ -1047,6 +1111,23 @@ export function splitDataType(raw: string): { baseType: string; params: string } return { baseType, params }; } +function splitDataTypeForDatabase(dbType: DatabaseType | undefined, raw: string): { baseType: string; params: string } { + if (dbType === "xugu") { + const match = raw.trim().match(/^(TIME|TIMESTAMP)\s*\(([^()]*)\)\s+WITH\s+TIME\s+ZONE$/i); + if (match) { + return { + baseType: `${match[1]} WITH TIME ZONE`, + params: match[2]!.trim(), + }; + } + } + return splitDataType(raw); +} + +export function dataTypeBaseInputValue(dbType: DatabaseType | undefined, rawDataType: string): string { + return splitDataTypeForDatabase(dbType, rawDataType).baseType; +} + export type DataTypeLengthUnit = "BYTE" | "CHAR"; const CHARACTER_LENGTH_UNIT_TYPES = new Set(["char", "varchar", "varchar2"]); @@ -1062,7 +1143,7 @@ export function getDataTypeLengthUnitOptions(dbType: DatabaseType | undefined, r } function splitDataTypeLengthParams(dbType: DatabaseType | undefined, rawDataType: string): { length: string; unit: DataTypeLengthUnit | "" } { - const { params } = splitDataType(rawDataType); + const { params } = splitDataTypeForDatabase(dbType, rawDataType); if (!params || getDataTypeLengthUnitOptions(dbType, rawDataType).length === 0) { return { length: params, unit: "" }; } @@ -1158,19 +1239,28 @@ export function combineDataTypeForDatabase(dbType: DatabaseType | undefined, bas 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; return combineDataType(baseType, normalizedParams); } export function dataTypeLengthInputValue(dbType: DatabaseType | undefined, rawDataType: string): string { - const parsed = splitDataType(rawDataType); + const parsed = splitDataTypeForDatabase(dbType, rawDataType); return isDataTypeLengthDisabled(dbType, parsed.baseType) ? "" : splitDataTypeLengthParams(dbType, rawDataType).length; } +function combineXuguTemporalType(baseType: string, params: string, dbType: DatabaseType | undefined): string | null { + if (dbType !== "xugu") return null; + const match = baseType.trim().match(/^(TIME|TIMESTAMP)\s+WITH\s+TIME\s+ZONE$/i); + if (!match) return null; + return params ? `${match[1]}(${params}) WITH TIME ZONE` : baseType.trim(); +} + export function normalizeDataTypeParams(dbType: DatabaseType | undefined, baseType: string, params: string): string { const p = params.trim(); if (!p) return ""; if (!isTemporalPrecisionType(dbType, baseType)) return p; - return isValidTemporalPrecision(dbType, p) ? p : ""; + return isValidTemporalPrecision(dbType, baseType, p) ? p : ""; } function isTemporalPrecisionType(dbType: DatabaseType | undefined, baseType: string): boolean { @@ -1200,6 +1290,8 @@ function isTemporalPrecisionType(dbType: DatabaseType | undefined, baseType: str return ["timestamp", "timestamp with time zone", "timestamp with local time zone"].includes(normalized); case "questdb": return ["timestamp"].includes(normalized); + case "xugu": + return ["time", "time with time zone", "timestamp", "timestamp with time zone"].includes(normalized); default: return false; } @@ -1224,10 +1316,11 @@ function isOracleLikeStructureType(dbType: DatabaseType | undefined): boolean { return dbType === "oracle" || dbType === "dameng" || dbType === "oceanbase-oracle" || dbType === "iris" || dbType === "yashandb" || dbType === "xugu"; } -function isValidTemporalPrecision(dbType: DatabaseType | undefined, params: string): boolean { +function isValidTemporalPrecision(dbType: DatabaseType | undefined, baseType: string, params: string): boolean { if (!/^\d+$/.test(params)) return false; const value = Number(params); - const max = dbType === "oracle" || dbType === "dameng" || dbType === "oceanbase-oracle" ? 9 : 6; + const normalizedBaseType = baseType.trim().replace(/\s+/g, " ").toLowerCase(); + const max = dbType === "xugu" && ["time", "time with time zone"].includes(normalizedBaseType) ? 3 : dbType === "oracle" || dbType === "dameng" || dbType === "oceanbase-oracle" ? 9 : 6; return Number.isInteger(value) && value >= 0 && value <= max && String(value) === params; } @@ -1303,6 +1396,10 @@ export function isDataTypeLengthDisabled(_dbType: DatabaseType | undefined, base return key !== "bit" && key !== "float_vector"; } else if (_dbType === "postgres" || _dbType === "gaussdb" || _dbType === "kwdb" || _dbType === "opengauss" || _dbType === "highgo" || _dbType === "uxdb" || _dbType === "vastbase" || _dbType === "kingbase") { return key.endsWith("[]") || POSTGRES_TYPE_LENGTH_DISABLES.includes(key); + } else if (_dbType === "xugu") { + // Xugu array suffixes and interval qualifiers require grammar-aware + // placement; a generic TYPE(length) editor would emit invalid DDL for them. + return key.endsWith("[]") || key.startsWith("interval ") || XUGU_TYPE_LENGTH_DISABLES.has(key); } else if (isOracleLikeStructureType(_dbType)) { // Dameng/Oracle integer aliases have fixed precision; MySQL-style display widths generate invalid DDL. return ORACLE_LIKE_TYPE_LENGTH_DISABLES.includes(key); diff --git a/crates/dbx-core/src/table_structure_sql/tests.rs b/crates/dbx-core/src/table_structure_sql/tests.rs index 4afdb51fdd..d23cb805b7 100644 --- a/crates/dbx-core/src/table_structure_sql/tests.rs +++ b/crates/dbx-core/src/table_structure_sql/tests.rs @@ -512,6 +512,48 @@ fn builds_postgres_type_change_that_drops_default() { ); } +#[test] +fn builds_xugu_timezone_temporal_precision_in_final_ddl() { + let mut local_time = column("local_time"); + local_time.data_type = "TIME(3) WITH TIME ZONE".to_string(); + let mut created_at = column("created_at"); + created_at.data_type = "TIMESTAMP(6) WITH TIME ZONE".to_string(); + let created = build_create_table_sql(structure_change_options( + DatabaseType::Xugu, + Some("public"), + "events", + vec![local_time, created_at], + )); + assert_eq!( + created.statements, + vec![ + r#"CREATE TABLE "public"."events" ( + "local_time" TIME(3) WITH TIME ZONE, + "created_at" TIMESTAMP(6) WITH TIME ZONE +);"# + ] + ); + + let mut altered_at = column("created_at"); + altered_at.data_type = "TIMESTAMP(6) WITH TIME ZONE".to_string(); + altered_at.original = Some(ColumnInfo { + name: "created_at".to_string(), + data_type: "TIMESTAMP".to_string(), + is_nullable: true, + ..Default::default() + }); + let altered = build_single_column_alter_sql(SingleColumnAlterSqlOptions { + database_type: Some(DatabaseType::Xugu), + schema: Some("public".to_string()), + table_name: "events".to_string(), + column: altered_at, + }); + assert_eq!( + altered.statements, + vec![r#"ALTER TABLE "public"."events" ALTER COLUMN "created_at" TIMESTAMP(6) WITH TIME ZONE;"#] + ); +} + #[test] fn builds_postgres_array_and_domain_type_casts_without_affecting_xugu() { let mut tags = column("tags");