From c1ff0fe59c17b17a7251dff17e1708782a948578 Mon Sep 17 00:00:00 2001 From: Fabian Jakobs Date: Fri, 6 Nov 2026 13:55:35 +0000 Subject: [PATCH 1/2] fix(appkit): return numbers and booleans from analytics queries JSON_ARRAY returns every cell as a string, so analytics query rows carried "42" for BIGINT, "1.50" for DECIMAL and "true" for BOOLEAN while the generated query types promise number and boolean. The SQL warehouse connector now attaches the manifest column types next to the column names it already carries, and the /query route converts numeric and BOOLEAN cells on both the native JSON_ARRAY path and the ARROW_STREAM fallback. NULL, date/time and nested values are unchanged, and the /metric route keeps its documented string | null wire values. Remove the dev-playground's number-coercion workarounds in the KPI cards. Co-authored-by: Isaac Signed-off-by: Fabian Jakobs --- .../smart-dashboard/components/kpi-cards.tsx | 26 ++----- docs/docs/development/type-generation.md | 11 +++ .../src/connectors/sql-warehouse/client.ts | 17 +++++ .../sql-warehouse/tests/client.test.ts | 25 +++++++ .../appkit/src/plugins/analytics/analytics.ts | 6 +- .../src/plugins/analytics/result-delivery.ts | 64 +++++++++++++++- .../plugins/analytics/tests/analytics.test.ts | 35 +++++++++ .../plugins/analytics/tests/metric.test.ts | 36 +++++++++ .../analytics/tests/result-delivery.test.ts | 73 +++++++++++++++++++ 9 files changed, 271 insertions(+), 22 deletions(-) diff --git a/apps/dev-playground/client/src/features/smart-dashboard/components/kpi-cards.tsx b/apps/dev-playground/client/src/features/smart-dashboard/components/kpi-cards.tsx index 07677d98d..58d0c658f 100644 --- a/apps/dev-playground/client/src/features/smart-dashboard/components/kpi-cards.tsx +++ b/apps/dev-playground/client/src/features/smart-dashboard/components/kpi-cards.tsx @@ -172,11 +172,8 @@ function formatNumber(n: number): string { /** Percent delta between the last `tail` window and the previous window. */ function windowedTrend(values: number[], tail: number): number | undefined { - // Drop nulls/undefined/NaN (e.g. days with no trips after a fare filter) and - // coerce everything to Number defensively — some drivers hand back DECIMAL - // columns as strings, and `0 + "12.35"` would silently string-concat and - // render "NaN%" once we tried to divide. - const clean = values.map((v) => Number(v)).filter(Number.isFinite); + // Drop nulls/undefined/NaN (e.g. days with no trips after a fare filter). + const clean = values.filter(Number.isFinite); if (clean.length < tail * 2) return undefined; const recent = clean.slice(-tail); const prior = clean.slice(-tail * 2, -tail); @@ -189,16 +186,10 @@ function windowedTrend(values: number[], tail: number): number | undefined { } export function KPICards({ data, sparklines, isLoading }: KPICardsProps) { - // Coerce on intake so downstream sparkline paths and trend math stay purely - // numeric — avoids surprises if a driver ever hands back DECIMAL-as-string. - const toNum = (v: unknown) => { - const n = Number(v); - return Number.isFinite(n) ? n : 0; - }; - const tripSeries = sparklines.map((r) => toNum(r.trip_count)); - const fareSeries = sparklines.map((r) => toNum(r.avg_fare)); - const distSeries = sparklines.map((r) => toNum(r.avg_distance)); - const revenueSeries = sparklines.map((r) => toNum(r.total_revenue)); + const tripSeries = sparklines.map((r) => r.trip_count); + const fareSeries = sparklines.map((r) => r.avg_fare); + const distSeries = sparklines.map((r) => r.avg_distance); + const revenueSeries = sparklines.map((r) => r.total_revenue); const TREND_WINDOW = 7; @@ -229,11 +220,8 @@ export function KPICards({ data, sparklines, isLoading }: KPICardsProps) { subtitle={ revenueSeries.length > 0 ? `$${formatNumber( - // Explicit Number() wrap on each accumulator step defends - // against a single stray string in the series silently - // turning the whole sum into a concatenated blob. revenueSeries.reduce( - (a, b) => a + (Number.isFinite(b) ? Number(b) : 0), + (a, b) => a + (Number.isFinite(b) ? b : 0), 0, ), )} revenue` diff --git a/docs/docs/development/type-generation.md b/docs/docs/development/type-generation.md index 8430a275d..cd0597401 100644 --- a/docs/docs/development/type-generation.md +++ b/docs/docs/development/type-generation.md @@ -157,6 +157,17 @@ resolve a real table. The sample value is used only at type-generation time; the runtime query still binds the actual parameter. See [SQL parameters → Sample values](../plugins/analytics.md#sample-values-for-type-generation). +### Result value types + +Numeric columns (`TINYINT`, `SMALLINT`, `INT`, `BIGINT`, `FLOAT`, `DOUBLE`, +`DECIMAL`) are typed `number` and `BOOLEAN` columns `boolean`. The warehouse +returns every JSON_ARRAY cell as a string, so the analytics query route converts +these columns to match. Date and timestamp columns stay ISO strings. A `BIGINT` +beyond `Number.MAX_SAFE_INTEGER` or a `DECIMAL` with more than ~15 significant +digits loses precision as a JS number; `CAST` such a column to `STRING` in SQL to +keep it exact (it is then typed `string`). Metric-view rows keep the warehouse's +string values; see [Metric-view types](#metric-view-types). + ## Using generated types Once types are generated, your IDE will provide autocomplete and type checking: diff --git a/packages/appkit/src/connectors/sql-warehouse/client.ts b/packages/appkit/src/connectors/sql-warehouse/client.ts index b980a66d8..77d989c40 100644 --- a/packages/appkit/src/connectors/sql-warehouse/client.ts +++ b/packages/appkit/src/connectors/sql-warehouse/client.ts @@ -53,6 +53,19 @@ function arrowColumnNames(response: StatementResponse): string[] | undefined { ); } +/** + * Manifest type names (`LONG`, `DECIMAL`, `BOOLEAN`, …), parallel to + * {@link arrowColumnNames}. Lets the analytics query route turn JSON_ARRAY's + * all-string cells into the JS types the generated query types promise. + */ +function columnTypeNames( + response: StatementResponse, +): Array | undefined { + const cols = response.manifest?.schema?.columns; + if (!cols || cols.length === 0) return undefined; + return cols.map((c) => c.typeName); +} + /** * Coerce the modular SDK's `bigint` row/byte counts back to `number` (the type * the legacy SDK used). AppKit never does arithmetic on these — they are purely @@ -1037,6 +1050,8 @@ export class SQLWarehouseConnector { result: { ...restResult, data: transformedData, + columnNames: arrowColumnNames(response), + columnTypes: columnTypeNames(response), }, }; } @@ -1084,6 +1099,7 @@ export class SQLWarehouseConnector { ...(response.result as ResultData & { attachment?: string; columnNames?: string[]; + columnTypes?: Array; }), // `statementId` is a top-level field, not on `ResultData` — carry it // onto the result (as the EXTERNAL_LINKS path does) so the route can @@ -1092,6 +1108,7 @@ export class SQLWarehouseConnector { // contract), sourced from the modular SDK's camelCase `statementId`. statement_id: response.statementId, columnNames, + columnTypes: columnTypeNames(response), }, }; } diff --git a/packages/appkit/src/connectors/sql-warehouse/tests/client.test.ts b/packages/appkit/src/connectors/sql-warehouse/tests/client.test.ts index 8344df2fc..4cb8cdb5b 100644 --- a/packages/appkit/src/connectors/sql-warehouse/tests/client.test.ts +++ b/packages/appkit/src/connectors/sql-warehouse/tests/client.test.ts @@ -121,6 +121,31 @@ describe("SQLWarehouseConnector._transformDataArray", () => { const result = await transform(connector, response); expect(result.result.data[0].metadata).toEqual({ key: "value" }); }); + + test("attaches manifest column names and types, leaving cells as delivered", async () => { + const connector = createConnector(); + const response = { + statementId: "stmt-1", + status: { state: "SUCCEEDED" }, + manifest: { + format: "JSON_ARRAY", + schema: { + columns: [ + { name: "trips", typeText: "BIGINT", typeName: "LONG" }, + { name: "", typeText: "BOOLEAN", typeName: "BOOLEAN" }, + ], + }, + }, + result: { + dataArray: [["42", "true"]], + }, + } as unknown as StatementResponse; + + const result = await transform(connector, response); + expect(result.result.data).toEqual([{ trips: "42", column_1: "true" }]); + expect(result.result.columnNames).toEqual(["trips", "column_1"]); + expect(result.result.columnTypes).toEqual(["LONG", "BOOLEAN"]); + }); }); describe("classic warehouse (EXTERNAL_LINKS + ARROW_STREAM)", () => { diff --git a/packages/appkit/src/plugins/analytics/analytics.ts b/packages/appkit/src/plugins/analytics/analytics.ts index 1d18aae6d..68d403944 100644 --- a/packages/appkit/src/plugins/analytics/analytics.ts +++ b/packages/appkit/src/plugins/analytics/analytics.ts @@ -403,12 +403,14 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider { // warehouse only accepts ARROW_STREAM for INLINE, retries as // ARROW_STREAM and decodes server-side — returning the SSE // `result` message with plain rows. (ARROW_STREAM requests are - // handled earlier via `_handleArrowStreamQuery`.) + // handled earlier via `_handleArrowStreamQuery`.) Numeric and + // BOOLEAN cells are coerced to match the generated query types. return await self._executeJsonArrayPath( executor, query, processedParams, sig, + { coerceScalars: true }, ); } catch (err) { originalError = err; @@ -764,12 +766,14 @@ export class AnalyticsPlugin extends Plugin implements ToolProvider { | Record | undefined, signal?: AbortSignal, + options?: { coerceScalars?: boolean }, ): Promise { const result = await deliverJsonResult( executor, query, processedParams, signal, + options, ); return makeResultMessage(result.data, { status: result.status, diff --git a/packages/appkit/src/plugins/analytics/result-delivery.ts b/packages/appkit/src/plugins/analytics/result-delivery.ts index 3f40439d2..344062a8b 100644 --- a/packages/appkit/src/plugins/analytics/result-delivery.ts +++ b/packages/appkit/src/plugins/analytics/result-delivery.ts @@ -41,6 +41,7 @@ export interface QueryExecutor { data?: Record[]; external_links?: ExternalLink[]; columnNames?: string[]; + columnTypes?: Array; statement_id?: string; status?: unknown; refreshChunkLink?: RefreshChunkLink; @@ -294,13 +295,27 @@ interface JsonResult { * attachment to plain rows server-side so the caller's JSON contract holds. * * EXTERNAL_LINKS is never used for the JSON fallback. + * + * Both paths yield the warehouse's all-string scalar cells. `coerceScalars` + * converts numeric and BOOLEAN columns to JS numbers/booleans (see + * {@link coerceScalarCells}); the query route sets it, the metric route keeps + * its documented `string | null` wire values. */ export async function deliverJsonResult( executor: QueryExecutor, query: string, processedParams: Record | undefined, signal?: AbortSignal, + options: { coerceScalars?: boolean } = {}, ): Promise { + const coerce = ( + rows: Record[] | undefined, + result: Awaited>, + ) => + options.coerceScalars + ? coerceScalarCells(rows, result?.columnNames, result?.columnTypes) + : rows; + try { const result = await executor.query( query, @@ -309,7 +324,7 @@ export async function deliverJsonResult( signal, ); return { - data: result?.data, + data: coerce(result?.data, result), status: result?.status, statement_id: result?.statement_id, }; @@ -336,12 +351,57 @@ export async function deliverJsonResult( arrowResult.columnNames, ); return { - data: rows, + data: coerce(rows, arrowResult), status: arrowResult.status, statement_id: arrowResult.statement_id, }; } +/** Manifest `typeName`s whose JSON_ARRAY string cells parse to a JS number. */ +const NUMERIC_TYPE_NAMES = new Set([ + "BYTE", + "SHORT", + "INT", + "LONG", + "FLOAT", + "DOUBLE", + "DECIMAL", +]); + +/** + * Convert JSON_ARRAY's string cells for numeric and BOOLEAN columns to JS + * numbers/booleans so rows match the generated query types (`number` / + * `boolean`). `columnNames`/`columnTypes` are the parallel manifest arrays the + * connector attaches; without them rows are returned unchanged. Mutates the + * freshly built rows in place. NULL cells stay `null`; nested types stay as + * delivered. + * + * shortcut: LONG beyond 2^53 and DECIMAL beyond ~15 significant digits lose + * precision as JS numbers; switch those to string/bigint if apps need exact values. + */ +export function coerceScalarCells( + rows: Record[] | undefined, + columnNames: string[] | undefined, + columnTypes: Array | undefined, +): Record[] | undefined { + if (!rows || !columnNames || !columnTypes) return rows; + const coercions: Array<[string, (v: string) => number | boolean]> = []; + columnNames.forEach((name, i) => { + const typeName = columnTypes[i]; + if (typeName === "BOOLEAN") coercions.push([name, (v) => v === "true"]); + else if (typeName && NUMERIC_TYPE_NAMES.has(typeName)) + coercions.push([name, Number]); + }); + if (coercions.length === 0) return rows; + for (const row of rows) { + for (const [name, convert] of coercions) { + const value = row[name]; + if (typeof value === "string") row[name] = convert(value); + } + } + return rows; +} + // ──────────────────────────────────────────────────────────────────────────── // Server-side Arrow → JSON row materializer (JSON fallback only) // ──────────────────────────────────────────────────────────────────────────── diff --git a/packages/appkit/src/plugins/analytics/tests/analytics.test.ts b/packages/appkit/src/plugins/analytics/tests/analytics.test.ts index 10bd01cc6..a03d9cfc0 100644 --- a/packages/appkit/src/plugins/analytics/tests/analytics.test.ts +++ b/packages/appkit/src/plugins/analytics/tests/analytics.test.ts @@ -729,6 +729,41 @@ describe("Analytics Plugin", () => { }); }); + test("/query/:query_key converts JSON_ARRAY numeric and BOOLEAN cells to match the generated query types", async () => { + const plugin = new AnalyticsPlugin(config); + const { router, getHandler } = createMockRouter(); + (plugin as any).app.getAppQuery = vi.fn().mockResolvedValue({ + query: "SELECT * FROM test", + isAsUser: false, + }); + // Connector output for a real JSON_ARRAY response: every cell a string, + // plus the manifest names/types the connector attaches. + (plugin as any).SQLClient.executeStatement = vi.fn().mockResolvedValue({ + result: { + data: [{ trips: "42", fare: "12.50", ok: "true", zip: "10001" }], + columnNames: ["trips", "fare", "ok", "zip"], + columnTypes: ["LONG", "DECIMAL", "BOOLEAN", "STRING"], + }, + }); + + plugin.injectRoutes(router); + const handler = getHandler("POST", "/query/:query_key"); + const mockRes = createMockResponse(); + await handler( + createMockRequest({ + params: { query_key: "test_query" }, + body: { parameters: {} }, + }), + mockRes, + ); + + expect(mockRes.write).toHaveBeenCalledWith( + expect.stringContaining( + '"data":[{"trips":42,"fare":12.5,"ok":true,"zip":"10001"}]', + ), + ); + }); + test("/query/:query_key falls back ARROW_STREAM INLINE→EXTERNAL_LINKS and streams the preserved links in-context", async () => { const plugin = new AnalyticsPlugin(config); const { router, getHandler } = createMockRouter(); diff --git a/packages/appkit/src/plugins/analytics/tests/metric.test.ts b/packages/appkit/src/plugins/analytics/tests/metric.test.ts index 07b83e449..bf7e1c82b 100644 --- a/packages/appkit/src/plugins/analytics/tests/metric.test.ts +++ b/packages/appkit/src/plugins/analytics/tests/metric.test.ts @@ -713,6 +713,42 @@ describe("analytics metric route", () => { expect(mockRes.end).toHaveBeenCalled(); }); + test("keeps JSON_ARRAY string cells (metric rows are typed string | null)", async () => { + const plugin = pluginForDir( + config, + registryDir({ + revenue: { + key: "revenue", + source: "cat.sch.revenue_metrics", + lane: "sp", + }, + }), + ); + const { router, getHandler } = createMockRouter(); + (plugin as any).SQLClient.executeStatement = vi.fn().mockResolvedValue({ + result: { + data: [{ arr: "1234" }], + columnNames: ["arr"], + columnTypes: ["LONG"], + }, + }); + + plugin.injectRoutes(router); + const handler = getHandler("POST", "/metric/:key"); + const mockRes = createMockResponse(); + await handler( + createMockRequest({ + params: { key: "revenue" }, + body: { measures: ["arr"] }, + }), + mockRes, + ); + + expect(mockRes.write).toHaveBeenCalledWith( + expect.stringContaining('"data":[{"arr":"1234"}]'), + ); + }); + test("emits warehouse_status before result", async () => { const plugin = pluginForDir( config, diff --git a/packages/appkit/src/plugins/analytics/tests/result-delivery.test.ts b/packages/appkit/src/plugins/analytics/tests/result-delivery.test.ts index 06438b5a5..7e6f7108e 100644 --- a/packages/appkit/src/plugins/analytics/tests/result-delivery.test.ts +++ b/packages/appkit/src/plugins/analytics/tests/result-delivery.test.ts @@ -6,6 +6,7 @@ import { type ArrowChunkStreamer, arrowDeliveryUnsupported, classifyDispositionRejection, + coerceScalarCells, decodeArrowAttachmentToRows, deliverArrowBytes, deliverJsonResult, @@ -424,3 +425,75 @@ describe("decodeArrowAttachmentToRows — captured Reyden fixtures", () => { expect(rows).toEqual([REYDEN_NESTED_FIXTURE.expected]); }); }); + +describe("deliverJsonResult — coerceScalars", () => { + const nativeResult = () => ({ + data: [ + { big: "42", dec: "1.50", dbl: "2.5", ok: "true", name: "7", nul: null }, + { big: "-1", dec: "0.00", dbl: "1e3", ok: "false", name: "x", nul: null }, + ], + columnNames: ["big", "dec", "dbl", "ok", "name", "nul"], + columnTypes: ["LONG", "DECIMAL", "DOUBLE", "BOOLEAN", "STRING", "LONG"], + }); + + test("converts numeric and BOOLEAN cells on the native JSON_ARRAY path", async () => { + const { executor } = executorFrom(async () => nativeResult()); + const result = await deliverJsonResult( + executor, + "SELECT 1", + undefined, + undefined, + { coerceScalars: true }, + ); + expect(result.data).toEqual([ + { big: 42, dec: 1.5, dbl: 2.5, ok: true, name: "7", nul: null }, + { big: -1, dec: 0, dbl: 1000, ok: false, name: "x", nul: null }, + ]); + }); + + test("leaves cells as delivered without the option", async () => { + const { executor } = executorFrom(async () => nativeResult()); + const result = await deliverJsonResult(executor, "SELECT 1", undefined); + expect(result.data).toEqual(nativeResult().data); + }); + + test("converts ARROW_STREAM fallback rows the same way; temporal and nested cells stay as delivered", async () => { + const { executor } = executorFrom(async (fp) => { + if (fp.format === "JSON_ARRAY") { + throw reject( + "INVALID_PARAMETER_VALUE", + "Inline disposition only supports ARROW_STREAM format.", + ); + } + return { + attachment: REYDEN_SCALAR_FIXTURE.attachment, + columnNames: [...REYDEN_SCALAR_FIXTURE.columns], + columnTypes: [ + "DECIMAL", + "DECIMAL", + "TIMESTAMP", + "TIMESTAMP_NTZ", + "DATE", + "STRUCT", + "ARRAY", + ], + }; + }); + const result = await deliverJsonResult( + executor, + "SELECT 1", + undefined, + undefined, + { coerceScalars: true }, + ); + expect(result.data).toEqual([ + { ...REYDEN_SCALAR_FIXTURE.expected, amt: 123.45, neg: -0.99 }, + ]); + }); + + test("returns rows unchanged when the manifest names or types are missing", () => { + const rows = [{ n: "1" }]; + expect(coerceScalarCells(rows, undefined, ["LONG"])).toEqual([{ n: "1" }]); + expect(coerceScalarCells(rows, ["n"], undefined)).toEqual([{ n: "1" }]); + }); +}); From 623aa149fc44192c3a4ac3dcc15cdbc2e512151f Mon Sep 17 00:00:00 2001 From: Fabian Jakobs Date: Fri, 6 Nov 2026 15:52:18 +0000 Subject: [PATCH 2/2] fix(appkit): keep NaN and Infinity analytics cells as strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under JSON_ARRAY the warehouse returns DOUBLE and FLOAT NaN and ±Infinity as "NaN", "Infinity" and "-Infinity". The /query coercion turned them into NaN/Infinity numbers, which the SSE JSON then sent as null, the same value as SQL NULL. Numeric cells that don't parse to a finite number now keep the warehouse string, since JSON has no number for them. Co-authored-by: Isaac Signed-off-by: Fabian Jakobs --- docs/docs/development/type-generation.md | 4 +++- .../appkit/src/plugins/analytics/result-delivery.ts | 12 +++++++++--- .../src/plugins/analytics/tests/analytics.test.ts | 10 ++++++---- .../plugins/analytics/tests/result-delivery.test.ts | 13 +++++++++++++ 4 files changed, 31 insertions(+), 8 deletions(-) diff --git a/docs/docs/development/type-generation.md b/docs/docs/development/type-generation.md index cd0597401..17646e290 100644 --- a/docs/docs/development/type-generation.md +++ b/docs/docs/development/type-generation.md @@ -162,7 +162,9 @@ runtime query still binds the actual parameter. See Numeric columns (`TINYINT`, `SMALLINT`, `INT`, `BIGINT`, `FLOAT`, `DOUBLE`, `DECIMAL`) are typed `number` and `BOOLEAN` columns `boolean`. The warehouse returns every JSON_ARRAY cell as a string, so the analytics query route converts -these columns to match. Date and timestamp columns stay ISO strings. A `BIGINT` +these columns to match. `NaN` and `±Infinity` in `DOUBLE` and `FLOAT` columns +have no JSON number, so they stay the strings `"NaN"`, `"Infinity"` and +`"-Infinity"`. Date and timestamp columns stay ISO strings. A `BIGINT` beyond `Number.MAX_SAFE_INTEGER` or a `DECIMAL` with more than ~15 significant digits loses precision as a JS number; `CAST` such a column to `STRING` in SQL to keep it exact (it is then typed `string`). Metric-view rows keep the warehouse's diff --git a/packages/appkit/src/plugins/analytics/result-delivery.ts b/packages/appkit/src/plugins/analytics/result-delivery.ts index 344062a8b..66d25ccfd 100644 --- a/packages/appkit/src/plugins/analytics/result-delivery.ts +++ b/packages/appkit/src/plugins/analytics/result-delivery.ts @@ -374,7 +374,8 @@ const NUMERIC_TYPE_NAMES = new Set([ * `boolean`). `columnNames`/`columnTypes` are the parallel manifest arrays the * connector attaches; without them rows are returned unchanged. Mutates the * freshly built rows in place. NULL cells stay `null`; nested types stay as - * delivered. + * delivered. DOUBLE/FLOAT `"NaN"`, `"Infinity"` and `"-Infinity"` stay strings: + * JSON has no number for them, and the SSE JSON would send `null` (SQL NULL). * * shortcut: LONG beyond 2^53 and DECIMAL beyond ~15 significant digits lose * precision as JS numbers; switch those to string/bigint if apps need exact values. @@ -385,12 +386,17 @@ export function coerceScalarCells( columnTypes: Array | undefined, ): Record[] | undefined { if (!rows || !columnNames || !columnTypes) return rows; - const coercions: Array<[string, (v: string) => number | boolean]> = []; + const toNumber = (v: string) => { + const n = Number(v); + return Number.isFinite(n) ? n : v; + }; + const coercions: Array<[string, (v: string) => number | boolean | string]> = + []; columnNames.forEach((name, i) => { const typeName = columnTypes[i]; if (typeName === "BOOLEAN") coercions.push([name, (v) => v === "true"]); else if (typeName && NUMERIC_TYPE_NAMES.has(typeName)) - coercions.push([name, Number]); + coercions.push([name, toNumber]); }); if (coercions.length === 0) return rows; for (const row of rows) { diff --git a/packages/appkit/src/plugins/analytics/tests/analytics.test.ts b/packages/appkit/src/plugins/analytics/tests/analytics.test.ts index a03d9cfc0..bf66b18f1 100644 --- a/packages/appkit/src/plugins/analytics/tests/analytics.test.ts +++ b/packages/appkit/src/plugins/analytics/tests/analytics.test.ts @@ -740,9 +740,11 @@ describe("Analytics Plugin", () => { // plus the manifest names/types the connector attaches. (plugin as any).SQLClient.executeStatement = vi.fn().mockResolvedValue({ result: { - data: [{ trips: "42", fare: "12.50", ok: "true", zip: "10001" }], - columnNames: ["trips", "fare", "ok", "zip"], - columnTypes: ["LONG", "DECIMAL", "BOOLEAN", "STRING"], + data: [ + { trips: "42", fare: "12.50", ok: "true", zip: "10001", r: "NaN" }, + ], + columnNames: ["trips", "fare", "ok", "zip", "r"], + columnTypes: ["LONG", "DECIMAL", "BOOLEAN", "STRING", "DOUBLE"], }, }); @@ -759,7 +761,7 @@ describe("Analytics Plugin", () => { expect(mockRes.write).toHaveBeenCalledWith( expect.stringContaining( - '"data":[{"trips":42,"fare":12.5,"ok":true,"zip":"10001"}]', + '"data":[{"trips":42,"fare":12.5,"ok":true,"zip":"10001","r":"NaN"}]', ), ); }); diff --git a/packages/appkit/src/plugins/analytics/tests/result-delivery.test.ts b/packages/appkit/src/plugins/analytics/tests/result-delivery.test.ts index 7e6f7108e..f4408a184 100644 --- a/packages/appkit/src/plugins/analytics/tests/result-delivery.test.ts +++ b/packages/appkit/src/plugins/analytics/tests/result-delivery.test.ts @@ -496,4 +496,17 @@ describe("deliverJsonResult — coerceScalars", () => { expect(coerceScalarCells(rows, undefined, ["LONG"])).toEqual([{ n: "1" }]); expect(coerceScalarCells(rows, ["n"], undefined)).toEqual([{ n: "1" }]); }); + + test("keeps NaN and ±Infinity as the warehouse strings (JSON has no number for them)", () => { + const rows = [ + { d: "NaN", f: "NaN" }, + { d: "Infinity", f: "1.25" }, + { d: "-Infinity", f: "1.0E20" }, + ]; + expect(coerceScalarCells(rows, ["d", "f"], ["DOUBLE", "FLOAT"])).toEqual([ + { d: "NaN", f: "NaN" }, + { d: "Infinity", f: 1.25 }, + { d: "-Infinity", f: 1e20 }, + ]); + }); });