Skip to content

Commit fae1062

Browse files
committed
feat: Unions can query() without type discriminator
1 parent beaa03e commit fae1062

File tree

7 files changed

+287
-12
lines changed

7 files changed

+287
-12
lines changed

.changeset/weak-grapes-kiss.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@data-client/endpoint': minor
3+
'@data-client/rest': minor
4+
---
5+
6+
Unions can query() without type discriminator
7+
8+
#### Before
9+
```tsx
10+
// @ts-expect-error
11+
const event = useQuery(EventUnion, { id });
12+
// event is undefined
13+
const newsEvent = useQuery(EventUnion, { id, type: 'news' });
14+
// newsEvent is found
15+
```
16+
17+
#### After
18+
19+
```tsx
20+
const event = useQuery(EventUnion, { id });
21+
// event is found
22+
const newsEvent = useQuery(EventUnion, { id, type: 'news' });
23+
// newsEvent is found
24+
```

packages/core/src/controller/__tests__/__snapshots__/get.ts.snap

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,23 @@ Group {
5353
}
5454
`;
5555

56+
exports[`Controller.get() Union based on args with function schemaAttribute 1`] = `
57+
User {
58+
"id": "1",
59+
"type": "users",
60+
"username": "bob",
61+
}
62+
`;
63+
64+
exports[`Controller.get() Union based on args with function schemaAttribute 2`] = `
65+
Group {
66+
"groupname": "fast",
67+
"id": "2",
68+
"memberCount": 5,
69+
"type": "groups",
70+
}
71+
`;
72+
5673
exports[`Controller.get() indexes query Entity based on index 1`] = `
5774
User {
5875
"id": "1",

packages/core/src/controller/__tests__/get.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -295,11 +295,11 @@ describe('Controller.get()', () => {
295295
id: string = '';
296296
}
297297
class User extends IDEntity {
298-
type = 'user';
298+
type = 'users';
299299
username: string = '';
300300
}
301301
class Group extends IDEntity {
302-
type = 'group';
302+
type = 'groups';
303303
groupname: string = '';
304304
memberCount = 0;
305305
}
@@ -349,6 +349,70 @@ describe('Controller.get()', () => {
349349
// @ts-expect-error
350350
() => controller.get(queryPerson, { id: '1', doesnotexist: 5 }, state);
351351
});
352+
353+
it('Union based on args with function schemaAttribute', () => {
354+
class IDEntity extends Entity {
355+
id: string = '';
356+
}
357+
class User extends IDEntity {
358+
type = 'user';
359+
username: string = '';
360+
}
361+
class Group extends IDEntity {
362+
type = 'group';
363+
groupname: string = '';
364+
memberCount = 0;
365+
}
366+
const queryPerson = new schema.Union(
367+
{
368+
users: User,
369+
groups: Group,
370+
},
371+
(value: { type: 'users' | 'groups' }) => value.type,
372+
);
373+
const controller = new Controller();
374+
const state = {
375+
...initialState,
376+
entities: {
377+
User: {
378+
'1': { id: '1', type: 'users', username: 'bob' },
379+
},
380+
Group: {
381+
'2': { id: '2', type: 'groups', groupname: 'fast', memberCount: 5 },
382+
},
383+
},
384+
};
385+
const user = controller.get(queryPerson, { id: '1', type: 'users' }, state);
386+
expect(user).toBeDefined();
387+
expect(user).toBeInstanceOf(User);
388+
expect(user).toMatchSnapshot();
389+
const group = controller.get(
390+
queryPerson,
391+
{ id: '2', type: 'groups' },
392+
state,
393+
);
394+
expect(group).toBeDefined();
395+
expect(group).toBeInstanceOf(Group);
396+
expect(group).toMatchSnapshot();
397+
398+
// should maintain referential equality
399+
expect(user).toBe(
400+
controller.get(queryPerson, { id: '1', type: 'users' }, state),
401+
);
402+
403+
// these are the 'fallback case' where it cannot determine type discriminator, so just enumerates
404+
() => controller.get(queryPerson, { id: '1' }, state);
405+
// @ts-expect-error
406+
() => controller.get(queryPerson, { id: '1', type: 'notrealtype' }, state);
407+
// @ts-expect-error
408+
() => controller.get(queryPerson, { id: { bob: 5 }, type: 'users' }, state);
409+
// @ts-expect-error
410+
expect(controller.get(queryPerson, 5, state)).toBeUndefined();
411+
// @ts-expect-error
412+
() => controller.get(queryPerson, { doesnotexist: 5 }, state);
413+
// @ts-expect-error
414+
() => controller.get(queryPerson, { id: '1', doesnotexist: 5 }, state);
415+
});
352416
});
353417

