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; }