Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
79 changes: 71 additions & 8 deletions packages/pg/lib/result.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 }
Expand Down
3 changes: 3 additions & 0 deletions packages/pg/lib/type-overrides.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
162 changes: 162 additions & 0 deletions packages/pg/test/unit/client/field-metadata-cache-tests.js
Original file line number Diff line number Diff line change
@@ -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')
})
Loading