354418
describe('Snapshot.getQueryMeta()', () => {

packages/endpoint/src/schema.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ export interface UnionConstructor {
229229
schemaAttribute: SchemaAttribute,
230230
): UnionInstance<
231231
Choices,
232-
UnionSchemaToArgs<Choices, SchemaAttribute> &
232+
Partial<UnionSchemaToArgs<Choices, SchemaAttribute>> &
233233
Partial<AbstractInstanceType<Choices[keyof Choices]>>
234234
>;
235235

packages/endpoint/src/schemas/Union.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,23 @@ export default class UnionSchema extends PolymorphicSchema {
2929

3030
queryKey(args: any, unvisit: (schema: any, args: any) => any) {
3131
if (!args[0]) return;
32+
// Often we have sufficient information in the first arg like { id, type }
3233
const schema = this.getSchemaAttribute(args[0], undefined, '');
3334
const discriminatedSchema = this.schema[schema];
3435

35-
// Was unable to infer the entity's schema from params
36-
if (discriminatedSchema === undefined) return;
37-
const id = unvisit(discriminatedSchema, args);
38-
if (id === undefined) return;
39-
return { id, schema };
36+
// Fast case - args include type discriminator
37+
if (discriminatedSchema) {
38+
const id = unvisit(discriminatedSchema, args);
39+
if (id === undefined) return;
40+
return { id, schema };
41+
}
42+
43+
// Fallback to trying every possible schema if it cannot be determined
44+
for (const key in this.schema) {
45+
const id = unvisit(this.schema[key], args);
46+
if (id !== undefined) {
47+
return { id, schema: key };
48+
}
49+
}
4050
}
4151
}

packages/endpoint/src/schemas/__tests__/Union.test.js

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,166 @@ describe(`${schema.Union.name} normalization`, () => {
7373
});
7474
});
7575

