diff --git a/.changeset/new-gifts-hammer.md b/.changeset/new-gifts-hammer.md new file mode 100644 index 000000000000..68ec9bd4ffa0 --- /dev/null +++ b/.changeset/new-gifts-hammer.md @@ -0,0 +1,15 @@ +--- +'@data-client/endpoint': minor +'@data-client/rest': minor +--- + +Query works with any Schema - including Collections + +```ts +export const queryRemainingTodos = new Query( + TodoResource.getList.schema, + (entries) => entries && entries.filter((todo) => !todo.completed).length, +); +``` + +BREAKING CHANGE: Query.schema internals are laid out differently \ No newline at end of file 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..d5c59ca71253 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,15 +11,13 @@ import type { Denormalize } from './normal.js'; */ export class Query< S extends SchemaSimple, - P extends any[] = [], + P extends SchemaToArgs = SchemaToArgs, R = Denormalize, > { declare schema: QuerySchema; // TODO: allow arbitrary return types then inferring it from declare process: (entries: Denormalize, ...args: P) => R; - readonly sideEffect = undefined; - constructor(schema: S, process?: (entries: Denormalize, ...args: P) => R) { this.schema = this.createQuerySchema(schema); if (process) this.process = process; @@ -34,17 +32,14 @@ export class Query< protected createQuerySchema(schema: SchemaSimple) { const query = Object.create(schema); - query.denormalize = ( - { args, input }: { args: P; input: any }, - _: P, - unvisit: any, - ) => { - if (input === undefined) return undefined; - const value = (schema as any).denormalize(input, args, unvisit); + query.denormalize = (input: any, args: P, unvisit: any) => { + const value = unvisit(input, schema); return typeof value === 'symbol' ? undefined : this.process(value, ...args); }; + // do not look like an entity + query.pk = undefined; query.infer = ( args: any, indexes: any, @@ -56,7 +51,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..14d0e6033fa0 100644 --- a/packages/endpoint/src/schemas/__tests__/Query.test.ts +++ b/packages/endpoint/src/schemas/__tests__/Query.test.ts @@ -17,6 +17,12 @@ afterAll(() => { dateSpy.mockRestore(); }); +const denormalize = denormalizeSimple; +class User extends IDEntity { + name = ''; + isAdmin = false; +} + describe.each([ ['direct', (data: T) => data, (data: T) => data], [ @@ -25,18 +31,24 @@ describe.each([ (v: any) => (typeof v?.toJS === 'function' ? v.toJS() : v), ], ])(`input (%s)`, (_, createInput, createOutput) => { - describe.each([ - ['current', denormalizeSimple, () => new WeakEntityMap()], - ] as const)( - `${Query.name} denormalization (%s)`, - (_, denormalize, createResultCache) => { - class User extends IDEntity { - name = ''; - isAdmin = false; - } + const SCHEMA_CASES = [ + ['All', new schema.Object({ results: new schema.All(User) })], + [ + 'Collection', + new schema.Object({ results: new schema.Collection([User]) }), + ], + ] as const; + if (_ === 'immutable') { + delete (SCHEMA_CASES as any)[1]; + } + + describe.each(SCHEMA_CASES)( + `${Query.name} denormalization (%s schema)`, + (_, usersSchema) => { const sortedUsers = new Query( - new schema.Object({ results: new schema.All(User) }), + usersSchema, ({ results }, { asc } = { asc: false }) => { + if (!results) return results; const sorted = [...results].sort((a, b) => a.name.localeCompare(b.name), ); @@ -53,6 +65,11 @@ describe.each([ 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( @@ -74,12 +91,20 @@ describe.each([ 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), + {}, + new WeakEntityMap(), + [{ asc: true }], ), ).toMatchSnapshot(); }); @@ -104,7 +129,7 @@ describe.each([ test('denormalize aggregates', () => { const userCountByAdmin = new Query( - new schema.Object({ results: new schema.All(User) }), + usersSchema, ({ results }, { isAdmin }: { isAdmin?: boolean } = {}) => { if (isAdmin === undefined) return results.length; return results.filter(user => user.isAdmin === isAdmin).length; @@ -117,6 +142,17 @@ describe.each([ 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 @@ -137,6 +173,9 @@ describe.each([ ), userCountByAdmin.schema, createInput(entities), + {}, + new WeakEntityMap(), + [{ isAdmin: false }], ); expect(nonAdminCount).toBe(3); const adminCount: @@ -150,6 +189,9 @@ describe.each([ ), userCountByAdmin.schema, createInput(entities), + {}, + new WeakEntityMap(), + [{ isAdmin: true }], ); expect(adminCount).toBe(1); if (typeof totalCount === 'symbol') return; @@ -162,3 +204,53 @@ describe.each([ }, ); }); + +describe('top level schema', () => { + const sortedUsers = new Query( + new schema.Collection([User]), + (results, { asc } = { asc: false }) => { + if (!results) return results; + 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' }, + }, + [new schema.Collection([User]).key]: { + [new schema.Collection([User]).pk({}, undefined, '', [])]: [1, 2, 3, 4], + }, + }; + const users: DenormalizeNullable | symbol = + denormalize( + inferResults(sortedUsers.schema, [], {}, entities), + sortedUsers.schema, + entities, + ); + expect(users).not.toEqual(expect.any(Symbol)); + if (typeof users === 'symbol') return; + expect(users && users[0].name).toBe('Zeta'); + expect(users).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(input, sortedUsers.schema, entities); + + expect(value).toEqual(undefined); + }); +}); 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..03aff7b24ad8 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,7 @@ 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", @@ -99,3 +99,78 @@ exports[`input (immutable) Query denormalization (current) denormalize sorts wit }, ] `; + +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", + "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[`top level 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", + }, +] +`; 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>;