Skip to content

Commit 22b1b43

Browse files
committed
Add support for calling fields with arguments
GraphQL supports fields at arbitrary depths that take arguments. We already support passing args at the base-most level of a query, like with: ```typescript await api.widget.findMany({filter: { ... }}); ``` but, GraphQL supports sending args at any depth at all! For example, you might want to fetch a filtered list of widgets, and their child gizmos, and filter the list of gizmos for each widget as well! In GraphQL, this looks like: ```graphql query { widgets(filter: { inventoryCount: { greaterThan: 10 } }) { edges { node { id inventoryCount gizmos(first: 10, filter: { published: { equals: true } }) { edges { node { id published } } } } } } ``` We don't currently support this in the JS client, even though our GraphQL schema supports it fine. The main use case is adjusting how many child records you get back for each parent on a nested fetch, as well as filtering those child records, and asks in discord have cropped up a bunch for this. In this code snippet, where do you put arguments for gizmos?: ```typescript // before, no calls supported await api.widget.findMany({ select: { id: true, name: true, gizmos: { edges: { node: { id: true, published: true, } } } } }); ``` This adds a thing to do this just this! There's two parts to it: knowing which fields take arguments, which we currently don't really describe, and then adding a JS-land syntax for passing calls at an arbitrary place in the selection. I chose to do this with a new primitive that looks like this: ```typescript await api.widget.findMany({ select: { id: true, name: true, gizmos: Call({ first: 10, filter: { published: { equals: true } }, }, { edges: { node: { id: true, published: true, } } }) } }); ``` [no-changelog-required]
1 parent 073eac1 commit 22b1b43

File tree

9 files changed

+458
-10
lines changed

9 files changed

+458
-10
lines changed

packages/api-client-core/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@gadgetinc/api-client-core",
3-
"version": "0.15.11",
3+
"version": "0.15.12",
44
"files": [
55
"Readme.md",
66
"dist/**/*"
@@ -37,7 +37,7 @@
3737
"graphql-ws": "^5.13.1",
3838
"isomorphic-ws": "^5.0.0",
3939
"klona": "^2.0.6",
40-
"tiny-graphql-query-compiler": "^0.2.2",
40+
"tiny-graphql-query-compiler": "^0.2.3",
4141
"tslib": "^2.6.2",
4242
"ws": "^8.13.0"
4343
},

packages/api-client-core/spec/Select-type.spec.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { AssertTrue, IsExact } from "conditional-type-checks";
2+
import { Call } from "../src/FieldSelection.js";
23
import type { DeepFilterNever, Select } from "../src/types.js";
34
import type { TestSchema } from "./TestSchema.js";
45

