Skip to content

Commit 87e0a30

Browse files
committed
feat(sea): TypedValue codec converter for positional params
Add toNapiTypedValue() and convertOrdinalParametersToTypedValueInputs() helpers that translate the Node driver's user-facing { ordinalParameters: [...] } shape into the flat { sqlType, value: string | null } payload the SEA napi codec (databricks-sql-kernel/napi/src/params.rs) consumes. The stringification rules (boolean -> "TRUE"/"FALSE", Date -> toISOString(), BigInt/Int64 -> .toString(), integer Number -> "INTEGER", non-integer Number -> "DOUBLE") are kept in lock-step with the Thrift emitter's toSparkParameter, so a positional value bound on either backend hits the server with the same wire-level (type-name, value) pair. A regression-lock test pins this alignment branch-by-branch. This is the JS-side half of the C5 (params-basic-binding) cluster. The kernel-napi half lands in databricks-sql-kernel PR #84. SEA-side wiring of these helpers into Connection.executeStatement(sql, options) happens once the SEA backend abstraction (PR #378) lands on main. Co-authored-by: Isaac Signed-off-by: Madhavendra Rathore <madhavendra.rathore@databricks.com>
1 parent e200a1b commit 87e0a30

2 files changed

Lines changed: 291 additions & 1 deletion

File tree

lib/DBSQLParameter.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,37 @@ import { TSparkParameter, TSparkParameterValue } from '../thrift/TCLIService_typ
33

44
export type DBSQLParameterValue = undefined | null | boolean | number | bigint | Int64 | Date | string;
55

