diff --git a/src/bulk-load.ts b/src/bulk-load.ts index fe47e4fff..9aa0bfb64 100644 --- a/src/bulk-load.ts +++ b/src/bulk-load.ts @@ -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 @@ -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) { 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/connection.ts b/src/connection.ts index 0ff2f90e3..49175cb85 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,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; diff --git a/src/data-type.ts b/src/data-type.ts index b39adce22..447df1e4b 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: 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..95149ab61 --- /dev/null +++ b/src/data-types/json.ts @@ -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; 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/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..5810e9196 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..d0b781c88 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: 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; @@ -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; } } } diff --git a/src/token/handler.ts b/src/token/handler.ts index 1d0174f41..fdda37a50 100644 --- a/src/token/handler.ts +++ b/src/token/handler.ts @@ -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'); diff --git a/src/token/nbcrow-token-parser.ts b/src/token/nbcrow-token-parser.ts index 0b8d07468..6aee52f17 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..bb3d77b8a 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..251ab3b63 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..bc6ff491b 100644 --- a/src/token/token.ts +++ b/src/token/token.ts @@ -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; } } diff --git a/src/value-parser.ts b/src/value-parser.ts index 32ba6d1e7..14bfb8742 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/integration/json-test.ts b/test/integration/json-test.ts new file mode 100644 index 000000000..ee5c065c5 --- /dev/null +++ b/test/integration/json-test.ts @@ -0,0 +1,280 @@ +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); + }); + + 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); + }); + + 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); + }); + + 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() { + 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'); + + 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}'); + + connection.execSql(request); + }); + }); +}); diff --git a/test/unit/bulk-load-test.ts b/test/unit/bulk-load-test.ts index d1bf4393a..ec9882360 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,27 @@ 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 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 + ]); + + assert.deepEqual(request.getColMetaData(), expected); + }); + }); + describe('#cancel', function() { it('marks the request as canceled', function() { const request = new BulkLoad('tablename', undefined, connectionOptions, { }, () => {}); diff --git a/test/unit/data-type.ts b/test/unit/data-type.ts index 7263f6450..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() { @@ -1679,3 +1705,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/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); }); diff --git a/test/unit/token/colmetadata-token-parser-test.ts b/test/unit/token/colmetadata-token-parser-test.ts index a45415136..161a348c4 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..b61ffe924 100644 --- a/test/unit/token/feature-ext-parser-test.ts +++ b/test/unit/token/feature-ext-parser-test.ts @@ -58,6 +58,53 @@ 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.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'); + } + }); + }); +}); diff --git a/test/unit/token/nbcrow-token-parser-test.ts b/test/unit/token/nbcrow-token-parser-test.ts index 64b6796ce..5a71a7496 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/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); + }); +}); diff --git a/test/unit/token/row-token-parser-test.ts b/test/unit/token/row-token-parser-test.ts index f45c0e4b3..c4d9fd631 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); + }); });