fix(iotdb): correct timestamp display and table editing

This commit is contained in:
zipg authored and GitHub committed 2026-08-18 23:24:57 +08:00
1 parent fa4238c5d3
commit b7338518d1
16 files changed
+1031 -73

No files matched your search

+87 -13
View File
@@ -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<number | null>(null);
type FormatterDraftKind = Exclude<ColumnFormatterConfig["kind"], "custom-ref">;
type FormatterDraftKind = Exclude<ColumnFormatterConfig["kind"], "custom-ref" | "iotdb-timestamp">;
const CUSTOM_FORMATTER_NEW = "__new";
const formatterKind = ref<FormatterDraftKind>("datetime");
const formatterDateUnit = ref<DateTimeFormatterUnit>("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<ColumnInfo, "d
}
function temporalEditorConfigForColumn(columnIndex: number): TemporalCellEditorConfig | undefined {
if (resolvedDatabaseType.value === "iotdb") {
const resultType = props.result.column_types?.[columnIndex];
const tableType = tableColumnForGridColumn(columnIndex)?.data_type;
if ((resultType ?? tableType)?.trim().toUpperCase().startsWith("TIMESTAMP")) {
const precision = iotdbTimestampPrecision(resolvedDatabaseType.value, resultType);
return precision ? { kind: "datetime", fractionPrecision: iotdbTimestampFractionDigits(precision) } : undefined;
}
}
return temporalCellEditorConfig(tableColumnForGridColumn(columnIndex), props.databaseType);
}
function isIoTDBTimestampColumn(columnIndex: number): boolean {
const columnType = props.result.column_types?.[columnIndex] ?? tableColumnForGridColumn(columnIndex)?.data_type;
return !!iotdbTimestampPrecision(resolvedDatabaseType.value, columnType);
}
function inlineCellEditorText(value: CellValue, columnIndex: number): string {
const columnInfo = tableColumnForGridColumn(columnIndex);
const columnType = props.result.column_types?.[columnIndex] ?? columnInfo?.data_type;
return (
formatIoTDBTimestampEditorValue(value, resolvedDatabaseType.value, columnType, resolvedConnectionConfig.value?.url_params) ??
dataGridCellEditorText({
value,
databaseType: resolvedDatabaseType.value,
columnInfo,
})
);
}
function normalizeTemporalCellEditorValue(value: string, columnIndex: number): string {
if (!isIoTDBTimestampColumn(columnIndex)) return value;
const columnType = props.result.column_types?.[columnIndex] ?? tableColumnForGridColumn(columnIndex)?.data_type;
const timestamp = parseIoTDBTimestampEditorValue(value, resolvedDatabaseType.value, columnType, resolvedConnectionConfig.value?.url_params);
if (timestamp === null) return "NULL";
if (timestamp === undefined) return value;
return formatIoTDBTimestampEditorValue(timestamp, resolvedDatabaseType.value, columnType, resolvedConnectionConfig.value?.url_params) ?? value;
}
function enumValuesForGridColumn(columnIndex: number): string[] {
return tableColumnForGridColumn(columnIndex)?.enum_values ?? [];
}
@@ -5162,11 +5227,7 @@ const hasPendingInlineEditorDraft = computed(() => {
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"
/>
@@ -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 {
@@ -16,6 +16,7 @@ function createResizeState(options: {
compactColumnHeaderActions?: boolean;
indexIndicatorColumnIndexes?: number[] | ReturnType<typeof ref<number[]>>;
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);
@@ -42,6 +42,7 @@ export interface UseDataGridColumnResizeOptions {
measureHeaderText?: (text: string) => number | undefined;
headerMeasurementKey?: Ref<unknown>;
rowNumberWidth?: Ref<number> | ComputedRef<number>;
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 {
@@ -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();
});
});
@@ -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<T extends CellValue>(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;
}