Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions packages/client-core/src/_qwp/sender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1499,13 +1499,19 @@ export class QwpSender {
}
}

intColumn(name: string, value: number | null | undefined): QwpSender {
intColumn(
name: string,
value: number | bigint | null | undefined,
): QwpSender {
if (this.omitsNullish(name, value)) return this;
try {
return this.addColumn(
name,
QWP_COLUMN_TYPE.LONG,
BigInt(checkedInteger(value, "intColumn value")),
// Same LONG column as longColumn(), so it takes the same values:
// checkedInt64() keeps the safe-integer rule for numbers and adds the
// int64 bound a BigInt needs.
checkedInt64(value, "intColumn value"),
);
} catch (error) {
return this.failRow(error);
Expand Down
26 changes: 21 additions & 5 deletions packages/nodejs-client/src/buffer/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import { isInteger, TimestampUnit } from "../utils";
// Default maximum length for table and column names.
const DEFAULT_MAX_NAME_LENGTH = 127;

// QuestDB's LONG is a 64-bit signed integer. A BigInt has no width of its own,
// so out-of-range values have to be rejected before they reach the wire.
const INT64_MIN = -9223372036854775808n;
const INT64_MAX = 9223372036854775807n;

/**
* Abstract base class for sender buffer implementations. <br>
* Provides common functionality for writing data into the buffer.
Expand Down Expand Up @@ -296,18 +301,29 @@ abstract class SenderBufferBase implements SenderBuffer {
* Use it to insert into LONG, INT, SHORT and BYTE columns.
*
* @param {string} name - Column name.
* @param {number | null | undefined} value - Column value, accepts only number values. A null or undefined value omits the column entirely (stored as NULL).
* @param {number | bigint | null | undefined} value - Column value, accepts integer or `BigInt` values. LONG is a 64-bit signed integer, which is wider than the safe integer range of `number`, so pass a `BigInt` beyond `Number.MAX_SAFE_INTEGER` to avoid losing precision. A null or undefined value omits the column entirely (stored as NULL).
* @return {SenderBuffer} Returns with a reference to this buffer.
* @throws Error if the value is not an integer
* @throws Error if the value is not an integer or a `BigInt`
* @throws RangeError if the value does not fit into a 64-bit signed integer
*/
intColumn(name: string, value: number | null | undefined): SenderBuffer {
intColumn(
name: string,
value: number | bigint | null | undefined,
): SenderBuffer {
this.validateColumnCall(name);
// A null or undefined value omits the column entirely (see issue #28).
if (this.isNullOrUndefined(value)) {
return this.omitColumn();
}
if (!Number.isInteger(value)) {
throw new Error(`Value must be an integer, received ${value}`);
if (typeof value === "bigint") {
// A BigInt is always an integer, so only its width is in question.
if (value < INT64_MIN || value > INT64_MAX) {
throw new RangeError(
`Value must fit into a 64-bit signed integer, received ${value}`,
);
}
} else if (!Number.isInteger(value)) {
throw new Error(`Value must be an integer or BigInt, received ${value}`);
}
this.writeColumn(name, value, () => {
const valueStr = value.toString();
Expand Down
10 changes: 7 additions & 3 deletions packages/nodejs-client/src/buffer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,11 +137,15 @@ interface SenderBuffer {
* Writes a 64-bit signed integer into the buffer.
* Use it to insert into LONG, INT, SHORT and BYTE columns.
* @param name - Column name.
* @param value - Column value, accepts only number values. A null or undefined value omits the column entirely (stored as NULL).
* @param value - Column value, accepts integer or `BigInt` values. LONG is a 64-bit signed integer, which is wider than the safe integer range of `number`, so pass a `BigInt` beyond `Number.MAX_SAFE_INTEGER` to avoid losing precision. A null or undefined value omits the column entirely (stored as NULL).
* @returns Returns with a reference to this buffer.
* @throws Error if the value is not an integer
* @throws Error if the value is not an integer or a `BigInt`
* @throws RangeError if the value does not fit into a 64-bit signed integer
*/
intColumn(name: string, value: number | null | undefined): SenderBuffer;
intColumn(
name: string,
value: number | bigint | null | undefined,
): SenderBuffer;

/**
* Writes a timestamp column and its value into the buffer.
Expand Down
7 changes: 4 additions & 3 deletions packages/nodejs-client/src/sender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,11 +455,12 @@ class Sender {
* Use it to insert into LONG, INT, SHORT and BYTE columns.
*
* @param {string} name - Column name.
* @param {number | null | undefined} value - Column value, accepts only number values. A null or undefined value omits the column entirely (stored as NULL).
* @param {number | bigint | null | undefined} value - Column value, accepts integer or `BigInt` values. LONG is a 64-bit signed integer, which is wider than the safe integer range of `number`, so pass a `BigInt` beyond `Number.MAX_SAFE_INTEGER` to avoid losing precision. A null or undefined value omits the column entirely (stored as NULL).
* @return {Sender} Returns with a reference to this sender.
* @throws Error if the value is not an integer
* @throws Error if the value is not an integer or a `BigInt`
* @throws RangeError if the value does not fit into a 64-bit signed integer
*/
intColumn(name: string, value: number | null | undefined): Sender {
intColumn(name: string, value: number | bigint | null | undefined): Sender {
if (this.qwpSender) this.qwpSender.intColumn(name, value);
else this.buffer!.intColumn(name, value);
return this;
Expand Down
23 changes: 23 additions & 0 deletions test/qwp/sender.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,29 @@ describe("QWP high-level sender", () => {
expect(explicit.columns[0].name).toBe("");
});

it("accepts BigInt values in intColumn()", async () => {
// intColumn() and longColumn() stage the same QWP LONG column, so a BigInt
// the ILP path accepts has to survive this path too. 2^62 is past
// Number.MAX_SAFE_INTEGER, where a `number` would already have rounded.
const session = new RecordingSession();
const sender = new QwpSender(async () => session, { autoFlush: false });
await sender
.table("events")
.intColumn("value", 2n ** 62n)
.atNow();
await expect(sender.flush()).resolves.toBe(true);

const column = session.sends[0].tables[0].columns[0];
expect(column.type).toBe(QWP_COLUMN_TYPE.LONG);
expect(column.values[0]).toBe(4611686018427387904n);

// The int64 bound still holds, and the rejected row is discarded.
expect(() => sender.table("events").intColumn("value", 2n ** 63n)).toThrow(
"intColumn value exceeds signed int64",
);
await sender.close();
});

it("uses the Java-compatible local-publication flush boundary by default", async () => {
const session = new PublishingSession();
const sender = new QwpSender(async () => session, { autoFlush: false });
Expand Down
79 changes: 78 additions & 1 deletion test/sender.buffer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1586,10 +1586,87 @@ describe("Sender message builder test suite (anything not covered in client inte
});
expect(() =>
sender.table("tableName").intColumn("intField", 123.222),
).toThrow("Value must be an integer, received 123.222");
).toThrow("Value must be an integer or BigInt, received 123.222");
await sender.close();
});

it("supports BigInt values in integer fields", async function () {
const sender = new Sender({
protocol: "tcp",
protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
await sender
.table("tableName")
.intColumn("small", 42n)
.intColumn("negative", -42n)
.at(1658484769000000, "us");
expect(bufferContent(sender)).toBe(
"tableName small=42i,negative=-42i 1658484769000000000\n",
);
await sender.close();
});

it("keeps full LONG precision above Number.MAX_SAFE_INTEGER", async function () {
const sender = new Sender({
protocol: "tcp",
protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
// 2^63-1, the largest QuestDB LONG. As a `number` this rounds to
// 9223372036854775808, which is out of range for the column.
await sender
.table("tableName")
.intColumn("maxLong", 9223372036854775807n)
.at(1658484769000000, "us");
expect(bufferContent(sender)).toBe(
"tableName maxLong=9223372036854775807i 1658484769000000000\n",
);
await sender.close();
});

it("throws exception if a BigInt does not fit into a 64-bit signed integer", async function () {
const build = () =>
new Sender({
protocol: "tcp",
protocol_version: "1",
host: "host",
init_buf_size: 1024,
});
// A BigInt carries no width of its own: without the bound, 2^63 would be
// written as 9223372036854775808i and rejected by the server, not here.
const tooLarge = build();
expect(() =>
tooLarge.table("tableName").intColumn("intField", 2n ** 63n),
).toThrow(
"Value must fit into a 64-bit signed integer, received 9223372036854775808",
);
await tooLarge.close();

const tooSmall = build();
expect(() =>
tooSmall.table("tableName").intColumn("intField", -(2n ** 63n) - 1n),
).toThrow(
"Value must fit into a 64-bit signed integer, received -9223372036854775809",
);
await tooSmall.close();

// The bounds themselves are in range. -2^63 is QuestDB's LONG NULL
// sentinel, so the server stores it as NULL rather than as a value, but it
// is a legal int64 and the guard lets it through.
const atBound = build();
await atBound
.table("tableName")
.intColumn("minLong", -(2n ** 63n))
.at(1658484769000000, "us");
expect(bufferContent(atBound)).toBe(
"tableName minLong=-9223372036854775808i 1658484769000000000\n",
);
await atBound.close();
});

it("throws exception if a float is passed as timestamp field", async function () {
const sender = new Sender({
protocol: "tcp",
Expand Down
Loading