76+
describe(`${schema.Union.name} buildQueryKey`, () => {
77+
class User extends IDEntity {
78+
static key = 'User';
79+
}
80+
class Group extends IDEntity {
81+
static key = 'Group';
82+
}
83+
84+
// Common schema definitions
85+
const stringAttributeUnion = new schema.Union(
86+
{
87+
users: User,
88+
groups: Group,
89+
},
90+
'type',
91+
);
92+
93+
const functionAttributeUnion = new schema.Union(
94+
{
95+
users: User,
96+
groups: Group,
97+
},
98+
input => {
99+
return (
100+
input.username ? 'users'
101+
: input.groupname ? 'groups'
102+
: undefined
103+
);
104+
},
105+
);
106+
107+
test('buildQueryKey with discriminator in args', () => {
108+
const memo = new SimpleMemoCache();
109+
110+
const state = {
111+
entities: {
112+
User: {
113+
1: { id: '1', username: 'Janey', type: 'users' },
114+
},
115+
Group: {
116+
2: { id: '2', groupname: 'People', type: 'groups' },
117+
},
118+
},
119+
indexes: {},
120+
};
121+
122+
// Fast case - args include type discriminator
123+
const result1 = memo.memo.buildQueryKey(
124+
stringAttributeUnion,
125+
[{ id: '1', type: 'users' }],
126+
state,
127+
);
128+
expect(result1).toEqual({ id: '1', schema: 'users' });
129+
130+
const result2 = memo.memo.buildQueryKey(
131+
stringAttributeUnion,
132+
[{ id: '2', type: 'groups' }],
133+
state,
134+
);
135+
expect(result2).toEqual({ id: '2', schema: 'groups' });
136+
});
137+
138+
test('buildQueryKey without discriminator - fallback case', () => {
139+
const memo = new SimpleMemoCache();
140+
141+
const state = {
142+
entities: {
143+
User: {
144+
1: { id: '1', username: 'Janey', type: 'users' },
145+
},
146+
Group: {
147+
2: { id: '2', groupname: 'People', type: 'groups' },
148+
},
149+
},
150+
indexes: {},
151+
};
152+
153+
// Fallback case - args missing type discriminator, only {id}
154+
// Should try every possible schema until it finds a match
155+
const result1 = memo.memo.buildQueryKey(
156+
stringAttributeUnion,
157+
[{ id: '1' }],
158+
state,
159+
);
160+
expect(result1).toEqual({ id: '1', schema: 'users' });
161+
162+
const result2 = memo.memo.buildQueryKey(
163+
stringAttributeUnion,
164+
[{ id: '2' }],
165+
state,
166+
);
167+
expect(result2).toEqual({ id: '2', schema: 'groups' });
168+
});
169+
170+
test('buildQueryKey with function schemaAttribute missing discriminator', () => {
171+
const memo = new SimpleMemoCache();
172+
173+
const state = {
174+
entities: {
175+
User: {
176+
1: { id: '1', username: 'Janey' },
177+
},
178+
Group: {
179+
2: { id: '2', groupname: 'People' },
180+
},
181+
},
182+
indexes: {},
183+
};
184+
185+
// With function schemaAttribute, args missing username/groupname
186+
// Should fallback to trying every schema
187+
const result1 = memo.memo.buildQueryKey(
188+
functionAttributeUnion,
189+
[{ id: '1' }],
190+
state,
191+
);
192+
expect(result1).toEqual({ id: '1', schema: 'users' });
193+
194+
const result2 = memo.memo.buildQueryKey(
195+
functionAttributeUnion,
196+
[{ id: '2' }],
197+
state,
198+
);
199+
expect(result2).toEqual({ id: '2', schema: 'groups' });
200+
});
201+
202+
test('buildQueryKey returns undefined when no entity found', () => {
203+
const memo = new SimpleMemoCache();
204+
205+
const state = {
206+
entities: {
207+
User: {},
208+
Group: {},
209+
},
210+
indexes: {},
211+
};
212+
213+
// No entity exists with id '999'
214+
const result = memo.memo.buildQueryKey(
215+
stringAttributeUnion,
216+
[{ id: '999' }],
217+
state,
218+
);
219+
expect(result).toBeUndefined();
220+
});
221+
222+
test('buildQueryKey returns undefined when no args provided', () => {
223+
const memo = new SimpleMemoCache();
224+
225+
const state = {
226+
entities: {},
227+
indexes: {},
228+
};
229+
230+
// No args provided
231+
const result = memo.memo.buildQueryKey(stringAttributeUnion, [], state);
232+
expect(result).toBeUndefined();
233+
});
234+
});
235+
76236
describe('complex case', () => {
77237
test('works with undefined', () => {
78238
const response = {

packages/react/src/hooks/__tests__/useQuery.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -260,12 +260,12 @@ describe('useQuery()', () => {
260260
expect(result.current.type).toBe('first');
261261
expect(result.current).toBeInstanceOf(FirstUnion);
262262

263-
// @ts-expect-error
264-
() => useQuery(UnionResource.get.schema, { type: 'notvalid' });
265-
// @ts-expect-error
263+
// these are the 'fallback case' where it cannot determine type discriminator, so just enumerates
266264
() => useQuery(UnionResource.get.schema, { id: '5' });
267-
// @ts-expect-error
268265
() => useQuery(UnionResource.get.schema, { body: '5' });
266+
267+
// @ts-expect-error
268+
() => useQuery(UnionResource.get.schema, { id: '5', type: 'notvalid' });
269269
// @ts-expect-error
270270
() => useQuery(UnionResource.get.schema, { doesnotexist: '5' });
271271
// @ts-expect-error

0 commit comments

Comments
 (0)