diff --git a/packages/api-client-core/spec/Select-type.spec.ts b/packages/api-client-core/spec/Select-type.spec.ts index 0ca1a7e5c..a6d1dfc3e 100644 --- a/packages/api-client-core/spec/Select-type.spec.ts +++ b/packages/api-client-core/spec/Select-type.spec.ts @@ -1,8 +1,11 @@ import type { AssertTrue, IsExact } from "conditional-type-checks"; -import type { DeepFilterNever, Select } from "../src/types.js"; +import type { DeepFilterNever, Select, Select2 } from "../src/types.js"; import type { TestSchema } from "./TestSchema.js"; describe("Select<>", () => { + type selectAny = Select2<{ a: "thing" }, any>; + type _SelectingAnyYieldsNever = AssertTrue>; + type _SelectingProperties = AssertTrue, { num: number }>>; type _ConditionallySelectingProperties = AssertTrue< diff --git a/packages/api-client-core/spec/TestSchema.ts b/packages/api-client-core/spec/TestSchema.ts index 8e9a9b3b0..d4751de78 100644 --- a/packages/api-client-core/spec/TestSchema.ts +++ b/packages/api-client-core/spec/TestSchema.ts @@ -1,5 +1,5 @@ import type { GadgetRecord } from "src/index.js"; -import type { AvailableSelection, DeepFilterNever, DefaultSelection, Select, Selectable } from "../src/types.js"; +import type { AvailableSelection2, DeepFilterNever, DefaultSelection, Select, Selectable } from "../src/types.js"; export type NestedThing = { bool: boolean; @@ -48,7 +48,7 @@ export type TestSchema = { }; }; -export type AvailableTestSchemaSelection = AvailableSelection; +export type AvailableTestSchemaSelection = AvailableSelection2; export const DefaultPostSelection = { __typename: true, diff --git a/packages/api-client-core/spec/default-selection.spec.ts b/packages/api-client-core/spec/default-selection.spec.ts index 795a83a2e..a570e0d2f 100644 --- a/packages/api-client-core/spec/default-selection.spec.ts +++ b/packages/api-client-core/spec/default-selection.spec.ts @@ -1,11 +1,90 @@ -import type { AssertTrue, IsExact } from "conditional-type-checks"; -import type { DefaultSelection } from "../src/types.js"; -import type { AvailableTestSchemaSelection } from "./TestSchema.js"; +import type { AssertTrue, Has, IsExact } from "conditional-type-checks"; +import type { FieldSelection } from "src/FieldSelection.js"; +import type { ComputedViewFunctionWithVariables, ComputedViewFunctionWithoutVariables } from "src/GadgetFunctions.js"; +import type { AllFieldsSelected, AvailableSelection2, DefaultSelection, DefaultSelection2 } from "../src/types.js"; +import type { AvailableTestSchemaSelection, TestSchema } from "./TestSchema.js"; +import type { ExpandRecursively } from "./helpers.js"; -type _NullDefault = DefaultSelection; -type _TestDefaultsNullToTheDefault = AssertTrue>; +test("DefaultSelection", () => { + type nullDefault = DefaultSelection; + type _TestDefaultsNullToTheDefault = AssertTrue>; -type _NonNullDefault = DefaultSelection; -type _TestRespectsTruthySelections = AssertTrue>; + type undefinedDefault = DefaultSelection; + type _TestDefaultsUndefinedToTheDefault = AssertTrue>; -test("true", () => undefined); + // eslint-disable-next-line @typescript-eslint/ban-types + type undefinedDefault2 = DefaultSelection; + type _TestDefaultsUndefinedToTheDefault2 = AssertTrue>; + + type anyDefault = DefaultSelection2; + type _TestDefaultsAnyToTheDefault = AssertTrue>; + + type nonDefault = DefaultSelection; + type _TestRespectsTruthySelections = AssertTrue>; + + type nonDefault2 = DefaultSelection; + type _TestRespectsTruthySelections2 = AssertTrue>; +}); + +test("AvailableSelection", () => { + type availableTestSchemaSelection = AvailableSelection2; + type _TestAvailableTestSchemaSelection = Has; + + type testType = { a: number; b: { c: string; d: { e: boolean } } }; + type availableSelection = AvailableSelection2; + type _TestAvailableSelection = AssertTrue< + IsExact< + availableSelection, + { + a?: boolean | null | undefined; + b?: + | { + c?: boolean | null | undefined; + d?: + | { + e?: boolean | null | undefined; + } + | undefined; + } + | undefined; + } + > + >; +}); + +test("AllFieldsSelected", () => { + type testType = { a: number; b: { c: string; d: { e: boolean } } }; + type availableSelection = AvailableSelection2; + type allFieldsSelected = ExpandRecursively>; + type _TestAllFieldsSelected = AssertTrue>; +}); + +test("ComputedViewFunction", () => { + type testType = { a: number; b: { c: string; d: { e: boolean } } }; + + const f: ComputedViewFunctionWithoutVariables = () => ({} as any); + + const resultWithDefaultSelection = f(); + type _TestResultWithDefaultSelection = AssertTrue, testType>>; + + const resultWithDefaultSelection2 = f({}); + type _TestResultWithDefaultSelection2 = AssertTrue, testType>>; + + const resultWithDefaultSelection3 = f({ select: null }); + type _TestResultWithDefaultSelection3 = AssertTrue, testType>>; + + const resultWithSelection = f({ select: { a: true } }); + type _TestResultWithSelection = AssertTrue, { a: number }>>; + + const resultWithSelection2 = f({ select: { a: false, b: { c: true } } }); + type _TestResultWithSelection2 = AssertTrue, { b: { c: string } }>>; + + type vars = { n?: number; b: boolean }; + const f2: ComputedViewFunctionWithVariables = () => ({} as any); + + const resultWithDefaultSelection4 = f2({ b: false }); + type _TestResultWithDefaultSelection4 = AssertTrue, testType>>; + + const resultWithSelection5 = f({ select: { a: false, b: { c: true } } }); + type _TestResultWithSelection5 = AssertTrue, { b: { c: string } }>>; +}); diff --git a/packages/api-client-core/spec/helpers.ts b/packages/api-client-core/spec/helpers.ts index 6ab27ff7a..b223051df 100644 --- a/packages/api-client-core/spec/helpers.ts +++ b/packages/api-client-core/spec/helpers.ts @@ -47,3 +47,19 @@ export const waitForExpectationToPass = async (run: () => void | Promise, }) ); }; + +/** + * Debugging type that will display a fully resolved type + * in Intellisense instead of just the type aliases + * + * @type {T} The type to expand out + */ +export type ExpandRecursively = T extends (...args: infer A) => infer R + ? (...args: ExpandRecursively) => ExpandRecursively + : T extends Array + ? ExpandRecursively[] + : T extends object + ? T extends infer O + ? { [K in keyof O]: ExpandRecursively } + : never + : T; diff --git a/packages/api-client-core/spec/operationBuilders.spec.ts b/packages/api-client-core/spec/operationBuilders.spec.ts index 8e9402af9..24b08bbb3 100644 --- a/packages/api-client-core/spec/operationBuilders.spec.ts +++ b/packages/api-client-core/spec/operationBuilders.spec.ts @@ -1,6 +1,7 @@ import { actionOperation, backgroundActionResultOperation, + computedViewOperation, enqueueActionOperation, findManyOperation, findOneByFieldOperation, @@ -1491,4 +1492,89 @@ describe("operation builders", () => { `); }); }); + describe("computedViewOperation", () => { + test("global view without variables", () => { + expect(computedViewOperation("boom", { a: true, b: true })).toMatchInlineSnapshot(` + { + "query": "query boom { + boom { + a + b + __typename + } + }", + "variables": {}, + } + `); + expect(computedViewOperation("boom", { a: true, b: true }, {}, { a: true })).toMatchInlineSnapshot(` + { + "query": "query boom { + boom { + a + __typename + } + }", + "variables": {}, + } + `); + expect(computedViewOperation("boom", { a: true, b: true }, undefined, { a: true })).toMatchInlineSnapshot(` + { + "query": "query boom { + boom { + a + __typename + } + }", + "variables": {}, + } + `); + }); + + test("global view without selection", () => { + expect( + computedViewOperation( + "boom", + { a: true, b: true }, + { a: { required: false, type: "Int", value: 42 }, b: { required: false, type: "String", value: "fortytwo" } } + ) + ).toMatchInlineSnapshot(` + { + "query": "query boom($a: Int, $b: String) { + boom(a: $a, b: $b) { + a + b + __typename + } + }", + "variables": { + "a": 42, + "b": "fortytwo", + }, + } + `); + }); + test("global view with selection", () => { + expect( + computedViewOperation( + "boom", + { a: true, b: true }, + { a: { required: false, type: "Int", value: 42 }, b: { required: false, type: "String", value: "fortytwo" } }, + { a: true } + ) + ).toMatchInlineSnapshot(` + { + "query": "query boom($a: Int, $b: String) { + boom(a: $a, b: $b) { + a + __typename + } + }", + "variables": { + "a": 42, + "b": "fortytwo", + }, + } + `); + }); + }); }); diff --git a/packages/api-client-core/spec/operationRunners.spec.ts b/packages/api-client-core/spec/operationRunners.spec.ts index 88482391c..8e033312b 100644 --- a/packages/api-client-core/spec/operationRunners.spec.ts +++ b/packages/api-client-core/spec/operationRunners.spec.ts @@ -9,6 +9,7 @@ import { GadgetConnection, actionRunner, backgroundActionResultRunner, + computedViewRunner, enqueueActionRunner, findManyRunner, findOneByFieldRunner, @@ -2040,4 +2041,24 @@ describe("operationRunners", () => { }); }); }); + describe("computedViewRunner", () => { + test("global view", () => { + const _promise = computedViewRunner( + connection, + "boom", + { a: true, b: true }, + { a: { required: false, type: "Int", value: 42 }, b: { required: false, type: "String", value: "fortytwo" } }, + { a: true } + ); + + expect(query).toMatchInlineSnapshot(` + "query boom($a: Int, $b: String) { + boom(a: $a, b: $b) { + a + __typename + } + }" + `); + }); + }); }); diff --git a/packages/api-client-core/src/GadgetFunctions.ts b/packages/api-client-core/src/GadgetFunctions.ts index 4789edb59..4eecf372b 100644 --- a/packages/api-client-core/src/GadgetFunctions.ts +++ b/packages/api-client-core/src/GadgetFunctions.ts @@ -1,6 +1,7 @@ +import type { FieldSelection } from "./FieldSelection.js"; import type { GadgetRecord, RecordShape } from "./GadgetRecord.js"; import type { GadgetRecordList } from "./GadgetRecordList.js"; -import type { LimitToKnownKeys, VariablesOptions } from "./types.js"; +import type { AllFieldsSelected, AvailableSelection2, DefaultSelection2, LimitToKnownKeys, Select2, VariablesOptions } from "./types.js"; export type PromiseOrLiveIterator = Promise | AsyncIterable; export type AsyncRecord = PromiseOrLiveIterator>; @@ -206,3 +207,63 @@ export interface GlobalActionFunction { export type AnyActionFunction = ActionFunctionMetadata | GlobalActionFunction; export type AnyBulkActionFunction = ActionFunctionMetadata; + +// This is a function that represents a computed view that doesn't take any input parameters/variables. +// Result is an explicit type parameter defining the shape of the full result. +export type ComputedViewFunctionWithoutVariables = + // Available, Options and Defaults are inferred at call time. + < + // Available is the full FieldSelection type derived from the shape of the Result type, i.e. all possible selections. + Available extends AvailableSelection2 & FieldSelection, + // Options holds the actual selection at call time. + Options extends { select?: Available | null }, + // Defaults is the default selection to be used when one is not provided at call time, + // for views we default to everything being selected. + Defaults extends AllFieldsSelected + >( + options?: Options + ) => Promise>>; + +// Represents a computed view that doesn't take any input parameters/variables. +// It includes the view function and the view metadata. +export interface ComputedViewWithoutVariables extends ComputedViewFunctionWithoutVariables { + type: "computedView"; + operationName: string; + namespace: string | string[] | null; + defaultSelection: FieldSelection; + selection?: FieldSelection; + selectionType: AvailableSelection2; + resultType: Result; +} + +// This is a function that represents a computed view that takes input parameters/variables. +// Result is an explicit type parameter defining the shape of the full result. +// Variables is an explicit type parameter that describes the shape of the variables parameter. +export type ComputedViewFunctionWithVariables = + // Available, Options and Defaults are inferred at call time. + < + // Available is the full FieldSelection type derived from the shape of the Result type, i.e. all possible selections. + Available extends AvailableSelection2 & FieldSelection, + // Options holds the actual selection at call time. + Options extends { select?: Available | null }, + // Defaults is the default selection to be used when one is not provided at call time, + // for views we default to everything being selected. + Defaults extends AllFieldsSelected + >( + variables: Variables, + options?: Options + ) => Promise>>; + +// Represents a computed view that takes input parameters/variables. +// It includes the view function and the view metadata. +export interface ComputedViewWithVariables extends ComputedViewFunctionWithVariables { + type: "computedView"; + operationName: string; + namespace: string | string[] | null; + variables: VariablesOptions; + variablesType: Variables; + defaultSelection: FieldSelection; + selection?: FieldSelection; + selectionType: AvailableSelection2; + resultType: Result; +} diff --git a/packages/api-client-core/src/operationBuilders.ts b/packages/api-client-core/src/operationBuilders.ts index 590e41ae1..ababb6415 100644 --- a/packages/api-client-core/src/operationBuilders.ts +++ b/packages/api-client-core/src/operationBuilders.ts @@ -1,5 +1,5 @@ import type { FieldSelection as BuilderFieldSelection, BuilderOperation, Variable } from "tiny-graphql-query-compiler"; -import { Call, Var, compileWithVariableValues } from "tiny-graphql-query-compiler"; +import { Call, Var, compile, compileWithVariableValues } from "tiny-graphql-query-compiler"; import type { FieldSelection } from "./FieldSelection.js"; import type { AnyActionFunction, HasReturnType } from "./index.js"; import { @@ -286,6 +286,29 @@ export const globalActionOperation = ( }); }; +export const computedViewOperation = ( + operation: string, + defaultSelection: FieldSelection, + variables?: VariablesOptions, + selection?: FieldSelection, + namespace?: string | string[] | null +) => { + let fields = { + [operation]: Call( + variables ? variableOptionsToVariables(variables) : {}, + fieldSelectionToQueryCompilerFields(selection ?? defaultSelection, true) + ), + }; + + if (namespace) { + fields = namespacify(namespace, fields); + } + + return variables + ? compileWithVariableValues({ type: "query", name: operation, fields }) + : { query: compile({ type: "query", name: operation, fields }), variables: {} }; +}; + export interface GraphQLBackgroundActionOptions { retries?: { retryCount: number }; queue?: { name: string; maxConcurrency?: number }; diff --git a/packages/api-client-core/src/operationRunners.ts b/packages/api-client-core/src/operationRunners.ts index 60aac27fe..453e10f15 100644 --- a/packages/api-client-core/src/operationRunners.ts +++ b/packages/api-client-core/src/operationRunners.ts @@ -19,6 +19,7 @@ import type { AnyModelManager } from "./ModelManager.js"; import { actionOperation, backgroundActionResultOperation, + computedViewOperation, enqueueActionOperation, findManyOperation, findOneByFieldOperation, @@ -384,6 +385,20 @@ export const globalActionRunner = async ( return assertMutationSuccess(response, dataPath).result; }; +export const computedViewRunner = async ( + connection: GadgetConnection, + operation: string, + defaultSelection: FieldSelection, + variableValues?: VariablesOptions, + selection?: FieldSelection, + namespace?: string | string[] | null +) => { + const { query, variables } = computedViewOperation(operation, defaultSelection, variableValues, selection, namespace); + const response = await connection.currentClient.query(query, variables); + const dataPath = namespaceDataPath([operation], namespace); + return assertOperationSuccess(response, dataPath); +}; + export async function enqueueActionRunner>( connection: GadgetConnection, action: Action, diff --git a/packages/api-client-core/src/types.ts b/packages/api-client-core/src/types.ts index 69baf1455..0b06e6a8a 100644 --- a/packages/api-client-core/src/types.ts +++ b/packages/api-client-core/src/types.ts @@ -21,15 +21,66 @@ export type LimitToKnownKeys = { */ export type VariablesOptions = Record; +/** + * Allows detecting an any type, this is rather tricky: + * The type constraint 0 extends 1 is not satisfied (0 is not assignable to 1), + * so it should be impossible for 0 extends (1 & T) to be satisfied either, since (1 & T) should be even narrower than 1. + * However, when T is any, it reduces 0 extends (1 & any) to 0 extends any, which is satisfied. + * That's because any is intentionally unsound and acts as both a supertype and subtype of almost every other type. + * source: https://stackoverflow.com/questions/49927523/disallow-call-with-any/49928360#49928360 + */ + +type IfAny = 0 extends 1 & T ? Y : N; + +/** + * Convert a schema type into the type that a selection of it must extend + * + * Example Schema: + * + * { + * foo: boolean; + * bar?: string; + * nested?: { + * count: number + * } + * } + * + * Example available selection: + * + * { + * foo?: boolean | null | undefined; + * bar?: boolean | null | undefined; + * nested?: { + * count: boolean | null | undefined + * } + * } + */ +export type AvailableSelection = Schema extends string | number | bigint | null | undefined + ? boolean | null | undefined + : { [key in keyof Schema]?: AvailableSelection }; + +export type AvailableSelection2 = Schema extends Array + ? AvailableSelection2 + : Schema extends object + ? { [key in keyof Schema]?: AvailableSelection2 } + : boolean | null | undefined; + /** * Given an options object from a find method, default the type of the selection to a default if no selection is passed */ + export type DefaultSelection< SelectionType, Options extends { select?: SelectionType | null }, Defaults extends SelectionType > = Options["select"] extends SelectionType ? Options["select"] : Defaults; +export type DefaultSelection2< + Available extends FieldSelection, + Options extends { select?: Available | null }, + Defaults extends SomeFieldsSelected +> = IfAny>; + /** * Describes an option set that accepts a selection */ @@ -38,6 +89,21 @@ export interface Selectable { select?: SelectionType | null; } +/** + * Take a FieldSelection type and construct a type with all its fields required and selected. + */ +export type AllFieldsSelected = { + [K in keyof Selection]-?: NonNullable extends FieldSelection ? AllFieldsSelected> : true; +}; + +/** + * Take a FieldSelection type and construct a type with its fields set to true + * rather than (boolean | null | undefined) + */ +export type SomeFieldsSelected = { + [K in keyof Selection]?: NonNullable extends FieldSelection ? SomeFieldsSelected> : true; +}; + /** * Describes the base options that many record finders accept */ @@ -74,6 +140,24 @@ type InnerSelect = : never; }; +type InnerSelect2 = IfAny< + Selection, + never, + Selection extends null | undefined + ? never + : Schema extends (infer T)[] + ? InnerSelect2[] + : Schema extends null + ? InnerSelect2, Selection> | null + : { + [Key in keyof Selection & keyof Schema]: Selection[Key] extends true + ? Schema[Key] + : Selection[Key] extends FieldSelection + ? InnerSelect2 + : never; + } +>; + /** * Filter out any keys in `T` that are mapped to `never` recursively. Any nested objects that are empty after having never valued keys removed are also removed. * @@ -100,6 +184,7 @@ export type DeepFilterNever = T extends Record * ``` */ export type Select = DeepFilterNever>; +export type Select2 = DeepFilterNever>; /** Represents an amount of some currency. Specified as a string so user's aren't tempted to do math on the value. */ export type CurrencyAmount = string; @@ -749,33 +834,6 @@ export type PaginateOptions = { select?: AnySelection | InternalFieldSelection | null; }; -/** - * Convert a schema type into the type that a selection of it must extend - * - * Example Schema: - * - * { - * foo: boolean; - * bar?: string; - * nested?: { - * count: number - * } - * } - * - * Example available selection: - * - * { - * foo?: boolean | null | undefined; - * bar?: boolean | null | undefined; - * nested?: { - * count: boolean | null | undefined - * } - * } - */ -export type AvailableSelection = Schema extends string | number | bigint | null | undefined - ? boolean | null | undefined - : { [key in keyof Schema]?: AvailableSelection }; - /** Options for configuring the queue for a background action */ export interface BackgroundActionQueue { /**