6+
/**
7+
* Wire shape expected by the SEA napi codec
8+
* (`databricks-sql-kernel/napi/src/params.rs::TypedValueInput`). The Rust
9+
* side parses `value` per `sqlType`; we stringify the JS value the same
10+
* way `toSparkParameter` does for Thrift, then hand the pair to napi.
11+
*
12+
* Why a parallel converter rather than re-using `toSparkParameter` and
13+
* unwrapping the wire-Thrift `TSparkParameter`: napi consumes a flat
14+
* `{ sqlType, value: string | null }` POJO. Mining the value out of a
15+
* `TSparkParameter` (which wraps it in `TSparkParameterValue.stringValue`
16+
* and may use `name` for named-binding mode) requires more glue than
17+
* just emitting the SEA shape directly. The two emitters share the
18+
* same stringification rules — boolean → "TRUE"/"FALSE", Date →
19+
* `toISOString()`, etc.
20+
*/
21+
export interface TypedValueInput {
22+
/**
23+
* Canonical Databricks SQL type name (`"INT"`, `"STRING"`,
24+
* `"DECIMAL(10,2)"`, …). The napi codec is case-insensitive for the
25+
* simple variants and requires the parenthesised form for DECIMAL.
26+
*/
27+
sqlType: string;
28+
/**
29+
* String-encoded literal, or `null` for SQL NULL. The Rust codec
30+
* short-circuits to `TypedValue::Null` regardless of `sqlType` when
31+
* this is `null` — matches the Thrift `TSparkParameter(name)` (no
32+
* type, no value) shape on the wire.
33+
*/
34+
value: string | null;
35+
}
36+
637
export enum DBSQLParameterType {
738
VOID = 'VOID', // aka NULL
839
STRING = 'STRING',
@@ -98,4 +129,100 @@ export class DBSQLParameter {
98129
}),
99130
});
100131
}
132+
133+
/**
134+
* SEA-backend sibling of `toSparkParameter`. Emits the flat
135+
* `{ sqlType, value }` shape the napi codec consumes.
136+
*
137+
* Stringification rules are kept in lock-step with `toSparkParameter`
138+
* so a positional parameter bound on the Thrift backend and on the
139+
* SEA backend hits the server with the same wire-level type-name +
140+
* value string. Divergence between the two emitters here would
141+
* silently re-introduce the kind of cross-backend behavior split the
142+
* driver-test parity suite is built to catch.
143+
*
144+
* @returns null for SQL NULL (caller is expected to emit it as
145+
* `{ sqlType: "VOID", value: null }` or to skip the entry,
146+
* depending on call-site convention). This method itself never
147+
* throws; unsupported value shapes fall through the STRING default
148+
* to match the Thrift emitter's behaviour.
149+
*/
150+
public toNapiTypedValue(): TypedValueInput {
151+
// VOID literal — explicit NULL, the napi codec short-circuits.
152+
if (this.type === DBSQLParameterType.VOID) {
153+
return { sqlType: 'VOID', value: null };
154+
}
155+
156+
// Inferred NULL — same shape as VOID. The napi codec's contract is
157+
// that `value: null` produces `TypedValue::Null` regardless of
158+
// sqlType, so any non-empty sqlType would work here; we emit VOID
159+
// for consistency with the explicit-NULL path above.
160+
if (this.value === undefined || this.value === null) {
161+
return { sqlType: 'VOID', value: null };
162+
}
163+
164+
if (typeof this.value === 'boolean') {
165+
return {
166+
sqlType: this.type ?? DBSQLParameterType.BOOLEAN,
167+
// Thrift emits "TRUE" / "FALSE"; the napi `parse_bool` accepts
168+
// both "true"/"false" and "TRUE"/"FALSE" via its case-insensitive
169+
// match. Keep the casing aligned with the Thrift emitter so any
170+
// log scrape that diffs the two wires sees identical strings.
171+
value: this.value ? 'TRUE' : 'FALSE',
172+
};
173+
}
174+
175+
if (typeof this.value === 'number') {
176+
return {
177+
sqlType: this.type ?? (Number.isInteger(this.value) ? DBSQLParameterType.INTEGER : DBSQLParameterType.DOUBLE),
178+
value: Number(this.value).toString(),
179+
};
180+
}
181+
182+
if (this.value instanceof Int64 || typeof this.value === 'bigint') {
183+
return {
184+
sqlType: this.type ?? DBSQLParameterType.BIGINT,
185+
value: this.value.toString(),
186+
};
187+
}
188+
189+
if (this.value instanceof Date) {
190+
return {
191+
sqlType: this.type ?? DBSQLParameterType.TIMESTAMP,
192+
value: this.value.toISOString(),
193+
};
194+
}
195+
196+
return {
197+
sqlType: this.type ?? DBSQLParameterType.STRING,
198+
value: this.value,
199+
};
200+
}
201+
}
202+
203+
/**
204+
* Convert the user-facing `ordinalParameters` array into the flat
205+
* `TypedValueInput[]` shape the SEA napi codec accepts.
206+
*
207+
* Mirrors the ordinal-only branch of `lib/DBSQLSession.ts::getQueryParameters`
208+
* — entries may be a `DBSQLParameter` instance or a bare value, and a
209+
* bare value is wrapped in a `DBSQLParameter` for emission. The wrapping
210+
* path is the load-bearing one (today the Node driver's public surface
211+
* accepts bare JS values for positional binding); this helper is the
212+
* single source of truth for how those bare values stringify against
213+
* the napi codec.
214+
*
215+
* Returns an empty array for an undefined / empty input. The caller is
216+
* expected to fall back to a no-positional-params execute in that case.
217+
*/
218+
export function convertOrdinalParametersToTypedValueInputs(
219+
ordinalParameters?: Array<DBSQLParameter | DBSQLParameterValue>,
220+
): Array<TypedValueInput> {
221+
if (ordinalParameters === undefined || ordinalParameters.length === 0) {
222+
return [];
223+
}
224+
return ordinalParameters.map((value) => {
225+
const param = value instanceof DBSQLParameter ? value : new DBSQLParameter({ value });
226+
return param.toNapiTypedValue();
227+
});
101228
}

tests/unit/DBSQLParameter.test.ts

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { expect } from 'chai';
22
import Int64 from 'node-int64';
33
import { TSparkParameterValue, TSparkParameter } from '../../thrift/TCLIService_types';
4-
import { DBSQLParameter, DBSQLParameterType, DBSQLParameterValue } from '../../lib/DBSQLParameter';
4+
import {
5+
DBSQLParameter,
6+
DBSQLParameterType,
7+
DBSQLParameterValue,
8+
TypedValueInput,
9+
convertOrdinalParametersToTypedValueInputs,
10+
} from '../../lib/DBSQLParameter';
511

