From 13cea2bb1bc83a3ae7d62f263900e38069cb3473 Mon Sep 17 00:00:00 2001 From: Malthe Borch Date: Mon, 4 Dec 2023 09:55:38 +0100 Subject: [PATCH] Query result (and related objects) is now generic on the row type --- .eslintrc.json | 1 + CHANGES.md | 11 ++++++++ README.md | 16 ++++++++--- package-lock.json | 4 +-- src/client.ts | 51 ++++++++++++++-------------------- src/index.ts | 1 + src/protocol.ts | 25 ++++++++--------- src/query.ts | 3 +- src/result.ts | 68 +++++++++++++++++++++++++++++++++++---------- src/types.ts | 25 +---------------- test/client.test.ts | 14 +++++++--- test/result.test.ts | 39 ++++++++++++++++++++------ test/types.test.ts | 12 ++++---- 13 files changed, 161 insertions(+), 109 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 5274dce..3a2fb38 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -15,5 +15,6 @@ "@typescript-eslint" ], "rules": { + "@typescript-eslint/no-explicit-any": 0 } } diff --git a/CHANGES.md b/CHANGES.md index 3222e8d..267f25e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,16 @@ In next release ... +- The `Value` type has been replaced with `any`, motivated by the new + generic result type as well as the possibility to implement custom + value readers which could return objects of any type. + +- Query results are now generic with typing support for the `get` + method on each row. + + In addition, a new `map` method now produces records which conform + to the specified type. This method is available on all of the result + objects. + - Use lookup table to optimize `get` method. - The `connect` method now returns a boolean status of whether the diff --git a/README.md b/README.md index 506a623..cf2b188 100644 --- a/README.md +++ b/README.md @@ -35,14 +35,17 @@ The client uses an async/await-based programming model. ```typescript import { Client } from 'ts-postgres'; +interface Greeting { + message: string; +} + async function main() { const client = new Client(); await client.connect(); try { - // Querying the client returns a query result promise - // which is also an asynchronous result iterator. - const result = client.query( + // The query method is generic on the result row. + const result = client.query( "SELECT 'Hello ' || $1 || '!' AS message", ['world'] ); @@ -112,9 +115,14 @@ const query = new Query( "SELECT 'Hello ' || $1 || '!' AS message", ['world'] ); -const result = await client.execute(query); +const result = await client.execute(query); ``` +If the row type is omitted, it defaults to `Record`, but +providing a type ensures that the row values are typed, both when +accessed via the `get` method or if the row is mapped to a record +using the `map` method. + ### Passing query parameters Query parameters use the format `$1`, `$2` etc. diff --git a/package-lock.json b/package-lock.json index 989d459..8fa8e1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ts-postgres", - "version": "1.4.0", + "version": "1.5.0-dev", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "ts-postgres", - "version": "1.4.0", + "version": "1.5.0-dev", "license": "MIT", "dependencies": { "ts-typed-events": "^3.0.0" diff --git a/src/client.ts b/src/client.ts index 47a9222..3ebe00a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -15,9 +15,8 @@ import { ConnectionOptions, TLSSocket, connect as tls, createSecureContext } fro import { DataHandler, - Result as _Result, - ResultIterator as _ResultIterator, - ResultRow as _ResultRow, + ResultIterator, + ResultRecord, makeResult } from './result'; @@ -37,19 +36,11 @@ import { import { DataFormat, DataType, - Row, - Value, ValueTypeReader } from './types'; import { md5 } from './utils'; -export type Result = _Result; - -export type ResultIterator = _ResultIterator; - -export type ResultRow = _ResultRow; - export type Connect = Error | null; export type End = void; @@ -67,7 +58,7 @@ export interface ClientNotice extends DatabaseError { export interface DataTypeError { dataType: DataType, - value: Value + value: any } export enum SSLMode { @@ -100,14 +91,14 @@ export interface Notification { payload?: string } -export interface PreparedStatement { +export interface PreparedStatement { close: (portal?: string) => Promise; execute: ( - values?: Value[], + values?: any[], portal?: string, format?: DataFormat | DataFormat[], streams?: Record, - ) => ResultIterator + ) => ResultIterator } type Callback = (data: T) => void; @@ -127,7 +118,7 @@ type Event = ( type CloseHandler = () => void; interface RowDataHandler { - callback: DataHandler, + callback: DataHandler, streams: Record, } @@ -154,7 +145,7 @@ interface Bind { name: string; format: DataFormat | DataFormat[] portal: string; - values: Value[], + values: any[], close: boolean } @@ -188,7 +179,7 @@ export class Client { private expect = 5; private stream = new Socket(); private mustDrain = false; - private activeRow: Array | null = null; + private activeRow: Array | null = null; private bindQueue = new Queue(); private closeHandlerQueue = new Queue(); @@ -513,10 +504,10 @@ export class Client { } } - prepare( + prepare( text: string, name?: string, - types?: DataType[]): Promise { + types?: DataType[]): Promise> { const providedNameOrGenerated = name || ( (this.config.preparedStatementPrefix || @@ -524,7 +515,7 @@ export class Client { this.nextPreparedStatementId++ )); - return new Promise( + return new Promise>( (resolve, reject) => { const errorHandler: ErrorHandler = (error) => reject(error); this.errorHandlerQueue.push(errorHandler); @@ -551,12 +542,12 @@ export class Client { ); }, execute: ( - values?: Value[], + values?: any[], portal?: string, format?: DataFormat | DataFormat[], streams?: Record, ) => { - const result = makeResult(); + const result = makeResult(); result.nameHandler(description.names); const info = { handler: { @@ -587,13 +578,13 @@ export class Client { }); } - query( + query( text: string, - values?: Value[], + values?: any[], types?: DataType[], format?: DataFormat | DataFormat[], streams?: Record): - ResultIterator { + ResultIterator { const query = (typeof text === 'string') ? new Query( @@ -604,7 +595,7 @@ export class Client { streams: streams, }) : text; - return this.execute(query); + return this.execute(query); } private bindAndExecute( @@ -642,7 +633,7 @@ export class Client { this.send(); } - execute(query: Query): ResultIterator { + execute(query: Query): ResultIterator { if (this.closed && !this.connecting) { throw new Error('Connection is closed.'); } @@ -654,7 +645,7 @@ export class Client { const types = options ? options.types : undefined; const streams = options ? options.streams : undefined; const portal = (options ? options.portal : undefined) || ''; - const result = makeResult(); + const result = makeResult(); const descriptionHandler = (description: RowDescription) => { result.nameHandler(description.names); @@ -877,7 +868,7 @@ export class Client { if (row === null) { const count = buffer.readInt16BE(start); - row = new Array(count); + row = new Array(count); } const startRowData = start + 2; diff --git a/src/index.ts b/src/index.ts index 2f0589b..12bc9bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export * from './client'; export * from './types'; export * from './query'; +export * from './result'; export { DatabaseError } from './protocol'; diff --git a/src/protocol.ts b/src/protocol.ts index 3692e3a..d6baae7 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -7,11 +7,8 @@ import { sum } from './utils'; import { arrayDataTypeMapping, isPoint, - ArrayValue, DataFormat, DataType, - Primitive, - Value, ValueTypeReader } from './types'; @@ -278,7 +275,7 @@ export function readRowDescription( export function readRowData( buffer: Buffer, - row: Array, + row: Array, columnSpecification: Uint32Array, encoding: BufferEncoding, types: ReadonlyMap | null, @@ -309,7 +306,7 @@ export function readRowData( const remaining = end - bufferLength; const partial = remaining > 0; - let value: Value = null; + let value: any = null; if (start < end) { const spec = columnSpecification[j]; @@ -449,7 +446,7 @@ export function readRowData( let offset = start; const readArray = (size: number) => { - const array: ArrayValue = + const array: any[] = new Array(size); for (let j = 0; j < size; j++) { @@ -476,7 +473,7 @@ export function readRowData( offset += 8; value = readArray(size); } else { - const arrays: ArrayValue[] = + const arrays: any[][] = new Array(dimCount); const dims = new Uint32Array(dimCount); @@ -624,7 +621,7 @@ export class Reader { } readRowData( - row: Array, + row: any[], columnSpecification: Uint32Array, encoding: BufferEncoding, types: ReadonlyMap | null, @@ -654,7 +651,7 @@ export class Writer { name: string, portal: string, format: DataFormat | DataFormat[] = DataFormat.Binary, - values: Value[] = [], + values: any[] = [], types: DataType[] = []) { // We silently ignore any mismatch here, assuming that the // query will fail and make the error evident. @@ -690,7 +687,7 @@ export class Writer { } }; - const addBinaryValue = (value: Value, dataType: DataType): number => { + const addBinaryValue = (value: any, dataType: DataType): number => { let size = -1; const setSize = reserve(SegmentType.Int32BE); @@ -836,7 +833,7 @@ export class Writer { }; const addBinaryArray = ( - value: Value[], + value: any[], dataType: DataType): number => { const setDimCount = reserve(SegmentType.Int32BE); add(SegmentType.Int32BE, 1); @@ -845,7 +842,7 @@ export class Writer { let bytes = 12; let dimCount = 0; - const go = (level: number, value: Value[]) => { + const go = (level: number, value: any[]) => { const length = value.length; if (length === 0) return; @@ -874,7 +871,7 @@ export class Writer { } const getTextFromValue = ( - value: Value, + value: any, dataType: DataType): null | string | string[] => { if (value === null) return null; @@ -929,7 +926,7 @@ export class Writer { } const getTextFromArray = ( - value: Value[], + value: any[], dataType: DataType): string[] => { const strings: string[] = []; strings.push('{'); diff --git a/src/query.ts b/src/query.ts index 00db2a4..bd30287 100644 --- a/src/query.ts +++ b/src/query.ts @@ -2,7 +2,6 @@ import { Writable } from 'stream'; import { DataFormat, DataType, - Value } from './types'; export interface QueryOptions { @@ -16,7 +15,7 @@ export interface QueryOptions { export class Query { constructor( public readonly text: string, - public readonly values?: Value[], + public readonly values?: any[], public readonly options?: Partial ) { } } diff --git a/src/result.ts b/src/result.ts index 66b60eb..0b6714b 100644 --- a/src/result.ts +++ b/src/result.ts @@ -7,10 +7,10 @@ type Resolver = (resolution: Resolution) => void; type ResultHandler = (resolve: Resolver) => void; type Callback = (item: T) => void; -export class ResultRow { +export class ResultRowImpl { private readonly lookup: {[name: string]: number}; - constructor(public readonly names: string[], public readonly data: T[]) { + constructor(public readonly names: string[], public readonly data: any[]) { const lookup: {[name: string]: number} = {}; let i = 0; for (const name of names) { @@ -20,20 +20,33 @@ export class ResultRow { this.lookup = lookup; } - [Symbol.iterator](): Iterator { + [Symbol.iterator](): Iterator { return this.data[Symbol.iterator](); } - get(name: string): T | undefined { + get(name: string): any { const i = this.lookup[name]; return this.data[i]; } + + map(): T { + const data = this.data; + const result: Record = {}; + this.names.forEach((key, i) => result[key] = data[i]); + return result as T; + } } -export class Result { +export type ResultRecord = Record + +export type ResultRow = Omit, 'get'> & { + get(name: K): T[K]; +} + +export class Result { constructor( public names: string[], - public rows: T[][], + public rows: any[][], public status: null | string ) { } @@ -47,7 +60,7 @@ export class Result { const names = this.names; const values = rows[i]; i++; - return new ResultRow(names, values); + return new ResultRowImpl(names, values) as unknown as ResultRow; }; return { @@ -57,18 +70,26 @@ export class Result { } } } + + map(): T[] { + return this.rows.map(data => { + const result: Record = {}; + this.names.forEach((key, i) => result[key] = data[i]); + return result; + }) as T[]; + } } -export class ResultIterator extends Promise> { +export class ResultIterator extends Promise> { private subscribers: ( (done: boolean, error?: (string | DatabaseError | Error) ) => void)[] = []; private done = false; - public rows: T[][] | null = null; + public rows: any[][] | null = null; public names: string[] | null = null; - constructor(private container: T[][], executor: ResultHandler) { + constructor(private container: any[][], executor: ResultHandler) { super((resolve, reject) => { executor((resolution) => { if (resolution instanceof Error) { @@ -95,6 +116,25 @@ export class ResultIterator extends Promise> { throw new Error('Query returned an empty result'); } + map(): AsyncIterable { + const iterator: AsyncIterator> = this[Symbol.asyncIterator](); + return { + [Symbol.asyncIterator]() { + return { + async next() { + const { done, value } = await iterator.next(); + if (done) return { done, value: null }; + return { done, value: value.map() }; + }, + async return() { + if (iterator?.return) await iterator?.return(); + return { done: true, value: null }; + } + }; + } + } + } + notify(done: boolean, status?: (string | DatabaseError | Error)) { if (done) this.done = true; for (const subscriber of this.subscribers) subscriber(done, status); @@ -115,7 +155,7 @@ export class ResultIterator extends Promise> { throw new Error("Column name mapping missing."); } - return new ResultRow(names, values); + return new ResultRowImpl(names, values) as unknown as ResultRow; }; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ @@ -164,13 +204,13 @@ export type NameHandler = Callback; ResultIterator.prototype.constructor = Promise export function makeResult() { - let dataHandler: DataHandler | null = null; + let dataHandler: DataHandler | null = null; const nameHandler = (names: string[]) => { p.names = names; } - const rows: T[][] = []; + const rows: any[][] = []; const p = new ResultIterator(rows, (resolve) => { - dataHandler = ((row: T[] | Resolution) => { + dataHandler = ((row: any[] | Resolution) => { if (row === null || typeof row === 'string') { p.rows = rows; resolve(row); diff --git a/src/types.ts b/src/types.ts index 053c1b1..9c442f5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -129,7 +129,7 @@ export type ValueTypeReader = ( end: number, format: DataFormat, encoding?: BufferEncoding -) => Value; +) => any; export interface Point { x: number, @@ -140,26 +140,3 @@ export interface Point { export function isPoint(item: any): item is Point { return 'x' in item && 'y' in item; } - -export type Builtin = - Buffer | - Date | - bigint | - boolean | - number | - null | - string; - -export type AnyJson = boolean | number | string | null | JsonArray | JsonMap; - -export interface JsonMap { [key: string]: AnyJson; } - -export type JsonArray = Array; - -export type ArrayValue = Array | T>; - -export type Primitive = Builtin | Point | JsonMap; - -export type Value = Primitive | ArrayValue; - -export type Row = ArrayValue; diff --git a/test/client.test.ts b/test/client.test.ts index 7245708..029d557 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -1,8 +1,14 @@ import { createServer, AddressInfo, Socket } from 'net'; import { testWithClient } from './helper'; -import { Query } from '../src/query'; -import { Client, PreparedStatement, Result, ResultIterator } from '../src/client'; -import { DataFormat, DataType, Value } from '../src/types'; +import { + Client, + DataFormat, + DataType, + PreparedStatement, + Query, + Result, + ResultIterator, +} from '../src/index'; // Adjust for benchmarking mode. const benchmarkEnabled = process.env.NODE_ENV === 'benchmark'; @@ -325,7 +331,7 @@ describe('Query', () => { query: ResultIterator; expectation: { names: string[]; - rows: Value[]; + rows: any[]; status: string; } | RegExp; } diff --git a/test/result.test.ts b/test/result.test.ts index 0d532ab..67aa85e 100644 --- a/test/result.test.ts +++ b/test/result.test.ts @@ -1,5 +1,5 @@ import { testWithClient } from './helper'; -import { Client, ResultIterator, ResultRow } from '../src/client'; +import { Client, ResultIterator, ResultRow } from '../src/index'; type ResultFunction = (result: ResultIterator) => @@ -27,7 +27,7 @@ async function testIteratorResult(client: Client, f: ResultFunction) { // Rows are themselves iterable. for (const [index, row] of rows.entries()) { - expect([...row]).toEqual([index]); + expect([...row]).toEqual([index]); } // We could iterate multiple times over the same result. @@ -54,25 +54,35 @@ async function testIteratorResult(client: Client, f: ResultFunction) { } describe('Result', () => { - testWithClient('Names', async (client) => { - expect.assertions(2); + testWithClient('Default type', async (client) => { + expect.assertions(7); const result = await client.query( 'select $1::text as message', ['Hello world!'] ); + expect(result.status).toEqual('SELECT 1'); expect(result.names.length).toEqual(1); expect(result.names[0]).toEqual('message'); + expect(result.map()).toEqual([{message: 'Hello world!'}]); + const rows = [...result]; + const row = rows[0]; + expect(row.get('message')).toEqual('Hello world!'); + expect(row.get('bad')).toEqual(undefined); + const mapped = row.map(); + expect(mapped.message).toEqual('Hello world!'); }); - testWithClient('Get', async (client) => { + testWithClient('Typed', async (client) => { expect.assertions(3); - const result = await client.query( + const result = await client.query<{message: string}>( 'select $1::text as message', ['Hello world!'] ); expect(result.status).toEqual('SELECT 1'); const rows = [...result]; const row = rows[0]; - expect(row.get('message')).toEqual('Hello world!'); - expect(row.get('bad')).toEqual(undefined); + const message: string = row.get('message'); + expect(message).toEqual('Hello world!'); + const mapped: {message: string} = row.map(); + expect(mapped.message).toEqual('Hello world!'); }); testWithClient('Parse array containing null', async (client) => { @@ -107,6 +117,19 @@ describe('Result', () => { expect(row.get('message')).toEqual('Hello world!'); }); + testWithClient('Map', async (client) => { + expect.assertions(1); + const mapped = client.query( + 'select $1::text as message', ['Hello world!'] + ).map(); + for await (const item of mapped) { + expect(item).toEqual({message: 'Hello world!'}); + } + for await (const item of mapped) { + expect(item).toEqual({message: 'Hello world!'}); + } + }); + testWithClient('One (empty query)', async (client) => { expect.assertions(1); await expect(client.query('select true where false').one()) diff --git a/test/types.test.ts b/test/types.test.ts index 5ac4ab6..b083acb 100644 --- a/test/types.test.ts +++ b/test/types.test.ts @@ -2,9 +2,7 @@ import { testWithClient } from './helper'; import { DataType, - JsonMap, Point, - Value, DataFormat } from '../src'; @@ -25,7 +23,7 @@ function getComparisonQueryFor(dataType: DataType, expression: string) { } } -function testType( +function testType( dataType: DataType, expression: string, expected: T, @@ -263,19 +261,19 @@ describe('Types', () => { DataType.ArrayTimestamptz, '\'{1999-12-31 23:59:59Z}\'::timestamptz[]', [utc_date(1999, 11, 31, 23, 59, 59)]); - testType( + testType>( DataType.Json, '\'{"foo": "bar"}\'::json', { 'foo': 'bar' }); - testType( + testType>( DataType.Jsonb, '\'{"foo": "bar"}\'::jsonb', { 'foo': 'bar' }); - testType( + testType[]>( DataType.ArrayJsonb, 'ARRAY[\'{"foo": "bar"}\'::jsonb, \'{"bar": "baz"}\'::jsonb]', [{ 'foo': 'bar' }, { 'bar': 'baz' }]); - testType( + testType[]>( DataType.ArrayJson, 'ARRAY[\'{"foo": "bar"}\'::json]', [{ 'foo': 'bar' }]);