diff --git a/docs/pages/features/queries.mdx b/docs/pages/features/queries.mdx index 63ecdde1e..a9fbb26bb 100644 --- a/docs/pages/features/queries.mdx +++ b/docs/pages/features/queries.mdx @@ -44,6 +44,10 @@ Custom conversion to a UTC date string. Buffer instances are unchanged. +**ArrayBuffer and ArrayBuffer views** + +Converted to a Buffer. An ArrayBuffer includes all of its bytes; typed arrays and DataView values include only the bytes within the view. + **Array** Converted to a string that describes a Postgres array. Each array item is recursively converted using the rules described here. diff --git a/packages/pg/lib/utils.js b/packages/pg/lib/utils.js index ba51c82c8..d0d65906a 100644 --- a/packages/pg/lib/utils.js +++ b/packages/pg/lib/utils.js @@ -3,7 +3,7 @@ const defaults = require('./defaults') const nodeUtils = require('util') -const { isDate } = require('util/types') +const { isDate, isArrayBuffer } = require('util/types') const invalidDateDeprecationNotice = nodeUtils.deprecate( () => {}, @@ -36,6 +36,8 @@ function arrayString(val) { item = Buffer.from(item.buffer, item.byteOffset, item.byteLength) } result += '\\\\x' + item.toString('hex') + } else if (isArrayBuffer(item)) { + result += '\\\\x' + Buffer.from(item).toString('hex') } else { result += escapeElement(prepareValue(item)) } @@ -60,6 +62,9 @@ const prepareValue = function (val, seen) { if (ArrayBuffer.isView(val)) { return Buffer.from(val.buffer, val.byteOffset, val.byteLength) } + if (isArrayBuffer(val)) { + return Buffer.from(val) + } if (isDate(val)) { if (isNaN(val.getTime())) { invalidDateDeprecationNotice() diff --git a/packages/pg/test/integration/client/arraybuffer-tests.js b/packages/pg/test/integration/client/arraybuffer-tests.js new file mode 100644 index 000000000..bc28df725 --- /dev/null +++ b/packages/pg/test/integration/client/arraybuffer-tests.js @@ -0,0 +1,85 @@ +'use strict' +const helper = require('./test-helper') +const assert = require('assert') +const vm = require('vm') +const suite = new helper.Suite() + +suite.test('ArrayBuffer parameters work with cached prepared queries', async function () { + const client = new helper.pg.Client(helper.config) + await client.connect() + try { + for (const bytes of [[], [65, 66, 67], [68, 69]]) { + const value = Uint8Array.from(bytes).buffer + const result = await client.query({ + name: 'arraybuffer-parameter', + text: 'SELECT $1::bytea AS value', + values: [value], + }) + assert.deepStrictEqual(result.rows[0].value, Buffer.from(bytes)) + } + } finally { + await client.end() + } +}) + +// libpq currently accepts scalar Buffer parameters as text, so arbitrary binary +// scalar parameters are only supported by the JavaScript client. +if (!helper.args.native) { + suite.test('ArrayBuffer round-trips packed metadata and a floating-point payload', async function () { + const client = new helper.pg.Client(helper.config) + await client.connect() + try { + const value = new ArrayBuffer(12) + new Uint8Array(value, 0, 4).set([0, 128, 255, 42]) + new Float32Array(value, 4).set([1.5, -2.5]) + await client.query('CREATE TEMP TABLE packed_data (value bytea)') + await client.query('INSERT INTO packed_data VALUES ($1)', [value]) + const result = await client.query('SELECT value FROM packed_data') + assert.deepStrictEqual(Array.from(result.rows[0].value), Array.from(new Uint8Array(value))) + } finally { + await client.end() + } + }) + + suite.test('ArrayBuffer parameters preserve all bytes, cross-realm values and view bounds', async function () { + const client = new helper.pg.Client(helper.config) + await client.connect() + try { + const bytes = Uint8Array.from({ length: 256 }, (_, i) => i) + const foreign = vm.runInNewContext('Uint8Array.from([0, 128, 255]).buffer') + const cases = [ + [bytes.buffer, Buffer.from(bytes)], + [foreign, Buffer.from([0, 128, 255])], + [new DataView(bytes.buffer, 127, 3), Buffer.from([127, 128, 129])], + [bytes.subarray(127, 130), Buffer.from([127, 128, 129])], + [{ toPostgres: () => foreign }, Buffer.from([0, 128, 255])], + ] + for (const [value, expected] of cases) { + const result = await client.query('SELECT $1::bytea AS value', [value]) + assert.deepStrictEqual(result.rows[0].value, expected) + } + } finally { + await client.end() + } + }) +} + +suite.test('ArrayBuffer arrays round-trip binary values and nulls', async function () { + const client = new helper.pg.Client(helper.config) + await client.connect() + try { + const bytes = Uint8Array.from({ length: 256 }, (_, i) => i) + const foreign = vm.runInNewContext('Uint8Array.from([0, 128, 255]).buffer') + const value = [ + [bytes.buffer, new ArrayBuffer(0), null], + [foreign, bytes.subarray(127, 130), new DataView(bytes.buffer, 254, 2)], + ] + const result = await client.query('SELECT $1::bytea[] AS value', [value]) + assert.deepStrictEqual(result.rows[0].value, [ + [Buffer.from(bytes), Buffer.alloc(0), null], + [Buffer.from([0, 128, 255]), Buffer.from([127, 128, 129]), Buffer.from([254, 255])], + ]) + } finally { + await client.end() + } +}) diff --git a/packages/pg/test/unit/arraybuffer-tests.js b/packages/pg/test/unit/arraybuffer-tests.js new file mode 100644 index 000000000..8bfd9f1d3 --- /dev/null +++ b/packages/pg/test/unit/arraybuffer-tests.js @@ -0,0 +1,70 @@ +'use strict' +const helper = require('./test-helper') +const utils = require('../../lib/utils') +const assert = require('assert') +const vm = require('vm') +const suite = new helper.Suite() +const test = suite.test.bind(suite) + +test('prepareValue: ArrayBuffer includes all packed views', function () { + const value = new ArrayBuffer(12) + new Uint8Array(value, 0, 4).set([0, 128, 255, 42]) + new Float32Array(value, 4).set([1.5, -2.5]) + + const out = utils.prepareValue(value) + assert.ok(Buffer.isBuffer(out)) + assert.deepStrictEqual(Array.from(out), Array.from(new Uint8Array(value))) +}) + +test('prepareValue: empty ArrayBuffer is an empty Buffer', function () { + assert.deepStrictEqual(utils.prepareValue(new ArrayBuffer(0)), Buffer.alloc(0)) +}) + +test('prepareValue: ArrayBuffer preserves every byte value', function () { + const bytes = Uint8Array.from({ length: 256 }, (_, i) => i) + assert.deepStrictEqual(utils.prepareValue(bytes.buffer), Buffer.from(bytes)) +}) + +test('prepareValue: ArrayBuffer from another realm is binary', function () { + const value = vm.runInNewContext('Uint8Array.from([0, 128, 255]).buffer') + assert.ok(!(value instanceof ArrayBuffer)) + assert.deepStrictEqual(utils.prepareValue(value), Buffer.from([0, 128, 255])) +}) + +test('prepareValue: custom scalar conversion can return an ArrayBuffer', function () { + const value = { + toPostgres: function () { + return Uint8Array.from([0, 128, 255]).buffer + }, + } + assert.deepStrictEqual(utils.prepareValue(value), Buffer.from([0, 128, 255])) +}) + +test('prepareValue: arrays mix ArrayBuffers and bounded views', function () { + const bytes = Uint8Array.from([0, 128, 255, 42]) + const values = [bytes.buffer, bytes.subarray(1, 3), new DataView(bytes.buffer, 2, 1), new ArrayBuffer(0)] + assert.strictEqual(utils.prepareValue(values), '{\\\\x0080ff2a,\\\\x80ff,\\\\xff,\\\\x}') +}) + +test('prepareValue: nested ArrayBuffer arrays preserve nulls', function () { + const value = vm.runInNewContext('Uint8Array.from([0, 128, 255]).buffer') + assert.strictEqual( + utils.prepareValue([ + [value, null], + [new ArrayBuffer(0), undefined], + ]), + '{{\\\\x0080ff,NULL},{\\\\x,NULL}}' + ) +}) + +test('prepareValue: DataView and typed array slices keep their byte boundaries', function () { + const bytes = Uint8Array.from([0, 1, 128, 255, 42, 43]) + assert.deepStrictEqual(utils.prepareValue(new DataView(bytes.buffer, 2, 2)), Buffer.from([128, 255])) + assert.deepStrictEqual(utils.prepareValue(new Uint16Array(bytes.buffer, 2, 1)), Buffer.from([128, 255])) +}) + +test('prepareValue: ArrayBuffer lookalikes remain JSON objects', function () { + const value = { byteLength: 3, [Symbol.toStringTag]: 'ArrayBuffer' } + assert.strictEqual(utils.prepareValue(value), '{"byteLength":3}') + assert.strictEqual(utils.prepareValue([value]), utils.prepareValue([{ byteLength: 3 }])) +})