Skip to content
Open
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
4 changes: 2 additions & 2 deletions packages/appkit/src/plugins/analytics/tests/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]);
});
Expand Down Expand Up @@ -229,7 +229,7 @@ describe("QueryProcessor", () => {
expect(result.parameters[0]).toEqual({
name: "age",
value: "25",
type: "NUMERIC",
type: "BIGINT",
});
});

Expand Down
150 changes: 132 additions & 18 deletions packages/shared/src/sql/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
Expand Down
80 changes: 69 additions & 11 deletions packages/shared/src/sql/tests/sql-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
});
Expand All @@ -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!";
Expand Down
14 changes: 13 additions & 1 deletion packages/shared/src/sql/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading