Skip to content

Commit 6105c26

Browse files
authored
Add generics and type mapping to Result, Session.run and Transaction.run (#1010)
The new generic typing allow mapping the records of these running queries to type-mapped Record. Given the following Person and Friendship definitions. ```typescript interface Person { age: Integer name: string } interface Friendship { since: Integer } interface PersonAndFriendship { p: Node<number, Person> f: Relationship<number, Friendship> } ``` The new type-mapping allow safe access the properties of query which return `Record<Person>` ```typescript const { records } = await session.run<Person>('MATCH (p:Person) RETURN p.name as name, p.age as age') for (const person of records) { const age: Integer = person.get('age') const name: string = person.get('name') // @ts-expect-error const nameInt: Integer = person.get('name') } ``` The type-mapping can be also extended for `Node` and `Relationship`. ```typescript const { records } = await session.run<PersonAndFriendship>('MATCH (p:Person)-[f:Friendship]-() RETURN p, f') for (const r of records) { const person = r.get('p') const age: Integer = person.properties.age const name: string = person.properties.name // @ts-expect-error const nameInt: Integer = person.properties.name // @ts-expect-error const err: string = person.properties.err const friendship = r.get('f') const since: Integer = friendship.properties.since // @ts-expect-error const sinceString: string = friendship.properties.since // @ts-expect-error const err2: string = friendship.properties.err } ``` The usage in combination with `executeRead` and `executeWrite` is also possible. ```typescript const { records } = await session.executeRead(tx => tx.run<Person>('MATCH (p:Person) RETURN p.name as name, p.age as age')) for (const person of records) { const age: Integer = person.get('age') const name: string = person.get('name') // @ts-expect-error const nameInt: Integer = person.get('name') } ``` With some async iterator usage: ```typescript const personList = await session.executeRead(async (tx) => { const result = tx.run<Person>('MATCH (p:Person) RETURN p.name as name, p.age as age') const objects: Person[] = [] // iterate while streaming the objects for await (const record of result) { objects.push(record.toObject()) } return objects }) for (const person of personList) { const age: Integer = person.age const name: string = person.name // @ts-expect-error const nameInt: Integer = person.name // @ts-expect-error const nome: string = person.nome } ``` ⚠️ This type definitions are not asserted in runtime. Thus mismatched type records coming from the database will not trigger type errors.
1 parent b1ff30b commit 6105c26

14 files changed

+307
-40
lines changed

packages/core/src/record.ts

+1
Original file line numberDiff line numberDiff line change
@@ -243,3 +243,4 @@ class Record<
243243
}
244244

245245
export default Record
246+
export type { Dict }

packages/core/src/result.ts

+14-14
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
/* eslint-disable @typescript-eslint/promise-function-async */
2121

2222
import ResultSummary from './result-summary'
23-
import Record from './record'
23+
import Record, { Dict } from './record'
2424
import { Query, PeekableAsyncIterator } from './types'
2525
import { observer, util, connectionHolder } from './internal'
2626
import { newError, PROTOCOL_ERROR } from './error'
@@ -56,16 +56,16 @@ const DEFAULT_ON_KEYS = (keys: string[]): void => {}
5656
* The query result is the combination of the {@link ResultSummary} and
5757
* the array {@link Record[]} produced by the query
5858
*/
59-
interface QueryResult {
60-
records: Record[]
59+
interface QueryResult<RecordShape extends Dict = Dict> {
60+
records: Array<Record<RecordShape>>
6161
summary: ResultSummary
6262
}
6363

6464
/**
6565
* Interface to observe updates on the Result which is being produced.
6666
*
6767
*/
68-
interface ResultObserver {
68+
interface ResultObserver<RecordShape extends Dict =Dict> {
6969
/**
7070
* Receive the keys present on the record whenever this information is available
7171
*
@@ -77,7 +77,7 @@ interface ResultObserver {
7777
* Receive the each record present on the {@link @Result}
7878
* @param {Record} record The {@link Record} produced
7979
*/
80-
onNext?: (record: Record) => void
80+
onNext?: (record: Record<RecordShape>) => void
8181

8282
/**
8383
* Called when the result is fully received
@@ -111,7 +111,7 @@ interface QueuedResultObserver extends ResultObserver {
111111
* Alternatively can be consumed lazily using {@link Result#subscribe} function.
112112
* @access public
113113
*/
114-
class Result implements Promise<QueryResult> {
114+
class Result<RecordShape extends Dict = Dict> implements Promise<QueryResult<RecordShape>> {
115115
private readonly _stack: string | null
116116
private readonly _streamObserverPromise: Promise<observer.ResultStreamObserver>
117117
private _p: Promise<QueryResult> | null
@@ -212,7 +212,7 @@ class Result implements Promise<QueryResult> {
212212
* @private
213213
* @return {Promise} new Promise.
214214
*/
215-
private _getOrCreatePromise (): Promise<QueryResult> {
215+
private _getOrCreatePromise (): Promise<QueryResult<RecordShape>> {
216216
if (this._p == null) {
217217
this._p = new Promise((resolve, reject) => {
218218
const records: Record[] = []
@@ -240,9 +240,9 @@ class Result implements Promise<QueryResult> {
240240
* *Should not be combined with {@link Result#subscribe} or ${@link Result#then} functions.*
241241
*
242242
* @public
243-
* @returns {PeekableAsyncIterator<Record, ResultSummary>} The async iterator for the Results
243+
* @returns {PeekableAsyncIterator<Record<RecordShape>, ResultSummary>} The async iterator for the Results
244244
*/
245-
[Symbol.asyncIterator] (): PeekableAsyncIterator<Record, ResultSummary> {
245+
[Symbol.asyncIterator] (): PeekableAsyncIterator<Record<RecordShape>, ResultSummary> {
246246
if (!this.isOpen()) {
247247
const error = newError('Result is already consumed')
248248
return {
@@ -345,9 +345,9 @@ class Result implements Promise<QueryResult> {
345345
* @param {function(error: {message:string, code:string})} onRejected - function to be called upon errors.
346346
* @return {Promise} promise.
347347
*/
348-
then<TResult1 = QueryResult, TResult2 = never>(
348+
then<TResult1 = QueryResult<RecordShape>, TResult2 = never>(
349349
onFulfilled?:
350-
| ((value: QueryResult) => TResult1 | PromiseLike<TResult1>)
350+
| ((value: QueryResult<RecordShape>) => TResult1 | PromiseLike<TResult1>)
351351
| null,
352352
onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null
353353
): Promise<TResult1 | TResult2> {
@@ -364,7 +364,7 @@ class Result implements Promise<QueryResult> {
364364
*/
365365
catch <TResult = never>(
366366
onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null
367-
): Promise<QueryResult | TResult> {
367+
): Promise<QueryResult<RecordShape> | TResult> {
368368
return this._getOrCreatePromise().catch(onRejected)
369369
}
370370

@@ -376,7 +376,7 @@ class Result implements Promise<QueryResult> {
376376
* @return {Promise} promise.
377377
*/
378378
[Symbol.toStringTag]: string
379-
finally (onfinally?: (() => void) | null): Promise<QueryResult> {
379+
finally (onfinally?: (() => void) | null): Promise<QueryResult<RecordShape>> {
380380
return this._getOrCreatePromise().finally(onfinally)
381381
}
382382

@@ -391,7 +391,7 @@ class Result implements Promise<QueryResult> {
391391
* @param {function(error: {message:string, code:string})} observer.onError - handle errors.
392392
* @return {void}
393393
*/
394-
subscribe (observer: ResultObserver): void {
394+
subscribe (observer: ResultObserver<RecordShape>): void {
395395
this._subscribe(observer)
396396
.catch(() => {})
397397
}

packages/core/src/session.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { NumberOrInteger } from './graph-types'
3636
import TransactionPromise from './transaction-promise'
3737
import ManagedTransaction from './transaction-managed'
3838
import BookmarkManager from './bookmark-manager'
39+
import { Dict } from './record'
3940

4041
type ConnectionConsumer = (connection: Connection | null) => any | undefined | Promise<any> | Promise<undefined>
4142
type TransactionWork<T> = (tx: Transaction) => Promise<T> | T
@@ -154,11 +155,11 @@ class Session {
154155
* @param {TransactionConfig} [transactionConfig] - Configuration for the new auto-commit transaction.
155156
* @return {Result} New Result.
156157
*/
157-
run (
158+
run<RecordShape extends Dict = Dict> (
158159
query: Query,
159160
parameters?: any,
160161
transactionConfig?: TransactionConfig
161-
): Result {
162+
): Result<RecordShape> {
162163
const { validatedQuery, params } = validateQueryAndParameters(
163164
query,
164165
parameters

packages/core/src/transaction-managed.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import Result from './result'
2121
import Transaction from './transaction'
2222
import { Query } from './types'
23+
import { Dict } from './record'
2324

2425
type Run = (query: Query, parameters?: any) => Result
2526

@@ -60,7 +61,7 @@ class ManagedTransaction {
6061
* @param {Object} parameters - Map with parameters to use in query
6162
* @return {Result} New Result
6263
*/
63-
run (query: Query, parameters?: any): Result {
64+
run<RecordShape extends Dict =Dict> (query: Query, parameters?: any): Result<RecordShape> {
6465
return this._run(query, parameters)
6566
}
6667
}

packages/core/src/transaction.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
import { newError } from './error'
3838
import Result from './result'
3939
import { Query } from './types'
40+
import { Dict } from './record'
4041

4142
/**
4243
* Represents a transaction in the Neo4j database.
@@ -109,7 +110,7 @@ class Transaction {
109110
this._lowRecordWatermak = lowRecordWatermark
110111
this._highRecordWatermark = highRecordWatermark
111112
this._bookmarks = Bookmarks.empty()
112-
this._acceptActive = () => { } // satisfy DenoJS
113+
this._acceptActive = () => { } // satisfy DenoJS
113114
this._activePromise = new Promise((resolve, reject) => {
114115
this._acceptActive = resolve
115116
})
@@ -174,7 +175,7 @@ class Transaction {
174175
* @param {Object} parameters - Map with parameters to use in query
175176
* @return {Result} New Result
176177
*/
177-
run (query: Query, parameters?: any): Result {
178+
run<RecordShape extends Dict = Dict> (query: Query, parameters?: any): Result<RecordShape> {
178179
const { validatedQuery, params } = validateQueryAndParameters(
179180
query,
180181
parameters

packages/core/test/result.test.ts

+100
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ import {
2828
import Result from '../src/result'
2929
import FakeConnection from './utils/connection.fake'
3030

31+
interface AB {
32+
a: number
33+
b: number
34+
}
35+
3136
describe('Result', () => {
3237
const expectedError = newError('some error')
3338

@@ -305,6 +310,34 @@ describe('Result', () => {
305310
])
306311
})
307312

313+
it('should redirect onNext to the client observer with type safety', async () => {
314+
const result = new Result<AB>(Promise.resolve(streamObserverMock), 'query')
315+
316+
const keys = ['a', 'b']
317+
const rawRecord1 = [1, 2]
318+
const rawRecord2 = [3, 4]
319+
const receivedRecords: Array<[number, number]> = []
320+
321+
streamObserverMock.onKeys(keys)
322+
streamObserverMock.onNext(rawRecord1)
323+
streamObserverMock.onNext(rawRecord2)
324+
325+
await result.subscribe({
326+
onNext (record) {
327+
const a: number = record.get('a')
328+
const b: number = record.get('b')
329+
330+
// @ts-expect-error
331+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
332+
const _: string = record.get('a')
333+
334+
receivedRecords.push([a, b])
335+
}
336+
})
337+
338+
expect(receivedRecords).toEqual([rawRecord1, rawRecord2])
339+
})
340+
308341
describe.each([
309342
['query', {}, { query: 'query', parameters: {} }],
310343
['query', { a: 1 }, { query: 'query', parameters: { a: 1 } }],
@@ -540,6 +573,45 @@ describe('Result', () => {
540573
new Record(keys, rawRecord2)
541574
])
542575
})
576+
577+
it('should resolve with summary and records type safety', async () => {
578+
const result = new Result<AB>(Promise.resolve(streamObserverMock), expected.query, expected.parameters)
579+
const metadata = {
580+
resultConsumedAfter: 20,
581+
resultAvailableAfter: 124,
582+
extraInfo: 'extra'
583+
}
584+
const expectedSummary = new ResultSummary(
585+
expected.query,
586+
expected.parameters,
587+
metadata
588+
)
589+
const keys = ['a', 'b']
590+
const rawRecord1 = [1, 2]
591+
const rawRecord2 = [3, 4]
592+
593+
streamObserverMock.onKeys(keys)
594+
streamObserverMock.onNext(rawRecord1)
595+
streamObserverMock.onNext(rawRecord2)
596+
597+
streamObserverMock.onCompleted(metadata)
598+
599+
const { summary, records } = await result
600+
601+
const rawRecords = records.map(record => {
602+
const a: number = record.get('a')
603+
const b: number = record.get('b')
604+
605+
// @ts-expect-error
606+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
607+
const _: string = record.get('a')
608+
609+
return [a, b]
610+
})
611+
612+
expect(summary).toEqual(expectedSummary)
613+
expect(rawRecords).toEqual([rawRecord1, rawRecord2])
614+
})
543615
})
544616

545617
it('should reject promise with the occurred error and new stacktrace', done => {
@@ -808,6 +880,34 @@ describe('Result', () => {
808880
])
809881
})
810882

883+
it('should iterate over record with type safety', async () => {
884+
const result = new Result<AB>(Promise.resolve(streamObserverMock), 'query')
885+
886+
const keys = ['a', 'b']
887+
const rawRecord1 = [1, 2]
888+
const rawRecord2 = [3, 4]
889+
890+
streamObserverMock.onKeys(keys)
891+
streamObserverMock.onNext(rawRecord1)
892+
streamObserverMock.onNext(rawRecord2)
893+
894+
streamObserverMock.onCompleted({})
895+
896+
const receivedRawRecords = []
897+
for await (const record of result) {
898+
const a: number = record.get('a')
899+
const b: number = record.get('b')
900+
901+
// @ts-expect-error
902+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
903+
const _: string = record.get('a')
904+
905+
receivedRawRecords.push([a, b])
906+
}
907+
908+
expect(receivedRawRecords).toEqual([rawRecord1, rawRecord2])
909+
})
910+
811911
it('should return summary when it finishes', async () => {
812912
const keys = ['a', 'b']
813913
const rawRecord1 = [1, 2]

packages/neo4j-driver-deno/lib/core/record.ts

+1
Original file line numberDiff line numberDiff line change
@@ -243,3 +243,4 @@ class Record<
243243
}
244244

245245
export default Record
246+
export type { Dict }

0 commit comments

Comments
 (0)