This is an automated email from the ASF dual-hosted git repository.
HTHou pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/iotdb-client-nodejs.git
The following commit(s) were added to refs/heads/develop by this push:
new 31601ee fix: DATE values encoded as days-since-epoch instead of INT32
yyyyMMdd (#15)
31601ee is described below
commit 31601eee4a6d63e11abd9c7bc30f2f37ae93860d
Author: CritasWang <[email protected]>
AuthorDate: Mon Jul 13 14:27:34 2026 +0800
fix: DATE values encoded as days-since-epoch instead of INT32 yyyyMMdd (#15)
* fix: encode DATE as INT32 yyyyMMdd instead of days-since-epoch
IoTDB's DATE wire format is an INT32 encoded as year*10000 + month*100 + day
(e.g. 2026-07-13 -> 20260713), matching the Java client's
DateUtils.parseDateExpressionToInt and the C# client. The Node.js client was
writing and reading days-since-epoch, so DATE values written via tablets
were
corrupted and unreadable by other clients (and vice versa).
Adjudicated against a live IoTDB 2.0.6 server: a row inserted via SQL
literal
'2026-07-13' reads back as INT32 20260713 on the wire.
Changes:
- Add shared parseDateToInt / parseIntToDate utils in src/utils/DataTypes.ts
(UTC calendar components, matching Java LocalDate semantics) and export
them
- Session.serializeColumn case 9: write yyyyMMdd (was days-since-epoch)
- FastSerializer.serializeDateColumn: same fix on the fast path
- Session.deserializeColumn case 9 (TSQueryDataSet): decode yyyyMMdd -> Date
- ColumnDecoder Int32ArrayColumnDecoder: decode DATE (type 9) columns to
Date
- Session.parseTsBlock: DATE columns arrive typed as INT32 (1) in the
TsBlock
header; convert them to Date using the metadata dataTypeList
- Unit tests: yyyyMMdd round-trip, exact wire bytes for 2026-07-13
(0x01352769), TsBlock decode; updated existing DATE serialization tests
Verified: tablet-written Date and SQL-literal date now read back identical
against a live IoTDB 2.0.6 (both 2026-07-13T00:00:00.000Z).
* fix: address review feedback on DATE yyyyMMdd encoding
Addresses three review comments from HTHou on PR #15:
1. parseTsBlock DATE conversion now respects
columnIndex2TsBlockColumnIndexList. dataTypeList is ordered by LOGICAL
response columns while physical TsBlock columns may be deduplicated or
reordered, so converting physical column i based on dataTypes[i] could
convert the wrong column. The mapping (logical -> physical, -1 = time
column) is now passed into parseQueryResult from both call sites
(executeQueryStatement initial batch and SessionDataSet.fetchNextBatch)
and inverted into per-physical-column logical types. DATE conversion
only applies when a physical column's logical type is unambiguously
DATE; conflicting duplicate mappings log a warning and skip conversion
instead of crashing. Absent mapping keeps identity behavior.
2. Docs updated to the yyyyMMdd contract: docs/data-types.md and the
English/Chinese tree user guides no longer describe numeric DATE as
days since epoch; they now document the INT32 yyyyMMdd encoding with
examples (20240101 for 2024-01-01).
3. parseDateToInt/parseIntToDate now validate like the Java client's
DateUtils/LocalDate: invalid Date objects (NaN time), non-integer or
non-finite numbers, years outside 1000-9999, and impossible calendar
dates (e.g. 20230229, month 13, day 0) throw a descriptive Error
instead of being silently normalized or serialized as garbage.
null/undefined handling is unchanged (callers filter nulls first).
Unit tests added: non-identity mapping TsBlock conversion (crafted
TsBlock buffers with INT32 wire columns + DATE logical type), dedup and
conflicting-mapping cases, and validation boundary tests (leap day
20240229 ok, 20230229/20241301/20240100/year 999/year 10000/NaN
Date/non-integer/legacy days-epoch 19723 all throw).
---
docs/data-types.md | 6 +-
docs/user-guide-tree-zh.md | 2 +-
docs/user-guide-tree.md | 2 +-
src/client/ColumnDecoder.ts | 13 +-
src/client/Session.ts | 112 +++++++++++--
src/client/SessionDataSet.ts | 1 +
src/index.ts | 7 +-
src/utils/DataTypes.ts | 119 +++++++++++++-
src/utils/FastSerializer.ts | 18 +-
tests/unit/DataTypes.test.ts | 292 +++++++++++++++++++++++++++++++++
tests/unit/FastSerializer.test.ts | 27 ++-
tests/unit/TabletSerialization.test.ts | 31 +++-
12 files changed, 578 insertions(+), 52 deletions(-)
diff --git a/docs/data-types.md b/docs/data-types.md
index 55d08f3..4589a0e 100644
--- a/docs/data-types.md
+++ b/docs/data-types.md
@@ -17,7 +17,7 @@ The IoTDB Node.js client supports all standard IoTDB data
types as defined in Ap
| 6 | VECTOR | Vector data (not yet implemented) | - | - |
| 7 | UNKNOWN | Unknown type (reserved) | - | - |
| 8 | TIMESTAMP | Timestamp (milliseconds) | `Date` | 8 bytes |
-| 9 | DATE | Date (days since epoch) | `Date` | 4 bytes |
+| 9 | DATE | Calendar date (INT32 `yyyyMMdd`, e.g. `20240101` for 2024-01-01)
| `Date` | 4 bytes |
| 10 | BLOB | Binary data | `Buffer` | Variable (4-byte length + content) |
| 11 | STRING | UTF-8 encoded string | `string` | Variable (4-byte length +
content) |
| 12 | OBJECT | Object type (reserved) | - | - |
@@ -176,7 +176,7 @@ for (const row of result.rows) {
| `bigint` | INT64, TIMESTAMP | Direct mapping |
| `string` | TEXT, STRING | UTF-8 encoded |
| `Buffer` | BLOB | Binary data |
-| `Date` | DATE, TIMESTAMP | Converted to days or milliseconds |
+| `Date` | DATE, TIMESTAMP | DATE: encoded as `yyyyMMdd` integer (e.g.
`20240101`); TIMESTAMP: milliseconds since epoch |
### IoTDB to JavaScript
@@ -190,7 +190,7 @@ for (const row of result.rows) {
| TEXT | `string` | UTF-8 decoded |
| BLOB | `Buffer` | Raw binary data |
| STRING | `string` | UTF-8 decoded |
-| DATE | `Date` | Days since epoch converted to Date |
+| DATE | `Date` | `yyyyMMdd` integer (e.g. `20240101`) converted to Date at
UTC midnight |
| TIMESTAMP | `Date` | Milliseconds since epoch |
## Null Values
diff --git a/docs/user-guide-tree-zh.md b/docs/user-guide-tree-zh.md
index 3b67d93..695603d 100644
--- a/docs/user-guide-tree-zh.md
+++ b/docs/user-guide-tree-zh.md
@@ -350,7 +350,7 @@ const pool = new SessionPool(poolConfig);
| 4 | DOUBLE | number | 64 位浮点数 |
| 5 | TEXT | string | UTF-8 字符串 |
| 8 | TIMESTAMP | number/Date | 自纪元以来的毫秒数 |
-| 9 | DATE | number/Date | 自纪元以来的天数 |
+| 9 | DATE | number/Date | 日历日期,`yyyyMMdd` 整数(例如 `20240101` 表示 2024-01-01)|
| 10 | BLOB | Buffer | 二进制数据 |
| 11 | STRING | string | 与 TEXT 相同 |
diff --git a/docs/user-guide-tree.md b/docs/user-guide-tree.md
index 10173a0..83ac5b2 100644
--- a/docs/user-guide-tree.md
+++ b/docs/user-guide-tree.md
@@ -350,7 +350,7 @@ The tree model supports all IoTDB data types:
| 4 | DOUBLE | number | 64-bit floating point |
| 5 | TEXT | string | UTF-8 string |
| 8 | TIMESTAMP | number/Date | Milliseconds since epoch |
-| 9 | DATE | number/Date | Days since epoch |
+| 9 | DATE | number/Date | Calendar date as `yyyyMMdd` integer (e.g.
`20240101` for 2024-01-01) |
| 10 | BLOB | Buffer | Binary data |
| 11 | STRING | string | Same as TEXT |
diff --git a/src/client/ColumnDecoder.ts b/src/client/ColumnDecoder.ts
index 52701cc..25d5c13 100644
--- a/src/client/ColumnDecoder.ts
+++ b/src/client/ColumnDecoder.ts
@@ -18,6 +18,7 @@
*/
import { logger } from "../utils/Logger";
+import { parseIntToDate } from "../utils/DataTypes";
/**
* Column encoding types matching Apache IoTDB ColumnEncoding enum
@@ -152,7 +153,6 @@ class Int32ArrayColumnDecoder implements ColumnDecoder {
switch (dataType) {
case 1: // INT32
- case 9: // DATE
for (let i = 0; i < positionCount; i++) {
if (nullIndicators && nullIndicators[i]) {
values[i] = null;
@@ -163,6 +163,17 @@ class Int32ArrayColumnDecoder implements ColumnDecoder {
}
break;
+ case 9: // DATE (INT32 yyyyMMdd encoding -> Date object)
+ for (let i = 0; i < positionCount; i++) {
+ if (nullIndicators && nullIndicators[i]) {
+ values[i] = null;
+ continue;
+ }
+ values[i] = parseIntToDate(buffer.readInt32BE(currentOffset));
+ currentOffset += 4;
+ }
+ break;
+
case 3: // FLOAT
for (let i = 0; i < positionCount; i++) {
if (nullIndicators && nullIndicators[i]) {
diff --git a/src/client/Session.ts b/src/client/Session.ts
index 879f62e..5c8cffc 100644
--- a/src/client/Session.ts
+++ b/src/client/Session.ts
@@ -35,6 +35,7 @@ import {
serializeTimestamps
} from "../utils/FastSerializer";
import { globalBufferPool } from "../utils/BufferPool";
+import { parseDateToInt, parseIntToDate } from "../utils/DataTypes";
const ttypes = require("../thrift/generated/client_types");
@@ -318,6 +319,7 @@ export class Session {
response.columns?.length || 0,
response.dataTypeList || [],
ignoreTimeStamp,
+ response.columnIndex2TsBlockColumnIndexList,
);
} else if (response.queryDataSet) {
// Old columnar format (TSQueryDataSet)
@@ -741,18 +743,11 @@ export class Session {
return buffer;
}
case 9: {
- // DATE (stored as INT32 - days since epoch) - Use big-endian
+ // DATE (stored as INT32 - yyyyMMdd, e.g. 20240101) - Use big-endian
const buffer = Buffer.alloc(values.length * 4);
values.forEach((v, i) => {
- let days = 0;
- if (v !== null && v !== undefined) {
- if (v instanceof Date) {
- days = Math.floor(v.getTime() / (24 * 60 * 60 * 1000));
- } else {
- days = v;
- }
- }
- buffer.writeInt32BE(days, i * 4);
+ const encoded = v === null || v === undefined ? 0 :
parseDateToInt(v);
+ buffer.writeInt32BE(encoded, i * 4);
});
return buffer;
}
@@ -888,12 +883,16 @@ export class Session {
* - Value columns data
*
* @param ignoreTimeStamp - If true, no time column is present
+ * @param columnIndex2TsBlockColumnIndexList - Server-provided mapping from
+ * LOGICAL response column index to PHYSICAL TsBlock column index (-1 =
+ * time column); identity mapping is assumed when absent
*/
async parseQueryResult(
queryResult: Buffer[],
_columnCount: number,
dataTypes: string[],
ignoreTimeStamp: boolean = false,
+ columnIndex2TsBlockColumnIndexList?: number[],
): Promise<any[][]> {
const rows: any[][] = [];
@@ -907,6 +906,14 @@ export class Session {
);
logger.debug(`parseQueryResult: dataTypes: ${JSON.stringify(dataTypes)}`);
+ // dataTypes is ordered by LOGICAL response columns; physical TsBlock
+ // columns may be deduplicated/reordered. Derive each PHYSICAL column's
+ // logical type once so parseTsBlock can convert DATE columns correctly.
+ const physicalColumnTypes = this.derivePhysicalColumnTypes(
+ dataTypes,
+ columnIndex2TsBlockColumnIndexList,
+ );
+
// Process each TsBlock in queryResult
for (let blockIndex = 0; blockIndex < queryResult.length; blockIndex++) {
const tsBlockBuffer = Buffer.isBuffer(queryResult[blockIndex])
@@ -920,7 +927,7 @@ export class Session {
try {
const blockRows = this.parseTsBlock(
tsBlockBuffer,
- dataTypes,
+ physicalColumnTypes,
ignoreTimeStamp,
);
rows.push(...blockRows);
@@ -934,6 +941,55 @@ export class Session {
return rows;
}
+ /**
+ * Derive the per-PHYSICAL-TsBlock-column logical data types from the
+ * logically-ordered dataTypeList and the server-provided
+ * columnIndex2TsBlockColumnIndexList (logical index -> physical index,
+ * -1 = time column).
+ *
+ * Multiple logical columns may map to the same physical column (the server
+ * deduplicates identical output columns). In practice duplicates share one
+ * type; if they ever disagree, the physical column's type is left undefined
+ * so no type-specific conversion is applied.
+ *
+ * When the mapping is absent, the logical order is the physical order
+ * (identity), preserving the previous behavior.
+ */
+ private derivePhysicalColumnTypes(
+ dataTypes: string[],
+ columnIndex2TsBlockColumnIndexList?: number[],
+ ): (string | undefined)[] {
+ if (
+ !columnIndex2TsBlockColumnIndexList ||
+ columnIndex2TsBlockColumnIndexList.length === 0
+ ) {
+ return dataTypes;
+ }
+
+ const physicalTypes: (string | undefined)[] = [];
+ const conflicting = new Set<number>();
+ for (let i = 0; i < dataTypes.length; i++) {
+ const physicalIndex = columnIndex2TsBlockColumnIndexList[i];
+ // -1 marks the time column; ignore missing/negative entries
+ if (physicalIndex === undefined || physicalIndex < 0) {
+ continue;
+ }
+ const existing = physicalTypes[physicalIndex];
+ if (existing === undefined) {
+ physicalTypes[physicalIndex] = dataTypes[i];
+ } else if (existing !== dataTypes[i]) {
+ conflicting.add(physicalIndex);
+ }
+ }
+ for (const physicalIndex of conflicting) {
+ logger.warn(
+ `derivePhysicalColumnTypes: conflicting logical types for TsBlock
column ${physicalIndex}; skipping type conversion for it`,
+ );
+ physicalTypes[physicalIndex] = undefined;
+ }
+ return physicalTypes;
+ }
+
/**
* Parse a single TsBlock buffer
* TsBlock format (from Apache IoTDB C# client):
@@ -947,10 +1003,14 @@ export class Session {
* regardless of the ignoreTimeStamp setting. The ignoreTimeStamp flag only
* affects whether the timestamp is included in the returned row data, not
the
* TsBlock binary format. This matches the behavior of iotdb-client-csharp.
+ *
+ * @param physicalColumnTypes - Logical data types ordered by PHYSICAL
+ * TsBlock column (see derivePhysicalColumnTypes); an undefined entry means
+ * "unknown, apply no type-specific conversion"
*/
private parseTsBlock(
buffer: Buffer,
- dataTypes: string[],
+ physicalColumnTypes: (string | undefined)[],
ignoreTimeStamp: boolean,
): any[][] {
let offset = 0;
@@ -1021,6 +1081,25 @@ export class Session {
);
}
+ // DATE columns arrive in TsBlock with wire type INT32 (1); the real DATE
+ // type is only present in the query metadata. Convert those columns'
+ // yyyyMMdd integers (e.g. 20240101) to Date objects here. Only convert
+ // when the physical column's logical type is unambiguously DATE.
+ for (let i = 0; i < valueColumns.length; i++) {
+ if (
+ valueColumnTypes[i] === 1 &&
+ physicalColumnTypes[i] !== undefined &&
+ this.getDataTypeCode(physicalColumnTypes[i]) === 9
+ ) {
+ const colValues = valueColumns[i].values;
+ for (let j = 0; j < colValues.length; j++) {
+ if (colValues[j] !== null) {
+ colValues[j] = parseIntToDate(colValues[j]);
+ }
+ }
+ }
+ }
+
// Build rows from columns
const rows: any[][] = [];
for (let rowIndex = 0; rowIndex < positionCount; rowIndex++) {
@@ -1313,15 +1392,14 @@ export class Session {
break;
}
case 9: {
- // DATE (stored as INT32 - days since epoch) - TSQueryDataSet uses
BIG ENDIAN
+ // DATE (stored as INT32 - yyyyMMdd) - TSQueryDataSet uses BIG ENDIAN
for (let i = 0; i < rowCount; i++) {
if (this.isNull(bitmap, i)) {
values.push(null);
} else {
- // Convert days since epoch to Date object
- const days = buffer.readInt32BE(i * 4);
- const date = new Date(days * 24 * 60 * 60 * 1000);
- values.push(date);
+ // Convert yyyyMMdd integer to Date object
+ const encoded = buffer.readInt32BE(i * 4);
+ values.push(parseIntToDate(encoded));
}
}
break;
diff --git a/src/client/SessionDataSet.ts b/src/client/SessionDataSet.ts
index 346ddde..ecaeb8a 100644
--- a/src/client/SessionDataSet.ts
+++ b/src/client/SessionDataSet.ts
@@ -274,6 +274,7 @@ export class SessionDataSet {
this.columnNames.length,
this.columnTypes,
this.ignoreTimeStamp,
+ this.columnIndex2TsBlockColumnIndexList,
);
} else if (response.queryDataSet) {
// Old columnar format (TSQueryDataSet)
diff --git a/src/index.ts b/src/index.ts
index 54fdee4..76a2965 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -39,7 +39,12 @@ export {
parseNodeUrls,
} from "./utils/Config";
export { logger, LogLevel } from "./utils/Logger";
-export { TSDataType, getDataTypeName } from "./utils/DataTypes";
+export {
+ TSDataType,
+ getDataTypeName,
+ parseDateToInt,
+ parseIntToDate,
+} from "./utils/DataTypes";
export { RedirectException, TSStatusCode } from "./utils/Errors";
export { RedirectCache } from "./client/RedirectCache";
export { enableGlobalCleanup } from "./utils/ProcessCleanup";
diff --git a/src/utils/DataTypes.ts b/src/utils/DataTypes.ts
index a497e8b..6fd2de3 100644
--- a/src/utils/DataTypes.ts
+++ b/src/utils/DataTypes.ts
@@ -77,8 +77,8 @@ export enum TSDataType {
/**
* Date with day precision (no time component)
- * JavaScript type: Date or number (days since epoch)
- * Storage size: 4 bytes (stored as INT32)
+ * JavaScript type: Date or number (yyyyMMdd integer, e.g. 20240101)
+ * Storage size: 4 bytes (stored as INT32, encoded as year*10000 + month*100
+ day)
*/
DATE = 9,
@@ -99,6 +99,121 @@ export enum TSDataType {
// OBJECT = 12, // Reserved - not yet implemented
}
+/**
+ * Days per month (index 0 = January) in a non-leap year.
+ */
+const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
+
+function isLeapYear(year: number): boolean {
+ return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
+}
+
+/**
+ * Validate a calendar date, matching the Java client's DateUtils semantics:
+ * years are restricted to 1000-9999 and (year, month, day) must form a real
+ * calendar date (LocalDate would reject e.g. 2023-02-29).
+ *
+ * @throws Error if the components do not form a valid calendar date
+ */
+function validateCalendarDate(
+ year: number,
+ month: number,
+ day: number,
+ source: string,
+): void {
+ if (year < 1000 || year > 9999) {
+ throw new Error(
+ `Invalid DATE value ${source}: year ${year} is out of range [1000,
9999]`,
+ );
+ }
+ if (month < 1 || month > 12) {
+ throw new Error(
+ `Invalid DATE value ${source}: month ${month} is out of range [1, 12]`,
+ );
+ }
+ const maxDay =
+ month === 2 && isLeapYear(year) ? 29 : DAYS_IN_MONTH[month - 1];
+ if (day < 1 || day > maxDay) {
+ throw new Error(
+ `Invalid DATE value ${source}: day ${day} is out of range [1, ${maxDay}]
for ${year}-${String(month).padStart(2, "0")}`,
+ );
+ }
+}
+
+/**
+ * Convert a JavaScript Date (or an already-encoded yyyyMMdd number) to the
+ * IoTDB DATE wire format: an INT32 encoded as year*10000 + month*100 + day
+ * (e.g. 2024-01-01 -> 20240101).
+ *
+ * This matches the Java client's DateUtils.parseDateExpressionToInt and the
+ * C# client. The calendar date is taken from the Date's UTC components,
+ * consistent with `new Date("2024-01-01")` which parses as UTC midnight.
+ *
+ * Invalid inputs are rejected (like the Java client, which limits years to
+ * 1000-9999 and whose LocalDate guarantees a valid calendar date): invalid
+ * Date objects, non-finite/non-integer numbers, and numbers whose yyyyMMdd
+ * decomposition is not a real calendar date all throw.
+ *
+ * Note: null/undefined are not handled here — callers filter nulls before
+ * invoking this helper.
+ *
+ * @param value - Date object or a yyyyMMdd integer (validated, then passed
+ * through unchanged)
+ * @returns The yyyyMMdd integer encoding
+ * @throws Error if the value is not a valid calendar date
+ */
+export function parseDateToInt(value: Date | number): number {
+ if (value instanceof Date) {
+ if (isNaN(value.getTime())) {
+ throw new Error("Invalid DATE value: Date object is invalid (NaN time)");
+ }
+ const year = value.getUTCFullYear();
+ const month = value.getUTCMonth() + 1;
+ const day = value.getUTCDate();
+ validateCalendarDate(year, month, day, value.toISOString());
+ return year * 10000 + month * 100 + day;
+ }
+ if (!Number.isInteger(value)) {
+ throw new Error(
+ `Invalid DATE value ${value}: expected an integer in yyyyMMdd form (e.g.
20240101)`,
+ );
+ }
+ validateCalendarDate(
+ Math.trunc(value / 10000),
+ Math.trunc(value / 100) % 100,
+ value % 100,
+ String(value),
+ );
+ return value;
+}
+
+/**
+ * Convert an IoTDB DATE wire value (INT32, year*10000 + month*100 + day)
+ * back to a JavaScript Date at UTC midnight of that calendar date.
+ *
+ * Inverse of {@link parseDateToInt}; matches the Java client's
+ * DateUtils.parseIntToDate. The value is validated the same way as
+ * {@link parseDateToInt} — non-integer numbers, years outside 1000-9999,
+ * and impossible calendar dates (e.g. 20230229) throw instead of being
+ * silently normalized by the Date constructor.
+ *
+ * @param value - The yyyyMMdd integer (e.g. 20240101)
+ * @returns Date at UTC midnight of the encoded calendar date
+ * @throws Error if the value is not a valid yyyyMMdd calendar date
+ */
+export function parseIntToDate(value: number): Date {
+ if (!Number.isInteger(value)) {
+ throw new Error(
+ `Invalid DATE value ${value}: expected an integer in yyyyMMdd form (e.g.
20240101)`,
+ );
+ }
+ const year = Math.trunc(value / 10000);
+ const month = Math.trunc(value / 100) % 100;
+ const day = value % 100;
+ validateCalendarDate(year, month, day, String(value));
+ return new Date(Date.UTC(year, month - 1, day));
+}
+
/**
* Get the name of a TSDataType from its numeric code
* @param typeCode - The numeric type code
diff --git a/src/utils/FastSerializer.ts b/src/utils/FastSerializer.ts
index a87f9b3..76ae9ed 100644
--- a/src/utils/FastSerializer.ts
+++ b/src/utils/FastSerializer.ts
@@ -18,6 +18,7 @@
*/
import { globalBufferPool } from "./BufferPool";
+import { parseDateToInt } from "./DataTypes";
/**
* Fast serialization utilities for IoTDB data types
@@ -167,26 +168,19 @@ export function serializeTimestampColumn(values: any[]):
Buffer {
}
/**
- * Serialize DATE column (4 bytes per value, days since epoch)
+ * Serialize DATE column (4 bytes per value, INT32 yyyyMMdd encoding)
* Optimized: Single buffer allocation with direct writes
*/
export function serializeDateColumn(values: any[]): Buffer {
const size = values.length * 4;
const buffer = size >= 1024 ? globalBufferPool.acquire(size) :
Buffer.allocUnsafe(size);
-
+
for (let i = 0; i < values.length; i++) {
const v = values[i];
- let days = 0;
- if (v !== null && v !== undefined) {
- if (v instanceof Date) {
- days = Math.floor(v.getTime() / (24 * 60 * 60 * 1000));
- } else {
- days = v;
- }
- }
- buffer.writeInt32BE(days, i * 4);
+ const encoded = v === null || v === undefined ? 0 : parseDateToInt(v);
+ buffer.writeInt32BE(encoded, i * 4);
}
-
+
return buffer.subarray(0, size);
}
diff --git a/tests/unit/DataTypes.test.ts b/tests/unit/DataTypes.test.ts
new file mode 100644
index 0000000..fd300a2
--- /dev/null
+++ b/tests/unit/DataTypes.test.ts
@@ -0,0 +1,292 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {
+ parseDateToInt,
+ parseIntToDate,
+} from "../../src/utils/DataTypes";
+import {
+ BaseColumnDecoder,
+ ColumnEncoding,
+} from "../../src/client/ColumnDecoder";
+import { Session } from "../../src/client/Session";
+
+describe("DATE yyyyMMdd conversion", () => {
+ describe("parseDateToInt", () => {
+ it("should encode a Date as INT32 yyyyMMdd (year*10000 + month*100 +
day)", () => {
+ expect(parseDateToInt(new Date("2026-07-13"))).toBe(20260713);
+ expect(parseDateToInt(new Date("2024-01-01"))).toBe(20240101);
+ expect(parseDateToInt(new Date("2024-12-31"))).toBe(20241231);
+ // Leap day
+ expect(parseDateToInt(new Date("2024-02-29"))).toBe(20240229);
+ });
+
+ it("should pass through already-encoded valid numbers unchanged", () => {
+ expect(parseDateToInt(20260713)).toBe(20260713);
+ expect(parseDateToInt(10000101)).toBe(10000101); // year lower bound
+ expect(parseDateToInt(99991231)).toBe(99991231); // year upper bound
+ });
+
+ it("should accept the leap day of a leap year", () => {
+ expect(parseDateToInt(20240229)).toBe(20240229);
+ expect(parseDateToInt(new Date("2024-02-29"))).toBe(20240229);
+ });
+
+ it("should reject invalid Date objects", () => {
+ expect(() => parseDateToInt(new Date(NaN))).toThrow(/Invalid DATE/);
+ });
+
+ it("should reject Date objects with years outside 1000-9999", () => {
+ expect(() => parseDateToInt(new Date("0999-12-31"))).toThrow(
+ /year 999/,
+ );
+ });
+
+ it("should reject non-finite and non-integer numbers", () => {
+ expect(() => parseDateToInt(NaN)).toThrow(/Invalid DATE/);
+ expect(() => parseDateToInt(Infinity)).toThrow(/Invalid DATE/);
+ expect(() => parseDateToInt(20240101.5)).toThrow(/Invalid DATE/);
+ });
+
+ it("should reject numbers that are not real calendar dates", () => {
+ expect(() => parseDateToInt(20230229)).toThrow(/day 29/); // not a leap
year
+ expect(() => parseDateToInt(20241301)).toThrow(/month 13/);
+ expect(() => parseDateToInt(20240100)).toThrow(/day 0/);
+ expect(() => parseDateToInt(9991231)).toThrow(/year 999/);
+ expect(() => parseDateToInt(100000101)).toThrow(/year 10000/);
+ // Old days-since-epoch value: 19723 -> year 1, month 97 -> invalid
+ expect(() => parseDateToInt(19723)).toThrow(/Invalid DATE/);
+ expect(() => parseDateToInt(0)).toThrow(/Invalid DATE/);
+ });
+ });
+
+ describe("parseIntToDate", () => {
+ it("should decode INT32 yyyyMMdd to a Date at UTC midnight", () => {
+ const date = parseIntToDate(20260713);
+ expect(date.getUTCFullYear()).toBe(2026);
+ expect(date.getUTCMonth()).toBe(6); // July (0-based)
+ expect(date.getUTCDate()).toBe(13);
+ expect(date.getUTCHours()).toBe(0);
+ expect(date.getUTCMinutes()).toBe(0);
+ });
+
+ it("should decode the leap day of a leap year", () => {
+ const date = parseIntToDate(20240229);
+ expect(date.getUTCFullYear()).toBe(2024);
+ expect(date.getUTCMonth()).toBe(1);
+ expect(date.getUTCDate()).toBe(29);
+ });
+
+ it("should reject impossible calendar dates instead of normalizing", () =>
{
+ // new Date(Date.UTC(2023, 1, 29)) would silently become 2023-03-01
+ expect(() => parseIntToDate(20230229)).toThrow(/day 29/);
+ expect(() => parseIntToDate(20241301)).toThrow(/month 13/);
+ expect(() => parseIntToDate(20240100)).toThrow(/day 0/);
+ });
+
+ it("should reject years outside 1000-9999", () => {
+ expect(() => parseIntToDate(9991231)).toThrow(/year 999/);
+ expect(() => parseIntToDate(100000101)).toThrow(/year 10000/);
+ // Old days-since-epoch value is not a valid yyyyMMdd
+ expect(() => parseIntToDate(19723)).toThrow(/Invalid DATE/);
+ });
+
+ it("should reject non-integer numbers", () => {
+ expect(() => parseIntToDate(NaN)).toThrow(/Invalid DATE/);
+ expect(() => parseIntToDate(20240101.5)).toThrow(/Invalid DATE/);
+ });
+ });
+
+ describe("round-trip", () => {
+ it("should round-trip Date -> yyyyMMdd -> Date", () => {
+ const dates = [
+ new Date("1970-01-01"),
+ new Date("2000-02-29"),
+ new Date("2024-01-01"),
+ new Date("2026-07-13"),
+ new Date("9999-12-31"),
+ ];
+ for (const original of dates) {
+ const encoded = parseDateToInt(original);
+ const decoded = parseIntToDate(encoded);
+ expect(decoded.getTime()).toBe(original.getTime());
+ // And the integer round-trips too
+ expect(parseDateToInt(decoded)).toBe(encoded);
+ }
+ });
+ });
+
+ describe("TsBlock column decode (Int32ArrayColumnDecoder)", () => {
+ it("should decode a DATE column value as a Date from yyyyMMdd wire bytes",
() => {
+ // Column layout: 1 byte null flag (0 = no nulls) + INT32 BE values
+ // 20260713 = 0x01352769
+ const buffer = Buffer.from([0x00, 0x01, 0x35, 0x27, 0x69]);
+ const decoder = BaseColumnDecoder.getDecoder(ColumnEncoding.Int32Array);
+ const { column, bytesRead } = decoder.readColumn(buffer, 0, 9, 1);
+
+ expect(bytesRead).toBe(5);
+ const value = column.values[0];
+ expect(value).toBeInstanceOf(Date);
+ expect(value.getUTCFullYear()).toBe(2026);
+ expect(value.getUTCMonth()).toBe(6);
+ expect(value.getUTCDate()).toBe(13);
+ });
+
+ it("should still decode plain INT32 columns as numbers", () => {
+ const buffer = Buffer.from([0x00, 0x01, 0x35, 0x27, 0x69]);
+ const decoder = BaseColumnDecoder.getDecoder(ColumnEncoding.Int32Array);
+ const { column } = decoder.readColumn(buffer, 0, 1, 1);
+ expect(column.values[0]).toBe(20260713);
+ });
+ });
+
+ describe("parseTsBlock DATE conversion with
columnIndex2TsBlockColumnIndexList", () => {
+ let session: Session;
+
+ beforeEach(() => {
+ session = new Session({
+ host: "localhost",
+ port: 6667,
+ username: "root",
+ password: "root",
+ });
+ });
+
+ /**
+ * Craft a TsBlock buffer with INT32 (wire type 1) value columns:
+ * valueColumnCount i32 BE | type bytes | positionCount i32 BE |
+ * time encoding byte | value encoding bytes | time column | value columns
+ * Each column: 1-byte null flag (0 = no nulls) + big-endian values.
+ */
+ function craftInt32TsBlock(
+ timestamps: number[],
+ int32Columns: number[][],
+ ): Buffer {
+ const positionCount = timestamps.length;
+ const parts: Buffer[] = [];
+
+ const header = Buffer.alloc(4);
+ header.writeInt32BE(int32Columns.length, 0);
+ parts.push(header);
+ // Wire type of every value column is INT32 (1)
+ parts.push(Buffer.from(int32Columns.map(() => 1)));
+
+ const posBuf = Buffer.alloc(4);
+ posBuf.writeInt32BE(positionCount, 0);
+ parts.push(posBuf);
+
+ // Encodings: time column Int64Array (2), value columns Int32Array (1)
+ parts.push(Buffer.from([2, ...int32Columns.map(() => 1)]));
+
+ // Time column: null flag + INT64 BE timestamps
+ const timeBuf = Buffer.alloc(1 + positionCount * 8);
+ timeBuf.writeUInt8(0, 0);
+ timestamps.forEach((t, i) => {
+ timeBuf.writeBigInt64BE(BigInt(t), 1 + i * 8);
+ });
+ parts.push(timeBuf);
+
+ // Value columns: null flag + INT32 BE values
+ for (const columnValues of int32Columns) {
+ const colBuf = Buffer.alloc(1 + positionCount * 4);
+ colBuf.writeUInt8(0, 0);
+ columnValues.forEach((v, i) => {
+ colBuf.writeInt32BE(v, 1 + i * 4);
+ });
+ parts.push(colBuf);
+ }
+
+ return Buffer.concat(parts);
+ }
+
+ it("should convert the DATE column per the non-identity logical->physical
mapping", async () => {
+ // Logical columns: [DATE, INT32]; physical TsBlock columns reordered:
+ // logical 0 (DATE) -> physical 1, logical 1 (INT32) -> physical 0
+ const buffer = craftInt32TsBlock([1000], [[42], [20240101]]);
+
+ const rows = await (session as any).parseQueryResult(
+ [buffer],
+ 2,
+ ["DATE", "INT32"],
+ false,
+ [1, 0],
+ );
+
+ expect(rows).toHaveLength(1);
+ const [timestamp, physical0, physical1] = rows[0];
+ expect(Number(timestamp)).toBe(1000);
+ // Physical column 0 is logically INT32 -> stays a number
+ expect(physical0).toBe(42);
+ // Physical column 1 is logically DATE -> converted to Date
+ expect(physical1).toBeInstanceOf(Date);
+ expect(physical1.getUTCFullYear()).toBe(2024);
+ expect(physical1.getUTCMonth()).toBe(0);
+ expect(physical1.getUTCDate()).toBe(1);
+ });
+
+ it("should keep identity behavior when the mapping is absent", async () =>
{
+ const buffer = craftInt32TsBlock([1000], [[20240101], [42]]);
+
+ const rows = await (session as any).parseQueryResult(
+ [buffer],
+ 2,
+ ["DATE", "INT32"],
+ false,
+ );
+
+ expect(rows).toHaveLength(1);
+ expect(rows[0][1]).toBeInstanceOf(Date);
+ expect(rows[0][1].getUTCFullYear()).toBe(2024);
+ expect(rows[0][2]).toBe(42);
+ });
+
+ it("should handle deduplicated columns (two logical columns -> one
physical)", async () => {
+ // SELECT d, d: logical [DATE, DATE] both map to physical 0
+ const buffer = craftInt32TsBlock([1000], [[20240101]]);
+
+ const rows = await (session as any).parseQueryResult(
+ [buffer],
+ 2,
+ ["DATE", "DATE"],
+ false,
+ [0, 0],
+ );
+
+ expect(rows).toHaveLength(1);
+ expect(rows[0][1]).toBeInstanceOf(Date);
+ expect(rows[0][1].getUTCFullYear()).toBe(2024);
+ });
+
+ it("should not crash nor convert when logical types disagree for one
physical column", async () => {
+ const buffer = craftInt32TsBlock([1000], [[20240101]]);
+
+ const rows = await (session as any).parseQueryResult(
+ [buffer],
+ 2,
+ ["DATE", "INT32"],
+ false,
+ [0, 0],
+ );
+
+ expect(rows).toHaveLength(1);
+ // Ambiguous type -> no DATE conversion applied
+ expect(rows[0][1]).toBe(20240101);
+ });
+ });
+});
diff --git a/tests/unit/FastSerializer.test.ts
b/tests/unit/FastSerializer.test.ts
index 4f2b7ba..f6e673d 100644
--- a/tests/unit/FastSerializer.test.ts
+++ b/tests/unit/FastSerializer.test.ts
@@ -155,18 +155,31 @@ describe("FastSerializer", () => {
});
describe("DATE Serialization", () => {
- it("should serialize DATE values correctly", () => {
+ it("should serialize DATE values as INT32 yyyyMMdd", () => {
const date1 = new Date("2024-01-01");
- const days1 = Math.floor(date1.getTime() / (24 * 60 * 60 * 1000));
- const values = [date1, 100, null, undefined];
+ const values = [date1, 20241231, null, undefined];
const buffer = serializeDateColumn(values);
expect(buffer.length).toBe(16); // 4 * 4 bytes
- expect(buffer.readInt32BE(0)).toBe(days1);
- expect(buffer.readInt32BE(4)).toBe(100);
+ expect(buffer.readInt32BE(0)).toBe(20240101); // yyyyMMdd, NOT days
since epoch
+ expect(buffer.readInt32BE(4)).toBe(20241231); // numbers pass through
unchanged
expect(buffer.readInt32BE(8)).toBe(0); // null -> 0
expect(buffer.readInt32BE(12)).toBe(0); // undefined -> 0
});
+
+ it("should produce exact wire bytes for 2026-07-13", () => {
+ // IoTDB DATE wire format: INT32 yyyyMMdd, big-endian
+ // 20260713 = 0x01352769
+ const buffer = serializeDateColumn([new Date("2026-07-13")]);
+ expect(Array.from(buffer)).toEqual([0x01, 0x35, 0x27, 0x69]);
+ });
+
+ it("should reject invalid DATE values", () => {
+ expect(() => serializeDateColumn([20230229])).toThrow(/Invalid DATE/);
// not a leap year
+ expect(() => serializeDateColumn([new Date(NaN)])).toThrow(
+ /Invalid DATE/,
+ );
+ });
});
describe("BLOB Serialization", () => {
@@ -241,8 +254,8 @@ describe("FastSerializer", () => {
const tsBuffer = serializeColumnFast([1000, 2000], 8);
expect(tsBuffer.length).toBe(16);
- // DATE (9)
- const dateBuffer = serializeColumnFast([100, 200], 9);
+ // DATE (9) - values must be valid yyyyMMdd integers
+ const dateBuffer = serializeColumnFast([20240101, 20241231], 9);
expect(dateBuffer.length).toBe(8);
// BLOB (10)
diff --git a/tests/unit/TabletSerialization.test.ts
b/tests/unit/TabletSerialization.test.ts
index fb86706..620a432 100644
--- a/tests/unit/TabletSerialization.test.ts
+++ b/tests/unit/TabletSerialization.test.ts
@@ -342,18 +342,35 @@ describe('Tablet Serialization', () => {
expect(buffer.readDoubleBE(16)).toBe(0.0);
});
- test('should serialize DATE column', () => {
+ test('should serialize DATE column as INT32 yyyyMMdd', () => {
const date1 = new Date('2024-01-01');
const date2 = new Date('2024-12-31');
- const values = [date1, date2, 0];
+ const values = [date1, date2, 20260713];
const buffer = (session as any).serializeColumn(values, TSDataType.DATE);
expect(buffer.length).toBe(12); // 3 values * 4 bytes
- const days1 = Math.floor(date1.getTime() / (24 * 60 * 60 * 1000));
- const days2 = Math.floor(date2.getTime() / (24 * 60 * 60 * 1000));
- expect(buffer.readInt32BE(0)).toBe(days1);
- expect(buffer.readInt32BE(4)).toBe(days2);
- expect(buffer.readInt32BE(8)).toBe(0);
+ expect(buffer.readInt32BE(0)).toBe(20240101); // yyyyMMdd, NOT days
since epoch
+ expect(buffer.readInt32BE(4)).toBe(20241231);
+ expect(buffer.readInt32BE(8)).toBe(20260713); // numbers pass through
unchanged
+ });
+
+ test('should reject invalid DATE values during serialization', () => {
+ expect(() =>
+ (session as any).serializeColumn([20230229], TSDataType.DATE),
+ ).toThrow(/Invalid DATE/); // 2023 is not a leap year
+ expect(() =>
+ (session as any).serializeColumn([new Date(NaN)], TSDataType.DATE),
+ ).toThrow(/Invalid DATE/);
+ });
+
+ test('should serialize DATE 2026-07-13 with exact wire bytes', () => {
+ // IoTDB DATE wire format: INT32 yyyyMMdd, big-endian
+ // 20260713 = 0x01352769
+ const buffer = (session as any).serializeColumn(
+ [new Date('2026-07-13')],
+ TSDataType.DATE,
+ );
+ expect(Array.from(buffer)).toEqual([0x01, 0x35, 0x27, 0x69]);
});
test('should serialize TIMESTAMP column', () => {