From 2502664186bf370562c10cdd992406962264fddb Mon Sep 17 00:00:00 2001 From: Matthew Erwin Date: Thu, 20 Mar 2025 21:41:29 -0400 Subject: [PATCH 01/15] feature: Json data type --- src/data-type.ts | 12 +++++++- src/data-types/json.ts | 63 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 src/data-types/json.ts diff --git a/src/data-type.ts b/src/data-type.ts index ae0e4447b..b010cd45c 100644 --- a/src/data-type.ts +++ b/src/data-type.ts @@ -41,6 +41,7 @@ import { type CryptoMetadata } from './always-encrypted/types'; import { type InternalConnectionOptions } from './connection'; import { Collation } from './collation'; +import Json from './data-types/json'; export interface Parameter { type: DataType; @@ -129,6 +130,7 @@ export const TYPE = { [UDT.id]: UDT, [TVP.id]: TVP, [Variant.id]: Variant, + [Json.id]: Json }; /** @@ -400,6 +402,13 @@ export const TYPE = { * ✓ * - * + * + * json + * [[TYPES.Json]] + * string|object + * ✓ + * - + * * * * @@ -467,7 +476,8 @@ export const TYPES = { DateTimeOffset, UDT, TVP, - Variant + Variant, + Json }; export const typeByName = TYPES; diff --git a/src/data-types/json.ts b/src/data-types/json.ts new file mode 100644 index 000000000..c150275cb --- /dev/null +++ b/src/data-types/json.ts @@ -0,0 +1,63 @@ +import { type DataType } from '../data-type'; +const UNKNOWN_PLP_LEN = Buffer.from([0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]); +const PLP_TERMINATOR = Buffer.from([0x00, 0x00, 0x00, 0x00]); +const MAX_NULL_LENGTH = Buffer.from([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]); + +const Json: DataType = { + id: 0xF4, + type: 'JSON', + name: 'Json', + + declaration: (parameter) => { + return 'json'; + }, + resolveLength: (parameter) => { + if ((parameter.value instanceof String || typeof (parameter.value) == 'string')) + return parameter.value.length; + else return JSON.stringify(parameter.value).length; + }, + + generateTypeInfo: (parameter) => { + const buffer = Buffer.alloc(1); + buffer.writeUInt8(0xF4, 0); + return buffer; + }, + + generateParameterLength: (parameter, options) => { + const value = parameter.value as Buffer | null; + + if (value == null) { + return MAX_NULL_LENGTH; + } + return UNKNOWN_PLP_LEN; + }, + + generateParameterData: function* (parameter, options) { + const value = parameter.value as Buffer | null; + + if (value == null || (value?.length ?? 0) === 0) { + return; + } + + const buffer = Buffer.alloc(4); + buffer.writeUInt32LE(value.length, 0); + yield buffer; + yield value; + yield PLP_TERMINATOR; + }, + + validate: (value, collation): Buffer | null => { + if (value == null) { + return null; + } + + if (!(value instanceof String || typeof (value) == 'string')) + value = JSON.stringify(value); + else JSON.parse(value as string); // validate parsing + + return Buffer.from(value, 'utf-8'); + } +}; + +export default Json; +module.exports = Json; From 9ffc9a9d79ee996c32fa5f00baacd4f33f37b8f6 Mon Sep 17 00:00:00 2001 From: Matthew Erwin Date: Thu, 20 Mar 2025 22:38:37 -0400 Subject: [PATCH 02/15] minor optimization --- src/data-types/json.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/data-types/json.ts b/src/data-types/json.ts index c150275cb..32c074f6f 100644 --- a/src/data-types/json.ts +++ b/src/data-types/json.ts @@ -17,15 +17,12 @@ const Json: DataType = { else return JSON.stringify(parameter.value).length; }, - generateTypeInfo: (parameter) => { - const buffer = Buffer.alloc(1); - buffer.writeUInt8(0xF4, 0); - return buffer; + generateTypeInfo(parameter) { + return Buffer.from([this.id]); }, generateParameterLength: (parameter, options) => { const value = parameter.value as Buffer | null; - if (value == null) { return MAX_NULL_LENGTH; } @@ -34,7 +31,6 @@ const Json: DataType = { generateParameterData: function* (parameter, options) { const value = parameter.value as Buffer | null; - if (value == null || (value?.length ?? 0) === 0) { return; } From 127dd313ddb1196a80e55536404bc0c1abc3db2e Mon Sep 17 00:00:00 2001 From: Arthur Schreiber Date: Fri, 17 Jul 2026 16:30:42 +0000 Subject: [PATCH 03/15] fix: always emit PLP terminator for non-null json parameter values The unknown-length PLP header emitted by generateParameterLength obligates a terminator, but zero-length values returned without yielding one, producing a malformed TDS stream. Co-Authored-By: Claude Fable 5 --- src/data-types/json.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/data-types/json.ts b/src/data-types/json.ts index 32c074f6f..2b31bb24b 100644 --- a/src/data-types/json.ts +++ b/src/data-types/json.ts @@ -31,14 +31,17 @@ const Json: DataType = { generateParameterData: function* (parameter, options) { const value = parameter.value as Buffer | null; - if (value == null || (value?.length ?? 0) === 0) { + if (value == null) { return; } - const buffer = Buffer.alloc(4); - buffer.writeUInt32LE(value.length, 0); - yield buffer; - yield value; + if (value.length > 0) { + const buffer = Buffer.alloc(4); + buffer.writeUInt32LE(value.length, 0); + yield buffer; + yield value; + } + yield PLP_TERMINATOR; }, From d53f03978cc686c1e07fade27f35ecceb88bca5c Mon Sep 17 00:00:00 2001 From: Arthur Schreiber Date: Fri, 17 Jul 2026 16:32:00 +0000 Subject: [PATCH 04/15] fix: reject non-serializable json parameter values with a clear error Buffers previously took the JSON.stringify branch and were silently mangled into their internal representation, and values that stringify to undefined (functions, symbols) produced a confusing error from Buffer.from. Both now throw an explicit TypeError. Also drop resolveLength: it ran after validate() had already replaced the value with a Buffer, so it returned the length of the Buffer's JSON.stringify representation. Nothing consumes the length for this type. Co-Authored-By: Claude Fable 5 --- src/data-types/json.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/data-types/json.ts b/src/data-types/json.ts index 2b31bb24b..9807fb63c 100644 --- a/src/data-types/json.ts +++ b/src/data-types/json.ts @@ -11,11 +11,6 @@ const Json: DataType = { declaration: (parameter) => { return 'json'; }, - resolveLength: (parameter) => { - if ((parameter.value instanceof String || typeof (parameter.value) == 'string')) - return parameter.value.length; - else return JSON.stringify(parameter.value).length; - }, generateTypeInfo(parameter) { return Buffer.from([this.id]); @@ -50,11 +45,21 @@ const Json: DataType = { return null; } - if (!(value instanceof String || typeof (value) == 'string')) - value = JSON.stringify(value); - else JSON.parse(value as string); // validate parsing + if (typeof value === 'string') { + JSON.parse(value); + return Buffer.from(value, 'utf-8'); + } + + if (Buffer.isBuffer(value)) { + throw new TypeError('Invalid JSON value.'); + } + + const serialized = JSON.stringify(value); + if (serialized === undefined) { + throw new TypeError('Invalid JSON value.'); + } - return Buffer.from(value, 'utf-8'); + return Buffer.from(serialized, 'utf-8'); } }; From 0630b268dda51bea154be2a8802931f88b65ee1b Mon Sep 17 00:00:00 2001 From: Arthur Schreiber Date: Fri, 17 Jul 2026 16:43:12 +0000 Subject: [PATCH 05/15] feat: negotiate JSONSUPPORT and support reading json values Per MS-TDS, the json data type (0xF4) is enabled through the JSONSUPPORT feature extension (0x0D). Request version 1 in the LOGIN7 feature extension block, parse the server's acknowledgement, and track it as Connection#serverSupportsJson. Sending a TYPES.Json parameter to a server that did not acknowledge the feature now fails client-side with a descriptive RequestError instead of a cryptic protocol error from the server. Also add the read path: json TYPE_INFO carries no additional metadata (no length or collation bytes), and values arrive as PLP-encoded UTF-8 strings in ROW, NBCROW and RETURNVALUE tokens. Co-Authored-By: Claude Fable 5 --- src/connection.ts | 20 ++++++++++++++++++++ src/login7-payload.ts | 11 +++++++++++ src/metadata-parser.ts | 15 +++++++++++++++ src/token/feature-ext-ack-parser.ts | 8 +++++++- src/token/handler.ts | 6 +++++- src/token/nbcrow-token-parser.ts | 2 ++ src/token/returnvalue-token-parser.ts | 2 ++ src/token/row-token-parser.ts | 2 ++ src/token/token.ts | 8 +++++++- src/value-parser.ts | 4 ++++ test/unit/login7-payload-test.ts | 5 +++-- 11 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/connection.ts b/src/connection.ts index 0ff2f90e3..6209370c6 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -1069,6 +1069,15 @@ class Connection extends EventEmitter { */ declare databaseCollation: Collation | undefined; + /** + * Whether the server acknowledged the JSON_SUPPORT feature extension, + * i.e. whether the `json` data type can be sent to and received from + * the server. + * + * @private + */ + declare serverSupportsJson: boolean; + /** * @private */ @@ -1805,6 +1814,7 @@ class Connection extends EventEmitter { this.state = this.STATE.INITIALIZED; this.attentionSent = false; + this.serverSupportsJson = false; this._cancelAfterRequestSent = () => { this.messageIo.sendMessage(TYPE.ATTENTION); @@ -2476,6 +2486,10 @@ class Connection extends EventEmitter { * @private */ sendLogin7Packet() { + // A new login attempt (e.g. after a transient failure or rerouting) + // must re-negotiate JSON support from scratch. + this.serverSupportsJson = false; + const payload = new Login7Payload({ tdsVersion: versions[this.config.options.tdsVersion], packetSize: this.config.options.packetSize, @@ -3209,6 +3223,12 @@ class Connection extends EventEmitter { process.nextTick(() => { request.callback(new RequestError('Canceled.', 'ECANCEL')); }); + } else if (payload instanceof RpcRequestPayload && !this.serverSupportsJson && payload.parameters.some((parameter) => parameter.type === TYPES.Json)) { + const message = 'The server does not support the `json` data type. `TYPES.Json` parameters require SQL Server 2025 (or newer) or Azure SQL with JSON support enabled.'; + this.debug.log(message); + process.nextTick(() => { + request.callback(new RequestError(message, 'EJSONNOTSUPPORTED')); + }); } else { if (packetType === TYPE.SQL_BATCH) { this.isSqlBatch = true; diff --git a/src/login7-payload.ts b/src/login7-payload.ts index 923da8102..1dbb3030c 100644 --- a/src/login7-payload.ts +++ b/src/login7-payload.ts @@ -444,6 +444,17 @@ class Login7Payload { buf.writeUInt8(UTF8_SUPPORT_CLIENT_SUPPORTS_UTF8, 5); buffers.push(buf); + // Signal JSON support: Value 0x0D, the single data byte is the highest + // JSON version the client supports. The server only sends or accepts the + // `json` data type (0xF4) after acknowledging this feature. + const JSON_SUPPORT_FEATURE_ID = 0x0d; + const JSON_SUPPORT_MAX_VERSION = 0x01; + const jsonSupportBuf = Buffer.alloc(6); + jsonSupportBuf.writeUInt8(JSON_SUPPORT_FEATURE_ID, 0); + jsonSupportBuf.writeUInt32LE(1, 1); + jsonSupportBuf.writeUInt8(JSON_SUPPORT_MAX_VERSION, 5); + buffers.push(jsonSupportBuf); + buffers.push(Buffer.from([FEATURE_EXT_TERMINATOR])); return Buffer.concat(buffers); diff --git a/src/metadata-parser.ts b/src/metadata-parser.ts index 8dc33e801..beacef110 100644 --- a/src/metadata-parser.ts +++ b/src/metadata-parser.ts @@ -272,6 +272,21 @@ function readMetadata(buf: Buffer, offset: number, options: ParserOptions): Resu }, offset); } + case 'Json': + // The `json` TYPE_INFO carries no additional metadata — no length + // and no collation (the collation is fixed to UTF-8 per RFC 8259). + return new Result({ + userType: userType, + flags: flags, + type: type, + collation: undefined, + precision: undefined, + scale: undefined, + dataLength: undefined, + schema: undefined, + udtInfo: undefined + }, offset); + case 'Xml': { let schema; ({ offset, value: schema } = readSchema(buf, offset)); diff --git a/src/token/feature-ext-ack-parser.ts b/src/token/feature-ext-ack-parser.ts index 0e6a5d034..f9a22862b 100644 --- a/src/token/feature-ext-ack-parser.ts +++ b/src/token/feature-ext-ack-parser.ts @@ -10,19 +10,21 @@ const FEATURE_ID = { GLOBALTRANSACTIONS: 0x05, AZURESQLSUPPORT: 0x08, UTF8_SUPPORT: 0x0A, + JSON_SUPPORT: 0x0D, TERMINATOR: 0xFF }; function featureExtAckParser(buf: Buffer, offset: number, _options: ParserOptions): Result { let fedAuth: Buffer | undefined; let utf8Support: boolean | undefined; + let jsonSupport: boolean | undefined; while (true) { let featureId; ({ value: featureId, offset } = readUInt8(buf, offset)); if (featureId === FEATURE_ID.TERMINATOR) { - return new Result(new FeatureExtAckToken(fedAuth, utf8Support), offset); + return new Result(new FeatureExtAckToken(fedAuth, utf8Support, jsonSupport), offset); } let featureAckDataLen; @@ -42,6 +44,10 @@ function featureExtAckParser(buf: Buffer, offset: number, _options: ParserOption case FEATURE_ID.UTF8_SUPPORT: utf8Support = !!featureData[0]; break; + case FEATURE_ID.JSON_SUPPORT: + // The single data byte is the JSON version chosen by the server. + jsonSupport = featureData[0] === 0x01; + break; } } } diff --git a/src/token/handler.ts b/src/token/handler.ts index 1d0174f41..a1b511631 100644 --- a/src/token/handler.ts +++ b/src/token/handler.ts @@ -310,13 +310,17 @@ export class Login7TokenHandler extends TokenHandler { onFeatureExtAck(token: FeatureExtAckToken) { const { authentication } = this.connection.config; + if (token.jsonSupport !== undefined) { + this.connection.serverSupportsJson = token.jsonSupport; + } + if (authentication.type === 'azure-active-directory-password' || authentication.type === 'azure-active-directory-access-token' || authentication.type === 'azure-active-directory-msi-vm' || authentication.type === 'azure-active-directory-msi-app-service' || authentication.type === 'azure-active-directory-service-principal-secret' || authentication.type === 'azure-active-directory-default') { if (token.fedAuth === undefined) { this.loginError = new ConnectionError('Did not receive Active Directory authentication acknowledgement'); } else if (token.fedAuth.length !== 0) { this.loginError = new ConnectionError(`Active Directory authentication acknowledgment for ${authentication.type} authentication method includes extra data`); } - } else if (token.fedAuth === undefined && token.utf8Support === undefined) { + } else if (token.fedAuth === undefined && token.utf8Support === undefined && token.jsonSupport === undefined) { this.loginError = new ConnectionError('Received acknowledgement for unknown feature'); } else if (token.fedAuth) { this.loginError = new ConnectionError('Did not request Active Directory authentication, but received the acknowledgment'); diff --git a/src/token/nbcrow-token-parser.ts b/src/token/nbcrow-token-parser.ts index 0b8d07468..fdb51810f 100644 --- a/src/token/nbcrow-token-parser.ts +++ b/src/token/nbcrow-token-parser.ts @@ -59,6 +59,8 @@ async function nbcRowParser(parser: Parser): Promise { columns.push({ value: iconv.decode(Buffer.concat(chunks), metadata.collation?.codepage ?? 'utf8'), metadata }); } else if (metadata.type.name === 'VarBinary' || metadata.type.name === 'UDT') { columns.push({ value: Buffer.concat(chunks), metadata }); + } else if (metadata.type.name === 'Json') { + columns.push({ value: Buffer.concat(chunks).toString('utf8'), metadata }); } } else { let result; diff --git a/src/token/returnvalue-token-parser.ts b/src/token/returnvalue-token-parser.ts index bcfa14ced..ba29fe429 100644 --- a/src/token/returnvalue-token-parser.ts +++ b/src/token/returnvalue-token-parser.ts @@ -57,6 +57,8 @@ async function returnParser(parser: Parser): Promise { value = iconv.decode(Buffer.concat(chunks), metadata.collation?.codepage ?? 'utf8'); } else if (metadata.type.name === 'VarBinary' || metadata.type.name === 'UDT') { value = Buffer.concat(chunks); + } else if (metadata.type.name === 'Json') { + value = Buffer.concat(chunks).toString('utf8'); } } else { try { diff --git a/src/token/row-token-parser.ts b/src/token/row-token-parser.ts index f394dbf97..007fafdf3 100644 --- a/src/token/row-token-parser.ts +++ b/src/token/row-token-parser.ts @@ -30,6 +30,8 @@ async function rowParser(parser: Parser): Promise { columns.push({ value: iconv.decode(Buffer.concat(chunks), metadata.collation?.codepage ?? 'utf8'), metadata }); } else if (metadata.type.name === 'VarBinary' || metadata.type.name === 'UDT') { columns.push({ value: Buffer.concat(chunks), metadata }); + } else if (metadata.type.name === 'Json') { + columns.push({ value: Buffer.concat(chunks).toString('utf8'), metadata }); } } else { let result; diff --git a/src/token/token.ts b/src/token/token.ts index 222d2bf02..cb10a8a0a 100644 --- a/src/token/token.ts +++ b/src/token/token.ts @@ -332,11 +332,17 @@ export class FeatureExtAckToken extends Token { * undefined when UTF8_SUPPORT not included in token. */ declare utf8Support: boolean | undefined; - constructor(fedAuth: Buffer | undefined, utf8Support: boolean | undefined) { + /** Value of JSON_SUPPORT acknowledgement. + * + * undefined when JSON_SUPPORT not included in token. */ + declare jsonSupport: boolean | undefined; + + constructor(fedAuth: Buffer | undefined, utf8Support: boolean | undefined, jsonSupport: boolean | undefined) { super('FEATUREEXTACK', 'onFeatureExtAck'); this.fedAuth = fedAuth; this.utf8Support = utf8Support; + this.jsonSupport = jsonSupport; } } diff --git a/src/value-parser.ts b/src/value-parser.ts index 32ba6d1e7..0dceb3f17 100644 --- a/src/value-parser.ts +++ b/src/value-parser.ts @@ -410,6 +410,10 @@ function isPLPStream(metadata: Metadata) { case 'UDT': { return true; } + + case 'Json': { + return true; + } } } diff --git a/test/unit/login7-payload-test.ts b/test/unit/login7-payload-test.ts index fcff768c4..8d37cb3d2 100644 --- a/test/unit/login7-payload-test.ts +++ b/test/unit/login7-payload-test.ts @@ -175,7 +175,7 @@ describe('Login7Payload', function() { 2 + 2 + (2 * payload.changePassword.length) + 4 + // cbSSPILong 4 + // Extension offset - 1 + (1 + 4 + 1) + (1 + 4 + 1) + 1; // Feature ext - v7.4 includes UTF8_SUPPORT unlike prior versions + 1 + (1 + 4 + 1) + (1 + 4 + 1) + (1 + 4 + 1) + 1; // Feature ext - v7.4 includes UTF8_SUPPORT and JSON_SUPPORT unlike prior versions assert.lengthOf(data, expectedLength); }); @@ -230,6 +230,7 @@ describe('Login7Payload', function() { 4 + // Extension offset (1 + 4 + 1 + 4 + (token.length * 2)) + // SECURITYTOKEN feature (1 + 4 + 1) + // UTF8_SUPPORT feature + (1 + 4 + 1) + // JSON_SUPPORT feature 1; // Terminator assert.lengthOf(data, expectedLength); @@ -282,7 +283,7 @@ describe('Login7Payload', function() { 2 + 2 + (2 * payload.changePassword.length) + 4 + // cbSSPILong 4 + // Extension offset - 1 + (1 + 4 + 1) + (1 + 4 + 1) + 1; // Feature ext - v7.4 includes UTF8_SUPPORT unlike prior versions + 1 + (1 + 4 + 1) + (1 + 4 + 1) + (1 + 4 + 1) + 1; // Feature ext - v7.4 includes UTF8_SUPPORT and JSON_SUPPORT unlike prior versions assert.lengthOf(data, expectedLength); }); From 527cea210ef56f4172fcc706886078c254d8cee2 Mon Sep 17 00:00:00 2001 From: Arthur Schreiber Date: Fri, 17 Jul 2026 16:48:23 +0000 Subject: [PATCH 06/15] test: cover the json data type Unit coverage for the Json type's wire encoding (type info, PLP parameter length and data, validation), the JSON_SUPPORT feature extension acknowledgement, and parsing of json COLMETADATA and PLP-encoded json values in ROW and NBCROW tokens. Integration tests exercise both server capabilities: round-trips of string, object and null parameters plus json result columns on servers that acknowledge JSON_SUPPORT, and the client-side EJSONNOTSUPPORTED error on servers that do not. Co-Authored-By: Claude Fable 5 --- test/integration/json-test.ts | 137 ++++++++++++++++++ test/unit/data-type.ts | 86 +++++++++++ .../token/colmetadata-token-parser-test.ts | 30 ++++ test/unit/token/feature-ext-parser-test.ts | 25 ++++ test/unit/token/nbcrow-token-parser-test.ts | 37 +++++ test/unit/token/row-token-parser-test.ts | 66 +++++++++ 6 files changed, 381 insertions(+) create mode 100644 test/integration/json-test.ts diff --git a/test/integration/json-test.ts b/test/integration/json-test.ts new file mode 100644 index 000000000..a63b1827e --- /dev/null +++ b/test/integration/json-test.ts @@ -0,0 +1,137 @@ +import { assert } from 'chai'; + +import Connection from '../../src/connection'; +import Request from '../../src/request'; +import { typeByName as TYPES } from '../../src/data-type'; +import { debugOptionsFromEnv } from '../helpers/debug-options-from-env'; + +import defaultConfig from '../config'; + +const config = { + ...defaultConfig, + options: { + ...defaultConfig.options, + debug: debugOptionsFromEnv(), + tdsVersion: process.env.TEDIOUS_TDS_VERSION + } +}; + +describe('json data type', function() { + let connection: Connection; + + beforeEach(function(done) { + connection = new Connection(config); + + if (process.env.TEDIOUS_DEBUG) { + connection.on('debug', console.log); + } + + connection.connect(done); + }); + + afterEach(function(done) { + if (!connection.closed) { + connection.on('end', done); + connection.close(); + } else { + done(); + } + }); + + describe('on servers that support the json data type', function() { + beforeEach(function() { + if (!connection.serverSupportsJson) { + this.skip(); + } + }); + + it('returns json columns as strings', function(done) { + const request = new Request('SELECT CAST(\'{"a":[1,"ü"]}\' AS json)', (err) => { + done(err); + }); + + request.on('row', (columns) => { + assert.strictEqual(columns[0].metadata.type.name, 'Json'); + assert.isString(columns[0].value); + assert.deepEqual(JSON.parse(columns[0].value), { a: [1, 'ü'] }); + }); + + connection.execSql(request); + }); + + it('returns null json values as `null`', function(done) { + const request = new Request('SELECT CAST(NULL AS json)', (err) => { + done(err); + }); + + request.on('row', (columns) => { + assert.strictEqual(columns[0].metadata.type.name, 'Json'); + assert.isNull(columns[0].value); + }); + + connection.execSql(request); + }); + + it('round-trips string parameter values', function(done) { + const value = '{"a":[1,"ü"],"b":null}'; + + const request = new Request('SELECT @p', (err) => { + done(err); + }); + request.addParameter('p', TYPES.Json, value); + + request.on('row', (columns) => { + assert.deepEqual(JSON.parse(columns[0].value), JSON.parse(value)); + }); + + connection.execSql(request); + }); + + it('round-trips object parameter values', function(done) { + const value = { a: [1, 'ü'], b: null }; + + const request = new Request('SELECT @p', (err) => { + done(err); + }); + request.addParameter('p', TYPES.Json, value); + + request.on('row', (columns) => { + assert.deepEqual(JSON.parse(columns[0].value), value); + }); + + connection.execSql(request); + }); + + it('round-trips `null` parameter values', function(done) { + const request = new Request('SELECT @p', (err) => { + done(err); + }); + request.addParameter('p', TYPES.Json, null); + + request.on('row', (columns) => { + assert.isNull(columns[0].value); + }); + + connection.execSql(request); + }); + }); + + describe('on servers that do not support the json data type', function() { + beforeEach(function() { + if (connection.serverSupportsJson) { + this.skip(); + } + }); + + it('fails json parameters with a descriptive error', function(done) { + const request = new Request('SELECT @p', (err) => { + assert.instanceOf(err, Error); + assert.strictEqual((err as any).code, 'EJSONNOTSUPPORTED'); + done(); + }); + request.addParameter('p', TYPES.Json, '{"a":1}'); + + connection.execSql(request); + }); + }); +}); diff --git a/test/unit/data-type.ts b/test/unit/data-type.ts index 7263f6450..e904be96a 100644 --- a/test/unit/data-type.ts +++ b/test/unit/data-type.ts @@ -1679,3 +1679,89 @@ describe('VarChar', function() { }); }); }); + +describe('Json', function() { + describe('.declaration', function() { + it('returns "json"', function() { + assert.strictEqual(TYPES.Json.declaration({ value: '{}' } as any), 'json'); + }); + }); + + describe('.generateTypeInfo', function() { + it('returns the JSON type token without additional metadata', function() { + const result = TYPES.Json.generateTypeInfo({ value: null }, options); + assert.deepEqual(result, Buffer.from([0xF4])); + }); + }); + + describe('.generateParameterLength', function() { + it('returns the PLP null length for `null` values', function() { + const result = TYPES.Json.generateParameterLength({ value: null }, options); + assert.deepEqual(result, Buffer.from('ffffffffffffffff', 'hex')); + }); + + it('returns the unknown PLP length for non-null values', function() { + const result = TYPES.Json.generateParameterLength({ value: Buffer.from('{"a":1}') }, options); + assert.deepEqual(result, Buffer.from('feffffffffffffff', 'hex')); + }); + }); + + describe('.generateParameterData', function() { + it('generates no data for `null` values', function() { + const buffer = Buffer.concat([...TYPES.Json.generateParameterData({ value: null }, options)]); + assert.deepEqual(buffer, Buffer.alloc(0)); + }); + + it('generates only the PLP terminator for empty values', function() { + const buffer = Buffer.concat([...TYPES.Json.generateParameterData({ value: Buffer.alloc(0) }, options)]); + assert.deepEqual(buffer, Buffer.from('00000000', 'hex')); + }); + + it('generates a single length-prefixed chunk followed by the PLP terminator', function() { + const value = Buffer.from('{"a":1}', 'utf8'); + const buffer = Buffer.concat([...TYPES.Json.generateParameterData({ value: value }, options)]); + + const expected = Buffer.concat([ + Buffer.from('07000000', 'hex'), + value, + Buffer.from('00000000', 'hex') + ]); + assert.deepEqual(buffer, expected); + }); + }); + + describe('.validate', function() { + it('returns `null` for `null` and `undefined` values', function() { + assert.isNull(TYPES.Json.validate(null, undefined)); + assert.isNull(TYPES.Json.validate(undefined, undefined)); + }); + + it('returns the UTF-8 encoded value for strings containing valid JSON', function() { + const result = TYPES.Json.validate('{"a":"ü"}', undefined); + assert.deepEqual(result, Buffer.from('{"a":"ü"}', 'utf8')); + }); + + it('throws for strings that do not contain valid JSON', function() { + assert.throws(() => { + TYPES.Json.validate('{oops', undefined); + }, SyntaxError); + }); + + it('serializes objects and arrays to their UTF-8 encoded JSON representation', function() { + assert.deepEqual(TYPES.Json.validate({ a: [1, 'ü'] }, undefined), Buffer.from('{"a":[1,"ü"]}', 'utf8')); + assert.deepEqual(TYPES.Json.validate([1, 2], undefined), Buffer.from('[1,2]', 'utf8')); + }); + + it('throws for Buffer values', function() { + assert.throws(() => { + TYPES.Json.validate(Buffer.from('{"a":1}'), undefined); + }, TypeError, 'Invalid JSON value.'); + }); + + it('throws for values that can not be serialized to JSON', function() { + assert.throws(() => { + TYPES.Json.validate(() => {}, undefined); + }, TypeError, 'Invalid JSON value.'); + }); + }); +}); diff --git a/test/unit/token/colmetadata-token-parser-test.ts b/test/unit/token/colmetadata-token-parser-test.ts index a45415136..73046a44c 100644 --- a/test/unit/token/colmetadata-token-parser-test.ts +++ b/test/unit/token/colmetadata-token-parser-test.ts @@ -122,4 +122,34 @@ describe('Colmetadata Token Parser', function() { assert.strictEqual(token.columns[0].colName, 'name'); assert.strictEqual(token.columns[0].dataLength, length); }); + + it('should parse json column metadata', async function() { + const debug = new Debug(); + const numberOfColumns = 1; + const userType = 0; + const flags = 0; + const columnName = 'col1'; + + const buffer = new WritableTrackingBuffer(50, 'ucs2'); + + buffer.writeUInt8(0x81); + buffer.writeUInt16LE(numberOfColumns); + buffer.writeUInt32LE(userType); + buffer.writeUInt16LE(flags); + buffer.writeUInt8(typeByName.Json.id); + buffer.writeBVarchar(columnName); + + const parser = StreamParser.parseTokens([buffer.data], debug, options); + + const result = await parser.next(); + assert.isFalse(result.done); + const token = result.value; + + assert.instanceOf(token, ColMetadataToken); + assert.strictEqual(token.columns.length, 1); + assert.strictEqual(token.columns[0].type.name, 'Json'); + assert.strictEqual(token.columns[0].colName, 'col1'); + assert.isUndefined(token.columns[0].collation); + assert.isUndefined(token.columns[0].dataLength); + }); }); diff --git a/test/unit/token/feature-ext-parser-test.ts b/test/unit/token/feature-ext-parser-test.ts index d63e60c6d..6e7f8595d 100644 --- a/test/unit/token/feature-ext-parser-test.ts +++ b/test/unit/token/feature-ext-parser-test.ts @@ -58,6 +58,31 @@ describe('Feature Ext Parser', () => { assert.instanceOf(token, FeatureExtAckToken); assert.strictEqual(token.utf8Support, true); // feature ext ack for UTF8_SUPPORT was positive assert.isUndefined(token.fedAuth); // fed auth not ack'd + assert.isUndefined(token.jsonSupport); // feature ext ack for JSON_SUPPORT was not received + + assert.isTrue((await parser.next()).done); + }); + + it('should parse JSON support token', async function() { + const debug = new Debug(); + const buffer = new WritableTrackingBuffer(8); + + buffer.writeUInt8(0xAE); // FEATUREEXTACK token header + buffer.writeUInt8(0x0D); // JSON_SUPPORT feature id + buffer.writeUInt32LE(0x00_00_00_01); // datalen + buffer.writeUInt8(0x01); // version 1 + + buffer.writeUInt8(0xFF); // TERMINATOR + + const parser = StreamParser.parseTokens([buffer.data], debug, options); + const result = await parser.next(); + assert.isFalse(result.done); + const token = result.value; + + assert.instanceOf(token, FeatureExtAckToken); + assert.strictEqual(token.jsonSupport, true); // feature ext ack for JSON_SUPPORT was positive + assert.isUndefined(token.utf8Support); // feature ext ack for UTF8_SUPPORT was not received + assert.isUndefined(token.fedAuth); // fed auth not ack'd assert.isTrue((await parser.next()).done); }); diff --git a/test/unit/token/nbcrow-token-parser-test.ts b/test/unit/token/nbcrow-token-parser-test.ts index 64b6796ce..4836a7df4 100644 --- a/test/unit/token/nbcrow-token-parser-test.ts +++ b/test/unit/token/nbcrow-token-parser-test.ts @@ -56,4 +56,41 @@ describe('NBCRow Token Parser', function() { assert.isTrue((await parser.next()).done); }); }); + + it('should parse json', async function() { + const debug = new Debug(); + const colMetadata: ColumnMetadata[] = [{ + colName: 'col0', + userType: 0, + flags: 0, + precision: undefined, + scale: undefined, + dataLength: undefined, + schema: undefined, + udtInfo: undefined, + type: dataTypeByName.Json, + collation: undefined + }]; + const value = '{"a":"\u00fc"}'; + const payload = Buffer.from(value, 'utf8'); + + const buffer = new WritableTrackingBuffer(0); + buffer.writeUInt8(0xd2); + buffer.writeUInt8(0x00); // null bitmap - column is not null + buffer.writeUInt64LE(payload.length); // PLP total length + buffer.writeUInt32LE(payload.length); // chunk length + buffer.writeBuffer(payload); + buffer.writeUInt32LE(0); // PLP terminator + + const parser = Parser.parseTokens([buffer.data], debug, options, colMetadata); + const result = await parser.next(); + assert.isFalse(result.done); + const token = result.value; + + assert.instanceOf(token, NBCRowToken); + assert.strictEqual(token.columns.length, 1); + assert.strictEqual(token.columns[0].value, value); + assert.strictEqual(token.columns[0].metadata, colMetadata[0]); + assert.isTrue((await parser.next()).done); + }); }); diff --git a/test/unit/token/row-token-parser-test.ts b/test/unit/token/row-token-parser-test.ts index f45c0e4b3..053197856 100644 --- a/test/unit/token/row-token-parser-test.ts +++ b/test/unit/token/row-token-parser-test.ts @@ -1427,4 +1427,70 @@ describe('Row Token Parser', function() { assert.strictEqual(token.columns[0].value, null); assert.isTrue((await parser.next()).done); }); + + it('should parse json', async function() { + const debug = new Debug(); + const colMetadata: ColumnMetadata[] = [{ + colName: 'col0', + userType: 0, + flags: 0, + precision: undefined, + scale: undefined, + dataLength: undefined, + schema: undefined, + udtInfo: undefined, + type: dataTypeByName.Json, + collation: undefined + }]; + const value = '{"a":"\u00fc"}'; + const payload = Buffer.from(value, 'utf8'); + + const buffer = new WritableTrackingBuffer(0); + buffer.writeUInt8(0xd1); + buffer.writeUInt64LE(payload.length); // PLP total length + buffer.writeUInt32LE(payload.length); // chunk length + buffer.writeBuffer(payload); + buffer.writeUInt32LE(0); // PLP terminator + + const parser = Parser.parseTokens([buffer.data], debug, options, colMetadata); + const result = await parser.next(); + assert.isFalse(result.done); + const token = result.value; + + assert.instanceOf(token, RowToken); + assert.strictEqual(token.columns.length, 1); + assert.strictEqual(token.columns[0].value, value); + assert.strictEqual(token.columns[0].metadata, colMetadata[0]); + assert.isTrue((await parser.next()).done); + }); + + it('should parse null json', async function() { + const debug = new Debug(); + const colMetadata: ColumnMetadata[] = [{ + colName: 'col0', + userType: 0, + flags: 0, + precision: undefined, + scale: undefined, + dataLength: undefined, + schema: undefined, + udtInfo: undefined, + type: dataTypeByName.Json, + collation: undefined + }]; + + const buffer = new WritableTrackingBuffer(0); + buffer.writeUInt8(0xd1); + buffer.writeBuffer(Buffer.alloc(8, 0xFF)); // PLP null + + const parser = Parser.parseTokens([buffer.data], debug, options, colMetadata); + const result = await parser.next(); + assert.isFalse(result.done); + const token = result.value; + + assert.instanceOf(token, RowToken); + assert.strictEqual(token.columns.length, 1); + assert.strictEqual(token.columns[0].value, null); + assert.isTrue((await parser.next()).done); + }); }); From 8dddb82b7bc48251931cfb00cd68da853b0cd88b Mon Sep 17 00:00:00 2001 From: Arthur Schreiber Date: Fri, 17 Jul 2026 16:48:42 +0000 Subject: [PATCH 07/15] docs: fix indentation of the json row in the data type table Co-Authored-By: Claude Fable 5 --- src/data-type.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data-type.ts b/src/data-type.ts index 664b5fd47..54ec195aa 100644 --- a/src/data-type.ts +++ b/src/data-type.ts @@ -402,7 +402,7 @@ export const TYPE = { * ✓ * - * - * + * * json * [[TYPES.Json]] * string|object From 091ed6f2ca6205efc74701f0534f8cb13bf435ac Mon Sep 17 00:00:00 2001 From: Arthur Schreiber Date: Fri, 17 Jul 2026 17:06:27 +0000 Subject: [PATCH 08/15] refactor: rename `TYPES.Json` to `TYPES.JSON` Matches how JavaScript itself names it (the global `JSON` object), which reads more natively than the PascalCase `Json`. The type has never been released, so this is not a breaking change. Co-Authored-By: Claude Fable 5 --- src/connection.ts | 4 +-- src/data-type.ts | 4 +-- src/data-types/json.ts | 2 +- src/metadata-parser.ts | 2 +- src/token/nbcrow-token-parser.ts | 2 +- src/token/returnvalue-token-parser.ts | 2 +- src/token/row-token-parser.ts | 2 +- src/value-parser.ts | 2 +- test/integration/json-test.ts | 12 +++---- test/unit/data-type.ts | 32 +++++++++---------- .../token/colmetadata-token-parser-test.ts | 4 +-- test/unit/token/nbcrow-token-parser-test.ts | 2 +- test/unit/token/row-token-parser-test.ts | 4 +-- 13 files changed, 37 insertions(+), 37 deletions(-) diff --git a/src/connection.ts b/src/connection.ts index 6209370c6..0afefac2b 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -3223,8 +3223,8 @@ class Connection extends EventEmitter { process.nextTick(() => { request.callback(new RequestError('Canceled.', 'ECANCEL')); }); - } else if (payload instanceof RpcRequestPayload && !this.serverSupportsJson && payload.parameters.some((parameter) => parameter.type === TYPES.Json)) { - const message = 'The server does not support the `json` data type. `TYPES.Json` parameters require SQL Server 2025 (or newer) or Azure SQL with JSON support enabled.'; + } else if (payload instanceof RpcRequestPayload && !this.serverSupportsJson && payload.parameters.some((parameter) => parameter.type === TYPES.JSON)) { + const message = 'The server does not support the `json` data type. `TYPES.JSON` parameters require SQL Server 2025 (or newer) or Azure SQL with JSON support enabled.'; this.debug.log(message); process.nextTick(() => { request.callback(new RequestError(message, 'EJSONNOTSUPPORTED')); diff --git a/src/data-type.ts b/src/data-type.ts index 54ec195aa..447df1e4b 100644 --- a/src/data-type.ts +++ b/src/data-type.ts @@ -404,7 +404,7 @@ export const TYPE = { * * * json - * [[TYPES.Json]] + * [[TYPES.JSON]] * string|object * ✓ * - @@ -477,7 +477,7 @@ export const TYPES = { UDT, TVP, Variant, - Json + JSON: Json }; export const typeByName = TYPES; diff --git a/src/data-types/json.ts b/src/data-types/json.ts index 9807fb63c..223119ef7 100644 --- a/src/data-types/json.ts +++ b/src/data-types/json.ts @@ -6,7 +6,7 @@ const MAX_NULL_LENGTH = Buffer.from([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0 const Json: DataType = { id: 0xF4, type: 'JSON', - name: 'Json', + name: 'JSON', declaration: (parameter) => { return 'json'; diff --git a/src/metadata-parser.ts b/src/metadata-parser.ts index beacef110..5810e9196 100644 --- a/src/metadata-parser.ts +++ b/src/metadata-parser.ts @@ -272,7 +272,7 @@ function readMetadata(buf: Buffer, offset: number, options: ParserOptions): Resu }, offset); } - case 'Json': + case 'JSON': // The `json` TYPE_INFO carries no additional metadata — no length // and no collation (the collation is fixed to UTF-8 per RFC 8259). return new Result({ diff --git a/src/token/nbcrow-token-parser.ts b/src/token/nbcrow-token-parser.ts index fdb51810f..6aee52f17 100644 --- a/src/token/nbcrow-token-parser.ts +++ b/src/token/nbcrow-token-parser.ts @@ -59,7 +59,7 @@ async function nbcRowParser(parser: Parser): Promise { columns.push({ value: iconv.decode(Buffer.concat(chunks), metadata.collation?.codepage ?? 'utf8'), metadata }); } else if (metadata.type.name === 'VarBinary' || metadata.type.name === 'UDT') { columns.push({ value: Buffer.concat(chunks), metadata }); - } else if (metadata.type.name === 'Json') { + } else if (metadata.type.name === 'JSON') { columns.push({ value: Buffer.concat(chunks).toString('utf8'), metadata }); } } else { diff --git a/src/token/returnvalue-token-parser.ts b/src/token/returnvalue-token-parser.ts index ba29fe429..bb3d77b8a 100644 --- a/src/token/returnvalue-token-parser.ts +++ b/src/token/returnvalue-token-parser.ts @@ -57,7 +57,7 @@ async function returnParser(parser: Parser): Promise { value = iconv.decode(Buffer.concat(chunks), metadata.collation?.codepage ?? 'utf8'); } else if (metadata.type.name === 'VarBinary' || metadata.type.name === 'UDT') { value = Buffer.concat(chunks); - } else if (metadata.type.name === 'Json') { + } else if (metadata.type.name === 'JSON') { value = Buffer.concat(chunks).toString('utf8'); } } else { diff --git a/src/token/row-token-parser.ts b/src/token/row-token-parser.ts index 007fafdf3..251ab3b63 100644 --- a/src/token/row-token-parser.ts +++ b/src/token/row-token-parser.ts @@ -30,7 +30,7 @@ async function rowParser(parser: Parser): Promise { columns.push({ value: iconv.decode(Buffer.concat(chunks), metadata.collation?.codepage ?? 'utf8'), metadata }); } else if (metadata.type.name === 'VarBinary' || metadata.type.name === 'UDT') { columns.push({ value: Buffer.concat(chunks), metadata }); - } else if (metadata.type.name === 'Json') { + } else if (metadata.type.name === 'JSON') { columns.push({ value: Buffer.concat(chunks).toString('utf8'), metadata }); } } else { diff --git a/src/value-parser.ts b/src/value-parser.ts index 0dceb3f17..14bfb8742 100644 --- a/src/value-parser.ts +++ b/src/value-parser.ts @@ -411,7 +411,7 @@ function isPLPStream(metadata: Metadata) { return true; } - case 'Json': { + case 'JSON': { return true; } } diff --git a/test/integration/json-test.ts b/test/integration/json-test.ts index a63b1827e..5e843a24a 100644 --- a/test/integration/json-test.ts +++ b/test/integration/json-test.ts @@ -51,7 +51,7 @@ describe('json data type', function() { }); request.on('row', (columns) => { - assert.strictEqual(columns[0].metadata.type.name, 'Json'); + assert.strictEqual(columns[0].metadata.type.name, 'JSON'); assert.isString(columns[0].value); assert.deepEqual(JSON.parse(columns[0].value), { a: [1, 'ü'] }); }); @@ -65,7 +65,7 @@ describe('json data type', function() { }); request.on('row', (columns) => { - assert.strictEqual(columns[0].metadata.type.name, 'Json'); + assert.strictEqual(columns[0].metadata.type.name, 'JSON'); assert.isNull(columns[0].value); }); @@ -78,7 +78,7 @@ describe('json data type', function() { const request = new Request('SELECT @p', (err) => { done(err); }); - request.addParameter('p', TYPES.Json, value); + request.addParameter('p', TYPES.JSON, value); request.on('row', (columns) => { assert.deepEqual(JSON.parse(columns[0].value), JSON.parse(value)); @@ -93,7 +93,7 @@ describe('json data type', function() { const request = new Request('SELECT @p', (err) => { done(err); }); - request.addParameter('p', TYPES.Json, value); + request.addParameter('p', TYPES.JSON, value); request.on('row', (columns) => { assert.deepEqual(JSON.parse(columns[0].value), value); @@ -106,7 +106,7 @@ describe('json data type', function() { const request = new Request('SELECT @p', (err) => { done(err); }); - request.addParameter('p', TYPES.Json, null); + request.addParameter('p', TYPES.JSON, null); request.on('row', (columns) => { assert.isNull(columns[0].value); @@ -129,7 +129,7 @@ describe('json data type', function() { assert.strictEqual((err as any).code, 'EJSONNOTSUPPORTED'); done(); }); - request.addParameter('p', TYPES.Json, '{"a":1}'); + request.addParameter('p', TYPES.JSON, '{"a":1}'); connection.execSql(request); }); diff --git a/test/unit/data-type.ts b/test/unit/data-type.ts index e904be96a..7940a86a4 100644 --- a/test/unit/data-type.ts +++ b/test/unit/data-type.ts @@ -1680,46 +1680,46 @@ describe('VarChar', function() { }); }); -describe('Json', function() { +describe('JSON', function() { describe('.declaration', function() { it('returns "json"', function() { - assert.strictEqual(TYPES.Json.declaration({ value: '{}' } as any), 'json'); + assert.strictEqual(TYPES.JSON.declaration({ value: '{}' } as any), 'json'); }); }); describe('.generateTypeInfo', function() { it('returns the JSON type token without additional metadata', function() { - const result = TYPES.Json.generateTypeInfo({ value: null }, options); + const result = TYPES.JSON.generateTypeInfo({ value: null }, options); assert.deepEqual(result, Buffer.from([0xF4])); }); }); describe('.generateParameterLength', function() { it('returns the PLP null length for `null` values', function() { - const result = TYPES.Json.generateParameterLength({ value: null }, options); + const result = TYPES.JSON.generateParameterLength({ value: null }, options); assert.deepEqual(result, Buffer.from('ffffffffffffffff', 'hex')); }); it('returns the unknown PLP length for non-null values', function() { - const result = TYPES.Json.generateParameterLength({ value: Buffer.from('{"a":1}') }, options); + const result = TYPES.JSON.generateParameterLength({ value: Buffer.from('{"a":1}') }, options); assert.deepEqual(result, Buffer.from('feffffffffffffff', 'hex')); }); }); describe('.generateParameterData', function() { it('generates no data for `null` values', function() { - const buffer = Buffer.concat([...TYPES.Json.generateParameterData({ value: null }, options)]); + const buffer = Buffer.concat([...TYPES.JSON.generateParameterData({ value: null }, options)]); assert.deepEqual(buffer, Buffer.alloc(0)); }); it('generates only the PLP terminator for empty values', function() { - const buffer = Buffer.concat([...TYPES.Json.generateParameterData({ value: Buffer.alloc(0) }, options)]); + const buffer = Buffer.concat([...TYPES.JSON.generateParameterData({ value: Buffer.alloc(0) }, options)]); assert.deepEqual(buffer, Buffer.from('00000000', 'hex')); }); it('generates a single length-prefixed chunk followed by the PLP terminator', function() { const value = Buffer.from('{"a":1}', 'utf8'); - const buffer = Buffer.concat([...TYPES.Json.generateParameterData({ value: value }, options)]); + const buffer = Buffer.concat([...TYPES.JSON.generateParameterData({ value: value }, options)]); const expected = Buffer.concat([ Buffer.from('07000000', 'hex'), @@ -1732,35 +1732,35 @@ describe('Json', function() { describe('.validate', function() { it('returns `null` for `null` and `undefined` values', function() { - assert.isNull(TYPES.Json.validate(null, undefined)); - assert.isNull(TYPES.Json.validate(undefined, undefined)); + assert.isNull(TYPES.JSON.validate(null, undefined)); + assert.isNull(TYPES.JSON.validate(undefined, undefined)); }); it('returns the UTF-8 encoded value for strings containing valid JSON', function() { - const result = TYPES.Json.validate('{"a":"ü"}', undefined); + const result = TYPES.JSON.validate('{"a":"ü"}', undefined); assert.deepEqual(result, Buffer.from('{"a":"ü"}', 'utf8')); }); it('throws for strings that do not contain valid JSON', function() { assert.throws(() => { - TYPES.Json.validate('{oops', undefined); + TYPES.JSON.validate('{oops', undefined); }, SyntaxError); }); it('serializes objects and arrays to their UTF-8 encoded JSON representation', function() { - assert.deepEqual(TYPES.Json.validate({ a: [1, 'ü'] }, undefined), Buffer.from('{"a":[1,"ü"]}', 'utf8')); - assert.deepEqual(TYPES.Json.validate([1, 2], undefined), Buffer.from('[1,2]', 'utf8')); + assert.deepEqual(TYPES.JSON.validate({ a: [1, 'ü'] }, undefined), Buffer.from('{"a":[1,"ü"]}', 'utf8')); + assert.deepEqual(TYPES.JSON.validate([1, 2], undefined), Buffer.from('[1,2]', 'utf8')); }); it('throws for Buffer values', function() { assert.throws(() => { - TYPES.Json.validate(Buffer.from('{"a":1}'), undefined); + TYPES.JSON.validate(Buffer.from('{"a":1}'), undefined); }, TypeError, 'Invalid JSON value.'); }); it('throws for values that can not be serialized to JSON', function() { assert.throws(() => { - TYPES.Json.validate(() => {}, undefined); + TYPES.JSON.validate(() => {}, undefined); }, TypeError, 'Invalid JSON value.'); }); }); diff --git a/test/unit/token/colmetadata-token-parser-test.ts b/test/unit/token/colmetadata-token-parser-test.ts index 73046a44c..161a348c4 100644 --- a/test/unit/token/colmetadata-token-parser-test.ts +++ b/test/unit/token/colmetadata-token-parser-test.ts @@ -136,7 +136,7 @@ describe('Colmetadata Token Parser', function() { buffer.writeUInt16LE(numberOfColumns); buffer.writeUInt32LE(userType); buffer.writeUInt16LE(flags); - buffer.writeUInt8(typeByName.Json.id); + buffer.writeUInt8(typeByName.JSON.id); buffer.writeBVarchar(columnName); const parser = StreamParser.parseTokens([buffer.data], debug, options); @@ -147,7 +147,7 @@ describe('Colmetadata Token Parser', function() { assert.instanceOf(token, ColMetadataToken); assert.strictEqual(token.columns.length, 1); - assert.strictEqual(token.columns[0].type.name, 'Json'); + assert.strictEqual(token.columns[0].type.name, 'JSON'); assert.strictEqual(token.columns[0].colName, 'col1'); assert.isUndefined(token.columns[0].collation); assert.isUndefined(token.columns[0].dataLength); diff --git a/test/unit/token/nbcrow-token-parser-test.ts b/test/unit/token/nbcrow-token-parser-test.ts index 4836a7df4..5a71a7496 100644 --- a/test/unit/token/nbcrow-token-parser-test.ts +++ b/test/unit/token/nbcrow-token-parser-test.ts @@ -68,7 +68,7 @@ describe('NBCRow Token Parser', function() { dataLength: undefined, schema: undefined, udtInfo: undefined, - type: dataTypeByName.Json, + type: dataTypeByName.JSON, collation: undefined }]; const value = '{"a":"\u00fc"}'; diff --git a/test/unit/token/row-token-parser-test.ts b/test/unit/token/row-token-parser-test.ts index 053197856..c4d9fd631 100644 --- a/test/unit/token/row-token-parser-test.ts +++ b/test/unit/token/row-token-parser-test.ts @@ -1439,7 +1439,7 @@ describe('Row Token Parser', function() { dataLength: undefined, schema: undefined, udtInfo: undefined, - type: dataTypeByName.Json, + type: dataTypeByName.JSON, collation: undefined }]; const value = '{"a":"\u00fc"}'; @@ -1475,7 +1475,7 @@ describe('Row Token Parser', function() { dataLength: undefined, schema: undefined, udtInfo: undefined, - type: dataTypeByName.Json, + type: dataTypeByName.JSON, collation: undefined }]; From 2ccb056f8b47e778e7cdcdc996c2eca25e96adc5 Mon Sep 17 00:00:00 2001 From: Arthur Schreiber Date: Sun, 19 Jul 2026 11:38:24 +0000 Subject: [PATCH 09/15] feat: support json columns in bulk loads The server rejects the `json` data type (0xF4) in bulk load column metadata ("Invalid column type from bcp client"), even when JSONSUPPORT was negotiated - verified against SQL Server 2025 RTM-CU7. Instead, substitute `varchar(max)` with a zeroed collation in the COLMETADATA and let the server convert the PLP-encoded UTF-8 values based on the `json` column type in the `insert bulk` statement. This matches how SqlClient's SqlBulkCopy handles json columns. Note that SQL Server 2025 releases before CU5 have server-side bugs in this conversion path (dropped rows, broken null handling, see dotnet/SqlClient#3000 and dotnet/SqlClient#3737). Co-Authored-By: Claude Fable 5 --- src/bulk-load.ts | 14 +++++++++-- test/integration/json-test.ts | 45 +++++++++++++++++++++++++++++++++++ test/unit/bulk-load-test.ts | 21 ++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/bulk-load.ts b/src/bulk-load.ts index fe47e4fff..c0b723c88 100644 --- a/src/bulk-load.ts +++ b/src/bulk-load.ts @@ -5,7 +5,7 @@ import Connection, { type InternalConnectionOptions } from './connection'; import { Transform } from 'stream'; import { TYPE as TOKEN_TYPE } from './token/token'; -import { type DataType, type Parameter } from './data-type'; +import { TYPES, type DataType, type Parameter } from './data-type'; import { Collation } from './collation'; /** @@ -563,7 +563,17 @@ class BulkLoad extends EventEmitter { tBuf.writeUInt16LE(flags); // TYPE_INFO - tBuf.writeBuffer(c.type.generateTypeInfo(c, this.options)); + if (c.type === TYPES.JSON) { + // The server rejects the `json` data type (0xF4) in bulk load column + // metadata ("Invalid column type from bcp client"), even when + // JSONSUPPORT was negotiated. Substitute `varchar(max)` with a zeroed + // collation instead - the values are PLP-encoded UTF-8 either way, + // and the server converts them to `json` based on the column's type + // in the `insert bulk` statement. This matches what SqlClient does. + tBuf.writeBuffer(TYPES.VarChar.generateTypeInfo({ length: Infinity, collation: undefined, value: null }, this.options)); + } else { + tBuf.writeBuffer(c.type.generateTypeInfo(c, this.options)); + } // TableName if (c.type.hasTableName) { diff --git a/test/integration/json-test.ts b/test/integration/json-test.ts index 5e843a24a..4f17b3458 100644 --- a/test/integration/json-test.ts +++ b/test/integration/json-test.ts @@ -114,6 +114,51 @@ describe('json data type', function() { connection.execSql(request); }); + + it('bulk loads json values', function(done) { + const bulkLoad = connection.newBulkLoad('#tedious_json_bulk', (err, rowCount) => { + if (err) { + return done(err); + } + + assert.strictEqual(rowCount, 3); + + const values: unknown[] = []; + const request = new Request('SELECT [value] FROM #tedious_json_bulk ORDER BY [id]', (err) => { + if (err) { + return done(err); + } + + assert.deepEqual(JSON.parse(values[0] as string), { a: [1, 'ü'] }); + assert.deepEqual(JSON.parse(values[1] as string), { b: 2 }); + assert.isNull(values[2]); + done(); + }); + + request.on('row', (columns) => { + values.push(columns[0].value); + }); + + connection.execSqlBatch(request); + }); + + bulkLoad.addColumn('id', TYPES.Int, { nullable: false }); + bulkLoad.addColumn('value', TYPES.JSON, { nullable: true }); + + const createTable = new Request('CREATE TABLE #tedious_json_bulk ([id] int NOT NULL, [value] json NULL)', (err) => { + if (err) { + return done(err); + } + + connection.execBulkLoad(bulkLoad, [ + [1, '{"a":[1,"ü"]}'], + [2, { b: 2 }], + [3, null] + ]); + }); + + connection.execSqlBatch(createTable); + }); }); describe('on servers that do not support the json data type', function() { diff --git a/test/unit/bulk-load-test.ts b/test/unit/bulk-load-test.ts index d1bf4393a..2e89958d0 100644 --- a/test/unit/bulk-load-test.ts +++ b/test/unit/bulk-load-test.ts @@ -1,5 +1,6 @@ import { assert } from 'chai'; import BulkLoad from '../../src/bulk-load'; +import { TYPES } from '../../src/data-type'; import { type InternalConnectionOptions } from '../../src/connection'; // Test options - using type assertion since tests only exercise code paths @@ -12,6 +13,26 @@ describe('BulkLoad', function() { assert.strictEqual(request.canceled, false); }); + describe('#getColMetaData', function() { + it('substitutes `varchar(max)` type info for `json` columns', function() { + const request = new BulkLoad('tablename', undefined, connectionOptions, { }, () => {}); + request.addColumn('value', TYPES.JSON, { nullable: true }); + + const expected = Buffer.concat([ + Buffer.from([0x81]), // COLMETADATA + Buffer.from([0x01, 0x00]), // column count + Buffer.from([0x00, 0x00, 0x00, 0x00]), // user type + Buffer.from([0x05, 0x00]), // flags (updateableReadWrite | nullable) + // The `json` data type (0xF4) is rejected by the server in bulk + // loads - `varchar(max)` with a zeroed collation is sent instead. + Buffer.from([0xA7, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00]), + Buffer.from('\x05v\0a\0l\0u\0e\0', 'binary') // column name + ]); + + assert.deepEqual(request.getColMetaData(), expected); + }); + }); + describe('#cancel', function() { it('marks the request as canceled', function() { const request = new BulkLoad('tablename', undefined, connectionOptions, { }, () => {}); From 38fb742dcfd62d1aa512db3bfabae619b7c51a05 Mon Sep 17 00:00:00 2001 From: Arthur Schreiber Date: Sun, 19 Jul 2026 11:59:11 +0000 Subject: [PATCH 10/15] feat: support json columns in table-valued parameters The server can not handle the `json` data type (0xF4) in TVP column metadata - it kills the session ("Cannot continue the execution because the session is in the kill state"). Substitute `varchar(max)` in the TVP column metadata instead, like bulk loads do. Unlike in bulk loads, the collation sent with the substituted type matters here: with a zeroed collation, the server decodes the data using its default codepage instead of UTF-8, garbling multi-byte characters. Send Latin1_General_100_BIN2_UTF8 - the collation the `json` data type is fixed to. Note that SqlClient does not support json columns in TVPs at all (its TVP type info writer has no case for them), so this has no SqlClient equivalent to compare against. Verified against SQL Server 2025 RTM-CU7. Co-Authored-By: Claude Fable 5 --- src/collation.ts | 8 +++++++ src/data-types/tvp.ts | 17 ++++++++++++++- test/integration/json-test.ts | 40 +++++++++++++++++++++++++++++++++++ test/unit/data-type.ts | 26 +++++++++++++++++++++++ 4 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/collation.ts b/src/collation.ts index b3f045153..f7a3ec3e4 100644 --- a/src/collation.ts +++ b/src/collation.ts @@ -352,3 +352,11 @@ export class Collation { return this.buffer; } } + +/** + * `Latin1_General_100_BIN2_UTF8`, the collation the `json` data type is fixed + * to. Sent in the `varchar(max)` TYPE_INFO that is substituted for `json` + * columns in bulk loads and TVPs, so that the server decodes their + * PLP-encoded data as UTF-8. + */ +export const JSON_COLLATION = new Collation(0x0409, Flags.BINARY2 | Flags.UTF8, 2, 0); diff --git a/src/data-types/tvp.ts b/src/data-types/tvp.ts index e1de96005..adc0f23bb 100644 --- a/src/data-types/tvp.ts +++ b/src/data-types/tvp.ts @@ -1,6 +1,9 @@ import { type DataType } from '../data-type'; import { InputError } from '../errors'; import WritableTrackingBuffer from '../tracking-buffer/writable-tracking-buffer'; +import { JSON_COLLATION } from '../collation'; +import Json from './json'; +import VarChar from './varchar'; const TVP_ROW_TOKEN = Buffer.from([0x01]); const TVP_END_TOKEN = Buffer.from([0x00]); @@ -69,7 +72,19 @@ const TVP: DataType = { yield buff; // TYPE_INFO - yield column.type.generateTypeInfo(column); + if (column.type === Json) { + // The server can not handle the `json` data type (0xF4) in TVP column + // metadata - it kills the session. Substitute `varchar(max)` with the + // fixed `json` collation instead - the values are PLP-encoded UTF-8 + // either way, and the server converts them to `json` based on the + // table type's column definition. This matches the substitution + // performed for bulk loads. The collation is required here: with a + // zeroed collation, the server decodes the data using its default + // codepage instead of UTF-8. + yield VarChar.generateTypeInfo({ length: Infinity, collation: JSON_COLLATION, value: null }, options); + } else { + yield column.type.generateTypeInfo(column); + } // ColName yield Buffer.from([0x00]); diff --git a/test/integration/json-test.ts b/test/integration/json-test.ts index 4f17b3458..568e683dc 100644 --- a/test/integration/json-test.ts +++ b/test/integration/json-test.ts @@ -159,6 +159,46 @@ describe('json data type', function() { connection.execSqlBatch(createTable); }); + + it('round-trips json values in table-valued parameters', function(done) { + const createType = new Request('DROP TYPE IF EXISTS [__tediousJsonTvpType]; CREATE TYPE [__tediousJsonTvpType] AS TABLE ([value] json NULL)', (err) => { + if (err) { + return done(err); + } + + const values: unknown[] = []; + const request = new Request('SELECT [value] FROM @tvp', (err) => { + const dropType = new Request('DROP TYPE IF EXISTS [__tediousJsonTvpType]', (dropErr) => { + if (err ?? dropErr) { + return done(err ?? dropErr); + } + + assert.deepEqual(JSON.parse(values[0] as string), { a: [1, 'ü'] }); + assert.isNull(values[1]); + done(); + }); + + connection.execSqlBatch(dropType); + }); + + request.on('row', (columns) => { + values.push(columns[0].value); + }); + + request.addParameter('tvp', TYPES.TVP, { + name: '__tediousJsonTvpType', + columns: [{ name: 'value', type: TYPES.JSON }], + rows: [ + ['{"a":[1,"ü"]}'], + [null] + ] + }); + + connection.execSql(request); + }); + + connection.execSqlBatch(createType); + }); }); describe('on servers that do not support the json data type', function() { diff --git a/test/unit/data-type.ts b/test/unit/data-type.ts index 7940a86a4..ab6796c6b 100644 --- a/test/unit/data-type.ts +++ b/test/unit/data-type.ts @@ -1451,6 +1451,32 @@ describe('TVP', function() { const buffer = Buffer.concat([...TYPES.TVP.generateParameterData(parameterValue, optionsWithUTCFalse)]); assert.deepEqual(buffer, expected); }); + + it('substitutes `varchar(max)` type info for `json` columns', function() { + const value = { + columns: [{ name: 'value', type: TYPES.JSON }], + rows: [['{"a":1}']] + }; + + const expected = Buffer.concat([ + Buffer.from('000000000000', 'hex'), // user type + flags + // The `json` data type (0xF4) is substituted with `varchar(max)` + // and the Latin1_General_100_BIN2_UTF8 collation. + Buffer.from('a7ffff0904002600', 'hex'), + Buffer.from('00', 'hex'), // column name (always zero length) + Buffer.from('00', 'hex'), // end of column metadata + Buffer.from('01', 'hex'), // row token + Buffer.from('feffffffffffffff', 'hex'), // unknown PLP length + Buffer.from('07000000', 'hex'), // PLP chunk length + Buffer.from('{"a":1}', 'utf8'), + Buffer.from('00000000', 'hex'), // PLP terminator + Buffer.from('00', 'hex') // end of rows + ]); + const parameterValue = { value }; + + const buffer = Buffer.concat([...TYPES.TVP.generateParameterData(parameterValue, optionsWithUTCFalse)]); + assert.deepEqual(buffer, expected); + }); }); describe('.generateTypeInfo', function() { From c4aea7225be96b4abbd5e4f6ae57a10a7c8c0bcd Mon Sep 17 00:00:00 2001 From: Arthur Schreiber Date: Sun, 19 Jul 2026 11:59:22 +0000 Subject: [PATCH 11/15] refactor: send the json collation in bulk load column metadata Send Latin1_General_100_BIN2_UTF8 instead of a zeroed collation in the `varchar(max)` type info substituted for `json` bulk load columns, aligning with the TVP substitution. The server ignores the collation here (the `insert bulk` statement pins the interpretation to UTF-8, verified in a database with a non-UTF-8 default collation), but sending the truthful value keeps the two substitutions identical. This deviates from SqlClient, which zeroes these bytes. Co-Authored-By: Claude Fable 5 --- src/bulk-load.ts | 16 ++++++++++------ test/unit/bulk-load-test.ts | 5 +++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/bulk-load.ts b/src/bulk-load.ts index c0b723c88..9aa0bfb64 100644 --- a/src/bulk-load.ts +++ b/src/bulk-load.ts @@ -6,7 +6,7 @@ import { Transform } from 'stream'; import { TYPE as TOKEN_TYPE } from './token/token'; import { TYPES, type DataType, type Parameter } from './data-type'; -import { Collation } from './collation'; +import { Collation, JSON_COLLATION } from './collation'; /** * @private @@ -566,11 +566,15 @@ class BulkLoad extends EventEmitter { if (c.type === TYPES.JSON) { // The server rejects the `json` data type (0xF4) in bulk load column // metadata ("Invalid column type from bcp client"), even when - // JSONSUPPORT was negotiated. Substitute `varchar(max)` with a zeroed - // collation instead - the values are PLP-encoded UTF-8 either way, - // and the server converts them to `json` based on the column's type - // in the `insert bulk` statement. This matches what SqlClient does. - tBuf.writeBuffer(TYPES.VarChar.generateTypeInfo({ length: Infinity, collation: undefined, value: null }, this.options)); + // JSONSUPPORT was negotiated. Substitute `varchar(max)` with the + // fixed `json` collation instead - the values are PLP-encoded UTF-8 + // either way, and the server converts them to `json` based on the + // column's type in the `insert bulk` statement. SqlClient performs + // the same substitution, but with a zeroed collation - the server + // ignores the collation here (the `insert bulk` statement pins the + // interpretation to UTF-8), so send the truthful one for consistency + // with the TVP substitution, where it is required. + tBuf.writeBuffer(TYPES.VarChar.generateTypeInfo({ length: Infinity, collation: JSON_COLLATION, value: null }, this.options)); } else { tBuf.writeBuffer(c.type.generateTypeInfo(c, this.options)); } diff --git a/test/unit/bulk-load-test.ts b/test/unit/bulk-load-test.ts index 2e89958d0..ec9882360 100644 --- a/test/unit/bulk-load-test.ts +++ b/test/unit/bulk-load-test.ts @@ -24,8 +24,9 @@ describe('BulkLoad', function() { Buffer.from([0x00, 0x00, 0x00, 0x00]), // user type Buffer.from([0x05, 0x00]), // flags (updateableReadWrite | nullable) // The `json` data type (0xF4) is rejected by the server in bulk - // loads - `varchar(max)` with a zeroed collation is sent instead. - Buffer.from([0xA7, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00]), + // loads - `varchar(max)` with the Latin1_General_100_BIN2_UTF8 + // collation is sent instead. + Buffer.from([0xA7, 0xFF, 0xFF, 0x09, 0x04, 0x00, 0x26, 0x00]), Buffer.from('\x05v\0a\0l\0u\0e\0', 'binary') // column name ]); From 2a8b9896242bae943cee7b51208fc4113317694c Mon Sep 17 00:00:00 2001 From: Arthur Schreiber Date: Sun, 19 Jul 2026 14:41:59 +0000 Subject: [PATCH 12/15] test: cover json output parameters Adds the first unit tests for the RETURNVALUE token parser, covering json values (PLP-encoded UTF-8) and json PLP NULLs - the only read path of the json data type that had no coverage. Also adds integration coverage for json output parameters, exercising the same path against a real server. Co-Authored-By: Claude Fable 5 --- test/integration/json-test.ts | 49 +++++++++++++ .../token/returnvalue-token-parser-test.ts | 70 +++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 test/unit/token/returnvalue-token-parser-test.ts diff --git a/test/integration/json-test.ts b/test/integration/json-test.ts index 568e683dc..6f3c57545 100644 --- a/test/integration/json-test.ts +++ b/test/integration/json-test.ts @@ -199,6 +199,55 @@ describe('json data type', function() { connection.execSqlBatch(createType); }); + + it('returns json values for output parameters', function(done) { + let returnValueReceived = false; + + const request = new Request('SET @out = @in', (err) => { + if (err) { + return done(err); + } + + assert.isTrue(returnValueReceived); + done(); + }); + + request.addParameter('in', TYPES.JSON, '{"a":[1,"ü"]}'); + request.addOutputParameter('out', TYPES.JSON); + + request.on('returnValue', (name, value, metadata) => { + assert.strictEqual(name, 'out'); + assert.deepEqual(JSON.parse(value as string), { a: [1, 'ü'] }); + assert.strictEqual(metadata.type.name, 'JSON'); + returnValueReceived = true; + }); + + connection.execSql(request); + }); + + it('returns `null` for json output parameters set to `null`', function(done) { + let returnValueReceived = false; + + const request = new Request('SET @out = NULL', (err) => { + if (err) { + return done(err); + } + + assert.isTrue(returnValueReceived); + done(); + }); + + request.addOutputParameter('out', TYPES.JSON); + + request.on('returnValue', (name, value, metadata) => { + assert.strictEqual(name, 'out'); + assert.isNull(value); + assert.strictEqual(metadata.type.name, 'JSON'); + returnValueReceived = true; + }); + + connection.execSql(request); + }); }); describe('on servers that do not support the json data type', function() { diff --git a/test/unit/token/returnvalue-token-parser-test.ts b/test/unit/token/returnvalue-token-parser-test.ts new file mode 100644 index 000000000..195a1a3b8 --- /dev/null +++ b/test/unit/token/returnvalue-token-parser-test.ts @@ -0,0 +1,70 @@ +import { assert } from 'chai'; + +import Parser, { type ParserOptions } from '../../../src/token/stream-parser'; +import { ReturnValueToken } from '../../../src/token/token'; +import WritableTrackingBuffer from '../../../src/tracking-buffer/writable-tracking-buffer'; +import Debug from '../../../src/debug'; + +const options = { + useUTC: false, + tdsVersion: '7_2' +} as ParserOptions; + +describe('ReturnValue Token Parser', function() { + it('should parse json values', async function() { + const debug = new Debug(); + const value = '{"a":"ü"}'; + const payload = Buffer.from(value, 'utf8'); + + const buffer = new WritableTrackingBuffer(0, 'ucs2'); + buffer.writeUInt8(0xAC); // RETURNVALUE token + buffer.writeUInt16LE(1); // paramOrdinal + buffer.writeBVarchar('@out'); // paramName + buffer.writeUInt8(0x01); // status + buffer.writeUInt32LE(0); // userType + buffer.writeUInt16LE(0); // flags + buffer.writeUInt8(0xF4); // json TYPE_INFO + buffer.writeUInt64LE(payload.length); // PLP total length + buffer.writeUInt32LE(payload.length); // chunk length + buffer.writeBuffer(payload); + buffer.writeUInt32LE(0); // PLP terminator + + const parser = Parser.parseTokens([buffer.data], debug, options); + const result = await parser.next(); + assert.isFalse(result.done); + const token = result.value; + + assert.instanceOf(token, ReturnValueToken); + assert.strictEqual(token.paramOrdinal, 1); + assert.strictEqual(token.paramName, 'out'); + assert.strictEqual(token.metadata.type.name, 'JSON'); + assert.strictEqual(token.value, value); + assert.isTrue((await parser.next()).done); + }); + + it('should parse null json values', async function() { + const debug = new Debug(); + + const buffer = new WritableTrackingBuffer(0, 'ucs2'); + buffer.writeUInt8(0xAC); // RETURNVALUE token + buffer.writeUInt16LE(1); // paramOrdinal + buffer.writeBVarchar('@out'); // paramName + buffer.writeUInt8(0x01); // status + buffer.writeUInt32LE(0); // userType + buffer.writeUInt16LE(0); // flags + buffer.writeUInt8(0xF4); // json TYPE_INFO + buffer.writeBuffer(Buffer.alloc(8, 0xFF)); // PLP null + + const parser = Parser.parseTokens([buffer.data], debug, options); + const result = await parser.next(); + assert.isFalse(result.done); + const token = result.value; + + assert.instanceOf(token, ReturnValueToken); + assert.strictEqual(token.paramOrdinal, 1); + assert.strictEqual(token.paramName, 'out'); + assert.strictEqual(token.metadata.type.name, 'JSON'); + assert.isNull(token.value); + assert.isTrue((await parser.next()).done); + }); +}); From 8f198780f81d6c5ffb3333bad3437eee5950b2de Mon Sep 17 00:00:00 2001 From: Arthur Schreiber Date: Sun, 19 Jul 2026 14:42:21 +0000 Subject: [PATCH 13/15] fix: fail the login when the server acknowledges an unsupported JSON version Previously, a JSONSUPPORT acknowledgement with any version other than 1 was silently treated as "server does not support json". That is not a safe degradation: the server considers the negotiation complete and will use the acknowledged version's wire format for `json` values, which this client would misparse as version 1 UTF-8 text - silently producing garbage values instead of an error. Carry the raw acknowledgement data on the FeatureExtAckToken (like fedAuth) and let the Login7TokenHandler apply policy: version 1 enables json support, a missing acknowledgement leaves it disabled, and any other version or data length fails the login with a ConnectionError. This matches SqlClient, which rejects both unexpected lengths and unexpected versions. Co-Authored-By: Claude Fable 5 --- src/token/feature-ext-ack-parser.ts | 6 ++- src/token/handler.ts | 10 +++- src/token/token.ts | 7 +-- test/unit/token/feature-ext-parser-test.ts | 24 ++++++++- test/unit/token/handler-test.ts | 61 ++++++++++++++++++++++ 5 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 test/unit/token/handler-test.ts diff --git a/src/token/feature-ext-ack-parser.ts b/src/token/feature-ext-ack-parser.ts index f9a22862b..d0b781c88 100644 --- a/src/token/feature-ext-ack-parser.ts +++ b/src/token/feature-ext-ack-parser.ts @@ -17,7 +17,7 @@ const FEATURE_ID = { function featureExtAckParser(buf: Buffer, offset: number, _options: ParserOptions): Result { let fedAuth: Buffer | undefined; let utf8Support: boolean | undefined; - let jsonSupport: boolean | undefined; + let jsonSupport: Buffer | undefined; while (true) { let featureId; @@ -46,7 +46,9 @@ function featureExtAckParser(buf: Buffer, offset: number, _options: ParserOption break; case FEATURE_ID.JSON_SUPPORT: // The single data byte is the JSON version chosen by the server. - jsonSupport = featureData[0] === 0x01; + // Whether that version is one this client supports is decided by the + // Login7TokenHandler. + jsonSupport = featureData; break; } } diff --git a/src/token/handler.ts b/src/token/handler.ts index a1b511631..fdda37a50 100644 --- a/src/token/handler.ts +++ b/src/token/handler.ts @@ -311,7 +311,15 @@ export class Login7TokenHandler extends TokenHandler { const { authentication } = this.connection.config; if (token.jsonSupport !== undefined) { - this.connection.serverSupportsJson = token.jsonSupport; + if (token.jsonSupport.length === 1 && token.jsonSupport[0] === 0x01) { + this.connection.serverSupportsJson = true; + } else { + // The server acknowledged a JSON version this client did not offer. + // It will use that version's wire format for `json` values, which + // this client can not safely parse - fail the login instead of + // continuing on a connection whose data can not be trusted. + this.loginError = new ConnectionError('Received invalid JSON support acknowledgement'); + } } if (authentication.type === 'azure-active-directory-password' || authentication.type === 'azure-active-directory-access-token' || authentication.type === 'azure-active-directory-msi-vm' || authentication.type === 'azure-active-directory-msi-app-service' || authentication.type === 'azure-active-directory-service-principal-secret' || authentication.type === 'azure-active-directory-default') { diff --git a/src/token/token.ts b/src/token/token.ts index cb10a8a0a..bc6ff491b 100644 --- a/src/token/token.ts +++ b/src/token/token.ts @@ -332,12 +332,13 @@ export class FeatureExtAckToken extends Token { * undefined when UTF8_SUPPORT not included in token. */ declare utf8Support: boolean | undefined; - /** Value of JSON_SUPPORT acknowledgement. + /** Raw data of the JSON_SUPPORT acknowledgement. Contains the JSON version + * chosen by the server as its single byte. * * undefined when JSON_SUPPORT not included in token. */ - declare jsonSupport: boolean | undefined; + declare jsonSupport: Buffer | undefined; - constructor(fedAuth: Buffer | undefined, utf8Support: boolean | undefined, jsonSupport: boolean | undefined) { + constructor(fedAuth: Buffer | undefined, utf8Support: boolean | undefined, jsonSupport: Buffer | undefined) { super('FEATUREEXTACK', 'onFeatureExtAck'); this.fedAuth = fedAuth; diff --git a/test/unit/token/feature-ext-parser-test.ts b/test/unit/token/feature-ext-parser-test.ts index 6e7f8595d..b61ffe924 100644 --- a/test/unit/token/feature-ext-parser-test.ts +++ b/test/unit/token/feature-ext-parser-test.ts @@ -80,10 +80,32 @@ describe('Feature Ext Parser', () => { const token = result.value; assert.instanceOf(token, FeatureExtAckToken); - assert.strictEqual(token.jsonSupport, true); // feature ext ack for JSON_SUPPORT was positive + assert.deepEqual(token.jsonSupport, Buffer.from([0x01])); // raw JSON_SUPPORT acknowledgement data assert.isUndefined(token.utf8Support); // feature ext ack for UTF8_SUPPORT was not received assert.isUndefined(token.fedAuth); // fed auth not ack'd assert.isTrue((await parser.next()).done); }); + + it('should preserve the raw data of JSON support tokens with unknown versions', async function() { + const debug = new Debug(); + const buffer = new WritableTrackingBuffer(8); + + buffer.writeUInt8(0xAE); // FEATUREEXTACK token header + buffer.writeUInt8(0x0D); // JSON_SUPPORT feature id + buffer.writeUInt32LE(0x00_00_00_01); // datalen + buffer.writeUInt8(0x02); // version 2 + + buffer.writeUInt8(0xFF); // TERMINATOR + + const parser = StreamParser.parseTokens([buffer.data], debug, options); + const result = await parser.next(); + assert.isFalse(result.done); + const token = result.value; + + assert.instanceOf(token, FeatureExtAckToken); + assert.deepEqual(token.jsonSupport, Buffer.from([0x02])); + + assert.isTrue((await parser.next()).done); + }); }); diff --git a/test/unit/token/handler-test.ts b/test/unit/token/handler-test.ts new file mode 100644 index 000000000..fece417b1 --- /dev/null +++ b/test/unit/token/handler-test.ts @@ -0,0 +1,61 @@ +import { assert } from 'chai'; + +import { Login7TokenHandler } from '../../../src/token/handler'; +import { FeatureExtAckToken } from '../../../src/token/token'; +import { ConnectionError } from '../../../src/errors'; +import type Connection from '../../../src/connection'; + +function buildConnection(): Connection { + return { + config: { authentication: { type: 'default', options: {} } }, + serverSupportsJson: false + } as unknown as Connection; +} + +describe('Login7TokenHandler', function() { + describe('#onFeatureExtAck', function() { + it('marks the server as json capable for a version 1 JSON support acknowledgement', function() { + const connection = buildConnection(); + const handler = new Login7TokenHandler(connection); + + handler.onFeatureExtAck(new FeatureExtAckToken(undefined, undefined, Buffer.from([0x01]))); + + assert.isTrue(connection.serverSupportsJson); + assert.isUndefined(handler.loginError); + }); + + it('leaves the server marked as not json capable without a JSON support acknowledgement', function() { + const connection = buildConnection(); + const handler = new Login7TokenHandler(connection); + + handler.onFeatureExtAck(new FeatureExtAckToken(undefined, true, undefined)); + + assert.isFalse(connection.serverSupportsJson); + assert.isUndefined(handler.loginError); + }); + + it('fails the login for a JSON support acknowledgement with an unknown version', function() { + const connection = buildConnection(); + const handler = new Login7TokenHandler(connection); + + handler.onFeatureExtAck(new FeatureExtAckToken(undefined, undefined, Buffer.from([0x02]))); + + assert.isFalse(connection.serverSupportsJson); + assert.instanceOf(handler.loginError, ConnectionError); + assert.strictEqual(handler.loginError!.message, 'Received invalid JSON support acknowledgement'); + }); + + it('fails the login for a JSON support acknowledgement with invalid data', function() { + for (const data of [Buffer.alloc(0), Buffer.from([0x01, 0x01])]) { + const connection = buildConnection(); + const handler = new Login7TokenHandler(connection); + + handler.onFeatureExtAck(new FeatureExtAckToken(undefined, undefined, data)); + + assert.isFalse(connection.serverSupportsJson); + assert.instanceOf(handler.loginError, ConnectionError); + assert.strictEqual(handler.loginError!.message, 'Received invalid JSON support acknowledgement'); + } + }); + }); +}); From f715b4b2aaa7274b8ead3c782f1d7e3172a9c363 Mon Sep 17 00:00:00 2001 From: Arthur Schreiber Date: Sun, 19 Jul 2026 14:42:21 +0000 Subject: [PATCH 14/15] fix: point the EJSONNOTSUPPORTED error at the configured tdsVersion when JSON support was never negotiated The same error fires for two different root causes: the server not acknowledging JSONSUPPORT, and a configured `tdsVersion` below 7_4 - in which case the feature extension (and with it the negotiation) is never sent, and the server never got asked. The message blamed the server in both cases, which is misleading against a json-capable server with an old `tdsVersion` configured. Name the actual cause in each case. Co-Authored-By: Claude Fable 5 --- src/connection.ts | 9 ++++++++- test/integration/json-test.ts | 9 +++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/connection.ts b/src/connection.ts index 0afefac2b..49175cb85 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -3224,7 +3224,14 @@ class Connection extends EventEmitter { request.callback(new RequestError('Canceled.', 'ECANCEL')); }); } else if (payload instanceof RpcRequestPayload && !this.serverSupportsJson && payload.parameters.some((parameter) => parameter.type === TYPES.JSON)) { - const message = 'The server does not support the `json` data type. `TYPES.JSON` parameters require SQL Server 2025 (or newer) or Azure SQL with JSON support enabled.'; + let message; + if (versions[this.config.options.tdsVersion] < versions['7_4']) { + // JSON support was never requested at login - feature extensions + // (and with them the JSONSUPPORT negotiation) require TDS 7.4. + message = 'JSON support was not negotiated with the server because the configured `tdsVersion` (`' + this.config.options.tdsVersion + '`) is lower than `7_4`. `TYPES.JSON` parameters require TDS version `7_4`.'; + } else { + message = 'The server does not support the `json` data type. `TYPES.JSON` parameters require SQL Server 2025 (or newer) or Azure SQL with JSON support enabled.'; + } this.debug.log(message); process.nextTick(() => { request.callback(new RequestError(message, 'EJSONNOTSUPPORTED')); diff --git a/test/integration/json-test.ts b/test/integration/json-test.ts index 6f3c57545..ee5c065c5 100644 --- a/test/integration/json-test.ts +++ b/test/integration/json-test.ts @@ -261,6 +261,15 @@ describe('json data type', function() { const request = new Request('SELECT @p', (err) => { assert.instanceOf(err, Error); assert.strictEqual((err as any).code, 'EJSONNOTSUPPORTED'); + + if (config.options.tdsVersion && config.options.tdsVersion < '7_4') { + // JSON support was never negotiated because of the configured TDS + // version - the error should point at that, not at the server. + assert.include((err as Error).message, 'tdsVersion'); + } else { + assert.include((err as Error).message, 'SQL Server 2025'); + } + done(); }); request.addParameter('p', TYPES.JSON, '{"a":1}'); From 937e6c950b733179a3ac52d6a30f0dbf3618a1b3 Mon Sep 17 00:00:00 2001 From: Arthur Schreiber Date: Sun, 19 Jul 2026 14:42:21 +0000 Subject: [PATCH 15/15] style: align json.ts with the other data type files Function expressions and shorthand methods instead of arrow functions, unused trailing parameters dropped, blank lines between logical blocks, and the codebase-wide 'utf8' encoding spelling. Co-Authored-By: Claude Fable 5 --- src/data-types/json.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/data-types/json.ts b/src/data-types/json.ts index 223119ef7..95149ab61 100644 --- a/src/data-types/json.ts +++ b/src/data-types/json.ts @@ -1,4 +1,5 @@ import { type DataType } from '../data-type'; + const UNKNOWN_PLP_LEN = Buffer.from([0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]); const PLP_TERMINATOR = Buffer.from([0x00, 0x00, 0x00, 0x00]); const MAX_NULL_LENGTH = Buffer.from([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]); @@ -8,24 +9,27 @@ const Json: DataType = { type: 'JSON', name: 'JSON', - declaration: (parameter) => { + declaration: function() { return 'json'; }, - generateTypeInfo(parameter) { + generateTypeInfo() { return Buffer.from([this.id]); }, - generateParameterLength: (parameter, options) => { + generateParameterLength(parameter, options) { const value = parameter.value as Buffer | null; + if (value == null) { return MAX_NULL_LENGTH; } + return UNKNOWN_PLP_LEN; }, - generateParameterData: function* (parameter, options) { + *generateParameterData(parameter, options) { const value = parameter.value as Buffer | null; + if (value == null) { return; } @@ -34,20 +38,21 @@ const Json: DataType = { const buffer = Buffer.alloc(4); buffer.writeUInt32LE(value.length, 0); yield buffer; + yield value; } yield PLP_TERMINATOR; }, - validate: (value, collation): Buffer | null => { + validate: function(value): Buffer | null { if (value == null) { return null; } if (typeof value === 'string') { JSON.parse(value); - return Buffer.from(value, 'utf-8'); + return Buffer.from(value, 'utf8'); } if (Buffer.isBuffer(value)) { @@ -59,7 +64,7 @@ const Json: DataType = { throw new TypeError('Invalid JSON value.'); } - return Buffer.from(serialized, 'utf-8'); + return Buffer.from(serialized, 'utf8'); } };