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