diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index c586d1f..ccda7d2 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -122,4 +122,5 @@ export { type InferValueFromArg, type InferValueFromArgs, type InferValueFromInputType, + type InferExternalValueFromInputType, } from "./types.js"; diff --git a/packages/schema/src/output.ts b/packages/schema/src/output.ts index c66ab5a..6716420 100644 --- a/packages/schema/src/output.ts +++ b/packages/schema/src/output.ts @@ -32,6 +32,10 @@ import { type InferValueFromOutputType, type InferValueFromArgs, type InferValueFromInputType, + type InferExternalValueFromInputType, + type GraphQLDefaultInput, + type LegacyDefaultValue, + type NativeDefault, } from "./types.js"; import { GraphQLBoolean, @@ -58,6 +62,24 @@ type ImpliedResolver< type Maybe = T | null | undefined; +type RefineGraphQLDefaultInput = Input extends { + value: unknown; +} + ? Input & { value: InferExternalValueFromInputType } + : Input; + +type NativeDefaultInput = RefineGraphQLDefaultInput< + Type, + GraphQLDefaultInput +>; + +type HasDefaultValue = + DefaultValue extends undefined + ? DefaultInput extends undefined + ? false + : true + : true; + type FieldFuncArgs< Source, Args extends { [Key in keyof Args]: GArg }, @@ -636,23 +658,31 @@ export type GWithContext = { * } * `; * ``` + * + * With GraphQL 17, `default` accepts uncoerced input or a GraphQL literal. On + * GraphQL 16, use `defaultValue`, which accepts an already-coerced value. + * + * Scalar values in `default: { value: ... }` are checked against the scalar's + * serialized output type, which may not cover every input it accepts at + * runtime. For example, use a string for an `ID` default rather than an + * integer. See {@link InferExternalValueFromInputType} for details. */ arg: < Type extends GInputType, DefaultValue extends InferValueFromInputType | undefined = undefined, + DefaultInput extends NativeDefaultInput | undefined = undefined, >( arg: Flatten< { type: Type; } & Omit< GraphQLInputFieldConfig & GraphQLArgumentConfig, - "type" | "defaultValue" + "type" | "defaultValue" | "default" > > & - (undefined extends DefaultValue - ? { defaultValue?: DefaultValue } - : { defaultValue: DefaultValue }) - ) => GArg; + LegacyDefaultValue & + NativeDefault + ) => GArg>; /** * Creates an {@link GInputObjectType input object type} * diff --git a/packages/schema/src/types.d.ts b/packages/schema/src/types.d.ts index 3243e40..0620f14 100644 --- a/packages/schema/src/types.d.ts +++ b/packages/schema/src/types.d.ts @@ -18,6 +18,7 @@ import type { GraphQLInputFieldExtensions, GraphQLInputObjectType, GraphQLInputObjectTypeConfig, + GraphQLInputType, GraphQLInterfaceType, GraphQLInterfaceTypeConfig, GraphQLList, @@ -39,6 +40,15 @@ type Maybe = T | null | undefined; export type GraphQLDefaultInput = GraphQLArgumentConfig["default" & keyof GraphQLArgumentConfig]; +// Preserve GraphQL.js's property metadata, including deprecation annotations. +export type LegacyDefaultValue< + Value, + IsRequired extends boolean = undefined extends Value ? false : true, +> = Pick & + (IsRequired extends true + ? { defaultValue: Value } + : { defaultValue?: Value }); + export type NativeDefault< Value, IsRequired extends boolean = undefined extends Value ? false : true, @@ -136,24 +146,77 @@ type InferValueFromNullableInputType = : Record : never; -type InferValueForOneOf< - T extends { [key: string]: { type: GInputType } }, - Key extends keyof T = keyof T, -> = Flatten< +type InferExternalValueFromNullableInputType = + // Broad input types have no known external shape. Expanding them as lists + // would recursively infer their elements without reaching a concrete type. + GNullableInputType extends Type + ? unknown + : Type extends GraphQLScalarType + ? Value + : Type extends GraphQLEnumType + ? Type extends GEnumType + ? keyof Values & string + : unknown + : Type extends GList + ? // A null input represents a null list, not a singleton null element. + | Exclude, null> + | (object & Iterable>) + : Type extends GraphQLInputObjectType + ? Type extends GInputObjectType + ? IsOneOf extends true + ? InferExternalValueForOneOf + : InferExternalValueFromArgs + : Record + : never; + +type OneOf = Flatten< Key extends unknown ? { - readonly [K in Key]: InferValueFromNullableInputType; + readonly [K in Key]: Values[K]; } & { - readonly [K in Exclude]?: never; + readonly [K in Exclude]?: never; } : never >; +type InferValueForOneOf = + OneOf<{ + [K in keyof T]: InferValueFromNullableInputType; + }>; + +type InferExternalValueForOneOf< + T extends { [key: string]: { type: GInputType } }, +> = OneOf<{ + [K in keyof T]: InferExternalValueFromNullableInputType; +}>; + export type InferValueFromArgs>> = { readonly [Key in keyof Args]: InferValueFromArg; } & {}; +type RequiredExternalArgKeys>> = { + // Coercion fills defaults and permits omitted nullable fields. + [Key in keyof Args]: Args[Key]["type"] extends GNonNull + ? Args[Key] extends GArg + ? never + : Key + : never; +}[keyof Args]; + +type InferExternalValueFromArgs>> = + Flatten< + { + readonly [ + Key in RequiredExternalArgKeys + ]: InferExternalValueFromInputType; + } & { + readonly [Key in Exclude>]?: + | InferExternalValueFromInputType + | undefined; + } + >; + export type InferValueFromArg> = // the distribution technically only needs to be around the AddUndefined // but having it here instead of inside the union @@ -163,17 +226,40 @@ export type InferValueFromArg> = Arg extends unknown ? | InferValueFromInputType - | AddUndefined + | (Arg extends GArg + ? never + : AddUndefined) : never; -type AddUndefined = - TInputType extends GNonNull ? never : DefaultValue & undefined; +type AddUndefined = + TInputType extends GNonNull ? never : undefined; export type InferValueFromInputType = Type extends GNonNull ? InferValueFromNullableInputType : InferValueFromNullableInputType | null; +/** + * Infers the externally represented value accepted by input coercion for an + * input type. + * + * This differs from `InferValueFromInputType`, which infers the internal value + * passed to resolvers after scalar and enum coercion. + * + * For scalars, this uses the serialized output type (the second type parameter + * of `GraphQLScalarType`) as an approximation of accepted input. GraphQL.js + * does not encode accepted input types separately, so some values accepted at + * runtime may be excluded. For example, `ID` is typed as `string` even though + * it also accepts integer inputs. Custom scalars whose accepted inputs differ + * from their serialized outputs have the same limitation. + */ +export type InferExternalValueFromInputType = + GInputType extends Type + ? unknown + : Type extends GNonNull + ? InferExternalValueFromNullableInputType + : InferExternalValueFromNullableInputType | null; + /** * A GraphQL output field for an {@link GObjectType object type} which should be * created using {@link g.field}. @@ -351,6 +437,17 @@ export type GInterfaceTypeConfig< } & Omit, "interfaces" | "fields"> >; +type GArgDefaults = + HasDefaultValue extends true + ? + | (LegacyDefaultValue<{} | null, true> & + NativeDefault) + | ("default" extends keyof GraphQLArgumentConfig + ? LegacyDefaultValue & + NativeDefault, true> + : never) + : LegacyDefaultValue & NativeDefault; + /** * A GraphQL argument. These should be created with {@link g.arg} * @@ -408,12 +505,8 @@ export type GInterfaceTypeConfig< export type GArg< Type extends GInputType, HasDefaultValue extends boolean = boolean, -> = { +> = GArgDefaults & { type: Type; - defaultValue: { - true: {} | null; - false: undefined; - }[`${HasDefaultValue}`]; description?: Maybe; deprecationReason?: Maybe; extensions?: Maybe; @@ -434,6 +527,25 @@ export type GInputObjectTypeConfig< > & (false extends IsOneOf ? { isOneOf?: IsOneOf } : { isOneOf: IsOneOf }); +type ResolvedInputFieldDefaults> = + // Broad fields must still accept native GraphQLInputField instances. + GArg extends Arg + ? {} + : // Preserve the alternative ways a concrete field can have a default. + Arg extends unknown + ? Pick + : never; + +// Override the type property instead of intersecting it with GraphQLInputType, +// which loses scalar inference when a resolved field is reused as an argument. +interface TypedInputField< + Type extends GraphQLInputType, + DefaultValue, +> extends GraphQLInputField { + type: Type; + defaultValue: DefaultValue; +} + /** * A GraphQL input object type. This should generally be constructed with * {@link g.inputObject}. @@ -453,10 +565,11 @@ export class GInputObjectType< isOneOf: IsOneOf; constructor(config: Readonly>); getFields(): { - [K in keyof Fields]: GraphQLInputField & { - type: Fields[K]["type"]; - defaultValue: Fields[K]["defaultValue"]; - }; + [K in keyof Fields]: TypedInputField< + Fields[K]["type"], + Fields[K]["defaultValue"] + > & + ResolvedInputFieldDefaults; }; } @@ -533,12 +646,13 @@ type GListToStringTag = * exactly the same as it's counterpart `g.nonNull` so it is safe to use * directly if desired. * - * Also unlike the named types in this module, the original + * On GraphQL 17, native and wrapped non-null types are mutually assignable. On + * GraphQL 16, unlike the named types in this module, the original * {@link GraphQLNonNull `GraphQLNonNull`} type from the `graphql` package * cannot be assigned to a variable of type `GNonNull`. Though `GNonNull` _is_ * assignable to `GraphQLNonNull`. * - * For example, the following code will not compile: + * For example, on GraphQL 16 the following code will not compile: * * ```ts * const nonNull: GNonNull> = new GraphQLNonNull( @@ -571,12 +685,13 @@ export class GNonNull< * exactly the same as it's counterpart `g.list` so it is safe to use directly * if desired. * - * Also unlike the named types in this module, the original + * On GraphQL 17, native and wrapped list types are mutually assignable. On + * GraphQL 16, unlike the named types in this module, the original * {@link GraphQLList `GraphQLList`} type from the `graphql` package cannot be * assigned to a variable of type `GList`. Though `GList` _is_ assignable to * `GraphQLList`. * - * For example, the following code will not compile: + * For example, on GraphQL 16 the following code will not compile: * * ```ts * const list: GList> = new GraphQLList(GraphQLString); diff --git a/test-project/default-value-deprecation.probe.ts b/test-project/default-value-deprecation.probe.ts new file mode 100644 index 0000000..0fa16d2 --- /dev/null +++ b/test-project/default-value-deprecation.probe.ts @@ -0,0 +1,6 @@ +import { gWithContext } from "@graphql-ts/schema"; + +const g = gWithContext(); +const arg = g.arg({ type: g.String, defaultValue: "legacy" }); + +console.log(arg.defaultValue); diff --git a/test-project/default-value-deprecation.test.ts b/test-project/default-value-deprecation.test.ts new file mode 100644 index 0000000..bfbde0d --- /dev/null +++ b/test-project/default-value-deprecation.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { resolve } from "node:path"; +import { versionInfo } from "graphql"; +import ts from "typescript"; + +test("defaultValue follows GraphQL.js deprecation metadata", () => { + const projectRoot = process.cwd(); + const configPath = ts.findConfigFile(projectRoot, ts.sys.fileExists); + assert(configPath); + + const config = ts.readConfigFile(configPath, ts.sys.readFile); + const parsedConfig = ts.parseJsonConfigFileContent( + config.config, + ts.sys, + projectRoot + ); + const probePath = resolve( + projectRoot, + "test-project/default-value-deprecation.probe.ts" + ); + const program = ts.createProgram([probePath], parsedConfig.options); + const checker = program.getTypeChecker(); + const source = program.getSourceFile(probePath); + assert(source); + + const defaultValueSymbols: ts.Symbol[] = []; + source.forEachChild(function visit(node) { + if (ts.isIdentifier(node) && node.text === "defaultValue") { + const symbol = ts.isPropertyAssignment(node.parent) + ? checker + .getContextualType(node.parent.parent) + ?.getProperty("defaultValue") + : checker.getSymbolAtLocation(node); + assert(symbol); + defaultValueSymbols.push(symbol); + } + node.forEachChild(visit); + }); + assert.equal(defaultValueSymbols.length, 2); + + for (const symbol of defaultValueSymbols) { + const isDeprecated = symbol + .getJsDocTags(checker) + .some((tag) => tag.name === "deprecated"); + + assert.equal(isDeprecated, versionInfo.major >= 17); + } +}); diff --git a/test-project/graphql-17/defaults.test.ts b/test-project/graphql-17/defaults.test.ts new file mode 100644 index 0000000..8f8d40e --- /dev/null +++ b/test-project/graphql-17/defaults.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { gWithContext } from "@graphql-ts/schema"; +import { GraphQLSchema, graphql, versionInfo } from "graphql"; + +test( + "native defaults are coerced before reaching resolvers", + { + skip: versionInfo.major < 17, + }, + async () => { + const g = gWithContext(); + const mode = g.enum({ + name: "Mode", + values: { FAST: { value: 42 } }, + }); + const options = g.inputObject({ + name: "Options", + fields: { + mode: g.arg({ type: mode, default: { value: "FAST" } }), + numbers: g.arg({ + type: g.list(g.Int), + default: { value: new Set([1, 2]) }, + }), + label: g.arg({ type: g.String }), + }, + }); + const query = g.object()({ + name: "Query", + fields: { + inspect: g.field({ + type: g.String, + args: { + options: g.arg({ type: options, default: { value: {} } }), + literal: g.arg({ + type: g.Int, + default: { literal: { kind: "IntValue", value: "7" } }, + }), + }, + resolve(_, args) { + assert.equal(args.options?.mode, 42); + assert.deepEqual(args.options?.numbers, [1, 2]); + assert.equal(args.options?.label, undefined); + assert.equal(args.literal, 7); + return "coerced"; + }, + }), + }, + }); + const result = await graphql({ + schema: new GraphQLSchema({ query }), + source: "{ inspect }", + }); + assert.deepEqual(JSON.parse(JSON.stringify(result)), { + data: { inspect: "coerced" }, + }); + } +); diff --git a/test-project/graphql-17/index.test-d.ts b/test-project/graphql-17/index.test-d.ts index 7f31d38..c1a7309 100644 --- a/test-project/graphql-17/index.test-d.ts +++ b/test-project/graphql-17/index.test-d.ts @@ -1,13 +1,17 @@ import type { GArg, GInputObjectType, + GInputType, GInterfaceField, GInterfaceType, GList, GNonNull, GScalarType, GUnionType, + InferExternalValueFromInputType, + InferValueFromArg, } from "@graphql-ts/schema"; +import { gWithContext } from "@graphql-ts/schema"; import type { GraphQLInputObjectType, GraphQLInterfaceType, @@ -51,3 +55,262 @@ assertCompatible< // @ts-expect-error precise interface fields are GraphQL-TS-only information GraphQLInterfaceType >(); + +const g = gWithContext(); + +assertCompatible< + Invariant, + Invariant> +>(); +assertCompatible< + Invariant, + Invariant> +>(); + +// Broad input object types must not recursively expand untyped list elements. +const untypedInput = undefined! as GInputObjectType< + Record>, + boolean +>; +g.arg({ type: untypedInput }); +g.arg({ type: untypedInput, default: { value: { anything: [1, "two"] } } }); + +const broadInput = undefined! as GInputObjectType< + Record> +>; +g.arg({ type: broadInput, default: { value: { anything: [1, "two"] } } }); + +type InternalAndExternalScalar = GScalarType; +const internalAndExternalScalar = undefined! as InternalAndExternalScalar; +assertCompatible< + Invariant, + Invariant> +>(); + +const argWithDefault = g.arg({ + type: g.String, + default: { value: "external input" }, +}); +assertCompatible< + Invariant>, + Invariant +>(); + +g.arg({ + type: g.String, + default: { value: "external input" }, +}); + +// @ts-expect-error explicitly selecting a default type requires default +g.arg({ + type: g.String, +}); +assertCompatible< + Invariant, + Invariant> +>(); + +const mode = g.enum({ + name: "Mode", + values: { FAST: { value: 42 } }, +}); +g.arg({ type: mode, default: { value: "FAST" } }); +g.arg({ + type: mode, + // @ts-expect-error native enum defaults use names, not internal values + default: { value: 42 }, +}); +g.arg({ + type: mode, + // @ts-expect-error native enum defaults must name a defined enum value + default: { value: "UNKNOWN" }, +}); + +g.arg({ + type: internalAndExternalScalar, + default: { value: "external input" }, +}); +g.arg({ + type: internalAndExternalScalar, + // @ts-expect-error native defaults use the scalar's external value type + default: { value: 1 }, +}); +g.arg({ + type: g.Int, + // @ts-expect-error native defaults must use the input type's value type + default: { value: "not a number" }, +}); + +const argWithLiteralDefault = g.arg({ + type: g.String, + default: { literal: { kind: "StringValue", value: "literal input" } }, +}); +assertCompatible< + Invariant>, + Invariant +>(); + +const argWithUndefinedDefault = g.arg({ + type: g.String, + default: undefined, +}); +assertCompatible< + Invariant>, + Invariant +>(); + +const argWithBothDefaults = g.arg({ + type: g.String, + defaultValue: "legacy", + default: { value: "external input" }, +}); +assertCompatible< + Invariant>, + Invariant +>(); + +const maybeDefault = + Math.random() > 0.5 ? { default: { value: "external input" } } : {}; +const argWithMaybeDefault = g.arg({ + type: g.String, + ...maybeDefault, +}); +assertCompatible< + Invariant>, + Invariant +>(); + +g.arg({ + type: g.String, + // @ts-expect-error GraphQLDefaultInput requires exactly one of value or literal + default: {}, +}); + +const optionalFieldsInput = g.inputObject({ + name: "OptionalFieldsInput", + fields: { + label: g.arg({ type: g.String }), + limit: g.arg({ type: g.nonNull(g.Int), defaultValue: 10 }), + offset: g.arg({ type: g.nonNull(g.Int), default: { value: 0 } }), + description: g.arg({ type: g.String, default: { value: "default" } }), + }, +}); +assertCompatible< + Invariant<{ + readonly label?: string | null | undefined; + readonly limit?: number | undefined; + readonly offset?: number | undefined; + readonly description?: string | null | undefined; + } | null>, + Invariant> +>(); +g.arg({ type: optionalFieldsInput, default: { value: {} } }); +g.arg({ + type: optionalFieldsInput, + default: { value: { label: undefined, limit: undefined } }, +}); +g.arg({ + type: optionalFieldsInput, + // @ts-expect-error a default does not allow null for a non-null field + default: { value: { limit: null } }, +}); + +const requiredFieldsInput = g.inputObject({ + name: "RequiredFieldsInput", + fields: { + name: g.arg({ type: g.nonNull(g.String) }), + options: g.arg({ type: optionalFieldsInput }), + }, +}); +g.arg({ type: requiredFieldsInput, default: { value: { name: "name" } } }); +g.arg({ + type: requiredFieldsInput, + default: { value: { name: "name", options: {} } }, +}); +g.arg({ + type: requiredFieldsInput, + // @ts-expect-error non-null fields without defaults must be supplied + default: { value: {} }, +}); + +const oneOfInput = g.inputObject({ + name: "OneOfInput", + isOneOf: true, + fields: { + name: g.arg({ type: g.String }), + options: g.arg({ type: optionalFieldsInput }), + }, +}); +g.arg({ type: oneOfInput, default: { value: { options: {} } } }); +g.arg({ + type: oneOfInput, + // @ts-expect-error one-of inputs still require exactly one field + default: { value: {} }, +}); +g.arg({ + type: oneOfInput, + // @ts-expect-error a selected one-of field cannot be null + default: { value: { name: null } }, +}); + +const intList = g.list(g.Int); +assertCompatible< + Invariant) | null>, + Invariant> +>(); +const singletonDefault = g.arg({ type: intList, default: { value: 1 } }); +assertCompatible< + Invariant<(number | null)[] | null>, + Invariant> +>(); +g.arg({ type: intList, default: { value: [1, null] as const } }); +g.arg({ type: intList, default: { value: new Set([1, 2]) } }); +g.arg({ + type: intList, + // @ts-expect-error iterable elements must match the input type + default: { value: new Set(["wrong"]) }, +}); +g.arg({ type: intList, default: { value: null } }); +g.arg({ type: g.list(g.list(g.Int)), default: { value: 1 } }); +g.arg({ type: g.list(g.list(g.Int)), default: { value: [1, [2]] } }); +g.arg({ type: g.list(optionalFieldsInput), default: { value: {} } }); +g.arg({ + type: intList, + // @ts-expect-error singleton inputs must still match the element type + default: { value: "not a number" }, +}); +g.arg({ + type: g.nonNull(intList), + // @ts-expect-error nullable elements do not make the list itself nullable + default: { value: null }, +}); +g.arg({ + type: g.list(g.nonNull(g.Int)), + // @ts-expect-error non-null list elements cannot be null + default: { value: [null] }, +}); + +const resolvedFields = optionalFieldsInput.getFields(); +assertCompatible< + Invariant, + Invariant> +>(); +assertCompatible< + Invariant, + Invariant> +>(); +const reusedInput = g.inputObject({ + name: "ReusedInput", + fields: resolvedFields, +}); +g.arg({ type: reusedInput, default: { value: {} } }); + +type UncertainDefaultArg = { + type: typeof g.String; + defaultValue?: undefined; + default?: { value: string }; +}; +assertCompatible< + Invariant, + Invariant> +>();