Skip to content

feat(appkit): infer numeric SQL type for sql.number(), add typed variants#323

Open
jamesbroadhead wants to merge 1 commit intomainfrom
sql-number-typed-variants
Open

feat(appkit): infer numeric SQL type for sql.number(), add typed variants#323
jamesbroadhead wants to merge 1 commit intomainfrom
sql-number-typed-variants

Conversation

@jamesbroadhead
Copy link
Copy Markdown
Contributor

Why

sql.number() unconditionally tags the parameter as __sql_type: "NUMERIC", which Databricks SQL binds as DECIMAL(10,0). Two real failures fall out of that:

1. LIMIT / OFFSET reject the binding. Spark requires an integer-typed expression for these clauses:

[INVALID_LIMIT_LIKE_EXPRESSION.DATA_TYPE]
The limit like expression "10" is invalid.
The limit expression must be integer type, but got "DECIMAL(10,0)". SQLSTATE: 42K0E

So useAnalyticsQuery("...", { limit: sql.number(10) }) against a query containing LIMIT :limit fails the moment the page loads. Reproducible directly:

SELECT 1 AS x LIMIT :n
  parameters=[{ name: "n", value: "10", type: "NUMERIC" }]
  -> FAILED  (binds as DECIMAL(10,0))

SELECT 1 AS x LIMIT :n
  parameters=[{ name: "n", value: "10", type: "BIGINT" }]
  -> SUCCEEDED

2. Non-integer JS numbers truncate silently. sql.number(3.14) sends value: "3.14" with type: "NUMERIC", which the server interprets as DECIMAL(10,0) — zero fractional digits, so the value either rounds or rejects depending on context. The user lost precision they didn't ask to lose.

Change

Infer the wire type from the JS value

sql.number() now picks the most-precise numeric SQL type the value can losslessly represent:

Input Wire type
sql.number(10) (integer) BIGINT
sql.number(3.14) (non-integer) DOUBLE
sql.number("123.4500") (string) NUMERIC

Strings stay NUMERIC because passing a string is a deliberate choice to skip JS-number coercion (currency, exact decimals, values beyond Number.MAX_SAFE_INTEGER). Callers who reach for the string form usually want decimal precision preserved end-to-end.

BIGINT covers every JS integer up to Number.MAX_SAFE_INTEGER and Spark coerces it implicitly to INT / DECIMAL / DOUBLE as needed, so existing queries against narrower or wider numeric columns keep working — plus LIMIT :limit now Just Works.

Typed variants for explicit control

When the inference is wrong for the caller's column or context:

sql.int(value)      // -> INT
sql.bigint(value)   // -> BIGINT (also accepts JS bigint)
sql.float(value)    // -> FLOAT
sql.double(value)   // -> DOUBLE
sql.decimal(value)  // -> NUMERIC (precision-preserving DECIMAL)

sql.int() and sql.bigint() reject non-integer inputs at the helper boundary (sql.int(3.14) throws synchronously) so the failure surfaces at the callsite, not at the wire.

Type widening

SQLNumberMarker.__sql_type widens from "NUMERIC" to "INT" | "BIGINT" | "FLOAT" | "DOUBLE" | "NUMERIC". This is non-breaking — any value previously typed as the narrow "NUMERIC" literal still satisfies the wider union in return position. The two unit tests that pinned type: "NUMERIC" for sql.number(integer) are updated to expect BIGINT.

Alternatives considered

  • Always INT. Rejected — would silently truncate sql.number(3.14) to 3 and break filters on BIGINT / DECIMAL / DOUBLE columns where the literal is outside INT range.
  • Always BIGINT. Rejected for the same precision reason on non-integer values; would also surprise callers passing a JS number that's actually meant to be a DOUBLE.
  • Document a CAST(:limit AS INT) workaround in every query. Real cost: every parameterized LIMIT / OFFSET in every consumer needs the cast. The library is the right place to fix it once.
  • Add typed variants only, leave sql.number() alone. Rejected — the failure mode is silent (rounding) or cryptic (LIMIT error), and callers won't reach for sql.int() until they've already been bitten. Better to make the common-case path correct.

Test plan

  • pnpm -r typecheck — clean across all packages
  • npx vitest run — 1761/1761 pass; new tests cover BIGINT inference for integers, DOUBLE for non-integers, NUMERIC for strings, plus all five typed variants
  • npx biome check on touched files — clean
  • pnpm build — appkit + appkit-ui rebuild green
  • Reproduced original failure and verified fix against dd43ee29fedd958d via databricks api post /api/2.0/sql/statements

This pull request and its description were written by Isaac.

…ants

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 <jamesbroadhead@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant