diff --git a/src/typegate/src/engine/typecheck/result.ts b/src/typegate/src/engine/typecheck/result.ts index 088f949105..29d14b7f21 100644 --- a/src/typegate/src/engine/typecheck/result.ts +++ b/src/typegate/src/engine/typecheck/result.ts @@ -30,7 +30,7 @@ import { export function generateValidator( tg: TypeGraph, operation: OperationDefinitionNode, - fragments: FragmentDefs, + fragments: FragmentDefs ): Validator { const code = new ResultValidationCompiler(tg, fragments).generate(operation); const validator = new Function(code)() as ValidatorFn; @@ -58,10 +58,7 @@ export class ResultValidationCompiler { codes: Map = new Map(); counter = 0; - constructor( - private tg: TypeGraph, - private fragments: FragmentDefs, - ) {} + constructor(private tg: TypeGraph, private fragments: FragmentDefs) {} private validatorName(idx: number, additionalSuffix = false) { if (!additionalSuffix) { @@ -112,7 +109,7 @@ export class ResultValidationCompiler { } else if (isScalar(typeNode)) { if (entry.selectionSet != null) { throw new Error( - `Unexpected selection set for scalar type '${typeNode.type}' at '${entry.path}'`, + `Unexpected selection set for scalar type '${typeNode.type}' at '${entry.path}'` ); } @@ -139,7 +136,7 @@ export class ResultValidationCompiler { case "optional": { const itemValidatorName = this.validatorName( typeNode.item, - entry.selectionSet != null, + entry.selectionSet != null ); cg.generateOptionalValidator(typeNode, itemValidatorName); queue.push({ @@ -154,7 +151,7 @@ export class ResultValidationCompiler { case "list": { const itemValidatorName = this.validatorName( typeNode.items, - entry.selectionSet != null, + entry.selectionSet != null ); cg.generateArrayValidator(typeNode, itemValidatorName); queue.push({ @@ -167,11 +164,11 @@ export class ResultValidationCompiler { } case "object": { - const childEntries: Record = this - .getChildEntries(typeNode, entry); + const childEntries: Record = + this.getChildEntries(typeNode, entry); cg.generateObjectValidator( typeNode, - mapValues(childEntries, (e) => e.name), + mapValues(childEntries, (e) => e.name) ); queue.push(...Object.values(childEntries)); break; @@ -181,7 +178,7 @@ export class ResultValidationCompiler { const childEntries = this.getVariantEntries(typeNode.anyOf, entry); cg.generateUnionValidator( typeNode, - childEntries.map((e) => e.name), + childEntries.map((e) => e.name) ); queue.push(...childEntries); break; @@ -191,7 +188,7 @@ export class ResultValidationCompiler { const childEntries = this.getVariantEntries(typeNode.oneOf, entry); cg.generateEitherValidator( typeNode, - childEntries.map((e) => e.name), + childEntries.map((e) => e.name) ); queue.push(...childEntries); break; @@ -200,7 +197,7 @@ export class ResultValidationCompiler { case "function": { const outputValidator = this.validatorName( typeNode.output, - entry.selectionSet != null, + entry.selectionSet != null ); cg.line(`${outputValidator}(value, path, errors, context)`); queue.push({ @@ -221,7 +218,7 @@ export class ResultValidationCompiler { const fnBody = cg.reset().join("\n"); this.codes.set( fnName, - `function ${fnName}(value, path, errors, context) {\n${fnBody}\n}`, + `function ${fnName}(value, path, errors, context) {\n${fnBody}\n}` ); } @@ -235,7 +232,7 @@ export class ResultValidationCompiler { private getChildEntryFromFieldNode( typeNode: ObjectNode, entry: QueueEntry, - node: FieldNode, + node: FieldNode ): [string, QueueEntry] { const { name, selectionSet, alias } = node; const propName = alias?.value ?? name.value; @@ -283,7 +280,7 @@ export class ResultValidationCompiler { private getChildEntriesFromSelectionNode( typeNode: ObjectNode, entry: QueueEntry, - node: SelectionNode, + node: SelectionNode ): Array<[string, QueueEntry]> { switch (node.kind) { case Kind.FIELD: @@ -312,34 +309,37 @@ export class ResultValidationCompiler { private getChildEntries( typeNode: ObjectNode, - entry: QueueEntry, + entry: QueueEntry ): Record { - return Object.fromEntries( - entry.selectionSet!.selections.flatMap((node) => - this.getChildEntriesFromSelectionNode(typeNode, entry, node) - ), - ); + if (entry.selectionSet?.selections) { + return Object.fromEntries( + entry.selectionSet!.selections.flatMap((node) => + this.getChildEntriesFromSelectionNode(typeNode, entry, node) + ) + ); + } + + // Empty object has no fields + return {}; } private getVariantEntries( variants: number[], - entry: QueueEntry, + entry: QueueEntry ): QueueEntry[] { const multilevelVariants = this.tg.typeUtils.flattenUnionVariants(variants); const selectableVariants = multilevelVariants.filter( (variant) => - !this.tg.typeUtils.isScalarOrListOfScalars(this.tg.type(variant)), + !this.tg.typeUtils.isScalarOrListOfScalars(this.tg.type(variant)) ); if (entry.selectionSet == null) { if (selectableVariants.length > 0) { const s = selectableVariants.length === 1 ? "" : "s"; const names = selectableVariants.map((idx) => this.tg.type(idx).title); throw new Error( - `at '${entry.path}': selection set required for type${s} ${ - names.join( - ", ", - ) - }`, + `at '${entry.path}': selection set required for type${s} ${names.join( + ", " + )}` ); } } @@ -348,18 +348,18 @@ export class ResultValidationCompiler { const variantSelections: Map = entry.selectionSet != null ? new Map( - entry.selectionSet.selections.map((node) => { - if ( - node.kind !== Kind.INLINE_FRAGMENT || - node.typeCondition == null - ) { - throw new Error( - `at '${entry.path}': selection nodes must be inline fragments with type condition`, - ); - } - return [node.typeCondition.name.value, node]; - }), - ) + entry.selectionSet.selections.map((node) => { + if ( + node.kind !== Kind.INLINE_FRAGMENT || + node.typeCondition == null + ) { + throw new Error( + `at '${entry.path}': selection nodes must be inline fragments with type condition` + ); + } + return [node.typeCondition.name.value, node]; + }) + ) : new Map(); const entries: QueueEntry[] = variants.map((variantIdx) => { @@ -372,7 +372,7 @@ export class ResultValidationCompiler { if (selectionSet == null) { // TODO link to matching documentation page throw new Error( - `at '${entry.path}': variant type '${typeName}' must have a selection set on an inline fragment with type condition`, + `at '${entry.path}': variant type '${typeName}' must have a selection set on an inline fragment with type condition` ); } variantSelections.delete(typeName); @@ -386,7 +386,7 @@ export class ResultValidationCompiler { case Type.UNION: case Type.EITHER: { const nestedVariants = this.tg.typeUtils.getFlatUnionVariants( - typeNode as UnionNode | EitherNode, + typeNode as UnionNode | EitherNode ); return { name: this.validatorName(variantIdx, true), @@ -421,7 +421,7 @@ export class ResultValidationCompiler { if (variantSelections.size > 0) { const names = [...variantSelections.keys()].join(", "); throw new Error( - `at '${entry.path}': Unexpected type conditions: ${names}`, + `at '${entry.path}': Unexpected type conditions: ${names}` ); } return entries; @@ -433,8 +433,9 @@ export class ResultValidationCompiler { for (let idx = queue.shift(); idx != null; idx = queue.shift()) { const typeNode = this.tg.type(idx); switch (typeNode.type) { - case Type.OBJECT: - return true; + case Type.OBJECT: { + return Object.keys(typeNode.properties).length > 0; + } case Type.FUNCTION: queue.push(typeNode.output); break; diff --git a/src/typegate/src/runtimes/typegraph.ts b/src/typegate/src/runtimes/typegraph.ts index e51d77ef29..c6c3c74702 100644 --- a/src/typegate/src/runtimes/typegraph.ts +++ b/src/typegate/src/runtimes/typegraph.ts @@ -22,6 +22,7 @@ import { type ObjectNode, Type, type TypeNode, + isEmptyObject, } from "../typegraph/type_node.ts"; import type { Resolver } from "../types.ts"; import { @@ -36,11 +37,11 @@ import type { PolicyIndices } from "../typegraph/types.ts"; type DeprecatedArg = { includeDeprecated?: boolean }; const SCALAR_TYPE_MAP = { - "boolean": "Boolean", - "integer": "Int", - "float": "Float", - "string": "String", - "file": "File", + boolean: "Boolean", + integer: "Int", + float: "Float", + string: "String", + file: "File", }; function generateCustomScalar(type: TypeNode, idx: number) { @@ -69,7 +70,7 @@ export class TypeGraphRuntime extends Runtime { static init( typegraph: TypeGraphDS, _materializers: TypeMaterializer[], - _args: Record, + _args: Record ): Runtime { return new TypeGraphRuntime(typegraph); } @@ -79,7 +80,7 @@ export class TypeGraphRuntime extends Runtime { materialize( stage: ComputeStage, _waitlist: ComputeStage[], - _verbose: boolean, + _verbose: boolean ): ComputeStage[] { const resolver: Resolver = (() => { const name = stage.props.materializer?.name; @@ -93,18 +94,16 @@ export class TypeGraphRuntime extends Runtime { if (name === "resolver") { return async ({ _: { parent } }) => { const resolver = parent[stage.props.node]; - const ret = typeof resolver === "function" - ? await resolver() - : resolver; + const ret = + typeof resolver === "function" ? await resolver() : resolver; return ret; }; } return async ({ _: { parent } }) => { const resolver = parent[stage.props.node]; - const ret = typeof resolver === "function" - ? await resolver() - : resolver; + const ret = + typeof resolver === "function" ? await resolver() : resolver; return ret; }; })(); @@ -138,8 +137,10 @@ export class TypeGraphRuntime extends Runtime { const inputRootTypeIndices = new Set(); const outputRootTypeIndices = new Set(); + // decides whether or not custom object should be generated let hasUnion = false; + let requireEmptyObject = false; const myVisitor: TypeVisitorMap = { [Type.FUNCTION]: ({ type }) => { @@ -164,11 +165,13 @@ export class TypeGraphRuntime extends Runtime { inputTypeIndices.add(idx); } return true; - }, + } ); return true; }, default: ({ type, idx }) => { + requireEmptyObject ||= isEmptyObject(type); + if ( inputRootTypeIndices.has(idx) && !outputRootTypeIndices.has(idx) @@ -192,37 +195,45 @@ export class TypeGraphRuntime extends Runtime { visitTypes(this.tg, getChildTypes(this.tg.types[0]), myVisitor); const distinctScalars = distinctBy( [...scalarTypeIndices].map((idx) => this.tg.types[idx]), - (t) => t.type, // for scalars: one GraphQL type per `type` not `title` + (t) => t.type // for scalars: one GraphQL type per `type` not `title` ); const scalarTypes = distinctScalars.map((type) => this.formatType(type, false, false) ); - const customScalarTypes = hasUnion + const adhocCustomScalarTypes = hasUnion ? distinctScalars.map((node) => { - const idx = this.scalarIndex.get(node.type)!; - const asObject = generateCustomScalar(node, idx); - return this.formatType(asObject, false, false); - }) + const idx = this.scalarIndex.get(node.type)!; + const asObject = generateCustomScalar(node, idx); + return this.formatType(asObject, false, false); + }) : []; const regularTypes = distinctBy( [...regularTypeIndices].map((idx) => this.tg.types[idx]), - (t) => t.title, + (t) => t.title ).map((type) => this.formatType(type, false, false)); const inputTypes = distinctBy( [...inputTypeIndices].map((idx) => this.tg.types[idx]), - (t) => t.title, + (t) => t.title ).map((type) => this.formatType(type, false, true)); const types = [ ...scalarTypes, - ...customScalarTypes, + ...adhocCustomScalarTypes, ...regularTypes, ...inputTypes, ]; + // Handle non-root leaf case + if ( + requireEmptyObject && + !types.some((t: any) => t!.title == "EmptyObject") + ) { + types.push(emptyObjectScalar()); + } + this.scalarIndex.clear(); return types; }, @@ -279,7 +290,7 @@ export class TypeGraphRuntime extends Runtime { formatType = ( type: TypeNode, required: boolean, - asInput: boolean, + asInput: boolean ): Record unknown> => { const common = { // https://github.com/graphql/graphql-js/blob/main/src/type/introspection.ts#L207 @@ -342,24 +353,31 @@ export class TypeGraphRuntime extends Runtime { kind: () => TypeKind.OBJECT, name: () => "Query", description: () => `${type.title} type`, - fields: () => [{ - name: () => "_", - args: () => [], - type: () => - this.formatType( - this.tg - .types[(this.tg.types[0] as ObjectNode).properties["query"]], // itself - false, - false, - ), - isDeprecated: () => true, - deprecationReason: () => - "Dummy value due to https://github.com/graphql/graphiql/issues/2308", - }], + fields: () => [ + { + name: () => "_", + args: () => [], + type: () => + this.formatType( + this.tg.types[ + (this.tg.types[0] as ObjectNode).properties["query"] + ], // itself + false, + false + ), + isDeprecated: () => true, + deprecationReason: () => + "Dummy value due to https://github.com/graphql/graphiql/issues/2308", + }, + ], interfaces: () => [], }; } + if (isEmptyObject(type)) { + return emptyObjectScalar(); + } + if (asInput) { return { ...common, @@ -367,9 +385,7 @@ export class TypeGraphRuntime extends Runtime { name: () => `${type.title}Inp`, description: () => `${type.title} input type`, inputFields: () => { - return Object.entries(type.properties).map( - this.formatField(true), - ); + return Object.entries(type.properties).map(this.formatField(true)); }, interfaces: () => [], }; @@ -404,11 +420,11 @@ export class TypeGraphRuntime extends Runtime { const variants = isUnion(type) ? type.anyOf : type.oneOf; if (asInput) { const titles = new Set( - variants.map((idx) => this.tg.types[idx].title), + variants.map((idx) => this.tg.types[idx].title) ); - const description = `${type.type} type\n${ - Array.from(titles).join(", ") - }`; + const description = `${type.type} type\n${Array.from(titles).join( + ", " + )}`; return { ...common, @@ -450,9 +466,9 @@ export class TypeGraphRuntime extends Runtime { if (typeof p === "number") { return describeOne(p); } - return Object.entries(p).map( - ([eff, polIdx]) => `${eff}:${describeOne(polIdx)}`, - ).join("; "); + return Object.entries(p) + .map(([eff, polIdx]) => `${eff}:${describeOne(polIdx)}`) + .join("; "); }; const policies = type.policies.map(describe); @@ -467,44 +483,59 @@ export class TypeGraphRuntime extends Runtime { return ret; } - formatField = (asInput: boolean) => ([name, typeIdx]: [string, number]) => { - const type = this.tg.types[typeIdx]; - const common = { - // https://github.com/graphql/graphql-js/blob/main/src/type/introspection.ts#L329 - name: () => name, - description: () => `${name} field${this.policyDescription(type)}`, - isDeprecated: () => false, - deprecationReason: () => null, - }; + formatField = + (asInput: boolean) => + ([name, typeIdx]: [string, number]) => { + const type = this.tg.types[typeIdx]; + const common = { + // https://github.com/graphql/graphql-js/blob/main/src/type/introspection.ts#L329 + name: () => name, + description: () => `${name} field${this.policyDescription(type)}`, + isDeprecated: () => false, + deprecationReason: () => null, + }; + + if (isFunction(type)) { + return { + ...common, + args: (_: DeprecatedArg = {}) => { + const inp = this.tg.types[type.input as number]; + ensure( + isObject(inp), + `${type} cannot be an input field, require struct` + ); + let entries = Object.entries((inp as ObjectNode).properties); + entries = entries.sort((a, b) => b[1] - a[1]); + return entries + .map(this.formatInputFields) + .filter((f) => f !== null); + }, + type: () => { + const output = this.tg.types[type.output as number]; + return this.formatType(output, true, false); + }, + }; + } - if (isFunction(type)) { return { ...common, - args: (_: DeprecatedArg = {}) => { - const inp = this.tg.types[type.input as number]; - ensure( - isObject(inp), - `${type} cannot be an input field, require struct`, - ); - let entries = Object.entries((inp as ObjectNode).properties); - entries = entries.sort((a, b) => b[1] - a[1]); - return entries - .map(this.formatInputFields) - .filter((f) => f !== null); - }, + args: () => [], type: () => { - const output = this.tg.types[type.output as number]; - return this.formatType(output, true, false); + return this.formatType(type, true, asInput); }, }; - } - - return { - ...common, - args: () => [], - type: () => { - return this.formatType(type, true, asInput); - }, }; +} + +function emptyObjectScalar() { + return { + kind: () => TypeKind.SCALAR, + name: () => "EmptyObject", + description: () => "object scalar type representing an empty object", + fields: () => {}, + inputFields: () => {}, + interfaces: () => {}, + enumValues: () => {}, + possibleTypes: () => {}, }; } diff --git a/src/typegate/src/typegraph/mod.ts b/src/typegate/src/typegraph/mod.ts index 2fa74da6ae..a7f19b1941 100644 --- a/src/typegate/src/typegraph/mod.ts +++ b/src/typegate/src/typegraph/mod.ts @@ -56,10 +56,7 @@ export type RuntimeResolver = Record; export class SecretManager { private typegraphName: string; - constructor( - typegraph: TypeGraphDS, - public secrets: Record, - ) { + constructor(typegraph: TypeGraphDS, public secrets: Record) { // name without prefix as secrets are not prefixed this.typegraphName = TypeGraph.formatName(typegraph, false); } @@ -68,7 +65,7 @@ export class SecretManager { const value = this.secretOrNull(name); ensure( value != null, - `cannot find secret "${name}" for "${this.typegraphName}"`, + `cannot find secret "${name}" for "${this.typegraphName}"` ); return value as string; } @@ -112,7 +109,7 @@ export class TypeGraph implements AsyncDisposable { public runtimeReferences: Runtime[], public cors: (req: Request) => Record, public auths: Map, - public introspection: TypeGraph | null, + public introspection: TypeGraph | null ) { this.root = this.type(0); this.name = TypeGraph.formatName(tg); @@ -156,7 +153,7 @@ export class TypeGraph implements AsyncDisposable { name = ""; } throw new Error( - `Invalid typegraph definition for '${name}': ${res.NotValid.reason}`, + `Invalid typegraph definition for '${name}': ${res.NotValid.reason}` ); } } @@ -166,7 +163,7 @@ export class TypeGraph implements AsyncDisposable { typegraph: TypeGraphDS, secretManager: SecretManager, staticReference: RuntimeResolver, - introspection: TypeGraph | null, + introspection: TypeGraph | null ): Promise { const typegraphName = TypeGraph.formatName(typegraph); const { meta, runtimes } = typegraph; @@ -183,13 +180,13 @@ export class TypeGraph implements AsyncDisposable { "Content-Type", "Authorization", ...meta.cors.allow_headers, - ]), + ]) ).join(","), "Access-Control-Expose-Headers": Array.from( - new Set([nextAuthorizationHeader, ...meta.cors.expose_headers]), + new Set([nextAuthorizationHeader, ...meta.cors.expose_headers]) ).join(","), - "Access-Control-Allow-Credentials": meta.cors.allow_credentials - .toString(), + "Access-Control-Allow-Credentials": + meta.cors.allow_credentials.toString(), }; if (meta.cors.max_age_sec) { staticCors["Access-Control-Max-Age"] = meta.cors.max_age_sec.toString(); @@ -220,7 +217,7 @@ export class TypeGraph implements AsyncDisposable { } const materializers = typegraph.materializers.filter( - (mat) => mat.runtime === idx, + (mat) => mat.runtime === idx ); return initRuntime(runtime.name, { @@ -231,7 +228,7 @@ export class TypeGraph implements AsyncDisposable { args: (runtime as any)?.data ?? {}, secretManager, }); - }), + }) ); const denoRuntime = runtimeReferences[denoRuntimeIdx]; @@ -247,7 +244,7 @@ export class TypeGraph implements AsyncDisposable { runtimeReferences, cors, auths, - introspection, + introspection ); for (const auth of meta.auths) { @@ -261,15 +258,15 @@ export class TypeGraph implements AsyncDisposable { { tg: tg, // required for validation runtimeReferences, - }, - ), + } + ) ); } // override "internal" to enforce internal auth auths.set( internalAuthName, - await InternalAuth.init(typegraphName, typegate.cryptoKeys), + await InternalAuth.init(typegraphName, typegate.cryptoKeys) ); return tg; @@ -278,12 +275,12 @@ export class TypeGraph implements AsyncDisposable { type(idx: number): TypeNode; type( idx: number, - asType: T, + asType: T ): TypeNode & { type: T }; type(idx: number, asType?: T): TypeNode { ensure( typeof idx === "number" && idx < this.tg.types.length, - `cannot find type with index '${idx}'`, + `cannot find type with index '${idx}'` ); const ret = this.tg.types[idx]; if (asType != undefined) { @@ -388,7 +385,7 @@ export class TypeGraph implements AsyncDisposable { isString(type) || isUnion(type) || isEither(type), - `object expected but got ${type.type}`, + `object expected but got ${type.type}` ); return (x: any) => { return ensureArray(x); @@ -410,11 +407,7 @@ export class TypeGraph implements AsyncDisposable { return tpe; } - getGraphQLType( - typeNode: TypeNode, - optional = false, - as_id = false, - ): string { + getGraphQLType(typeNode: TypeNode, optional = false, as_id = false): string { if (typeNode.type === Type.OPTIONAL) { return this.getGraphQLType(this.type(typeNode.item), true); } @@ -445,7 +438,7 @@ export class TypeGraph implements AsyncDisposable { isSelectionSetExpectedFor(typeIdx: number): boolean { const typ = this.type(typeIdx); if (typ.type === Type.OBJECT) { - return true; + return Object.keys(typ.properties).length > 0; } if (typ.type === Type.UNION) { @@ -481,7 +474,7 @@ export class TypeGraph implements AsyncDisposable { Object.entries(typeNode.properties).map(([key, idx]) => [ key, this.getPossibleSelectionFields(idx), - ]), + ]) ); } @@ -496,13 +489,13 @@ export class TypeGraph implements AsyncDisposable { const entries = variants.map( (idx) => - [this.type(idx).title, this.getPossibleSelectionFields(idx)] as const, + [this.type(idx).title, this.getPossibleSelectionFields(idx)] as const ); if (entries[0][1] === null) { if (entries.some((e) => e[1] !== null)) { throw new Error( - "Unexpected: All the variants must not expect selection set", + "Unexpected: All the variants must not expect selection set" ); } return null; @@ -513,7 +506,7 @@ export class TypeGraph implements AsyncDisposable { } const expandNestedUnions = ( - entry: readonly [string, PossibleSelectionFields], + entry: readonly [string, PossibleSelectionFields] ): Array<[string, Map]> => { const [typeName, possibleSelections] = entry; diff --git a/src/typegate/src/typegraph/type_node.ts b/src/typegate/src/typegraph/type_node.ts index 97b3a94861..3f77cf4818 100644 --- a/src/typegate/src/typegraph/type_node.ts +++ b/src/typegate/src/typegraph/type_node.ts @@ -87,6 +87,10 @@ export function isObject(t: TypeNode): t is ObjectNode { return t.type === Type.OBJECT; } +export function isEmptyObject(t: TypeNode): t is AnyNode { + return isObject(t) && Object.keys(t.properties).length == 0; +} + export function isOptional(t: TypeNode): t is OptionalNode { return t.type === Type.OPTIONAL; } @@ -126,7 +130,7 @@ export function getWrappedType(t: QuantifierNode): number { * or `either`. */ export function getVariantTypesIndexes( - typeNode: UnionNode | EitherNode, + typeNode: UnionNode | EitherNode ): number[] { if (typeNode.type === "union") { return typeNode.anyOf; diff --git a/tests/typecheck/__snapshots__/typecheck_test.ts.snap b/tests/typecheck/__snapshots__/typecheck_test.ts.snap index 82a4536c7b..a760f8e782 100644 --- a/tests/typecheck/__snapshots__/typecheck_test.ts.snap +++ b/tests/typecheck/__snapshots__/typecheck_test.ts.snap @@ -1,7 +1,7 @@ export const snapshot = {}; snapshot[`typecheck 1`] = ` -"function validate_51_1(value, path, errors, context) { +"function validate_54_1(value, path, errors, context) { if (typeof value !== \\"object\\") { errors.push([path, \`expected an object, got \${typeof value}\`]); } @@ -122,7 +122,7 @@ errors.push([path, \\"string does not match to the pattern /^[a-z]+\$/\\"]); } } } -return validate_51_1" +return validate_54_1" `; snapshot[`typecheck 2`] = ` diff --git a/tests/typecheck/typecheck.py b/tests/typecheck/typecheck.py index 1713b955f2..e01195b6da 100644 --- a/tests/typecheck/typecheck.py +++ b/tests/typecheck/typecheck.py @@ -103,6 +103,8 @@ def typecheck(g: Graph): name="Product", ) + empty = t.struct({}).rename("WillNotHaveAnyEffectLikeOtherScalars") + g.expose( my_policy, createUser=create_user, @@ -111,4 +113,5 @@ def typecheck(g: Graph): createPost=create_post, enums=deno.identity(enums), findProduct=deno.identity(product), + emptyObjectOutput=deno.static(empty, {}), ) diff --git a/tests/typecheck/typecheck_test.ts b/tests/typecheck/typecheck_test.ts index ffbd7e397b..7382750219 100644 --- a/tests/typecheck/typecheck_test.ts +++ b/tests/typecheck/typecheck_test.ts @@ -25,7 +25,7 @@ Meta.test("typecheck", async (t) => { } return new ResultValidationCompiler(tg, fragments).generate( - operation.unwrap(), + operation.unwrap() ); }; @@ -49,7 +49,7 @@ Meta.test("typecheck", async (t) => { } `), Error, - "Unexpected property 'postis' at 'Query1'", + "Unexpected property 'postis' at 'Query1'" ); assertThrows( @@ -64,7 +64,7 @@ Meta.test("typecheck", async (t) => { } `), Error, - "Unexpected property 'text' at 'Query2.posts'", + "Unexpected property 'text' at 'Query2.posts'" ); }); @@ -229,4 +229,16 @@ Meta.test("typecheck", async (t) => { }) .on(e); }); + + await t.should("accept empty object output at root", async () => { + await gql` + query { + emptyObjectOutput + } + ` + .expectData({ + emptyObjectOutput: {}, + }) + .on(e); + }); });