From 834cbd116e111e1ef2fc30bb9276b27eed68624c Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Wed, 27 Sep 2023 16:02:25 -0500 Subject: [PATCH] update query work --- .../todo-app/src/resources/TodoResource.ts | 9 +- packages/endpoint/src/index.ts | 1 + packages/endpoint/src/interface.ts | 4 +- packages/endpoint/src/normal.ts | 28 ++ packages/endpoint/src/queryEndpoint.ts | 16 +- packages/endpoint/src/schema.d.ts | 5 +- packages/endpoint/src/schemaTypes.ts | 25 +- packages/endpoint/src/schemas/Collection.ts | 30 +- .../src/schemas/__tests__/Collection.test.ts | 1 + .../src/schemas/__tests__/Query.test.ts | 284 ++++++++++-------- .../__snapshots__/Query.test.ts.snap | 58 +++- packages/rest/src/resourceTypes.ts | 22 +- 12 files changed, 308 insertions(+), 175 deletions(-) diff --git a/examples/todo-app/src/resources/TodoResource.ts b/examples/todo-app/src/resources/TodoResource.ts index cd089155a0c8..d886d6cd2632 100644 --- a/examples/todo-app/src/resources/TodoResource.ts +++ b/examples/todo-app/src/resources/TodoResource.ts @@ -21,11 +21,6 @@ export const TodoResource = createPlaceholderResource({ }); export const queryRemainingTodos = new Query( - new schema.All(Todo), - (entries, { userId } = {}) => { - if (userId !== undefined) - return entries.filter((todo) => todo.userId === userId && !todo.completed) - .length; - return entries.filter((todo) => !todo.completed).length; - }, + TodoResource.getList.schema, + (entries) => entries && entries.filter((todo) => !todo.completed).length, ); diff --git a/packages/endpoint/src/index.ts b/packages/endpoint/src/index.ts index 711b1d9eeef7..83d753829e8e 100644 --- a/packages/endpoint/src/index.ts +++ b/packages/endpoint/src/index.ts @@ -34,6 +34,7 @@ export type { NormalizeNullable, Denormalize, DenormalizeNullable, + SchemaToArgs, } from './normal.js'; export type { EndpointExtraOptions, diff --git a/packages/endpoint/src/interface.ts b/packages/endpoint/src/interface.ts index 8b4faf1f647e..92fb117f4bc7 100644 --- a/packages/endpoint/src/interface.ts +++ b/packages/endpoint/src/interface.ts @@ -14,7 +14,7 @@ export type Serializable< T extends { toJSON(): string } = { toJSON(): string }, > = (value: any) => T; -export interface SchemaSimple { +export interface SchemaSimple { normalize( input: any, parent: any, @@ -23,7 +23,7 @@ export interface SchemaSimple { addEntity: (...args: any) => any, visitedEntities: Record, storeEntities: any, - args?: any[], + args: Args, ): any; denormalize( input: {}, diff --git a/packages/endpoint/src/normal.ts b/packages/endpoint/src/normal.ts index 9ba85d72c7b2..45d6d4952809 100644 --- a/packages/endpoint/src/normal.ts +++ b/packages/endpoint/src/normal.ts @@ -131,3 +131,31 @@ export type NormalizedSchema = { export interface EntityMap { readonly [k: string]: EntityInterface; } + +export type SchemaToArgs< + S extends { + normalize( + input: any, + parent: any, + key: any, + visit: (...args: any) => any, + addEntity: (...args: any) => any, + visitedEntities: Record, + storeEntities: any, + args: any, + ): any; + }, +> = S extends { + normalize( + input: any, + parent: any, + key: any, + visit: (...args: any) => any, + addEntity: (...args: any) => any, + visitedEntities: Record, + storeEntities: any, + args: infer Args, + ): any; +} + ? Args + : never; diff --git a/packages/endpoint/src/queryEndpoint.ts b/packages/endpoint/src/queryEndpoint.ts index dddd277a588a..b422b0dfedae 100644 --- a/packages/endpoint/src/queryEndpoint.ts +++ b/packages/endpoint/src/queryEndpoint.ts @@ -3,7 +3,7 @@ import type { NormalizedIndex, SchemaSimple, } from './interface.js'; -import type { Denormalize } from './normal.js'; +import type { Denormalize, SchemaToArgs } from './normal.js'; /** * Programmatic cache reading @@ -11,7 +11,7 @@ import type { Denormalize } from './normal.js'; */ export class Query< S extends SchemaSimple, - P extends any[] = [], + P extends SchemaToArgs = SchemaToArgs, R = Denormalize, > { declare schema: QuerySchema; @@ -34,17 +34,15 @@ export class Query< protected createQuerySchema(schema: SchemaSimple) { const query = Object.create(schema); - query.denormalize = ( - { args, input }: { args: P; input: any }, - _: P, - unvisit: any, - ) => { + query.denormalize = (input: any, args: P, unvisit: any) => { if (input === undefined) return undefined; - const value = (schema as any).denormalize(input, args, unvisit); + const value = unvisit(input, schema); return typeof value === 'symbol' ? undefined : this.process(value, ...args); }; + // anywhere in the hierarchy + if ('key' in schema) query.key = `QUERY ${schema.key}`; query.infer = ( args: any, indexes: any, @@ -56,7 +54,7 @@ export class Query< ) => any, entities: EntityTable, ) => { - return { args, input: recurse(schema, args, indexes, entities) }; + return recurse(schema, args, indexes, entities); }; return query; } diff --git a/packages/endpoint/src/schema.d.ts b/packages/endpoint/src/schema.d.ts index 614f29ccf3c8..7219bb248dae 100644 --- a/packages/endpoint/src/schema.d.ts +++ b/packages/endpoint/src/schema.d.ts @@ -317,11 +317,12 @@ export declare let CollectionRoot: CollectionConstructor; */ export declare class Collection< S extends any[] | PolymorphicInterface = any, - Parent extends any[] = [ + Args extends any[] = [ urlParams: Record, body?: Record, ], -> extends CollectionRoot {} + Parent = any, +> extends CollectionRoot {} // id is in Instance, so we default to that as pk /** diff --git a/packages/endpoint/src/schemaTypes.ts b/packages/endpoint/src/schemaTypes.ts index 72c439691696..049655763291 100644 --- a/packages/endpoint/src/schemaTypes.ts +++ b/packages/endpoint/src/schemaTypes.ts @@ -24,9 +24,10 @@ export type CollectionArrayAdder = S extends { export interface CollectionInterface< S extends PolymorphicInterface = any, - Parent extends any[] = any, + Args extends any[] = any[], + Parent = any, > { - addWith

( + addWith

( merge: (existing: any, incoming: any) => any, createCollectionFilter?: ( ...args: P @@ -46,7 +47,7 @@ export interface CollectionInterface< addEntity: (...args: any) => any, visitedEntities: Record, storeEntities: any, - args: any[], + args: Args, ): string; merge(existing: any, incoming: any): any; @@ -126,28 +127,34 @@ export interface CollectionInterface< * @see https://dataclient.io/rest/api/Collection#assign */ assign: S extends { denormalize(...args: any): Record } - ? schema.Collection + ? schema.Collection : never; } export type CollectionFromSchema< S extends any[] | PolymorphicInterface = any, - Parent extends any[] = [ + Args extends any[] = [ urlParams: Record, body?: Record, ], -> = CollectionInterface : S, Parent>; + Parent = any, +> = CollectionInterface< + S extends any[] ? schema.Array : S, + Args, + Parent +>; export interface CollectionConstructor { new < S extends SchemaSimple[] | PolymorphicInterface = any, - Parent extends any[] = [ + Args extends any[] = [ urlParams: Record, body?: Record, ], + Parent = any, >( schema: S, - options?: CollectionOptions, - ): CollectionFromSchema; + options?: CollectionOptions, + ): CollectionFromSchema; readonly prototype: CollectionInterface; } diff --git a/packages/endpoint/src/schemas/Collection.ts b/packages/endpoint/src/schemas/Collection.ts index 55132cc05626..1509921c1ef8 100644 --- a/packages/endpoint/src/schemas/Collection.ts +++ b/packages/endpoint/src/schemas/Collection.ts @@ -21,10 +21,11 @@ const createValue = (value: any) => ({ ...value }); */ export default class CollectionSchema< S extends PolymorphicInterface = any, - Parent extends any[] = [ + Args extends any[] = [ urlParams: Record, body?: Record, ], + Parent = any, > { protected declare nestKey: (parent: any, key: string) => Record; @@ -35,18 +36,18 @@ export default class CollectionSchema< declare readonly key: string; declare push: S extends ArraySchema - ? CollectionSchema + ? CollectionSchema : undefined; declare unshift: S extends ArraySchema - ? CollectionSchema + ? CollectionSchema : undefined; declare assign: S extends Values - ? CollectionSchema + ? CollectionSchema : undefined; - addWith

( + addWith

( merge: (existing: any, incoming: any) => any, createCollectionFilter?: ( ...args: P @@ -59,7 +60,7 @@ export default class CollectionSchema< // so fetch(create, { userId: 'bob', completed: true }, data) // would possibly add to {}, {userId: 'bob'}, {completed: true}, {userId: 'bob', completed: true } - but only those already in the store // it ignores keys that start with sort as those are presumed to not filter results - protected createCollectionFilter(...args: Parent) { + protected createCollectionFilter(...args: Args) { return (collectionKey: Record) => Object.entries(collectionKey).every( ([key, value]) => @@ -74,7 +75,7 @@ export default class CollectionSchema< return key.startsWith('order'); } - constructor(schema: S, options?: CollectionOptions) { + constructor(schema: S, options?: CollectionOptions) { this.schema = Array.isArray(schema) ? (new ArraySchema(schema[0]) as any) : schema; @@ -109,7 +110,7 @@ export default class CollectionSchema< this.createCollectionFilter = ( options as any as { createCollectionFilter: ( - ...args: Parent + ...args: Args ) => (collectionKey: Record) => boolean; } ).createCollectionFilter.bind(this) as any; @@ -152,13 +153,13 @@ export default class CollectionSchema< normalize( input: any, - parent: any, + parent: Parent, key: string, visit: (...args: any) => any, addEntity: (...args: any) => any, visitedEntities: Record, storeEntities: any, - args: any[], + args: Args, ): string { const pkList = this.schema.normalize( input, @@ -239,22 +240,23 @@ export default class CollectionSchema< } export type CollectionOptions< - Parent extends any[] = [ + Args extends any[] = [ urlParams: Record, body?: Record, ], + Parent = any, > = ( | { - nestKey?: (parent: any, key: string) => Record; + nestKey?: (parent: Parent, key: string) => Record; } | { - argsKey?: (...args: any) => Record; + argsKey?: (...args: Args) => Record; } ) & ( | { createCollectionFilter?: ( - ...args: Parent + ...args: Args ) => (collectionKey: Record) => boolean; } | { diff --git a/packages/endpoint/src/schemas/__tests__/Collection.test.ts b/packages/endpoint/src/schemas/__tests__/Collection.test.ts index f3e964683461..53ca84309b93 100644 --- a/packages/endpoint/src/schemas/__tests__/Collection.test.ts +++ b/packages/endpoint/src/schemas/__tests__/Collection.test.ts @@ -88,6 +88,7 @@ describe(`${schema.Collection.name} normalization`, () => { () => undefined, {}, {}, + // @ts-expect-error [], ); } diff --git a/packages/endpoint/src/schemas/__tests__/Query.test.ts b/packages/endpoint/src/schemas/__tests__/Query.test.ts index 2c5b7677ede2..d84790aa1f88 100644 --- a/packages/endpoint/src/schemas/__tests__/Query.test.ts +++ b/packages/endpoint/src/schemas/__tests__/Query.test.ts @@ -25,140 +25,172 @@ describe.each([ (v: any) => (typeof v?.toJS === 'function' ? v.toJS() : v), ], ])(`input (%s)`, (_, createInput, createOutput) => { + const denormalize = denormalizeSimple; + class User extends IDEntity { + name = ''; + isAdmin = false; + } describe.each([ - ['current', denormalizeSimple, () => new WeakEntityMap()], - ] as const)( - `${Query.name} denormalization (%s)`, - (_, denormalize, createResultCache) => { - class User extends IDEntity { - name = ''; - isAdmin = false; - } - const sortedUsers = new Query( - new schema.Object({ results: new schema.All(User) }), - ({ results }, { asc } = { asc: false }) => { - const sorted = [...results].sort((a, b) => - a.name.localeCompare(b.name), - ); - if (asc) return sorted; - return sorted.reverse(); - }, - ); - - test('denormalize sorts', () => { - const entities = { - User: { - 1: { id: '1', name: 'Milo' }, - 2: { id: '2', name: 'Jake' }, - 3: { id: '3', name: 'Zeta' }, - 4: { id: '4', name: 'Alpha' }, - }, - }; - const users: DenormalizeNullable | symbol = - denormalize( - inferResults(sortedUsers.schema, [], {}, entities), - sortedUsers.schema, - createInput(entities), - ); - expect(users).not.toEqual(expect.any(Symbol)); - if (typeof users === 'symbol') return; - expect(users && users[0].name).toBe('Zeta'); - expect(users).toMatchSnapshot(); - }); - - test('denormalize sorts with arg', () => { - const entities = { - User: { - 1: { id: '1', name: 'Milo' }, - 2: { id: '2', name: 'Jake' }, - 3: { id: '3', name: 'Zeta' }, - 4: { id: '4', name: 'Alpha' }, - }, - }; - expect( - denormalize( - inferResults(sortedUsers.schema, [{ asc: true }], {}, entities), - sortedUsers.schema, - createInput(entities), - ), - ).toMatchSnapshot(); - }); - - test('denormalizes should not be found when no entities are present', () => { - const entities = { - DOG: { - 1: { id: '1', name: 'Milo' }, - 2: { id: '2', name: 'Jake' }, - }, - }; - const input = inferResults(sortedUsers.schema, [], {}, entities); + ['All', new schema.Object({ results: new schema.All(User) })], + [ + 'Collection', + new schema.Object({ results: new schema.Collection([User]) }), + ], + ] as const)(`${Query.name} denormalization (%s schema)`, (_, usersSchema) => { + const sortedUsers = new Query( + usersSchema, + ({ results }, { asc } = { asc: false }) => { + const sorted = [...results].sort((a, b) => + a.name.localeCompare(b.name), + ); + if (asc) return sorted; + return sorted.reverse(); + }, + ); - const value = denormalize( - createInput(input), + test('denormalize sorts', () => { + const entities = { + User: { + 1: { id: '1', name: 'Milo' }, + 2: { id: '2', name: 'Jake' }, + 3: { id: '3', name: 'Zeta' }, + 4: { id: '4', name: 'Alpha' }, + }, + [new schema.Collection([User]).key]: { + [new schema.Collection([User]).pk(undefined, undefined, '', [])]: [ + 1, 2, 3, 4, + ], + }, + }; + const users: DenormalizeNullable | symbol = + denormalize( + inferResults(sortedUsers.schema, [], {}, entities), sortedUsers.schema, createInput(entities), ); + expect(users).not.toEqual(expect.any(Symbol)); + if (typeof users === 'symbol') return; + expect(users && users[0].name).toBe('Zeta'); + expect(users).toMatchSnapshot(); + }); - expect(createOutput(value)).toEqual(undefined); - }); - - test('denormalize aggregates', () => { - const userCountByAdmin = new Query( - new schema.Object({ results: new schema.All(User) }), - ({ results }, { isAdmin }: { isAdmin?: boolean } = {}) => { - if (isAdmin === undefined) return results.length; - return results.filter(user => user.isAdmin === isAdmin).length; - }, - ); - const entities = { - User: { - 1: { id: '1', name: 'Milo' }, - 2: { id: '2', name: 'Jake', isAdmin: true }, - 3: { id: '3', name: 'Zeta' }, - 4: { id: '4', name: 'Alpha' }, - }, - }; - const totalCount: - | DenormalizeNullable - | symbol = denormalize( - inferResults(userCountByAdmin.schema, [], {}, entities), - userCountByAdmin.schema, + test('denormalize sorts with arg', () => { + const entities = { + User: { + 1: { id: '1', name: 'Milo' }, + 2: { id: '2', name: 'Jake' }, + 3: { id: '3', name: 'Zeta' }, + 4: { id: '4', name: 'Alpha' }, + }, + [new schema.Collection([User]).key]: { + [new schema.Collection([User]).pk(undefined, undefined, '', [ + { asc: true }, + ])]: [1, 2, 3, 4], + }, + }; + expect( + denormalize( + inferResults(sortedUsers.schema, [{ asc: true }], {}, entities), + sortedUsers.schema, createInput(entities), - ); - expect(totalCount).toBe(4); - const nonAdminCount: - | DenormalizeNullable - | symbol = denormalize( - inferResults( - userCountByAdmin.schema, - [{ isAdmin: false }], - {}, - entities, - ), + {}, + new WeakEntityMap(), + [{ asc: true }], + ), + ).toMatchSnapshot(); + }); + + test('denormalizes should not be found when no entities are present', () => { + const entities = { + DOG: { + 1: { id: '1', name: 'Milo' }, + 2: { id: '2', name: 'Jake' }, + }, + }; + const input = inferResults(sortedUsers.schema, [], {}, entities); + + const value = denormalize( + createInput(input), + sortedUsers.schema, + createInput(entities), + ); + + expect(createOutput(value)).toEqual(undefined); + }); + + test('denormalize aggregates', () => { + const userCountByAdmin = new Query( + usersSchema, + ({ results }, { isAdmin }: { isAdmin?: boolean } = {}) => { + if (isAdmin === undefined) return results.length; + return results.filter(user => user.isAdmin === isAdmin).length; + }, + ); + const entities = { + User: { + 1: { id: '1', name: 'Milo' }, + 2: { id: '2', name: 'Jake', isAdmin: true }, + 3: { id: '3', name: 'Zeta' }, + 4: { id: '4', name: 'Alpha' }, + }, + [new schema.Collection([User]).key]: { + [new schema.Collection([User]).pk(undefined, undefined, '', [])]: [ + 1, 2, 3, 4, + ], + [new schema.Collection([User]).pk(undefined, undefined, '', [ + { isAdmin: false }, + ])]: [1, 3, 4], + [new schema.Collection([User]).pk(undefined, undefined, '', [ + { isAdmin: true }, + ])]: [2], + }, + }; + const totalCount: + | DenormalizeNullable + | symbol = denormalize( + inferResults(userCountByAdmin.schema, [], {}, entities), + userCountByAdmin.schema, + createInput(entities), + ); + expect(totalCount).toBe(4); + const nonAdminCount: + | DenormalizeNullable + | symbol = denormalize( + inferResults( userCountByAdmin.schema, - createInput(entities), - ); - expect(nonAdminCount).toBe(3); - const adminCount: - | DenormalizeNullable - | symbol = denormalize( - inferResults( - userCountByAdmin.schema, - [{ isAdmin: true }], - {}, - entities, - ), + [{ isAdmin: false }], + {}, + entities, + ), + userCountByAdmin.schema, + createInput(entities), + {}, + new WeakEntityMap(), + [{ isAdmin: false }], + ); + expect(nonAdminCount).toBe(3); + const adminCount: + | DenormalizeNullable + | symbol = denormalize( + inferResults( userCountByAdmin.schema, - createInput(entities), - ); - expect(adminCount).toBe(1); - if (typeof totalCount === 'symbol') return; + [{ isAdmin: true }], + {}, + entities, + ), + userCountByAdmin.schema, + createInput(entities), + {}, + new WeakEntityMap(), + [{ isAdmin: true }], + ); + expect(adminCount).toBe(1); + if (typeof totalCount === 'symbol') return; - // typecheck - totalCount !== undefined && totalCount + 5; - // @ts-expect-error - totalCount?.bob; - }); - }, - ); + // typecheck + totalCount !== undefined && totalCount + 5; + // @ts-expect-error + totalCount?.bob; + }); + }); }); diff --git a/packages/endpoint/src/schemas/__tests__/__snapshots__/Query.test.ts.snap b/packages/endpoint/src/schemas/__tests__/__snapshots__/Query.test.ts.snap index aa2bde285478..ba35b24134d7 100644 --- a/packages/endpoint/src/schemas/__tests__/__snapshots__/Query.test.ts.snap +++ b/packages/endpoint/src/schemas/__tests__/__snapshots__/Query.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`input (direct) Query denormalization (current) denormalize sorts 1`] = ` +exports[`input (direct) Query denormalization (All schema) denormalize sorts 1`] = ` [ User { "id": "3", @@ -25,7 +25,7 @@ exports[`input (direct) Query denormalization (current) denormalize sorts 1`] = ] `; -exports[`input (direct) Query denormalization (current) denormalize sorts with arg 1`] = ` +exports[`input (direct) Query denormalization (All schema) denormalize sorts with arg 1`] = ` [ User { "id": "4", @@ -50,7 +50,7 @@ exports[`input (direct) Query denormalization (current) denormalize sorts with a ] `; -exports[`input (immutable) Query denormalization (current) denormalize sorts 1`] = ` +exports[`input (direct) Query denormalization (Collection schema) denormalize sorts 1`] = ` [ User { "id": "3", @@ -75,7 +75,57 @@ exports[`input (immutable) Query denormalization (current) denormalize sorts 1`] ] `; -exports[`input (immutable) Query denormalization (current) denormalize sorts with arg 1`] = ` +exports[`input (direct) Query denormalization (Collection schema) denormalize sorts with arg 1`] = ` +[ + User { + "id": "4", + "isAdmin": false, + "name": "Alpha", + }, + User { + "id": "2", + "isAdmin": false, + "name": "Jake", + }, + User { + "id": "1", + "isAdmin": false, + "name": "Milo", + }, + User { + "id": "3", + "isAdmin": false, + "name": "Zeta", + }, +] +`; + +exports[`input (immutable) Query denormalization (All schema) denormalize sorts 1`] = ` +[ + User { + "id": "3", + "isAdmin": false, + "name": "Zeta", + }, + User { + "id": "1", + "isAdmin": false, + "name": "Milo", + }, + User { + "id": "2", + "isAdmin": false, + "name": "Jake", + }, + User { + "id": "4", + "isAdmin": false, + "name": "Alpha", + }, +] +`; + +exports[`input (immutable) Query denormalization (All schema) denormalize sorts with arg 1`] = ` [ User { "id": "4", diff --git a/packages/rest/src/resourceTypes.ts b/packages/rest/src/resourceTypes.ts index 03399c73e386..8847fb873295 100644 --- a/packages/rest/src/resourceTypes.ts +++ b/packages/rest/src/resourceTypes.ts @@ -72,7 +72,16 @@ export interface Resource< ? GetEndpoint< { path: ShortenPath; - schema: schema.Collection<[O['schema']]>; + schema: schema.Collection< + [O['schema']], + [ + 'searchParams' extends keyof O + ? O['searchParams'] extends undefined + ? PathArgs> + : O['searchParams'] & PathArgs> + : PathArgs>, + ] + >; body: 'body' extends keyof O ? O['body'] : Partial>; @@ -82,7 +91,16 @@ export interface Resource< : GetEndpoint< { path: ShortenPath; - schema: schema.Collection<[O['schema']]>; + schema: schema.Collection< + [O['schema']], + [ + 'searchParams' extends keyof O + ? O['searchParams'] extends undefined + ? PathArgs> + : O['searchParams'] & PathArgs> + : PathArgs>, + ] + >; body: 'body' extends keyof O ? O['body'] : Partial>;