Skip to content

Commit 45f865d

Browse files
committed
[SEA-NodeJS] Address code-review findings on params/metadata/getInfo
Code-review-squad #412 review (79/100). Validated each finding against the kernel source + a live warehouse before fixing: - F1 (HIGH): getPrimaryKeys turned an omitted catalog into `?? ''`, which the kernel's `Identifier::new` rejects with an opaque "identifier must not be empty" — a regression vs Thrift (which forwards undefined). The napi `getPrimaryKeys(catalog: string, …)` requires a non-empty catalog, so reject up front with a clear, actionable message and document the divergence. Added a test (omitted + empty-string catalog) asserting the kernel call is never reached. (getCrossReference is unaffected — its parent args are nullable and passed through without `?? ''`.) - F3 (MED): mutual-exclusivity of ordinal/named params threw HiveDriverError with a different message than the Thrift path, which throws ParameterError('Driver does not support both ordinal and named parameters.') (ParameterError extends Error, not HiveDriverError — so a caller catching it worked on Thrift and silently failed on SEA). Now throws the identical ParameterError + message. Test updated. - F2 (MED): empirically re-verified against the live warehouse over Thrift — the server returns the exact same three values we synthesize ("Spark SQL" / "Spark SQL" / "3.1.1") and errors on every other TGetInfoType (probed CLI_MAX_DRIVER_CONNECTIONS, CLI_DATA_SOURCE_NAME, …). So throwing on an unsupported type matches Thrift's effective behaviour rather than narrowing it; softened the "byte-for-byte" assertion into the validated fact in both the SeaServerInfo and getInfo doc comments. - F4 (LOW): getInfo's rejection now names the accepted enum identifiers/values (CLI_SERVER_NAME (13), CLI_DBMS_NAME (17), CLI_DBMS_VER (18)) so an agent/SDK consumer can self-correct. - F5 (LOW): metadata + bound-param execute failures now emit a debug-level breadcrumb (mapped error + session id) before throwing, via a shared `logAndMapError` helper, matching the rest of the SEA backend's logging. - F7 (LOW): dropped the vestigial `nativeStatement!` non-null assertion in executeStatement (typed the local, mirroring runMetadata). - F8 (test): added coverage for the getPrimaryKeys omitted-catalog path (F1), the INTERVAL *→INTERVAL collapse, and the DECIMAL precision clamp-to-38. F6 (kernel diagnostic getters unconsumed) is tracked as a follow-up — wiring them in must land with the errorDetailsJson size-guard, out of scope here. Validated live: getPrimaryKeys(with/without catalog), the ParameterError parity, and the named-identifier getInfo error all behave as asserted. Co-authored-by: Isaac Signed-off-by: Madhavendra Rathore <madhavendra.rathore@databricks.com>
1 parent 9fe5357 commit 45f865d

4 files changed

Lines changed: 104 additions & 21 deletions

File tree

lib/sea/SeaServerInfo.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,22 @@ import { TGetInfoType, TGetInfoValue } from '../../thrift/TCLIService_types';
2222
* the values client-side.
2323
*
2424
* The Databricks Thrift server itself answers only three `TGetInfoType`s and
25-
* rejects every other value; we mirror that surface byte-for-byte so the SEA
26-
* path is a drop-in equivalent:
25+
* rejects every other value; we mirror that surface so the SEA path is a
26+
* drop-in equivalent:
2727
*
2828
* | TGetInfoType | Thrift server | SEA (here) |
2929
* |---------------------|---------------|-------------------|
3030
* | CLI_SERVER_NAME (13)| "Spark SQL" | "Spark SQL" |
3131
* | CLI_DBMS_NAME (17)| "Spark SQL" | "Spark SQL" |
3232
* | CLI_DBMS_VER (18)| "3.1.1" | "3.1.1" |
3333
* | (any other) | error | undefined → error |
34+
*
35+
* Empirically re-verified against a live warehouse over the Thrift path: the
36+
* three values above are byte-identical to the server's, and the server
37+
* returns an error for every other `TGetInfoType` (probed CLI_MAX_DRIVER_-
38+
* CONNECTIONS, CLI_DATA_SOURCE_NAME, CLI_FETCH_DIRECTION, … — all errored). So
39+
* the SEA "undefined → throw" path matches Thrift's effective behaviour rather
40+
* than narrowing it.
3441
*/
3542

