diff --git a/examples/benchmark/core.js b/examples/benchmark/core.js index 838dd28618f7..cf85c2b0d08c 100644 --- a/examples/benchmark/core.js +++ b/examples/benchmark/core.js @@ -137,7 +137,7 @@ export default function addReducerSuite(suite) { } }) .add('getResponse Query-sorted', () => { - return controller.getResponse(ProjectQuerySorted, cachedState); + return controller.query(ProjectQuerySorted, cachedState); }) .add('getResponse Collection', () => { return controllerCollection.getResponse( diff --git a/examples/benchmark/schemas.js b/examples/benchmark/schemas.js index 811f0ade77b9..07b1b1f8516e 100644 --- a/examples/benchmark/schemas.js +++ b/examples/benchmark/schemas.js @@ -1,4 +1,4 @@ -import { Entity, schema, Query } from './dist/index.js'; +import { Entity, schema } from './dist/index.js'; export class BuildTypeDescription extends Entity { id = ''; @@ -100,7 +100,7 @@ export const ProjectSchemaMixin = { export const ProjectQuery = { project: new schema.All(ProjectWithBuildTypesDescription), }; -export const ProjectQuerySorted = new Query( +export const ProjectQuerySorted = new schema.Query( new schema.All(ProjectWithBuildTypesDescription), entries => { return [...entries].sort((a, b) => a.internalId - b.internalId); diff --git a/examples/coin-app/src/resources/Currency.ts b/examples/coin-app/src/resources/Currency.ts index 260490f435fb..6d326063d338 100644 --- a/examples/coin-app/src/resources/Currency.ts +++ b/examples/coin-app/src/resources/Currency.ts @@ -1,4 +1,4 @@ -import { Entity, Query, createResource, schema } from '@data-client/rest'; +import { Entity, createResource, schema } from '@data-client/rest'; import { Stats } from './Stats'; @@ -61,7 +61,7 @@ export const CurrencyResource = createResource({ interface Args { type?: string; } -export const queryCurrency = new Query( +export const queryCurrency = new schema.Query( new schema.All(Currency), (entries, { type = 'crypto' }: Args) => { let sorted = entries.filter( diff --git a/examples/coin-app/src/resources/Product.ts b/examples/coin-app/src/resources/Product.ts index 8afe0e23bd90..fd9bb199a5d5 100644 --- a/examples/coin-app/src/resources/Product.ts +++ b/examples/coin-app/src/resources/Product.ts @@ -1,4 +1,4 @@ -import { Entity, Query, createResource, schema } from '@data-client/rest'; +import { Entity, createResource, schema } from '@data-client/rest'; import { Stats } from './Stats'; @@ -34,7 +34,7 @@ export const ProductResource = createResource({ interface Args { quote_currency?: string; } -export const queryProduct = new Query( +export const queryProduct = new schema.Query( new schema.All(Product), (entries, { quote_currency }: Args) => { let sorted = entries.filter(product => product.stats); diff --git a/examples/nextjs/resources/TodoResource.ts b/examples/nextjs/resources/TodoResource.ts index d886d6cd2632..cca3cfb2c56f 100644 --- a/examples/nextjs/resources/TodoResource.ts +++ b/examples/nextjs/resources/TodoResource.ts @@ -1,4 +1,4 @@ -import { Query, schema } from '@data-client/rest'; +import { schema } from '@data-client/rest'; import { createPlaceholderResource, @@ -20,7 +20,7 @@ export const TodoResource = createPlaceholderResource({ searchParams: {} as { userId?: string | number } | undefined, }); -export const queryRemainingTodos = new Query( +export const queryRemainingTodos = new schema.Query( TodoResource.getList.schema, (entries) => entries && entries.filter((todo) => !todo.completed).length, ); diff --git a/examples/todo-app/src/pages/Home/TodoStats.tsx b/examples/todo-app/src/pages/Home/TodoStats.tsx index 1d2544dd9cbe..a7a085440731 100644 --- a/examples/todo-app/src/pages/Home/TodoStats.tsx +++ b/examples/todo-app/src/pages/Home/TodoStats.tsx @@ -1,8 +1,8 @@ -import { useCache } from '@data-client/react'; +import { useQuery } from '@data-client/react'; import { queryRemainingTodos } from 'resources/TodoResource'; export default function TodoStats({ userId }: { userId?: number }) { - const remaining = useCache(queryRemainingTodos, { userId }); + const remaining = useQuery(queryRemainingTodos, { userId }); return
{remaining} tasks remaining
; } diff --git a/examples/todo-app/src/resources/TodoResource.ts b/examples/todo-app/src/resources/TodoResource.ts index d886d6cd2632..cca3cfb2c56f 100644 --- a/examples/todo-app/src/resources/TodoResource.ts +++ b/examples/todo-app/src/resources/TodoResource.ts @@ -1,4 +1,4 @@ -import { Query, schema } from '@data-client/rest'; +import { schema } from '@data-client/rest'; import { createPlaceholderResource, @@ -20,7 +20,7 @@ export const TodoResource = createPlaceholderResource({ searchParams: {} as { userId?: string | number } | undefined, }); -export const queryRemainingTodos = new Query( +export const queryRemainingTodos = new schema.Query( TodoResource.getList.schema, (entries) => entries && entries.filter((todo) => !todo.completed).length, ); diff --git a/examples/todo-app/typetest.ts b/examples/todo-app/typetest.ts index de597de42ef9..1866b9dfc2e0 100644 --- a/examples/todo-app/typetest.ts +++ b/examples/todo-app/typetest.ts @@ -1,4 +1,4 @@ -import { useCache, useController, useSuspense } from '@data-client/react'; +import { useQuery, useController, useSuspense } from '@data-client/react'; import { queryRemainingTodos, @@ -23,7 +23,7 @@ function useTest() { ); }); - const remaining = useCache(queryRemainingTodos, { userId: 1 }); + const remaining = useQuery(queryRemainingTodos, { userId: 1 }); const users = useSuspense(UserResource.getList); users.map((user) => { diff --git a/packages/core/src/controller/Controller.ts b/packages/core/src/controller/Controller.ts index c344458afe55..7e88569f0195 100644 --- a/packages/core/src/controller/Controller.ts +++ b/packages/core/src/controller/Controller.ts @@ -397,7 +397,7 @@ export default class Controller< let results; let resultCache: ResultCache; if (cacheResults === undefined && endpoint.schema !== undefined) { - if (!this.globalCache.inputEndpointCache[key]) + if (!this.globalCache.inputEndpointCache[key] || true) this.globalCache.inputEndpointCache[key] = inferResults( endpoint.schema, args, @@ -473,7 +473,7 @@ export default class Controller< const querySchemaCache = this.globalCache.queries.get(schema) as { [key: string]: unknown; }; - if (!querySchemaCache[key]) + if (!querySchemaCache[key] || true) querySchemaCache[key] = inferResults( schema, args, diff --git a/packages/endpoint/src/index.ts b/packages/endpoint/src/index.ts index eb55b3b2c744..9b56e1159ce9 100644 --- a/packages/endpoint/src/index.ts +++ b/packages/endpoint/src/index.ts @@ -50,5 +50,4 @@ export type { export { default as Endpoint, ExtendableEndpoint } from './endpoint.js'; export type { KeyofEndpointInstance } from './endpoint.js'; export * from './indexEndpoint.js'; -export * from './queryEndpoint.js'; export { default as AbortOptimistic } from './AbortOptimistic.js'; diff --git a/packages/endpoint/src/interface.ts b/packages/endpoint/src/interface.ts index d0d19d9f90ba..873bdec6c53d 100644 --- a/packages/endpoint/src/interface.ts +++ b/packages/endpoint/src/interface.ts @@ -24,7 +24,7 @@ export type Serializable< T extends { toJSON(): string } = { toJSON(): string }, > = (value: any) => T; -export interface SchemaSimple { +export interface SchemaSimple { normalize( input: any, parent: any, diff --git a/packages/endpoint/src/schema.d.ts b/packages/endpoint/src/schema.d.ts index cef0f8d8a29a..bccbb012f271 100644 --- a/packages/endpoint/src/schema.d.ts +++ b/packages/endpoint/src/schema.d.ts @@ -29,6 +29,7 @@ import { PKClass, } from './schemas/EntitySchema.js'; import { default as Invalidate } from './schemas/Invalidate.js'; +import { default as Query } from './schemas/Query.js'; import type { CollectionConstructor, SchemaAttributeFunction, @@ -36,7 +37,7 @@ import type { UnionResult, } from './schemaTypes.js'; -export { EntityMap, Invalidate }; +export { EntityMap, Invalidate, Query }; export type { SchemaClass }; @@ -137,7 +138,8 @@ export class All< ): (S extends EntityMap ? T : Denormalize)[]; infer( - args: [], + // TODO: hack for now to allow for variable arg combinations with Query + args: [] | [unknown], indexes: NormalizedIndex, recurse: (...args: any) => any, entities: EntityTable, diff --git a/packages/endpoint/src/schema.js b/packages/endpoint/src/schema.js index 99c24e891429..8e6d433340f9 100644 --- a/packages/endpoint/src/schema.js +++ b/packages/endpoint/src/schema.js @@ -7,3 +7,4 @@ export { default as Object } from './schemas/Object.js'; export { default as Invalidate } from './schemas/Invalidate.js'; export { default as Collection } from './schemas/Collection.js'; export { default as Entity } from './schemas/EntitySchema.js'; +export { default as Query } from './schemas/Query.js'; diff --git a/packages/endpoint/src/schemas/Query.ts b/packages/endpoint/src/schemas/Query.ts new file mode 100644 index 000000000000..d749f1135da4 --- /dev/null +++ b/packages/endpoint/src/schemas/Query.ts @@ -0,0 +1,68 @@ +import type { + EntityTable, + NormalizedIndex, + Queryable, + SchemaSimple, +} from '../interface.js'; +import type { Denormalize, SchemaArgs } from '../normal.js'; + +/** + * Programmatic cache reading + * @see https://dataclient.io/rest/api/Query + */ +export default class Query< + S extends Queryable, + P extends (entries: Denormalize, ...args: any) => any = ( + entries: Denormalize, + ...args: SchemaArgs + ) => Denormalize, +> implements SchemaSimple | undefined, ProcessParameters> +{ + declare schema: S; + declare process: P; + declare thingy: ProcessParameters; + + constructor(schema: S, process?: P) { + this.schema = schema; + if (process) this.process = process; + // allows for inheritance overrides + else if (!this.process) + this.process = ((entries: Denormalize) => entries) as any; + } + + normalize(...args: any) { + return (this.schema as any).normalize(...args); + } + + denormalize(input: {}, args: any, unvisit: any): ReturnType

| undefined { + const value = unvisit(input, this.schema); + return typeof value === 'symbol' ? undefined : this.process(value, ...args); + } + + infer( + args: ProcessParameters, + indexes: any, + recurse: ( + schema: any, + args: any, + indexes: NormalizedIndex, + entities: EntityTable, + ) => any, + entities: EntityTable, + ) { + return recurse(this.schema, args, indexes, entities); + } + + declare _denormalizeNullable: ( + input: {}, + args: readonly any[], + unvisit: (input: any, schema: any) => any, + ) => ReturnType

| undefined; +} + +type ProcessParameters = + P extends (entries: any, ...args: infer Par) => any ? + Par extends [] ? + SchemaArgs + : Par & SchemaArgs + : SchemaArgs; diff --git a/packages/endpoint/src/schemas/__tests__/Collection.test.ts b/packages/endpoint/src/schemas/__tests__/Collection.test.ts index 53ca84309b93..f3e964683461 100644 --- a/packages/endpoint/src/schemas/__tests__/Collection.test.ts +++ b/packages/endpoint/src/schemas/__tests__/Collection.test.ts @@ -88,7 +88,6 @@ 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 14d0e6033fa0..5e4bcdb024a4 100644 --- a/packages/endpoint/src/schemas/__tests__/Query.test.ts +++ b/packages/endpoint/src/schemas/__tests__/Query.test.ts @@ -4,7 +4,7 @@ import { IDEntity } from '__tests__/new'; import { fromJS } from 'immutable'; import { denormalizeSimple } from './denormalize'; -import { schema, Query, Denormalize, DenormalizeNullable } from '../..'; +import { schema, DenormalizeNullable } from '../..'; let dateSpy: jest.SpyInstance; beforeAll(() => { @@ -43,9 +43,9 @@ describe.each([ } describe.each(SCHEMA_CASES)( - `${Query.name} denormalization (%s schema)`, + `${schema.Query.name} denormalization (%s schema)`, (_, usersSchema) => { - const sortedUsers = new Query( + const sortedUsers = new schema.Query( usersSchema, ({ results }, { asc } = { asc: false }) => { if (!results) return results; @@ -71,10 +71,10 @@ describe.each([ ], }, }; - const users: DenormalizeNullable | symbol = + const users: DenormalizeNullable | symbol = denormalize( - inferResults(sortedUsers.schema, [], {}, entities), - sortedUsers.schema, + inferResults(sortedUsers, [], {}, entities), + sortedUsers, createInput(entities), ); expect(users).not.toEqual(expect.any(Symbol)); @@ -99,8 +99,8 @@ describe.each([ }; expect( denormalize( - inferResults(sortedUsers.schema, [{ asc: true }], {}, entities), - sortedUsers.schema, + inferResults(sortedUsers, [{ asc: true }], {}, entities), + sortedUsers, createInput(entities), {}, new WeakEntityMap(), @@ -116,11 +116,11 @@ describe.each([ 2: { id: '2', name: 'Jake' }, }, }; - const input = inferResults(sortedUsers.schema, [], {}, entities); + const input = inferResults(sortedUsers, [], {}, entities); const value = denormalize( createInput(input), - sortedUsers.schema, + sortedUsers, createInput(entities), ); @@ -128,7 +128,7 @@ describe.each([ }); test('denormalize aggregates', () => { - const userCountByAdmin = new Query( + const userCountByAdmin = new schema.Query( usersSchema, ({ results }, { isAdmin }: { isAdmin?: boolean } = {}) => { if (isAdmin === undefined) return results.length; @@ -155,23 +155,18 @@ describe.each([ }, }; const totalCount: - | DenormalizeNullable + | DenormalizeNullable | symbol = denormalize( - inferResults(userCountByAdmin.schema, [], {}, entities), - userCountByAdmin.schema, + inferResults(userCountByAdmin, [], {}, entities), + userCountByAdmin, createInput(entities), ); expect(totalCount).toBe(4); const nonAdminCount: - | DenormalizeNullable + | DenormalizeNullable | symbol = denormalize( - inferResults( - userCountByAdmin.schema, - [{ isAdmin: false }], - {}, - entities, - ), - userCountByAdmin.schema, + inferResults(userCountByAdmin, [{ isAdmin: false }], {}, entities), + userCountByAdmin, createInput(entities), {}, new WeakEntityMap(), @@ -179,15 +174,10 @@ describe.each([ ); expect(nonAdminCount).toBe(3); const adminCount: - | DenormalizeNullable + | DenormalizeNullable | symbol = denormalize( - inferResults( - userCountByAdmin.schema, - [{ isAdmin: true }], - {}, - entities, - ), - userCountByAdmin.schema, + inferResults(userCountByAdmin, [{ isAdmin: true }], {}, entities), + userCountByAdmin, createInput(entities), {}, new WeakEntityMap(), @@ -206,7 +196,7 @@ describe.each([ }); describe('top level schema', () => { - const sortedUsers = new Query( + const sortedUsers = new schema.Query( new schema.Collection([User]), (results, { asc } = { asc: false }) => { if (!results) return results; @@ -228,12 +218,11 @@ describe('top level schema', () => { [new schema.Collection([User]).pk({}, undefined, '', [])]: [1, 2, 3, 4], }, }; - const users: DenormalizeNullable | symbol = - denormalize( - inferResults(sortedUsers.schema, [], {}, entities), - sortedUsers.schema, - entities, - ); + const users: DenormalizeNullable | symbol = denormalize( + inferResults(sortedUsers, [], {}, entities), + sortedUsers, + entities, + ); expect(users).not.toEqual(expect.any(Symbol)); if (typeof users === 'symbol') return; expect(users && users[0].name).toBe('Zeta'); @@ -247,9 +236,9 @@ describe('top level schema', () => { 2: { id: '2', name: 'Jake' }, }, }; - const input = inferResults(sortedUsers.schema, [], {}, entities); + const input = inferResults(sortedUsers, [], {}, entities); - const value = denormalize(input, sortedUsers.schema, entities); + const value = denormalize(input, sortedUsers, entities); expect(value).toEqual(undefined); }); diff --git a/packages/normalizr/src/types.ts b/packages/normalizr/src/types.ts index a65185e0b4cf..6cd5368ce7ff 100644 --- a/packages/normalizr/src/types.ts +++ b/packages/normalizr/src/types.ts @@ -130,7 +130,7 @@ export type NormalizedSchema = { export type EntityMap = Record>; -export type SchemaArgs = +export type SchemaArgs = S extends EntityInterface ? EntityFields : S extends ( { @@ -143,4 +143,4 @@ export type SchemaArgs = } ) ? Args - : []; + : unknown; diff --git a/packages/react/src/__tests__/integration-endpoint.web.tsx b/packages/react/src/__tests__/integration-endpoint.web.tsx index 010eb9c81971..7ba4f1557d32 100644 --- a/packages/react/src/__tests__/integration-endpoint.web.tsx +++ b/packages/react/src/__tests__/integration-endpoint.web.tsx @@ -1,4 +1,4 @@ -import { schema, Entity, Query } from '@data-client/endpoint'; +import { schema, Entity } from '@data-client/endpoint'; import { Endpoint } from '@data-client/endpoint'; import { CacheProvider } from '@data-client/react'; import { CacheProvider as ExternalCacheProvider } from '@data-client/redux'; @@ -20,7 +20,13 @@ import nock from 'nock'; // relative imports to avoid circular dependency in tsconfig references import { makeRenderDataClient, act } from '../../../test'; -import { useCache, useController, useFetch, useSuspense } from '../hooks'; +import { + useCache, + useController, + useFetch, + useQuery, + useSuspense, +} from '../hooks'; import { payload, createPayload, @@ -238,11 +244,11 @@ describe.each([ const getList = CoolerArticleResource.getList.extend({ schema: new schema.All(CoolerArticle), }); - const queryArticle = new Query(new schema.All(CoolerArticle)); + const queryArticle = new schema.Query(new schema.All(CoolerArticle)); const { result, waitForNextUpdate } = renderDataClient(() => { useFetch(getList); - return useCache(queryArticle); + return useQuery(queryArticle); }); expect(result.current).toBeUndefined(); await waitForNextUpdate(); @@ -256,7 +262,7 @@ describe.each([ }); it('should filter Query based on arguments', async () => { - const queryArticle = new Query( + const queryArticle = new schema.Query( new schema.All(CoolerArticle), (entries, { tags }: { tags: string }) => { if (!tags) return entries; @@ -268,8 +274,8 @@ describe.each([ renderDataClient( ({ tags }: { tags: string }) => { useFetch(CoolerArticleResource.getList); - const data = useCache(queryArticle, { tags }); - return useCache(queryArticle, { tags }); + const data = useQuery(queryArticle, { tags }); + return useQuery(queryArticle, { tags }); }, { initialProps: { tags: 'a' } }, ); diff --git a/packages/react/src/hooks/useCache.ts b/packages/react/src/hooks/useCache.ts index 38950da490cc..1cf04228ea55 100644 --- a/packages/react/src/hooks/useCache.ts +++ b/packages/react/src/hooks/useCache.ts @@ -23,9 +23,10 @@ export default function useCache< EndpointInterface, 'key' | 'schema' | 'invalidIfStale' >, + Args extends readonly [...Parameters] | readonly [null], >( endpoint: E, - ...args: readonly [...Parameters] | readonly [null] + ...args: Args ): E['schema'] extends undefined | null ? E extends (...args: any) => any ? ResolveType | undefined @@ -40,6 +41,7 @@ export default function useCache< // Compute denormalized value const { data, expiryStatus, expiresAt } = useMemo(() => { + // @ts-ignore return controller.getResponse(endpoint, ...args, state) as { data: DenormalizeNullable; expiryStatus: ExpiryStatus; @@ -74,6 +76,7 @@ export default function useCache< // if useSuspense() would suspend, don't include entities from cache if (wouldSuspend) { if (!endpoint.schema) return undefined; + // @ts-ignore return controller.getResponse(endpoint, ...args, { ...state, entities: {}, diff --git a/packages/rest/src/__tests__/createResource.test.ts b/packages/rest/src/__tests__/createResource.test.ts index 89e045c1df1c..8ae8d0869541 100644 --- a/packages/rest/src/__tests__/createResource.test.ts +++ b/packages/rest/src/__tests__/createResource.test.ts @@ -1,10 +1,11 @@ -import { Entity, schema } from '@data-client/endpoint'; +import { Entity, schema, SchemaArgs } from '@data-client/endpoint'; import { CacheProvider, useCache, useController, Controller, useSuspense, + useQuery, } from '@data-client/react'; import { makeRenderDataClient } from '@data-client/test'; import { act } from '@testing-library/react-hooks'; @@ -1094,4 +1095,41 @@ describe('createResource()', () => { // this is set in our override expect(result.current[0].isAdmin).toBe(true); }); + + it('searchParams are used in Queries based on getList.schema', () => { + class Todo extends Entity { + id = ''; + readonly userId: number = 0; + readonly title: string = ''; + readonly completed: boolean = false; + + static key = 'Todo'; + + pk() { + return this.id; + } + } + + const TodoResource = createResource({ + path: '/todos/:id', + schema: Todo, + optimistic: true, + searchParams: {} as { userId?: string | number } | undefined, + }); + + const queryRemainingTodos = new schema.Query( + TodoResource.getList.schema, + entries => entries && entries.filter(todo => !todo.completed).length, + ); + + () => useQuery(queryRemainingTodos, { userId: 1 }); + // TODO + // () => useQuery(queryRemainingTodos); + // @ts-expect-error + () => useQuery(queryRemainingTodos, { user: 1 }); + // @ts-expect-error + () => useQuery(queryRemainingTodos, 5); + // @ts-expect-error + () => useQuery(queryRemainingTodos, { userId: 1 }, 5); + }); }); diff --git a/website/src/components/Demo/code/posts-app/graphql/api.ts b/website/src/components/Demo/code/posts-app/graphql/api.ts index 572af8bdd6f5..4343ecabff6a 100644 --- a/website/src/components/Demo/code/posts-app/graphql/api.ts +++ b/website/src/components/Demo/code/posts-app/graphql/api.ts @@ -1,9 +1,4 @@ -import { - GQLEndpoint, - GQLEntity, - Query, - schema, -} from '@data-client/graphql'; +import { GQLEndpoint, GQLEntity, schema } from '@data-client/graphql'; const gql = new GQLEndpoint('/'); @@ -48,7 +43,7 @@ export const TodoResource = { }`, { updateTodo: Todo }, ), - queryRemaining: new Query( + queryRemaining: new schema.Query( new schema.All(Todo), (entries, { userId } = {}) => { if (userId !== undefined) diff --git a/website/src/components/Demo/code/todo-app/graphql/api.ts b/website/src/components/Demo/code/todo-app/graphql/api.ts index 572af8bdd6f5..4343ecabff6a 100644 --- a/website/src/components/Demo/code/todo-app/graphql/api.ts +++ b/website/src/components/Demo/code/todo-app/graphql/api.ts @@ -1,9 +1,4 @@ -import { - GQLEndpoint, - GQLEntity, - Query, - schema, -} from '@data-client/graphql'; +import { GQLEndpoint, GQLEntity, schema } from '@data-client/graphql'; const gql = new GQLEndpoint('/'); @@ -48,7 +43,7 @@ export const TodoResource = { }`, { updateTodo: Todo }, ), - queryRemaining: new Query( + queryRemaining: new schema.Query( new schema.All(Todo), (entries, { userId } = {}) => { if (userId !== undefined)