@@ -75,5 +76,111 @@ describe("Select<>", () => {
7576
>
7677
>;
7778

79+
const argsSelection = {
80+
someConnection: Call({ first: 5 }, { pageInfo: { hasNextPage: true }, edges: { node: { id: true, state: true } } }),
81+
} as const;
82+
83+
type _withArgsSelection = Select<TestSchema, typeof argsSelection>;
84+
85+
type _TestSelectingConnectionWithArgs = AssertTrue<
86+
IsExact<
87+
_withArgsSelection,
88+
{
89+
someConnection: {
90+
pageInfo: { hasNextPage: boolean };
91+
edges: ({ node: { id: string; state: string } | null } | null)[] | null;
92+
};
93+
}
94+
>
95+
>;
96+
97+
const emptyArgsSelection = {
98+
someConnection: Call({}, { pageInfo: { hasNextPage: true }, edges: { node: { id: true, state: true } } }),
99+
} as const;
100+
101+
type _withEmptyArgsSelection = Select<TestSchema, typeof emptyArgsSelection>;
102+
103+
type _TestSelectingConnectionWithEmptyArgs = AssertTrue<
104+
IsExact<
105+
_withEmptyArgsSelection,
106+
{
107+
someConnection: {
108+
pageInfo: { hasNextPage: boolean };
109+
edges: ({ node: { id: string; state: string } | null } | null)[] | null;
110+
};
111+
}
112+
>
113+
>;
114+
115+
const wrongArgsSelection = {
116+
someConnection: Call({ first: "wrong type" }, { pageInfo: { hasNextPage: true }, edges: { node: { id: true, state: true } } }),
117+
} as const;
118+
type _withWrongArgsSelection = Select<TestSchema, typeof wrongArgsSelection>;
119+
type _TestSelectingConnectionWithWrongArgs = AssertTrue<
120+
IsExact<_withWrongArgsSelection, { someConnection: { $error: "incorrectly typed args passed when calling field" } }>
121+
>;
122+
123+
const doesntAcceptArgsSelection = {
124+
optionalObj: Call({ first: "wrong type" }, { test: true }),
125+
} as const;
126+
type _withDoesntAcceptArgsSelection = Select<TestSchema, typeof doesntAcceptArgsSelection>;
127+
type _TestSelectingFieldWhichDoesntAcceptArgs = AssertTrue<
128+
IsExact<_withDoesntAcceptArgsSelection, { optionalObj: { $error: "field does not accept args" } }>
129+
>;
130+
131+
const doesntAcceptArgsScalarSelection = {
132+
num: Call({ first: "wrong type" }, { test: true }),
133+
} as const;
134+
type _withDoesntAcceptArgsScalarSelection = Select<TestSchema, typeof doesntAcceptArgsScalarSelection>;
135+
type _TestSelectingScalarFieldWhichDoesntAcceptArgs = AssertTrue<
136+
IsExact<_withDoesntAcceptArgsScalarSelection, { num: { $error: "field does not accept args" } }>
137+
>;
138+
139+
const nestedCallSelection = {
140+
someConnection: Call(
141+
{ first: 5 },
142+
{
143+
edges: {
144+
node: {
145+
id: true,
146+
state: true,
147+
children: Call(
148+
{ first: 10 },
149+
{
150+
edges: {
151+
node: {
152+
id: true,
153+
},
154+
},
155+
}
156+
),
157+
},
158+
},
159+
}
160+
),
161+
} as const;
162+
const nestedCallNoSelection = {
163+
someConnection: {
164+
edges: {
165+
node: {
166+
id: true,
167+
state: true,
168+
children: {
169+
edges: {
170+
node: {
171+
id: true,
172+
},
173+
},
174+
},
175+
},
176+
},
177+
},
178+
} as const;
179+
180+
type _withNestedNoCallSelection = Select<TestSchema, typeof nestedCallNoSelection>;
181+
type _withNestedCallSelection = Select<TestSchema, typeof nestedCallSelection>;
182+
183+
type _TestSelectingWithNestedCalls = AssertTrue<IsExact<_withNestedCallSelection, _withNestedNoCallSelection>>;
184+
78185
test("true", () => undefined);
79186
});

