From 9dcb28cfb6ceb1501daa7779c89efff980db3b45 Mon Sep 17 00:00:00 2001 From: Madhavendra Rathore Date: Sun, 31 May 2026 22:32:06 +0000 Subject: [PATCH 1/2] feat(sea): wire rowLimit + statementConf + TIMESTAMP_NTZ/LTZ params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three statement-option / param-type additions where the kernel + napi were already ready but the node SEA layer didn't expose them: - rowLimit: new `ExecuteStatementOptions.rowLimit` → napi `rowLimit` (SEA `row_limit`). SEA-only server-side cap; Thrift has no execute-time cap. - statementConf: new `ExecuteStatementOptions.statementConf` → napi `statementConf` (SEA `statement_conf`), the Thrift `confOverlay` equivalent. Generalises the existing query_tags serialisation so a caller-supplied statementConf and queryTags merge into one conf map (queryTags already forwarded upstream). - TIMESTAMP_NTZ / TIMESTAMP_LTZ: added to `DBSQLParameterType` so callers can bind timezone-explicit timestamp params. `toSparkParameter` already honours an explicit type and `SeaPositionalParams` passes the SQL type verbatim to the kernel codec (which has the NTZ/LTZ arms). Co-authored-by: Isaac Signed-off-by: Madhavendra Rathore --- lib/DBSQLParameter.ts | 8 ++++ lib/contracts/IDBSQLSession.ts | 14 +++++++ lib/sea/SeaSessionBackend.ts | 49 +++++++++++++++---------- tests/unit/sea/execution.test.ts | 27 ++++++++++++++ tests/unit/sea/positionalParams.test.ts | 19 ++++++++++ 5 files changed, 97 insertions(+), 20 deletions(-) diff --git a/lib/DBSQLParameter.ts b/lib/DBSQLParameter.ts index 5e7f0abc..d00c8f65 100644 --- a/lib/DBSQLParameter.ts +++ b/lib/DBSQLParameter.ts @@ -8,6 +8,14 @@ export enum DBSQLParameterType { STRING = 'STRING', DATE = 'DATE', TIMESTAMP = 'TIMESTAMP', + // Timezone-explicit timestamp variants. A bare `Date` value defaults to + // `TIMESTAMP`; set one of these explicitly to bind a TIMESTAMP_NTZ + // (no timezone, wall-clock) or TIMESTAMP_LTZ (local timezone) parameter. + // The Thrift wire only has `TIMESTAMP`; these are SEA-path types the kernel + // param codec accepts — without them a migrating caller silently coerces + // NTZ/LTZ columns to TIMESTAMP. + TIMESTAMP_NTZ = 'TIMESTAMP_NTZ', + TIMESTAMP_LTZ = 'TIMESTAMP_LTZ', FLOAT = 'FLOAT', DECIMAL = 'DECIMAL', DOUBLE = 'DOUBLE', diff --git a/lib/contracts/IDBSQLSession.ts b/lib/contracts/IDBSQLSession.ts index 392f3108..5551e51d 100644 --- a/lib/contracts/IDBSQLSession.ts +++ b/lib/contracts/IDBSQLSession.ts @@ -27,6 +27,20 @@ export type ExecuteStatementOptions = { * These tags apply only to this statement and do not persist across queries. */ queryTags?: Record; + /** + * Server-side cap on the number of rows the statement returns (SEA path only). + * Maps to the kernel's `row_limit` / SEA `row_limit`. The Thrift backend has no + * execute-time server cap, so this is a no-op there; use `maxRows` for the + * client-side per-fetch chunk size on both backends. + */ + rowLimit?: number; + /** + * Arbitrary per-statement configuration overlay (SEA path only). Maps to the + * kernel's `statement_conf` / SEA `statement_conf`, the same mechanism the + * Thrift backend exposes as `confOverlay`. `queryTags` are merged into this map + * under the `query_tags` key, mirroring the Thrift wire shape. + */ + statementConf?: Record; }; export type TypeInfoRequest = { diff --git a/lib/sea/SeaSessionBackend.ts b/lib/sea/SeaSessionBackend.ts index 383ad914..1960ac07 100644 --- a/lib/sea/SeaSessionBackend.ts +++ b/lib/sea/SeaSessionBackend.ts @@ -117,19 +117,17 @@ export default class SeaSessionBackend implements ISessionBackend { /** * Execute a SQL statement through the napi binding. * - * Catalog / schema / sessionConf were applied at session open, so - * there are no per-statement options to thread through. + * Catalog / schema / sessionConf were applied at session open. The + * per-statement options threaded here mirror the Thrift backend: + * `ordinalParameters` / `namedParameters` (bound params), `queryTimeout` + * (server wait timeout), `queryTags` (serialised into the conf overlay's + * `query_tags` key), `statementConf` (arbitrary conf overlay), and + * `rowLimit` (SEA-only server-side row cap). * - * M0 intentionally rejects `queryTimeout`, `namedParameters`, and - * `ordinalParameters` with explicit deferred-to-M1 errors. `useCloudFetch` - * is a no-op on the SEA path — the kernel hardcodes the SEA - * `disposition` to `INLINE_OR_EXTERNAL_LINKS`, and per-statement - * conf overrides have no reader on the kernel; cloud-fetch behaviour - * is governed entirely by the kernel's `ResultConfig` (M1 binding - * surface). - * - * The Thrift backend remains the path for consumers that need any - * of those today. + * `useCloudFetch` is a no-op on the SEA path — the kernel hardcodes the + * SEA `disposition` to `INLINE_OR_EXTERNAL_LINKS`; cloud-fetch behaviour + * is governed by the kernel's `ResultConfig`. `maxRows` is the + * client-side per-fetch chunk size, applied by the facade, not here. */ public async executeStatement(statement: string, options: ExecuteStatementOptions): Promise { this.failIfClosed(); @@ -175,15 +173,26 @@ export default class SeaSessionBackend implements ISessionBackend { if (options.queryTimeout !== undefined) { nativeOptions.queryTimeoutSecs = Number(options.queryTimeout); } - // Query tags: serialise JS-side into the conf overlay's `query_tags` key - // (the same wire shape the Thrift backend produces via `serializeQueryTags` - // → `confOverlay`). Not forwarded via the napi `queryTags` field: that's a - // `HashMap` which can't represent a null-valued tag, and the - // kernel rejects setting both the field and a `query_tags` conf key. A - // null-valued tag therefore round-trips as a key-only segment. + // Server-side row cap (SEA `row_limit`). SEA-only — the Thrift backend has + // no execute-time server cap, so there is no parity obligation here. + if (options.rowLimit !== undefined) { + nativeOptions.rowLimit = Number(options.rowLimit); + } + // Per-statement conf overlay (`statement_conf`) plus query tags. Tags are + // serialised JS-side into the `query_tags` key (the same wire shape the + // Thrift backend produces via `serializeQueryTags` → `confOverlay`), rather + // than via the napi `queryTags` field: napi's `HashMap` + // can't represent a null-valued tag, and the kernel rejects setting both + // the `queryTags` field and a `query_tags` conf key. const serializedQueryTags = serializeQueryTags(options.queryTags); - if (serializedQueryTags !== undefined) { - nativeOptions.statementConf = { query_tags: serializedQueryTags }; + if (options.statementConf !== undefined || serializedQueryTags !== undefined) { + const statementConf: Record = { ...(options.statementConf ?? {}) }; + if (serializedQueryTags !== undefined) { + statementConf.query_tags = serializedQueryTags; + } + if (Object.keys(statementConf).length > 0) { + nativeOptions.statementConf = statementConf; + } } const hasOptions = Object.keys(nativeOptions).length > 0; diff --git a/tests/unit/sea/execution.test.ts b/tests/unit/sea/execution.test.ts index 6adf2714..14ff75d6 100644 --- a/tests/unit/sea/execution.test.ts +++ b/tests/unit/sea/execution.test.ts @@ -506,6 +506,33 @@ describe('SeaSessionBackend', () => { expect(connection.lastListSchemasArgs).to.deep.equal([undefined, '%']); }); + it('executeStatement forwards rowLimit as napi rowLimit', async () => { + const connection = new FakeNativeConnection(); + const session = makeSession(connection); + await session.executeStatement('SELECT 1', { rowLimit: 500 }); + expect(connection.lastOptions?.rowLimit).to.equal(500); + }); + + it('executeStatement forwards statementConf verbatim as napi statementConf', async () => { + const connection = new FakeNativeConnection(); + const session = makeSession(connection); + await session.executeStatement('SELECT 1', { statementConf: { 'spark.sql.ansi.enabled': 'true' } }); + expect(connection.lastOptions?.statementConf).to.deep.equal({ 'spark.sql.ansi.enabled': 'true' }); + }); + + it('executeStatement merges queryTags into a provided statementConf', async () => { + const connection = new FakeNativeConnection(); + const session = makeSession(connection); + await session.executeStatement('SELECT 1', { + statementConf: { 'spark.sql.ansi.enabled': 'true' }, + queryTags: { team: 'data' }, + }); + expect(connection.lastOptions?.statementConf).to.deep.equal({ + 'spark.sql.ansi.enabled': 'true', + query_tags: 'team:data', + }); + }); + it('executeStatement uses the no-options fast path when nothing is bound', async () => { const connection = new FakeNativeConnection(); const session = makeSession(connection); diff --git a/tests/unit/sea/positionalParams.test.ts b/tests/unit/sea/positionalParams.test.ts index bd10b0f8..28e854d7 100644 --- a/tests/unit/sea/positionalParams.test.ts +++ b/tests/unit/sea/positionalParams.test.ts @@ -54,6 +54,25 @@ describe('SeaPositionalParams.buildSeaPositionalParams', () => { { sqlType: 'TIMESTAMP', value: '2024-01-15 10:30:00' }, ]); }); + + it('honours explicit TIMESTAMP_NTZ / TIMESTAMP_LTZ types (kernel param codec)', () => { + expect( + buildSeaPositionalParams([ + new DBSQLParameter({ type: DBSQLParameterType.TIMESTAMP_NTZ, value: '2024-01-15 10:30:00' }), + new DBSQLParameter({ type: DBSQLParameterType.TIMESTAMP_LTZ, value: '2024-01-15 10:30:00' }), + ]), + ).to.deep.equal([ + { sqlType: 'TIMESTAMP_NTZ', value: '2024-01-15 10:30:00' }, + { sqlType: 'TIMESTAMP_LTZ', value: '2024-01-15 10:30:00' }, + ]); + }); + + it('routes a Date with explicit TIMESTAMP_NTZ type as NTZ (not the default TIMESTAMP)', () => { + const d = new Date('2024-01-15T10:30:00.000Z'); + expect( + buildSeaPositionalParams([new DBSQLParameter({ type: DBSQLParameterType.TIMESTAMP_NTZ, value: d })]), + ).to.deep.equal([{ sqlType: 'TIMESTAMP_NTZ', value: d.toISOString() }]); + }); }); describe('SeaPositionalParams.buildSeaNamedParams', () => { From edd07c8b9bb29ec10fed0b23a6c0aab096f39bff Mon Sep 17 00:00:00 2001 From: Madhavendra Rathore Date: Mon, 1 Jun 2026 01:26:31 +0000 Subject: [PATCH 2/2] test(sea): update fakes for statementId / sessionId on the napi surface The cascade commit that surfaced `statementId` on `SeaNativeStatement` and `sessionId` on `SeaNativeConnection` (matching the kernel napi binding's new getters) didn't update the blocking test fakes, breaking compilation. Add the readonly fields to FakeNativeStatement / FakeNativeConnection (execution.test.ts) and FakeNativeStatement / FakeMetadataConnection (metadata.test.ts). The async fakes already carried statementId. Co-authored-by: Isaac Signed-off-by: Madhavendra Rathore --- tests/unit/sea/execution.test.ts | 4 ++++ tests/unit/sea/metadata.test.ts | 3 +++ 2 files changed, 7 insertions(+) diff --git a/tests/unit/sea/execution.test.ts b/tests/unit/sea/execution.test.ts index 14ff75d6..baf55168 100644 --- a/tests/unit/sea/execution.test.ts +++ b/tests/unit/sea/execution.test.ts @@ -38,6 +38,8 @@ import { ConnectionOptions } from '../../../lib/contracts/IDBSQLClient'; // ----------------------------------------------------------------------------- class FakeNativeStatement implements SeaNativeStatement { + public readonly statementId = 'fake-statement-id'; + public closed = false; public cancelled = false; @@ -98,6 +100,8 @@ class FakeNativeAsyncStatement implements SeaNativeAsyncStatement { } class FakeNativeConnection implements SeaNativeConnection { + public readonly sessionId = 'fake-session-id'; + public closed = false; public lastSql?: string; diff --git a/tests/unit/sea/metadata.test.ts b/tests/unit/sea/metadata.test.ts index 1131df0b..58b2282d 100644 --- a/tests/unit/sea/metadata.test.ts +++ b/tests/unit/sea/metadata.test.ts @@ -29,6 +29,7 @@ import HiveDriverError from '../../../lib/errors/HiveDriverError'; // ─── Fakes ─────────────────────────────────────────────────────────────────── class FakeNativeStatement implements SeaNativeStatement { + public readonly statementId = 'fake-statement-id'; public async fetchNextBatch() { return null; } public async schema() { return { ipcBytes: Buffer.alloc(0) }; } public async cancel() {} @@ -47,6 +48,8 @@ interface RecordedMetadataCall { * wrapping path. */ class FakeMetadataConnection implements SeaNativeConnection { + public readonly sessionId = 'fake-session-id'; + public readonly calls: RecordedMetadataCall[] = []; public throwNextCall: unknown = null;