diff --git a/.changeset/plenty-regions-visit.md b/.changeset/plenty-regions-visit.md new file mode 100644 index 000000000000..68b502b3438c --- /dev/null +++ b/.changeset/plenty-regions-visit.md @@ -0,0 +1,27 @@ +--- +'@data-client/normalizr': patch +'@data-client/endpoint': patch +'@data-client/react': patch +'@data-client/core': patch +'@data-client/rest': patch +'@data-client/graphql': patch +--- + +Normalize delegate.invalidate() first argument only has `key` param. + +`indexes` optional param no longer provided as it was never used. + + +```ts +normalize( + input: any, + parent: any, + key: string | undefined, + args: any[], + visit: (...args: any) => any, + delegate: INormalizeDelegate, +): string { + delegate.invalidate({ key: this._entity.key }, pk); + return pk; +} +``` \ No newline at end of file diff --git a/.changeset/weak-grapes-kiss.md b/.changeset/weak-grapes-kiss.md new file mode 100644 index 000000000000..69e5aedfdb78 --- /dev/null +++ b/.changeset/weak-grapes-kiss.md @@ -0,0 +1,25 @@ +--- +'@data-client/endpoint': patch +'@data-client/rest': patch +'@data-client/graphql': patch +--- + +Unions can query() without type discriminator + +#### Before +```tsx +// @ts-expect-error +const event = useQuery(EventUnion, { id }); +// event is undefined +const newsEvent = useQuery(EventUnion, { id, type: 'news' }); +// newsEvent is found +``` + +#### After + +```tsx +const event = useQuery(EventUnion, { id }); +// event is found +const newsEvent = useQuery(EventUnion, { id, type: 'news' }); +// newsEvent is found +``` \ No newline at end of file diff --git a/packages/core/src/controller/__tests__/__snapshots__/get.ts.snap b/packages/core/src/controller/__tests__/__snapshots__/get.ts.snap index 270ae2b48d43..0cd336fe2e22 100644 --- a/packages/core/src/controller/__tests__/__snapshots__/get.ts.snap +++ b/packages/core/src/controller/__tests__/__snapshots__/get.ts.snap @@ -53,6 +53,23 @@ Group { } `; +exports[`Controller.get() Union based on args with function schemaAttribute 1`] = ` +User { + "id": "1", + "type": "users", + "username": "bob", +} +`; + +exports[`Controller.get() Union based on args with function schemaAttribute 2`] = ` +Group { + "groupname": "fast", + "id": "2", + "memberCount": 5, + "type": "groups", +} +`; + exports[`Controller.get() indexes query Entity based on index 1`] = ` User { "id": "1", diff --git a/packages/core/src/controller/__tests__/get.ts b/packages/core/src/controller/__tests__/get.ts index ef5824a36f07..36dca26a824f 100644 --- a/packages/core/src/controller/__tests__/get.ts +++ b/packages/core/src/controller/__tests__/get.ts @@ -295,11 +295,11 @@ describe('Controller.get()', () => { id: string = ''; } class User extends IDEntity { - type = 'user'; + type = 'users'; username: string = ''; } class Group extends IDEntity { - type = 'group'; + type = 'groups'; groupname: string = ''; memberCount = 0; } @@ -349,6 +349,70 @@ describe('Controller.get()', () => { // @ts-expect-error () => controller.get(queryPerson, { id: '1', doesnotexist: 5 }, state); }); + + it('Union based on args with function schemaAttribute', () => { + class IDEntity extends Entity { + id: string = ''; + } + class User extends IDEntity { + type = 'user'; + username: string = ''; + } + class Group extends IDEntity { + type = 'group'; + groupname: string = ''; + memberCount = 0; + } + const queryPerson = new schema.Union( + { + users: User, + groups: Group, + }, + (value: { type: 'users' | 'groups' }) => value.type, + ); + const controller = new Controller(); + const state = { + ...initialState, + entities: { + User: { + '1': { id: '1', type: 'users', username: 'bob' }, + }, + Group: { + '2': { id: '2', type: 'groups', groupname: 'fast', memberCount: 5 }, + }, + }, + }; + const user = controller.get(queryPerson, { id: '1', type: 'users' }, state); + expect(user).toBeDefined(); + expect(user).toBeInstanceOf(User); + expect(user).toMatchSnapshot(); + const group = controller.get( + queryPerson, + { id: '2', type: 'groups' }, + state, + ); + expect(group).toBeDefined(); + expect(group).toBeInstanceOf(Group); + expect(group).toMatchSnapshot(); + + // should maintain referential equality + expect(user).toBe( + controller.get(queryPerson, { id: '1', type: 'users' }, state), + ); + + // these are the 'fallback case' where it cannot determine type discriminator, so just enumerates + () => controller.get(queryPerson, { id: '1' }, state); + // @ts-expect-error + () => controller.get(queryPerson, { id: '1', type: 'notrealtype' }, state); + // @ts-expect-error + () => controller.get(queryPerson, { id: { bob: 5 }, type: 'users' }, state); + // @ts-expect-error + expect(controller.get(queryPerson, 5, state)).toBeUndefined(); + // @ts-expect-error + () => controller.get(queryPerson, { doesnotexist: 5 }, state); + // @ts-expect-error + () => controller.get(queryPerson, { id: '1', doesnotexist: 5 }, state); + }); }); describe('Snapshot.getQueryMeta()', () => { diff --git a/packages/endpoint/src/interface.ts b/packages/endpoint/src/interface.ts index 1f1bd52ecbff..a5744e1a0912 100644 --- a/packages/endpoint/src/interface.ts +++ b/packages/endpoint/src/interface.ts @@ -177,7 +177,7 @@ export interface INormalizeDelegate { meta?: { fetchedAt: number; date: number; expiresAt: number }, ): void; /** Invalidates an entity, potentially triggering suspense */ - invalidate(schema: { key: string; indexes?: any }, pk: string): void; + invalidate(schema: { key: string }, pk: string): void; /** Returns true when we're in a cycle, so we should not continue recursing */ checkLoop(key: string, pk: string, input: object): boolean; } diff --git a/packages/endpoint/src/schema.d.ts b/packages/endpoint/src/schema.d.ts index 31c0c19431bc..cad355134502 100644 --- a/packages/endpoint/src/schema.d.ts +++ b/packages/endpoint/src/schema.d.ts @@ -229,7 +229,7 @@ export interface UnionConstructor { schemaAttribute: SchemaAttribute, ): UnionInstance< Choices, - UnionSchemaToArgs & + Partial> & Partial> >; diff --git a/packages/endpoint/src/schemas/Invalidate.ts b/packages/endpoint/src/schemas/Invalidate.ts index 90e0f5618855..40e8f0b686ff 100644 --- a/packages/endpoint/src/schemas/Invalidate.ts +++ b/packages/endpoint/src/schemas/Invalidate.ts @@ -34,7 +34,7 @@ export default class Invalidate< this._entity = entity; } - get key() { + get key(): string { return this._entity.key; } @@ -73,7 +73,7 @@ export default class Invalidate< // any queued updates are meaningless with delete, so we should just set it // and creates will have a different pk - delegate.invalidate(this as any, pk); + delegate.invalidate({ key: this._entity.key }, pk); return pk; } @@ -86,6 +86,7 @@ export default class Invalidate< args: readonly any[], unvisit: (schema: any, input: any) => any, ): AbstractInstanceType { + // TODO: is this really always going to be the full object - validate that calling fetch will give this even when input is a string return unvisit(this._entity, id) as any; } diff --git a/packages/endpoint/src/schemas/Polymorphic.ts b/packages/endpoint/src/schemas/Polymorphic.ts index 8f14b5a37dec..1ff35b7a5b9f 100644 --- a/packages/endpoint/src/schemas/Polymorphic.ts +++ b/packages/endpoint/src/schemas/Polymorphic.ts @@ -103,7 +103,7 @@ Value: ${JSON.stringify(value, undefined, 2)}`, /* istanbul ignore else */ if (process.env.NODE_ENV !== 'production' && value) { console.warn( - `TypeError: Unable to infer schema for ${this.constructor.name} + `TypeError: Unable to determine schema for ${this.constructor.name} Value: ${JSON.stringify(value, undefined, 2)}.`, ); } diff --git a/packages/endpoint/src/schemas/Union.ts b/packages/endpoint/src/schemas/Union.ts index ec14ea508fa4..12679c497d31 100644 --- a/packages/endpoint/src/schemas/Union.ts +++ b/packages/endpoint/src/schemas/Union.ts @@ -29,13 +29,23 @@ export default class UnionSchema extends PolymorphicSchema { queryKey(args: any, unvisit: (schema: any, args: any) => any) { if (!args[0]) return; + // Often we have sufficient information in the first arg like { id, type } const schema = this.getSchemaAttribute(args[0], undefined, ''); const discriminatedSchema = this.schema[schema]; - // Was unable to infer the entity's schema from params - if (discriminatedSchema === undefined) return; - const id = unvisit(discriminatedSchema, args); - if (id === undefined) return; - return { id, schema }; + // Fast case - args include type discriminator + if (discriminatedSchema) { + const id = unvisit(discriminatedSchema, args); + if (id === undefined) return; + return { id, schema }; + } + + // Fallback to trying every possible schema if it cannot be determined + for (const key in this.schema) { + const id = unvisit(this.schema[key], args); + if (id !== undefined) { + return { id, schema: key }; + } + } } } diff --git a/packages/endpoint/src/schemas/__tests__/Union.test.js b/packages/endpoint/src/schemas/__tests__/Union.test.js index 7cfa892b4f7d..dfad34ef55dd 100644 --- a/packages/endpoint/src/schemas/__tests__/Union.test.js +++ b/packages/endpoint/src/schemas/__tests__/Union.test.js @@ -73,6 +73,166 @@ describe(`${schema.Union.name} normalization`, () => { }); }); +describe(`${schema.Union.name} buildQueryKey`, () => { + class User extends IDEntity { + static key = 'User'; + } + class Group extends IDEntity { + static key = 'Group'; + } + + // Common schema definitions + const stringAttributeUnion = new schema.Union( + { + users: User, + groups: Group, + }, + 'type', + ); + + const functionAttributeUnion = new schema.Union( + { + users: User, + groups: Group, + }, + input => { + return ( + input.username ? 'users' + : input.groupname ? 'groups' + : undefined + ); + }, + ); + + test('buildQueryKey with discriminator in args', () => { + const memo = new SimpleMemoCache(); + + const state = { + entities: { + User: { + 1: { id: '1', username: 'Janey', type: 'users' }, + }, + Group: { + 2: { id: '2', groupname: 'People', type: 'groups' }, + }, + }, + indexes: {}, + }; + + // Fast case - args include type discriminator + const result1 = memo.memo.buildQueryKey( + stringAttributeUnion, + [{ id: '1', type: 'users' }], + state, + ); + expect(result1).toEqual({ id: '1', schema: 'users' }); + + const result2 = memo.memo.buildQueryKey( + stringAttributeUnion, + [{ id: '2', type: 'groups' }], + state, + ); + expect(result2).toEqual({ id: '2', schema: 'groups' }); + }); + + test('buildQueryKey without discriminator - fallback case', () => { + const memo = new SimpleMemoCache(); + + const state = { + entities: { + User: { + 1: { id: '1', username: 'Janey', type: 'users' }, + }, + Group: { + 2: { id: '2', groupname: 'People', type: 'groups' }, + }, + }, + indexes: {}, + }; + + // Fallback case - args missing type discriminator, only {id} + // Should try every possible schema until it finds a match + const result1 = memo.memo.buildQueryKey( + stringAttributeUnion, + [{ id: '1' }], + state, + ); + expect(result1).toEqual({ id: '1', schema: 'users' }); + + const result2 = memo.memo.buildQueryKey( + stringAttributeUnion, + [{ id: '2' }], + state, + ); + expect(result2).toEqual({ id: '2', schema: 'groups' }); + }); + + test('buildQueryKey with function schemaAttribute missing discriminator', () => { + const memo = new SimpleMemoCache(); + + const state = { + entities: { + User: { + 1: { id: '1', username: 'Janey' }, + }, + Group: { + 2: { id: '2', groupname: 'People' }, + }, + }, + indexes: {}, + }; + + // With function schemaAttribute, args missing username/groupname + // Should fallback to trying every schema + const result1 = memo.memo.buildQueryKey( + functionAttributeUnion, + [{ id: '1' }], + state, + ); + expect(result1).toEqual({ id: '1', schema: 'users' }); + + const result2 = memo.memo.buildQueryKey( + functionAttributeUnion, + [{ id: '2' }], + state, + ); + expect(result2).toEqual({ id: '2', schema: 'groups' }); + }); + + test('buildQueryKey returns undefined when no entity found', () => { + const memo = new SimpleMemoCache(); + + const state = { + entities: { + User: {}, + Group: {}, + }, + indexes: {}, + }; + + // No entity exists with id '999' + const result = memo.memo.buildQueryKey( + stringAttributeUnion, + [{ id: '999' }], + state, + ); + expect(result).toBeUndefined(); + }); + + test('buildQueryKey returns undefined when no args provided', () => { + const memo = new SimpleMemoCache(); + + const state = { + entities: {}, + indexes: {}, + }; + + // No args provided + const result = memo.memo.buildQueryKey(stringAttributeUnion, [], state); + expect(result).toBeUndefined(); + }); +}); + describe('complex case', () => { test('works with undefined', () => { const response = { diff --git a/packages/endpoint/src/schemas/__tests__/__snapshots__/Union.test.js.snap b/packages/endpoint/src/schemas/__tests__/__snapshots__/Union.test.js.snap index bad1861764bb..a3b519d81641 100644 --- a/packages/endpoint/src/schemas/__tests__/__snapshots__/Union.test.js.snap +++ b/packages/endpoint/src/schemas/__tests__/__snapshots__/Union.test.js.snap @@ -546,7 +546,7 @@ exports[`input (direct) UnionSchema denormalization (current) returns the origin exports[`input (direct) UnionSchema denormalization (current) returns the original value when no schema is given 2`] = ` [ [ - "TypeError: Unable to infer schema for UnionSchema + "TypeError: Unable to determine schema for UnionSchema Value: { "id": "1" }.", @@ -559,7 +559,7 @@ exports[`input (direct) UnionSchema denormalization (current) returns the origin exports[`input (direct) UnionSchema denormalization (current) returns the original value when string is given 2`] = ` [ [ - "TypeError: Unable to infer schema for UnionSchema + "TypeError: Unable to determine schema for UnionSchema Value: "1".", ], ] @@ -606,7 +606,7 @@ Immutable.Map { exports[`input (immutable) UnionSchema denormalization (current) returns the original value when no schema is given 2`] = ` [ [ - "TypeError: Unable to infer schema for UnionSchema + "TypeError: Unable to determine schema for UnionSchema Value: { "id": "1" }.", @@ -619,7 +619,7 @@ exports[`input (immutable) UnionSchema denormalization (current) returns the ori exports[`input (immutable) UnionSchema denormalization (current) returns the original value when string is given 2`] = ` [ [ - "TypeError: Unable to infer schema for UnionSchema + "TypeError: Unable to determine schema for UnionSchema Value: "1".", ], ] diff --git a/packages/normalizr/src/interface.ts b/packages/normalizr/src/interface.ts index b29045dc3462..20174c889251 100644 --- a/packages/normalizr/src/interface.ts +++ b/packages/normalizr/src/interface.ts @@ -164,7 +164,7 @@ export interface INormalizeDelegate { meta?: { fetchedAt: number; date: number; expiresAt: number }, ): void; /** Invalidates an entity, potentially triggering suspense */ - invalidate(schema: { key: string; indexes?: any }, pk: string): void; + invalidate(schema: { key: string }, pk: string): void; /** Returns true when we're in a cycle, so we should not continue recursing */ checkLoop(key: string, pk: string, input: object): boolean; } diff --git a/packages/normalizr/src/normalize/NormalizeDelegate.ts b/packages/normalizr/src/normalize/NormalizeDelegate.ts index a4a1ad685300..2400bd90a8be 100644 --- a/packages/normalizr/src/normalize/NormalizeDelegate.ts +++ b/packages/normalizr/src/normalize/NormalizeDelegate.ts @@ -150,9 +150,9 @@ export class NormalizeDelegate } /** Invalidates an entity, potentially triggering suspense */ - invalidate(schema: { key: string; indexes?: any }, pk: string) { + invalidate({ key }: { key: string }, pk: string) { // set directly: any queued updates are meaningless with delete - this.setEntity(schema, pk, INVALID); + this.setEntity({ key }, pk, INVALID); } protected _setEntity(key: string, pk: string, entity: any) { diff --git a/packages/react/src/__tests__/__snapshots__/integration-collections.tsx.snap b/packages/react/src/__tests__/__snapshots__/integration-collections.tsx.snap index 038f9bc78c82..5a1664cc2a52 100644 --- a/packages/react/src/__tests__/__snapshots__/integration-collections.tsx.snap +++ b/packages/react/src/__tests__/__snapshots__/integration-collections.tsx.snap @@ -567,7 +567,7 @@ Value: { }", ], [ - "TypeError: Unable to infer schema for ArraySchema + "TypeError: Unable to determine schema for ArraySchema Value: { "id": "6", "body": "hi", @@ -575,7 +575,7 @@ Value: { }.", ], [ - "TypeError: Unable to infer schema for ArraySchema + "TypeError: Unable to determine schema for ArraySchema Value: { "id": "7", "body": "hi" @@ -1178,7 +1178,7 @@ Value: { }", ], [ - "TypeError: Unable to infer schema for ArraySchema + "TypeError: Unable to determine schema for ArraySchema Value: { "id": "6", "body": "hi", @@ -1186,7 +1186,7 @@ Value: { }.", ], [ - "TypeError: Unable to infer schema for ArraySchema + "TypeError: Unable to determine schema for ArraySchema Value: { "id": "7", "body": "hi" diff --git a/packages/react/src/__tests__/__snapshots__/integration-endpoint.web.tsx.snap b/packages/react/src/__tests__/__snapshots__/integration-endpoint.web.tsx.snap index f0fc2b1d4f37..9bd33075cf41 100644 --- a/packages/react/src/__tests__/__snapshots__/integration-endpoint.web.tsx.snap +++ b/packages/react/src/__tests__/__snapshots__/integration-endpoint.web.tsx.snap @@ -175,7 +175,7 @@ Value: { }", ], [ - "TypeError: Unable to infer schema for UnionSchema + "TypeError: Unable to determine schema for UnionSchema Value: { "id": "5", "body": "hi", @@ -183,7 +183,7 @@ Value: { }.", ], [ - "TypeError: Unable to infer schema for UnionSchema + "TypeError: Unable to determine schema for UnionSchema Value: { "id": "5", "body": "hi" @@ -394,7 +394,7 @@ Value: { }", ], [ - "TypeError: Unable to infer schema for UnionSchema + "TypeError: Unable to determine schema for UnionSchema Value: { "id": "5", "body": "hi", @@ -402,7 +402,7 @@ Value: { }.", ], [ - "TypeError: Unable to infer schema for UnionSchema + "TypeError: Unable to determine schema for UnionSchema Value: { "id": "5", "body": "hi" diff --git a/packages/react/src/hooks/__tests__/__snapshots__/useQuery.tsx.snap b/packages/react/src/hooks/__tests__/__snapshots__/useQuery.tsx.snap index 0bba746f2a6d..02fc321aa41a 100644 --- a/packages/react/src/hooks/__tests__/__snapshots__/useQuery.tsx.snap +++ b/packages/react/src/hooks/__tests__/__snapshots__/useQuery.tsx.snap @@ -81,7 +81,7 @@ Value: { }", ], [ - "TypeError: Unable to infer schema for ArraySchema + "TypeError: Unable to determine schema for ArraySchema Value: { "id": "6", "body": "hi", @@ -89,7 +89,7 @@ Value: { }.", ], [ - "TypeError: Unable to infer schema for ArraySchema + "TypeError: Unable to determine schema for ArraySchema Value: { "id": "7", "body": "hi" diff --git a/packages/react/src/hooks/__tests__/useController/__snapshots__/fetch.tsx.snap b/packages/react/src/hooks/__tests__/useController/__snapshots__/fetch.tsx.snap index 8fff3552dec2..f0dfaf24eb20 100644 --- a/packages/react/src/hooks/__tests__/useController/__snapshots__/fetch.tsx.snap +++ b/packages/react/src/hooks/__tests__/useController/__snapshots__/fetch.tsx.snap @@ -28,7 +28,7 @@ Value: { }", ], [ - "TypeError: Unable to infer schema for UnionSchema + "TypeError: Unable to determine schema for UnionSchema Value: { "id": "6", "body": "hi", @@ -36,7 +36,7 @@ Value: { }.", ], [ - "TypeError: Unable to infer schema for UnionSchema + "TypeError: Unable to determine schema for UnionSchema Value: { "id": "7", "body": "hi" @@ -73,7 +73,7 @@ Value: { }", ], [ - "TypeError: Unable to infer schema for UnionSchema + "TypeError: Unable to determine schema for UnionSchema Value: { "id": "6", "body": "hi", @@ -81,7 +81,7 @@ Value: { }.", ], [ - "TypeError: Unable to infer schema for UnionSchema + "TypeError: Unable to determine schema for UnionSchema Value: { "id": "7", "body": "hi" diff --git a/packages/react/src/hooks/__tests__/useQuery.tsx b/packages/react/src/hooks/__tests__/useQuery.tsx index 5d2d01c11ad1..6c7fd887d038 100644 --- a/packages/react/src/hooks/__tests__/useQuery.tsx +++ b/packages/react/src/hooks/__tests__/useQuery.tsx @@ -260,12 +260,12 @@ describe('useQuery()', () => { expect(result.current.type).toBe('first'); expect(result.current).toBeInstanceOf(FirstUnion); - // @ts-expect-error - () => useQuery(UnionResource.get.schema, { type: 'notvalid' }); - // @ts-expect-error + // these are the 'fallback case' where it cannot determine type discriminator, so just enumerates () => useQuery(UnionResource.get.schema, { id: '5' }); - // @ts-expect-error () => useQuery(UnionResource.get.schema, { body: '5' }); + + // @ts-expect-error + () => useQuery(UnionResource.get.schema, { id: '5', type: 'notvalid' }); // @ts-expect-error () => useQuery(UnionResource.get.schema, { doesnotexist: '5' }); // @ts-expect-error diff --git a/packages/rest/src/__tests__/__snapshots__/createResource.test.ts.snap b/packages/rest/src/__tests__/__snapshots__/resource-construction.test.ts.snap similarity index 100% rename from packages/rest/src/__tests__/__snapshots__/createResource.test.ts.snap rename to packages/rest/src/__tests__/__snapshots__/resource-construction.test.ts.snap diff --git a/packages/rest/src/__tests__/createResource.test.ts b/packages/rest/src/__tests__/resource-construction.test.ts similarity index 99% rename from packages/rest/src/__tests__/createResource.test.ts rename to packages/rest/src/__tests__/resource-construction.test.ts index 068b5e6c8dc9..18fbfa5198e9 100644 --- a/packages/rest/src/__tests__/createResource.test.ts +++ b/packages/rest/src/__tests__/resource-construction.test.ts @@ -939,9 +939,9 @@ describe('resource()', () => { // @ts-expect-error useQuery(FeedUnion, { id: '5', typed: 'link' }); // @ts-expect-error - useQuery(FeedUnion, { id: '5' }); - // @ts-expect-error useQuery(FeedUnion, { id: '5', type: 'bob' }); + // these are the 'fallback case' where it cannot determine type discriminator, so just enumerates + useQuery(FeedUnion, { id: '5' }); }; () =>