From 82da9cb6dcfc7acbede45633bf450f213c279bec Mon Sep 17 00:00:00 2001
From: Elias <45232878+mapan0424@users.noreply.github.com>
Date: Thu, 20 Aug 2026 01:52:43 +0800
Subject: [PATCH] feat(xugu): expand table editor data types
---
agents/drivers/xugu/data_types_live_test.go | 144 ++++++++++++++++++
agents/drivers/xugu/main.go | 90 ++++++++---
agents/drivers/xugu/main_test.go | 50 +++++-
.../structure/TableStructureEditor.vue | 16 +-
.../table/tableStructureEditorState.spec.ts | 50 ++++++
.../lib/table/tableStructureEditorState.ts | 111 +++++++++++++-
.../dbx-core/src/table_structure_sql/tests.rs | 42 +++++
7 files changed, 465 insertions(+), 38 deletions(-)
create mode 100644 agents/drivers/xugu/data_types_live_test.go
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");
|