diff --git a/packages/pg/lib/result.js b/packages/pg/lib/result.js index 329fbf9fc..f97228c9d 100644 --- a/packages/pg/lib/result.js +++ b/packages/pg/lib/result.js @@ -1,9 +1,32 @@ 'use strict' const types = require('pg-types') +const TypeOverrides = require('./type-overrides') const matchRegexp = /^([A-Za-z]+)(?: (\d+))?(?: (\d+))?/ +// Real workloads repeat a small set of result-set shapes, so the parser array and +// prebuilt empty row for a shape are cached per types object instead of rebuilt on +// every query. Entries are only cached against types objects whose registrations are +// versioned — TypeOverrides instances (see setTypeParser there) and the pg-types +// module — so late setTypeParser calls invalidate; arbitrary user-supplied types +// objects keep the uncached path since their behavior can change without notice. +const fieldMetadataCache = new WeakMap() +const FIELD_METADATA_CACHE_LIMIT = 1000 + +// Registrations on the global module must invalidate cached field metadata no matter +// how they arrive — pg.types.setTypeParser, defaults.parseInt8, or a direct pg-types +// require all mutate the same module object — so its setTypeParser is wrapped once +// to count them. +if (!types.__setTypeParserWrapped) { + const setTypeParser = types.setTypeParser + types.setTypeParser = function () { + types.__typeParserVersion = (types.__typeParserVersion || 0) + 1 + return setTypeParser.apply(types, arguments) + } + types.__setTypeParserWrapped = true +} + // result object returned from query // in the 'end' event and also // passed as second argument to provided callback @@ -85,21 +108,61 @@ class Result { // of rowDescriptions...eg: 'select NOW(); select 1::int;' // you need to reset the fields this.fields = fieldDescriptions - if (this.fields.length) { - this._parsers = new Array(fieldDescriptions.length) + if (!fieldDescriptions.length) { + this._prebuiltEmptyResultObject = {} + return + } + + const typesInstance = this._types || types + const cacheable = + typesInstance === types || (typesInstance instanceof TypeOverrides && typesInstance._types === types) + if (!cacheable) { + this._buildFieldMetadata(fieldDescriptions, typesInstance) + return + } + + // The global module's version is always folded in: TypeOverrides.getTypeParser + // falls back to it for any oid without an instance-level override. + const version = (typesInstance.__typeParserVersion || 0) + (types.__typeParserVersion || 0) + let cacheEntry = fieldMetadataCache.get(typesInstance) + if (!cacheEntry || cacheEntry.version !== version) { + cacheEntry = { version, map: new Map() } + fieldMetadataCache.set(typesInstance, cacheEntry) + } + + // Postgres identifiers and wire strings cannot contain NUL, so '\0' cannot + // collide with field names. + let signature = '' + for (let i = 0; i < fieldDescriptions.length; i++) { + const desc = fieldDescriptions[i] + signature += desc.name + '\0' + desc.dataTypeID + '\0' + (desc.format || 'text') + '\0' + } + + const cached = cacheEntry.map.get(signature) + if (cached) { + this._parsers = cached.parsers + this._prebuiltEmptyResultObject = cached.prebuiltEmptyResultObject + return + } + + this._buildFieldMetadata(fieldDescriptions, typesInstance) + if (cacheEntry.map.size >= FIELD_METADATA_CACHE_LIMIT) { + cacheEntry.map.clear() } + cacheEntry.map.set(signature, { + parsers: this._parsers, + prebuiltEmptyResultObject: this._prebuiltEmptyResultObject, + }) + } + _buildFieldMetadata(fieldDescriptions, typesInstance) { + this._parsers = new Array(fieldDescriptions.length) const row = Object.create(null) for (let i = 0; i < fieldDescriptions.length; i++) { const desc = fieldDescriptions[i] row[desc.name] = null - - if (this._types) { - this._parsers[i] = this._types.getTypeParser(desc.dataTypeID, desc.format || 'text') - } else { - this._parsers[i] = types.getTypeParser(desc.dataTypeID, desc.format || 'text') - } + this._parsers[i] = typesInstance.getTypeParser(desc.dataTypeID, desc.format || 'text') } this._prebuiltEmptyResultObject = { ...row } diff --git a/packages/pg/lib/type-overrides.js b/packages/pg/lib/type-overrides.js index 9d219e525..1385e7e3f 100644 --- a/packages/pg/lib/type-overrides.js +++ b/packages/pg/lib/type-overrides.js @@ -25,6 +25,9 @@ TypeOverrides.prototype.setTypeParser = function (oid, format, parseFn) { format = 'text' } this.getOverrides(format)[oid] = parseFn + // Invalidates any per-query-shape field metadata cached against this instance + // (see result.js's addFields). + this.__typeParserVersion = (this.__typeParserVersion || 0) + 1 } TypeOverrides.prototype.getTypeParser = function (oid, format) { diff --git a/packages/pg/test/unit/client/field-metadata-cache-tests.js b/packages/pg/test/unit/client/field-metadata-cache-tests.js new file mode 100644 index 000000000..b9d4ba128 --- /dev/null +++ b/packages/pg/test/unit/client/field-metadata-cache-tests.js @@ -0,0 +1,162 @@ +'use strict' +const assert = require('assert') +const helper = require('./test-helper') +const Result = require('../../../lib/result') +const TypeOverrides = require('../../../lib/type-overrides') + +const suite = new helper.Suite() +const test = suite.test.bind(suite) + +const intField = { name: 'a', dataTypeID: 23, format: 'text' } +const textField = { name: 'b', dataTypeID: 25, format: 'text' } + +test('same query shape reuses the cached parser array and prebuilt row', function () { + const types = new TypeOverrides() + const first = new Result('', types) + first.addFields([intField, textField]) + const second = new Result('', types) + second.addFields([intField, textField]) + + assert.strictEqual(second._parsers, first._parsers) + assert.strictEqual(second._prebuiltEmptyResultObject, first._prebuiltEmptyResultObject) + assert.deepStrictEqual(second.parseRow(['42', 'hi']), { a: 42, b: 'hi' }) +}) + +test('different query shapes get their own metadata', function () { + const types = new TypeOverrides() + const first = new Result('', types) + first.addFields([intField]) + const second = new Result('', types) + second.addFields([textField]) + + assert.notStrictEqual(second._parsers, first._parsers) + assert.deepStrictEqual(first.parseRow(['1']), { a: 1 }) + assert.deepStrictEqual(second.parseRow(['1']), { b: '1' }) +}) + +test('field name, dataTypeID and format all key the cache', function () { + const types = new TypeOverrides() + const first = new Result('', types) + first.addFields([{ name: 'a', dataTypeID: 23, format: 'text' }]) + const second = new Result('', types) + second.addFields([{ name: 'a', dataTypeID: 25, format: 'text' }]) + + assert.notStrictEqual(second._parsers, first._parsers) + assert.deepStrictEqual(first.parseRow(['7']), { a: 7 }) + assert.deepStrictEqual(second.parseRow(['7']), { a: '7' }) +}) + +test('setTypeParser invalidates cached shapes on that instance', function () { + const types = new TypeOverrides() + const before = new Result('', types) + before.addFields([intField]) + assert.deepStrictEqual(before.parseRow(['42']), { a: 42 }) + + types.setTypeParser(23, 'text', (value) => `parsed:${value}`) + + const after = new Result('', types) + after.addFields([intField]) + assert.notStrictEqual(after._parsers, before._parsers) + assert.deepStrictEqual(after.parseRow(['42']), { a: 'parsed:42' }) +}) + +test('global pg-types setTypeParser between queries applies to later same-shape queries', function () { + const pgTypes = require('pg-types') + const bigintField = { name: 'big', dataTypeID: 20, format: 'text' } + const originalParser = pgTypes.getTypeParser(20, 'text') + const types = new TypeOverrides() + + const before = new Result('', types) + before.addFields([bigintField]) + assert.deepStrictEqual(before.parseRow(['1']), { big: originalParser('1') }) + + pgTypes.setTypeParser(20, 'text', (value) => `G:${value}`) + try { + const after = new Result('', types) + after.addFields([bigintField]) + assert.deepStrictEqual(after.parseRow(['1']), { big: 'G:1' }) + } finally { + pgTypes.setTypeParser(20, 'text', originalParser) + } +}) + +test('separate TypeOverrides instances do not share cached parsers', function () { + const typesA = new TypeOverrides() + typesA.setTypeParser(23, 'text', (value) => `A:${value}`) + const typesB = new TypeOverrides() + + const resultA = new Result('', typesA) + resultA.addFields([intField]) + const resultB = new Result('', typesB) + resultB.addFields([intField]) + + assert.deepStrictEqual(resultA.parseRow(['1']), { a: 'A:1' }) + assert.deepStrictEqual(resultB.parseRow(['1']), { a: 1 }) +}) + +test('custom user-supplied types objects bypass the cache', function () { + let calls = 0 + const customTypes = { + getTypeParser() { + calls++ + return (value) => `custom:${value}` + }, + } + + const first = new Result('', customTypes) + first.addFields([intField]) + const second = new Result('', customTypes) + second.addFields([intField]) + + assert.strictEqual(calls, 2) + assert.notStrictEqual(second._parsers, first._parsers) + assert.deepStrictEqual(second.parseRow(['1']), { a: 'custom:1' }) +}) + +test('a TypeOverrides wrapping custom user types bypasses the cache', function () { + let calls = 0 + const customTypes = { + getTypeParser() { + calls++ + return (value) => `custom:${value}` + }, + } + const types = new TypeOverrides(customTypes) + + const first = new Result('', types) + first.addFields([intField]) + const second = new Result('', types) + second.addFields([intField]) + + assert.strictEqual(calls, 2) + assert.deepStrictEqual(second.parseRow(['1']), { a: 'custom:1' }) +}) + +test('client.setTypeParser between queries applies to later same-shape queries', function (done) { + const client = helper.client() + const con = client.connection + + client.query('select 1', (err, result) => { + assert.ifError(err) + assert.deepStrictEqual(result.rows, [{ a: 42 }]) + + client.setTypeParser(23, 'text', (value) => `overridden:${value}`) + + client.query('select 1', (err2, result2) => { + assert.ifError(err2) + assert.deepStrictEqual(result2.rows, [{ a: 'overridden:42' }]) + done() + }) + + con.emit('rowDescription', { fields: [intField] }) + con.emit('dataRow', { fields: ['42'] }) + con.emit('commandComplete', { text: 'SELECT 1' }) + con.emit('readyForQuery') + }) + + con.emit('readyForQuery') + con.emit('rowDescription', { fields: [intField] }) + con.emit('dataRow', { fields: ['42'] }) + con.emit('commandComplete', { text: 'SELECT 1' }) + con.emit('readyForQuery') +})