diff --git a/agents/drivers/iotdb/README.md b/agents/drivers/iotdb/README.md index a420a6163e..161e9e47ad 100644 --- a/agents/drivers/iotdb/README.md +++ b/agents/drivers/iotdb/README.md @@ -48,6 +48,9 @@ for TLS or mTLS connections. paging, and sequential batch/transaction execution. - Table sessions expose databases, tables, TIME/TAG/FIELD column categories, comments, queries, DDL, paging, and database switching. +- Query results annotate timestamp columns as `TIMESTAMP(ms|us|ns)` using the + server-reported `TimestampPrecision`. Raw timestamp integers are transported + as decimal strings so nanosecond values are not rounded by JavaScript. - The Agent keeps one physical IoTDB session per logical DBX session and invalidates that session after cancellation, timeout, or connection failure. diff --git a/agents/drivers/iotdb/driver.go b/agents/drivers/iotdb/driver.go index a72f12004d..1997b37b0e 100644 --- a/agents/drivers/iotdb/driver.go +++ b/agents/drivers/iotdb/driver.go @@ -78,8 +78,9 @@ type connectionConfig struct { } type sessionClient struct { - session iotdbSession - dialect string + session iotdbSession + dialect string + timestampPrecision string } func parseConnectionConfig(params connectParams) (connectionConfig, error) { @@ -263,9 +264,64 @@ func newSessionClient(config connectionConfig) (*sessionClient, error) { } } } + precisionTimeout := min(time.Duration(config.ConnectTimeoutMS)*time.Millisecond, 5*time.Second) + precisionCtx, precisionCancel := context.WithTimeout(context.Background(), precisionTimeout) + connected.timestampPrecision, _ = queryTimestampPrecision(precisionCtx, wrapped) + precisionCancel() return connected, nil } +func queryTimestampPrecision(ctx context.Context, session iotdbSession) (string, error) { + dataset, err := session.ExecuteQueryStatement(ctx, "SHOW VARIABLES", nil) + if err != nil { + return "", err + } + defer dataset.Close() + columns := dataset.GetColumnNames() + variableIndex, valueIndex := int32(1), int32(2) + for index, column := range columns { + switch strings.ToLower(strings.TrimSpace(column)) { + case "variable": + variableIndex = int32(index + 1) + case "value": + valueIndex = int32(index + 1) + } + } + for { + hasNext, err := dataset.Next() + if err != nil { + return "", err + } + if !hasNext { + return "", errors.New("IoTDB SHOW VARIABLES did not return TimestampPrecision") + } + variable, err := dataset.GetStringByIndex(variableIndex) + if err != nil { + return "", err + } + if !strings.EqualFold(strings.ReplaceAll(strings.TrimSpace(variable), "_", ""), "TimestampPrecision") { + continue + } + value, err := dataset.GetStringByIndex(valueIndex) + if err != nil { + return "", err + } + if precision := normalizeTimestampPrecision(value); precision != "" { + return precision, nil + } + return "", fmt.Errorf("unsupported IoTDB timestamp precision: %s", value) + } +} + +func normalizeTimestampPrecision(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "ms", "us", "ns": + return strings.ToLower(strings.TrimSpace(value)) + default: + return "" + } +} + func (s *sessionClient) Close() error { if s == nil || s.session == nil { return nil diff --git a/agents/drivers/iotdb/integration_test.go b/agents/drivers/iotdb/integration_test.go index 392df5ffc5..796a3c9416 100644 --- a/agents/drivers/iotdb/integration_test.go +++ b/agents/drivers/iotdb/integration_test.go @@ -43,6 +43,9 @@ func TestLiveIoTDBAgentTreeAndTable(t *testing.T) { if err != nil || len(page.Rows) != 2 || !page.HasMore || page.SessionID == nil { t.Fatalf("tree first page = %#v, %v", page, err) } + if page.ColumnTypes[0] != "TIMESTAMP(ms)" || page.Rows[0][0] != "1" { + t.Fatalf("tree time column = types %#v row %#v", page.ColumnTypes, page.Rows[0]) + } lastPage, err := treeServer.fetchQueryPage(*page.SessionID, 2) if err != nil || len(lastPage.Rows) != 1 || lastPage.HasMore || !lastPage.Truncated { t.Fatalf("tree final page = %#v, %v", lastPage, err) @@ -62,15 +65,15 @@ func TestLiveIoTDBAgentTreeAndTable(t *testing.T) { mustExecuteNonQuery(t, tableServer, "CREATE DATABASE "+quoteTableIdentifier(tableDatabase), "") mustExecuteNonQuery(t, tableServer, "CREATE TABLE "+quoteTableIdentifier(tableDatabase)+"."+quoteTableIdentifier("d1")+ - " (time TIMESTAMP TIME, device STRING TAG, s1 INT64 FIELD COMMENT 'value') COMMENT 'DBX live test' WITH (TTL='INF')", + " (time TIMESTAMP TIME, device STRING TAG, event_time TIMESTAMP FIELD, s1 INT64 FIELD COMMENT 'value') COMMENT 'DBX live test' WITH (TTL='INF')", tableDatabase, ) mustExecuteNonQuery(t, tableServer, - "INSERT INTO "+quoteTableIdentifier("d1")+"(time,device,s1) VALUES(1,'a',10),(2,'a',20)", + "INSERT INTO "+quoteTableIdentifier("d1")+"(time,device,event_time,s1) VALUES(1,'a',1001,10),(2,'a',1002,20)", tableDatabase, ) tableColumns, err := tableServer.getColumns(tableDatabase, "d1") - if err != nil || len(tableColumns) != 3 || !tableColumns[0].IsPrimaryKey || !tableColumns[1].IsPrimaryKey { + if err != nil || len(tableColumns) != 4 || !tableColumns[0].IsPrimaryKey || !tableColumns[1].IsPrimaryKey || tableColumns[2].IsPrimaryKey { t.Fatalf("table getColumns() = %#v, %v", tableColumns, err) } comment, err := tableServer.getTableComment(tableDatabase, "d1") @@ -78,7 +81,7 @@ func TestLiveIoTDBAgentTreeAndTable(t *testing.T) { t.Fatalf("table comment = %#v, %v", comment, err) } result, err := tableServer.executeQuery(queryOptions{SQL: "SELECT * FROM d1 ORDER BY time", Database: tableDatabase, MaxRows: 10}) - if err != nil || len(result.Rows) != 2 || result.Rows[0][0] != "1970-01-01T08:00:00.001+08:00" { + if err != nil || len(result.Rows) != 2 || result.ColumnTypes[0] != "TIMESTAMP(ms)" || result.ColumnTypes[2] != "TIMESTAMP(ms)" || result.Rows[0][0] != "1" || result.Rows[0][2] != "1001" { t.Fatalf("table query = %#v, %v", result, err) } tableDDL, err := tableServer.getTableDDL(tableDatabase, "d1") diff --git a/agents/drivers/iotdb/main_test.go b/agents/drivers/iotdb/main_test.go index a7c6564c46..678011fb8c 100644 --- a/agents/drivers/iotdb/main_test.go +++ b/agents/drivers/iotdb/main_test.go @@ -120,8 +120,8 @@ func TestMetadataHelpers(t *testing.T) { func TestNormalizeIoTDBValues(t *testing.T) { when := time.Date(2026, time.August, 10, 12, 34, 56, 123, time.FixedZone("CST", 8*60*60)) - if got := normalizeIoTDBValue(when, "TIMESTAMP"); got != "2026-08-10T12:34:56.000000123+08:00" { - t.Fatalf("unexpected timestamp: %#v", got) + if got := normalizeIoTDBValue(when, "DATETIME"); got != "2026-08-10T12:34:56.000000123+08:00" { + t.Fatalf("unexpected datetime: %#v", got) } if got := normalizeIoTDBValue(when, "DATE"); got != "2026-08-10" { t.Fatalf("unexpected date: %#v", got) @@ -131,6 +131,53 @@ func TestNormalizeIoTDBValues(t *testing.T) { } } +func TestTreeTimeColumnPresentation(t *testing.T) { + columns := []string{"Time", "root.db.d1.s1"} + for _, precision := range []string{"ms", "us", "ns"} { + types := normalizedColumnTypes([]string{"INT64", "DOUBLE"}, columns, client.TreeSqlDialect, precision) + want := []string{"TIMESTAMP(" + precision + ")", "DOUBLE"} + if !reflect.DeepEqual(types, want) { + t.Fatalf("unexpected tree column types for %s: %#v", precision, types) + } + } +} + +func TestTableTimestampColumnPresentation(t *testing.T) { + columns := []string{"time", "device", "event_time"} + for _, precision := range []string{"ms", "us", "ns"} { + got := normalizedColumnTypes([]string{"TIMESTAMP", "STRING", "TIMESTAMP"}, columns, client.TableSqlDialect, precision) + want := []string{"TIMESTAMP(" + precision + ")", "STRING", "TIMESTAMP(" + precision + ")"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected table column types for %s: %#v", precision, got) + } + } +} + +func TestUnknownTimestampPrecisionDoesNotEnableFormattingMetadata(t *testing.T) { + columns := []string{"Time", "root.db.d1.s1"} + if got := normalizedColumnTypes([]string{"INT64", "DOUBLE"}, columns, client.TreeSqlDialect, ""); !reflect.DeepEqual(got, []string{"TIMESTAMP", "DOUBLE"}) { + t.Fatalf("unexpected unknown-precision types: %#v", got) + } + for _, value := range []string{"ms", "US", " ns "} { + if normalizeTimestampPrecision(value) == "" { + t.Fatalf("expected supported precision %q", value) + } + } + if normalizeTimestampPrecision("seconds") != "" { + t.Fatal("unsupported precision must not be accepted") + } +} + +func TestTreeTimeColumnPresentationDoesNotRewriteOrdinaryInt64(t *testing.T) { + columns := []string{"Time", "value"} + if got := normalizedColumnTypes([]string{"INT64", "INT64"}, columns, client.TableSqlDialect, "ms"); !reflect.DeepEqual(got, []string{"INT64", "INT64"}) { + t.Fatalf("unexpected table column types: %#v", got) + } + if got := normalizedColumnTypes([]string{"INT64", "INT64"}, []string{"value", "Time"}, client.TreeSqlDialect, "ms"); !reflect.DeepEqual(got, []string{"INT64", "INT64"}) { + t.Fatalf("unexpected non-axis column types: %#v", got) + } +} + func TestClassifyIoTDBErrors(t *testing.T) { canceled := classifyRPCError("execute_query", "session-1", context.Canceled) if canceled.Data.Category != "canceled" || canceled.Data.SessionDisposition != "quarantine" { diff --git a/agents/drivers/iotdb/query.go b/agents/drivers/iotdb/query.go index 59380d6676..9f2433a4d6 100644 --- a/agents/drivers/iotdb/query.go +++ b/agents/drivers/iotdb/query.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "math" + "strconv" "strings" "time" @@ -87,8 +88,8 @@ func (s *server) queryValues(sql, database string, limit, timeoutSecs int) (quer } defer dataset.Close() columns := append([]string(nil), dataset.GetColumnNames()...) - columnTypes := normalizedColumnTypes(dataset.GetColumnTypes(), len(columns)) - rows, truncated, err := readDataset(ctx, dataset, connected.dialect, columns, columnTypes, limit) + columnTypes := normalizedColumnTypes(dataset.GetColumnTypes(), columns, connected.dialect, connected.timestampPrecision) + rows, truncated, err := readDataset(ctx, dataset, connected, columns, columnTypes, limit) if err != nil { return queryResult{}, err } @@ -163,7 +164,7 @@ func (s *server) executeQueryPage(options queryOptions, requestedPageSize int) ( dataset: dataset, client: connected, columns: append([]string(nil), dataset.GetColumnNames()...), - columnTypes: normalizedColumnTypes(dataset.GetColumnTypes(), len(dataset.GetColumnNames())), + columnTypes: normalizedColumnTypes(dataset.GetColumnTypes(), dataset.GetColumnNames(), connected.dialect, connected.timestampPrecision), remaining: limit, } rows, hasMore, truncated, err := s.readQuerySessionPage(ctx, queryState, pageSize) @@ -279,7 +280,7 @@ func (s *server) nextDatasetRow(ctx context.Context, state *querySession) ([]any if err := ctx.Err(); err != nil { return nil, false, err } - row, err := datasetRow(state.dataset, state.client.dialect, state.columns, state.columnTypes) + row, err := datasetRow(state.dataset, state.client, state.columns, state.columnTypes) return row, true, err } @@ -372,7 +373,7 @@ func runCancelable[T any](s *server, timeoutSecs int, connected *sessionClient, return value, err } -func readDataset(ctx context.Context, dataset *client.SessionDataSet, dialect string, columns, columnTypes []string, limit int) ([][]any, bool, error) { +func readDataset(ctx context.Context, dataset *client.SessionDataSet, connected *sessionClient, columns, columnTypes []string, limit int) ([][]any, bool, error) { if limit <= 0 { limit = defaultMaxRows } @@ -391,7 +392,7 @@ func readDataset(ctx context.Context, dataset *client.SessionDataSet, dialect st if err := ctx.Err(); err != nil { return nil, false, err } - row, err := datasetRow(dataset, dialect, columns, columnTypes) + row, err := datasetRow(dataset, connected, columns, columnTypes) if err != nil { return nil, false, err } @@ -404,7 +405,7 @@ func readDataset(ctx context.Context, dataset *client.SessionDataSet, dialect st return rows, hasNext, err } -func datasetRow(dataset *client.SessionDataSet, dialect string, columns, columnTypes []string) ([]any, error) { +func datasetRow(dataset *client.SessionDataSet, connected *sessionClient, columns, columnTypes []string) ([]any, error) { row := make([]any, len(columns)) for index := range columns { columnIndex := int32(index + 1) @@ -412,12 +413,20 @@ func datasetRow(dataset *client.SessionDataSet, dialect string, columns, columnT if index < len(columnTypes) { columnType = strings.ToUpper(columnTypes[index]) } - if dialect == client.TreeSqlDialect && index == 0 && strings.EqualFold(columns[index], "Time") { - value, err := dataset.GetStringByIndex(columnIndex) + if iotdbColumnTypeBase(columnType) == "TIMESTAMP" { + isNull, err := dataset.IsNullByIndex(columnIndex) if err != nil { return nil, err } - row[index] = value + if isNull { + row[index] = nil + continue + } + value, err := dataset.GetLongByIndex(columnIndex) + if err != nil { + return nil, err + } + row[index] = strconv.FormatInt(value, 10) continue } value, err := dataset.GetObjectByIndex(columnIndex) @@ -455,16 +464,37 @@ func normalizeIoTDBValue(value any, columnType string) any { } } -func normalizedColumnTypes(values []string, columnCount int) []string { - result := make([]string, columnCount) +func normalizedColumnTypes(values, columns []string, dialect, timestampPrecision string) []string { + result := make([]string, len(columns)) for index := range result { if index < len(values) { result[index] = strings.ToUpper(values[index]) } + if isTreeTimeColumn(dialect, columns, index) { + result[index] = "TIMESTAMP" + } + if iotdbColumnTypeBase(result[index]) == "TIMESTAMP" { + result[index] = timestampColumnType(timestampPrecision) + } } return result } +func timestampColumnType(precision string) string { + if normalized := normalizeTimestampPrecision(precision); normalized != "" { + return "TIMESTAMP(" + normalized + ")" + } + return "TIMESTAMP" +} + +func iotdbColumnTypeBase(value string) string { + return strings.TrimSpace(strings.SplitN(strings.ToUpper(value), "(", 2)[0]) +} + +func isTreeTimeColumn(dialect string, columns []string, index int) bool { + return dialect == client.TreeSqlDialect && index == 0 && len(columns) > 0 && strings.EqualFold(columns[0], "Time") +} + func timeoutMilliseconds(timeoutSecs int) *int64 { if timeoutSecs <= 0 { return nil diff --git a/agents/drivers/yashandb/src/main/java/com/dbx/agent/yashandb/YashandbAgent.java b/agents/drivers/yashandb/src/main/java/com/dbx/agent/yashandb/YashandbAgent.java index abe8a675d0..ecf21396b7 100644 --- a/agents/drivers/yashandb/src/main/java/com/dbx/agent/yashandb/YashandbAgent.java +++ b/agents/drivers/yashandb/src/main/java/com/dbx/agent/yashandb/YashandbAgent.java @@ -39,10 +39,10 @@ public final class YashandbAgent extends ConfiguredJdbcAgent { @Override protected JdbcExecutor.ResultValueReader resultValueReader() { - return (JdbcExecutor.ColumnAwareResultValueReader) this::resultValue; + return (JdbcExecutor.ColumnAwareResultValueReader) this::readYashandbValue; } - private Object resultValue(ResultSet resultSet, int index, int sqlType, String columnTypeName) throws SQLException { + private Object readYashandbValue(ResultSet resultSet, int index, int sqlType, String columnTypeName) throws SQLException { if (!isStructuredValue(sqlType, columnTypeName)) { return super.resultValue(resultSet, index, sqlType); } diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 421f15befd..da4e5acb7e 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -147,7 +147,21 @@ import { import { buildBinaryHexViewRows } from "@/lib/dataGrid/binaryHexViewer"; import { canFormatCellDetailJson, cellDetailEditorText, compactJsonText, defaultCellDetailTab, formatJsonText, isGeometryColumnType, linkedCellDetailTarget, looksLikeJsonContainerText, valueEditorActions, visibleCellDetailTabs, type CellDetailTab } from "@/lib/dataGrid/cellDetailPresentation"; import { buildDataGridCellDetail, buildDataGridColumnDetail, buildDataGridRowDetail, CELL_DETAIL_VALUE_PREVIEW_MAX_LENGTH, dataGridColumnDetailJson, dataGridColumnDetailTsv, dataGridRowDetailJson, dataGridRowDetailTsv, type DataGridCellDetail } from "@/lib/dataGrid/dataGridDetail"; -import { applyColumnFormatter, buildColumnFormatterKey, getSupportedTimeZoneOptions, normalizeColumnFormatter, resolveColumnFormatter, type ColumnFormatterConfig, type DateTimeFormatterUnit, DateTimePatterns } from "@/lib/dataGrid/columnFormatter"; +import { + applyColumnFormatter, + buildColumnFormatterKey, + defaultIoTDBTimestampFormatter, + formatIoTDBTimestampEditorValue, + getSupportedTimeZoneOptions, + iotdbTimestampFractionDigits, + iotdbTimestampPrecision, + normalizeColumnFormatter, + parseIoTDBTimestampEditorValue, + resolveColumnFormatter, + type ColumnFormatterConfig, + type DateTimeFormatterUnit, + DateTimePatterns, +} from "@/lib/dataGrid/columnFormatter"; import { temporalCellEditorConfig, type TemporalCellEditorConfig } from "@/lib/dataGrid/dataGridTemporalEditor"; import { BOOLEAN_CELL_EDITOR_VALUES, booleanCellEditorValue, isBooleanCellValue, isBooleanColumnType, isPointInBooleanCheckbox, nextBooleanCellValue, normalizeBooleanCellValue, parseBooleanCellEditorValue } from "@/lib/dataGrid/dataGridBooleanColumn"; import { resolveDataGridColumnNullability, resolveDataGridColumnsByResultIndex } from "@/lib/dataGrid/dataGridColumnMetadata"; @@ -910,7 +924,7 @@ const filterEditorView = computed(() => settingsStore.editorSettings.dataGridFil const structuredFilterCount = computed(() => structuredFilterRules.value.filter((rule) => !rule.disabled && !!rule.columnName && filterModeHasCompleteValue(rule.mode, rule.rawValue, rule.rawEndValue)).length); const hasStructuredFilters = computed(() => !!combineWhereInputs(undefined, appliedStructuredWhereInput.value)); const formatterOpenColumn = ref(null); -type FormatterDraftKind = Exclude; +type FormatterDraftKind = Exclude; const CUSTOM_FORMATTER_NEW = "__new"; const formatterKind = ref("datetime"); const formatterDateUnit = ref("auto"); @@ -1274,10 +1288,14 @@ function columnFormatter(columnIndex: number): ColumnFormatterConfig | undefined const column = props.result.columns[columnIndex]; if (!column) return undefined; const key = formatterKeyForColumn(column); - return resolveColumnFormatter(key ? settingsStore.editorSettings.columnFormatters[key] : undefined, settingsStore.editorSettings.customColumnFormatters, { + const columnType = props.result.column_types?.[columnIndex] ?? tableColumnForGridColumn(columnIndex)?.data_type; + const savedFormatter = key ? settingsStore.editorSettings.columnFormatters[key] : undefined; + const configured = resolveColumnFormatter(savedFormatter, settingsStore.editorSettings.customColumnFormatters, { pattern: settingsStore.editorSettings.globalDateTimeDisplayFormat, - columnType: props.result.column_types?.[columnIndex] ?? tableColumnForGridColumn(columnIndex)?.data_type, + columnType, }); + if (savedFormatter && configured) return configured; + return defaultIoTDBTimestampFormatter(resolvedDatabaseType.value, columnType, resolvedConnectionConfig.value?.url_params) ?? configured; } function savedColumnFormatter(columnIndex: number): ColumnFormatterConfig | undefined { @@ -1332,11 +1350,15 @@ function loadFormatterDraft(formatter: ColumnFormatterConfig | undefined) { pattern: "YYYY-MM-DD HH:mm:ss" as const, timezone: dayjs.tz.guess(), }; - formatterKind.value = draft.kind === "custom-ref" ? "custom-template" : draft.kind; + formatterKind.value = draft.kind === "custom-ref" ? "custom-template" : draft.kind === "iotdb-timestamp" ? "datetime" : draft.kind; if (draft.kind === "datetime") { formatterDateUnit.value = draft.unit; formatterDatetimePattern.value = draft.pattern; formatterDateTimezone.value = draft.timezone || dayjs.tz.guess() || "UTC"; + } else if (draft.kind === "iotdb-timestamp") { + formatterDateUnit.value = "milliseconds"; + formatterDatetimePattern.value = "YYYY-MM-DDTHH:mm:ss.SSSZ"; + formatterDateTimezone.value = draft.timezone; } else if (draft.kind === "json-path") { formatterJsonPath.value = draft.path; } else if (draft.kind === "mask") { @@ -2316,6 +2338,11 @@ function measureColumnHeaderText(text: string): number | undefined { return Math.ceil(columnHeaderMeasureContext.measureText(text).width); } +function columnWidthDisplayValue(value: CellValue, columnIndex: number): CellValue { + const formatter = columnFormatter(columnIndex); + return formatter ? applyColumnFormatter(value, formatter) : value; +} + const { initColumnWidths, onResizeStart, autoFitColumn, renderedColumnWidths, totalWidth, columnVars, getIsResizing } = useDataGridColumnResize({ columns: visibleColumns, sourceRows: computed(() => props.result.rows), @@ -2328,6 +2355,7 @@ const { initColumnWidths, onResizeStart, autoFitColumn, renderedColumnWidths, to measureHeaderText: measureColumnHeaderText, headerMeasurementKey: columnHeaderMeasurementKey, rowNumberWidth, + displayValue: columnWidthDisplayValue, }); const gridStyle = computed(() => ({ ...columnVars.value, @@ -3596,7 +3624,9 @@ async function startCellEdit(rowId: number, columnIndex: number, expanded: boole if (!(await hydrateLargeValueCell(rowId, columnIndex))) return; closeReadonlyCellTextSelection(); expandedCellEditor.value = expanded ? { rowId, col: columnIndex } : null; + const item = getRowItem(rowId); startEdit(rowId, columnIndex); + if (item) editValue.value = inlineCellEditorText(item.data[columnIndex] ?? null, columnIndex); } async function startDomCellEdit(rowId: number, columnIndex: number, displayText: string, event: MouseEvent) { @@ -3716,9 +3746,44 @@ function resultColumnInfoForGridColumn(columnIndex: number): Pick { if (!cell) return false; const item = getRowItem(cell.rowId); if (!item || item.isDeleted) return false; - const originalValue = dataGridCellEditorText({ - value: item.data[cell.col] ?? null, - databaseType: resolvedDatabaseType.value, - columnInfo: tableColumnForGridColumn(cell.col), - }); + const originalValue = inlineCellEditorText(item.data[cell.col] ?? null, cell.col); return editValue.value !== originalValue; }); const hasPendingDataEditorDraft = computed(() => hasPendingDetailEditorDraft.value || hasPendingInlineEditorDraft.value); @@ -5285,7 +5346,7 @@ watch(activeCellDetail, (detail) => { const detailTemporalEditorConfig = computed(() => { const detail = activeCellDetail.value; - return detail ? temporalEditorConfigForColumn(detail.colIndex) : undefined; + return detail && !isIoTDBTimestampColumn(detail.colIndex) ? temporalEditorConfigForColumn(detail.colIndex) : undefined; }); const sideDetailValueFillsHeight = computed(() => cellDetailPanelIsBottom.value || isEditingDetail.value || (!cellDetailPanelIsBottom.value && !activeCellDetail.value?.imagePreviewUrl)); const canCompactDetailJson = computed(() => { @@ -7871,7 +7932,15 @@ function deleteCurrentRow(): boolean { return true; } +function currentIoTDBTimestampEditValue(): CellValue | undefined { + const cell = editingCell.value; + if (!cell || !isIoTDBTimestampColumn(cell.col)) return undefined; + const columnType = props.result.column_types?.[cell.col] ?? tableColumnForGridColumn(cell.col)?.data_type; + return parseIoTDBTimestampEditorValue(editValue.value, resolvedDatabaseType.value, columnType, resolvedConnectionConfig.value?.url_params); +} + function commitGridEdit(value?: CellValue) { + if (value === undefined) value = currentIoTDBTimestampEditValue(); void commitEditAndMaybeAutoSave(value === undefined ? undefined : { explicitValue: value }).finally(() => nextTick(() => gridRef.value?.focus({ preventScroll: true }))); } @@ -7891,15 +7960,17 @@ function commitBooleanGridEdit(value?: string | null) { async function commitEditFromCellBlur() { const target = pendingQuickEntryDraftCellFocus.value; pendingQuickEntryDraftCellFocus.value = null; + const timestamp = currentIoTDBTimestampEditValue(); + const timestampOptions = timestamp === undefined ? {} : { explicitValue: timestamp }; if (target && editingCell.value?.rowId === quickEntryDraftRowId && target.rowId === quickEntryDraftRowId) { - await commitEditFromBlur({ promoteDraft: false }); + await commitEditFromBlur({ ...timestampOptions, promoteDraft: false }); nextTick(() => { const item = getRowItem(target.rowId); if (item && canEditCellItem(item, target.col)) void startCellEdit(target.rowId, target.col, false); }); return; } - await commitEditFromBlur(); + await commitEditFromBlur(timestampOptions); } function onRowNumberMouseDown(item: RowItem, event: MouseEvent) { @@ -10905,6 +10976,7 @@ function currentGridContextMenuItems(): ContextMenuItem[] { v-model="editValue" :kind="temporalEditorConfigForColumn(cell.valueIndex)!.kind" :fraction-precision="temporalEditorConfigForColumn(cell.valueIndex)!.fractionPrecision" + :normalize-value="(value) => normalizeTemporalCellEditorValue(value, cell.valueIndex)" cell-layout="transpose" @cancel="cancelEdit" @commit="commitGridEdit" @@ -11495,6 +11567,7 @@ function currentGridContextMenuItems(): ContextMenuItem[] { v-model="editValue" :kind="temporalEditorConfigForColumn(canvasEditingCell.actualColIdx)!.kind" :fraction-precision="temporalEditorConfigForColumn(canvasEditingCell.actualColIdx)!.fractionPrecision" + :normalize-value="(value) => normalizeTemporalCellEditorValue(value, canvasEditingCell!.actualColIdx)" @cancel="cancelEdit" @commit="commitGridEdit" /> @@ -11700,6 +11773,7 @@ function currentGridContextMenuItems(): ContextMenuItem[] { v-model="editValue" :kind="temporalEditorConfigForColumn(col.actualColIdx)!.kind" :fraction-precision="temporalEditorConfigForColumn(col.actualColIdx)!.fractionPrecision" + :normalize-value="(value) => normalizeTemporalCellEditorValue(value, col.actualColIdx)" @cancel="cancelEdit" @commit="commitGridEdit" /> diff --git a/apps/desktop/src/components/grid/TemporalCellEditor.vue b/apps/desktop/src/components/grid/TemporalCellEditor.vue index e8d9faf355..e0a44b4ff8 100644 --- a/apps/desktop/src/components/grid/TemporalCellEditor.vue +++ b/apps/desktop/src/components/grid/TemporalCellEditor.vue @@ -14,6 +14,7 @@ const props = withDefaults( cellLayout?: "grid" | "transpose"; commitOnClose?: boolean; fractionPrecision?: number; + normalizeValue?: (value: string) => string; }>(), { variant: "cell", @@ -99,9 +100,10 @@ function setOpen(value: boolean) { if (!value && props.commitOnClose && !closeHandled && !skipCommitOnClose) finishCommit(); } -function setModelValue(value: string) { - localValue.value = value; - emit("update:modelValue", value); +function setModelValue(value: string, normalize = false) { + const nextValue = normalize ? (props.normalizeValue?.(value) ?? value) : value; + localValue.value = nextValue; + emit("update:modelValue", nextValue); } function updateTextInput(event: Event) { @@ -125,14 +127,14 @@ function updateDateFromInput(part: "day" | "month" | "year", event: Event) { } function stepDate(part: "day" | "month" | "year", delta: number) { - setModelValue(stepTemporalInputValue(localValue.value, props.kind, part, delta)); + setModelValue(stepTemporalInputValue(localValue.value, props.kind, part, delta), true); } function updateTime(part: "hour" | "minute" | "second", rawValue: string | number) { const parts = { ...timeParts.value, [part]: normalizeTimePart(rawValue, part === "hour" ? 23 : 59) }; const nextTime = `${parts.hour}:${parts.minute}:${parts.second}${fractionSuffix.value}`; if (props.kind === "time") { - setModelValue(nextTime); + setModelValue(nextTime, true); return; } setDateTimeValue(dateParts.value.year, dateParts.value.month, dateParts.value.day, nextTime); @@ -143,7 +145,7 @@ function updateTimeFromInput(part: "hour" | "minute" | "second", event: Event) { } function stepTime(part: "hour" | "minute" | "second", delta: number) { - setModelValue(stepTemporalInputValue(localValue.value, props.kind, part, delta)); + setModelValue(stepTemporalInputValue(localValue.value, props.kind, part, delta), true); } const fractionSuffix = computed(() => { @@ -160,7 +162,7 @@ function updateFractionValue(rawValue: string) { const digits = rawValue.replace(/\D/g, "").slice(0, maxLength); const nextTime = `${timeParts.value.hour}:${timeParts.value.minute}:${timeParts.value.second}${digits ? `.${digits}` : ""}`; if (props.kind === "time") { - setModelValue(nextTime); + setModelValue(nextTime, true); return; } setDateTimeValue(dateParts.value.year, dateParts.value.month, dateParts.value.day, nextTime); @@ -179,20 +181,20 @@ function flushInputValue(target: EventTarget | null) { } function normalizeTextInputValue() { - setModelValue(parseTemporalInputValue(localValue.value, props.kind) ?? ""); + setModelValue(parseTemporalInputValue(localValue.value, props.kind) ?? "", true); } function setNull() { - setModelValue("NULL"); + setModelValue("NULL", true); } function setNow() { const now = new Date(); const dateText = [String(now.getFullYear()).padStart(4, "0"), String(now.getMonth() + 1).padStart(2, "0"), String(now.getDate()).padStart(2, "0")].join("-"); const nextTime = [String(now.getHours()).padStart(2, "0"), String(now.getMinutes()).padStart(2, "0"), String(now.getSeconds()).padStart(2, "0")].join(":") + nowFractionSuffix(now); - if (props.kind === "date") setModelValue(dateText); - else if (props.kind === "time") setModelValue(nextTime); - else setModelValue(`${dateText} ${nextTime}`); + if (props.kind === "date") setModelValue(dateText, true); + else if (props.kind === "time") setModelValue(nextTime, true); + else setModelValue(`${dateText} ${nextTime}`, true); } function finishCommit() { @@ -264,8 +266,8 @@ function normalizeTimePart(value: string | number, max: number): string { function setDateTimeValue(year: number, month: number, day: number, time: string) { const dateText = [String(year).padStart(4, "0"), String(month).padStart(2, "0"), String(day).padStart(2, "0")].join("-"); - if (props.kind === "date") setModelValue(dateText); - else setModelValue(`${dateText} ${time}`); + if (props.kind === "date") setModelValue(dateText, true); + else setModelValue(`${dateText} ${time}`, true); } function parseFractionDigits(value: string): string { diff --git a/apps/desktop/src/composables/__tests__/useDataGridColumnResize.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridColumnResize.spec.ts index 406ec7676d..ec75bba0f8 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridColumnResize.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridColumnResize.spec.ts @@ -16,6 +16,7 @@ function createResizeState(options: { compactColumnHeaderActions?: boolean; indexIndicatorColumnIndexes?: number[] | ReturnType>; headerTextWidth?: number; + displayValue?: (value: string | number | boolean | null, columnIndex: number) => string | number | boolean | null; }) { const compact = ref(options.compactColumnHeaderActions ?? true); const headerTextWidth = ref(options.headerTextWidth); @@ -34,6 +35,7 @@ function createResizeState(options: { columnStructureSignature: computed(() => createDataGridColumnStructureSignature(options.columns, options.columnTypes)), measureHeaderText: () => headerTextWidth.value, headerMeasurementKey, + displayValue: options.displayValue, }); return { ...state, @@ -107,6 +109,23 @@ describe("useDataGridColumnResize", () => { expect(state.columnWidths.value[0]).toBeGreaterThan(60); }); + it("measures formatted display values instead of raw source values", () => { + const raw = createResizeState({ + columns: ["event_time"], + rows: [[1786958306456]], + }); + const formatted = createResizeState({ + columns: ["event_time"], + rows: [[1786958306456]], + displayValue: () => "2026-08-17T17:18:26.456+08:00", + }); + + raw.initColumnWidths(); + formatted.initColumnWidths(); + + expect(formatted.columnWidths.value[0]).toBeGreaterThan(raw.columnWidths.value[0]); + }); + it("clamps manual column resizing to the minimum width", () => { expect(resizeDataGridColumnWidth(120, -200)).toBe(DATA_GRID_COL_MIN_WIDTH); expect(resizeDataGridColumnWidth(120, 30)).toBe(150); diff --git a/apps/desktop/src/composables/useDataGridColumnResize.ts b/apps/desktop/src/composables/useDataGridColumnResize.ts index 5943a7d19e..9a153a6be2 100644 --- a/apps/desktop/src/composables/useDataGridColumnResize.ts +++ b/apps/desktop/src/composables/useDataGridColumnResize.ts @@ -42,6 +42,7 @@ export interface UseDataGridColumnResizeOptions { measureHeaderText?: (text: string) => number | undefined; headerMeasurementKey?: Ref; rowNumberWidth?: Ref | ComputedRef; + displayValue?: (value: CellValue, columnIndex: number) => CellValue; } export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions) { @@ -68,7 +69,8 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions) const actualColIdx = columnIndexes.value[visibleColIdx]; if (actualColIdx === undefined) return []; const preset = COLUMN_WIDTH_DENSITY_PRESETS[density.value]; - return sampleDataGridColumnValues(sourceRows.value, actualColIdx, preset.sampleRows); + const values = sampleDataGridColumnValues(sourceRows.value, actualColIdx, preset.sampleRows); + return options.displayValue ? values.map((value) => options.displayValue!(value, actualColIdx)) : values; } function neededColumnWidth(colIdx: number): number { diff --git a/apps/desktop/src/lib/__tests__/dataGrid/columnFormatter.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/columnFormatter.spec.ts index 232e42a3fe..7461277661 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/columnFormatter.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/columnFormatter.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { normalizeSupportedDateTimePattern } from "@/lib/dataGrid/columnFormatter"; +import { applyColumnFormatter, defaultIoTDBTimestampFormatter, formatIoTDBTimestampEditorValue, normalizeSupportedDateTimePattern, parseIoTDBTimestampEditorValue } from "@/lib/dataGrid/columnFormatter"; describe("normalizeSupportedDateTimePattern", () => { it("accepts the format grammar shared by the frontend and backend", () => { @@ -12,3 +12,54 @@ describe("normalizeSupportedDateTimePattern", () => { expect(normalizeSupportedDateTimePattern("%Y-%m-%d")).toBe(""); }); }); + +describe("defaultIoTDBTimestampFormatter", () => { + it.each([ + ["ms", "1786954706123", "2026-08-17T16:18:26.123+08:00"], + ["us", "1786954706123456", "2026-08-17T16:18:26.123456+08:00"], + ["ns", "1786954706123456789", "2026-08-17T16:18:26.123456789+08:00"], + ])("formats %s precision in the connection time zone without replacing the raw value", (precision, rawValue, expected) => { + const formatter = defaultIoTDBTimestampFormatter("iotdb", `TIMESTAMP(${precision})`, "time_zone=Asia%2FShanghai"); + const row = [rawValue]; + + expect(applyColumnFormatter(row[0], formatter)).toBe(expected); + expect(row).toEqual([rawValue]); + }); + + it("does not guess when precision metadata is absent or invalid", () => { + expect(defaultIoTDBTimestampFormatter("iotdb", "TIMESTAMP", "time_zone=Asia%2FShanghai")).toBeUndefined(); + expect(defaultIoTDBTimestampFormatter("iotdb", "TIMESTAMP(seconds)", "time_zone=Asia%2FShanghai")).toBeUndefined(); + expect(defaultIoTDBTimestampFormatter("mysql", "TIMESTAMP(ms)", "time_zone=Asia%2FShanghai")).toBeUndefined(); + expect(defaultIoTDBTimestampFormatter("iotdb", "INT64", "time_zone=Asia%2FShanghai")).toBeUndefined(); + }); + + it("uses UTC when the connection does not specify a time zone", () => { + const formatter = defaultIoTDBTimestampFormatter("iotdb", "TIMESTAMP(ms)", ""); + expect(applyColumnFormatter(1, formatter)).toBe("1970-01-01T00:00:00.001+00:00"); + }); + + it("round-trips a negative nanosecond timestamp without truncating toward zero", () => { + const formatter = defaultIoTDBTimestampFormatter("iotdb", "TIMESTAMP(ns)", "time_zone=UTC"); + const display = "1969-12-31T23:59:59.999999999+00:00"; + expect(applyColumnFormatter("-1", formatter)).toBe(display); + expect(parseIoTDBTimestampEditorValue(display, "iotdb", "TIMESTAMP(ns)", "time_zone=UTC")).toBe("-1"); + }); + + it.each([ + ["ms", "1786954706123", "2026-08-17T16:18:26.123+08:00"], + ["us", "1786954706123456", "2026-08-17T16:18:26.123456+08:00"], + ["ns", "1786954706123456789", "2026-08-17T16:18:26.123456789+08:00"], + ])("round-trips %s precision through the temporal editor", (precision, rawValue, editorValue) => { + const columnType = `TIMESTAMP(${precision})`; + expect(formatIoTDBTimestampEditorValue(rawValue, "iotdb", columnType, "time_zone=Asia%2FShanghai")).toBe(editorValue); + expect(parseIoTDBTimestampEditorValue(editorValue, "iotdb", columnType, "time_zone=UTC")).toBe(rawValue); + expect(parseIoTDBTimestampEditorValue(editorValue.replace("T", " ").replace("+08:00", ""), "iotdb", columnType, "time_zone=Asia%2FShanghai")).toBe(rawValue); + }); + + it("keeps invalid and unrelated editor values on the generic path", () => { + expect(parseIoTDBTimestampEditorValue("2026-02-30 10:00:00", "iotdb", "TIMESTAMP(ms)", "time_zone=Asia%2FShanghai")).toBeUndefined(); + expect(parseIoTDBTimestampEditorValue("2026-08-17 10:00:00.1234", "iotdb", "TIMESTAMP(ms)", "time_zone=Asia%2FShanghai")).toBeUndefined(); + expect(parseIoTDBTimestampEditorValue("2026-08-17 10:00:00", "mysql", "TIMESTAMP(ms)", "time_zone=Asia%2FShanghai")).toBeUndefined(); + expect(parseIoTDBTimestampEditorValue("2026-08-17 10:00:00", "iotdb", "TIMESTAMP", "time_zone=Asia%2FShanghai")).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/lib/dataGrid/columnFormatter.ts b/apps/desktop/src/lib/dataGrid/columnFormatter.ts index a67e4c3c36..885709a282 100644 --- a/apps/desktop/src/lib/dataGrid/columnFormatter.ts +++ b/apps/desktop/src/lib/dataGrid/columnFormatter.ts @@ -9,6 +9,7 @@ dayjs.extend(utc); dayjs.extend(timezone); export type DateTimeFormatterUnit = "seconds" | "milliseconds" | "auto"; +export type IoTDBTimestampPrecision = "ms" | "us" | "ns"; const DEFAULT_DATETIME_PATTERN = "YYYY-MM-DD HH:mm:ss"; export const DateTimePatterns = [ "YYYY-MM-DD", @@ -101,6 +102,7 @@ export function normalizeSupportedDateTimePattern(value: string): string { export type ColumnFormatterConfig = | { kind: "datetime"; unit: DateTimeFormatterUnit; pattern: string; timezone: string | undefined } + | { kind: "iotdb-timestamp"; precision: IoTDBTimestampPrecision; timezone: string } | { kind: "json-path"; path: string } | { kind: "mask"; prefix: number; suffix: number } | { kind: "foreign-key-display"; refSchema?: string; refTable: string; refColumn: string; displayColumn: string } @@ -212,6 +214,77 @@ export function resolveColumnFormatter(formatter: ColumnFormatterConfig | undefi return customFormatter ? { kind: "custom-template", template: customFormatter.template } : undefined; } +export function defaultIoTDBTimestampFormatter(databaseType: string | undefined, columnType: string | null | undefined, urlParams: string | undefined): ColumnFormatterConfig | undefined { + const precision = iotdbTimestampPrecision(databaseType, columnType); + if (!precision) return undefined; + const timezone = iotdbTimestampTimeZone(urlParams); + return { kind: "iotdb-timestamp", precision, timezone }; +} + +export function formatIoTDBTimestampEditorValue(value: CellValue, databaseType: string | undefined, columnType: string | null | undefined, urlParams: string | undefined): string | undefined { + const formatter = defaultIoTDBTimestampFormatter(databaseType, columnType, urlParams); + if (!formatter || formatter.kind !== "iotdb-timestamp") return undefined; + return formatIoTDBTimestamp(value, formatter.precision, formatter.timezone); +} + +export function parseIoTDBTimestampEditorValue(value: string, databaseType: string | undefined, columnType: string | null | undefined, urlParams: string | undefined): string | null | undefined { + const precision = iotdbTimestampPrecision(databaseType, columnType); + if (!precision) return undefined; + const text = value.trim(); + if (!text || text.toUpperCase() === "NULL") return null; + if (isIntegerString(text)) { + try { + return BigInt(text).toString(); + } catch { + return undefined; + } + } + + const offsetMatch = text.match(ISO_OFFSET_DATETIME_PATTERN); + if (offsetMatch) { + const [, year, separator, month, day, hour, minute, second, fraction = "", zone] = offsetMatch; + if (!isValidDateTimeParts(year, month, day, hour, minute, second) || !isValidOffset(zone)) return undefined; + const baseMilliseconds = Date.parse(`${year}${separator}${month}${separator}${day}T${hour}:${minute}:${second}${zone}`); + if (!Number.isFinite(baseMilliseconds)) return undefined; + return iotdbRawTimestamp(baseMilliseconds, fraction.slice(1), precision); + } + + const match = text.match(FRACTIONAL_LOCAL_DATETIME_PATTERN) ?? text.match(/^(\d{4})([-/])(\d{1,2})\2(\d{1,2})([ T])(\d{1,2}):(\d{1,2}):(\d{1,2})$/); + if (!match) return undefined; + const [, year, , month, day, , hour, minute, second] = match; + if (!isValidDateTimeParts(year, month, day, hour, minute, second)) return undefined; + const fraction = match.length > 9 ? String(match[9] ?? "") : ""; + const normalized = `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}T${hour.padStart(2, "0")}:${minute.padStart(2, "0")}:${second.padStart(2, "0")}`; + const pattern = "YYYY-MM-DDTHH:mm:ss"; + try { + const parsed = dayjs.tz(normalized, pattern, iotdbTimestampTimeZone(urlParams)); + return parsed.isValid() && parsed.format(pattern) === normalized ? iotdbRawTimestamp(parsed.valueOf(), fraction, precision) : undefined; + } catch { + return undefined; + } +} + +export function iotdbTimestampPrecision(databaseType: string | undefined, columnType: string | null | undefined): IoTDBTimestampPrecision | undefined { + if (databaseType !== "iotdb") return undefined; + const match = columnType?.trim().match(/^TIMESTAMP\((ms|us|ns)\)$/i); + return match?.[1]?.toLowerCase() as IoTDBTimestampPrecision | undefined; +} + +export function iotdbTimestampFractionDigits(precision: IoTDBTimestampPrecision): number { + return precision === "ms" ? 3 : precision === "us" ? 6 : 9; +} + +function iotdbTimestampTimeZone(urlParams: string | undefined): string { + const params = new URLSearchParams(urlParams ?? ""); + const candidate = params.get("time_zone") || params.get("timezone") || params.get("zone_id") || "UTC"; + try { + dayjs(0).tz(candidate); + return candidate; + } catch { + return "UTC"; + } +} + export function formatTemporalRowsForExport(rows: readonly (readonly T[])[], columnTypes: readonly (string | null | undefined)[], pattern: string): T[][] { const normalizedPattern = normalizeGlobalDateTimePattern(pattern); if (!normalizedPattern) return rows.map((row) => [...row]); @@ -248,6 +321,7 @@ export function applyColumnFormatter(value: CellValue, formatter: ColumnFormatte if (!formatter) return displayCellValue(value); try { + if (formatter.kind === "iotdb-timestamp") return formatIoTDBTimestamp(value, formatter.precision, formatter.timezone) ?? displayCellValue(value); if (formatter.kind === "datetime") return formatDateTime(value, formatter.unit, formatter.pattern, formatter.timezone); if (formatter.kind === "json-path") return formatJsonPath(value, formatter.path); if (formatter.kind === "mask") return formatMask(value, formatter); @@ -378,6 +452,43 @@ function parseTimestampMilliseconds(value: string | number, unit: DateTimeFormat return isAutoTimestampValue(value, numeric) ? convertToMilliseconds(numeric, unit) : undefined; } +function formatIoTDBTimestamp(value: CellValue, precision: IoTDBTimestampPrecision, timezone: string): string | undefined { + if (typeof value !== "number" && (typeof value !== "string" || !isIntegerString(value))) return undefined; + let raw: bigint; + try { + raw = BigInt(value); + } catch { + return undefined; + } + const fractionDigits = iotdbTimestampFractionDigits(precision); + const factor = 10n ** BigInt(fractionDigits); + let seconds = raw / factor; + let fraction = raw % factor; + if (fraction < 0) { + seconds -= 1n; + fraction += factor; + } + const milliseconds = seconds * 1000n + (fraction * 1000n) / factor; + const numericMilliseconds = Number(milliseconds); + if (!Number.isSafeInteger(numericMilliseconds)) return undefined; + const parsed = dayjs(numericMilliseconds); + if (!parsed.isValid()) return undefined; + try { + const zoned = parsed.tz(timezone); + return `${zoned.format("YYYY-MM-DDTHH:mm:ss")}.${fraction.toString().padStart(fractionDigits, "0")}${zoned.format("Z")}`; + } catch { + return undefined; + } +} + +function iotdbRawTimestamp(baseMilliseconds: number, fraction: string, precision: IoTDBTimestampPrecision): string | undefined { + if (!Number.isSafeInteger(baseMilliseconds) || baseMilliseconds % 1000 !== 0) return undefined; + const digits = iotdbTimestampFractionDigits(precision); + if (fraction.length > digits && /[1-9]/.test(fraction.slice(digits))) return undefined; + const normalizedFraction = fraction.slice(0, digits).padEnd(digits, "0"); + return (BigInt(baseMilliseconds / 1000) * 10n ** BigInt(digits) + BigInt(normalizedFraction || "0")).toString(); +} + function convertToMilliseconds(value: number, unit: DateTimeFormatterUnit): number { return unit === "seconds" || (unit === "auto" && Math.abs(value) < 100_000_000_000) ? value * 1000 : value; } diff --git a/crates/dbx-core/src/data_grid_iotdb_sql.rs b/crates/dbx-core/src/data_grid_iotdb_sql.rs new file mode 100644 index 0000000000..307e54dc27 --- /dev/null +++ b/crates/dbx-core/src/data_grid_iotdb_sql.rs @@ -0,0 +1,423 @@ +use serde_json::Value; +use std::collections::HashSet; + +use super::*; +use crate::models::connection::DatabaseType; + +pub(super) fn uses_iotdb_table_model_save(options: &DataGridSaveStatementOptions) -> bool { + options.database_type == Some(DatabaseType::Iotdb) && !options.table_meta.primary_keys.is_empty() +} + +pub(super) fn build_iotdb_data_grid_save_statements(options: &DataGridSaveStatementOptions) -> Vec { + let save_columns = effective_columns(options); + let mut statements = Vec::new(); + + for (row_index, changes) in &options.dirty_rows { + let Some(row) = options.rows.get(*row_index) else { + continue; + }; + if let Some(statement) = build_iotdb_changed_row_insert(options, &save_columns, row, changes, true) { + statements.push(statement); + } + } + + for row_index in &options.deleted_rows { + let Some(row) = options.rows.get(*row_index) else { + continue; + }; + if let Some(statement) = build_iotdb_delete_statement(options, &save_columns, row) { + statements.push(statement); + } + } + + for row in &options.new_rows { + if let Some(statement) = build_iotdb_full_row_insert(options, &save_columns, row) { + statements.push(statement); + } + } + + statements +} + +pub(super) fn build_iotdb_data_grid_rollback_statements(options: &DataGridSaveStatementOptions) -> Vec { + let save_columns = effective_columns(options); + let mut statements = Vec::new(); + + for row in &options.new_rows { + if let Some(statement) = build_iotdb_delete_statement(options, &save_columns, row) { + statements.push(statement); + } + } + + for row_index in &options.deleted_rows { + let Some(row) = options.rows.get(*row_index) else { + continue; + }; + if let Some(statement) = build_iotdb_full_row_insert(options, &save_columns, row) { + statements.push(statement); + } + } + + for (row_index, changes) in &options.dirty_rows { + let Some(row) = options.rows.get(*row_index) else { + continue; + }; + let requires_full_restore = changes.iter().any(|(column_index, _)| { + let value = row.get(*column_index).unwrap_or(&Value::Null); + let column = save_columns.get(*column_index).and_then(Option::as_deref); + value.is_null() + || column.is_some_and(|column| { + empty_string_saves_as_null(value, column_info_for(iotdb_column_info(options), column)) + }) + }); + if requires_full_restore { + if let Some(statement) = build_iotdb_delete_statement(options, &save_columns, row) { + statements.push(statement); + } + if let Some(statement) = build_iotdb_full_row_insert(options, &save_columns, row) { + statements.push(statement); + } + } else if let Some(statement) = build_iotdb_changed_row_insert(options, &save_columns, row, changes, false) { + statements.push(statement); + } + } + + statements +} + +pub(super) fn validate_iotdb_existing_rows(options: &DataGridSaveStatementOptions) -> Option { + if !uses_iotdb_table_model_save(options) || options.dirty_rows.is_empty() { + return None; + } + + let save_columns = effective_columns(options); + let primary_keys = + options.table_meta.primary_keys.iter().map(|column| normalize_column_name(column)).collect::>(); + for (_, changes) in &options.dirty_rows { + for (column_index, value) in changes { + let Some(column) = save_columns.get(*column_index).and_then(Option::as_deref) else { + continue; + }; + if primary_keys.contains(&normalize_column_name(column)) { + return Some("IoTDB row identifier columns (time and TAG) cannot be edited.".to_string()); + } + if value.is_null() || empty_string_saves_as_null(value, column_info_for(iotdb_column_info(options), column)) + { + return Some( + "IoTDB Table model cannot clear a FIELD or ATTRIBUTE value without deleting the row. Enter a non-NULL value or delete the row instead." + .to_string(), + ); + } + } + } + None +} + +fn build_iotdb_changed_row_insert( + options: &DataGridSaveStatementOptions, + save_columns: &[Option], + row: &[Value], + changes: &[(usize, Value)], + use_changed_values: bool, +) -> Option { + let changed_indexes = changes.iter().map(|(index, _)| *index).collect::>(); + let primary_keys = + options.table_meta.primary_keys.iter().map(|column| normalize_column_name(column)).collect::>(); + let insert_pairs = save_columns + .iter() + .enumerate() + .filter_map(|(index, column)| { + let column = column.as_deref()?; + let is_primary_key = primary_keys.contains(&normalize_column_name(column)); + if !is_primary_key && !changed_indexes.contains(&index) { + return None; + } + let value = if use_changed_values && !is_primary_key { + changes.iter().find(|(changed_index, _)| *changed_index == index).map(|(_, value)| value)? + } else { + row.get(index).unwrap_or(&Value::Null) + }; + Some((column, value)) + }) + .collect::>(); + build_iotdb_insert_statement(options, insert_pairs) +} + +fn build_iotdb_full_row_insert( + options: &DataGridSaveStatementOptions, + save_columns: &[Option], + row: &[Value], +) -> Option { + let insert_pairs = save_columns + .iter() + .enumerate() + .filter_map(|(index, column)| Some((column.as_deref()?, row.get(index).unwrap_or(&Value::Null)))) + .filter(|(_, value)| !value.is_null()) + .collect::>(); + build_iotdb_insert_statement(options, insert_pairs) +} + +fn build_iotdb_insert_statement( + options: &DataGridSaveStatementOptions, + insert_pairs: Vec<(&str, &Value)>, +) -> Option { + if insert_pairs.is_empty() { + return None; + } + let columns = insert_pairs + .iter() + .map(|(column, _)| data_grid_identifier(Some(DatabaseType::Iotdb), column, options.identifier_quote.as_deref())) + .collect::>() + .join(", "); + let values = insert_pairs + .iter() + .map(|(column, value)| { + format_grid_save_sql_literal( + value, + Some(DatabaseType::Iotdb), + column_info_for(iotdb_column_info(options), column), + ) + }) + .collect::>() + .join(", "); + Some(data_grid_statement( + Some(DatabaseType::Iotdb), + format!("INSERT INTO {} ({columns}) VALUES ({values})", iotdb_table(options)), + )) +} + +fn build_iotdb_delete_statement( + options: &DataGridSaveStatementOptions, + save_columns: &[Option], + row: &[Value], +) -> Option { + let where_clause = build_primary_key_where( + Some(DatabaseType::Iotdb), + &options.table_meta.primary_keys, + save_columns, + row, + iotdb_column_info(options), + options.identifier_quote.as_deref(), + ); + if where_clause.is_empty() { + return None; + } + Some(data_grid_statement( + Some(DatabaseType::Iotdb), + data_grid_delete_sql(Some(DatabaseType::Iotdb), &iotdb_table(options), &where_clause), + )) +} + +fn iotdb_table(options: &DataGridSaveStatementOptions) -> String { + data_grid_qualified_table_name( + Some(DatabaseType::Iotdb), + options.table_meta.catalog.as_deref(), + options.table_meta.schema.as_deref(), + options.table_meta.database.as_deref(), + &options.table_meta.table_name, + options.identifier_quote.as_deref(), + ) +} + +fn iotdb_column_info(options: &DataGridSaveStatementOptions) -> &[DataGridColumnInfo] { + options.table_meta.columns.as_deref().unwrap_or(&[]) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn column(name: &str, data_type: &str, nullable: bool, category: &str) -> DataGridColumnInfo { + DataGridColumnInfo { + name: name.to_string(), + data_type: data_type.to_string(), + is_nullable: nullable, + is_primary_key: matches!(category, "TIME" | "TAG"), + column_default: None, + extra: Some(category.to_string()), + } + } + + fn table_options() -> DataGridSaveStatementOptions { + DataGridSaveStatementOptions { + database_type: Some(DatabaseType::Iotdb), + identifier_quote: None, + table_meta: DataGridTableMeta { + catalog: None, + database: None, + schema: Some("dbx_edit_preview".to_string()), + table_name: "events".to_string(), + primary_keys: vec!["time".to_string(), "device".to_string()], + columns: Some(vec![ + column("time", "TIMESTAMP", false, "TIME"), + column("device", "STRING", false, "TAG"), + column("event_time", "TIMESTAMP", true, "FIELD"), + column("temperature", "DOUBLE", true, "FIELD"), + ]), + }, + columns: vec![ + "time".to_string(), + "device".to_string(), + "event_time".to_string(), + "temperature".to_string(), + ], + source_columns: None, + rows: vec![vec![json!("1786954706123"), json!("sensor-a"), json!("1786954706123"), json!(23.5)]], + dirty_rows: Vec::new(), + deleted_rows: Vec::new(), + new_rows: Vec::new(), + } + } + + #[test] + fn prepares_iotdb_timestamp_field_overwrite_and_rollback() { + let mut options = table_options(); + options.dirty_rows = vec![(0, vec![(2, json!("2026-08-17 17:18:27.123"))])]; + + let result = prepare_data_grid_save(options); + + assert_eq!(result.validation_error, None); + assert_eq!( + result.statements, + vec!["INSERT INTO dbx_edit_preview.events (time, device, event_time) VALUES (1786954706123, 'sensor-a', '2026-08-17 17:18:27.123');"] + ); + assert_eq!( + result.rollback_statements, + vec!["INSERT INTO dbx_edit_preview.events (time, device, event_time) VALUES (1786954706123, 'sensor-a', 1786954706123);"] + ); + } + + #[test] + fn prepares_iotdb_only_with_changed_fields() { + let mut options = table_options(); + options.dirty_rows = vec![(0, vec![(2, json!("1786958307123")), (3, json!(24.75))])]; + + let result = prepare_data_grid_save(options); + + assert_eq!( + result.statements, + vec!["INSERT INTO dbx_edit_preview.events (time, device, event_time, temperature) VALUES (1786954706123, 'sensor-a', 1786958307123, 24.75);"] + ); + assert_eq!( + result.rollback_statements, + vec!["INSERT INTO dbx_edit_preview.events (time, device, event_time, temperature) VALUES (1786954706123, 'sensor-a', 1786954706123, 23.5);"] + ); + } + + #[test] + fn prepares_iotdb_insert_delete_and_rollback() { + let mut options = table_options(); + options.deleted_rows = vec![0]; + options.new_rows = vec![vec![json!("1786961906789"), json!("sensor-b"), json!("1786961906789"), json!(22.75)]]; + + let result = prepare_data_grid_save(options); + + assert_eq!( + result.statements, + vec![ + "DELETE FROM dbx_edit_preview.events WHERE time = 1786954706123 AND device = 'sensor-a';", + "INSERT INTO dbx_edit_preview.events (time, device, event_time, temperature) VALUES (1786961906789, 'sensor-b', 1786961906789, 22.75);", + ] + ); + assert_eq!( + result.rollback_statements, + vec![ + "DELETE FROM dbx_edit_preview.events WHERE time = 1786961906789 AND device = 'sensor-b';", + "INSERT INTO dbx_edit_preview.events (time, device, event_time, temperature) VALUES (1786954706123, 'sensor-a', 1786954706123, 23.5);", + ] + ); + } + + #[test] + fn rejects_iotdb_row_identifier_edits() { + let mut options = table_options(); + options.dirty_rows = vec![(0, vec![(1, json!("sensor-b"))])]; + + let result = prepare_data_grid_save(options); + + assert_eq!( + result.validation_error, + Some("IoTDB row identifier columns (time and TAG) cannot be edited.".to_string()) + ); + assert!(result.statements.is_empty()); + assert!(result.rollback_statements.is_empty()); + } + + #[test] + fn rejects_iotdb_null_field_edits_that_insert_cannot_clear() { + let mut options = table_options(); + options.dirty_rows = vec![(0, vec![(2, Value::Null)])]; + + let result = prepare_data_grid_save(options); + + assert_eq!( + result.validation_error, + Some( + "IoTDB Table model cannot clear a FIELD or ATTRIBUTE value without deleting the row. Enter a non-NULL value or delete the row instead." + .to_string() + ) + ); + assert!(result.statements.is_empty()); + } + + #[test] + fn restores_iotdb_original_null_with_delete_and_full_row_insert() { + let mut options = table_options(); + options.rows[0][2] = Value::Null; + options.dirty_rows = vec![(0, vec![(2, json!("1786958307123"))])]; + + let result = prepare_data_grid_save(options); + + assert_eq!( + result.statements, + vec!["INSERT INTO dbx_edit_preview.events (time, device, event_time) VALUES (1786954706123, 'sensor-a', 1786958307123);"] + ); + assert_eq!( + result.rollback_statements, + vec![ + "DELETE FROM dbx_edit_preview.events WHERE time = 1786954706123 AND device = 'sensor-a';", + "INSERT INTO dbx_edit_preview.events (time, device, temperature) VALUES (1786954706123, 'sensor-a', 23.5);", + ] + ); + } + + #[test] + fn preserves_timestamp_precision_strings_as_unquoted_integer_literals() { + for (original, changed) in [ + ("1786954706123", "1786958307123"), + ("1786954706123456", "1786958307123456"), + ("1786954706123456789", "1786958307123456789"), + ] { + let mut options = table_options(); + options.rows[0][0] = json!(original); + options.rows[0][2] = json!(original); + options.dirty_rows = vec![(0, vec![(2, json!(changed))])]; + + let result = prepare_data_grid_save(options); + + assert_eq!( + result.statements, + vec![format!( + "INSERT INTO dbx_edit_preview.events (time, device, event_time) VALUES ({original}, 'sensor-a', {changed});" + )] + ); + assert_eq!( + result.rollback_statements, + vec![format!( + "INSERT INTO dbx_edit_preview.events (time, device, event_time) VALUES ({original}, 'sensor-a', {original});" + )] + ); + } + } + + #[test] + fn keeps_iotdb_tree_model_on_the_existing_sql_path() { + let mut options = table_options(); + options.table_meta.primary_keys.clear(); + options.table_meta.schema = Some("root.dbx".to_string()); + options.table_meta.table_name = "d1".to_string(); + + assert!(!uses_iotdb_table_model_save(&options)); + } +} diff --git a/crates/dbx-core/src/data_grid_sql.rs b/crates/dbx-core/src/data_grid_sql.rs index 9f7708f45a..9ac1da8649 100644 --- a/crates/dbx-core/src/data_grid_sql.rs +++ b/crates/dbx-core/src/data_grid_sql.rs @@ -7,6 +7,13 @@ use std::collections::HashSet; mod data_grid_neo4j_sql; use data_grid_neo4j_sql::{build_neo4j_data_grid_rollback_statements, build_neo4j_data_grid_save_statements}; +#[path = "data_grid_iotdb_sql.rs"] +mod data_grid_iotdb_sql; +use data_grid_iotdb_sql::{ + build_iotdb_data_grid_rollback_statements, build_iotdb_data_grid_save_statements, uses_iotdb_table_model_save, + validate_iotdb_existing_rows, +}; + #[path = "data_grid_tdengine_sql.rs"] mod data_grid_tdengine_sql; use data_grid_tdengine_sql::{ @@ -540,24 +547,45 @@ pub fn build_data_grid_context_filter_condition(options: DataGridContextFilterCo )), DataGridContextFilterMode::LessThan => Some(format!( "{column} < {}", - format_grid_sql_literal(value, options.database_type, options.column_info.as_ref()) + format_data_grid_context_filter_literal( + value, + options.database_type, + &options.column_name, + options.column_info.as_ref() + ) )), DataGridContextFilterMode::LessThanOrEqual => Some(format!( "{column} <= {}", - format_grid_sql_literal(value, options.database_type, options.column_info.as_ref()) + format_data_grid_context_filter_literal( + value, + options.database_type, + &options.column_name, + options.column_info.as_ref() + ) )), DataGridContextFilterMode::GreaterThan => Some(format!( "{column} > {}", - format_grid_sql_literal(value, options.database_type, options.column_info.as_ref()) + format_data_grid_context_filter_literal( + value, + options.database_type, + &options.column_name, + options.column_info.as_ref() + ) )), DataGridContextFilterMode::GreaterThanOrEqual => Some(format!( "{column} >= {}", - format_grid_sql_literal(value, options.database_type, options.column_info.as_ref()) + format_data_grid_context_filter_literal( + value, + options.database_type, + &options.column_name, + options.column_info.as_ref() + ) )), DataGridContextFilterMode::In => build_data_grid_context_membership_filter_condition( &column, &options.values, options.database_type, + &options.column_name, options.column_info.as_ref(), false, ), @@ -565,6 +593,7 @@ pub fn build_data_grid_context_filter_condition(options: DataGridContextFilterCo &column, &options.values, options.database_type, + &options.column_name, options.column_info.as_ref(), true, ), @@ -573,6 +602,7 @@ pub fn build_data_grid_context_filter_condition(options: DataGridContextFilterCo value, options.end_value.as_ref(), options.database_type, + &options.column_name, options.column_info.as_ref(), false, ), @@ -581,24 +611,55 @@ pub fn build_data_grid_context_filter_condition(options: DataGridContextFilterCo value, options.end_value.as_ref(), options.database_type, + &options.column_name, options.column_info.as_ref(), true, ), DataGridContextFilterMode::Equals => Some(format!( "{column} = {}", - format_grid_sql_literal(value, options.database_type, options.column_info.as_ref()) + format_data_grid_context_filter_literal( + value, + options.database_type, + &options.column_name, + options.column_info.as_ref() + ) )), DataGridContextFilterMode::NotEquals => Some(format!( "{column} <> {}", - format_grid_sql_literal(value, options.database_type, options.column_info.as_ref()) + format_data_grid_context_filter_literal( + value, + options.database_type, + &options.column_name, + options.column_info.as_ref() + ) )), } } +fn format_data_grid_context_filter_literal( + value: &Value, + database_type: Option, + column_name: &str, + column_info: Option<&DataGridColumnInfo>, +) -> String { + if database_type == Some(DatabaseType::Iotdb) && column_info.is_none() && column_name.eq_ignore_ascii_case("time") { + if let Some(value) = value.as_str().filter(|value| is_decimal_i64(value)) { + return value.to_string(); + } + } + format_grid_sql_literal(value, database_type, column_info) +} + +fn is_decimal_i64(value: &str) -> bool { + let digits = value.strip_prefix('-').unwrap_or(value); + !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()) && value.parse::().is_ok() +} + fn build_data_grid_context_membership_filter_condition( column: &str, values: &[Value], database_type: Option, + column_name: &str, column_info: Option<&DataGridColumnInfo>, negated: bool, ) -> Option { @@ -614,7 +675,7 @@ fn build_data_grid_context_membership_filter_condition( has_null = true; continue; } - let literal = format_grid_sql_literal(value, database_type, column_info); + let literal = format_data_grid_context_filter_literal(value, database_type, column_name, column_info); if seen_literals.insert(literal.clone()) { literals.push(literal); } @@ -670,6 +731,7 @@ fn build_data_grid_context_range_filter_condition( start_value: &Value, end_value: Option<&Value>, database_type: Option, + column_name: &str, column_info: Option<&DataGridColumnInfo>, negated: bool, ) -> Option { @@ -677,8 +739,8 @@ fn build_data_grid_context_range_filter_condition( if start_value.is_null() || end_value.is_null() { return None; } - let start = format_grid_sql_literal(start_value, database_type, column_info); - let end = format_grid_sql_literal(end_value, database_type, column_info); + let start = format_data_grid_context_filter_literal(start_value, database_type, column_name, column_info); + let end = format_data_grid_context_filter_literal(end_value, database_type, column_name, column_info); if database_type == Some(DatabaseType::Neo4j) { return if negated { Some(format!("({column} < {start} OR {column} > {end})")) @@ -896,6 +958,9 @@ fn build_neo4j_data_grid_column_distinct_values_sql(options: &DataGridColumnDist } fn validate_data_grid_save(options: &DataGridSaveStatementOptions) -> Option { + if let Some(error) = validate_iotdb_existing_rows(options) { + return Some(error); + } if let Some(error) = validate_tdengine_inserted_rows(options) { return Some(error); } @@ -1114,6 +1179,9 @@ fn build_data_grid_save_statements( if options.database_type == Some(DatabaseType::Tdengine) { return build_tdengine_data_grid_save_statements(options); } + if uses_iotdb_table_model_save(options) { + return build_iotdb_data_grid_save_statements(options); + } let save_columns = effective_columns(options); let column_info = options.table_meta.columns.as_deref().unwrap_or(&[]); @@ -1284,6 +1352,9 @@ fn build_data_grid_rollback_statements( if options.database_type == Some(DatabaseType::Tdengine) { return build_tdengine_data_grid_rollback_statements(options); } + if uses_iotdb_table_model_save(options) { + return build_iotdb_data_grid_rollback_statements(options); + } if options.database_type == Some(DatabaseType::ClickHouse) { return Vec::new(); } @@ -1836,6 +1907,18 @@ pub fn format_grid_sql_literal( return format_pg_array_sql_literal(arr); } let text = value.as_str().map_or_else(|| value.to_string(), ToString::to_string); + if database_type == Some(DatabaseType::Iotdb) + && column_info.is_some_and(|column| { + column.data_type.trim().split(['(', ' ']).next().is_some_and(|base| base.eq_ignore_ascii_case("timestamp")) + }) + { + if let Ok(timestamp) = text.parse::() { + // IoTDB can use nanosecond epoch values that exceed JavaScript's + // safe integer range, so its Agent transports TIMESTAMP cells as + // decimal strings. Keep those strings as numeric SQL literals. + return timestamp.to_string(); + } + } if is_mysql_binary_literal_column(database_type, column_info) { if let Some(literal) = format_mysql_binary_literal_text(&text) { // DBX result values expose binary columns as prefixed hex; keep them @@ -3707,6 +3790,40 @@ mod tests { ); } + #[test] + fn keeps_iotdb_tree_time_epoch_filters_unquoted_without_column_metadata() { + let condition = |mode, value, values, end_value| { + build_data_grid_context_filter_condition(DataGridContextFilterConditionOptions { + database_type: Some(DatabaseType::Iotdb), + identifier_quote: None, + column_name: "Time".to_string(), + mode, + value, + values, + end_value, + column_info: None, + }) + }; + + assert_eq!( + condition(DataGridContextFilterMode::Equals, json!("1786954706123"), Vec::new(), None), + Some("Time = 1786954706123".to_string()) + ); + assert_eq!( + condition( + DataGridContextFilterMode::Between, + json!("1786954706123"), + Vec::new(), + Some(json!("1786958307123")) + ), + Some("Time BETWEEN 1786954706123 AND 1786958307123".to_string()) + ); + assert_eq!( + condition(DataGridContextFilterMode::Equals, json!("not-an-epoch"), Vec::new(), None), + Some("Time = 'not-an-epoch'".to_string()) + ); + } + #[test] fn builds_membership_and_range_context_filter_conditions() { assert_eq!( diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index a84a50e616..6f8093b596 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -8543,19 +8543,22 @@ mod ddl_tests { } } + fn assert_table_ddl_options( + options: TableDdlOptions, + include_partitions: bool, + portable_oracle: bool, + include_postgres_access: bool, + ) { + assert_eq!(options.include_partitions, include_partitions); + assert_eq!(options.portable_oracle, portable_oracle); + assert_eq!(options.include_postgres_access, include_postgres_access); + } + #[test] fn table_structure_export_includes_partition_tree() { - assert!(TableDdlOptions::EXPORT.include_partitions); - assert!(TableDdlOptions::EXPORT.portable_oracle); - assert!(!TableDdlOptions::EXPORT.include_postgres_access); - - assert!(!TableDdlOptions::RELATION_EXPORT.include_partitions); - assert!(TableDdlOptions::RELATION_EXPORT.portable_oracle); - assert!(!TableDdlOptions::RELATION_EXPORT.include_postgres_access); - - assert!(TableDdlOptions::DISPLAY.include_partitions); - assert!(!TableDdlOptions::DISPLAY.portable_oracle); - assert!(TableDdlOptions::DISPLAY.include_postgres_access); + assert_table_ddl_options(TableDdlOptions::EXPORT, true, true, false); + assert_table_ddl_options(TableDdlOptions::RELATION_EXPORT, false, true, false); + assert_table_ddl_options(TableDdlOptions::DISPLAY, true, false, true); } #[test] diff --git a/crates/dbx-core/tests/database_export_partition_ddl.rs b/crates/dbx-core/tests/database_export_partition_ddl.rs index d01733b335..7ef306334d 100644 --- a/crates/dbx-core/tests/database_export_partition_ddl.rs +++ b/crates/dbx-core/tests/database_export_partition_ddl.rs @@ -174,8 +174,25 @@ fn postgres_test_config(id: &str, port: u16) -> ConnectionConfig { /// a partition-local dropped default, and the exported SQL must replay /// cleanly into a fresh schema (the actual "restore" check the review /// comment asked for). -#[tokio::test] -async fn database_export_of_partition_tree_has_no_duplicates_and_replays() { +#[test] +fn database_export_of_partition_tree_has_no_duplicates_and_replays() { + let handle = std::thread::Builder::new() + .name("database-export-partition-ddl".to_string()) + .stack_size(8 * 1024 * 1024) + .spawn(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build partition ddl test runtime") + .block_on(run_database_export_of_partition_tree_has_no_duplicates_and_replays()); + }) + .expect("spawn partition ddl test thread"); + if let Err(panic) = handle.join() { + std::panic::resume_unwind(panic); + } +} + +async fn run_database_export_of_partition_tree_has_no_duplicates_and_replays() { let Some(container) = start_docker_postgres("dbx-export-partition-ddl") else { return; };