packages/api-client-core/spec/TestSchema.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,37 @@ export type TestSchema = {
3232
}[]
3333
| null;
3434
someConnection: {
35+
["$args"]: {
36+
first?: number;
37+
last?: number;
38+
before?: string;
39+
after?: string;
40+
};
3541
edges:
3642
| ({
3743
node: {
3844
id: string;
3945
state: string;
46+
children: {
47+
["$args"]: {
48+
first?: number;
49+
last?: number;
50+
before?: string;
51+
after?: string;
52+
};
53+
pageInfo: {
54+
hasNextPage: boolean;
55+
hasPreviousPage: boolean;
56+
};
57+
edges:
58+
| ({
59+
node: {
60+
id: string;
61+
state: string;
62+
};
63+
} | null)[]
64+
| null;
65+
};
4066
} | null;
4167
} | null)[]
4268
| null;

packages/api-client-core/spec/operationBuilders.spec.ts

Lines changed: 168 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { actionOperation, findManyOperation, findOneByFieldOperation, findOneOperation } from "../src/index.js";
1+
import { Call, actionOperation, findManyOperation, findOneByFieldOperation, findOneOperation } from "../src/index.js";
22

33
describe("operation builders", () => {
44
describe("findOneOperation", () => {
@@ -63,6 +63,41 @@ describe("operation builders", () => {
6363
}
6464
`);
6565
});
66+
67+
test("findOneOperation should build a query with a call in it", () => {
68+
expect(
69+
findOneOperation(
70+
"widget",
71+
"123",
72+
{ __typename: true, id: true, state: true, gizmos: Call({ first: 10 }, { edges: { node: { id: true } } }) },
73+
"widget",
74+
{ live: true }
75+
)
76+
).toMatchInlineSnapshot(`
77+
{
78+
"query": "query widget($id: GadgetID!) @live {
79+
widget(id: $id) {
80+
__typename
81+
id
82+
state
83+
gizmos(first: 10) {
84+
edges {
85+
node {
86+
id
87+
}
88+
}
89+
}
90+
}
91+
gadgetMeta {
92+
hydrations(modelName: "widget")
93+
}
94+
}",
95+
"variables": {
96+
"id": "123",
97+
},
98+
}
99+
`);
100+
});
66101
});
67102

68103
describe("findManyOperation", () => {
@@ -259,6 +294,92 @@ describe("operation builders", () => {
259294
}
260295
`);
261296
});
297+
298+
test("findManyOperation should build a query with arguments in it", () => {
299+
expect(
300+
findManyOperation(
301+
"widgets",
302+
{ __typename: true, id: true, state: true, gizmos: Call({ first: 10, after: "foobar" }, { edges: { node: { id: true } } }) },
303+
"widget",
304+
{ live: true }
305+
)
306+
).toMatchInlineSnapshot(`
307+
{
308+
"query": "query widgets($after: String, $first: Int, $before: String, $last: Int) @live {
309+
widgets(after: $after, first: $first, before: $before, last: $last) {
310+
pageInfo {
311+
hasNextPage
312+
hasPreviousPage
313+
startCursor
314+
endCursor
315+
}
316+
edges {
317+
cursor
318+
node {
319+
__typename
320+
id
321+
state
322+
gizmos(first: 10, after: "foobar") {
323+
edges {
324+
node {
325+
id
326+
}
327+
}
328+
}
329+
}
330+
}
331+
}
332+
gadgetMeta {
333+
hydrations(modelName: "widget")
334+
}
335+
}",
336+
"variables": {},
337+
}
338+
`);
339+
});
340+
341+
test("findManyOperation should build a query with a call but no arguments", () => {
342+
expect(
343+
findManyOperation(
344+
"widgets",
345+
{ __typename: true, id: true, state: true, gizmos: Call({}, { edges: { node: { id: true } } }) },
346+
"widget",
347+
{ live: true }
348+
)
349+
).toMatchInlineSnapshot(`
350+
{
351+
"query": "query widgets($after: String, $first: Int, $before: String, $last: Int) @live {
352+
widgets(after: $after, first: $first, before: $before, last: $last) {
353+
pageInfo {
354+
hasNextPage
355+
hasPreviousPage
356+
startCursor
357+
endCursor
358+
}
359+
edges {
360+
cursor
361+
node {
362+
__typename
363+
id
364+
state
365+
gizmos {
366+
edges {
367+
node {
368+
id
369+
}
370+
}
371+
}
372+
}
373+
}
374+
}
375+
gadgetMeta {
376+
hydrations(modelName: "widget")
377+
}
378+
}",
379+
"variables": {},
380+
}
381+
`);
382+
});
262383
});
263384

264385
describe("findOneByFieldOperation", () => {
@@ -577,5 +698,51 @@ describe("operation builders", () => {
577698
}
578699
`);
579700
});
701+
702+
test("actionOperation should build a mutation query for a result that has a call in it", () => {
703+
expect(
704+
actionOperation(
705+
"createWidget",
706+
{ __typename: true, id: true, state: true, gizmos: Call({ first: 10 }, { edges: { node: { id: true } } }) },
707+
"widget",
708+
"widget",
709+
{}
710+
)
711+
).toMatchInlineSnapshot(`
712+
{
713+
"query": "mutation createWidget {
714+
createWidget {
715+
success
716+
errors {
717+
message
718+
code
719+
... on InvalidRecordError {
720+
validationErrors {
721+
message
722+
apiIdentifier
723+
}
724+
}
725+
}
726+
widget {
727+
__typename
728+
id
729+
state
730+
gizmos(first: 10) {
731+
edges {
732+
node {
733+
id
734+
}
735+
}
736+
}
737+
}
738+
}
739+
gadgetMeta {
740+
hydrations(modelName: "widget")
741+
}
742+
}",
743+
"variables": {},
744+
}
745+
`);
746+
});
580747
});
581748
});

0 commit comments

Comments
 (0)