From 2c6d43acaa1d427224991066ac1f267c5484f36b Mon Sep 17 00:00:00 2001 From: Harry Brundage Date: Mon, 27 Nov 2023 11:05:10 -0500 Subject: [PATCH] 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] --- packages/api-client-core/package.json | 4 +- .../api-client-core/spec/Select-type.spec.ts | 107 +++++++++++ packages/api-client-core/spec/TestSchema.ts | 26 +++ .../spec/operationBuilders.spec.ts | 169 +++++++++++++++++- .../spec/operationRunners.spec.ts | 117 +++++++++++- .../api-client-core/src/FieldSelection.ts | 21 ++- packages/api-client-core/src/types.ts | 13 +- .../tiny-graphql-query-compiler/package.json | 2 +- .../tiny-graphql-query-compiler/src/index.ts | 9 +- pnpm-lock.yaml | 2 +- 10 files changed, 459 insertions(+), 11 deletions(-) diff --git a/packages/api-client-core/package.json b/packages/api-client-core/package.json index c8785a94b..9d6b7b3c0 100644 --- a/packages/api-client-core/package.json +++ b/packages/api-client-core/package.json @@ -1,6 +1,6 @@ { "name": "@gadgetinc/api-client-core", - "version": "0.15.11", + "version": "0.15.12", "files": [ "Readme.md", "dist/**/*" @@ -37,7 +37,7 @@ "graphql-ws": "^5.13.1", "isomorphic-ws": "^5.0.0", "klona": "^2.0.6", - "tiny-graphql-query-compiler": "^0.2.2", + "tiny-graphql-query-compiler": "^0.2.3", "tslib": "^2.6.2", "ws": "^8.13.0" }, diff --git a/packages/api-client-core/spec/Select-type.spec.ts b/packages/api-client-core/spec/Select-type.spec.ts index 0ca1a7e5c..767720f6c 100644 --- a/packages/api-client-core/spec/Select-type.spec.ts +++ b/packages/api-client-core/spec/Select-type.spec.ts @@ -1,4 +1,5 @@ import type { AssertTrue, IsExact } from "conditional-type-checks"; +import { Call } from "../src/FieldSelection.js"; import type { DeepFilterNever, Select } from "../src/types.js"; import type { TestSchema } from "./TestSchema.js"; @@ -75,5 +76,111 @@ describe("Select<>", () => { > >; + const argsSelection = { + someConnection: Call({ first: 5 }, { pageInfo: { hasNextPage: true }, edges: { node: { id: true, state: true } } }), + } as const; + + type _withArgsSelection = Select; + + type _TestSelectingConnectionWithArgs = AssertTrue< + IsExact< + _withArgsSelection, + { + someConnection: { + pageInfo: { hasNextPage: boolean }; + edges: ({ node: { id: string; state: string } | null } | null)[] | null; + }; + } + > + >; + + const emptyArgsSelection = { + someConnection: Call({}, { pageInfo: { hasNextPage: true }, edges: { node: { id: true, state: true } } }), + } as const; + + type _withEmptyArgsSelection = Select; + + type _TestSelectingConnectionWithEmptyArgs = AssertTrue< + IsExact< + _withEmptyArgsSelection, + { + someConnection: { + pageInfo: { hasNextPage: boolean }; + edges: ({ node: { id: string; state: string } | null } | null)[] | null; + }; + } + > + >; + + const wrongArgsSelection = { + someConnection: Call({ first: "wrong type" }, { pageInfo: { hasNextPage: true }, edges: { node: { id: true, state: true } } }), + } as const; + type _withWrongArgsSelection = Select; + type _TestSelectingConnectionWithWrongArgs = AssertTrue< + IsExact<_withWrongArgsSelection, { someConnection: { $error: "incorrectly typed args passed when calling field" } }> + >; + + const doesntAcceptArgsSelection = { + optionalObj: Call({ first: "wrong type" }, { test: true }), + } as const; + type _withDoesntAcceptArgsSelection = Select; + type _TestSelectingFieldWhichDoesntAcceptArgs = AssertTrue< + IsExact<_withDoesntAcceptArgsSelection, { optionalObj: { $error: "field does not accept args" } }> + >; + + const doesntAcceptArgsScalarSelection = { + num: Call({ first: "wrong type" }, { test: true }), + } as const; + type _withDoesntAcceptArgsScalarSelection = Select; + type _TestSelectingScalarFieldWhichDoesntAcceptArgs = AssertTrue< + IsExact<_withDoesntAcceptArgsScalarSelection, { num: { $error: "field does not accept args" } }> + >; + + const nestedCallSelection = { + someConnection: Call( + { first: 5 }, + { + edges: { + node: { + id: true, + state: true, + children: Call( + { first: 10 }, + { + edges: { + node: { + id: true, + }, + }, + } + ), + }, + }, + } + ), + } as const; + const nestedCallNoSelection = { + someConnection: { + edges: { + node: { + id: true, + state: true, + children: { + edges: { + node: { + id: true, + }, + }, + }, + }, + }, + }, + } as const; + + type _withNestedNoCallSelection = Select; + type _withNestedCallSelection = Select; + + type _TestSelectingWithNestedCalls = AssertTrue>; + test("true", () => undefined); }); diff --git a/packages/api-client-core/spec/TestSchema.ts b/packages/api-client-core/spec/TestSchema.ts index 12bad75ef..3ec328692 100644 --- a/packages/api-client-core/spec/TestSchema.ts +++ b/packages/api-client-core/spec/TestSchema.ts @@ -32,11 +32,37 @@ export type TestSchema = { }[] | null; someConnection: { + ["$args"]: { + first?: number; + last?: number; + before?: string; + after?: string; + }; edges: | ({ node: { id: string; state: string; + children: { + ["$args"]: { + first?: number; + last?: number; + before?: string; + after?: string; + }; + pageInfo: { + hasNextPage: boolean; + hasPreviousPage: boolean; + }; + edges: + | ({ + node: { + id: string; + state: string; + }; + } | null)[] + | null; + }; } | null; } | null)[] | null; diff --git a/packages/api-client-core/spec/operationBuilders.spec.ts b/packages/api-client-core/spec/operationBuilders.spec.ts index 680a4513f..87eca29fd 100644 --- a/packages/api-client-core/spec/operationBuilders.spec.ts +++ b/packages/api-client-core/spec/operationBuilders.spec.ts @@ -1,4 +1,4 @@ -import { actionOperation, findManyOperation, findOneByFieldOperation, findOneOperation } from "../src/index.js"; +import { Call, actionOperation, findManyOperation, findOneByFieldOperation, findOneOperation } from "../src/index.js"; describe("operation builders", () => { describe("findOneOperation", () => { @@ -63,6 +63,41 @@ describe("operation builders", () => { } `); }); + + test("findOneOperation should build a query with a call in it", () => { + expect( + findOneOperation( + "widget", + "123", + { __typename: true, id: true, state: true, gizmos: Call({ first: 10 }, { edges: { node: { id: true } } }) }, + "widget", + { live: true } + ) + ).toMatchInlineSnapshot(` + { + "query": "query widget($id: GadgetID!) @live { + widget(id: $id) { + __typename + id + state + gizmos(first: 10) { + edges { + node { + id + } + } + } + } + gadgetMeta { + hydrations(modelName: "widget") + } + }", + "variables": { + "id": "123", + }, + } + `); + }); }); describe("findManyOperation", () => { @@ -259,6 +294,92 @@ describe("operation builders", () => { } `); }); + + test("findManyOperation should build a query with arguments in it", () => { + expect( + findManyOperation( + "widgets", + { __typename: true, id: true, state: true, gizmos: Call({ first: 10, after: "foobar" }, { edges: { node: { id: true } } }) }, + "widget", + { live: true } + ) + ).toMatchInlineSnapshot(` + { + "query": "query widgets($after: String, $first: Int, $before: String, $last: Int) @live { + widgets(after: $after, first: $first, before: $before, last: $last) { + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + edges { + cursor + node { + __typename + id + state + gizmos(first: 10, after: "foobar") { + edges { + node { + id + } + } + } + } + } + } + gadgetMeta { + hydrations(modelName: "widget") + } + }", + "variables": {}, + } + `); + }); + + test("findManyOperation should build a query with a call but no arguments", () => { + expect( + findManyOperation( + "widgets", + { __typename: true, id: true, state: true, gizmos: Call({}, { edges: { node: { id: true } } }) }, + "widget", + { live: true } + ) + ).toMatchInlineSnapshot(` + { + "query": "query widgets($after: String, $first: Int, $before: String, $last: Int) @live { + widgets(after: $after, first: $first, before: $before, last: $last) { + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + edges { + cursor + node { + __typename + id + state + gizmos { + edges { + node { + id + } + } + } + } + } + } + gadgetMeta { + hydrations(modelName: "widget") + } + }", + "variables": {}, + } + `); + }); }); describe("findOneByFieldOperation", () => { @@ -577,5 +698,51 @@ describe("operation builders", () => { } `); }); + + test("actionOperation should build a mutation query for a result that has a call in it", () => { + expect( + actionOperation( + "createWidget", + { __typename: true, id: true, state: true, gizmos: Call({ first: 10 }, { edges: { node: { id: true } } }) }, + "widget", + "widget", + {} + ) + ).toMatchInlineSnapshot(` + { + "query": "mutation createWidget { + createWidget { + success + errors { + message + code + ... on InvalidRecordError { + validationErrors { + message + apiIdentifier + } + } + } + widget { + __typename + id + state + gizmos(first: 10) { + edges { + node { + id + } + } + } + } + } + gadgetMeta { + hydrations(modelName: "widget") + } + }", + "variables": {}, + } + `); + }); }); }); diff --git a/packages/api-client-core/spec/operationRunners.spec.ts b/packages/api-client-core/spec/operationRunners.spec.ts index fac652f86..e158c2b44 100644 --- a/packages/api-client-core/spec/operationRunners.spec.ts +++ b/packages/api-client-core/spec/operationRunners.spec.ts @@ -1,6 +1,6 @@ import nock from "nock"; import type { GadgetErrorGroup } from "../src/index.js"; -import { GadgetConnection, actionRunner } from "../src/index.js"; +import { Call, GadgetConnection, actionRunner, findManyRunner } from "../src/index.js"; import { mockUrqlClient } from "./mockUrqlClient.js"; nock.disableNetConnect(); @@ -13,6 +13,121 @@ describe("operationRunners", () => { jest.spyOn(connection, "currentClient", "get").mockReturnValue(mockUrqlClient as any); }); + describe("findManyRunner", () => { + test("it can find many records", async () => { + const promise = findManyRunner<{ id: string; name: string }>( + { + connection, + } as any, + "widgets", + { id: true, name: true }, + "widget", + { + select: { + id: true, + name: true, + }, + }, + false + ); + + mockUrqlClient.executeQuery.pushResponse("widgets", { + data: { + widgets: { + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: "123", + endCursor: "abc", + }, + edges: [ + { + node: { + id: "123", + name: "test", + }, + }, + { + node: { + id: "456", + name: "test 2", + }, + }, + ], + }, + }, + stale: false, + hasNext: false, + }); + + const results = await promise; + expect(results[0].id).toBeTruthy(); + expect(results[0].name).toBeTruthy(); + expect(results[1].id).toBeTruthy(); + expect(results[1].name).toBeTruthy(); + }); + + test("it can find many records with a call in the selection", async () => { + const promise = findManyRunner( + { + connection, + } as any, + "widgets", + { id: true, name: true }, + "widget", + { + select: { + id: true, + name: true, + gizmos: Call({ first: 5 }, { edges: { node: { id: true, name: true } } }), + }, + }, + false + ); + + mockUrqlClient.executeQuery.pushResponse("widgets", { + data: { + widgets: { + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: "123", + endCursor: "abc", + }, + edges: [ + { + node: { + id: "123", + name: "test", + gizmos: { + edges: [{ node: { id: "1", name: "gizmo a" } }, { node: { id: "2", name: "gizmo b" } }], + }, + }, + }, + { + node: { + id: "456", + name: "test 2", + gizmos: { + edges: [], + }, + }, + }, + ], + }, + }, + stale: false, + hasNext: false, + }); + + const results = await promise; + expect(results[0].id).toBeTruthy(); + expect(results[0].name).toBeTruthy(); + expect(results[0].gizmos.edges).toHaveLength(2); + expect(results[1].gizmos.edges).toHaveLength(0); + }); + }); + describe("actionRunner", () => { test("can run a single create action", async () => { const promise = actionRunner<{ id: string; name: string }>( diff --git a/packages/api-client-core/src/FieldSelection.ts b/packages/api-client-core/src/FieldSelection.ts index b42434c52..4f2e16bf4 100644 --- a/packages/api-client-core/src/FieldSelection.ts +++ b/packages/api-client-core/src/FieldSelection.ts @@ -1,7 +1,26 @@ +import { FieldCall as QueryCompilerFieldCall } from "tiny-graphql-query-compiler"; + +/** Object capturing the call arguments for a call to a field in a selection */ +export class FieldCall< + Args extends Record, + Subselection extends FieldSelection | null | undefined, + Schema = unknown +> extends QueryCompilerFieldCall { + constructor(readonly args: Args, readonly subselection?: Subselection) { + super(args, subselection); + } +} + +/** Use this to pass a GraphQL field arguments in the `select` param */ +export const Call = , Subselection extends FieldSelection | null | undefined, Schema = unknown>( + args: Args, + subselection?: Subselection +) => new FieldCall(args, subselection); + /** * Represents a list of fields selected from a GraphQL API call. Allows nesting, conditional selection. * Example: `{ id: true, name: false, richText: { markdown: true, html: false } }` **/ export interface FieldSelection { - [key: string]: boolean | null | undefined | FieldSelection; + [key: string]: boolean | null | undefined | FieldSelection | FieldCall; } diff --git a/packages/api-client-core/src/types.ts b/packages/api-client-core/src/types.ts index 3d93fc5fc..c36dde0a4 100644 --- a/packages/api-client-core/src/types.ts +++ b/packages/api-client-core/src/types.ts @@ -1,5 +1,5 @@ import type { VariableOptions } from "tiny-graphql-query-compiler"; -import type { FieldSelection } from "./FieldSelection.js"; +import type { FieldCall, FieldSelection } from "./FieldSelection.js"; /** * Limit the keys in T to only those that also exist in U. AKA Subset or Intersection. @@ -49,6 +49,15 @@ export type NonNeverKeys = { */ export type FilterNever> = NonNeverKeys extends never ? never : { [Key in NonNeverKeys]: T[Key] }; +type InnerSelectWithCall = Schema extends { + ["$args"]: Record; +} + ? Args extends Schema["$args"] + ? InnerSelect + : { $error: `incorrectly typed args passed when calling field` } + : { $error: `field does not accept args` }; + +/** Helper type for recursing through the selection to build the result type */ type InnerSelect = Selection extends null | undefined ? never : Schema extends (infer T)[] @@ -58,6 +67,8 @@ type InnerSelect = : { [Key in keyof Selection & keyof Schema]: Selection[Key] extends true ? Schema[Key] + : Selection[Key] extends FieldCall + ? InnerSelectWithCall : Selection[Key] extends FieldSelection ? InnerSelect : never; diff --git a/packages/tiny-graphql-query-compiler/package.json b/packages/tiny-graphql-query-compiler/package.json index ee086d4a8..b6513c6a8 100644 --- a/packages/tiny-graphql-query-compiler/package.json +++ b/packages/tiny-graphql-query-compiler/package.json @@ -1,6 +1,6 @@ { "name": "tiny-graphql-query-compiler", - "version": "0.2.2", + "version": "0.2.3", "type": "module", "exports": { ".": { diff --git a/packages/tiny-graphql-query-compiler/src/index.ts b/packages/tiny-graphql-query-compiler/src/index.ts index e393a43ab..cca709039 100644 --- a/packages/tiny-graphql-query-compiler/src/index.ts +++ b/packages/tiny-graphql-query-compiler/src/index.ts @@ -14,7 +14,7 @@ const compileFieldSelection = (fields: FieldSelection): string[] => { .flatMap(([field, value]) => { if (typeof value === "boolean") { return value ? field : false; - } else if (value instanceof FieldCall) { + } else if (isFieldCall(value)) { let args = ""; const signatures = Object.entries(value.args) .filter(([_, value]) => value !== null && value !== undefined) @@ -78,9 +78,12 @@ const compileVariables = (operation: BuilderOperation) => { return `(${signatures.join(", ")})`; }; -class FieldCall { - constructor(readonly args: Record, readonly subselection?: FieldSelection) {} +const kIsCall = Symbol.for("gadget/isCall"); +export class FieldCall { + [kIsCall] = true as const; + constructor(readonly args: Record, readonly subselection?: FieldSelection | null) {} } +const isFieldCall = (value: any): value is FieldCall => value && value[kIsCall]; export interface VariableOptions { type: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af14832f9..67042c032 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,7 +118,7 @@ importers: specifier: ^2.0.6 version: 2.0.6 tiny-graphql-query-compiler: - specifier: ^0.2.2 + specifier: ^0.2.3 version: link:../tiny-graphql-query-compiler tslib: specifier: ^2.6.2