Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions src/bulk-load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ 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 { Collation } from './collation';
import { TYPES, type DataType, type Parameter } from './data-type';
import { Collation, JSON_COLLATION } from './collation';

/**
* @private
Expand Down Expand Up @@ -563,7 +563,21 @@ 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 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));
}

// TableName
if (c.type.hasTableName) {
Expand Down
8 changes: 8 additions & 0 deletions src/collation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
27 changes: 27 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,19 @@ 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)) {
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'));
});
} 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;
72 changes: 72 additions & 0 deletions src/data-types/json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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: function() {
return 'json';
},

generateTypeInfo() {
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(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: function(value): Buffer | null {
if (value == null) {
return null;
}

if (typeof value === 'string') {
JSON.parse(value);
return Buffer.from(value, 'utf8');
}

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, 'utf8');
}
};

export default Json;
module.exports = Json;
17 changes: 16 additions & 1 deletion src/data-types/tvp.ts
Original file line number Diff line number Diff line change
@@ -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]);
Expand Down Expand Up @@ -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]);
Expand Down
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
10 changes: 9 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: Buffer | 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,12 @@ 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.
// Whether that version is one this client supports is decided by the
// Login7TokenHandler.
jsonSupport = featureData;
break;
}
}
}
Expand Down
14 changes: 13 additions & 1 deletion src/token/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,13 +310,25 @@ export class Login7TokenHandler extends TokenHandler {
onFeatureExtAck(token: FeatureExtAckToken) {
const { authentication } = this.connection.config;

if (token.jsonSupport !== undefined) {
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') {
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
9 changes: 8 additions & 1 deletion src/token/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,11 +332,18 @@ export class FeatureExtAckToken extends Token {
* undefined when UTF8_SUPPORT not included in token. */
declare utf8Support: boolean | undefined;

constructor(fedAuth: Buffer | undefined, utf8Support: boolean | undefined) {
/** 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: Buffer | undefined;

constructor(fedAuth: Buffer | undefined, utf8Support: boolean | undefined, jsonSupport: Buffer | undefined) {
super('FEATUREEXTACK', 'onFeatureExtAck');

this.fedAuth = fedAuth;
this.utf8Support = utf8Support;
this.jsonSupport = jsonSupport;
}
}

Expand Down
Loading
Loading