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