Skip to content

Commit 5b288ad

Browse files
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 <jamesbroadhead@gmail.com>
1 parent 73ec9c4 commit 5b288ad

10 files changed

Lines changed: 532 additions & 113 deletions

File tree

apps/dev-playground/shared/appkit-types/analytics.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ declare module "@databricks/appkit-ui/react" {
105105
parameters: {
106106
/** STRING - use sql.string() */
107107
stringParam: SQLStringMarker;
108-
/** NUMERIC - use sql.number() */
108+
/** NUMERIC - use sql.numeric() */
109109
numberParam: SQLNumberMarker;
110110
/** BOOLEAN - use sql.boolean() */
111111
booleanParam: SQLBooleanMarker;

docs/docs/api/appkit/Variable.sql.md

Lines changed: 132 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,25 @@
22

33
```ts
44
const sql: {
5-
bigint: SQLNumberMarker;
5+
bigint: SQLNumberMarker & {
6+
__sql_type: "BIGINT";
7+
};
68
binary: SQLBinaryMarker;
79
boolean: SQLBooleanMarker;
810
date: SQLDateMarker;
9-
decimal: SQLNumberMarker;
10-
double: SQLNumberMarker;
11-
float: SQLNumberMarker;
12-
int: SQLNumberMarker;
11+
double: SQLNumberMarker & {
12+
__sql_type: "DOUBLE";
13+
};
14+
float: SQLNumberMarker & {
15+
__sql_type: "FLOAT";
16+
};
17+
int: SQLNumberMarker & {
18+
__sql_type: "INT";
19+
};
1320
number: SQLNumberMarker;
21+
numeric: SQLNumberMarker & {
22+
__sql_type: "NUMERIC";
23+
};
1424
string: SQLStringMarker;
1525
timestamp: SQLTimestampMarker;
1626
};
@@ -23,12 +33,17 @@ SQL helper namespace
2333
### bigint()
2434

2535
```ts
26-
bigint(value: string | number | bigint): SQLNumberMarker;
36+
bigint(value: string | number | bigint): SQLNumberMarker & {
37+
__sql_type: "BIGINT";
38+
};
2739
```
2840

2941
Creates a `BIGINT` (64-bit signed integer) parameter. Accepts JS
3042
`bigint` so callers can round-trip values outside `Number.MAX_SAFE_INTEGER`
31-
without precision loss.
43+
without precision loss; for `number` inputs, requires
44+
`Number.isSafeInteger(value)`.
45+
46+
Rejects values outside the signed 64-bit range `[-2^63, 2^63 - 1]`.
3247

3348
#### Parameters
3449

@@ -38,7 +53,19 @@ without precision loss.
3853

3954
#### Returns
4055

41-
`SQLNumberMarker`
56+
`SQLNumberMarker` & \{
57+
`__sql_type`: `"BIGINT"`;
58+
\}
59+
60+
Marker pinned to `BIGINT`
61+
62+
#### Example
63+
64+
```typescript
65+
sql.bigint(42); // { __sql_type: "BIGINT", value: "42" }
66+
sql.bigint(9007199254740993n); // { __sql_type: "BIGINT", value: "9007199254740993" }
67+
sql.bigint("9007199254740993"); // { __sql_type: "BIGINT", value: "9007199254740993" }
68+
```
4269

4370
### binary()
4471

@@ -159,52 +186,48 @@ const params = { startDate: sql.date("2024-01-01") };
159186
params = { startDate: "2024-01-01" }
160187
```
161188

162-
### decimal()
189+
### double()
163190

164191
```ts
165-
decimal(value: string | number): SQLNumberMarker;
192+
double(value: string | number): SQLNumberMarker & {
193+
__sql_type: "DOUBLE";
194+
};
166195
```
167196

168-
Creates a `NUMERIC` (fixed-point DECIMAL) parameter. Use when you need
169-
exact decimal arithmetic (currency, percentages) — pass values as
170-
strings to avoid JS-number precision loss.
197+
Creates a `DOUBLE` (double-precision, 64-bit) parameter. Same precision
198+
as a JS `number`, so `sql.double(value)` is exact for any JS number.
171199

172200
#### Parameters
173201

174202
| Parameter | Type | Description |
175203
| ------ | ------ | ------ |
176-
| `value` | `string` \| `number` | Number or numeric string (strings preferred for precision) |
204+
| `value` | `string` \| `number` | Number or numeric string |
177205

178206
#### Returns
179207

180-
`SQLNumberMarker`
181-
182-
### double()
183-
184-
```ts
185-
double(value: string | number): SQLNumberMarker;
186-
```
187-
188-
Creates a `DOUBLE` (double-precision) parameter. Same precision as a JS
189-
`number`, so `sql.double(value)` is exact for any JS number.
190-
191-
#### Parameters
208+
`SQLNumberMarker` & \{
209+
`__sql_type`: `"DOUBLE"`;
210+
\}
192211

193-
| Parameter | Type | Description |
194-
| ------ | ------ | ------ |
195-
| `value` | `string` \| `number` | Number or numeric string |
212+
Marker pinned to `DOUBLE`
196213

197-
#### Returns
214+
#### Example
198215

199-
`SQLNumberMarker`
216+
```typescript
217+
sql.double(3.14); // { __sql_type: "DOUBLE", value: "3.14" }
218+
```
200219

201220
### float()
202221

203222
```ts
204-
float(value: string | number): SQLNumberMarker;
223+
float(value: string | number): SQLNumberMarker & {
224+
__sql_type: "FLOAT";
225+
};
205226
```
206227

207-
Creates a `FLOAT` (single-precision) parameter.
228+
Creates a `FLOAT` (single-precision, 32-bit) parameter. Note that JS
229+
numbers are 64-bit doubles, so values may be rounded to fit FLOAT
230+
precision at bind time.
208231

209232
#### Parameters
210233

@@ -214,18 +237,34 @@ Creates a `FLOAT` (single-precision) parameter.
214237

215238
#### Returns
216239

217-
`SQLNumberMarker`
240+
`SQLNumberMarker` & \{
241+
`__sql_type`: `"FLOAT"`;
242+
\}
243+
244+
Marker pinned to `FLOAT`
245+
246+
#### Example
247+
248+
```typescript
249+
sql.float(3.14); // { __sql_type: "FLOAT", value: "3.14" }
250+
```
218251

219252
### int()
220253

221254
```ts
222-
int(value: string | number): SQLNumberMarker;
255+
int(value: string | number): SQLNumberMarker & {
256+
__sql_type: "INT";
257+
};
223258
```
224259

225260
Creates an `INT` (32-bit signed integer) parameter. Use when the column
226261
or context requires `INT` specifically (e.g. legacy schemas, or to make
227262
the wire type explicit).
228263

264+
Rejects non-integers, values outside `Number.MAX_SAFE_INTEGER` (for
265+
number inputs), and values outside the signed 32-bit range
266+
`[-2^31, 2^31 - 1]`.
267+
229268
#### Parameters
230269

231270
| Parameter | Type | Description |
@@ -234,7 +273,18 @@ the wire type explicit).
234273

235274
#### Returns
236275

237-
`SQLNumberMarker`
276+
`SQLNumberMarker` & \{
277+
`__sql_type`: `"INT"`;
278+
\}
279+
280+
Marker pinned to `INT`
281+
282+
#### Example
283+
284+
```typescript
285+
sql.int(42); // { __sql_type: "INT", value: "42" }
286+
sql.int("42"); // { __sql_type: "INT", value: "42" }
287+
```
238288

239289
### number()
240290

@@ -248,10 +298,14 @@ and `OFFSET` (which require integer types):
248298

249299
- JS integer (`10`) → `BIGINT`
250300
- JS non-integer (`3.14`) → `DOUBLE`
251-
- numeric string (`"123.45"`) → `NUMERIC` (preserves caller's precision intent)
301+
- integer-shaped string (`"10"`) → `BIGINT` (common HTTP-input case;
302+
works with `LIMIT :n` / `OFFSET :m`)
303+
- decimal-shaped string (`"123.45"`) → `NUMERIC` (preserves precision)
252304

253-
Reach for `sql.int()`, `sql.bigint()`, `sql.float()`, `sql.double()`, or
254-
`sql.decimal()` if you need to override the inferred type.
305+
Throws on `NaN`, `Infinity`, JS integers outside `Number.MAX_SAFE_INTEGER`,
306+
or non-numeric strings. Reach for `sql.int()`, `sql.bigint()`,
307+
`sql.float()`, `sql.double()`, or `sql.numeric()` if you need to override
308+
the inferred type.
255309

256310
#### Parameters
257311

@@ -268,9 +322,45 @@ Marker for a numeric SQL parameter
268322
#### Example
269323

270324
```typescript
271-
const params = { userId: sql.number(123) }; // BIGINT, value "123"
272-
const params = { ratio: sql.number(0.5) }; // DOUBLE, value "0.5"
273-
const params = { amount: sql.number("123.45") }; // NUMERIC, value "123.45"
325+
sql.number(123); // { __sql_type: "BIGINT", value: "123" }
326+
sql.number(0.5); // { __sql_type: "DOUBLE", value: "0.5" }
327+
sql.number("10"); // { __sql_type: "BIGINT", value: "10" }
328+
sql.number("123.45"); // { __sql_type: "NUMERIC", value: "123.45" }
329+
```
330+
331+
### numeric()
332+
333+
```ts
334+
numeric(value: string | number): SQLNumberMarker & {
335+
__sql_type: "NUMERIC";
336+
};
337+
```
338+
339+
Creates a `NUMERIC` (fixed-point DECIMAL) parameter. Use when you need
340+
exact decimal arithmetic (currency, percentages) — pass values as
341+
strings to avoid JS-number precision loss.
342+
343+
Note: passing a JS `number` is accepted but lossy for many values
344+
(e.g. `0.1 + 0.2``"0.30000000000000004"`). Prefer strings.
345+
346+
#### Parameters
347+
348+
| Parameter | Type | Description |
349+
| ------ | ------ | ------ |
350+
| `value` | `string` \| `number` | Number or numeric string (strings preferred for precision) |
351+
352+
#### Returns
353+
354+
`SQLNumberMarker` & \{
355+
`__sql_type`: `"NUMERIC"`;
356+
\}
357+
358+
Marker pinned to `NUMERIC`
359+
360+
#### Example
361+
362+
```typescript
363+
sql.numeric("12345.6789"); // { __sql_type: "NUMERIC", value: "12345.6789" }
274364
```
275365

276366
### string()

docs/docs/plugins/analytics.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,21 @@ Use `:paramName` placeholders and optionally annotate parameter types using SQL
4343
```sql
4444
-- @param startDate DATE
4545
-- @param endDate DATE
46-
-- @param limit NUMERIC
46+
-- @param limit BIGINT
4747
SELECT ...
4848
WHERE usage_date BETWEEN :startDate AND :endDate
4949
LIMIT :limit
5050
```
5151

52+
`LIMIT` / `OFFSET` require an integer-typed binding (`INT` or `BIGINT`).
53+
Annotate accordingly, or use `sql.number()` (auto-infers `BIGINT` for integer
54+
inputs) / `sql.bigint()` / `sql.int()` at the call site.
55+
5256
**Supported `-- @param` types** (case-insensitive):
53-
- `STRING`, `NUMERIC`, `BOOLEAN`, `DATE`, `TIMESTAMP`, `BINARY`
57+
- `STRING`, `BOOLEAN`, `DATE`, `TIMESTAMP`, `BINARY`
58+
- `INT`, `BIGINT`, `TINYINT`, `SMALLINT` — bind via `sql.int()` / `sql.bigint()`
59+
- `FLOAT`, `DOUBLE` — bind via `sql.float()` / `sql.double()`
60+
- `NUMERIC`, `DECIMAL` — bind via `sql.numeric()` (pass strings for precision)
5461

5562
## Server-injected parameters
5663

packages/appkit/src/plugins/analytics/tests/query.test.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,13 +171,59 @@ describe("QueryProcessor", () => {
171171

172172
const result = await processor.processQueryParams(query, parameters);
173173

174+
// Integer-shaped strings infer BIGINT (matches LIMIT/OFFSET pattern)
174175
expect(result.workspaceId).toEqual({
175-
__sql_type: "NUMERIC",
176+
__sql_type: "BIGINT",
176177
value: "9876543210",
177178
});
178179
});
179180
});
180181

