From 1132c1f1ab3c2479fbd2f51512b1e21146861eef Mon Sep 17 00:00:00 2001 From: James Broadhead Date: Tue, 28 Apr 2026 22:21:23 +0000 Subject: [PATCH 1/6] feat(appkit): infer numeric SQL type for sql.number(), add typed variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently sql.number() unconditionally produces __sql_type: "NUMERIC", which Databricks SQL binds as DECIMAL(10,0). That breaks LIMIT and OFFSET — Spark requires an integer-typed expression and rejects the parameter with INVALID_LIMIT_LIKE_EXPRESSION.DATA_TYPE — and silently truncates non-integer JS numbers (sql.number(3.14) sent value="3.14" into a DECIMAL(10,0) slot). Two changes: 1. sql.number() now infers the wire type from the value: - integer JS number -> BIGINT - non-integer JS number -> DOUBLE - numeric string -> NUMERIC (preserves caller's precision intent; a string is an explicit choice to skip JS-number coercion) Picks the most-precise type the value can losslessly represent. Spark coerces numeric types implicitly so existing queries against BIGINT/DECIMAL/DOUBLE columns keep working, plus LIMIT now accepts sql.number(10) directly. 2. Typed variants for callers who need to override the inference: - sql.int(value) -> INT - sql.bigint(value) -> BIGINT (also accepts JS bigint for values beyond Number.MAX_SAFE_INTEGER) - sql.float(value) -> FLOAT - sql.double(value) -> DOUBLE - sql.decimal(value) -> NUMERIC (decimal precision preserved) sql.int() and sql.bigint() reject non-integer inputs at the helper boundary so the failure surfaces early, before the wire. SQLNumberMarker.__sql_type widens from "NUMERIC" to a union of the numeric SQL types. Non-breaking for callers: any code that previously held an SQLNumberMarker still type-checks (the union is wider in return position). The two unit tests that pinned `type: "NUMERIC"` for sql.number(integer) are updated to expect BIGINT. Discovered while building a parameterized analytics query against LIMIT — the bare `LIMIT :limit` case is the most visible failure but the underlying issue affects any query where the column type matters. Signed-off-by: James Broadhead --- .../src/plugins/analytics/tests/query.test.ts | 4 +- packages/shared/src/sql/helpers.ts | 150 +++++++++++++++--- .../shared/src/sql/tests/sql-helpers.test.ts | 80 ++++++++-- packages/shared/src/sql/types.ts | 14 +- 4 files changed, 216 insertions(+), 32 deletions(-) diff --git a/packages/appkit/src/plugins/analytics/tests/query.test.ts b/packages/appkit/src/plugins/analytics/tests/query.test.ts index 7840b2526..1fa2c3310 100644 --- a/packages/appkit/src/plugins/analytics/tests/query.test.ts +++ b/packages/appkit/src/plugins/analytics/tests/query.test.ts @@ -32,7 +32,7 @@ describe("QueryProcessor", () => { expect(result.statement).toBe(query); expect(result.parameters).toHaveLength(2); expect(result.parameters).toEqual([ - { name: "user_id", value: "123", type: "NUMERIC" }, + { name: "user_id", value: "123", type: "BIGINT" }, { name: "name", value: "Alice", type: "STRING" }, ]); }); @@ -229,7 +229,7 @@ describe("QueryProcessor", () => { expect(result.parameters[0]).toEqual({ name: "age", value: "25", - type: "NUMERIC", + type: "BIGINT", }); }); diff --git a/packages/shared/src/sql/helpers.ts b/packages/shared/src/sql/helpers.ts index 85b39520f..d5553e8dd 100644 --- a/packages/shared/src/sql/helpers.ts +++ b/packages/shared/src/sql/helpers.ts @@ -8,6 +8,45 @@ import type { SQLTypeMarker, } from "./types"; +function coerceNumericLike(value: number | string, fnName: string): string { + if (typeof value === "number") { + return value.toString(); + } + if (typeof value === "string") { + if (value === "" || Number.isNaN(Number(value))) { + throw new Error( + `${fnName}() expects number or numeric string, got: ${value === "" ? "empty string" : value}`, + ); + } + return value; + } + throw new Error( + `${fnName}() expects number or numeric string, got: ${typeof value}`, + ); +} + +function coerceIntegerLike(value: number | string, fnName: string): string { + if (typeof value === "number") { + if (!Number.isInteger(value)) { + throw new Error( + `${fnName}() expects an integer, got non-integer number: ${value}`, + ); + } + return value.toString(); + } + if (typeof value === "string") { + if (value === "" || !/^-?\d+$/.test(value)) { + throw new Error( + `${fnName}() expects integer number or integer-shaped string, got: ${value === "" ? "empty string" : value}`, + ); + } + return value; + } + throw new Error( + `${fnName}() expects integer number or integer-shaped string, got: ${typeof value}`, + ); +} + /** * SQL helper namespace */ @@ -109,50 +148,125 @@ export const sql = { }, /** - * Creates a NUMERIC type parameter - * Accepts numbers or numeric strings + * Creates a numeric type parameter. The wire SQL type is inferred from the + * value so the parameter binds correctly in any context, including `LIMIT` + * and `OFFSET` (which require integer types): + * + * - JS integer (`10`) → `BIGINT` + * - JS non-integer (`3.14`) → `DOUBLE` + * - numeric string (`"123.45"`) → `NUMERIC` (preserves caller's precision intent) + * + * Reach for `sql.int()`, `sql.bigint()`, `sql.float()`, `sql.double()`, or + * `sql.decimal()` if you need to override the inferred type. + * * @param value - Number or numeric string - * @returns Marker object for NUMERIC type parameter + * @returns Marker for a numeric SQL parameter * @example * ```typescript - * const params = { userId: sql.number(123) }; - * params = { userId: "123" } - * ``` - * @example - * ```typescript - * const params = { userId: sql.number("123") }; - * params = { userId: "123" } + * const params = { userId: sql.number(123) }; // BIGINT, value "123" + * const params = { ratio: sql.number(0.5) }; // DOUBLE, value "0.5" + * const params = { amount: sql.number("123.45") }; // NUMERIC, value "123.45" * ``` */ number(value: number | string): SQLNumberMarker { let numValue: string = ""; + let inferredType: SQLNumberMarker["__sql_type"] = "NUMERIC"; - // check if value is a number if (typeof value === "number") { numValue = value.toString(); - } - // check if value is a string - else if (typeof value === "string") { + inferredType = Number.isInteger(value) ? "BIGINT" : "DOUBLE"; + } else if (typeof value === "string") { if (value === "" || Number.isNaN(Number(value))) { throw new Error( `sql.number() expects number or numeric string, got: ${value === "" ? "empty string" : value}`, ); } numValue = value; - } - // if value is not a number or string, throw an error - else { + // Strings stay NUMERIC: the caller chose to pass a string, so honour + // their precision intent rather than coercing through JS number. + inferredType = "NUMERIC"; + } else { throw new Error( `sql.number() expects number or numeric string, got: ${typeof value}`, ); } return { - __sql_type: "NUMERIC", + __sql_type: inferredType, value: numValue, }; }, + /** + * Creates an `INT` (32-bit signed integer) parameter. Use when the column + * or context requires `INT` specifically (e.g. legacy schemas, or to make + * the wire type explicit). + * + * @param value - Integer number or integer-shaped string + */ + int(value: number | string): SQLNumberMarker { + return { + __sql_type: "INT", + value: coerceIntegerLike(value, "sql.int"), + }; + }, + + /** + * Creates a `BIGINT` (64-bit signed integer) parameter. Accepts JS + * `bigint` so callers can round-trip values outside `Number.MAX_SAFE_INTEGER` + * without precision loss. + * + * @param value - Integer number, bigint, or integer-shaped string + */ + bigint(value: number | bigint | string): SQLNumberMarker { + if (typeof value === "bigint") { + return { __sql_type: "BIGINT", value: value.toString() }; + } + return { + __sql_type: "BIGINT", + value: coerceIntegerLike(value, "sql.bigint"), + }; + }, + + /** + * Creates a `FLOAT` (single-precision) parameter. + * + * @param value - Number or numeric string + */ + float(value: number | string): SQLNumberMarker { + return { + __sql_type: "FLOAT", + value: coerceNumericLike(value, "sql.float"), + }; + }, + + /** + * Creates a `DOUBLE` (double-precision) parameter. Same precision as a JS + * `number`, so `sql.double(value)` is exact for any JS number. + * + * @param value - Number or numeric string + */ + double(value: number | string): SQLNumberMarker { + return { + __sql_type: "DOUBLE", + value: coerceNumericLike(value, "sql.double"), + }; + }, + + /** + * Creates a `NUMERIC` (fixed-point DECIMAL) parameter. Use when you need + * exact decimal arithmetic (currency, percentages) — pass values as + * strings to avoid JS-number precision loss. + * + * @param value - Number or numeric string (strings preferred for precision) + */ + decimal(value: number | string): SQLNumberMarker { + return { + __sql_type: "NUMERIC", + value: coerceNumericLike(value, "sql.decimal"), + }; + }, + /** * Creates a STRING type parameter * Accepts strings, numbers, or booleans diff --git a/packages/shared/src/sql/tests/sql-helpers.test.ts b/packages/shared/src/sql/tests/sql-helpers.test.ts index 9b62f4831..0f36bf21e 100644 --- a/packages/shared/src/sql/tests/sql-helpers.test.ts +++ b/packages/shared/src/sql/tests/sql-helpers.test.ts @@ -37,27 +37,40 @@ describe("SQL Helpers", () => { }); describe("number()", () => { - it("should create a NUMERIC type parameter from a number", () => { - const number = 1234567890; - const result = sql.number(number); + it("should bind a JS integer as BIGINT (works in LIMIT/OFFSET)", () => { + const result = sql.number(1234567890); expect(result).toEqual({ - __sql_type: "NUMERIC", + __sql_type: "BIGINT", value: "1234567890", }); }); - it("should create a NUMERIC type parameter from a numeric string", () => { - const number = "1234567890"; - const result = sql.number(number); + it("should bind a JS non-integer as DOUBLE", () => { + const result = sql.number(3.14); + expect(result).toEqual({ + __sql_type: "DOUBLE", + value: "3.14", + }); + }); + + it("should keep numeric strings as NUMERIC (preserve precision)", () => { + const result = sql.number("1234567890"); expect(result).toEqual({ __sql_type: "NUMERIC", value: "1234567890", }); }); + it("should keep decimal strings as NUMERIC (no JS-number coercion)", () => { + const result = sql.number("123.4500000000001"); + expect(result).toEqual({ + __sql_type: "NUMERIC", + value: "123.4500000000001", + }); + }); + it("should reject non-numeric string", () => { - const number = "hello"; - expect(() => sql.number(number as any)).toThrow( + expect(() => sql.number("hello" as any)).toThrow( "sql.number() expects number or numeric string, got: hello", ); }); @@ -69,13 +82,58 @@ describe("SQL Helpers", () => { }); it("should reject boolean value", () => { - const number = true; - expect(() => sql.number(number as any)).toThrow( + expect(() => sql.number(true as any)).toThrow( "sql.number() expects number or numeric string, got: boolean", ); }); }); + describe("int() / bigint() / float() / double() / decimal()", () => { + it("sql.int() should produce INT", () => { + expect(sql.int(42)).toEqual({ __sql_type: "INT", value: "42" }); + expect(sql.int("42")).toEqual({ __sql_type: "INT", value: "42" }); + }); + + it("sql.int() should reject non-integers", () => { + expect(() => sql.int(3.14)).toThrow( + "sql.int() expects an integer, got non-integer number: 3.14", + ); + expect(() => sql.int("3.14")).toThrow( + "sql.int() expects integer number or integer-shaped string, got: 3.14", + ); + }); + + it("sql.bigint() should produce BIGINT and accept JS bigint", () => { + expect(sql.bigint(42)).toEqual({ __sql_type: "BIGINT", value: "42" }); + expect(sql.bigint("9007199254740993")).toEqual({ + __sql_type: "BIGINT", + value: "9007199254740993", + }); + expect(sql.bigint(9007199254740993n)).toEqual({ + __sql_type: "BIGINT", + value: "9007199254740993", + }); + }); + + it("sql.float() should produce FLOAT", () => { + expect(sql.float(3.14)).toEqual({ __sql_type: "FLOAT", value: "3.14" }); + }); + + it("sql.double() should produce DOUBLE", () => { + expect(sql.double(3.14)).toEqual({ + __sql_type: "DOUBLE", + value: "3.14", + }); + }); + + it("sql.decimal() should produce NUMERIC", () => { + expect(sql.decimal("12345.6789")).toEqual({ + __sql_type: "NUMERIC", + value: "12345.6789", + }); + }); + }); + describe("string()", () => { it("should create a STRING type parameter from a string", () => { const string = "Hello, world!"; diff --git a/packages/shared/src/sql/types.ts b/packages/shared/src/sql/types.ts index e2dabcbdf..52c4191c4 100644 --- a/packages/shared/src/sql/types.ts +++ b/packages/shared/src/sql/types.ts @@ -3,8 +3,20 @@ export interface SQLStringMarker { value: string; } +/** + * SQL numeric parameter marker. The wire type controls how Databricks SQL + * binds the value — notably, only integer types satisfy the `LIMIT` and + * `OFFSET` clauses. + * + * - `BIGINT` / `INT` — integer columns, LIMIT/OFFSET, IDs + * - `FLOAT` / `DOUBLE` — floating-point columns + * - `NUMERIC` — fixed-point DECIMAL columns (preserves precision) + * + * Created by `sql.number()` (auto-inferred), or by typed variants + * `sql.int()`, `sql.bigint()`, `sql.float()`, `sql.double()`, `sql.decimal()`. + */ export interface SQLNumberMarker { - __sql_type: "NUMERIC"; + __sql_type: "INT" | "BIGINT" | "FLOAT" | "DOUBLE" | "NUMERIC"; value: string; } From f41d6711c23a4423a196c0dbd356fe11d4629718 Mon Sep 17 00:00:00 2001 From: James Broadhead Date: Mon, 11 May 2026 14:07:11 +0000 Subject: [PATCH 2/6] docs: regenerate sql.* API reference for typed numeric variants Co-authored-by: Isaac --- docs/docs/api/appkit/Variable.sql.md | 128 ++++++++++++++++++++++++--- 1 file changed, 117 insertions(+), 11 deletions(-) diff --git a/docs/docs/api/appkit/Variable.sql.md b/docs/docs/api/appkit/Variable.sql.md index 1b42dcd0d..a3fcc75f2 100644 --- a/docs/docs/api/appkit/Variable.sql.md +++ b/docs/docs/api/appkit/Variable.sql.md @@ -2,9 +2,14 @@ ```ts const sql: { + bigint: SQLNumberMarker; binary: SQLBinaryMarker; boolean: SQLBooleanMarker; date: SQLDateMarker; + decimal: SQLNumberMarker; + double: SQLNumberMarker; + float: SQLNumberMarker; + int: SQLNumberMarker; number: SQLNumberMarker; string: SQLStringMarker; timestamp: SQLTimestampMarker; @@ -15,6 +20,26 @@ SQL helper namespace ## Type Declaration +### bigint() + +```ts +bigint(value: string | number | bigint): SQLNumberMarker; +``` + +Creates a `BIGINT` (64-bit signed integer) parameter. Accepts JS +`bigint` so callers can round-trip values outside `Number.MAX_SAFE_INTEGER` +without precision loss. + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `value` | `string` \| `number` \| `bigint` | Integer number, bigint, or integer-shaped string | + +#### Returns + +`SQLNumberMarker` + ### binary() ```ts @@ -134,14 +159,34 @@ const params = { startDate: sql.date("2024-01-01") }; params = { startDate: "2024-01-01" } ``` -### number() +### decimal() ```ts -number(value: string | number): SQLNumberMarker; +decimal(value: string | number): SQLNumberMarker; ``` -Creates a NUMERIC type parameter -Accepts numbers or numeric strings +Creates a `NUMERIC` (fixed-point DECIMAL) parameter. Use when you need +exact decimal arithmetic (currency, percentages) — pass values as +strings to avoid JS-number precision loss. + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `value` | `string` \| `number` | Number or numeric string (strings preferred for precision) | + +#### Returns + +`SQLNumberMarker` + +### double() + +```ts +double(value: string | number): SQLNumberMarker; +``` + +Creates a `DOUBLE` (double-precision) parameter. Same precision as a JS +`number`, so `sql.double(value)` is exact for any JS number. #### Parameters @@ -153,18 +198,79 @@ Accepts numbers or numeric strings `SQLNumberMarker` -Marker object for NUMERIC type parameter +### float() -#### Examples +```ts +float(value: string | number): SQLNumberMarker; +``` -```typescript -const params = { userId: sql.number(123) }; -params = { userId: "123" } +Creates a `FLOAT` (single-precision) parameter. + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `value` | `string` \| `number` | Number or numeric string | + +#### Returns + +`SQLNumberMarker` + +### int() + +```ts +int(value: string | number): SQLNumberMarker; ``` +Creates an `INT` (32-bit signed integer) parameter. Use when the column +or context requires `INT` specifically (e.g. legacy schemas, or to make +the wire type explicit). + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `value` | `string` \| `number` | Integer number or integer-shaped string | + +#### Returns + +`SQLNumberMarker` + +### number() + +```ts +number(value: string | number): SQLNumberMarker; +``` + +Creates a numeric type parameter. The wire SQL type is inferred from the +value so the parameter binds correctly in any context, including `LIMIT` +and `OFFSET` (which require integer types): + +- JS integer (`10`) → `BIGINT` +- JS non-integer (`3.14`) → `DOUBLE` +- numeric string (`"123.45"`) → `NUMERIC` (preserves caller's precision intent) + +Reach for `sql.int()`, `sql.bigint()`, `sql.float()`, `sql.double()`, or +`sql.decimal()` if you need to override the inferred type. + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `value` | `string` \| `number` | Number or numeric string | + +#### Returns + +`SQLNumberMarker` + +Marker for a numeric SQL parameter + +#### Example + ```typescript -const params = { userId: sql.number("123") }; -params = { userId: "123" } +const params = { userId: sql.number(123) }; // BIGINT, value "123" +const params = { ratio: sql.number(0.5) }; // DOUBLE, value "0.5" +const params = { amount: sql.number("123.45") }; // NUMERIC, value "123.45" ``` ### string() From 5b288ad70d2f3e11260341b6fdbdaebc702f1159 Mon Sep 17 00:00:00 2001 From: James Broadhead Date: Wed, 13 May 2026 10:26:54 +0000 Subject: [PATCH 3/6] fix(shared): address review feedback on typed numeric SQL variants P1 fixes - Reject JS integers outside Number.MAX_SAFE_INTEGER in sql.number(), sql.int(), and sql.bigint(number). The marker would otherwise advertise BIGINT for a value already lost to JS-double precision. - Widen sql.number("10") to BIGINT for integer-shaped strings so handler code that passes req.query strings works with LIMIT/OFFSET. Decimal- shaped strings still emit NUMERIC. - Type-generator: extract INT/BIGINT/TINYINT/SMALLINT/FLOAT/DOUBLE/DECIMAL -- @param annotations, give them sensible defaults, and route each SQL type to its closest typed helper in sqlTypeToHelper. P2 fixes - Reject Infinity / -Infinity / NaN, hex / scientific-only / whitespace strings via a strict NUMERIC_LITERAL_RE. - Emit BIGINT wire values via BigInt(value).toString() so 1e15 -> "1000000000000000" rather than exponent text. - Bound-check sql.int (32-bit signed) and sql.bigint (64-bit signed) inputs; surface a descriptive error pointing to the wider helper. - Narrow each typed helper's return type to the exact __sql_type literal. - Add bound, boundary, error, and precision-loss tests for the typed helpers, plus an integration test for LIMIT :n OFFSET :m bindings. - Add @returns and @example for every new helper; regenerate docs/docs/api/appkit/Variable.sql.md. - Rename sql.decimal -> sql.numeric so name matches the wire literal (existing typed helpers all match name -> wire). Update analytics.md to mention the new helpers and refresh the LIMIT example. Co-authored-by: Isaac Signed-off-by: James Broadhead --- .../shared/appkit-types/analytics.d.ts | 2 +- docs/docs/api/appkit/Variable.sql.md | 174 +++++++++++---- docs/docs/plugins/analytics.md | 11 +- .../src/plugins/analytics/tests/query.test.ts | 48 +++- .../src/type-generator/query-registry.ts | 10 +- .../tests/query-registry.test.ts | 27 +++ packages/appkit/src/type-generator/types.ts | 20 +- packages/shared/src/sql/helpers.ts | 211 ++++++++++++++---- .../shared/src/sql/tests/sql-helpers.test.ts | 140 +++++++++++- packages/shared/src/sql/types.ts | 2 +- 10 files changed, 532 insertions(+), 113 deletions(-) diff --git a/apps/dev-playground/shared/appkit-types/analytics.d.ts b/apps/dev-playground/shared/appkit-types/analytics.d.ts index 43666dd06..52d64f2d1 100644 --- a/apps/dev-playground/shared/appkit-types/analytics.d.ts +++ b/apps/dev-playground/shared/appkit-types/analytics.d.ts @@ -105,7 +105,7 @@ declare module "@databricks/appkit-ui/react" { parameters: { /** STRING - use sql.string() */ stringParam: SQLStringMarker; - /** NUMERIC - use sql.number() */ + /** NUMERIC - use sql.numeric() */ numberParam: SQLNumberMarker; /** BOOLEAN - use sql.boolean() */ booleanParam: SQLBooleanMarker; diff --git a/docs/docs/api/appkit/Variable.sql.md b/docs/docs/api/appkit/Variable.sql.md index a3fcc75f2..2d8dafba3 100644 --- a/docs/docs/api/appkit/Variable.sql.md +++ b/docs/docs/api/appkit/Variable.sql.md @@ -2,15 +2,25 @@ ```ts const sql: { - bigint: SQLNumberMarker; + bigint: SQLNumberMarker & { + __sql_type: "BIGINT"; + }; binary: SQLBinaryMarker; boolean: SQLBooleanMarker; date: SQLDateMarker; - decimal: SQLNumberMarker; - double: SQLNumberMarker; - float: SQLNumberMarker; - int: SQLNumberMarker; + double: SQLNumberMarker & { + __sql_type: "DOUBLE"; + }; + float: SQLNumberMarker & { + __sql_type: "FLOAT"; + }; + int: SQLNumberMarker & { + __sql_type: "INT"; + }; number: SQLNumberMarker; + numeric: SQLNumberMarker & { + __sql_type: "NUMERIC"; + }; string: SQLStringMarker; timestamp: SQLTimestampMarker; }; @@ -23,12 +33,17 @@ SQL helper namespace ### bigint() ```ts -bigint(value: string | number | bigint): SQLNumberMarker; +bigint(value: string | number | bigint): SQLNumberMarker & { + __sql_type: "BIGINT"; +}; ``` Creates a `BIGINT` (64-bit signed integer) parameter. Accepts JS `bigint` so callers can round-trip values outside `Number.MAX_SAFE_INTEGER` -without precision loss. +without precision loss; for `number` inputs, requires +`Number.isSafeInteger(value)`. + +Rejects values outside the signed 64-bit range `[-2^63, 2^63 - 1]`. #### Parameters @@ -38,7 +53,19 @@ without precision loss. #### Returns -`SQLNumberMarker` +`SQLNumberMarker` & \{ + `__sql_type`: `"BIGINT"`; +\} + +Marker pinned to `BIGINT` + +#### Example + +```typescript +sql.bigint(42); // { __sql_type: "BIGINT", value: "42" } +sql.bigint(9007199254740993n); // { __sql_type: "BIGINT", value: "9007199254740993" } +sql.bigint("9007199254740993"); // { __sql_type: "BIGINT", value: "9007199254740993" } +``` ### binary() @@ -159,52 +186,48 @@ const params = { startDate: sql.date("2024-01-01") }; params = { startDate: "2024-01-01" } ``` -### decimal() +### double() ```ts -decimal(value: string | number): SQLNumberMarker; +double(value: string | number): SQLNumberMarker & { + __sql_type: "DOUBLE"; +}; ``` -Creates a `NUMERIC` (fixed-point DECIMAL) parameter. Use when you need -exact decimal arithmetic (currency, percentages) — pass values as -strings to avoid JS-number precision loss. +Creates a `DOUBLE` (double-precision, 64-bit) parameter. Same precision +as a JS `number`, so `sql.double(value)` is exact for any JS number. #### Parameters | Parameter | Type | Description | | ------ | ------ | ------ | -| `value` | `string` \| `number` | Number or numeric string (strings preferred for precision) | +| `value` | `string` \| `number` | Number or numeric string | #### Returns -`SQLNumberMarker` - -### double() - -```ts -double(value: string | number): SQLNumberMarker; -``` - -Creates a `DOUBLE` (double-precision) parameter. Same precision as a JS -`number`, so `sql.double(value)` is exact for any JS number. - -#### Parameters +`SQLNumberMarker` & \{ + `__sql_type`: `"DOUBLE"`; +\} -| Parameter | Type | Description | -| ------ | ------ | ------ | -| `value` | `string` \| `number` | Number or numeric string | +Marker pinned to `DOUBLE` -#### Returns +#### Example -`SQLNumberMarker` +```typescript +sql.double(3.14); // { __sql_type: "DOUBLE", value: "3.14" } +``` ### float() ```ts -float(value: string | number): SQLNumberMarker; +float(value: string | number): SQLNumberMarker & { + __sql_type: "FLOAT"; +}; ``` -Creates a `FLOAT` (single-precision) parameter. +Creates a `FLOAT` (single-precision, 32-bit) parameter. Note that JS +numbers are 64-bit doubles, so values may be rounded to fit FLOAT +precision at bind time. #### Parameters @@ -214,18 +237,34 @@ Creates a `FLOAT` (single-precision) parameter. #### Returns -`SQLNumberMarker` +`SQLNumberMarker` & \{ + `__sql_type`: `"FLOAT"`; +\} + +Marker pinned to `FLOAT` + +#### Example + +```typescript +sql.float(3.14); // { __sql_type: "FLOAT", value: "3.14" } +``` ### int() ```ts -int(value: string | number): SQLNumberMarker; +int(value: string | number): SQLNumberMarker & { + __sql_type: "INT"; +}; ``` Creates an `INT` (32-bit signed integer) parameter. Use when the column or context requires `INT` specifically (e.g. legacy schemas, or to make the wire type explicit). +Rejects non-integers, values outside `Number.MAX_SAFE_INTEGER` (for +number inputs), and values outside the signed 32-bit range +`[-2^31, 2^31 - 1]`. + #### Parameters | Parameter | Type | Description | @@ -234,7 +273,18 @@ the wire type explicit). #### Returns -`SQLNumberMarker` +`SQLNumberMarker` & \{ + `__sql_type`: `"INT"`; +\} + +Marker pinned to `INT` + +#### Example + +```typescript +sql.int(42); // { __sql_type: "INT", value: "42" } +sql.int("42"); // { __sql_type: "INT", value: "42" } +``` ### number() @@ -248,10 +298,14 @@ and `OFFSET` (which require integer types): - JS integer (`10`) → `BIGINT` - JS non-integer (`3.14`) → `DOUBLE` -- numeric string (`"123.45"`) → `NUMERIC` (preserves caller's precision intent) +- integer-shaped string (`"10"`) → `BIGINT` (common HTTP-input case; + works with `LIMIT :n` / `OFFSET :m`) +- decimal-shaped string (`"123.45"`) → `NUMERIC` (preserves precision) -Reach for `sql.int()`, `sql.bigint()`, `sql.float()`, `sql.double()`, or -`sql.decimal()` if you need to override the inferred type. +Throws on `NaN`, `Infinity`, JS integers outside `Number.MAX_SAFE_INTEGER`, +or non-numeric strings. Reach for `sql.int()`, `sql.bigint()`, +`sql.float()`, `sql.double()`, or `sql.numeric()` if you need to override +the inferred type. #### Parameters @@ -268,9 +322,45 @@ Marker for a numeric SQL parameter #### Example ```typescript -const params = { userId: sql.number(123) }; // BIGINT, value "123" -const params = { ratio: sql.number(0.5) }; // DOUBLE, value "0.5" -const params = { amount: sql.number("123.45") }; // NUMERIC, value "123.45" +sql.number(123); // { __sql_type: "BIGINT", value: "123" } +sql.number(0.5); // { __sql_type: "DOUBLE", value: "0.5" } +sql.number("10"); // { __sql_type: "BIGINT", value: "10" } +sql.number("123.45"); // { __sql_type: "NUMERIC", value: "123.45" } +``` + +### numeric() + +```ts +numeric(value: string | number): SQLNumberMarker & { + __sql_type: "NUMERIC"; +}; +``` + +Creates a `NUMERIC` (fixed-point DECIMAL) parameter. Use when you need +exact decimal arithmetic (currency, percentages) — pass values as +strings to avoid JS-number precision loss. + +Note: passing a JS `number` is accepted but lossy for many values +(e.g. `0.1 + 0.2` → `"0.30000000000000004"`). Prefer strings. + +#### Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `value` | `string` \| `number` | Number or numeric string (strings preferred for precision) | + +#### Returns + +`SQLNumberMarker` & \{ + `__sql_type`: `"NUMERIC"`; +\} + +Marker pinned to `NUMERIC` + +#### Example + +```typescript +sql.numeric("12345.6789"); // { __sql_type: "NUMERIC", value: "12345.6789" } ``` ### string() diff --git a/docs/docs/plugins/analytics.md b/docs/docs/plugins/analytics.md index 22204f529..811a62372 100644 --- a/docs/docs/plugins/analytics.md +++ b/docs/docs/plugins/analytics.md @@ -43,14 +43,21 @@ Use `:paramName` placeholders and optionally annotate parameter types using SQL ```sql -- @param startDate DATE -- @param endDate DATE --- @param limit NUMERIC +-- @param limit BIGINT SELECT ... WHERE usage_date BETWEEN :startDate AND :endDate LIMIT :limit ``` +`LIMIT` / `OFFSET` require an integer-typed binding (`INT` or `BIGINT`). +Annotate accordingly, or use `sql.number()` (auto-infers `BIGINT` for integer +inputs) / `sql.bigint()` / `sql.int()` at the call site. + **Supported `-- @param` types** (case-insensitive): -- `STRING`, `NUMERIC`, `BOOLEAN`, `DATE`, `TIMESTAMP`, `BINARY` +- `STRING`, `BOOLEAN`, `DATE`, `TIMESTAMP`, `BINARY` +- `INT`, `BIGINT`, `TINYINT`, `SMALLINT` — bind via `sql.int()` / `sql.bigint()` +- `FLOAT`, `DOUBLE` — bind via `sql.float()` / `sql.double()` +- `NUMERIC`, `DECIMAL` — bind via `sql.numeric()` (pass strings for precision) ## Server-injected parameters diff --git a/packages/appkit/src/plugins/analytics/tests/query.test.ts b/packages/appkit/src/plugins/analytics/tests/query.test.ts index 1fa2c3310..4db355453 100644 --- a/packages/appkit/src/plugins/analytics/tests/query.test.ts +++ b/packages/appkit/src/plugins/analytics/tests/query.test.ts @@ -171,13 +171,59 @@ describe("QueryProcessor", () => { const result = await processor.processQueryParams(query, parameters); + // Integer-shaped strings infer BIGINT (matches LIMIT/OFFSET pattern) expect(result.workspaceId).toEqual({ - __sql_type: "NUMERIC", + __sql_type: "BIGINT", value: "9876543210", }); }); }); + describe("LIMIT / OFFSET bindings (regression for #323)", () => { + test("sql.number(integer) binds as BIGINT for LIMIT/OFFSET", () => { + const query = "SELECT * FROM events LIMIT :n OFFSET :m"; + const parameters = { + n: sql.number(10), + m: sql.number(20), + }; + + const result = processor.convertToSQLParameters(query, parameters); + + expect(result.parameters).toEqual([ + { name: "n", value: "10", type: "BIGINT" }, + { name: "m", value: "20", type: "BIGINT" }, + ]); + }); + + test("sql.number(integer-shaped string) binds as BIGINT for LIMIT/OFFSET", () => { + // Express/URLSearchParams return strings — this is the common + // handler pattern: sql.number(req.query.n). + const query = "SELECT * FROM events LIMIT :n OFFSET :m"; + const parameters = { + n: sql.number("10"), + m: sql.number("20"), + }; + + const result = processor.convertToSQLParameters(query, parameters); + + expect(result.parameters).toEqual([ + { name: "n", value: "10", type: "BIGINT" }, + { name: "m", value: "20", type: "BIGINT" }, + ]); + }); + + test("sql.bigint(string) binds as BIGINT for LIMIT/OFFSET", () => { + const query = "SELECT * FROM events LIMIT :n"; + const parameters = { n: sql.bigint("10") }; + + const result = processor.convertToSQLParameters(query, parameters); + + expect(result.parameters).toEqual([ + { name: "n", value: "10", type: "BIGINT" }, + ]); + }); + }); + describe("_createParameter - Type Handling", () => { test("should handle date parameters with sql.date()", () => { const query = "SELECT * FROM events WHERE event_date = :startDate"; diff --git a/packages/appkit/src/type-generator/query-registry.ts b/packages/appkit/src/type-generator/query-registry.ts index 196690c2d..9d4ae3e8a 100644 --- a/packages/appkit/src/type-generator/query-registry.ts +++ b/packages/appkit/src/type-generator/query-registry.ts @@ -194,7 +194,7 @@ function generateUnknownResultQuery(sql: string, queryName: string): string { export function extractParameterTypes(sql: string): Record { const paramTypes: Record = {}; const regex = - /--\s*@param\s+(\w+)\s+(STRING|NUMERIC|BOOLEAN|DATE|TIMESTAMP|BINARY)/gi; + /--\s*@param\s+(\w+)\s+(STRING|NUMERIC|DECIMAL|BIGINT|TINYINT|SMALLINT|INT|FLOAT|DOUBLE|BOOLEAN|DATE|TIMESTAMP|BINARY)/gi; const matches = sql.matchAll(regex); for (const match of matches) { const [, paramName, paramType] = match; @@ -207,7 +207,15 @@ export function extractParameterTypes(sql: string): Record { export function defaultForType(sqlType: string | undefined): string { switch (sqlType?.toUpperCase()) { case "NUMERIC": + case "DECIMAL": + case "BIGINT": + case "TINYINT": + case "SMALLINT": + case "INT": return "0"; + case "FLOAT": + case "DOUBLE": + return "0.0"; case "STRING": return "''"; case "BOOLEAN": diff --git a/packages/appkit/src/type-generator/tests/query-registry.test.ts b/packages/appkit/src/type-generator/tests/query-registry.test.ts index 8d46f98e9..2e77f7f56 100644 --- a/packages/appkit/src/type-generator/tests/query-registry.test.ts +++ b/packages/appkit/src/type-generator/tests/query-registry.test.ts @@ -148,6 +148,13 @@ SELECT * FROM users WHERE date BETWEEN :startDate AND :endDate`; test("handles all supported types", () => { const sql = `-- @param str STRING -- @param num NUMERIC +-- @param dec DECIMAL +-- @param i INT +-- @param big BIGINT +-- @param tiny TINYINT +-- @param small SMALLINT +-- @param f FLOAT +-- @param d DOUBLE -- @param bool BOOLEAN -- @param dt DATE -- @param ts TIMESTAMP @@ -157,6 +164,13 @@ SELECT 1`; expect(types.str).toBe("STRING"); expect(types.num).toBe("NUMERIC"); + expect(types.dec).toBe("DECIMAL"); + expect(types.i).toBe("INT"); + expect(types.big).toBe("BIGINT"); + expect(types.tiny).toBe("TINYINT"); + expect(types.small).toBe("SMALLINT"); + expect(types.f).toBe("FLOAT"); + expect(types.d).toBe("DOUBLE"); expect(types.bool).toBe("BOOLEAN"); expect(types.dt).toBe("DATE"); expect(types.ts).toBe("TIMESTAMP"); @@ -213,6 +227,19 @@ describe("defaultForType", () => { expect(defaultForType("BINARY")).toBe("X'00'"); }); + test("returns '0' for integer aliases (INT/BIGINT/TINYINT/SMALLINT/DECIMAL)", () => { + expect(defaultForType("INT")).toBe("0"); + expect(defaultForType("BIGINT")).toBe("0"); + expect(defaultForType("TINYINT")).toBe("0"); + expect(defaultForType("SMALLINT")).toBe("0"); + expect(defaultForType("DECIMAL")).toBe("0"); + }); + + test("returns '0.0' for FLOAT and DOUBLE", () => { + expect(defaultForType("FLOAT")).toBe("0.0"); + expect(defaultForType("DOUBLE")).toBe("0.0"); + }); + test("returns empty string literal for undefined (unknown fallback)", () => { expect(defaultForType(undefined)).toBe("''"); }); diff --git a/packages/appkit/src/type-generator/types.ts b/packages/appkit/src/type-generator/types.ts index 5af43591a..f54176a8c 100644 --- a/packages/appkit/src/type-generator/types.ts +++ b/packages/appkit/src/type-generator/types.ts @@ -50,15 +50,17 @@ export const sqlTypeToHelper: Record = { BINARY: "sql.binary()", // boolean BOOLEAN: "sql.boolean()", - // numeric - NUMERIC: "sql.number()", - INT: "sql.number()", - BIGINT: "sql.number()", - TINYINT: "sql.number()", - SMALLINT: "sql.number()", - FLOAT: "sql.number()", - DOUBLE: "sql.number()", - DECIMAL: "sql.number()", + // numeric — route each SQL type to its closest typed helper. INT/BIGINT + // are critical for LIMIT/OFFSET; FLOAT/DOUBLE preserve precision intent; + // NUMERIC/DECIMAL route to sql.numeric() for exact-decimal columns. + NUMERIC: "sql.numeric()", + DECIMAL: "sql.numeric()", + BIGINT: "sql.bigint()", + INT: "sql.int()", + TINYINT: "sql.int()", + SMALLINT: "sql.int()", + FLOAT: "sql.float()", + DOUBLE: "sql.double()", // date/time DATE: "sql.date()", TIMESTAMP: "sql.timestamp()", diff --git a/packages/shared/src/sql/helpers.ts b/packages/shared/src/sql/helpers.ts index d5553e8dd..80102782c 100644 --- a/packages/shared/src/sql/helpers.ts +++ b/packages/shared/src/sql/helpers.ts @@ -8,12 +8,57 @@ import type { SQLTypeMarker, } from "./types"; +// Strict numeric-literal regex used by string-input paths. Rejects empty +// strings, whitespace, hex/octal/binary, `NaN`, `Infinity`, and other forms +// that JS `Number()` would silently coerce. +const NUMERIC_LITERAL_RE = /^-?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/; +const INTEGER_LITERAL_RE = /^-?\d+$/; + +// 32-bit signed INT range +const INT_MIN = -(2n ** 31n); +const INT_MAX = 2n ** 31n - 1n; +// 64-bit signed BIGINT range +const BIGINT_MIN = -(2n ** 63n); +const BIGINT_MAX = 2n ** 63n - 1n; + +function ensureFiniteNumber(value: number, fnName: string): void { + if (!Number.isFinite(value)) { + throw new Error(`${fnName}() expects a finite number, got: ${value}`); + } +} + +function ensureSafeInteger(value: number, fnName: string): void { + if (!Number.isSafeInteger(value)) { + throw new Error( + `${fnName}() received an integer outside Number.MAX_SAFE_INTEGER ` + + `(${value}); JS numbers cannot represent it exactly. ` + + `Pass a bigint (sql.bigint(BigInt("..."))) or an integer-shaped string instead.`, + ); + } +} + +function ensureInBigIntRange( + parsed: bigint, + min: bigint, + max: bigint, + typeName: string, + fnName: string, + hint: string, +): void { + if (parsed < min || parsed > max) { + throw new Error( + `${fnName}() value ${parsed} is outside ${typeName} range [${min}, ${max}]. ${hint}`, + ); + } +} + function coerceNumericLike(value: number | string, fnName: string): string { if (typeof value === "number") { + ensureFiniteNumber(value, fnName); return value.toString(); } if (typeof value === "string") { - if (value === "" || Number.isNaN(Number(value))) { + if (!NUMERIC_LITERAL_RE.test(value)) { throw new Error( `${fnName}() expects number or numeric string, got: ${value === "" ? "empty string" : value}`, ); @@ -27,15 +72,19 @@ function coerceNumericLike(value: number | string, fnName: string): string { function coerceIntegerLike(value: number | string, fnName: string): string { if (typeof value === "number") { + ensureFiniteNumber(value, fnName); if (!Number.isInteger(value)) { throw new Error( `${fnName}() expects an integer, got non-integer number: ${value}`, ); } - return value.toString(); + ensureSafeInteger(value, fnName); + // BigInt(value).toString() emits canonical decimal-integer text; + // Number.prototype.toString emits exponent notation for values like 1e21. + return BigInt(value).toString(); } if (typeof value === "string") { - if (value === "" || !/^-?\d+$/.test(value)) { + if (!INTEGER_LITERAL_RE.test(value)) { throw new Error( `${fnName}() expects integer number or integer-shaped string, got: ${value === "" ? "empty string" : value}`, ); @@ -154,47 +203,53 @@ export const sql = { * * - JS integer (`10`) → `BIGINT` * - JS non-integer (`3.14`) → `DOUBLE` - * - numeric string (`"123.45"`) → `NUMERIC` (preserves caller's precision intent) + * - integer-shaped string (`"10"`) → `BIGINT` (common HTTP-input case; + * works with `LIMIT :n` / `OFFSET :m`) + * - decimal-shaped string (`"123.45"`) → `NUMERIC` (preserves precision) * - * Reach for `sql.int()`, `sql.bigint()`, `sql.float()`, `sql.double()`, or - * `sql.decimal()` if you need to override the inferred type. + * Throws on `NaN`, `Infinity`, JS integers outside `Number.MAX_SAFE_INTEGER`, + * or non-numeric strings. Reach for `sql.int()`, `sql.bigint()`, + * `sql.float()`, `sql.double()`, or `sql.numeric()` if you need to override + * the inferred type. * * @param value - Number or numeric string * @returns Marker for a numeric SQL parameter * @example * ```typescript - * const params = { userId: sql.number(123) }; // BIGINT, value "123" - * const params = { ratio: sql.number(0.5) }; // DOUBLE, value "0.5" - * const params = { amount: sql.number("123.45") }; // NUMERIC, value "123.45" + * sql.number(123); // { __sql_type: "BIGINT", value: "123" } + * sql.number(0.5); // { __sql_type: "DOUBLE", value: "0.5" } + * sql.number("10"); // { __sql_type: "BIGINT", value: "10" } + * sql.number("123.45"); // { __sql_type: "NUMERIC", value: "123.45" } * ``` */ number(value: number | string): SQLNumberMarker { - let numValue: string = ""; - let inferredType: SQLNumberMarker["__sql_type"] = "NUMERIC"; - if (typeof value === "number") { - numValue = value.toString(); - inferredType = Number.isInteger(value) ? "BIGINT" : "DOUBLE"; - } else if (typeof value === "string") { - if (value === "" || Number.isNaN(Number(value))) { + ensureFiniteNumber(value, "sql.number"); + if (Number.isInteger(value)) { + ensureSafeInteger(value, "sql.number"); + return { __sql_type: "BIGINT", value: BigInt(value).toString() }; + } + return { __sql_type: "DOUBLE", value: value.toString() }; + } + if (typeof value === "string") { + if (!NUMERIC_LITERAL_RE.test(value)) { throw new Error( `sql.number() expects number or numeric string, got: ${value === "" ? "empty string" : value}`, ); } - numValue = value; - // Strings stay NUMERIC: the caller chose to pass a string, so honour - // their precision intent rather than coercing through JS number. - inferredType = "NUMERIC"; - } else { - throw new Error( - `sql.number() expects number or numeric string, got: ${typeof value}`, - ); + // Integer-shaped strings: emit BIGINT so HTTP-input callers + // (`req.query.n` is always a string) work with LIMIT/OFFSET without + // having to reach for `sql.bigint("10")` explicitly. + if (INTEGER_LITERAL_RE.test(value)) { + return { __sql_type: "BIGINT", value }; + } + // Non-integer strings stay NUMERIC: the caller chose to pass a string, + // honour their precision intent rather than coercing through JS number. + return { __sql_type: "NUMERIC", value }; } - - return { - __sql_type: inferredType, - value: numValue, - }; + throw new Error( + `sql.number() expects number or numeric string, got: ${typeof value}`, + ); }, /** @@ -202,38 +257,87 @@ export const sql = { * or context requires `INT` specifically (e.g. legacy schemas, or to make * the wire type explicit). * + * Rejects non-integers, values outside `Number.MAX_SAFE_INTEGER` (for + * number inputs), and values outside the signed 32-bit range + * `[-2^31, 2^31 - 1]`. + * * @param value - Integer number or integer-shaped string + * @returns Marker pinned to `INT` + * @example + * ```typescript + * sql.int(42); // { __sql_type: "INT", value: "42" } + * sql.int("42"); // { __sql_type: "INT", value: "42" } + * ``` */ - int(value: number | string): SQLNumberMarker { - return { - __sql_type: "INT", - value: coerceIntegerLike(value, "sql.int"), - }; + int(value: number | string): SQLNumberMarker & { __sql_type: "INT" } { + const stringValue = coerceIntegerLike(value, "sql.int"); + ensureInBigIntRange( + BigInt(stringValue), + INT_MIN, + INT_MAX, + "INT (32-bit signed)", + "sql.int", + "Use sql.bigint() for 64-bit values.", + ); + return { __sql_type: "INT", value: stringValue }; }, /** * Creates a `BIGINT` (64-bit signed integer) parameter. Accepts JS * `bigint` so callers can round-trip values outside `Number.MAX_SAFE_INTEGER` - * without precision loss. + * without precision loss; for `number` inputs, requires + * `Number.isSafeInteger(value)`. + * + * Rejects values outside the signed 64-bit range `[-2^63, 2^63 - 1]`. * * @param value - Integer number, bigint, or integer-shaped string + * @returns Marker pinned to `BIGINT` + * @example + * ```typescript + * sql.bigint(42); // { __sql_type: "BIGINT", value: "42" } + * sql.bigint(9007199254740993n); // { __sql_type: "BIGINT", value: "9007199254740993" } + * sql.bigint("9007199254740993"); // { __sql_type: "BIGINT", value: "9007199254740993" } + * ``` */ - bigint(value: number | bigint | string): SQLNumberMarker { + bigint( + value: number | bigint | string, + ): SQLNumberMarker & { __sql_type: "BIGINT" } { if (typeof value === "bigint") { + ensureInBigIntRange( + value, + BIGINT_MIN, + BIGINT_MAX, + "BIGINT (64-bit signed)", + "sql.bigint", + "Use sql.numeric() with a string for arbitrary-precision integers.", + ); return { __sql_type: "BIGINT", value: value.toString() }; } - return { - __sql_type: "BIGINT", - value: coerceIntegerLike(value, "sql.bigint"), - }; + const stringValue = coerceIntegerLike(value, "sql.bigint"); + ensureInBigIntRange( + BigInt(stringValue), + BIGINT_MIN, + BIGINT_MAX, + "BIGINT (64-bit signed)", + "sql.bigint", + "Use sql.numeric() with a string for arbitrary-precision integers.", + ); + return { __sql_type: "BIGINT", value: stringValue }; }, /** - * Creates a `FLOAT` (single-precision) parameter. + * Creates a `FLOAT` (single-precision, 32-bit) parameter. Note that JS + * numbers are 64-bit doubles, so values may be rounded to fit FLOAT + * precision at bind time. * * @param value - Number or numeric string + * @returns Marker pinned to `FLOAT` + * @example + * ```typescript + * sql.float(3.14); // { __sql_type: "FLOAT", value: "3.14" } + * ``` */ - float(value: number | string): SQLNumberMarker { + float(value: number | string): SQLNumberMarker & { __sql_type: "FLOAT" } { return { __sql_type: "FLOAT", value: coerceNumericLike(value, "sql.float"), @@ -241,12 +345,17 @@ export const sql = { }, /** - * Creates a `DOUBLE` (double-precision) parameter. Same precision as a JS - * `number`, so `sql.double(value)` is exact for any JS number. + * Creates a `DOUBLE` (double-precision, 64-bit) parameter. Same precision + * as a JS `number`, so `sql.double(value)` is exact for any JS number. * * @param value - Number or numeric string + * @returns Marker pinned to `DOUBLE` + * @example + * ```typescript + * sql.double(3.14); // { __sql_type: "DOUBLE", value: "3.14" } + * ``` */ - double(value: number | string): SQLNumberMarker { + double(value: number | string): SQLNumberMarker & { __sql_type: "DOUBLE" } { return { __sql_type: "DOUBLE", value: coerceNumericLike(value, "sql.double"), @@ -258,12 +367,20 @@ export const sql = { * exact decimal arithmetic (currency, percentages) — pass values as * strings to avoid JS-number precision loss. * + * Note: passing a JS `number` is accepted but lossy for many values + * (e.g. `0.1 + 0.2` → `"0.30000000000000004"`). Prefer strings. + * * @param value - Number or numeric string (strings preferred for precision) + * @returns Marker pinned to `NUMERIC` + * @example + * ```typescript + * sql.numeric("12345.6789"); // { __sql_type: "NUMERIC", value: "12345.6789" } + * ``` */ - decimal(value: number | string): SQLNumberMarker { + numeric(value: number | string): SQLNumberMarker & { __sql_type: "NUMERIC" } { return { __sql_type: "NUMERIC", - value: coerceNumericLike(value, "sql.decimal"), + value: coerceNumericLike(value, "sql.numeric"), }; }, diff --git a/packages/shared/src/sql/tests/sql-helpers.test.ts b/packages/shared/src/sql/tests/sql-helpers.test.ts index 0f36bf21e..b765a079e 100644 --- a/packages/shared/src/sql/tests/sql-helpers.test.ts +++ b/packages/shared/src/sql/tests/sql-helpers.test.ts @@ -53,15 +53,17 @@ describe("SQL Helpers", () => { }); }); - it("should keep numeric strings as NUMERIC (preserve precision)", () => { + it("should bind an integer-shaped string as BIGINT (HTTP-input case)", () => { + // Express/URLSearchParams return strings; common pattern is + // sql.number(req.query.n) which must work with LIMIT/OFFSET. const result = sql.number("1234567890"); expect(result).toEqual({ - __sql_type: "NUMERIC", + __sql_type: "BIGINT", value: "1234567890", }); }); - it("should keep decimal strings as NUMERIC (no JS-number coercion)", () => { + it("should bind decimal-shaped strings as NUMERIC (preserve precision)", () => { const result = sql.number("123.4500000000001"); expect(result).toEqual({ __sql_type: "NUMERIC", @@ -69,12 +71,45 @@ describe("SQL Helpers", () => { }); }); - it("should reject non-numeric string", () => { - expect(() => sql.number("hello" as any)).toThrow( - "sql.number() expects number or numeric string, got: hello", + it("should reject JS integers outside Number.MAX_SAFE_INTEGER", () => { + // 9007199254740993 is MAX_SAFE_INTEGER + 2 and cannot be represented + // exactly as a JS number. The marker would advertise BIGINT but the + // value is already wrong before the helper runs. + expect(() => sql.number(Number.MAX_SAFE_INTEGER + 2)).toThrow( + /outside Number\.MAX_SAFE_INTEGER/, ); }); + it("should reject Infinity / -Infinity / NaN", () => { + expect(() => sql.number(Number.POSITIVE_INFINITY)).toThrow( + /finite number/, + ); + expect(() => sql.number(Number.NEGATIVE_INFINITY)).toThrow( + /finite number/, + ); + expect(() => sql.number(Number.NaN)).toThrow(/finite number/); + }); + + it("should emit canonical decimal text (no exponent) for safe integers", () => { + // Sanity check: even though Number.prototype.toString could emit + // exponent form for very large integers, the helper always emits + // decimal text via BigInt(value).toString(). + const result = sql.number(1e15); + expect(result).toEqual({ + __sql_type: "BIGINT", + value: "1000000000000000", + }); + }); + + it.each([["NaN"], ["Infinity"], ["0x10"], [" "], ["hello"]])( + "should reject non-numeric string %s", + (input) => { + expect(() => sql.number(input as any)).toThrow( + /expects number or numeric string/, + ); + }, + ); + it("should reject empty string", () => { expect(() => sql.number("")).toThrow( "sql.number() expects number or numeric string, got: empty string", @@ -88,7 +123,7 @@ describe("SQL Helpers", () => { }); }); - describe("int() / bigint() / float() / double() / decimal()", () => { + describe("int() / bigint() / float() / double() / numeric()", () => { it("sql.int() should produce INT", () => { expect(sql.int(42)).toEqual({ __sql_type: "INT", value: "42" }); expect(sql.int("42")).toEqual({ __sql_type: "INT", value: "42" }); @@ -103,6 +138,27 @@ describe("SQL Helpers", () => { ); }); + it("sql.int() should reject values outside 32-bit signed range", () => { + // 2^31 is just outside INT_MAX + expect(() => sql.int(2147483648)).toThrow(/INT \(32-bit signed\) range/); + expect(() => sql.int(-2147483649)).toThrow(/INT \(32-bit signed\) range/); + // string-shaped out-of-range value + expect(() => sql.int("9999999999999999999")).toThrow( + /INT \(32-bit signed\) range/, + ); + }); + + it("sql.int() should accept the INT boundaries", () => { + expect(sql.int(2147483647)).toEqual({ + __sql_type: "INT", + value: "2147483647", + }); + expect(sql.int(-2147483648)).toEqual({ + __sql_type: "INT", + value: "-2147483648", + }); + }); + it("sql.bigint() should produce BIGINT and accept JS bigint", () => { expect(sql.bigint(42)).toEqual({ __sql_type: "BIGINT", value: "42" }); expect(sql.bigint("9007199254740993")).toEqual({ @@ -115,8 +171,47 @@ describe("SQL Helpers", () => { }); }); + it("sql.bigint(number) should reject values outside Number.MAX_SAFE_INTEGER", () => { + expect(() => sql.bigint(Number.MAX_SAFE_INTEGER + 2)).toThrow( + /outside Number\.MAX_SAFE_INTEGER/, + ); + }); + + it("sql.bigint(bigint) should reject values outside 64-bit signed range", () => { + expect(() => sql.bigint(2n ** 63n)).toThrow( + /BIGINT \(64-bit signed\) range/, + ); + expect(() => sql.bigint(-(2n ** 63n) - 1n)).toThrow( + /BIGINT \(64-bit signed\) range/, + ); + }); + + it("sql.bigint() should accept the BIGINT boundaries", () => { + expect(sql.bigint(2n ** 63n - 1n)).toEqual({ + __sql_type: "BIGINT", + value: "9223372036854775807", + }); + expect(sql.bigint(-(2n ** 63n))).toEqual({ + __sql_type: "BIGINT", + value: "-9223372036854775808", + }); + }); + it("sql.float() should produce FLOAT", () => { expect(sql.float(3.14)).toEqual({ __sql_type: "FLOAT", value: "3.14" }); + expect(sql.float("3.14")).toEqual({ + __sql_type: "FLOAT", + value: "3.14", + }); + }); + + it("sql.float() should reject non-finite and non-numeric inputs", () => { + expect(() => sql.float(Number.POSITIVE_INFINITY)).toThrow( + /finite number/, + ); + expect(() => sql.float("hello" as any)).toThrow( + /expects number or numeric string/, + ); }); it("sql.double() should produce DOUBLE", () => { @@ -124,14 +219,41 @@ describe("SQL Helpers", () => { __sql_type: "DOUBLE", value: "3.14", }); + expect(sql.double("3.14")).toEqual({ + __sql_type: "DOUBLE", + value: "3.14", + }); + }); + + it("sql.double() should reject non-finite and non-numeric inputs", () => { + expect(() => sql.double(Number.NaN)).toThrow(/finite number/); + expect(() => sql.double("0x10" as any)).toThrow( + /expects number or numeric string/, + ); }); - it("sql.decimal() should produce NUMERIC", () => { - expect(sql.decimal("12345.6789")).toEqual({ + it("sql.numeric() should produce NUMERIC from a string", () => { + expect(sql.numeric("12345.6789")).toEqual({ __sql_type: "NUMERIC", value: "12345.6789", }); }); + + it("sql.numeric(number) is lossy by design — caller is warned via docstring", () => { + // Regression test: passing a JS number to sql.numeric serialises with + // JS-double precision. This pins the behaviour the docstring warns + // about so the precision-loss caveat is visible in the test suite. + expect(sql.numeric(0.1 + 0.2)).toEqual({ + __sql_type: "NUMERIC", + value: "0.30000000000000004", + }); + }); + + it("sql.numeric() should reject non-numeric strings", () => { + expect(() => sql.numeric("hello" as any)).toThrow( + /expects number or numeric string/, + ); + }); }); describe("string()", () => { diff --git a/packages/shared/src/sql/types.ts b/packages/shared/src/sql/types.ts index 52c4191c4..b37e1a9ac 100644 --- a/packages/shared/src/sql/types.ts +++ b/packages/shared/src/sql/types.ts @@ -13,7 +13,7 @@ export interface SQLStringMarker { * - `NUMERIC` — fixed-point DECIMAL columns (preserves precision) * * Created by `sql.number()` (auto-inferred), or by typed variants - * `sql.int()`, `sql.bigint()`, `sql.float()`, `sql.double()`, `sql.decimal()`. + * `sql.int()`, `sql.bigint()`, `sql.float()`, `sql.double()`, `sql.numeric()`. */ export interface SQLNumberMarker { __sql_type: "INT" | "BIGINT" | "FLOAT" | "DOUBLE" | "NUMERIC"; From 389355122b34cfb9b7afead5bcd80d324fb42b5c Mon Sep 17 00:00:00 2001 From: James Broadhead Date: Wed, 13 May 2026 10:44:09 +0000 Subject: [PATCH 4/6] fix(shared): address ACE multi-model review findings Two confirmed issues from a follow-up multi-model review of the prior commit's typed numeric variants: - sql.number(integer-shaped string) widened to BIGINT without bounds-checking. "9223372036854775808" overflowed the 64-bit wire type silently. Now runs the same BIGINT range check as sql.bigint() and throws with a hint to sql.numeric() for arbitrary-precision integers. - The @param extraction regex matched TIMESTAMP_NTZ as TIMESTAMP, losing the NTZ specificity. Reordered the alternation so TIMESTAMP_NTZ wins, added a \b boundary, and gave it a default literal in defaultForType. Plus drive-bys: - Remove stale sql.interval() reference in SQLTypeMarker JSDoc. - Pin behaviour with boundary tests at +/- 2^63 and a regression test that TIMESTAMP_NTZ is not partially matched. Co-authored-by: Isaac Signed-off-by: James Broadhead --- .../src/type-generator/query-registry.ts | 6 ++++- .../tests/query-registry.test.ts | 16 ++++++++++++++ packages/shared/src/sql/helpers.ts | 12 +++++++++- .../shared/src/sql/tests/sql-helpers.test.ts | 22 +++++++++++++++++++ packages/shared/src/sql/types.ts | 4 +++- 5 files changed, 57 insertions(+), 3 deletions(-) diff --git a/packages/appkit/src/type-generator/query-registry.ts b/packages/appkit/src/type-generator/query-registry.ts index 9d4ae3e8a..06ee64bac 100644 --- a/packages/appkit/src/type-generator/query-registry.ts +++ b/packages/appkit/src/type-generator/query-registry.ts @@ -193,8 +193,10 @@ function generateUnknownResultQuery(sql: string, queryName: string): string { export function extractParameterTypes(sql: string): Record { const paramTypes: Record = {}; + // Alternation order matters: TIMESTAMP_NTZ must precede TIMESTAMP so the + // regex engine doesn't greedy-match TIMESTAMP and leave `_NTZ` unconsumed. const regex = - /--\s*@param\s+(\w+)\s+(STRING|NUMERIC|DECIMAL|BIGINT|TINYINT|SMALLINT|INT|FLOAT|DOUBLE|BOOLEAN|DATE|TIMESTAMP|BINARY)/gi; + /--\s*@param\s+(\w+)\s+(STRING|NUMERIC|DECIMAL|BIGINT|TINYINT|SMALLINT|INT|FLOAT|DOUBLE|BOOLEAN|DATE|TIMESTAMP_NTZ|TIMESTAMP|BINARY)\b/gi; const matches = sql.matchAll(regex); for (const match of matches) { const [, paramName, paramType] = match; @@ -224,6 +226,8 @@ export function defaultForType(sqlType: string | undefined): string { return "'2000-01-01'"; case "TIMESTAMP": return "'2000-01-01T00:00:00Z'"; + case "TIMESTAMP_NTZ": + return "'2000-01-01T00:00:00'"; case "BINARY": return "X'00'"; default: diff --git a/packages/appkit/src/type-generator/tests/query-registry.test.ts b/packages/appkit/src/type-generator/tests/query-registry.test.ts index 2e77f7f56..b149d5bbe 100644 --- a/packages/appkit/src/type-generator/tests/query-registry.test.ts +++ b/packages/appkit/src/type-generator/tests/query-registry.test.ts @@ -158,6 +158,7 @@ SELECT * FROM users WHERE date BETWEEN :startDate AND :endDate`; -- @param bool BOOLEAN -- @param dt DATE -- @param ts TIMESTAMP +-- @param tsNtz TIMESTAMP_NTZ -- @param bin BINARY SELECT 1`; const types = extractParameterTypes(sql); @@ -174,9 +175,20 @@ SELECT 1`; expect(types.bool).toBe("BOOLEAN"); expect(types.dt).toBe("DATE"); expect(types.ts).toBe("TIMESTAMP"); + expect(types.tsNtz).toBe("TIMESTAMP_NTZ"); expect(types.bin).toBe("BINARY"); }); + test("TIMESTAMP_NTZ is not partially matched as TIMESTAMP", () => { + // Regression: the alternation TIMESTAMP_NTZ must come before TIMESTAMP + // (and end with a word boundary) so the regex engine doesn't capture + // `TIMESTAMP` and leave `_NTZ` unconsumed. + const sql = `-- @param eventTs TIMESTAMP_NTZ +SELECT 1`; + const types = extractParameterTypes(sql); + expect(types.eventTs).toBe("TIMESTAMP_NTZ"); + }); + test("ignores malformed @param comments", () => { const sql = `-- @param startDate -- @param INVALID @@ -240,6 +252,10 @@ describe("defaultForType", () => { expect(defaultForType("DOUBLE")).toBe("0.0"); }); + test("returns NTZ-shaped literal for TIMESTAMP_NTZ", () => { + expect(defaultForType("TIMESTAMP_NTZ")).toBe("'2000-01-01T00:00:00'"); + }); + test("returns empty string literal for undefined (unknown fallback)", () => { expect(defaultForType(undefined)).toBe("''"); }); diff --git a/packages/shared/src/sql/helpers.ts b/packages/shared/src/sql/helpers.ts index 80102782c..fba54b97e 100644 --- a/packages/shared/src/sql/helpers.ts +++ b/packages/shared/src/sql/helpers.ts @@ -239,8 +239,18 @@ export const sql = { } // Integer-shaped strings: emit BIGINT so HTTP-input callers // (`req.query.n` is always a string) work with LIMIT/OFFSET without - // having to reach for `sql.bigint("10")` explicitly. + // having to reach for `sql.bigint("10")` explicitly. Out-of-range + // values throw — sql.numeric() is the right helper for + // arbitrary-precision integers. if (INTEGER_LITERAL_RE.test(value)) { + ensureInBigIntRange( + BigInt(value), + BIGINT_MIN, + BIGINT_MAX, + "BIGINT (64-bit signed)", + "sql.number", + "Use sql.numeric() with a string for arbitrary-precision integers.", + ); return { __sql_type: "BIGINT", value }; } // Non-integer strings stay NUMERIC: the caller chose to pass a string, diff --git a/packages/shared/src/sql/tests/sql-helpers.test.ts b/packages/shared/src/sql/tests/sql-helpers.test.ts index b765a079e..54501c917 100644 --- a/packages/shared/src/sql/tests/sql-helpers.test.ts +++ b/packages/shared/src/sql/tests/sql-helpers.test.ts @@ -63,6 +63,28 @@ describe("SQL Helpers", () => { }); }); + it("should accept BIGINT-boundary integer strings", () => { + expect(sql.number("9223372036854775807")).toEqual({ + __sql_type: "BIGINT", + value: "9223372036854775807", + }); + expect(sql.number("-9223372036854775808")).toEqual({ + __sql_type: "BIGINT", + value: "-9223372036854775808", + }); + }); + + it("should reject integer strings outside 64-bit signed range", () => { + // String input bypasses Number.MAX_SAFE_INTEGER guards, but the + // BIGINT wire type still cannot hold values outside 2^63. + expect(() => sql.number("9223372036854775808")).toThrow( + /BIGINT \(64-bit signed\) range/, + ); + expect(() => sql.number("-9223372036854775809")).toThrow( + /BIGINT \(64-bit signed\) range/, + ); + }); + it("should bind decimal-shaped strings as NUMERIC (preserve precision)", () => { const result = sql.number("123.4500000000001"); expect(result).toEqual({ diff --git a/packages/shared/src/sql/types.ts b/packages/shared/src/sql/types.ts index b37e1a9ac..8d94b5358 100644 --- a/packages/shared/src/sql/types.ts +++ b/packages/shared/src/sql/types.ts @@ -43,7 +43,9 @@ export interface SQLTimestampMarker { /** * Object that identifies a typed SQL parameter. - * Created using sql.date(), sql.string(), sql.number(), sql.boolean(), sql.timestamp(), sql.binary(), or sql.interval(). + * Created using sql.date(), sql.string(), sql.number() (or the typed numeric + * variants sql.int/bigint/float/double/numeric), sql.boolean(), + * sql.timestamp(), or sql.binary(). */ export type SQLTypeMarker = | SQLStringMarker From 7c0fada47dc9447a35b4f36f3cdd23ee931a2eb9 Mon Sep 17 00:00:00 2001 From: James Broadhead Date: Wed, 13 May 2026 15:05:07 +0000 Subject: [PATCH 5/6] fix(shared): sql.number infers INT (not BIGINT) for LIMIT/OFFSET compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empirical testing against e2-dogfood.staging (from @calvarjorge) showed that Spark's LIMIT/OFFSET operators require IntegerType specifically — LongType/BIGINT is rejected with INVALID_LIMIT_LIKE_EXPRESSION.DATA_TYPE: The offset expression must be integer type, but got "BIGINT". So the original PR's "LIMIT now Just Works" claim was false: BIGINT fails for the exact motivating case. Catalyst auto-widens INT to BIGINT/DECIMAL/DOUBLE for wider columns, so defaulting to INT is strictly better than defaulting to BIGINT. Change sql.number inference: - JS integer in [-2^31, 2^31 - 1] → INT (was BIGINT) - JS integer outside INT but within MAX_SAFE_INTEGER → BIGINT - integer-shaped string in INT range → INT (was BIGINT) - integer-shaped string outside INT, within BIGINT → BIGINT - everything else unchanged (DOUBLE for non-integer, NUMERIC for decimal strings, throw for out-of-BIGINT and non-numeric) Update analytics.md to recommend `-- @param limit INT` and explain the Spark IntegerType requirement. Update unit + integration tests to pin INT bindings for in-range values, with explicit boundary coverage at +/- 2^31. Regenerate the API reference page. Co-authored-by: Isaac Signed-off-by: James Broadhead --- docs/docs/api/appkit/Variable.sql.md | 30 +++++++---- docs/docs/plugins/analytics.md | 10 ++-- .../src/plugins/analytics/tests/query.test.ts | 29 +++++----- packages/shared/src/sql/helpers.ts | 53 +++++++++++------- .../shared/src/sql/tests/sql-helpers.test.ts | 54 ++++++++++++++++--- 5 files changed, 124 insertions(+), 52 deletions(-) diff --git a/docs/docs/api/appkit/Variable.sql.md b/docs/docs/api/appkit/Variable.sql.md index 2d8dafba3..9750c9c57 100644 --- a/docs/docs/api/appkit/Variable.sql.md +++ b/docs/docs/api/appkit/Variable.sql.md @@ -294,18 +294,25 @@ number(value: string | number): SQLNumberMarker; Creates a numeric type parameter. The wire SQL type is inferred from the value so the parameter binds correctly in any context, including `LIMIT` -and `OFFSET` (which require integer types): +and `OFFSET`: -- JS integer (`10`) → `BIGINT` +- JS integer in `[-2^31, 2^31 - 1]` → `INT` +- JS integer outside `INT` but within `Number.MAX_SAFE_INTEGER` → `BIGINT` - JS non-integer (`3.14`) → `DOUBLE` -- integer-shaped string (`"10"`) → `BIGINT` (common HTTP-input case; - works with `LIMIT :n` / `OFFSET :m`) +- integer-shaped string in `INT` range → `INT` (common HTTP-input case) +- integer-shaped string outside `INT` but within `BIGINT` → `BIGINT` - decimal-shaped string (`"123.45"`) → `NUMERIC` (preserves precision) +Why default to `INT`? Spark's `LIMIT` and `OFFSET` operators require +`IntegerType` specifically — `BIGINT` (`LongType`) is rejected with +`INVALID_LIMIT_LIKE_EXPRESSION.DATA_TYPE`. Catalyst auto-widens `INT` +to `BIGINT` / `DECIMAL` / `DOUBLE` for wider columns, so `INT` is a +strictly better default than `BIGINT`. + Throws on `NaN`, `Infinity`, JS integers outside `Number.MAX_SAFE_INTEGER`, -or non-numeric strings. Reach for `sql.int()`, `sql.bigint()`, -`sql.float()`, `sql.double()`, or `sql.numeric()` if you need to override -the inferred type. +integer-shaped strings outside the `BIGINT` range, or non-numeric strings. +Reach for `sql.int()`, `sql.bigint()`, `sql.float()`, `sql.double()`, or +`sql.numeric()` to override the inferred type. #### Parameters @@ -322,10 +329,11 @@ Marker for a numeric SQL parameter #### Example ```typescript -sql.number(123); // { __sql_type: "BIGINT", value: "123" } -sql.number(0.5); // { __sql_type: "DOUBLE", value: "0.5" } -sql.number("10"); // { __sql_type: "BIGINT", value: "10" } -sql.number("123.45"); // { __sql_type: "NUMERIC", value: "123.45" } +sql.number(123); // { __sql_type: "INT", value: "123" } +sql.number(3_000_000_000); // { __sql_type: "BIGINT", value: "3000000000" } +sql.number(0.5); // { __sql_type: "DOUBLE", value: "0.5" } +sql.number("10"); // { __sql_type: "INT", value: "10" } +sql.number("123.45"); // { __sql_type: "NUMERIC", value: "123.45" } ``` ### numeric() diff --git a/docs/docs/plugins/analytics.md b/docs/docs/plugins/analytics.md index 811a62372..9fd0b4a30 100644 --- a/docs/docs/plugins/analytics.md +++ b/docs/docs/plugins/analytics.md @@ -43,15 +43,17 @@ Use `:paramName` placeholders and optionally annotate parameter types using SQL ```sql -- @param startDate DATE -- @param endDate DATE --- @param limit BIGINT +-- @param limit INT SELECT ... WHERE usage_date BETWEEN :startDate AND :endDate LIMIT :limit ``` -`LIMIT` / `OFFSET` require an integer-typed binding (`INT` or `BIGINT`). -Annotate accordingly, or use `sql.number()` (auto-infers `BIGINT` for integer -inputs) / `sql.bigint()` / `sql.int()` at the call site. +`LIMIT` / `OFFSET` require Spark `IntegerType` specifically — `BIGINT` +(`LongType`) is rejected with `INVALID_LIMIT_LIKE_EXPRESSION.DATA_TYPE`. +Annotate with `INT`, or use `sql.number()` (auto-infers `INT` for values in +`[-2^31, 2^31-1]`, falling back to `BIGINT` for wider values) / `sql.int()` +at the call site. **Supported `-- @param` types** (case-insensitive): - `STRING`, `BOOLEAN`, `DATE`, `TIMESTAMP`, `BINARY` diff --git a/packages/appkit/src/plugins/analytics/tests/query.test.ts b/packages/appkit/src/plugins/analytics/tests/query.test.ts index 4db355453..1752ad571 100644 --- a/packages/appkit/src/plugins/analytics/tests/query.test.ts +++ b/packages/appkit/src/plugins/analytics/tests/query.test.ts @@ -32,7 +32,7 @@ describe("QueryProcessor", () => { expect(result.statement).toBe(query); expect(result.parameters).toHaveLength(2); expect(result.parameters).toEqual([ - { name: "user_id", value: "123", type: "BIGINT" }, + { name: "user_id", value: "123", type: "INT" }, { name: "name", value: "Alice", type: "STRING" }, ]); }); @@ -167,11 +167,12 @@ describe("QueryProcessor", () => { test("should not override workspace_id if already provided", async () => { const query = "SELECT * FROM data WHERE workspace_id = :workspaceId"; + // 9876543210 exceeds INT_MAX (2^31 - 1) so inference falls through to + // BIGINT — appropriate for ID columns. const parameters = { workspaceId: sql.number("9876543210") }; const result = await processor.processQueryParams(query, parameters); - // Integer-shaped strings infer BIGINT (matches LIMIT/OFFSET pattern) expect(result.workspaceId).toEqual({ __sql_type: "BIGINT", value: "9876543210", @@ -180,7 +181,11 @@ describe("QueryProcessor", () => { }); describe("LIMIT / OFFSET bindings (regression for #323)", () => { - test("sql.number(integer) binds as BIGINT for LIMIT/OFFSET", () => { + // Spark requires IntegerType for LIMIT/OFFSET; BIGINT/LongType is + // rejected with INVALID_LIMIT_LIKE_EXPRESSION.DATA_TYPE. These tests + // pin INT inference so sql.number(req.query.n) works against the + // warehouse without explicit casting. + test("sql.number(integer) binds as INT for LIMIT/OFFSET", () => { const query = "SELECT * FROM events LIMIT :n OFFSET :m"; const parameters = { n: sql.number(10), @@ -190,12 +195,12 @@ describe("QueryProcessor", () => { const result = processor.convertToSQLParameters(query, parameters); expect(result.parameters).toEqual([ - { name: "n", value: "10", type: "BIGINT" }, - { name: "m", value: "20", type: "BIGINT" }, + { name: "n", value: "10", type: "INT" }, + { name: "m", value: "20", type: "INT" }, ]); }); - test("sql.number(integer-shaped string) binds as BIGINT for LIMIT/OFFSET", () => { + test("sql.number(integer-shaped string) binds as INT for LIMIT/OFFSET", () => { // Express/URLSearchParams return strings — this is the common // handler pattern: sql.number(req.query.n). const query = "SELECT * FROM events LIMIT :n OFFSET :m"; @@ -207,19 +212,19 @@ describe("QueryProcessor", () => { const result = processor.convertToSQLParameters(query, parameters); expect(result.parameters).toEqual([ - { name: "n", value: "10", type: "BIGINT" }, - { name: "m", value: "20", type: "BIGINT" }, + { name: "n", value: "10", type: "INT" }, + { name: "m", value: "20", type: "INT" }, ]); }); - test("sql.bigint(string) binds as BIGINT for LIMIT/OFFSET", () => { + test("sql.int(string) binds as INT for LIMIT/OFFSET (explicit form)", () => { const query = "SELECT * FROM events LIMIT :n"; - const parameters = { n: sql.bigint("10") }; + const parameters = { n: sql.int("10") }; const result = processor.convertToSQLParameters(query, parameters); expect(result.parameters).toEqual([ - { name: "n", value: "10", type: "BIGINT" }, + { name: "n", value: "10", type: "INT" }, ]); }); }); @@ -275,7 +280,7 @@ describe("QueryProcessor", () => { expect(result.parameters[0]).toEqual({ name: "age", value: "25", - type: "BIGINT", + type: "INT", }); }); diff --git a/packages/shared/src/sql/helpers.ts b/packages/shared/src/sql/helpers.ts index fba54b97e..e130de1c2 100644 --- a/packages/shared/src/sql/helpers.ts +++ b/packages/shared/src/sql/helpers.ts @@ -199,27 +199,35 @@ export const sql = { /** * Creates a numeric type parameter. The wire SQL type is inferred from the * value so the parameter binds correctly in any context, including `LIMIT` - * and `OFFSET` (which require integer types): + * and `OFFSET`: * - * - JS integer (`10`) → `BIGINT` + * - JS integer in `[-2^31, 2^31 - 1]` → `INT` + * - JS integer outside `INT` but within `Number.MAX_SAFE_INTEGER` → `BIGINT` * - JS non-integer (`3.14`) → `DOUBLE` - * - integer-shaped string (`"10"`) → `BIGINT` (common HTTP-input case; - * works with `LIMIT :n` / `OFFSET :m`) + * - integer-shaped string in `INT` range → `INT` (common HTTP-input case) + * - integer-shaped string outside `INT` but within `BIGINT` → `BIGINT` * - decimal-shaped string (`"123.45"`) → `NUMERIC` (preserves precision) * + * Why default to `INT`? Spark's `LIMIT` and `OFFSET` operators require + * `IntegerType` specifically — `BIGINT` (`LongType`) is rejected with + * `INVALID_LIMIT_LIKE_EXPRESSION.DATA_TYPE`. Catalyst auto-widens `INT` + * to `BIGINT` / `DECIMAL` / `DOUBLE` for wider columns, so `INT` is a + * strictly better default than `BIGINT`. + * * Throws on `NaN`, `Infinity`, JS integers outside `Number.MAX_SAFE_INTEGER`, - * or non-numeric strings. Reach for `sql.int()`, `sql.bigint()`, - * `sql.float()`, `sql.double()`, or `sql.numeric()` if you need to override - * the inferred type. + * integer-shaped strings outside the `BIGINT` range, or non-numeric strings. + * Reach for `sql.int()`, `sql.bigint()`, `sql.float()`, `sql.double()`, or + * `sql.numeric()` to override the inferred type. * * @param value - Number or numeric string * @returns Marker for a numeric SQL parameter * @example * ```typescript - * sql.number(123); // { __sql_type: "BIGINT", value: "123" } - * sql.number(0.5); // { __sql_type: "DOUBLE", value: "0.5" } - * sql.number("10"); // { __sql_type: "BIGINT", value: "10" } - * sql.number("123.45"); // { __sql_type: "NUMERIC", value: "123.45" } + * sql.number(123); // { __sql_type: "INT", value: "123" } + * sql.number(3_000_000_000); // { __sql_type: "BIGINT", value: "3000000000" } + * sql.number(0.5); // { __sql_type: "DOUBLE", value: "0.5" } + * sql.number("10"); // { __sql_type: "INT", value: "10" } + * sql.number("123.45"); // { __sql_type: "NUMERIC", value: "123.45" } * ``` */ number(value: number | string): SQLNumberMarker { @@ -227,7 +235,13 @@ export const sql = { ensureFiniteNumber(value, "sql.number"); if (Number.isInteger(value)) { ensureSafeInteger(value, "sql.number"); - return { __sql_type: "BIGINT", value: BigInt(value).toString() }; + const asBigInt = BigInt(value); + // INT (32-bit) is required by Spark for LIMIT/OFFSET; Catalyst + // widens INT → BIGINT/DECIMAL/DOUBLE automatically. + if (asBigInt >= INT_MIN && asBigInt <= INT_MAX) { + return { __sql_type: "INT", value: asBigInt.toString() }; + } + return { __sql_type: "BIGINT", value: asBigInt.toString() }; } return { __sql_type: "DOUBLE", value: value.toString() }; } @@ -237,20 +251,23 @@ export const sql = { `sql.number() expects number or numeric string, got: ${value === "" ? "empty string" : value}`, ); } - // Integer-shaped strings: emit BIGINT so HTTP-input callers - // (`req.query.n` is always a string) work with LIMIT/OFFSET without - // having to reach for `sql.bigint("10")` explicitly. Out-of-range - // values throw — sql.numeric() is the right helper for - // arbitrary-precision integers. + // Integer-shaped strings get the same INT-preferring inference, so + // `sql.number(req.query.n)` (Express/URLSearchParams strings) works + // with LIMIT/OFFSET out of the box. Out-of-BIGINT-range throws — + // sql.numeric() is the right helper for arbitrary-precision integers. if (INTEGER_LITERAL_RE.test(value)) { + const parsed = BigInt(value); ensureInBigIntRange( - BigInt(value), + parsed, BIGINT_MIN, BIGINT_MAX, "BIGINT (64-bit signed)", "sql.number", "Use sql.numeric() with a string for arbitrary-precision integers.", ); + if (parsed >= INT_MIN && parsed <= INT_MAX) { + return { __sql_type: "INT", value }; + } return { __sql_type: "BIGINT", value }; } // Non-integer strings stay NUMERIC: the caller chose to pass a string, diff --git a/packages/shared/src/sql/tests/sql-helpers.test.ts b/packages/shared/src/sql/tests/sql-helpers.test.ts index 54501c917..e35dacb07 100644 --- a/packages/shared/src/sql/tests/sql-helpers.test.ts +++ b/packages/shared/src/sql/tests/sql-helpers.test.ts @@ -37,14 +37,45 @@ describe("SQL Helpers", () => { }); describe("number()", () => { - it("should bind a JS integer as BIGINT (works in LIMIT/OFFSET)", () => { + it("should bind a JS integer in INT range as INT (works with Spark LIMIT/OFFSET)", () => { + // Spark requires IntegerType for LIMIT/OFFSET; BIGINT/LongType is + // rejected with INVALID_LIMIT_LIKE_EXPRESSION.DATA_TYPE. INT is + // auto-widened to BIGINT/DECIMAL/DOUBLE by Catalyst for wider columns. const result = sql.number(1234567890); expect(result).toEqual({ - __sql_type: "BIGINT", + __sql_type: "INT", value: "1234567890", }); }); + it("should bind a JS integer outside INT range as BIGINT", () => { + const result = sql.number(3_000_000_000); + expect(result).toEqual({ + __sql_type: "BIGINT", + value: "3000000000", + }); + }); + + it("should bind INT boundaries correctly", () => { + expect(sql.number(2147483647)).toEqual({ + __sql_type: "INT", + value: "2147483647", + }); + expect(sql.number(-2147483648)).toEqual({ + __sql_type: "INT", + value: "-2147483648", + }); + // Just past INT_MAX → BIGINT + expect(sql.number(2147483648)).toEqual({ + __sql_type: "BIGINT", + value: "2147483648", + }); + expect(sql.number(-2147483649)).toEqual({ + __sql_type: "BIGINT", + value: "-2147483649", + }); + }); + it("should bind a JS non-integer as DOUBLE", () => { const result = sql.number(3.14); expect(result).toEqual({ @@ -53,16 +84,24 @@ describe("SQL Helpers", () => { }); }); - it("should bind an integer-shaped string as BIGINT (HTTP-input case)", () => { + it("should bind an integer-shaped string in INT range as INT (HTTP-input case)", () => { // Express/URLSearchParams return strings; common pattern is - // sql.number(req.query.n) which must work with LIMIT/OFFSET. + // sql.number(req.query.n) which must work with Spark LIMIT/OFFSET. const result = sql.number("1234567890"); expect(result).toEqual({ - __sql_type: "BIGINT", + __sql_type: "INT", value: "1234567890", }); }); + it("should bind an integer-shaped string outside INT range as BIGINT", () => { + const result = sql.number("3000000000"); + expect(result).toEqual({ + __sql_type: "BIGINT", + value: "3000000000", + }); + }); + it("should accept BIGINT-boundary integer strings", () => { expect(sql.number("9223372036854775807")).toEqual({ __sql_type: "BIGINT", @@ -112,10 +151,11 @@ describe("SQL Helpers", () => { expect(() => sql.number(Number.NaN)).toThrow(/finite number/); }); - it("should emit canonical decimal text (no exponent) for safe integers", () => { + it("should emit canonical decimal text (no exponent) for large safe integers", () => { // Sanity check: even though Number.prototype.toString could emit // exponent form for very large integers, the helper always emits - // decimal text via BigInt(value).toString(). + // decimal text via BigInt(value).toString(). 1e15 is outside INT + // range, so the wire type is BIGINT. const result = sql.number(1e15); expect(result).toEqual({ __sql_type: "BIGINT", From 047227e6f8be0ef8f971b38b0997894bbcd57c0b Mon Sep 17 00:00:00 2001 From: James Broadhead Date: Wed, 13 May 2026 15:12:20 +0000 Subject: [PATCH 6/6] docs(shared): document how to re-validate LIMIT/OFFSET mocked tests against prod MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LIMIT/OFFSET integration tests assert wire-type strings produced by sql.number — they don't round-trip against a real warehouse. The original PR claimed BIGINT worked for LIMIT and tests passed, but production Spark rejected the binding. Add a comment in the test block with a copy-pasteable /api/2.0/sql/statements payload, the expected success and failure states, and the exact error string. If Spark's LIMIT type contract ever changes, this comment is the smoke test to catch it before the mocked assertions silently drift away from production behaviour. Verified empirically against two independent SQL Warehouses on e2-dogfood.staging (including dd43ee29fedd958d, the warehouse the original PR cited as evidence): INT succeeds with 2 rows, BIGINT fails with [INVALID_LIMIT_LIKE_EXPRESSION.DATA_TYPE] ... SQLSTATE: 42K0E. Co-authored-by: Isaac Signed-off-by: James Broadhead --- .../src/plugins/analytics/tests/query.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/appkit/src/plugins/analytics/tests/query.test.ts b/packages/appkit/src/plugins/analytics/tests/query.test.ts index 1752ad571..bbf0a2ce4 100644 --- a/packages/appkit/src/plugins/analytics/tests/query.test.ts +++ b/packages/appkit/src/plugins/analytics/tests/query.test.ts @@ -185,6 +185,35 @@ describe("QueryProcessor", () => { // rejected with INVALID_LIMIT_LIKE_EXPRESSION.DATA_TYPE. These tests // pin INT inference so sql.number(req.query.n) works against the // warehouse without explicit casting. + // + // These tests are MOCKED — they assert the wire-type string the + // helper emits, not warehouse round-trip behaviour. To re-validate + // that the mocked assertions still match production Spark semantics: + // + // 1. Pick any RUNNING SQL Warehouse you can reach + // (`databricks warehouses list -p ` and grep for RUNNING). + // 2. POST /api/2.0/sql/statements with the helper's wire-type strings + // directly, using the same VALUES-based query so no table is + // required: + // + // databricks api post /api/2.0/sql/statements --json '{ + // "statement": "SELECT x FROM (VALUES (1),(2),(3),(4),(5)) AS t(x) ORDER BY x LIMIT :n OFFSET :m", + // "warehouse_id": "", + // "wait_timeout": "30s", + // "parameters": [ + // {"name": "n", "value": "2", "type": "INT"}, + // {"name": "m", "value": "1", "type": "INT"} + // ] + // }' + // + // 3. Expect: `status.state == "SUCCEEDED"`, `result.row_count == 2`. + // 4. Swap both parameter `type` values to `"BIGINT"` and re-run. + // Expect: `status.state == "FAILED"`, error message + // `[INVALID_LIMIT_LIKE_EXPRESSION.DATA_TYPE] ... must be integer + // type, but got "BIGINT". SQLSTATE: 42K0E`. + // + // If (3) fails or (4) starts succeeding, Spark's LIMIT type contract + // has changed and the INT-by-default inference should be re-evaluated. test("sql.number(integer) binds as INT for LIMIT/OFFSET", () => { const query = "SELECT * FROM events LIMIT :n OFFSET :m"; const parameters = {