Skip to content
20 changes: 20 additions & 0 deletions src/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 11 additions & 1 deletion src/data-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -129,6 +130,7 @@ export const TYPE = {
[UDT.id]: UDT,
[TVP.id]: TVP,
[Variant.id]: Variant,
[Json.id]: Json
};

/**
Expand Down Expand Up @@ -400,6 +402,13 @@ export const TYPE = {
* <td>✓</td>
* <td>-</td>
* </tr>
* <tr>
* <td><code>json</code></td>
* <td><code>[[TYPES.JSON]]</code></td>
* <td><code>string|object</code></td>
* <td>✓</td>
* <td>-</td>
* </tr>
* </tbody>
* </table>
*
Expand Down Expand Up @@ -467,7 +476,8 @@ export const TYPES = {
DateTimeOffset,
UDT,
TVP,
Variant
Variant,
JSON: Json
};

export const typeByName = TYPES;
67 changes: 67 additions & 0 deletions src/data-types/json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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';
},

generateTypeInfo(parameter) {
return Buffer.from([this.id]);
},

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) {
return;
}

if (value.length > 0) {
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 (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(serialized, 'utf-8');
}
};

export default Json;
module.exports = Json;
11 changes: 11 additions & 0 deletions src/login7-payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
15 changes: 15 additions & 0 deletions src/metadata-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
8 changes: 7 additions & 1 deletion src/token/feature-ext-ack-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FeatureExtAckToken> {
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;
Expand All @@ -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;
}
}
}
Expand Down
6 changes: 5 additions & 1 deletion src/token/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
2 changes: 2 additions & 0 deletions src/token/nbcrow-token-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ async function nbcRowParser(parser: Parser): Promise<NBCRowToken> {
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;
Expand Down
2 changes: 2 additions & 0 deletions src/token/returnvalue-token-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ async function returnParser(parser: Parser): Promise<ReturnValueToken> {
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 {
Expand Down
2 changes: 2 additions & 0 deletions src/token/row-token-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ async function rowParser(parser: Parser): Promise<RowToken> {
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;
Expand Down
8 changes: 7 additions & 1 deletion src/token/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/value-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,10 @@ function isPLPStream(metadata: Metadata) {
case 'UDT': {
return true;
}

case 'JSON': {
return true;
}
}
}

Expand Down
Loading
Loading