diff --git a/lib/sea/SeaArrowIpc.ts b/lib/sea/SeaArrowIpc.ts index 59418ab5..5e128885 100644 --- a/lib/sea/SeaArrowIpc.ts +++ b/lib/sea/SeaArrowIpc.ts @@ -112,6 +112,19 @@ export function patchIpcBytes(ipcBytes: Buffer): Buffer { function arrowTypeToTTypeId(field: Field): TTypeId { const typeName = field.metadata.get(DATABRICKS_TYPE_NAME)?.toUpperCase(); + // `intervals_as_string` (set by the SEA backend for Thrift parity) + // renders INTERVAL columns as physical Arrow `Utf8` while the kernel + // keeps the `INTERVAL …` type_name metadata. The Thrift driver reports + // such string-rendered intervals as STRING (type 7), so honour the + // physical type here rather than the semantic metadata — otherwise the + // SEA path would report INTERVAL (20/21) and diverge from Thrift on a + // column whose values are already identical strings. Native interval + // encodings (the kernel default) are Duration / MonthInterval, never + // Utf8, so this guard is inert unless `intervals_as_string` is on. + if (typeName?.startsWith('INTERVAL') && DataType.isUtf8(field.type)) { + return TTypeId.STRING_TYPE; + } + switch (typeName) { case 'BOOLEAN': return TTypeId.BOOLEAN_TYPE; diff --git a/lib/sea/SeaAuth.ts b/lib/sea/SeaAuth.ts index c6fd5178..0d2538e1 100644 --- a/lib/sea/SeaAuth.ts +++ b/lib/sea/SeaAuth.ts @@ -66,6 +66,24 @@ export interface SeaSessionDefaults { catalog?: string; schema?: string; sessionConf?: Record; + /** + * Render `INTERVAL` / `DURATION` result columns as strings + * (kernel `ResultConfig.intervals_as_string`). The kernel default is + * native Arrow `month_interval` / `duration[us]`, but the NodeJS + * Thrift driver surfaces intervals as strings — so the SEA path sets + * this `true` so its result shape is a byte-compatible drop-in for the + * Thrift backend. Omitting it falls back to the kernel's native types. + */ + intervalsAsString?: boolean; + /** + * Render complex (`ARRAY` / `MAP` / `STRUCT` / `VARIANT`) result + * columns as JSON strings (kernel `ResultConfig.complex_types_as_json`). + * Left unset on the SEA path: native Arrow nested types already decode + * identically to the Thrift backend through the shared Arrow converter, + * so forcing JSON here would *introduce* a divergence rather than + * remove one. + */ + complexTypesAsJson?: boolean; } export type SeaNativeConnectionOptions = SeaSessionDefaults & @@ -161,6 +179,13 @@ export function buildSeaConnectionOptions(options: ConnectionOptions): SeaNative const base = { hostName: options.host, httpPath: prependSlash(options.path), + // Match the NodeJS Thrift driver, which surfaces INTERVAL columns as + // strings. The kernel defaults to native Arrow interval/duration + // types; forcing the string rendering here keeps the SEA path a + // byte-compatible drop-in. Complex types are intentionally left at + // the kernel default (native Arrow) — they already decode identically + // to Thrift via the shared Arrow converter. + intervalsAsString: true, }; const oauth = options as { diff --git a/lib/sea/SeaErrorMapping.ts b/lib/sea/SeaErrorMapping.ts index d7bec2ee..0be5d17e 100644 --- a/lib/sea/SeaErrorMapping.ts +++ b/lib/sea/SeaErrorMapping.ts @@ -147,6 +147,18 @@ export function mapKernelErrorToJsError(kErr: KernelErrorShape): ErrorWithSqlSta error = new ParameterError(message); break; + case 'SqlError': + // A server-side SQL execution failure (the statement reached an + // ERROR state on the warehouse — bad SQL, PERMISSION_DENIED, + // SCHEMA_ALREADY_EXISTS, …). The Thrift backend surfaces exactly + // this situation as an `OperationStateError(ERROR)` after polling + // the operation status, so we mirror that class here for + // drop-in parity (both extend HiveDriverError, so existing + // `catch (HiveDriverError)` callers are unaffected). + error = new OperationStateError(OperationStateErrorCode.Error); + error.message = message; + break; + // All remaining kernel ErrorCode variants map to the base driver error class. // M0 intentionally does not introduce new error classes; M1 may add nuance. case 'NotFound': @@ -156,7 +168,6 @@ export function mapKernelErrorToJsError(kErr: KernelErrorShape): ErrorWithSqlSta case 'Internal': case 'InvalidStatementHandle': case 'NetworkError': - case 'SqlError': error = new HiveDriverError(message); break; diff --git a/lib/sea/SeaInputValidation.ts b/lib/sea/SeaInputValidation.ts new file mode 100644 index 00000000..8d4e4113 --- /dev/null +++ b/lib/sea/SeaInputValidation.ts @@ -0,0 +1,114 @@ +// Copyright (c) 2026 Databricks, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Int64 from 'node-int64'; +import { DBSQLParameter, DBSQLParameterValue } from '../DBSQLParameter'; +import ParameterError from '../errors/ParameterError'; + +/** + * Coerce an empty-string metadata argument to `undefined`. + * + * The kernel's `Identifier` / `LikePattern` reject empty strings with + * `InvalidArgument`, whereas the Thrift backend forwards `""` to the server + * which treats it as "unspecified" (match-all / session default). To keep the + * SEA metadata surface behaviourally identical to Thrift, the SEA adapter + * maps `""` → `undefined` before crossing the napi boundary so the kernel + * sees "argument omitted" rather than "empty identifier". + */ +export function emptyToUndefined(value: string | undefined | null): string | undefined { + return value == null || value === '' ? undefined : value; +} + +/** + * Walk a SQL string counting `?` parameter markers, ignoring markers inside + * string literals (`'...'`, `"..."`), backtick-quoted identifiers, and + * comments (`-- ...`, `/* ... *​/`). Mirrors the kernel's + * `statement::params::count_parameter_markers` state machine so the JS-side + * arity check matches what the kernel binds. + */ +export function countParameterMarkers(sql: string): number { + let count = 0; + let i = 0; + const n = sql.length; + type State = 'normal' | 'single' | 'double' | 'backtick' | 'line' | 'block'; + let state: State = 'normal'; + while (i < n) { + const c = sql[i]; + const next = i + 1 < n ? sql[i + 1] : ''; + switch (state) { + case 'normal': + if (c === '?') { + count += 1; + } else if (c === "'") { + state = 'single'; + } else if (c === '"') { + state = 'double'; + } else if (c === '`') { + state = 'backtick'; + } else if (c === '-' && next === '-') { + state = 'line'; + i += 1; + } else if (c === '/' && next === '*') { + state = 'block'; + i += 1; + } + break; + case 'single': + if (c === "'" && next === "'") i += 1; // escaped '' + else if (c === "'") state = 'normal'; + break; + case 'double': + if (c === '"' && next === '"') i += 1; // escaped "" + else if (c === '"') state = 'normal'; + break; + case 'backtick': + if (c === '`') state = 'normal'; + break; + case 'line': + if (c === '\n') state = 'normal'; + break; + case 'block': + if (c === '*' && next === '/') { + state = 'normal'; + i += 1; + } + break; + } + i += 1; + } + return count; +} + +/** + * Reject a parameter value that cannot be bound as a scalar. Arrays and plain + * objects stringify to garbage (e.g. `[1,2,3]` → `"1,2,3"`) that the server + * fails to coerce — on the Thrift path the operation never returns to + * FINISHED (a DoS hazard), and on SEA it surfaces an opaque server error. We + * fail fast at bind time instead, mirroring the kernel's compound-type + * rejection. `DBSQLParameter`, `Int64`, `Date`, and JS primitives are allowed. + */ +export function assertBindableValue(value: DBSQLParameter | DBSQLParameterValue, label: string): void { + if (value instanceof DBSQLParameter) return; + if (value === null || value === undefined) return; + if (Array.isArray(value)) { + throw new ParameterError( + `${label} is an array; compound types (ARRAY/MAP/STRUCT) are not bindable as a parameter value`, + ); + } + if (typeof value === 'object' && !(value instanceof Date) && !(value instanceof Int64)) { + throw new ParameterError( + `${label} is an object; only scalar values (string/number/bigint/boolean), Date, and Int64 are bindable`, + ); + } +} diff --git a/lib/sea/SeaNativeLoader.ts b/lib/sea/SeaNativeLoader.ts index a68caf90..42f6ceb7 100644 --- a/lib/sea/SeaNativeLoader.ts +++ b/lib/sea/SeaNativeLoader.ts @@ -72,13 +72,51 @@ export interface SeaNativeStatement { * napi-rs emits `string | undefined | null` for every Rust `Option` * parameter — both `undefined` and `null` are accepted at the call site. */ +/** + * A single positional bound parameter in the napi `{ sqlType, value? }` + * shape the kernel's param codec (`parse_typed_value`) accepts. `sqlType` + * is the Databricks SQL type name (`INT`, `STRING`, `TIMESTAMP`, … and the + * parenthesised `DECIMAL(p,s)`); a missing `value` is SQL NULL. Built by + * `SeaPositionalParams.buildSeaPositionalParams`. + */ +export interface SeaNativeTypedValueInput { + sqlType: string; + value?: string; +} + +/** + * A single named bound parameter — a `SeaNativeTypedValueInput` plus its + * `:name`. Maps to the kernel's `param_named`. Built by + * `SeaPositionalParams.buildSeaNamedParams`. + */ +export interface SeaNativeNamedTypedValueInput { + name: string; + sqlType: string; + value?: string; +} + +/** + * Per-statement options accepted by the napi `executeStatement`. Matches + * the kernel `ExecuteOptions`. All fields optional; an omitted/empty + * object is the no-options fast path. + */ +export interface SeaNativeExecuteOptions { + statementConf?: Record; + queryTags?: Record; + rowLimit?: number; + queryTimeoutSecs?: number; + positionalParams?: Array; + namedParams?: Array; +} + export interface SeaNativeConnection { /** * Execute a SQL statement. Catalog / schema / sessionConf are - * session-level — set on `openSession`, applied to every statement - * executed on the resulting `Connection`. No per-statement options. + * session-level — set on `openSession`. Per-statement options (bound + * positional parameters, row limit, query timeout, tags) ride on the + * optional `options` argument. */ - executeStatement(sql: string): Promise; + executeStatement(sql: string, options?: SeaNativeExecuteOptions): Promise; // ── Metadata methods ────────────────────────────────────────────────── /** All catalogs visible to the session. */ diff --git a/lib/sea/SeaPositionalParams.ts b/lib/sea/SeaPositionalParams.ts new file mode 100644 index 00000000..807eb491 --- /dev/null +++ b/lib/sea/SeaPositionalParams.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2026 Databricks, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { DBSQLParameter, DBSQLParameterValue } from '../DBSQLParameter'; +import { SeaNativeTypedValueInput, SeaNativeNamedTypedValueInput } from './SeaNativeLoader'; +import { assertBindableValue } from './SeaInputValidation'; + +/** + * Derive `(precision,scale)` from a decimal value string for the SEA + * `DECIMAL(p,s)` type name — the kernel param codec requires the + * parenthesised form (plain `"DECIMAL"` is rejected) so it can preserve + * the caller's fractional digits. `"99.99"` ⇒ `"4,2"`; `"-123"` ⇒ `"3,0"`. + * Clamped to the Databricks max precision of 38. + */ +function decimalPrecisionScale(v: string): string { + const digits = (v.match(/\d/g) ?? []).length; + const dot = v.indexOf('.'); + const scale = dot < 0 ? 0 : (v.slice(dot + 1).match(/\d/g) ?? []).length; + const precision = Math.min(Math.max(digits, 1), 38); + return `${precision},${Math.min(scale, precision)}`; +} + +/** + * Reduce a `DBSQLParameter | DBSQLParameterValue` to the napi + * `TypedValueInput` (`{ sqlType, value? }`) the kernel's positional-param + * codec (`parse_typed_value`) accepts. Reuses `DBSQLParameter.toSparkParameter` + * — the same type-inference + value-stringification the Thrift backend uses — + * then adapts the type name to the codec's expectations: + * - DECIMAL → `DECIMAL(p,s)` (parenthesised form required) + * - INTERVAL * → `INTERVAL` (the codec's single interval type name) + * - a missing value ⇒ SQL NULL (`parse_typed_value` maps `value: None` to NULL). + */ +function toTypedValueInput(value: DBSQLParameter | DBSQLParameterValue): SeaNativeTypedValueInput { + const param = value instanceof DBSQLParameter ? value : new DBSQLParameter({ value }); + const spark = param.toSparkParameter(); + const stringValue = spark.value?.stringValue ?? undefined; + + // NULL: no value (and `VOID` ignores any type), matching toSparkParameter's + // type/value-less shape for null/undefined. + if (stringValue === undefined || stringValue === null) { + return { sqlType: 'VOID' }; + } + + let sqlType = spark.type ?? 'STRING'; + const upper = sqlType.toUpperCase(); + if (upper === 'DECIMAL') { + sqlType = `DECIMAL(${decimalPrecisionScale(stringValue)})`; + } else if (upper.startsWith('INTERVAL')) { + sqlType = 'INTERVAL'; + } + return { sqlType, value: stringValue }; +} + +/** + * Convert the public `ordinalParameters` option into the napi + * `positionalParams` array (1-based `?` placeholders). Returns `undefined` + * when none were supplied, so the caller can keep the minimal no-options + * call shape. + */ +export function buildSeaPositionalParams( + ordinalParameters?: Array, +): Array | undefined { + if (ordinalParameters === undefined || ordinalParameters.length === 0) { + return undefined; + } + return ordinalParameters.map((value, i) => { + assertBindableValue(value, `ordinalParameters[${i}]`); + return toTypedValueInput(value); + }); +} + +/** + * Convert the public `namedParameters` option (`Record`) into + * the napi `namedParams` array (`:name` placeholders). Each value reuses the + * same `toTypedValueInput` mapping (DECIMAL → DECIMAL(p,s), NULL → VOID, …), + * then carries its name. Returns `undefined` when none were supplied. + */ +export function buildSeaNamedParams( + namedParameters?: Record, +): Array | undefined { + if (namedParameters === undefined || Object.keys(namedParameters).length === 0) { + return undefined; + } + return Object.keys(namedParameters).map((name) => { + assertBindableValue(namedParameters[name], `namedParameters[${name}]`); + return { name, ...toTypedValueInput(namedParameters[name]) }; + }); +} diff --git a/lib/sea/SeaServerInfo.ts b/lib/sea/SeaServerInfo.ts new file mode 100644 index 00000000..9c8c4042 --- /dev/null +++ b/lib/sea/SeaServerInfo.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2026 Databricks, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { TGetInfoType, TGetInfoValue } from '../../thrift/TCLIService_types'; + +/** + * `getInfo` (JDBC `DatabaseMetaData` / ODBC `SQLGetInfo`) is a Thrift-protocol + * concept: the Thrift backend forwards `TGetInfoReq` to the server's `getInfo` + * RPC. The SEA REST protocol and the Rust kernel have **no** equivalent + * endpoint, so — exactly as JDBC does for `DatabaseMetaData` — we synthesize + * the values client-side. + * + * The Databricks Thrift server itself answers only three `TGetInfoType`s and + * rejects every other value; we mirror that surface byte-for-byte so the SEA + * path is a drop-in equivalent: + * + * | TGetInfoType | Thrift server | SEA (here) | + * |---------------------|---------------|-------------------| + * | CLI_SERVER_NAME (13)| "Spark SQL" | "Spark SQL" | + * | CLI_DBMS_NAME (17)| "Spark SQL" | "Spark SQL" | + * | CLI_DBMS_VER (18)| "3.1.1" | "3.1.1" | + * | (any other) | error | undefined → error | + */ + +/** Canonical DBMS product name — identical to the Thrift server's value. */ +export const SEA_DBMS_NAME = 'Spark SQL'; + +/** Server-name answer — identical to the Thrift server's value. */ +export const SEA_SERVER_NAME = 'Spark SQL'; + +/** + * DBMS version string. Mirrors the constant the Databricks Thrift server + * reports for `CLI_DBMS_VER` (the HiveServer2-compat Spark SQL version, not + * the DBR release). Kept in lock-step with Thrift for parity; if the server + * ever changes it the comparator's GET_INFO suite flags the drift. + */ +export const SEA_DBMS_VERSION = '3.1.1'; + +/** + * Synthesize the `TGetInfoValue` for a `getInfo` request on the SEA path. + * Returns `undefined` for any `TGetInfoType` the (Thrift) server does not + * answer — the caller surfaces that as an error, matching Thrift's + * reject-unsupported-info-type behaviour. + */ +export function seaServerInfoValue(infoType: number): TGetInfoValue | undefined { + switch (infoType) { + case TGetInfoType.CLI_SERVER_NAME: + return new TGetInfoValue({ stringValue: SEA_SERVER_NAME }); + case TGetInfoType.CLI_DBMS_NAME: + return new TGetInfoValue({ stringValue: SEA_DBMS_NAME }); + case TGetInfoType.CLI_DBMS_VER: + return new TGetInfoValue({ stringValue: SEA_DBMS_VERSION }); + default: + return undefined; + } +} diff --git a/lib/sea/SeaSessionBackend.ts b/lib/sea/SeaSessionBackend.ts index 512958aa..bd4ad59d 100644 --- a/lib/sea/SeaSessionBackend.ts +++ b/lib/sea/SeaSessionBackend.ts @@ -31,10 +31,15 @@ import { import Status from '../dto/Status'; import InfoValue from '../dto/InfoValue'; import HiveDriverError from '../errors/HiveDriverError'; -import { SeaNativeConnection } from './SeaNativeLoader'; +import { SeaNativeConnection, SeaNativeExecuteOptions } from './SeaNativeLoader'; import { decodeNapiKernelError } from './SeaErrorMapping'; import SeaOperationBackend from './SeaOperationBackend'; import SeaTableTypeFilter from './SeaTableTypeFilter'; +import { seaServerInfoValue } from './SeaServerInfo'; +import { buildSeaPositionalParams, buildSeaNamedParams } from './SeaPositionalParams'; +import ParameterError from '../errors/ParameterError'; +import { emptyToUndefined, countParameterMarkers } from './SeaInputValidation'; +import { serializeQueryTags } from '../utils'; export interface SeaSessionBackendOptions { /** The opaque napi `Connection` handle returned by `openSession`. */ @@ -90,8 +95,23 @@ export default class SeaSessionBackend implements ISessionBackend { return this._id; } - public async getInfo(_infoType: number): Promise { - throw new HiveDriverError('SeaSessionBackend.getInfo: not implemented yet (deferred to M1)'); + public async getInfo(infoType: number): Promise { + this.failIfClosed(); + // `getInfo` (TGetInfoReq) is a Thrift/JDBC concept with no SEA-protocol or + // kernel equivalent, so — like JDBC's DatabaseMetaData — we synthesize the + // values client-side. `seaServerInfoValue` returns matches for the three + // TGetInfoTypes the Thrift server answers (server name, DBMS name, DBMS + // version) and `undefined` for the rest, which we surface as an error to + // mirror the server's reject-unsupported-info-type behaviour. + const value = seaServerInfoValue(infoType); + if (value === undefined) { + throw new HiveDriverError( + `SEA getInfo: TGetInfoType ${infoType} is not supported. The SEA/kernel protocol ` + + 'has no getInfo RPC; only CLI_SERVER_NAME, CLI_DBMS_NAME and CLI_DBMS_VER are ' + + 'synthesised (matching the Thrift server, which also rejects all other info types).', + ); + } + return new InfoValue(value); } /** @@ -114,21 +134,58 @@ export default class SeaSessionBackend implements ISessionBackend { public async executeStatement(statement: string, options: ExecuteStatementOptions): Promise { this.failIfClosed(); - // M0 surfaces a clear error rather than silently dropping M1-only knobs. - if (options.namedParameters !== undefined || options.ordinalParameters !== undefined) { - throw new HiveDriverError( - 'SEA executeStatement: query parameters are not supported in M0 (deferred to M1)', - ); + // Reduce `?` / `:name` bindings to the napi inputs the kernel param codec + // accepts (DECIMAL → DECIMAL(p,s), NULL → value-less), reusing + // DBSQLParameter's stringification. Positional and named are mutually + // exclusive at the SQL level (matches the Thrift backend). + const positionalParams = buildSeaPositionalParams(options.ordinalParameters); + const namedParams = buildSeaNamedParams(options.namedParameters); + if (positionalParams !== undefined && namedParams !== undefined) { + throw new ParameterError('Driver does not support both ordinal and named parameters.'); } + // Arity check: positional params must match the `?` marker count, or the + // server silently binds the prefix and drops the rest (data-correctness + // footgun). Markers inside string literals / comments are not counted. + if (positionalParams !== undefined) { + const markerCount = countParameterMarkers(statement); + if (positionalParams.length !== markerCount) { + throw new ParameterError( + `ordinalParameters length ${positionalParams.length} does not match the ` + + `${markerCount} '?' placeholder(s) in the SQL`, + ); + } + } + + const nativeOptions: SeaNativeExecuteOptions = {}; + if (positionalParams !== undefined) { + nativeOptions.positionalParams = positionalParams; + } + if (namedParams !== undefined) { + nativeOptions.namedParams = namedParams; + } + // JDBC `setQueryTimeout` is whole seconds; the kernel's + // `query_timeout_secs` (SEA wait timeout, on_wait_timeout = CANCEL) is + // the native equivalent. The SEA wire caps it at 50s server-side. if (options.queryTimeout !== undefined) { - throw new HiveDriverError( - 'SEA executeStatement: queryTimeout is not supported in M0 (deferred to M1)', - ); + 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. + const serializedQueryTags = serializeQueryTags(options.queryTags); + if (serializedQueryTags !== undefined) { + nativeOptions.statementConf = { query_tags: serializedQueryTags }; + } + const hasOptions = Object.keys(nativeOptions).length > 0; let nativeStatement; try { - nativeStatement = await this.connection.executeStatement(statement); + nativeStatement = hasOptions + ? await this.connection.executeStatement(statement, nativeOptions) + : await this.connection.executeStatement(statement); } catch (err) { throw decodeNapiKernelError(err); } @@ -165,8 +222,8 @@ export default class SeaSessionBackend implements ISessionBackend { let nativeStatement; try { nativeStatement = await this.connection.listSchemas( - request.catalogName, - request.schemaName, + emptyToUndefined(request.catalogName), + emptyToUndefined(request.schemaName), ); } catch (err) { throw decodeNapiKernelError(err); @@ -179,9 +236,9 @@ export default class SeaSessionBackend implements ISessionBackend { let nativeStatement; try { nativeStatement = await this.connection.listTables( - request.catalogName, - request.schemaName, - request.tableName, + emptyToUndefined(request.catalogName), + emptyToUndefined(request.schemaName), + emptyToUndefined(request.tableName), request.tableTypes, ); } catch (err) { @@ -212,10 +269,10 @@ export default class SeaSessionBackend implements ISessionBackend { let nativeStatement; try { nativeStatement = await this.connection.listColumns( - request.catalogName, - request.schemaName, - request.tableName, - request.columnName, + emptyToUndefined(request.catalogName), + emptyToUndefined(request.schemaName), + emptyToUndefined(request.tableName), + emptyToUndefined(request.columnName), ); } catch (err) { throw decodeNapiKernelError(err); @@ -228,9 +285,9 @@ export default class SeaSessionBackend implements ISessionBackend { let nativeStatement; try { nativeStatement = await this.connection.listFunctions( - request.catalogName, - request.schemaName, - request.functionName, + emptyToUndefined(request.catalogName), + emptyToUndefined(request.schemaName), + emptyToUndefined(request.functionName), ); } catch (err) { throw decodeNapiKernelError(err); diff --git a/tests/unit/sea/SeaIntervalParity.test.ts b/tests/unit/sea/SeaIntervalParity.test.ts index bc1bf083..0863f740 100644 --- a/tests/unit/sea/SeaIntervalParity.test.ts +++ b/tests/unit/sea/SeaIntervalParity.test.ts @@ -40,9 +40,11 @@ import { RecordBatch, makeData, Struct, + Utf8, vectorFromArray, tableToIPC, } from 'apache-arrow'; +import { arrowSchemaToThriftSchema } from '../../../lib/sea/SeaArrowIpc'; // eslint-disable-next-line import/no-internal-modules import { Message as FbMessage } from 'apache-arrow/fb/message'; @@ -363,3 +365,29 @@ describe('SeaOperationBackend — INTERVAL parity with thrift', () => { expect((rows[0] as any).iv).to.equal('1 00:00:00.000000000'); }); }); + +describe('SeaArrowIpc interval-as-string type mapping (Thrift parity)', () => { + const { TTypeId } = require('../../../thrift/TCLIService_types'); // eslint-disable-line @typescript-eslint/no-var-requires, global-require, import/no-internal-modules + + function thriftType(field: Field) { + const cols = arrowSchemaToThriftSchema(new Schema([field])).columns; + return cols[0].typeDesc?.types?.[0]?.primitiveEntry?.type; + } + + it('Utf8 + INTERVAL DAY TO SECOND metadata → STRING (matches Thrift, not INTERVAL)', () => { + // intervals_as_string=true renders the column as physical Utf8 while the + // kernel keeps the INTERVAL type_name metadata; we must report STRING. + const f = new Field('dt', new Utf8(), true, new Map([['databricks.type_name', 'INTERVAL DAY TO SECOND']])); + expect(thriftType(f)).to.equal(TTypeId.STRING_TYPE); + }); + + it('Utf8 + INTERVAL YEAR TO MONTH metadata → STRING', () => { + const f = new Field('ym', new Utf8(), true, new Map([['databricks.type_name', 'INTERVAL YEAR TO MONTH']])); + expect(thriftType(f)).to.equal(TTypeId.STRING_TYPE); + }); + + it('native Arrow Interval + INTERVAL metadata still maps to INTERVAL_YEAR_MONTH (guard is inert without intervals_as_string)', () => { + const f = new Field('ym', new Interval(IntervalUnit.YEAR_MONTH), true, new Map([['databricks.type_name', 'INTERVAL YEAR TO MONTH']])); + expect(thriftType(f)).to.equal(TTypeId.INTERVAL_YEAR_MONTH_TYPE); + }); +}); diff --git a/tests/unit/sea/auth-m2m.test.ts b/tests/unit/sea/auth-m2m.test.ts index 0a38ebc5..ebb4ca04 100644 --- a/tests/unit/sea/auth-m2m.test.ts +++ b/tests/unit/sea/auth-m2m.test.ts @@ -38,6 +38,7 @@ describe('SeaAuth + SeaBackend — OAuth M2M auth flow', () => { authMode: 'OAuthM2m', oauthClientId: 'client-uuid', oauthClientSecret: 'dose-fake-secret', + intervalsAsString: true, }); }); @@ -182,6 +183,7 @@ describe('SeaAuth + SeaBackend — OAuth M2M auth flow', () => { authMode: 'OAuthM2m', oauthClientId: 'client-uuid', oauthClientSecret: 'dose-fake-secret', + intervalsAsString: true, }); await session.close(); diff --git a/tests/unit/sea/auth-pat.test.ts b/tests/unit/sea/auth-pat.test.ts index bdd024f7..e94d1cee 100644 --- a/tests/unit/sea/auth-pat.test.ts +++ b/tests/unit/sea/auth-pat.test.ts @@ -33,6 +33,7 @@ describe('SeaAuth — PAT auth options builder', () => { httpPath: '/sql/1.0/warehouses/abc', authMode: 'Pat', token: 'dapi-fake-pat', + intervalsAsString: true, }); }); diff --git a/tests/unit/sea/auth-u2m.test.ts b/tests/unit/sea/auth-u2m.test.ts index e18109fa..0e619ebc 100644 --- a/tests/unit/sea/auth-u2m.test.ts +++ b/tests/unit/sea/auth-u2m.test.ts @@ -35,6 +35,7 @@ describe('SeaAuth + SeaBackend — OAuth U2M auth flow', () => { httpPath: '/sql/1.0/warehouses/abc', authMode: 'OAuthU2m', oauthRedirectPort: 8030, + intervalsAsString: true, }); }); @@ -145,6 +146,7 @@ describe('SeaAuth + SeaBackend — OAuth U2M auth flow', () => { httpPath: '/sql/1.0/warehouses/abc', authMode: 'OAuthU2m', oauthRedirectPort: 8030, + intervalsAsString: true, }); await session.close(); diff --git a/tests/unit/sea/error-mapping.test.ts b/tests/unit/sea/error-mapping.test.ts index 8331bc57..619605e2 100644 --- a/tests/unit/sea/error-mapping.test.ts +++ b/tests/unit/sea/error-mapping.test.ts @@ -76,8 +76,15 @@ describe('SeaErrorMapping.mapKernelErrorToJsError', () => { expectedClass: HiveDriverError, }, { + // Server-side SQL execution failures surface as OperationStateError(ERROR), + // mirroring the Thrift backend's operation-status-poll error path so the + // two drivers throw the same class. (OperationStateError extends + // HiveDriverError, so base-class catchers still match.) code: 'SqlError', - expectedClass: HiveDriverError, + expectedClass: OperationStateError, + extra: (err) => { + expect((err as OperationStateError).errorCode).to.equal(OperationStateErrorCode.Error); + }, }, ]; diff --git a/tests/unit/sea/execution.test.ts b/tests/unit/sea/execution.test.ts index 638f76c4..f155caf2 100644 --- a/tests/unit/sea/execution.test.ts +++ b/tests/unit/sea/execution.test.ts @@ -20,6 +20,7 @@ import SeaOperationBackend from '../../../lib/sea/SeaOperationBackend'; import { SeaNativeBinding, SeaNativeConnection, + SeaNativeExecuteOptions, SeaNativeStatement, } from '../../../lib/sea/SeaNativeLoader'; import IClientContext, { ClientConfig } from '../../../lib/contracts/IClientContext'; @@ -60,22 +61,31 @@ class FakeNativeConnection implements SeaNativeConnection { public lastSql?: string; + public lastOptions?: SeaNativeExecuteOptions; + public throwOnExecute: Error | null = null; public statementToReturn: FakeNativeStatement = new FakeNativeStatement(); - public async executeStatement(sql: string): Promise { + public async executeStatement( + sql: string, + options?: SeaNativeExecuteOptions, + ): Promise { if (this.throwOnExecute) { throw this.throwOnExecute; } this.lastSql = sql; + this.lastOptions = options; return this.statementToReturn; } // Metadata stubs — return a fresh statement so callers can test wrapping. public async listCatalogs() { return new FakeNativeStatement(); } - public async listSchemas(_catalog: string | undefined, _schemaPattern: string | undefined) { + public lastListSchemasArgs?: [string | undefined | null, string | undefined | null]; + + public async listSchemas(catalog: string | undefined | null, schemaPattern: string | undefined | null) { + this.lastListSchemasArgs = [catalog, schemaPattern]; return new FakeNativeStatement(); } @@ -263,6 +273,7 @@ describe('SeaBackend', () => { httpPath: '/sql/1.0/warehouses/xyz', authMode: 'Pat', token: 'dapi-token', + intervalsAsString: true, }); }); @@ -354,42 +365,94 @@ describe('SeaSessionBackend', () => { expect(op.id).to.be.a('string').and.have.length.greaterThan(0); }); - it('executeStatement rejects namedParameters (M1)', async () => { + it('executeStatement forwards ordinalParameters as napi positionalParams ({sqlType,value})', async () => { + const connection = new FakeNativeConnection(); + const session = makeSession(connection); + await session.executeStatement('SELECT ?', { ordinalParameters: ['hello'] }); + expect(connection.lastOptions?.positionalParams).to.deep.equal([{ sqlType: 'STRING', value: 'hello' }]); + }); + + it('executeStatement forwards queryTimeout as queryTimeoutSecs', async () => { + const connection = new FakeNativeConnection(); + const session = makeSession(connection); + await session.executeStatement('SELECT 1', { queryTimeout: 30 }); + expect(connection.lastOptions?.queryTimeoutSecs).to.equal(30); + }); + + it('executeStatement serialises queryTags into statementConf.query_tags (Thrift wire shape)', async () => { + const connection = new FakeNativeConnection(); + const session = makeSession(connection); + await session.executeStatement('SELECT 1', { queryTags: { team: 'data', env: 'prod' } }); + expect(connection.lastOptions?.statementConf?.query_tags).to.equal('team:data,env:prod'); + // Must NOT use the napi `queryTags` field (can't carry null tags; kernel + // rejects both field + conf key). + expect(connection.lastOptions?.queryTags).to.equal(undefined); + }); + + it('executeStatement carries a null-valued queryTag as a key-only segment', async () => { + const connection = new FakeNativeConnection(); + const session = makeSession(connection); + await session.executeStatement('SELECT 1', { queryTags: { audited: null } }); + expect(connection.lastOptions?.statementConf?.query_tags).to.equal('audited'); + }); + + it('executeStatement forwards namedParameters as napi namedParams ({name,sqlType,value})', async () => { + const connection = new FakeNativeConnection(); + const session = makeSession(connection); + await session.executeStatement('SELECT :x', { namedParameters: { x: 'hello' } }); + expect(connection.lastOptions?.namedParams).to.deep.equal([{ name: 'x', sqlType: 'STRING', value: 'hello' }]); + expect(connection.lastOptions?.positionalParams).to.equal(undefined); + }); + + it('executeStatement rejects mixing ordinal and named parameters', async () => { const connection = new FakeNativeConnection(); const session = makeSession(connection); let thrown: unknown; try { - await session.executeStatement('SELECT :x', { namedParameters: { x: 1 } }); + await session.executeStatement('SELECT ? AND :x', { ordinalParameters: [1], namedParameters: { x: 2 } }); } catch (err) { thrown = err; } - expect(thrown).to.be.instanceOf(HiveDriverError); - expect((thrown as Error).message).to.match(/parameters/); + expect(thrown).to.be.instanceOf(Error); + expect((thrown as Error).message).to.match(/both ordinal and named/); }); - it('executeStatement rejects ordinalParameters (M1)', async () => { - const connection = new FakeNativeConnection(); - const session = makeSession(connection); + it('executeStatement rejects an array-shaped ordinal parameter (DoS guard)', async () => { + const session = makeSession(new FakeNativeConnection()); let thrown: unknown; try { - await session.executeStatement('SELECT ?', { ordinalParameters: [1] }); + await session.executeStatement('SELECT ?', { ordinalParameters: [[1, 2, 3]] as never }); } catch (err) { thrown = err; } - expect(thrown).to.be.instanceOf(HiveDriverError); + expect(thrown).to.be.instanceOf(Error); + expect((thrown as Error).message).to.match(/array/); }); - it('executeStatement rejects queryTimeout (M1)', async () => { - const connection = new FakeNativeConnection(); - const session = makeSession(connection); + it('executeStatement rejects an ordinal-parameter count mismatch', async () => { + const session = makeSession(new FakeNativeConnection()); let thrown: unknown; try { - await session.executeStatement('SELECT 1', { queryTimeout: 30 }); + await session.executeStatement('SELECT ? AS only', { ordinalParameters: [1, 2] }); } catch (err) { thrown = err; } - expect(thrown).to.be.instanceOf(HiveDriverError); - expect((thrown as Error).message).to.match(/queryTimeout/); + expect(thrown).to.be.instanceOf(Error); + expect((thrown as Error).message).to.match(/does not match/); + }); + + it('getSchemas coerces empty-string args to undefined (Thrift-parity for the kernel)', async () => { + const connection = new FakeNativeConnection(); + const session = makeSession(connection); + await session.getSchemas({ catalogName: '', schemaName: '%' }); + expect(connection.lastListSchemasArgs).to.deep.equal([undefined, '%']); + }); + + it('executeStatement uses the no-options fast path when nothing is bound', async () => { + const connection = new FakeNativeConnection(); + const session = makeSession(connection); + await session.executeStatement('SELECT 1', {}); + expect(connection.lastOptions).to.equal(undefined); }); // Metadata-method happy-path and arg-routing coverage lives in @@ -401,6 +464,27 @@ describe('SeaSessionBackend', () => { expect(op).to.be.instanceOf(SeaOperationBackend); }); + it('getInfo synthesizes Thrift-identical values for the three answered info types', async () => { + const session = makeSession(new FakeNativeConnection()); + const { TGetInfoType } = require('../../../thrift/TCLIService_types'); // eslint-disable-line @typescript-eslint/no-var-requires, global-require, import/no-internal-modules + expect((await session.getInfo(TGetInfoType.CLI_DBMS_NAME)).getValue()).to.equal('Spark SQL'); + expect((await session.getInfo(TGetInfoType.CLI_DBMS_VER)).getValue()).to.equal('3.1.1'); + expect((await session.getInfo(TGetInfoType.CLI_SERVER_NAME)).getValue()).to.equal('Spark SQL'); + }); + + it('getInfo throws for info types the Thrift server does not answer', async () => { + const session = makeSession(new FakeNativeConnection()); + const { TGetInfoType } = require('../../../thrift/TCLIService_types'); // eslint-disable-line @typescript-eslint/no-var-requires, global-require, import/no-internal-modules + let thrown: unknown; + try { + await session.getInfo(TGetInfoType.CLI_MAX_DRIVER_CONNECTIONS); + } catch (err) { + thrown = err; + } + expect(thrown).to.be.instanceOf(HiveDriverError); + expect((thrown as Error).message).to.match(/not supported/); + }); + it('close() forwards to the native connection', async () => { const connection = new FakeNativeConnection(); const session = makeSession(connection); diff --git a/tests/unit/sea/inputValidation.test.ts b/tests/unit/sea/inputValidation.test.ts new file mode 100644 index 00000000..b7766d52 --- /dev/null +++ b/tests/unit/sea/inputValidation.test.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Databricks, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { expect } from 'chai'; +import Int64 from 'node-int64'; +import { + emptyToUndefined, + countParameterMarkers, + assertBindableValue, +} from '../../../lib/sea/SeaInputValidation'; +import { DBSQLParameter, DBSQLParameterType } from '../../../lib/DBSQLParameter'; +import ParameterError from '../../../lib/errors/ParameterError'; + +describe('SeaInputValidation.emptyToUndefined', () => { + it('maps empty string and null/undefined to undefined; passes real values', () => { + expect(emptyToUndefined('')).to.equal(undefined); + expect(emptyToUndefined(null)).to.equal(undefined); + expect(emptyToUndefined(undefined)).to.equal(undefined); + expect(emptyToUndefined('samples')).to.equal('samples'); + expect(emptyToUndefined('%')).to.equal('%'); + }); +}); + +describe('SeaInputValidation.countParameterMarkers', () => { + it('counts bare markers', () => { + expect(countParameterMarkers('SELECT ?')).to.equal(1); + expect(countParameterMarkers('SELECT * FROM t WHERE a = ? AND b = ?')).to.equal(2); + expect(countParameterMarkers('SELECT 1')).to.equal(0); + }); + + it('ignores markers inside string literals, identifiers, and comments', () => { + expect(countParameterMarkers("SELECT '?' AS q")).to.equal(0); + expect(countParameterMarkers('SELECT "?" AS q')).to.equal(0); + expect(countParameterMarkers('SELECT `a?b` FROM t')).to.equal(0); + expect(countParameterMarkers('SELECT 1 -- ? in a line comment\n, ?')).to.equal(1); + expect(countParameterMarkers('SELECT /* ? in block */ ?')).to.equal(1); + expect(countParameterMarkers("SELECT 'it''s ?' , ?")).to.equal(1); // escaped quote + }); +}); + +describe('SeaInputValidation.assertBindableValue', () => { + it('accepts scalars, Date, Int64, bigint, null, and DBSQLParameter', () => { + expect(() => assertBindableValue(42, 'p')).to.not.throw(); + expect(() => assertBindableValue('x', 'p')).to.not.throw(); + expect(() => assertBindableValue(true, 'p')).to.not.throw(); + expect(() => assertBindableValue(BigInt(10), 'p')).to.not.throw(); + expect(() => assertBindableValue(null, 'p')).to.not.throw(); + expect(() => assertBindableValue(new Date(), 'p')).to.not.throw(); + expect(() => assertBindableValue(new Int64(5), 'p')).to.not.throw(); + expect(() => assertBindableValue(new DBSQLParameter({ type: DBSQLParameterType.INTEGER, value: 1 }), 'p')).to.not.throw(); + }); + + it('rejects arrays (compound types)', () => { + expect(() => assertBindableValue([1, 2, 3] as never, 'ordinalParameters[0]')).to.throw(ParameterError, /array/); + }); + + it('rejects plain objects', () => { + expect(() => assertBindableValue({ a: 1 } as never, 'p')).to.throw(ParameterError, /object/); + }); +}); diff --git a/tests/unit/sea/positionalParams.test.ts b/tests/unit/sea/positionalParams.test.ts new file mode 100644 index 00000000..bd10b0f8 --- /dev/null +++ b/tests/unit/sea/positionalParams.test.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Databricks, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { expect } from 'chai'; +import { buildSeaPositionalParams, buildSeaNamedParams } from '../../../lib/sea/SeaPositionalParams'; +import { DBSQLParameter, DBSQLParameterType } from '../../../lib/DBSQLParameter'; + +describe('SeaPositionalParams.buildSeaPositionalParams', () => { + it('returns undefined for no params (keeps the no-options fast path)', () => { + expect(buildSeaPositionalParams(undefined)).to.equal(undefined); + expect(buildSeaPositionalParams([])).to.equal(undefined); + }); + + it('infers types from raw values, matching DBSQLParameter rules', () => { + expect(buildSeaPositionalParams([42, 'hello', true])).to.deep.equal([ + { sqlType: 'INTEGER', value: '42' }, + { sqlType: 'STRING', value: 'hello' }, + { sqlType: 'BOOLEAN', value: 'TRUE' }, + ]); + }); + + it('emits DECIMAL in the parenthesised DECIMAL(p,s) form the kernel codec requires', () => { + expect( + buildSeaPositionalParams([new DBSQLParameter({ type: DBSQLParameterType.DECIMAL, value: '99.99' })]), + ).to.deep.equal([{ sqlType: 'DECIMAL(4,2)', value: '99.99' }]); + expect( + buildSeaPositionalParams([new DBSQLParameter({ type: DBSQLParameterType.DECIMAL, value: '-123' })]), + ).to.deep.equal([{ sqlType: 'DECIMAL(3,0)', value: '-123' }]); + }); + + it('maps NULL to a value-less VOID input', () => { + expect(buildSeaPositionalParams([null])).to.deep.equal([{ sqlType: 'VOID' }]); + }); + + it('honours explicit DATE / TIMESTAMP types', () => { + expect( + buildSeaPositionalParams([ + new DBSQLParameter({ type: DBSQLParameterType.DATE, value: '2024-01-15' }), + new DBSQLParameter({ type: DBSQLParameterType.TIMESTAMP, value: '2024-01-15 10:30:00' }), + ]), + ).to.deep.equal([ + { sqlType: 'DATE', value: '2024-01-15' }, + { sqlType: 'TIMESTAMP', value: '2024-01-15 10:30:00' }, + ]); + }); +}); + +describe('SeaPositionalParams.buildSeaNamedParams', () => { + it('returns undefined for no named params', () => { + expect(buildSeaNamedParams(undefined)).to.equal(undefined); + expect(buildSeaNamedParams({})).to.equal(undefined); + }); + + it('emits {name, sqlType, value} triples, reusing the same type mapping', () => { + expect( + buildSeaNamedParams({ + n: 42, + s: 'hello', + d: new DBSQLParameter({ type: DBSQLParameterType.DECIMAL, value: '99.99' }), + }), + ).to.deep.include.members([ + { name: 'n', sqlType: 'INTEGER', value: '42' }, + { name: 's', sqlType: 'STRING', value: 'hello' }, + { name: 'd', sqlType: 'DECIMAL(4,2)', value: '99.99' }, + ]); + }); + + it('maps a named NULL to a value-less VOID input (with the name)', () => { + expect(buildSeaNamedParams({ x: null })).to.deep.equal([{ name: 'x', sqlType: 'VOID' }]); + }); +}); diff --git a/tests/unit/sea/serverInfo.test.ts b/tests/unit/sea/serverInfo.test.ts new file mode 100644 index 00000000..8733c21f --- /dev/null +++ b/tests/unit/sea/serverInfo.test.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Databricks, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { expect } from 'chai'; +import { + seaServerInfoValue, + SEA_DBMS_NAME, + SEA_SERVER_NAME, + SEA_DBMS_VERSION, +} from '../../../lib/sea/SeaServerInfo'; +import { TGetInfoType } from '../../../thrift/TCLIService_types'; + +describe('SeaServerInfo.seaServerInfoValue', () => { + it('CLI_DBMS_NAME matches Thrift exactly ("Spark SQL")', () => { + expect(seaServerInfoValue(TGetInfoType.CLI_DBMS_NAME)?.stringValue).to.equal('Spark SQL'); + expect(SEA_DBMS_NAME).to.equal('Spark SQL'); + }); + + it('CLI_DBMS_VER matches the Thrift server version constant', () => { + expect(seaServerInfoValue(TGetInfoType.CLI_DBMS_VER)?.stringValue).to.equal('3.1.1'); + expect(SEA_DBMS_VERSION).to.equal('3.1.1'); + }); + + it('CLI_SERVER_NAME matches Thrift exactly ("Spark SQL")', () => { + const v = seaServerInfoValue(TGetInfoType.CLI_SERVER_NAME)?.stringValue; + expect(v).to.equal(SEA_SERVER_NAME); + expect(v).to.equal('Spark SQL'); + }); + + it('all three answered info types are byte-identical to Thrift', () => { + expect(seaServerInfoValue(TGetInfoType.CLI_SERVER_NAME)?.stringValue).to.equal('Spark SQL'); + expect(seaServerInfoValue(TGetInfoType.CLI_DBMS_NAME)?.stringValue).to.equal('Spark SQL'); + expect(seaServerInfoValue(TGetInfoType.CLI_DBMS_VER)?.stringValue).to.equal('3.1.1'); + }); + + it('returns undefined for info types the Thrift server rejects (e.g. CLI_MAX_DRIVER_CONNECTIONS)', () => { + expect(seaServerInfoValue(TGetInfoType.CLI_MAX_DRIVER_CONNECTIONS)).to.equal(undefined); + expect(seaServerInfoValue(TGetInfoType.CLI_USER_NAME)).to.equal(undefined); + expect(seaServerInfoValue(99999)).to.equal(undefined); + }); +});