182+
describe("LIMIT / OFFSET bindings (regression for #323)", () => {
183+
test("sql.number(integer) binds as BIGINT for LIMIT/OFFSET", () => {
184+
const query = "SELECT * FROM events LIMIT :n OFFSET :m";
185+
const parameters = {
186+
n: sql.number(10),
187+
m: sql.number(20),
188+
};
189+
190+
const result = processor.convertToSQLParameters(query, parameters);
191+
192+
expect(result.parameters).toEqual([
193+
{ name: "n", value: "10", type: "BIGINT" },
194+
{ name: "m", value: "20", type: "BIGINT" },
195+
]);
196+
});
197+
198+
test("sql.number(integer-shaped string) binds as BIGINT for LIMIT/OFFSET", () => {
199+
// Express/URLSearchParams return strings — this is the common
200+
// handler pattern: sql.number(req.query.n).
201+
const query = "SELECT * FROM events LIMIT :n OFFSET :m";
202+
const parameters = {
203+
n: sql.number("10"),
204+
m: sql.number("20"),
205+
};
206+
207+
const result = processor.convertToSQLParameters(query, parameters);
208+
209+
expect(result.parameters).toEqual([
210+
{ name: "n", value: "10", type: "BIGINT" },
211+
{ name: "m", value: "20", type: "BIGINT" },
212+
]);
213+
});
214+
215+
test("sql.bigint(string) binds as BIGINT for LIMIT/OFFSET", () => {
216+
const query = "SELECT * FROM events LIMIT :n";
217+
const parameters = { n: sql.bigint("10") };
218+
219+
const result = processor.convertToSQLParameters(query, parameters);
220+
221+
expect(result.parameters).toEqual([
222+
{ name: "n", value: "10", type: "BIGINT" },
223+
]);
224+
});
225+
});
226+
181227
describe("_createParameter - Type Handling", () => {
182228
test("should handle date parameters with sql.date()", () => {
183229
const query = "SELECT * FROM events WHERE event_date = :startDate";

packages/appkit/src/type-generator/query-registry.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ function generateUnknownResultQuery(sql: string, queryName: string): string {
194194
export function extractParameterTypes(sql: string): Record<string, string> {
195195
const paramTypes: Record<string, string> = {};
196196
const regex =
197-
/--\s*@param\s+(\w+)\s+(STRING|NUMERIC|BOOLEAN|DATE|TIMESTAMP|BINARY)/gi;
197+
/--\s*@param\s+(\w+)\s+(STRING|NUMERIC|DECIMAL|BIGINT|TINYINT|SMALLINT|INT|FLOAT|DOUBLE|BOOLEAN|DATE|TIMESTAMP|BINARY)/gi;
198198
const matches = sql.matchAll(regex);
199199
for (const match of matches) {
200200
const [, paramName, paramType] = match;
@@ -207,7 +207,15 @@ export function extractParameterTypes(sql: string): Record<string, string> {
207207
export function defaultForType(sqlType: string | undefined): string {
208208
switch (sqlType?.toUpperCase()) {
209209
case "NUMERIC":
210+
case "DECIMAL":
211+
case "BIGINT":
212+
case "TINYINT":
213+
case "SMALLINT":
214+
case "INT":
210215
return "0";
216+
case "FLOAT":
217+
case "DOUBLE":
218+
return "0.0";
211219
case "STRING":
212220
return "''";
213221
case "BOOLEAN":

0 commit comments

Comments
 (0)