feat(xugu): expand table editor data types

This commit is contained in:
Elias authored and GitHub committed 2026-08-20 01:52:43 +08:00
1 parent 884744df4c
commit 82da9cb6dc
7 files changed
+465 -38

No files matched your search

+144
View File
@@ -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)
}
+68 -22
View File
@@ -183,33 +183,61 @@ WHERE s.DB_ID = CURRENT_DB_ID
ORDER BY p.SUBPARTI_NO` ORDER BY p.SUBPARTI_NO`
var xuguDataTypes = []string{ var xuguDataTypes = []string{
"BOOLEAN",
"INTEGER",
"SMALLINT",
"BIGINT", "BIGINT",
"FLOAT",
"NUMERIC",
"CHAR",
"VARCHAR",
"CLOB",
"DATE",
"TIME",
"TIMESTAMP",
"BINARY", "BINARY",
"VARBINARY", "BIT",
"BLOB", "BLOB",
"XML",
"BOOL", "BOOL",
"INT", "BOOLEAN",
"SHORT", "CHAR",
"LONGINT", "CHAR[]",
"LONG", "CLOB",
"REAL", "CLOB[]",
"DATE",
"DATETIME",
"DATETIME WITH TIME ZONE",
"DECIMAL", "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", "NCHAR",
"NUMERIC",
"NVARCHAR", "NVARCHAR",
"NVARCHAR2", "NVARCHAR2",
"REAL",
"ROWID",
"SHORT",
"SMALLINT",
"TEXT",
"TIME",
"TIME WITH TIME ZONE",
"TIMESTAMP",
"TIMESTAMP WITH TIME ZONE",
"TINYINT",
"VARBINARY",
"VARBIT",
"VARCHAR",
"XML",
} }
type request struct { type request struct {
@@ -4682,17 +4710,31 @@ func columnTypeDDL(column columnInfo) string {
if column.CharacterMaximumLength != nil { if column.CharacterMaximumLength != nil {
return fmt.Sprintf("%s(%d)", dataType, *column.CharacterMaximumLength) return fmt.Sprintf("%s(%d)", dataType, *column.CharacterMaximumLength)
} }
if column.NumericPrecision != nil && column.NumericScale != nil { if column.NumericPrecision != nil {
return fmt.Sprintf("%s(%d,%d)", dataType, *column.NumericPrecision, *column.NumericScale) if column.NumericScale != nil {
return fmt.Sprintf("%s(%d,%d)", dataType, *column.NumericPrecision, *column.NumericScale)
}
return xuguSingleParameterTypeDDL(dataType, *column.NumericPrecision)
} }
return dataType 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) { func decodeXuguScale(dataType string, scale *int) (*int, *int, *int) {
if scale == nil || *scale < 0 { if scale == nil || *scale < 0 {
return nil, nil, nil 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") { if strings.Contains(upper, "CHAR") || strings.Contains(upper, "BINARY") {
length := *scale length := *scale
return nil, nil, &length return nil, nil, &length
@@ -4702,6 +4744,10 @@ func decodeXuguScale(dataType string, scale *int) (*int, *int, *int) {
numericScale := *scale % 65536 numericScale := *scale % 65536
return &precision, &numericScale, nil 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 return nil, nil, nil
} }
+49 -1
View File
@@ -361,11 +361,21 @@ func TestListDataTypesReturnsXuguTypes(t *testing.T) {
if err := json.Unmarshal(data, &result); err != nil { if err := json.Unmarshal(data, &result); err != nil {
t.Fatal(err) 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) { if !contains(result, want) {
t.Fatalf("expected data type %q in %v", want, result) 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) { func TestEmptyResultSlicesMarshalAsArrays(t *testing.T) {
@@ -1802,6 +1812,44 @@ func TestDecodeXuguScale(t *testing.T) {
if precision != nil || scale != nil || length == nil || *length != 128 { if precision != nil || scale != nil || length == nil || *length != 128 {
t.Fatalf("unexpected char scale decode: precision=%v scale=%v length=%v", precision, scale, length) 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) { func TestNormalizeXuguColumnTypeUsesVaryingFlag(t *testing.T) {
@@ -56,6 +56,7 @@ import {
createForeignKeyDrafts, createForeignKeyDrafts,
createIndexDrafts, createIndexDrafts,
createTriggerDrafts, createTriggerDrafts,
dataTypeBaseInputValue,
dataTypeLengthInputValue, dataTypeLengthInputValue,
dataTypeLengthUnitValue, dataTypeLengthUnitValue,
defaultNewColumnDataType, defaultNewColumnDataType,
@@ -79,7 +80,6 @@ import {
resolveInsertColumnIndex, resolveInsertColumnIndex,
restoreCharacterLengthUnitsAfterSave, restoreCharacterLengthUnitsAfterSave,
sameStructureIndexType, sameStructureIndexType,
splitDataType,
tableStructureIdentifierComparisonKey, tableStructureIdentifierComparisonKey,
toColumnNames, toColumnNames,
} from "@/lib/table/tableStructureEditorState"; } from "@/lib/table/tableStructureEditorState";
@@ -891,13 +891,13 @@ const targetLabel = computed(() => buildStructureTargetLabel(connection.value?.n
function isManticoreTextColumn(column: EditableStructureColumn): boolean { function isManticoreTextColumn(column: EditableStructureColumn): boolean {
if (databaseType.value !== "manticoresearch") return false; 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"; return baseType === "text" || baseType === "string";
} }
function isManticoreJsonColumn(column: EditableStructureColumn): boolean { function isManticoreJsonColumn(column: EditableStructureColumn): boolean {
if (databaseType.value !== "manticoresearch") return false; 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; let sqlPreviewRequestId = 0;
@@ -2061,14 +2061,14 @@ function removeMysqlEnumValue(column: EditableStructureColumn, index: number) {
} }
function updateColumnDataTypeLength(column: EditableStructureColumn, value: string | 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)); column.dataType = combineDataTypeForDatabaseWithLengthUnit(databaseType.value, baseType, String(value), dataTypeLengthUnitValue(databaseType.value, column.dataType));
syncSqlServerIdentityForDataType(column); syncSqlServerIdentityForDataType(column);
syncDamengIdentityForDataType(column); syncDamengIdentityForDataType(column);
} }
function updateColumnDataTypeLengthUnit(column: EditableStructureColumn, value: unknown) { 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 ?? ""); const unit = value === "__default" ? "" : String(value ?? "");
column.dataType = combineDataTypeForDatabaseWithLengthUnit(databaseType.value, baseType, dataTypeLengthInputValue(databaseType.value, column.dataType), unit); column.dataType = combineDataTypeForDatabaseWithLengthUnit(databaseType.value, baseType, dataTypeLengthInputValue(databaseType.value, column.dataType), unit);
syncSqlServerIdentityForDataType(column); syncSqlServerIdentityForDataType(column);
@@ -2377,7 +2377,7 @@ function isColumnLengthDisabled(column: EditableStructureColumn): boolean {
if (isColumnTypeDisabled(column)) { if (isColumnTypeDisabled(column)) {
return true; return true;
} }
const baseType = splitDataType(column.dataType).baseType.trim().toLowerCase(); const baseType = dataTypeBaseInputValue(databaseType.value, column.dataType).trim().toLowerCase();
return isDataTypeLengthDisabled(databaseType.value, baseType); return isDataTypeLengthDisabled(databaseType.value, baseType);
} }
@@ -3320,7 +3320,7 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
<td :class="structureCellClass"> <td :class="structureCellClass">
<SearchableSelect <SearchableSelect
v-if="!isColumnTypeDisabled(column)" v-if="!isColumnTypeDisabled(column)"
:model-value="splitDataType(column.dataType).baseType" :model-value="dataTypeBaseInputValue(databaseType, column.dataType)"
:options="dataTypeOptions" :options="dataTypeOptions"
:placeholder="t('structureEditor.typePlaceholder')" :placeholder="t('structureEditor.typePlaceholder')"
:search-placeholder="t('structureEditor.typePlaceholder')" :search-placeholder="t('structureEditor.typePlaceholder')"
@@ -3332,7 +3332,7 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
:trigger-class="[structureMonoControlClass, 'w-full']" :trigger-class="[structureMonoControlClass, 'w-full']"
@update:model-value="(v: string) => updateColumnDataType(column, v)" @update:model-value="(v: string) => updateColumnDataType(column, v)"
/> />
<Input v-else :model-value="gaussdbMDataTypeDisplayName(splitDataType(column.dataType).baseType)" :class="[structureMonoControlClass, 'w-full']" disabled /> <Input v-else :model-value="gaussdbMDataTypeDisplayName(dataTypeBaseInputValue(databaseType, column.dataType))" :class="[structureMonoControlClass, 'w-full']" disabled />
</td> </td>
<td v-if="columnEditorControls.length" :class="structureCellClass"> <td v-if="columnEditorControls.length" :class="structureCellClass">
<Popover v-if="isMysqlEnumDataType(databaseType, column.dataType)"> <Popover v-if="isMysqlEnumDataType(databaseType, column.dataType)">
@@ -7,6 +7,7 @@ import {
createCopiedColumnDrafts, createCopiedColumnDrafts,
createColumnDrafts, createColumnDrafts,
createTriggerDrafts, createTriggerDrafts,
dataTypeBaseInputValue,
dataTypeLengthInputValue, dataTypeLengthInputValue,
dataTypeLengthUnitValue, dataTypeLengthUnitValue,
DATA_TYPE_OPTIONS, DATA_TYPE_OPTIONS,
@@ -410,6 +411,55 @@ describe("tableStructureEditorState", () => {
expect(defaultNewColumnDataType("clickhouse")).toBe("String"); 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", () => { it("requires a SQLite rebuild only for a retained existing column type change", () => {
const [column] = createColumnDrafts( const [column] = createColumnDrafts(
[ [
@@ -371,7 +371,63 @@ export const DATA_TYPE_OPTIONS: Record<string, string[]> = {
"interval day to second", "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"], 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"], 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"], 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"]; 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 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 { 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 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 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 { 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; if (parsed.params) return column.data_type;
const baseType = parsed.baseType.trim().replace(/\s+/g, " "); 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) { 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)); 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) { 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}` : ""; const scale = Number.isInteger(column.numeric_scale) && Number(column.numeric_scale) >= 0 ? `,${column.numeric_scale}` : "";
return combineDataTypeForDatabase(databaseType, baseType, `${column.numeric_precision}${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 }; 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"; export type DataTypeLengthUnit = "BYTE" | "CHAR";
const CHARACTER_LENGTH_UNIT_TYPES = new Set(["char", "varchar", "varchar2"]); 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 | "" } { 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) { if (!params || getDataTypeLengthUnitOptions(dbType, rawDataType).length === 0) {
return { length: params, unit: "" }; return { length: params, unit: "" };
} }
@@ -1158,19 +1239,28 @@ export function combineDataTypeForDatabase(dbType: DatabaseType | undefined, bas
const normalizedParams = normalizeDataTypeParams(dbType, baseType, params); const normalizedParams = normalizeDataTypeParams(dbType, baseType, params);
const mysqlType = combineMysqlNumericAttributeType(dbType, baseType, normalizedParams); const mysqlType = combineMysqlNumericAttributeType(dbType, baseType, normalizedParams);
if (mysqlType) return mysqlType; if (mysqlType) return mysqlType;
const xuguTemporalType = combineXuguTemporalType(baseType, normalizedParams, dbType);
if (xuguTemporalType) return xuguTemporalType;
return combineDataType(baseType, normalizedParams); return combineDataType(baseType, normalizedParams);
} }
export function dataTypeLengthInputValue(dbType: DatabaseType | undefined, rawDataType: string): string { 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; 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 { export function normalizeDataTypeParams(dbType: DatabaseType | undefined, baseType: string, params: string): string {
const p = params.trim(); const p = params.trim();
if (!p) return ""; if (!p) return "";
if (!isTemporalPrecisionType(dbType, baseType)) return p; 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 { 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); return ["timestamp", "timestamp with time zone", "timestamp with local time zone"].includes(normalized);
case "questdb": case "questdb":
return ["timestamp"].includes(normalized); return ["timestamp"].includes(normalized);
case "xugu":
return ["time", "time with time zone", "timestamp", "timestamp with time zone"].includes(normalized);
default: default:
return false; 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"; 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; if (!/^\d+$/.test(params)) return false;
const value = Number(params); 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; 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"; return key !== "bit" && key !== "float_vector";
} else if (_dbType === "postgres" || _dbType === "gaussdb" || _dbType === "kwdb" || _dbType === "opengauss" || _dbType === "highgo" || _dbType === "uxdb" || _dbType === "vastbase" || _dbType === "kingbase") { } 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); 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)) { } else if (isOracleLikeStructureType(_dbType)) {
// Dameng/Oracle integer aliases have fixed precision; MySQL-style display widths generate invalid DDL. // Dameng/Oracle integer aliases have fixed precision; MySQL-style display widths generate invalid DDL.
return ORACLE_LIKE_TYPE_LENGTH_DISABLES.includes(key); return ORACLE_LIKE_TYPE_LENGTH_DISABLES.includes(key);
@@ -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] #[test]
fn builds_postgres_array_and_domain_type_casts_without_affecting_xugu() { fn builds_postgres_array_and_domain_type_casts_without_affecting_xugu() {
let mut tags = column("tags"); let mut tags = column("tags");