From d525acd21cbeb1114eaac9830d8d99c2cf688755 Mon Sep 17 00:00:00 2001 From: Malthe Borch Date: Mon, 11 Dec 2023 21:41:49 +0100 Subject: [PATCH 1/2] Use jest globals --- package-lock.json | 22 +--------------------- package.json | 2 +- test/client.test.ts | 1 + test/helper.ts | 1 + test/result.test.ts | 1 + test/types.test.ts | 1 + 6 files changed, 6 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index 40ea37e..18d62df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "ts-typed-events": "^3.0.0" }, "devDependencies": { - "@types/jest": "^29.5.8", + "@jest/globals": "^29.7.0", "@types/node": "^18.15.3", "@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/parser": "^6.10.0", @@ -1679,16 +1679,6 @@ "@types/istanbul-lib-report": "*" } }, - "node_modules/@types/jest": { - "version": "29.5.8", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.8.tgz", - "integrity": "sha512-fXEFTxMV2Co8ZF5aYFJv+YeA08RTYJfhtN5c9JSv/mFEMe+xxjufCb+PHL+bJcMs/ebPUsBu+UNTEz+ydXrR6g==", - "dev": true, - "dependencies": { - "expect": "^29.0.0", - "pretty-format": "^29.0.0" - } - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -10353,16 +10343,6 @@ "@types/istanbul-lib-report": "*" } }, - "@types/jest": { - "version": "29.5.8", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.8.tgz", - "integrity": "sha512-fXEFTxMV2Co8ZF5aYFJv+YeA08RTYJfhtN5c9JSv/mFEMe+xxjufCb+PHL+bJcMs/ebPUsBu+UNTEz+ydXrR6g==", - "dev": true, - "requires": { - "expect": "^29.0.0", - "pretty-format": "^29.0.0" - } - }, "@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", diff --git a/package.json b/package.json index 693841c..178e727 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "singleQuote": true }, "devDependencies": { - "@types/jest": "^29.5.8", + "@jest/globals": "^29.7.0", "@types/node": "^18.15.3", "@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/parser": "^6.10.0", diff --git a/test/client.test.ts b/test/client.test.ts index 029d557..385ad1b 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -1,4 +1,5 @@ import { createServer, AddressInfo, Socket } from 'net'; +import { describe, expect, jest, test } from '@jest/globals'; import { testWithClient } from './helper'; import { Client, diff --git a/test/helper.ts b/test/helper.ts index dd1c65d..ef63a5d 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -1,3 +1,4 @@ +import { test } from '@jest/globals'; import { Client } from '../src/client'; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ diff --git a/test/result.test.ts b/test/result.test.ts index d9d56d2..6c31a34 100644 --- a/test/result.test.ts +++ b/test/result.test.ts @@ -1,3 +1,4 @@ +import { describe, expect } from '@jest/globals'; import { testWithClient } from './helper'; import { Client, ResultIterator, ResultRow } from '../src/index'; diff --git a/test/types.test.ts b/test/types.test.ts index b083acb..258587c 100644 --- a/test/types.test.ts +++ b/test/types.test.ts @@ -1,3 +1,4 @@ +import { describe, expect } from '@jest/globals'; import { testWithClient } from './helper'; import { From a0cc3374de4b9c7c3758b81fd9854d4660ac5b15 Mon Sep 17 00:00:00 2001 From: Malthe Borch Date: Wed, 13 Dec 2023 12:48:09 +0100 Subject: [PATCH 2/2] Query result iterator now returns objects (#83) --- CHANGES.md | 10 +++ README.md | 48 +++++++----- src/client.ts | 2 +- src/result.ts | 183 ++++++++++++++++++++++---------------------- test/result.test.ts | 101 +++++++++--------------- 5 files changed, 164 insertions(+), 180 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 561977c..7ccc99a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,13 @@ +In next release ... + +- The iterator methods now return reified representations of the query result + (i.e. objects), carrying the generic type parameter specified for the query + (#83). + +- The result rows now extend the array type, providing `get` and `reify` methods. + This separates the query results interface into an iterator interface (providing + objects) and a result interface (providing rows and column names). + 1.5.0 (2023-12-06) ------------------ diff --git a/README.md b/README.md index 4ea8511..e2bf58f 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,9 @@ $ npm install ts-postgres@latest * Multiple queries can be sent at once (pipeline) * Extensible value model * Hybrid query result object - * Iterable (synchronous or asynchronous; one row at a time) - * Promise-based - * Streaming + * Iterable (synchronous or asynchronous; one object at a time) + * Rows and column names + * Streaming data directly into a socket See the [documentation](https://malthe.github.io/ts-postgres/) for a complete reference. @@ -50,9 +50,9 @@ async function main() { ['world'] ); - for await (const row of result) { + for await (const obj of result) { // 'Hello world!' - console.log(row.get('message')); + console.log(obj.message); } } finally { await client.end(); @@ -118,10 +118,9 @@ const query = new 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. +If the object type is omitted, it defaults to `Record`, but +providing a type ensures that the object values are typed, both when +accessed via the iterator or record interface (see below). ### Passing query parameters @@ -139,7 +138,7 @@ different ways: ```typescript import { DataType } from 'ts-postgres'; const result = await client.query( - "select $1 || ' bottles of beer'", [99], [DataType.Int4] + "SELECT $1 || ' bottles of beer'", [99], [DataType.Int4] ); ``` @@ -150,30 +149,39 @@ should be used. ### Iterator interface -Whether we're operating on a stream or an already waited for result set, the iterator interface provides the most high-level row interface. This also applies when using the _spread_ operator: +The query result can be iterated over, either asynchronously, or after being awaited. The returned objects are reified representations of the result rows, provided as _objects_ of the generic type parameter specified for the query (optional, it defaults to `Record`). + +To extract all objects from the query result, you can use the _spread_ operator: ```typescript -const rows = [...result]; +const result = await client.query("SELECT generate_series(0, 9) AS i"); +const objects = [...result]; ``` -Each row provides direct access to values through its ``data`` attribute, but we can also get a value by name using the ``get(name)`` method. +The asynchronous await syntax around for-loops is another option: ```typescript -for (const row of rows) { - console.log('The number is: ' + row.get('i')); // 1, 2, 3, ... +const result = client.query(...); +for await (const obj of result) { + console.log('The number is: ' + obj.i); // 1, 2, 3, ... } ``` -Note that values are polymorphic and need to be explicitly cast to a concrete type such as ``number`` or ``string``. ### Result interface -This interface is available on the already waited for result object. It makes data available in the ``rows`` attribute as an array of arrays (of values). +The awaited result object provides an interface based on rows and column names. + ```typescript for (const row of result.rows) { + // Using the array indices: console.log('The number is: ' + row[0]); // 1, 2, 3, ... + + // Using the column name: + console.log('The number is: ' + row.get('i')); // 1, 2, 3, ... } ``` -This is the most efficient way to work with result data. Column names are available as the ``names`` attribute of a result. + +Column names are available via the ``names`` property. ### Streaming @@ -208,8 +216,8 @@ You can prepare a query and subsequently execute it multiple times. This is also const statement = await client.prepare( `SELECT 'Hello ' || $1 || '!' AS message` ); -for await (const row of statement.execute(['world'])) { - console.log(row.get('message')); // 'Hello world!' +for await (const object of statement.execute(['world'])) { + console.log(object.message); // 'Hello world!' } ``` When the prepared statement is no longer needed, it should be closed to release the resource. diff --git a/src/client.ts b/src/client.ts index e94e4e2..423f8b9 100644 --- a/src/client.ts +++ b/src/client.ts @@ -118,7 +118,7 @@ type Event = ( type CloseHandler = () => void; interface RowDataHandler { - callback: DataHandler, + callback: DataHandler, streams: Record, } diff --git a/src/result.ts b/src/result.ts index d697946..a1e8024 100644 --- a/src/result.ts +++ b/src/result.ts @@ -2,78 +2,82 @@ import { DatabaseError } from './protocol'; -type Resolution = null | string | Error | DatabaseError; -type Resolver = (resolution: Resolution) => void; -type ResultHandler = (resolve: Resolver) => void; +type Resolution = null | string; type Callback = (item: T) => void; +type ResultHandler = (resolve: Callback, reject: Callback) => void; -export class ResultRowImpl { - private readonly lookup: {[name: string]: number}; +/** The default result type, used if no generic type parameter is specified. */ +export type ResultRecord = Record - constructor(public readonly names: string[], public readonly data: any[]) { - const lookup: {[name: string]: number} = {}; - let i = 0; - for (const name of names) { - lookup[name] = i; - i++; - } - this.lookup = lookup; - } - [Symbol.iterator](): Iterator { - return this.data[Symbol.iterator](); +function makeRecord(names: string[], data: ReadonlyArray): T { + const result: Record = {}; + names.forEach((key, j) => result[key] = data[j]); + return result as T; +} + +class ResultRowImpl extends Array { + #names?: string[]; + #lookup?: Map; + + set(names: string[], lookup: Map, values: any[]) { + this.#names = names; + this.#lookup = lookup; + this.push(...values); } - get(name: string): any { - const i = this.lookup[name]; - return this.data[i]; + /** + * Return value for the provided column name. + */ + get(name: keyof T): T[K] { + const i = this.#lookup?.get(name); + if (i === undefined) throw new Error(`Invalid column name: ${String(name)}`); + return this[i]; } - reify(): T { - const data = this.data; - const result: Record = {}; - this.names.forEach((key, i) => result[key] = data[i]); - return result as T; + /** + * Return an object mapping column names to values. + */ + reify() { + if (this.#names === undefined) throw new Error('Column names not available'); + return makeRecord(this.#names, this); } } -/** The default result type, used if no generic type parameter is specified. */ -export type ResultRecord = Record - /** - * A result row provides access to data for a single row. + * A result row provides access to data for a single row, extending an array. + * @interface * * The generic type parameter is carried over from the query method. - * @interface * * To retrieve a column value by name use the {@link get} method; or use {@link reify} to convert * the row into an object. * - * The {@link data} attribute provides raw access to the row data, but it's also possible to use the - * spread operator to destructure into a tuple. */ -export type ResultRow = Omit, 'get'> & { - get(name: K): T[K]; -} +export type ResultRow = ReadonlyArray & Pick, 'get' | 'reify'>; +/** + * The awaited query result. + * + * Iterating over the result yields objects of the generic type parameter. + */ export class Result { constructor( public names: string[], - public rows: any[][], + public rows: ResultRow[], public status: null | string ) { } - [Symbol.iterator](): Iterator> { + [Symbol.iterator](): Iterator { let i = 0; const rows = this.rows; const length = rows.length; + const names = this.names; const shift = () => { - const names = this.names; - const values = rows[i]; - i++; - return new ResultRowImpl(names, values) as unknown as ResultRow; + const data = rows[i++]; + return makeRecord(names, data); }; return { @@ -83,45 +87,53 @@ export class Result { } } } - - reify(): T[] { - return this.rows.map(data => { - const result: Record = {}; - this.names.forEach((key, i) => result[key] = data[i]); - return result; - }) as T[]; - } } +/** + * The query result iterator. + * + * Iterating asynchronously yields objects of the generic type parameter. + */ export class ResultIterator extends Promise> { private subscribers: ( (done: boolean, error?: (string | DatabaseError | Error) ) => void)[] = []; private done = false; - public rows: any[][] | null = null; - public names: string[] | null = null; - - constructor(private container: any[][], executor: ResultHandler) { + constructor(private names: string[], private data: any[][], executor: ResultHandler) { super((resolve, reject) => { - executor((resolution) => { - if (resolution instanceof Error) { - reject(resolution); - } else { - const names = this.names || []; - const rows = this.rows || []; - resolve(new Result(names, rows, resolution)); + executor((status) => { + const names = this.names || []; + const data = this.data || []; + + const lookup: Map = new Map(); + let i = 0; + for (const name of names) { + lookup.set(name as keyof T, i); + i++; } - }); + + resolve(new Result(names, data.map(values => { + const row = new ResultRowImpl(); + row.set(names, lookup, values); + return row; + }), status)); + }, reject); }); } + /** + * Return the first item (if any) from the query results. + */ async first() { for await (const row of this) { return row; } } + /** + * Return the first item from the query results, or throw an error. + */ async one() { for await (const row of this) { return row; @@ -129,46 +141,27 @@ export class ResultIterator extends Promise> { throw new Error('Query returned an empty result'); } - reify(): 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.reify() }; - }, - 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); this.subscribers.length = 0; } - [Symbol.asyncIterator](): AsyncIterator> { + [Symbol.asyncIterator](): AsyncIterator { let i = 0; - const container = this.container; + //const container = this.container; const shift = () => { const names = this.names; - const values = container[i]; + const values = this.data[i]; i++; if (names === null) { throw new Error("Column name mapping missing."); } - return new ResultRowImpl(names, values) as unknown as ResultRow; + return makeRecord(names, values); }; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ @@ -184,7 +177,7 @@ export class ResultIterator extends Promise> { throw error; } - if (container.length <= i) { + if (this.data.length <= i) { if (this.done) { return { done: true, value: undefined! }; } @@ -210,34 +203,38 @@ export class ResultIterator extends Promise> { } } -export type DataHandler = Callback; +export type DataHandler = Callback; export type NameHandler = Callback; ResultIterator.prototype.constructor = Promise export function makeResult() { - let dataHandler: DataHandler | null = null; - const nameHandler = (names: string[]) => { - p.names = names; - } + let dataHandler: DataHandler | null = null; + + const names: string[] = []; const rows: any[][] = []; - const p = new ResultIterator(rows, (resolve) => { - dataHandler = ((row: any[] | Resolution) => { + + const p = new ResultIterator(names, rows, (resolve, reject) => { + dataHandler = ((row: any[] | Resolution | Error) => { if (row === null || typeof row === 'string') { - p.rows = rows; resolve(row); p.notify(true); } else if (Array.isArray(row)) { rows.push(row); p.notify(false); } else { - resolve(row); + reject(row); p.notify(true, row); } }); }); + const nameHandler = (ns: string[]) => { + names.length = 0; + names.push(...ns); + } + return { iterator: p, dataHandler: dataHandler!, diff --git a/test/result.test.ts b/test/result.test.ts index 6c31a34..cb73e4d 100644 --- a/test/result.test.ts +++ b/test/result.test.ts @@ -2,56 +2,36 @@ import { describe, expect } from '@jest/globals'; import { testWithClient } from './helper'; import { Client, ResultIterator, ResultRow } from '../src/index'; -type ResultFunction = - (result: ResultIterator) => - Promise; +type ResultFunction = (result: ResultIterator) => Promise; -async function testIteratorResult(client: Client, f: ResultFunction) { - const query = () => client.query( +async function testIteratorResult(client: Client, f: ResultFunction) { + const query = () => client.query( 'select generate_series($1::int, $2::int) as i', [0, 9] ); - const rows = await f(query()); + const iterator = query(); + const items = await f(iterator); + //const result = await iterator; - expect(rows.length).toEqual(10); - const expectation = [...Array(10).keys()]; - const keys = rows.map((row) => [...row.names]); - const values = rows.map((row) => [...row.data]); + expect(items.length).toEqual(10); + expect(items).toEqual([...Array(10).keys()].map(i => ({i: i}))); - // The get method returns a column using name lookup. - expect(values).toEqual(rows.map((row) => [row.get('i')])); + const result = await iterator; + expect(result.names).toEqual(['i']); - // Keys are column names. - expect(keys).toEqual(expectation.map(() => ['i'])); - - // Values are row values. - expect(values).toEqual(expectation.map((i) => [i])); - - // Rows are themselves iterable. - for (const [index, row] of rows.entries()) { - expect([...row]).toEqual([index]); - } - - // We could iterate multiple times over the same result. let count = 0; - const result = query(); /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ - for await (const _ of result) { + for await (const _ of iterator) { count += 1; } expect(count).toEqual(10); + // We could iterate multiple times over the same result. /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ - for await (const _ of result) { + for await (const _ of iterator) { count += 1; } expect(count).toEqual(20); - - // The result is also available in the public rows attribute. - expect(result.rows).toEqual( - expectation.map((i) => { return [i] }) - ); - } describe('Result', () => { @@ -63,27 +43,29 @@ describe('Result', () => { expect(result.status).toEqual('SELECT 1'); expect(result.names.length).toEqual(1); expect(result.names[0]).toEqual('message'); - expect(result.reify()).toEqual([{message: 'Hello world!'}]); + expect([...result]).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.reify(); + expect(row.message).toEqual('Hello world!'); + expect(row.bad).toEqual(undefined); + const mapped = result.rows[0].reify(); expect(mapped.message).toEqual('Hello world!'); }); testWithClient('Typed', async (client) => { expect.assertions(3); - const result = await client.query<{message: string}>( + type T = { + message: string + }; + const result = await client.query( 'select $1::text as message', ['Hello world!'] ); expect(result.status).toEqual('SELECT 1'); const rows = [...result]; - const row = rows[0]; - const message: string = row.get('message'); - expect(message).toEqual('Hello world!'); - const mapped: {message: string} = row.reify(); - expect(mapped.message).toEqual('Hello world!'); + const row: ResultRow = result.rows[0]; + const obj: T = rows[0]; + expect(row.get('message')).toEqual('Hello world!'); + expect(obj.message).toEqual('Hello world!'); }); testWithClient('Parse array containing null', async (client) => { @@ -91,7 +73,7 @@ describe('Result', () => { const row = await client.query( 'select ARRAY[null::text] as a' ).one(); - expect(row.get('a')).toEqual([null]); + expect(row.a).toEqual([null]); }); testWithClient('Format array containing null value', async (client) => { @@ -99,7 +81,7 @@ describe('Result', () => { const row = await client.query( 'select $1::text[] as a', [[null]] ).one(); - expect(row.get('a')).toEqual([null]); + expect(row.a).toEqual([null]); }); testWithClient('Format null-array', async (client) => { @@ -107,7 +89,7 @@ describe('Result', () => { const row = await client.query( 'select $1::text[] as a', [null] ).one(); - expect(row.get('a')).toEqual(null); + expect(row.a).toEqual(null); }); testWithClient('One', async (client) => { @@ -115,20 +97,7 @@ describe('Result', () => { const row = await client.query( 'select $1::text as message', ['Hello world!'] ).one(); - 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!'] - ).reify(); - for await (const item of mapped) { - expect(item).toEqual({message: 'Hello world!'}); - } - for await (const item of mapped) { - expect(item).toEqual({message: 'Hello world!'}); - } + expect(row.message).toEqual('Hello world!'); }); testWithClient('One (empty query)', async (client) => { @@ -157,9 +126,9 @@ describe('Result', () => { 'select $1::text as a, $2::text[] as b, $3::jsonb[] as c', [null, null, null] ).one(); - expect(row.get('a')).toBeNull() - expect(row.get('b')).toBeNull(); - expect(row.get('c')).toBeNull(); + expect(row.a).toBeNull() + expect(row.b).toBeNull(); + expect(row.c).toBeNull(); }); testWithClient('Synchronous iteration', async (client) => { @@ -167,7 +136,7 @@ describe('Result', () => { client, async (p) => { return p.then((result) => { - const rows: ResultRow[] = []; + const rows = []; for (const row of result) { rows.push(row); } @@ -180,7 +149,7 @@ describe('Result', () => { await testIteratorResult( client, async (result) => { - const rows: ResultRow[] = []; + const rows = []; for await (const row of result) { rows.push(row); } @@ -191,6 +160,6 @@ describe('Result', () => { testWithClient('Null typed array', async (client) => { expect.assertions(1); const row = await client.query('select null::text[] as value').one(); - expect(row.get('value')).toEqual(null); + expect(row.value).toBeNull(); }); });