3643
/** Canonical DBMS product name — identical to the Thrift server's value. */

lib/sea/SeaSessionBackend.ts

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import {
3131
import Status from '../dto/Status';
3232
import InfoValue from '../dto/InfoValue';
3333
import HiveDriverError from '../errors/HiveDriverError';
34+
import ParameterError from '../errors/ParameterError';
35+
import { LogLevel } from '../contracts/IDBSQLLogger';
3436
import { SeaConnection, SeaNativeExecuteOptions, SeaStatement } from './SeaNativeLoader';
3537
import { decodeNapiKernelError } from './SeaErrorMapping';
3638
import SeaOperationBackend from './SeaOperationBackend';
@@ -85,18 +87,27 @@ export default class SeaSessionBackend implements ISessionBackend {
8587
/**
8688
* `getInfo` (JDBC `DatabaseMetaData` / ODBC `SQLGetInfo`) has no SEA/kernel
8789
* endpoint, so — exactly as JDBC does for `DatabaseMetaData` — we synthesize
88-
* the answer client-side, mirroring the three `TGetInfoType`s the Databricks
89-
* Thrift server answers (server name / DBMS name / DBMS version) and
90-
* rejecting every other value the same way the server does. See
90+
* the answer client-side for the three `TGetInfoType`s the Databricks server
91+
* answers (server name / DBMS name / DBMS version) and reject the rest.
92+
*
93+
* This is NOT a SEA-only contract narrowing: probing the live warehouse over
94+
* the Thrift path confirms the server itself returns an error for every
95+
* other `TGetInfoType` (CLI_MAX_DRIVER_CONNECTIONS, CLI_DATA_SOURCE_NAME, …),
96+
* and the three values it does answer are byte-identical to the constants we
97+
* synthesize (`"Spark SQL"` / `"Spark SQL"` / `"3.1.1"`, re-verified live).
98+
* So rejecting an unsupported type matches Thrift's effective behaviour — we
99+
* just surface a clearer, typed error than the server's opaque one. See
91100
* {@link seaServerInfoValue}.
92101
*/
93102
public async getInfo(infoType: number): Promise<InfoValue> {
94103
this.failIfClosed();
95104
const value = seaServerInfoValue(infoType);
96105
if (value === undefined) {
97106
throw new HiveDriverError(
98-
`SEA getInfo: unsupported TGetInfoType ${infoType}. The SEA/kernel path answers only the ` +
99-
`info types the Databricks server itself supports (server name, DBMS name, DBMS version).`,
107+
`SEA getInfo: unsupported TGetInfoType ${infoType}. Only the info types the Databricks ` +
108+
`server itself answers are supported: CLI_SERVER_NAME (13), CLI_DBMS_NAME (17), ` +
109+
`CLI_DBMS_VER (18). The server rejects every other type on the Thrift path too, so this ` +
110+
`is not a SEA-specific restriction.`,
100111
);
101112
}
102113
return new InfoValue(value);
@@ -123,14 +134,14 @@ export default class SeaSessionBackend implements ISessionBackend {
123134
this.failIfClosed();
124135

125136
// Positional (`?`) and named (`:name`) parameters are mutually exclusive —
126-
// the kernel param codec binds exactly one placeholder style per
127-
// statement, so passing both is a caller error we surface up-front.
137+
// the kernel param codec binds exactly one placeholder style per statement.
138+
// Use the SAME error type and message as the Thrift backend
139+
// (`ThriftSessionBackend.getQueryParameters`) so a caller catching
140+
// `ParameterError` for this case behaves identically across backends.
128141
const positionalParams = buildSeaPositionalParams(options.ordinalParameters);
129142
const namedParams = buildSeaNamedParams(options.namedParameters);
130143
if (positionalParams !== undefined && namedParams !== undefined) {
131-
throw new HiveDriverError(
132-
'SEA executeStatement: ordinalParameters and namedParameters are mutually exclusive — pass only one.',
133-
);
144+
throw new ParameterError('Driver does not support both ordinal and named parameters.');
134145
}
135146

136147
if (options.queryTimeout !== undefined) {
@@ -168,16 +179,16 @@ export default class SeaSessionBackend implements ISessionBackend {
168179
execOptions = { positionalParams, namedParams };
169180
}
170181

171-
let nativeStatement;
182+
let nativeStatement: SeaStatement;
172183
try {
173184
nativeStatement =
174185
execOptions === undefined
175186
? await this.connection.executeStatement(statement)
176187
: await this.connection.executeStatement(statement, execOptions);
177188
} catch (err) {
178-
throw decodeNapiKernelError(err);
189+
throw this.logAndMapError('executeStatement', err);
179190
}
180-
return this.wrapStatement(nativeStatement!);
191+
return this.wrapStatement(nativeStatement);
181192
}
182193

183194
/** Wrap a napi `Statement` (from execute or a metadata call) as an operation backend. */
@@ -244,8 +255,19 @@ export default class SeaSessionBackend implements ISessionBackend {
244255

245256
public async getPrimaryKeys(request: PrimaryKeysRequest): Promise<IOperationBackend> {
246257
this.failIfClosed();
258+
// The kernel requires a catalog for primary-key lookup (`Identifier::new`
259+
// rejects an empty string). The Thrift backend can forward an undefined
260+
// catalog and let the server resolve a default; the SEA/kernel path cannot,
261+
// so reject up front with a clear, actionable message rather than passing
262+
// `''` and surfacing the kernel's opaque "identifier must not be empty".
263+
if (request.catalogName === undefined || request.catalogName === '') {
264+
throw new HiveDriverError(
265+
'SEA getPrimaryKeys requires a catalog — pass `catalogName` explicitly. (The Thrift backend ' +
266+
'can omit it and let the server resolve a default; the SEA kernel path requires it.)',
267+
);
268+
}
247269
return this.runMetadata(() =>
248-
this.connection.getPrimaryKeys(request.catalogName ?? '', request.schemaName, request.tableName),
270+
this.connection.getPrimaryKeys(request.catalogName as string, request.schemaName, request.tableName),
249271
);
250272
}
251273

@@ -265,15 +287,27 @@ export default class SeaSessionBackend implements ISessionBackend {
265287

266288
/** Run a napi metadata call, mapping kernel errors and wrapping the result handle. */
267289
private async runMetadata(call: () => Promise<SeaStatement>): Promise<IOperationBackend> {
268-
let nativeStatement;
290+
let nativeStatement: SeaStatement;
269291
try {
270292
nativeStatement = await call();
271293
} catch (err) {
272-
throw decodeNapiKernelError(err);
294+
throw this.logAndMapError('metadata', err);
273295
}
274296
return this.wrapStatement(nativeStatement);
275297
}
276298

299+
/**
300+
* Map a napi/kernel error to a typed driver error and emit a debug breadcrumb
301+
* first, matching the rest of the SEA backend's logging convention
302+
* (`SeaOperationLifecycle` / `SeaOperationBackend`). Metadata and bound-param
303+
* execute failures otherwise threw with no on-call signal.
304+
*/
305+
private logAndMapError(op: string, err: unknown): Error {
306+
const mapped = decodeNapiKernelError(err);
307+
this.context.getLogger().log(LogLevel.debug, `SEA ${op} failed for session ${this._id}: ${mapped.message}`);
308+
return mapped;
309+
}
310+
277311
public async close(): Promise<Status> {
278312
if (this.closed) {
279313
return Status.success();

tests/unit/sea/execution.test.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { SeaNativeBinding, SeaConnection, SeaStatement } from '../../../lib/sea/
2121
import IClientContext, { ClientConfig } from '../../../lib/contracts/IClientContext';
2222
import IDBSQLLogger, { LogLevel } from '../../../lib/contracts/IDBSQLLogger';
2323
import HiveDriverError from '../../../lib/errors/HiveDriverError';
24+
import ParameterError from '../../../lib/errors/ParameterError';
2425
import { ConnectionOptions } from '../../../lib/contracts/IDBSQLClient';
2526

2627
// -----------------------------------------------------------------------------
@@ -434,7 +435,7 @@ describe('SeaSessionBackend', () => {
434435
expect(connection.lastOptions).to.equal(undefined);
435436
});
436437

437-
it('executeStatement rejects mixing ordinal and named parameters (mutually exclusive)', async () => {
438+
it('executeStatement rejects mixing ordinal and named parameters with the same ParameterError as Thrift', async () => {
438439
const connection = new FakeNativeConnection();
439440
const session = makeSession(connection);
440441
let thrown: unknown;
@@ -443,8 +444,10 @@ describe('SeaSessionBackend', () => {
443444
} catch (err) {
444445
thrown = err;
445446
}
446-
expect(thrown).to.be.instanceOf(HiveDriverError);
447-
expect((thrown as Error).message).to.match(/mutually exclusive/);
447+
// Cross-backend parity: ThriftSessionBackend throws ParameterError with this
448+
// exact message, so a caller catching ParameterError behaves identically.
449+
expect(thrown).to.be.instanceOf(ParameterError);
450+
expect((thrown as Error).message).to.equal('Driver does not support both ordinal and named parameters.');
448451
});
449452

450453
it('executeStatement rejects queryTimeout (M1)', async () => {
@@ -521,6 +524,24 @@ describe('SeaSessionBackend', () => {
521524
]);
522525
});
523526

527+
it('getPrimaryKeys rejects an omitted catalog up front (the kernel requires one)', async () => {
528+
const connection = new FakeNativeConnection();
529+
const session = makeSession(connection);
530+
for (const request of [{ schemaName: 'def', tableName: 't' }, { catalogName: '', schemaName: 'def', tableName: 't' }]) {
531+
let thrown: unknown;
532+
try {
533+
// eslint-disable-next-line no-await-in-loop
534+
await session.getPrimaryKeys(request);
535+
} catch (err) {
536+
thrown = err;
537+
}
538+
expect(thrown, `expected reject for ${JSON.stringify(request)}`).to.be.instanceOf(HiveDriverError);
539+
expect((thrown as Error).message).to.match(/requires a catalog/);
540+
}
541+
// The kernel call must NOT be reached (no empty-identifier sent over FFI).
542+
expect(connection.metadataCalls.filter((c) => c[0] === 'getPrimaryKeys')).to.have.length(0);
543+
});
544+
524545
it('getInfo synthesizes the three server-answered info types and rejects the rest', async () => {
525546
const connection = new FakeNativeConnection();
526547
const session = makeSession(connection);

tests/unit/sea/positionalParams.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,27 @@ describe('SeaPositionalParams.buildSeaPositionalParams', () => {
3939
).to.deep.equal([{ sqlType: 'DECIMAL(3,0)', value: '-123' }]);
4040
});
4141

42+
it('clamps DECIMAL precision to the Databricks max of 38', () => {
43+
// 40 integer digits → precision clamped to 38 (scale 0).
44+
expect(
45+
buildSeaPositionalParams([
46+
new DBSQLParameter({ type: DBSQLParameterType.DECIMAL, value: '1234567890123456789012345678901234567890' }),
47+
]),
48+
).to.deep.equal([{ sqlType: 'DECIMAL(38,0)', value: '1234567890123456789012345678901234567890' }]);
49+
});
50+
51+
it('collapses every INTERVAL subtype to the kernel codec\'s single "INTERVAL" type name', () => {
52+
expect(
53+
buildSeaPositionalParams([
54+
new DBSQLParameter({ type: DBSQLParameterType.INTERVALMONTH, value: '13' }),
55+
new DBSQLParameter({ type: DBSQLParameterType.INTERVALDAY, value: '1 02:03:04' }),
56+
]),
57+
).to.deep.equal([
58+
{ sqlType: 'INTERVAL', value: '13' },
59+
{ sqlType: 'INTERVAL', value: '1 02:03:04' },
60+
]);
61+
});
62+
4263
it('maps NULL to a value-less VOID input', () => {
4364
expect(buildSeaPositionalParams([null])).to.deep.equal([{ sqlType: 'VOID' }]);
4465
});

0 commit comments

Comments
 (0)