612
describe('DBSQLParameter', () => {
713
it('should infer types correctly', () => {
@@ -101,4 +107,161 @@ describe('DBSQLParameter', () => {
101107
expect(dbsqlParam.toSparkParameter()).to.deep.equal(expectedParam);
102108
}
103109
});
110+
111+
// ─── toNapiTypedValue (SEA-backend codec input) ─────────────────────
112+
//
113+
// These tests pin the wire-level alignment between the Thrift emitter
114+
// (`toSparkParameter`) and the SEA emitter (`toNapiTypedValue`).
115+
// Divergence between the two is a parity regression — see C5 cluster
116+
// in sea-workflow/decisions/2026-05-28-autonomous-parity-decisions.md.
117+
118+
describe('toNapiTypedValue', () => {
119+
it('infers types in sync with toSparkParameter (10-type C5 matrix)', () => {
120+
const cases: Array<[DBSQLParameterValue, TypedValueInput]> = [
121+
// BOOLEAN — note "TRUE"/"FALSE" casing matches Thrift; the napi
122+
// codec's parse_bool is case-insensitive so this is wire-safe.
123+
[false, { sqlType: DBSQLParameterType.BOOLEAN, value: 'FALSE' }],
124+
[true, { sqlType: DBSQLParameterType.BOOLEAN, value: 'TRUE' }],
125+
// INTEGER from a plain JS number (`Number.isInteger(123) === true`).
126+
[123, { sqlType: DBSQLParameterType.INTEGER, value: '123' }],
127+
// DOUBLE from a non-integer JS number.
128+
[3.14, { sqlType: DBSQLParameterType.DOUBLE, value: '3.14' }],
129+
// BIGINT from JS bigint.
130+
[BigInt(9999999999), { sqlType: DBSQLParameterType.BIGINT, value: '9999999999' }],
131+
// BIGINT from Int64 (the node-int64 path the Thrift driver uses).
132+
[new Int64(1234), { sqlType: DBSQLParameterType.BIGINT, value: '1234' }],
133+
// TIMESTAMP from a JS Date — `.toISOString()` emits the trailing
134+
// `Z`, the napi codec strips it before NaiveDateTime parsing.
135+
[
136+
new Date('2023-09-06T03:14:27.843Z'),
137+
{ sqlType: DBSQLParameterType.TIMESTAMP, value: '2023-09-06T03:14:27.843Z' },
138+
],
139+
// STRING fallback.
140+
['Hello', { sqlType: DBSQLParameterType.STRING, value: 'Hello' }],
141+
];
142+
for (const [value, expected] of cases) {
143+
const param = new DBSQLParameter({ value });
144+
expect(param.toNapiTypedValue()).to.deep.equal(expected);
145+
}
146+
});
147+
148+
it('honours an explicitly-set type', () => {
149+
const customType = 'DECIMAL(10,2)' as DBSQLParameterType;
150+
const param = new DBSQLParameter({ type: customType, value: '-123.45' });
151+
expect(param.toNapiTypedValue()).to.deep.equal({
152+
sqlType: 'DECIMAL(10,2)',
153+
value: '-123.45',
154+
});
155+
});
156+
157+
it('emits VOID/null for an explicit VOID type regardless of value', () => {
158+
const param = new DBSQLParameter({ type: DBSQLParameterType.VOID, value: 'ignored' });
159+
expect(param.toNapiTypedValue()).to.deep.equal({ sqlType: 'VOID', value: null });
160+
});
161+
162+
it('emits VOID/null for an undefined or null value', () => {
163+
for (const value of [undefined, null] as Array<DBSQLParameterValue>) {
164+
const param = new DBSQLParameter({ value });
165+
expect(param.toNapiTypedValue()).to.deep.equal({ sqlType: 'VOID', value: null });
166+
}
167+
});
168+
169+
it('alignment regression-lock: every type-inference branch matches the Thrift emitter', () => {
170+
// Every value the Thrift emitter handles must round-trip to the
171+
// same wire-level (sqlType, stringValue) pair through the napi
172+
// emitter. This is the one-liner that catches a future
173+
// refactor that diverges the two stringification paths.
174+
const values: Array<DBSQLParameterValue> = [
175+
false,
176+
true,
177+
0,
178+
1,
179+
-1,
180+
3.14,
181+
BigInt(0),
182+
new Int64(0),
183+
new Date(0),
184+
'',
185+
];
186+
for (const value of values) {
187+
const param = new DBSQLParameter({ value });
188+
const thrift = param.toSparkParameter();
189+
const napi = param.toNapiTypedValue();
190+
expect(napi.sqlType, `sqlType for ${String(value)}`).to.equal(thrift.type);
191+
expect(napi.value, `value for ${String(value)}`).to.equal(thrift.value?.stringValue);
192+
}
193+
});
194+
});
195+
196+
describe('convertOrdinalParametersToTypedValueInputs', () => {
197+
it('returns [] for undefined input', () => {
198+
expect(convertOrdinalParametersToTypedValueInputs(undefined)).to.deep.equal([]);
199+
});
200+
201+
it('returns [] for an empty array', () => {
202+
expect(convertOrdinalParametersToTypedValueInputs([])).to.deep.equal([]);
203+
});
204+
205+
it('wraps bare JS values in a DBSQLParameter for stringification', () => {
206+
const got = convertOrdinalParametersToTypedValueInputs([42, 'hello', true, null]);
207+
expect(got).to.deep.equal([
208+
{ sqlType: DBSQLParameterType.INTEGER, value: '42' },
209+
{ sqlType: DBSQLParameterType.STRING, value: 'hello' },
210+
{ sqlType: DBSQLParameterType.BOOLEAN, value: 'TRUE' },
211+
// null → VOID/null per the toNapiTypedValue contract.
212+
{ sqlType: 'VOID', value: null },
213+
]);
214+
});
215+
216+
it('passes DBSQLParameter instances through with their explicit type', () => {
217+
const decimal = new DBSQLParameter({
218+
type: 'DECIMAL(10,2)' as DBSQLParameterType,
219+
value: '-123.45',
220+
});
221+
const got = convertOrdinalParametersToTypedValueInputs([decimal, BigInt('9999999999')]);
222+
expect(got).to.deep.equal([
223+
{ sqlType: 'DECIMAL(10,2)', value: '-123.45' },
224+
{ sqlType: DBSQLParameterType.BIGINT, value: '9999999999' },
225+
]);
226+
});
227+
228+
it('preserves order — index i in the input is index i on the wire', () => {
229+
// The napi codec's `positional_params` is 1-based (index i ↔
230+
// ordinal i+1). The JS adapter is responsible for preserving the
231+
// input ordering — this test pins that contract.
232+
const got = convertOrdinalParametersToTypedValueInputs([1, 2, 3, 4, 5]);
233+
expect(got.map((x) => x.value)).to.deep.equal(['1', '2', '3', '4', '5']);
234+
});
235+
236+
it('c5 ten-type matrix — one positional value per basic SQL type', () => {
237+
// Mirrors the kernel-napi `c5_ten_type_matrix_round_trips` test
238+
// (databricks-sql-kernel/napi/src/params.rs) one-for-one. The
239+
// wire-level (sqlType, value) pairs emitted here must be the
240+
// exact input the kernel codec accepts. BINARY is omitted —
241+
// the napi codec rejects it at bind time, see the cross-driver
242+
// validation reply from python-sea-oracle for the contract.
243+
const inputs: Array<DBSQLParameter | DBSQLParameterValue> = [
244+
new DBSQLParameter({ type: DBSQLParameterType.INTEGER, value: 42 }),
245+
new DBSQLParameter({ type: DBSQLParameterType.BIGINT, value: BigInt('9999999999') }),
246+
new DBSQLParameter({ type: DBSQLParameterType.FLOAT, value: 3.14 }),
247+
new DBSQLParameter({ type: DBSQLParameterType.DOUBLE, value: 2.718281828459045 }),
248+
new DBSQLParameter({ type: DBSQLParameterType.STRING, value: 'hello world' }),
249+
new DBSQLParameter({ type: DBSQLParameterType.BOOLEAN, value: true }),
250+
new DBSQLParameter({ type: DBSQLParameterType.DATE, value: '2026-05-15' }),
251+
new DBSQLParameter({ type: DBSQLParameterType.TIMESTAMP, value: '2026-05-15 12:30:45' }),
252+
new DBSQLParameter({ type: 'DECIMAL(10,2)' as DBSQLParameterType, value: '-123.45' }),
253+
];
254+
const got = convertOrdinalParametersToTypedValueInputs(inputs);
255+
expect(got).to.have.lengthOf(9);
256+
expect(got[0]).to.deep.equal({ sqlType: 'INTEGER', value: '42' });
257+
expect(got[1]).to.deep.equal({ sqlType: 'BIGINT', value: '9999999999' });
258+
expect(got[2]).to.deep.equal({ sqlType: 'FLOAT', value: '3.14' });
259+
expect(got[3]).to.deep.equal({ sqlType: 'DOUBLE', value: '2.718281828459045' });
260+
expect(got[4]).to.deep.equal({ sqlType: 'STRING', value: 'hello world' });
261+
expect(got[5]).to.deep.equal({ sqlType: 'BOOLEAN', value: 'TRUE' });
262+
expect(got[6]).to.deep.equal({ sqlType: 'DATE', value: '2026-05-15' });
263+
expect(got[7]).to.deep.equal({ sqlType: 'TIMESTAMP', value: '2026-05-15 12:30:45' });
264+
expect(got[8]).to.deep.equal({ sqlType: 'DECIMAL(10,2)', value: '-123.45' });
265+
});
266+
});
104267
});

0 commit comments

Comments
 (0)