Skip to content

Commit 3337234

Browse files
committed
feat(sea): wire positional query parameters through the napi binding
Removes the M0 param deferral: SeaSessionBackend.executeStatement now forwards positional `?` bindings to the kernel via the napi `ExecuteOptions.positionalParams` surface (kernel #84 / TypedValueInput `{ sqlType, value? }`). New SeaPositionalParams.ts reduces each `DBSQLParameter | value` to that shape — reusing DBSQLParameter's type-inference + stringification — adapting DECIMAL to the parenthesised `DECIMAL(p,s)` form the kernel codec requires and mapping NULL to a value-less VOID input. Also wires `queryTimeout` → `queryTimeoutSecs`. Named params stay rejected (positional-only on the napi surface today). Validated live: all PREPARED_STATEMENT_TYPES cases bind correctly (INT/BIGINT/DECIMAL/STRING/BOOLEAN/DATE/TIMESTAMP/NULL + interval CAST). 201 sea unit tests pass. Co-authored-by: Isaac Signed-off-by: Madhavendra Rathore <madhavendra.rathore@databricks.com>
1 parent 1f33e94 commit 3337234

5 files changed

Lines changed: 217 additions & 32 deletions

File tree

lib/sea/SeaNativeLoader.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,39 @@ export interface SeaNativeStatement {
7272
* napi-rs emits `string | undefined | null` for every Rust `Option<String>`
7373
* parameter — both `undefined` and `null` are accepted at the call site.
7474
*/
75+
/**
76+
* A single positional bound parameter in the napi `{ sqlType, value? }`
77+
* shape the kernel's param codec (`parse_typed_value`) accepts. `sqlType`
78+
* is the Databricks SQL type name (`INT`, `STRING`, `TIMESTAMP`, … and the
79+
* parenthesised `DECIMAL(p,s)`); a missing `value` is SQL NULL. Built by
80+
* `SeaPositionalParams.buildSeaPositionalParams`.
81+
*/
82+
export interface SeaNativeTypedValueInput {
83+
sqlType: string;
84+
value?: string;
85+
}
86+
87+
/**
88+
* Per-statement options accepted by the napi `executeStatement`. Matches
89+
* the kernel `ExecuteOptions`. All fields optional; an omitted/empty
90+
* object is the no-options fast path.
91+
*/
92+
export interface SeaNativeExecuteOptions {
93+
statementConf?: Record<string, string>;
94+
queryTags?: Record<string, string>;
95+
rowLimit?: number;
96+
queryTimeoutSecs?: number;
97+
positionalParams?: Array<SeaNativeTypedValueInput>;
98+
}
99+
75100
export interface SeaNativeConnection {
76101
/**
77102
* Execute a SQL statement. Catalog / schema / sessionConf are
78-
* session-level — set on `openSession`, applied to every statement
79-
* executed on the resulting `Connection`. No per-statement options.
103+
* session-level — set on `openSession`. Per-statement options (bound
104+
* positional parameters, row limit, query timeout, tags) ride on the
105+
* optional `options` argument.
80106
*/
81-
executeStatement(sql: string): Promise<SeaNativeStatement>;
107+
executeStatement(sql: string, options?: SeaNativeExecuteOptions): Promise<SeaNativeStatement>;
82108

83109
// ── Metadata methods ──────────────────────────────────────────────────
84110
/** All catalogs visible to the session. */

lib/sea/SeaPositionalParams.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright (c) 2026 Databricks, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import { DBSQLParameter, DBSQLParameterValue } from '../DBSQLParameter';
16+
import ParameterError from '../errors/ParameterError';
17+
import { SeaNativeTypedValueInput } from './SeaNativeLoader';
18+
19+
/**
20+
* Derive `(precision,scale)` from a decimal value string for the SEA
21+
* `DECIMAL(p,s)` type name — the kernel param codec requires the
22+
* parenthesised form (plain `"DECIMAL"` is rejected) so it can preserve
23+
* the caller's fractional digits. `"99.99"` ⇒ `"4,2"`; `"-123"` ⇒ `"3,0"`.
24+
* Clamped to the Databricks max precision of 38.
25+
*/
26+
function decimalPrecisionScale(v: string): string {
27+
const digits = (v.match(/\d/g) ?? []).length;
28+
const dot = v.indexOf('.');
29+
const scale = dot < 0 ? 0 : (v.slice(dot + 1).match(/\d/g) ?? []).length;
30+
const precision = Math.min(Math.max(digits, 1), 38);
31+
return `${precision},${Math.min(scale, precision)}`;
32+
}
33+
34+
/**
35+
* Reduce a `DBSQLParameter | DBSQLParameterValue` to the napi
36+
* `TypedValueInput` (`{ sqlType, value? }`) the kernel's positional-param
37+
* codec (`parse_typed_value`) accepts. Reuses `DBSQLParameter.toSparkParameter`
38+
* — the same type-inference + value-stringification the Thrift backend uses —
39+
* then adapts the type name to the codec's expectations:
40+
* - DECIMAL → `DECIMAL(p,s)` (parenthesised form required)
41+
* - INTERVAL * → `INTERVAL` (the codec's single interval type name)
42+
* - a missing value ⇒ SQL NULL (`parse_typed_value` maps `value: None` to NULL).
43+
*/
44+
function toTypedValueInput(value: DBSQLParameter | DBSQLParameterValue): SeaNativeTypedValueInput {
45+
const param = value instanceof DBSQLParameter ? value : new DBSQLParameter({ value });
46+
const spark = param.toSparkParameter();
47+
const stringValue = spark.value?.stringValue ?? undefined;
48+
49+
// NULL: no value (and `VOID` ignores any type), matching toSparkParameter's
50+
// type/value-less shape for null/undefined.
51+
if (stringValue === undefined || stringValue === null) {
52+
return { sqlType: 'VOID' };
53+
}
54+
55+
let sqlType = spark.type ?? 'STRING';
56+
const upper = sqlType.toUpperCase();
57+
if (upper === 'DECIMAL') {
58+
sqlType = `DECIMAL(${decimalPrecisionScale(stringValue)})`;
59+
} else if (upper.startsWith('INTERVAL')) {
60+
sqlType = 'INTERVAL';
61+
}
62+
return { sqlType, value: stringValue };
63+
}
64+
65+
/**
66+
* Convert the public `ordinalParameters` option into the napi
67+
* `positionalParams` array. Returns `undefined` when no positional params
68+
* were supplied (so the caller can keep the minimal no-options call shape).
69+
*
70+
* Named parameters are not yet bindable on the SEA path — the kernel napi
71+
* surface (`ExecuteOptions.positionalParams`) exposes positional only — so a
72+
* caller passing `namedParameters` is rejected with a clear `ParameterError`
73+
* rather than silently ignored.
74+
*/
75+
export function buildSeaPositionalParams(
76+
ordinalParameters?: Array<DBSQLParameter | DBSQLParameterValue>,
77+
): Array<SeaNativeTypedValueInput> | undefined {
78+
if (ordinalParameters === undefined || ordinalParameters.length === 0) {
79+
return undefined;
80+
}
81+
return ordinalParameters.map(toTypedValueInput);
82+
}

lib/sea/SeaSessionBackend.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,12 @@ import {
3131
import Status from '../dto/Status';
3232
import InfoValue from '../dto/InfoValue';
3333
import HiveDriverError from '../errors/HiveDriverError';
34-
import { SeaNativeConnection } from './SeaNativeLoader';
34+
import { SeaNativeConnection, SeaNativeExecuteOptions } from './SeaNativeLoader';
3535
import { decodeNapiKernelError } from './SeaErrorMapping';
3636
import SeaOperationBackend from './SeaOperationBackend';
3737
import SeaTableTypeFilter from './SeaTableTypeFilter';
3838
import { seaServerInfoValue } from './SeaServerInfo';
39+
import { buildSeaPositionalParams } from './SeaPositionalParams';
3940

4041
export interface SeaSessionBackendOptions {
4142
/** The opaque napi `Connection` handle returned by `openSession`. */
@@ -130,21 +131,37 @@ export default class SeaSessionBackend implements ISessionBackend {
130131
public async executeStatement(statement: string, options: ExecuteStatementOptions): Promise<IOperationBackend> {
131132
this.failIfClosed();
132133

133-
// M0 surfaces a clear error rather than silently dropping M1-only knobs.
134-
if (options.namedParameters !== undefined || options.ordinalParameters !== undefined) {
134+
// Named params aren't bindable on the SEA path yet — the kernel napi
135+
// surface (ExecuteOptions.positionalParams) exposes positional only.
136+
// Reject rather than silently ignore.
137+
if (options.namedParameters !== undefined && Object.keys(options.namedParameters).length > 0) {
135138
throw new HiveDriverError(
136-
'SEA executeStatement: query parameters are not supported in M0 (deferred to M1)',
139+
'SEA executeStatement: named parameters are not supported yet (use positional `?` parameters).',
137140
);
138141
}
142+
143+
// Reduce positional `?` bindings to the napi `{ sqlType, value? }` inputs
144+
// the kernel param codec accepts (DECIMAL → DECIMAL(p,s), NULL →
145+
// value-less), reusing DBSQLParameter's stringification.
146+
const positionalParams = buildSeaPositionalParams(options.ordinalParameters);
147+
148+
const nativeOptions: SeaNativeExecuteOptions = {};
149+
if (positionalParams !== undefined) {
150+
nativeOptions.positionalParams = positionalParams;
151+
}
152+
// JDBC `setQueryTimeout` is whole seconds; the kernel's
153+
// `query_timeout_secs` (SEA wait timeout, on_wait_timeout = CANCEL) is
154+
// the native equivalent. The SEA wire caps it at 50s server-side.
139155
if (options.queryTimeout !== undefined) {
140-
throw new HiveDriverError(
141-
'SEA executeStatement: queryTimeout is not supported in M0 (deferred to M1)',
142-
);
156+
nativeOptions.queryTimeoutSecs = Number(options.queryTimeout);
143157
}
158+
const hasOptions = Object.keys(nativeOptions).length > 0;
144159

145160
let nativeStatement;
146161
try {
147-
nativeStatement = await this.connection.executeStatement(statement);
162+
nativeStatement = hasOptions
163+
? await this.connection.executeStatement(statement, nativeOptions)
164+
: await this.connection.executeStatement(statement);
148165
} catch (err) {
149166
throw decodeNapiKernelError(err);
150167
}

tests/unit/sea/execution.test.ts

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import SeaOperationBackend from '../../../lib/sea/SeaOperationBackend';
2020
import {
2121
SeaNativeBinding,
2222
SeaNativeConnection,
23+
SeaNativeExecuteOptions,
2324
SeaNativeStatement,
2425
} from '../../../lib/sea/SeaNativeLoader';
2526
import IClientContext, { ClientConfig } from '../../../lib/contracts/IClientContext';
@@ -60,15 +61,21 @@ class FakeNativeConnection implements SeaNativeConnection {
6061

6162
public lastSql?: string;
6263

64+
public lastOptions?: SeaNativeExecuteOptions;
65+
6366
public throwOnExecute: Error | null = null;
6467

6568
public statementToReturn: FakeNativeStatement = new FakeNativeStatement();
6669

67-
public async executeStatement(sql: string): Promise<SeaNativeStatement> {
70+
public async executeStatement(
71+
sql: string,
72+
options?: SeaNativeExecuteOptions,
73+
): Promise<SeaNativeStatement> {
6874
if (this.throwOnExecute) {
6975
throw this.throwOnExecute;
7076
}
7177
this.lastSql = sql;
78+
this.lastOptions = options;
7279
return this.statementToReturn;
7380
}
7481

@@ -355,42 +362,38 @@ describe('SeaSessionBackend', () => {
355362
expect(op.id).to.be.a('string').and.have.length.greaterThan(0);
356363
});
357364

358-
it('executeStatement rejects namedParameters (M1)', async () => {
365+
it('executeStatement forwards ordinalParameters as napi positionalParams ({sqlType,value})', async () => {
359366
const connection = new FakeNativeConnection();
360367
const session = makeSession(connection);
361-
let thrown: unknown;
362-
try {
363-
await session.executeStatement('SELECT :x', { namedParameters: { x: 1 } });
364-
} catch (err) {
365-
thrown = err;
366-
}
367-
expect(thrown).to.be.instanceOf(HiveDriverError);
368-
expect((thrown as Error).message).to.match(/parameters/);
368+
await session.executeStatement('SELECT ?', { ordinalParameters: ['hello'] });
369+
expect(connection.lastOptions?.positionalParams).to.deep.equal([{ sqlType: 'STRING', value: 'hello' }]);
369370
});
370371

371-
it('executeStatement rejects ordinalParameters (M1)', async () => {
372+
it('executeStatement forwards queryTimeout as queryTimeoutSecs', async () => {
372373
const connection = new FakeNativeConnection();
373374
const session = makeSession(connection);
374-
let thrown: unknown;
375-
try {
376-
await session.executeStatement('SELECT ?', { ordinalParameters: [1] });
377-
} catch (err) {
378-
thrown = err;
379-
}
380-
expect(thrown).to.be.instanceOf(HiveDriverError);
375+
await session.executeStatement('SELECT 1', { queryTimeout: 30 });
376+
expect(connection.lastOptions?.queryTimeoutSecs).to.equal(30);
381377
});
382378

383-
it('executeStatement rejects queryTimeout (M1)', async () => {
379+
it('executeStatement still rejects namedParameters (positional only on SEA)', async () => {
384380
const connection = new FakeNativeConnection();
385381
const session = makeSession(connection);
386382
let thrown: unknown;
387383
try {
388-
await session.executeStatement('SELECT 1', { queryTimeout: 30 });
384+
await session.executeStatement('SELECT :x', { namedParameters: { x: 1 } });
389385
} catch (err) {
390386
thrown = err;
391387
}
392388
expect(thrown).to.be.instanceOf(HiveDriverError);
393-
expect((thrown as Error).message).to.match(/queryTimeout/);
389+
expect((thrown as Error).message).to.match(/named parameters/);
390+
});
391+
392+
it('executeStatement uses the no-options fast path when nothing is bound', async () => {
393+
const connection = new FakeNativeConnection();
394+
const session = makeSession(connection);
395+
await session.executeStatement('SELECT 1', {});
396+
expect(connection.lastOptions).to.equal(undefined);
394397
});
395398

396399
// Metadata-method happy-path and arg-routing coverage lives in
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Copyright (c) 2026 Databricks, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import { expect } from 'chai';
16+
import { buildSeaPositionalParams } from '../../../lib/sea/SeaPositionalParams';
17+
import { DBSQLParameter, DBSQLParameterType } from '../../../lib/DBSQLParameter';
18+
19+
describe('SeaPositionalParams.buildSeaPositionalParams', () => {
20+
it('returns undefined for no params (keeps the no-options fast path)', () => {
21+
expect(buildSeaPositionalParams(undefined)).to.equal(undefined);
22+
expect(buildSeaPositionalParams([])).to.equal(undefined);
23+
});
24+
25+
it('infers types from raw values, matching DBSQLParameter rules', () => {
26+
expect(buildSeaPositionalParams([42, 'hello', true])).to.deep.equal([
27+
{ sqlType: 'INTEGER', value: '42' },
28+
{ sqlType: 'STRING', value: 'hello' },
29+
{ sqlType: 'BOOLEAN', value: 'TRUE' },
30+
]);
31+
});
32+
33+
it('emits DECIMAL in the parenthesised DECIMAL(p,s) form the kernel codec requires', () => {
34+
expect(
35+
buildSeaPositionalParams([new DBSQLParameter({ type: DBSQLParameterType.DECIMAL, value: '99.99' })]),
36+
).to.deep.equal([{ sqlType: 'DECIMAL(4,2)', value: '99.99' }]);
37+
expect(
38+
buildSeaPositionalParams([new DBSQLParameter({ type: DBSQLParameterType.DECIMAL, value: '-123' })]),
39+
).to.deep.equal([{ sqlType: 'DECIMAL(3,0)', value: '-123' }]);
40+
});
41+
42+
it('maps NULL to a value-less VOID input', () => {
43+
expect(buildSeaPositionalParams([null])).to.deep.equal([{ sqlType: 'VOID' }]);
44+
});
45+
46+
it('honours explicit DATE / TIMESTAMP types', () => {
47+
expect(
48+
buildSeaPositionalParams([
49+
new DBSQLParameter({ type: DBSQLParameterType.DATE, value: '2024-01-15' }),
50+
new DBSQLParameter({ type: DBSQLParameterType.TIMESTAMP, value: '2024-01-15 10:30:00' }),
51+
]),
52+
).to.deep.equal([
53+
{ sqlType: 'DATE', value: '2024-01-15' },
54+
{ sqlType: 'TIMESTAMP', value: '2024-01-15 10:30:00' },
55+
]);
56+
});
57+
});

0 commit comments

Comments
 (0)