From 2433e52d8f42825a65f2374706f06c2e59b3531a Mon Sep 17 00:00:00 2001 From: huntiez Date: Sun, 12 Jul 2026 13:56:14 +0200 Subject: [PATCH] perf(pglite): reduce per-row allocation in parseResults Hoist field names and type OIDs once per result set on rowDescription, then build rows with indexed loops instead of Object.fromEntries and per-row .map(). Adds unit tests for parseResults covering object/array row modes, multi-result sets, and custom parsers. --- .changeset/optimize-parse-results.md | 5 + packages/pglite/src/parse.ts | 56 ++++++---- packages/pglite/tests/parse.test.ts | 159 +++++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 23 deletions(-) create mode 100644 .changeset/optimize-parse-results.md create mode 100644 packages/pglite/tests/parse.test.ts diff --git a/.changeset/optimize-parse-results.md b/.changeset/optimize-parse-results.md new file mode 100644 index 000000000..e4b046131 --- /dev/null +++ b/.changeset/optimize-parse-results.md @@ -0,0 +1,5 @@ +--- +"@electric-sql/pglite": patch +--- + +Reduce per-row allocation overhead in `parseResults()` by hoisting field metadata once per result set and building rows with indexed loops instead of `Object.fromEntries` / `.map()`. diff --git a/packages/pglite/src/parse.ts b/packages/pglite/src/parse.ts index 06e4490d7..0302d37a4 100644 --- a/packages/pglite/src/parse.ts +++ b/packages/pglite/src/parse.ts @@ -20,6 +20,8 @@ export function parseResults( ): Array { const resultSets: Results[] = [] let currentResultSet: Results = { rows: [], fields: [] } + let fieldNames: string[] | undefined + let fieldTypes: number[] | undefined let affectedRows = 0 const parsers = { ...defaultParsers, ...options?.parsers } @@ -27,35 +29,41 @@ export function parseResults( switch (message.name) { case 'rowDescription': { const msg = message as RowDescriptionMessage - currentResultSet.fields = msg.fields.map((field) => ({ - name: field.name, - dataTypeID: field.dataTypeID, - })) + const n = msg.fields.length + fieldNames = new Array(n) + fieldTypes = new Array(n) + currentResultSet.fields = new Array(n) + for (let i = 0; i < n; i++) { + const field = msg.fields[i] + fieldNames[i] = field.name + fieldTypes[i] = field.dataTypeID + currentResultSet.fields[i] = { + name: field.name, + dataTypeID: field.dataTypeID, + } + } break } case 'dataRow': { - if (!currentResultSet) break + if (!fieldNames || !fieldTypes) break const msg = message as DataRowMessage + const n = msg.fields.length if (options?.rowMode === 'array') { - currentResultSet.rows.push( - msg.fields.map((field, i) => - parseType(field, currentResultSet!.fields[i].dataTypeID, parsers), - ), - ) + const row = new Array(n) + for (let i = 0; i < n; i++) { + row[i] = parseType(msg.fields[i], fieldTypes[i], parsers) + } + currentResultSet.rows.push(row) } else { - // rowMode === "object" - currentResultSet.rows.push( - Object.fromEntries( - msg.fields.map((field, i) => [ - currentResultSet!.fields[i].name, - parseType( - field, - currentResultSet!.fields[i].dataTypeID, - parsers, - ), - ]), - ), - ) + const row: Record = {} + for (let i = 0; i < n; i++) { + row[fieldNames[i]] = parseType( + msg.fields[i], + fieldTypes[i], + parsers, + ) + } + currentResultSet.rows.push(row) } break } @@ -70,6 +78,8 @@ export function parseResults( }) currentResultSet = { rows: [], fields: [] } + fieldNames = undefined + fieldTypes = undefined break } } diff --git a/packages/pglite/tests/parse.test.ts b/packages/pglite/tests/parse.test.ts new file mode 100644 index 000000000..6e9490c17 --- /dev/null +++ b/packages/pglite/tests/parse.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect } from 'vitest' +import { + RowDescriptionMessage, + DataRowMessage, + CommandCompleteMessage, +} from '@electric-sql/pg-protocol/messages' +import { parseResults, parseDescribeStatementResults } from '../src/parse' +import { ParameterDescriptionMessage } from '@electric-sql/pg-protocol/messages' + +function rowDescription( + fields: Array<{ name: string; dataTypeID: number }>, +): RowDescriptionMessage { + const msg = new RowDescriptionMessage(0, fields.length) + msg.fields = fields.map((field) => ({ + name: field.name, + tableID: 0, + columnID: 0, + dataTypeID: field.dataTypeID, + dataTypeSize: 0, + dataTypeModifier: 0, + format: 0, + })) + return msg +} + +function dataRow(values: (string | null)[]): DataRowMessage { + return new DataRowMessage(0, values) +} + +function commandComplete(text: string): CommandCompleteMessage { + return new CommandCompleteMessage(0, text) +} + +describe('parseResults', () => { + it('parses object-mode rows with typed values', () => { + const messages = [ + rowDescription([ + { name: 'id', dataTypeID: 23 }, + { name: 'name', dataTypeID: 25 }, + ]), + dataRow(['1', 'alice']), + dataRow(['2', 'bob']), + commandComplete('SELECT 2'), + ] + + const [result] = parseResults(messages, {}) + + expect(result.fields).toEqual([ + { name: 'id', dataTypeID: 23 }, + { name: 'name', dataTypeID: 25 }, + ]) + expect(result.rows).toEqual([ + { id: 1, name: 'alice' }, + { id: 2, name: 'bob' }, + ]) + expect(result.affectedRows).toBe(0) + }) + + it('parses array-mode rows', () => { + const messages = [ + rowDescription([ + { name: 'id', dataTypeID: 23 }, + { name: 'active', dataTypeID: 16 }, + ]), + dataRow(['42', 't']), + commandComplete('SELECT 1'), + ] + + const [result] = parseResults(messages, {}, { rowMode: 'array' }) + + expect(result.rows).toEqual([[42, true]]) + }) + + it('accumulates affected rows across multiple result sets', () => { + const messages = [ + rowDescription([{ name: 'id', dataTypeID: 23 }]), + dataRow(['1']), + commandComplete('SELECT 1'), + rowDescription([{ name: 'id', dataTypeID: 23 }]), + dataRow(['2']), + commandComplete('SELECT 1'), + ] + + const results = parseResults(messages, {}) + + expect(results).toHaveLength(2) + expect(results[0].rows).toEqual([{ id: 1 }]) + expect(results[1].rows).toEqual([{ id: 2 }]) + expect(results[0].affectedRows).toBe(0) + expect(results[1].affectedRows).toBe(0) + }) + + it('tracks INSERT/UPDATE/DELETE row counts', () => { + const insert = parseResults([commandComplete('INSERT 0 3')], {})[0] + .affectedRows + const update = parseResults([commandComplete('UPDATE 5')], {})[0] + .affectedRows + const del = parseResults([commandComplete('DELETE 2')], {})[0].affectedRows + + expect(insert).toBe(3) + expect(update).toBe(5) + expect(del).toBe(2) + }) + + it('returns empty result set when no commandComplete is present', () => { + const [result] = parseResults([], {}) + + expect(result).toEqual({ + affectedRows: 0, + rows: [], + fields: [], + }) + }) + + it('applies custom parsers from options', () => { + const messages = [ + rowDescription([{ name: 'value', dataTypeID: 25 }]), + dataRow(['hello']), + commandComplete('SELECT 1'), + ] + + const [result] = parseResults( + messages, + {}, + { + parsers: { + 25: (value) => `parsed:${value}`, + }, + }, + ) + + expect(result.rows).toEqual([{ value: 'parsed:hello' }]) + }) + + it('attaches blob when provided', () => { + const blob = new Blob(['test']) + const [result] = parseResults( + [commandComplete('SELECT 0')], + {}, + undefined, + blob, + ) + + expect(result.blob).toBe(blob) + }) +}) + +describe('parseDescribeStatementResults', () => { + it('returns parameter type OIDs when present', () => { + const msg = new ParameterDescriptionMessage(0, 2) + msg.dataTypeIDs = [23, 25] + + expect(parseDescribeStatementResults([msg])).toEqual([23, 25]) + }) + + it('returns empty array when parameter description is missing', () => { + expect(parseDescribeStatementResults([])).toEqual([]) + }) +})