diff --git a/.changeset/old-pigs-float.md b/.changeset/old-pigs-float.md new file mode 100644 index 0000000000..6f8efb1409 --- /dev/null +++ b/.changeset/old-pigs-float.md @@ -0,0 +1,5 @@ +--- +"@neo4j/graphql": major +--- + +The `requires` argument of the `@customResolver` directive now accepts a graphql selection set. This means it is now possible to require non-scalar fields such as related types. diff --git a/docs/modules/ROOT/pages/custom-resolvers.adoc b/docs/modules/ROOT/pages/custom-resolvers.adoc index aba6fd1581..0e3f9c9cac 100644 --- a/docs/modules/ROOT/pages/custom-resolvers.adoc +++ b/docs/modules/ROOT/pages/custom-resolvers.adoc @@ -5,6 +5,9 @@ The library will autogenerate Query and Mutation resolvers, so you don’t need == Custom object type field resolver +[[custom-resolver-directive]] +=== `@customResolver` + If you would like to add a field to an object type which is resolved from existing values in the type, rather than storing new values, you should mark it with the `@customResolver` directive (see below) and define a custom resolver for it. Take for instance a simple schema: [source, javascript, indent=0] @@ -13,7 +16,7 @@ const typeDefs = ` type User { firstName: String! lastName: String! - fullName: String! @customResolver(requires: ["firstName", "lastName"]) + fullName: String! @customResolver(requires: "firstName lastName") } `; @@ -35,14 +38,6 @@ Here `fullName` is a value that is resolved from the fields `firstName` and `las The inclusion of the fields `firstName` and `lastName` in the `requires` argument means that in the definition of the resolver, the properties `firstName` and `lastName` will always be defined on the `source` object. If these fields are not specified, this cannot be guaranteed. -[[custom-resolver-directive]] -=== `@customResolver` - -This field will essentially be completely ignored during the generation of Query and Mutation fields, and will require a custom resolver to resolve the field. - -Any fields that the custom resolver depends on should be passed to the `requires` argument to ensure that during the Cypher generation process those properties are selected from the database. -Allowable fields are any returning a Scalar or Enum type including those defined using the xref::type-definitions/cypher.adoc#type-definitions-cypher[`@cypher`] directive. - ==== Definition [source, graphql, indent=0] @@ -54,6 +49,112 @@ directive @customResolver( ) on FIELD_DEFINITION ---- +==== The `requires` argument + +Any fields that the custom resolver depends on should be passed to the `requires` argument to ensure that during the Cypher generation process those properties are selected from the database. + +Any field can be required, as long as it is not another `@customResolver` field. + +The `requires` argument accepts a selection set string. Using a selection set string makes it possible to select fields from related types as below: + +[source, javascript, indent=0] +---- +const typeDefs = ` + type Address { + houseNumber: Int! + street: String! + city: String! + } + + type User { + id: ID! + firstName: String! + lastName: String! + address: Address! @relationship(type: "LIVES_AT", direction: OUT) + fullName: String + @customResolver(requires: "firstName lastName address { city street }") + } +`; + +const resolvers = { + User: { + fullName({ firstName, lastName, address }) { + return `${firstName} ${lastName} from ${address.street} in ${address.city}`; + }, + }, +}; + +const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, +}); +---- + +In this example, the `firstName`, `lastName`, `address.street` and `address.city` fields will always be selected from the database if the `fullName` field is selected and will be available to the custom resolver. + +It is also possible to inline fragments to conditionally select fields from interface/union types: + +[source, graphql, indent=0] +---- +interface Publication { + publicationYear: Int! +} + +type Author { + name: String! + publications: [Publication!]! @relationship(type: "WROTE", direction: OUT) + publicationsWithAuthor: [String!]! + @customResolver( + requires: "name publications { publicationYear ...on Book { title } ... on Journal { subject } }" + ) +} + +type Book implements Publication { + title: String! + publicationYear: Int! + author: [Author!]! @relationship(type: "WROTE", direction: IN) +} + +type Journal implements Publication { + subject: String! + publicationYear: Int! + author: [Author!]! @relationship(type: "WROTE", direction: IN) +} +---- + +It is **not** possible to require extra fields generated by the library such as aggregations and connections. +For example, the type definitions below would throw an error as they attempt to require the `publicationsAggregate`: + +[source, graphql, indent=0] +---- +interface Publication { + publicationYear: Int! +} + +type Author { + name: String! + publications: [Publication!]! @relationship(type: "WROTE", direction: OUT) + publicationsWithAuthor: [String!]! + @customResolver( + requires: "name publicationsAggregate { count }" + ) +} + +type Book implements Publication { + title: String! + publicationYear: Int! + author: [Author!]! @relationship(type: "WROTE", direction: IN) +} + +type Journal implements Publication { + subject: String! + publicationYear: Int! + author: [Author!]! @relationship(type: "WROTE", direction: IN) +} +---- + +==== Providing custom resolvers + Note that any field marked with the `@customResolver` directive, requires a custom resolver to be defined. If the directive is marked on an interface, any implementation of that interface requires a custom resolver to be defined. Take for example this schema: @@ -107,8 +208,4 @@ const neoSchema = new Neo4jGraphQL({ }, }, }) ----- - -== Custom Query/Mutation type field resolver - -You can define additional custom Query and Mutation fields in your type definitions and provide custom resolvers for them. A prime use case for this is using the xref::ogm/index.adoc[OGM] to manipulate types and fields which are not available through the API. You can find an example of it being used in this capacity in the xref::ogm/examples/custom-resolvers.adoc[Custom Resolvers] example. +---- \ No newline at end of file diff --git a/docs/modules/ROOT/pages/guides/v4-migration/index.adoc b/docs/modules/ROOT/pages/guides/v4-migration/index.adoc index eb33c31d99..e4467d96a3 100644 --- a/docs/modules/ROOT/pages/guides/v4-migration/index.adoc +++ b/docs/modules/ROOT/pages/guides/v4-migration/index.adoc @@ -406,6 +406,75 @@ type query { Additionally, escaping strings is no longer needed. +=== `@customResolver` changes + +In version 4.0.0, it is now possible to require non-scalar fields. This means it is also possible to require fields on related type. +To make this possible, the `requires` argument now accept a graphql selection set instead of a list of strings. + +Therefore, the following type definitions: + +[source, graphql, indent=0] +---- +type User { + firstName: String! + lastName: String! + fullName: String! @customResolver(requires: ["firstName", "lastName"]) +} +---- + +Would need to be modified to use a selection set as below: + +[source, graphql, indent=0] +---- +type User { + firstName: String! + lastName: String! + fullName: String! @customResolver(requires: "firstName lastName") +} +---- + +Below is a more advanced example showing what the selection set is capable of: + +[source, graphql, indent=0] +---- +interface Publication { + publicationYear: Int! +} + +type Author { + name: String! + publications: [Publication!]! @relationship(type: "WROTE", direction: OUT) + publicationsWithAuthor: [String!]! + @customResolver( + requires: "name publications { publicationYear ...on Book { title } ... on Journal { subject } }" + ) +} + +type Book implements Publication { + title: String! + publicationYear: Int! + author: [Author!]! @relationship(type: "WROTE", direction: IN) +} + +type Journal implements Publication { + subject: String! + publicationYear: Int! + author: [Author!]! @relationship(type: "WROTE", direction: IN) +} +---- + +Additionally, the requires argument also validates the required selection set against your type definitions. +Therefore, as there is no field called `someFieldThatDoesNotExist`, an error would be thrown on startup if you tried to use the following type definitions: + +[source, graphql, indent=0] +---- +type User { + firstName: String! + lastName: String! + fullName: String! @customResolver(requires: "firstName someFieldThatDoesNotExist") +} +---- + == Miscellaneous changes diff --git a/packages/apollo-federation-subgraph-compatibility/src/type-defs.ts b/packages/apollo-federation-subgraph-compatibility/src/type-defs.ts index 70e4b40cb7..c99082912f 100644 --- a/packages/apollo-federation-subgraph-compatibility/src/type-defs.ts +++ b/packages/apollo-federation-subgraph-compatibility/src/type-defs.ts @@ -75,11 +75,13 @@ export const typeDefs = gql` } type Query { - product(id: ID!): Product @cypher(statement: "MATCH (product:Product) WHERE product.id = $id RETURN product") + product(id: ID!): Product + @cypher(statement: "MATCH (product:Product) WHERE product.id = $id RETURN product", columnName: "product") deprecatedProduct(sku: String!, package: String!): DeprecatedProduct @deprecated(reason: "Use product query instead") @cypher( statement: "MATCH (product:DeprecatedProduct) WHERE product.sku = $sku AND product.package = $package = $id RETURN product" + columnName: "product" ) } diff --git a/packages/graphql/src/classes/Neo4jGraphQL.ts b/packages/graphql/src/classes/Neo4jGraphQL.ts index ae5a607098..2f0a1b47fa 100644 --- a/packages/graphql/src/classes/Neo4jGraphQL.ts +++ b/packages/graphql/src/classes/Neo4jGraphQL.ts @@ -31,7 +31,7 @@ import type { Neo4jGraphQLPlugins, Neo4jGraphQLCallbacks, Neo4jFeaturesSettings, - StartupValidationConfig + StartupValidationConfig, } from "../types"; import { makeAugmentedSchema } from "../schema"; import type Node from "./Node"; @@ -195,7 +195,7 @@ class Neo4jGraphQL { driver, driverConfig, nodes: this.nodes, - options: input.options + options: input.options, }); } @@ -225,7 +225,7 @@ class Neo4jGraphQL { private async getNeo4jDatabaseInfo(driver: Driver, driverConfig?: DriverConfig): Promise { const executorConstructorParam: ExecutorConstructorParam = { - executionContext: driver + executionContext: driver, }; if (driverConfig?.database) { @@ -250,13 +250,13 @@ class Neo4jGraphQL { nodes: this.nodes, relationships: this.relationships, schemaModel: this.schemaModel, - plugins: this.plugins + plugins: this.plugins, }; const resolversComposition = { "Query.*": [wrapResolver(wrapResolverArgs)], "Mutation.*": [wrapResolver(wrapResolverArgs)], - "Subscription.*": [wrapSubscription(wrapResolverArgs)] + "Subscription.*": [wrapSubscription(wrapResolverArgs)], }; // Merge generated and custom resolvers @@ -270,9 +270,7 @@ class Neo4jGraphQL { const { validateTypeDefs, validateResolvers } = this.parseStartupValidationConfig(); - if (validateTypeDefs) { - validateDocument(document); - } + validateDocument(document, validateTypeDefs); const { nodes, relationships, typeDefs, resolvers } = makeAugmentedSchema(document, { features: this.features, @@ -280,7 +278,7 @@ class Neo4jGraphQL { validateResolvers, generateSubscriptions: Boolean(this.plugins?.subscriptions), callbacks: this.config.callbacks, - userCustomResolvers: this.resolvers + userCustomResolvers: this.resolvers, }); this.schemaModel = generateModel(document); @@ -293,7 +291,7 @@ class Neo4jGraphQL { const schema = makeExecutableSchema({ typeDefs, - resolvers: wrappedResolvers + resolvers: wrappedResolvers, }); resolve(this.addDefaultFieldResolvers(schema)); @@ -310,9 +308,8 @@ class Neo4jGraphQL { const { validateTypeDefs, validateResolvers } = this.parseStartupValidationConfig(); - if (validateTypeDefs) { - validateDocument(document, directives, types); - } + validateDocument(document, validateTypeDefs, directives, types); + const { nodes, relationships, typeDefs, resolvers } = makeAugmentedSchema(document, { features: this.features, enableRegex: this.config?.enableRegex, @@ -320,7 +317,7 @@ class Neo4jGraphQL { generateSubscriptions: Boolean(this.plugins?.subscriptions), callbacks: this.config.callbacks, userCustomResolvers: this.resolvers, - subgraph + subgraph, }); this.schemaModel = generateModel(document); @@ -334,7 +331,7 @@ class Neo4jGraphQL { const schema = subgraph.buildSchema({ typeDefs: subgraphTypeDefs, - resolvers: wrappedResolvers as Record + resolvers: wrappedResolvers as Record, }); return this.addDefaultFieldResolvers(schema); @@ -350,7 +347,7 @@ class Neo4jGraphQL { if (this.config?.startupValidation === false) { return { validateTypeDefs: false, - validateResolvers: false + validateResolvers: false, }; } @@ -364,7 +361,7 @@ class Neo4jGraphQL { return { validateTypeDefs, - validateResolvers + validateResolvers, }; } diff --git a/packages/graphql/src/graphql/directives/customResolver.ts b/packages/graphql/src/graphql/directives/customResolver.ts index 532cb91140..ea0878b331 100644 --- a/packages/graphql/src/graphql/directives/customResolver.ts +++ b/packages/graphql/src/graphql/directives/customResolver.ts @@ -17,7 +17,8 @@ * limitations under the License. */ -import { DirectiveLocation, GraphQLDirective, GraphQLList, GraphQLNonNull, GraphQLString } from "graphql"; +import { DirectiveLocation, GraphQLDirective } from "graphql"; +import { GraphQLSelectionSet } from "../scalars/SelectionSet"; export const customResolverDirective = new GraphQLDirective({ name: "customResolver", @@ -27,8 +28,8 @@ export const customResolverDirective = new GraphQLDirective({ args: { requires: { description: - "Fields that the custom resolver will depend on. These are passed as an object to the first argument of the custom resolver.", - type: new GraphQLList(new GraphQLNonNull(GraphQLString)), + "Selection set of the fields that the custom resolver will depend on. These fields are passed as an object to the first argument of the custom resolver.", + type: GraphQLSelectionSet, }, }, }); diff --git a/packages/graphql/src/graphql/scalars/SelectionSet.ts b/packages/graphql/src/graphql/scalars/SelectionSet.ts new file mode 100644 index 0000000000..373c201bc8 --- /dev/null +++ b/packages/graphql/src/graphql/scalars/SelectionSet.ts @@ -0,0 +1,57 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { parse, ValueNode, GraphQLError, GraphQLScalarType, Kind } from "graphql"; + +export const GraphQLSelectionSet = new GraphQLScalarType({ + name: "SelectionSet", + description: + "A GraphQL SelectionSet without the outer curly braces. It must be passed as a string and is always returned as a string.", + serialize(outputValue: unknown) { + if (typeof outputValue !== "string") { + throw new GraphQLError(`SelectionSet cannot represent non string value: ${outputValue}`); + } + parseSelectionSet(outputValue); + + return outputValue; + }, + parseValue(inputValue: unknown) { + if (typeof inputValue !== "string") { + throw new GraphQLError(`SelectionSet cannot represent non string value: ${inputValue}`); + } + parseSelectionSet(inputValue); + + return inputValue; + }, + parseLiteral(ast: ValueNode) { + if (ast.kind !== Kind.STRING) { + throw new GraphQLError(`SelectionSet cannot represent non string value`); + } + parseSelectionSet(ast.value); + return ast.value; + }, +}); + +function parseSelectionSet(input: string) { + try { + parse(`{ ${input} }`); + } catch { + throw new GraphQLError(`SelectionSet cannot parse the following value: ${input}`); + } +} diff --git a/packages/graphql/src/schema/get-custom-resolver-meta.test.ts b/packages/graphql/src/schema/get-custom-resolver-meta.test.ts index 7a0df63562..6c244933bc 100644 --- a/packages/graphql/src/schema/get-custom-resolver-meta.test.ts +++ b/packages/graphql/src/schema/get-custom-resolver-meta.test.ts @@ -17,34 +17,447 @@ * limitations under the License. */ -import type { FieldDefinitionNode, ObjectTypeDefinitionNode } from "graphql"; -import { Kind } from "graphql"; -import getCustomResolverMeta, { ERROR_MESSAGE } from "./get-custom-resolver-meta"; +import { + FieldDefinitionNode, + InterfaceTypeDefinitionNode, + ObjectTypeDefinitionNode, + UnionTypeDefinitionNode, + Kind, +} from "graphql"; +import { generateResolveTree } from "../translate/utils/resolveTree"; +import { getCustomResolverMeta, INVALID_SELECTION_SET_ERROR } from "./get-custom-resolver-meta"; describe("getCustomResolverMeta", () => { - const fieldName = "someFieldName"; - const objectName = "someObjectName"; - const interfaceName = "anInterface"; - const object: ObjectTypeDefinitionNode = { - kind: Kind.OBJECT_TYPE_DEFINITION, - name: { - kind: Kind.NAME, - value: objectName, + const authorType = "Author"; + const bookType = "Book"; + const journalType = "Journal"; + const publicationInterface = "Publication"; + + const customResolverField = "publicationsWithAuthor"; + + const objects: ObjectTypeDefinitionNode[] = [ + { + kind: Kind.OBJECT_TYPE_DEFINITION, + description: undefined, + name: { + kind: Kind.NAME, + value: authorType, + }, + interfaces: [], + directives: [], + fields: [ + { + kind: Kind.FIELD_DEFINITION, + description: undefined, + name: { + kind: Kind.NAME, + value: "name", + }, + arguments: [], + type: { + kind: Kind.NON_NULL_TYPE, + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: "String", + }, + }, + }, + directives: [], + }, + { + kind: Kind.FIELD_DEFINITION, + description: undefined, + name: { + kind: Kind.NAME, + value: "publications", + }, + arguments: [], + type: { + kind: Kind.NON_NULL_TYPE, + type: { + kind: Kind.LIST_TYPE, + type: { + kind: Kind.NON_NULL_TYPE, + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: publicationInterface, + }, + }, + }, + }, + }, + directives: [ + { + kind: Kind.DIRECTIVE, + name: { + kind: Kind.NAME, + value: "relationship", + }, + arguments: [ + { + kind: Kind.ARGUMENT, + name: { + kind: Kind.NAME, + value: "type", + }, + value: { + kind: Kind.STRING, + value: "WROTE", + block: false, + }, + }, + { + kind: Kind.ARGUMENT, + name: { + kind: Kind.NAME, + value: "direction", + }, + value: { + kind: Kind.ENUM, + value: "OUT", + }, + }, + ], + }, + ], + }, + { + kind: Kind.FIELD_DEFINITION, + description: undefined, + name: { + kind: Kind.NAME, + value: customResolverField, + }, + arguments: [], + type: { + kind: Kind.NON_NULL_TYPE, + type: { + kind: Kind.LIST_TYPE, + type: { + kind: Kind.NON_NULL_TYPE, + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: "String", + }, + }, + }, + }, + }, + directives: [ + { + kind: Kind.DIRECTIVE, + name: { + kind: Kind.NAME, + value: "customResolver", + }, + arguments: [ + { + kind: Kind.ARGUMENT, + name: { + kind: Kind.NAME, + value: "requires", + }, + value: { + kind: Kind.STRING, + value: "name publications { publicationYear ...on Book { title } ... on Journal { subject } }", + block: false, + }, + }, + ], + }, + ], + }, + ], }, - interfaces: [ - { - kind: Kind.NAMED_TYPE, - name: { - kind: Kind.NAME, - value: interfaceName, + { + kind: Kind.OBJECT_TYPE_DEFINITION, + description: undefined, + name: { + kind: Kind.NAME, + value: bookType, + }, + interfaces: [ + { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: publicationInterface, + }, + }, + ], + directives: [], + fields: [ + { + kind: Kind.FIELD_DEFINITION, + description: undefined, + name: { + kind: Kind.NAME, + value: "title", + }, + arguments: [], + type: { + kind: Kind.NON_NULL_TYPE, + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: "String", + }, + }, + }, + directives: [], + }, + { + kind: Kind.FIELD_DEFINITION, + description: undefined, + name: { + kind: Kind.NAME, + value: "publicationYear", + }, + arguments: [], + type: { + kind: Kind.NON_NULL_TYPE, + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: "Int", + }, + }, + }, + directives: [], + }, + { + kind: Kind.FIELD_DEFINITION, + description: undefined, + name: { + kind: Kind.NAME, + value: "author", + }, + arguments: [], + type: { + kind: Kind.NON_NULL_TYPE, + type: { + kind: Kind.LIST_TYPE, + type: { + kind: Kind.NON_NULL_TYPE, + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: authorType, + }, + }, + }, + }, + }, + directives: [ + { + kind: Kind.DIRECTIVE, + name: { + kind: Kind.NAME, + value: "relationship", + }, + arguments: [ + { + kind: Kind.ARGUMENT, + name: { + kind: Kind.NAME, + value: "type", + }, + value: { + kind: Kind.STRING, + value: "WROTE", + block: false, + }, + }, + { + kind: Kind.ARGUMENT, + name: { + kind: Kind.NAME, + value: "direction", + }, + value: { + kind: Kind.ENUM, + value: "IN", + }, + }, + ], + }, + ], }, + ], + }, + { + kind: Kind.OBJECT_TYPE_DEFINITION, + description: undefined, + name: { + kind: Kind.NAME, + value: journalType, }, - ], - }; + interfaces: [ + { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: publicationInterface, + }, + }, + ], + directives: [], + fields: [ + { + kind: Kind.FIELD_DEFINITION, + description: undefined, + name: { + kind: Kind.NAME, + value: "subject", + }, + arguments: [], + type: { + kind: Kind.NON_NULL_TYPE, + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: "String", + }, + }, + }, + directives: [], + }, + { + kind: Kind.FIELD_DEFINITION, + description: undefined, + name: { + kind: Kind.NAME, + value: "publicationYear", + }, + arguments: [], + type: { + kind: Kind.NON_NULL_TYPE, + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: "Int", + }, + }, + }, + directives: [], + }, + { + kind: Kind.FIELD_DEFINITION, + description: undefined, + name: { + kind: Kind.NAME, + value: "author", + }, + arguments: [], + type: { + kind: Kind.NON_NULL_TYPE, + type: { + kind: Kind.LIST_TYPE, + type: { + kind: Kind.NON_NULL_TYPE, + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: authorType, + }, + }, + }, + }, + }, + directives: [ + { + kind: Kind.DIRECTIVE, + name: { + kind: Kind.NAME, + value: "relationship", + }, + arguments: [ + { + kind: Kind.ARGUMENT, + name: { + kind: Kind.NAME, + value: "type", + }, + value: { + kind: Kind.STRING, + value: "WROTE", + block: false, + }, + }, + { + kind: Kind.ARGUMENT, + name: { + kind: Kind.NAME, + value: "direction", + }, + value: { + kind: Kind.ENUM, + value: "IN", + }, + }, + ], + }, + ], + }, + ], + }, + ]; + + const interfaces: InterfaceTypeDefinitionNode[] = [ + { + kind: Kind.INTERFACE_TYPE_DEFINITION, + description: undefined, + name: { + kind: Kind.NAME, + value: publicationInterface, + }, + interfaces: [], + directives: [], + fields: [ + { + kind: Kind.FIELD_DEFINITION, + description: undefined, + name: { + kind: Kind.NAME, + value: "publicationYear", + }, + arguments: [], + type: { + kind: Kind.NON_NULL_TYPE, + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: "Int", + }, + }, + }, + directives: [], + }, + ], + }, + ]; + + const unions: UnionTypeDefinitionNode[] = []; + + const object = objects.find((obj) => obj.name.value === authorType) as ObjectTypeDefinitionNode; const resolvers = { - [fieldName]: () => 25, + [customResolverField]: () => 25, }; + test("should return undefined if no directive found", () => { // @ts-ignore const field: FieldDefinitionNode = { @@ -68,15 +481,23 @@ describe("getCustomResolverMeta", () => { ], name: { kind: Kind.NAME, - value: fieldName, + value: customResolverField, }, }; - const result = getCustomResolverMeta(field, object, true, resolvers); + const result = getCustomResolverMeta({ + field, + object, + objects, + validateResolvers: true, + interfaces, + unions, + customResolvers: resolvers, + }); expect(result).toBeUndefined(); }); - test("should throw if requires not a list - all strings", () => { + test("should return no required fields if no requires argument", () => { const field: FieldDefinitionNode = { directives: [ { @@ -85,14 +506,6 @@ describe("getCustomResolverMeta", () => { value: "customResolver", // @ts-ignore }, - arguments: [ - { - // @ts-ignore - name: { value: "requires" }, - // @ts-ignore - value: { kind: Kind.BOOLEAN }, - }, - ], }, { // @ts-ignore @@ -109,13 +522,26 @@ describe("getCustomResolverMeta", () => { ], name: { kind: Kind.NAME, - value: fieldName, + value: customResolverField, }, }; - expect(() => getCustomResolverMeta(field, object, true, resolvers)).toThrow(ERROR_MESSAGE); + const result = getCustomResolverMeta({ + field, + object, + objects, + validateResolvers: true, + interfaces, + unions, + customResolvers: resolvers, + }); + + expect(result).toEqual({ + requiredFields: {}, + }); }); - test("should throw if requires not a list of strings", () => { + test("should return the correct meta when a list of required fields is provided", () => { + const requiredFields = "name"; const field: FieldDefinitionNode = { directives: [ { @@ -130,12 +556,8 @@ describe("getCustomResolverMeta", () => { name: { value: "requires" }, // @ts-ignore value: { - kind: Kind.LIST, - values: [ - { kind: Kind.STRING, value: "field1" }, - { kind: Kind.STRING, value: "field2" }, - { kind: Kind.BOOLEAN, value: true }, - ], + kind: Kind.STRING, + value: requiredFields, }, }, ], @@ -155,13 +577,27 @@ describe("getCustomResolverMeta", () => { ], name: { kind: Kind.NAME, - value: fieldName, + value: customResolverField, }, }; - expect(() => getCustomResolverMeta(field, object, true, resolvers)).toThrow(ERROR_MESSAGE); + const result = getCustomResolverMeta({ + field, + object, + objects, + validateResolvers: true, + interfaces, + unions, + customResolvers: resolvers, + }); + + expect(result).toEqual({ + requiredFields: generateResolveTree({ name: requiredFields }), + }); }); - test("should return the correct meta if no requires argument", () => { + + test("should return the correct meta when a selection set of required fields provided", () => { + const requiredFields = `name publications { publicationYear ...on ${bookType} { title } ... on ${journalType} { subject } }`; const field: FieldDefinitionNode = { directives: [ { @@ -170,6 +606,17 @@ describe("getCustomResolverMeta", () => { value: "customResolver", // @ts-ignore }, + arguments: [ + { + // @ts-ignore + name: { value: "requires" }, + // @ts-ignore + value: { + kind: Kind.STRING, + value: requiredFields, + }, + }, + ], }, { // @ts-ignore @@ -186,18 +633,65 @@ describe("getCustomResolverMeta", () => { ], name: { kind: Kind.NAME, - value: fieldName, + value: customResolverField, }, }; - const result = getCustomResolverMeta(field, object, true, resolvers); + const result = getCustomResolverMeta({ + field, + object, + objects, + validateResolvers: true, + interfaces, + unions, + customResolvers: resolvers, + }); - expect(result).toMatchObject({ - requiredFields: [], + expect(result).toEqual({ + requiredFields: { + name: { + name: "name", + alias: "name", + args: {}, + fieldsByTypeName: {}, + }, + publications: { + name: "publications", + alias: "publications", + args: {}, + fieldsByTypeName: { + Publication: { + publicationYear: { + name: "publicationYear", + alias: "publicationYear", + args: {}, + fieldsByTypeName: {}, + }, + }, + Book: { + title: { + name: "title", + alias: "title", + args: {}, + fieldsByTypeName: {}, + }, + }, + Journal: { + subject: { + name: "subject", + alias: "subject", + args: {}, + fieldsByTypeName: {}, + }, + }, + }, + }, + }, }); }); - test("should return the correct meta with requires argument", () => { - const requiredFields = ["field1", "field2", "field3"]; + + test("should throw an error if a non-existant field is passed to the required selection set", () => { + const requiredFields = `name publications doesNotExist { publicationYear ...on ${bookType} { title } ... on ${journalType} { subject } }`; const field: FieldDefinitionNode = { directives: [ { @@ -212,11 +706,8 @@ describe("getCustomResolverMeta", () => { name: { value: "requires" }, // @ts-ignore value: { - kind: Kind.LIST, - values: requiredFields.map((requiredField) => ({ - kind: Kind.STRING, - value: requiredField, - })), + kind: Kind.STRING, + value: requiredFields, }, }, ], @@ -236,18 +727,25 @@ describe("getCustomResolverMeta", () => { ], name: { kind: Kind.NAME, - value: fieldName, + value: customResolverField, }, }; - const result = getCustomResolverMeta(field, object, true, resolvers); - - expect(result).toMatchObject({ - requiredFields, - }); + expect(() => + getCustomResolverMeta({ + field, + object, + objects, + validateResolvers: true, + interfaces, + unions, + customResolvers: resolvers, + }) + ).toThrow(INVALID_SELECTION_SET_ERROR); }); + test("Check throws error if customResolver is not provided", () => { - const requiredFields = ["field1", "field2", "field3"]; + const requiredFields = "name"; const field: FieldDefinitionNode = { directives: [ { @@ -262,11 +760,8 @@ describe("getCustomResolverMeta", () => { name: { value: "requires" }, // @ts-ignore value: { - kind: Kind.LIST, - values: requiredFields.map((requiredField) => ({ - kind: Kind.STRING, - value: requiredField, - })), + kind: Kind.STRING, + value: requiredFields, }, }, ], @@ -286,18 +781,26 @@ describe("getCustomResolverMeta", () => { ], name: { kind: Kind.NAME, - value: fieldName, + value: customResolverField, }, }; const resolvers = {}; - expect(() => getCustomResolverMeta(field, object, true, resolvers)).toThrow( - `Custom resolver for ${fieldName} has not been provided` - ); + expect(() => + getCustomResolverMeta({ + field, + object, + objects, + validateResolvers: true, + interfaces, + unions, + customResolvers: resolvers, + }) + ).toThrow(`Custom resolver for ${customResolverField} has not been provided`); }); test("Check throws error if customResolver defined on interface", () => { - const requiredFields = ["field1", "field2", "field3"]; + const requiredFields = "name"; const field: FieldDefinitionNode = { directives: [ { @@ -312,11 +815,8 @@ describe("getCustomResolverMeta", () => { name: { value: "requires" }, // @ts-ignore value: { - kind: Kind.LIST, - values: requiredFields.map((requiredField) => ({ - kind: Kind.STRING, - value: requiredField, - })), + kind: Kind.STRING, + value: requiredFields, }, }, ], @@ -336,23 +836,31 @@ describe("getCustomResolverMeta", () => { ], name: { kind: Kind.NAME, - value: fieldName, + value: customResolverField, }, }; const resolvers = { - [interfaceName]: { - [fieldName]: () => "Hello World!", + [publicationInterface]: { + [customResolverField]: () => "Hello World!", }, }; - expect(() => getCustomResolverMeta(field, object, true, resolvers)).toThrow( - `Custom resolver for ${fieldName} has not been provided` - ); + expect(() => + getCustomResolverMeta({ + field, + object, + objects, + validateResolvers: true, + interfaces, + unions, + customResolvers: resolvers, + }) + ).toThrow(`Custom resolver for ${customResolverField} has not been provided`); }); test("Check does not throw error if validateResolvers false", () => { - const requiredFields = ["field1", "field2", "field3"]; + const requiredFields = "name"; const field: FieldDefinitionNode = { directives: [ { @@ -367,11 +875,8 @@ describe("getCustomResolverMeta", () => { name: { value: "requires" }, // @ts-ignore value: { - kind: Kind.LIST, - values: requiredFields.map((requiredField) => ({ - kind: Kind.STRING, - value: requiredField, - })), + kind: Kind.STRING, + value: requiredFields, }, }, ], @@ -391,16 +896,26 @@ describe("getCustomResolverMeta", () => { ], name: { kind: Kind.NAME, - value: fieldName, + value: customResolverField, }, }; const resolvers = { - [interfaceName]: { - [fieldName]: () => "Hello World!", + [publicationInterface]: { + [customResolverField]: () => "Hello World!", }, }; - expect(() => getCustomResolverMeta(field, object, false, resolvers)).not.toThrow(); + expect(() => + getCustomResolverMeta({ + field, + object, + objects, + validateResolvers: false, + interfaces, + unions, + customResolvers: resolvers, + }) + ).not.toThrow(); }); }); diff --git a/packages/graphql/src/schema/get-custom-resolver-meta.ts b/packages/graphql/src/schema/get-custom-resolver-meta.ts index 29114f4a65..da70b20c7e 100644 --- a/packages/graphql/src/schema/get-custom-resolver-meta.ts +++ b/packages/graphql/src/schema/get-custom-resolver-meta.ts @@ -18,31 +18,54 @@ */ import type { IResolvers } from "@graphql-tools/utils"; -import type { +import { FieldDefinitionNode, - StringValueNode, InterfaceTypeDefinitionNode, ObjectTypeDefinitionNode, + DocumentNode, + SelectionSetNode, + TypeNode, + UnionTypeDefinitionNode, + Kind, + parse, + FieldNode, } from "graphql"; -import { Kind } from "graphql"; -import { removeDuplicates } from "../utils/utils"; +import type { FieldsByTypeName, ResolveTree } from "graphql-parse-resolve-info"; +import { generateResolveTree } from "../translate/utils/resolveTree"; type CustomResolverMeta = { - requiredFields: string[]; + requiredFields: Record; }; -export const ERROR_MESSAGE = "Required fields of @customResolver must be a list of strings"; +const INVALID_DIRECTIVES_TO_REQUIRE = ["customResolver"]; +export const INVALID_REQUIRED_FIELD_ERROR = `It is not possible to require fields that use the following directives: ${INVALID_DIRECTIVES_TO_REQUIRE.map( + (name) => `\`@${name}\`` +).join(", ")}`; +export const INVALID_SELECTION_SET_ERROR = "Invalid selection set passed to @customResolver required"; -function getCustomResolverMeta( - field: FieldDefinitionNode, - object: ObjectTypeDefinitionNode | InterfaceTypeDefinitionNode, - validateResolvers: boolean, - customResolvers?: IResolvers | IResolvers[], - interfaceField?: FieldDefinitionNode -): CustomResolverMeta | undefined { +export function getCustomResolverMeta({ + field, + object, + objects, + validateResolvers, + interfaces, + unions, + customResolvers, + interfaceField, +}: { + field: FieldDefinitionNode; + object: ObjectTypeDefinitionNode | InterfaceTypeDefinitionNode; + objects: ObjectTypeDefinitionNode[]; + validateResolvers: boolean; + interfaces: InterfaceTypeDefinitionNode[]; + unions: UnionTypeDefinitionNode[]; + customResolvers?: IResolvers | IResolvers[]; + interfaceField?: FieldDefinitionNode; +}): CustomResolverMeta | undefined { const directive = field.directives?.find((x) => x.name.value === "customResolver") || interfaceField?.directives?.find((x) => x.name.value === "customResolver"); + if (!directive) { return undefined; } @@ -51,27 +74,212 @@ function getCustomResolverMeta( throw new Error(`Custom resolver for ${field.name.value} has not been provided`); } - const directiveFromArgument = directive?.arguments?.find((arg) => arg.name.value === "requires"); - if (!directiveFromArgument) { + const directiveRequiresArgument = directive?.arguments?.find((arg) => arg.name.value === "requires"); + + if (!directiveRequiresArgument) { return { - requiredFields: [], + requiredFields: {}, }; } - if ( - directiveFromArgument?.value.kind !== Kind.LIST || - directiveFromArgument?.value.values.some((value) => value.kind !== Kind.STRING) - ) { - throw new Error(ERROR_MESSAGE); + if (directiveRequiresArgument?.value.kind !== Kind.STRING) { + throw new Error("@customResolver requires expects a string"); } - const requiredFields = removeDuplicates( - directiveFromArgument.value.values.map((v) => (v as StringValueNode).value) ?? [] + const selectionSetDocument = parse(`{ ${directiveRequiresArgument.value.value} }`); + const requiredFieldsResolveTree = selectionSetToResolveTree( + object.fields || [], + objects, + interfaces, + unions, + selectionSetDocument ); + if (requiredFieldsResolveTree) { + return { + requiredFields: requiredFieldsResolveTree, + }; + } +} + +function selectionSetToResolveTree( + objectFields: ReadonlyArray, + objects: ObjectTypeDefinitionNode[], + interfaces: InterfaceTypeDefinitionNode[], + unions: UnionTypeDefinitionNode[], + document: DocumentNode +) { + if (document.definitions.length !== 1) { + throw new Error(INVALID_SELECTION_SET_ERROR); + } + + const selectionSetDocument = document.definitions[0]; + if (selectionSetDocument.kind !== Kind.OPERATION_DEFINITION) { + throw new Error(INVALID_SELECTION_SET_ERROR); + } + + return nestedSelectionSetToResolveTrees( + objectFields, + objects, + interfaces, + unions, + selectionSetDocument.selectionSet + ); +} + +function nestedSelectionSetToResolveTrees( + object: ReadonlyArray, + objects: ObjectTypeDefinitionNode[], + interfaces: InterfaceTypeDefinitionNode[], + unions: UnionTypeDefinitionNode[], + selectionSet: SelectionSetNode +): Record; +function nestedSelectionSetToResolveTrees( + object: ReadonlyArray, + objects: ObjectTypeDefinitionNode[], + interfaces: InterfaceTypeDefinitionNode[], + unions: UnionTypeDefinitionNode[], + selectionSet: SelectionSetNode, + outerFieldType: string +): FieldsByTypeName; +function nestedSelectionSetToResolveTrees( + objectFields: ReadonlyArray, + objects: ObjectTypeDefinitionNode[], + interfaces: InterfaceTypeDefinitionNode[], + unions: UnionTypeDefinitionNode[], + selectionSet: SelectionSetNode, + outerFieldType?: string +): Record | FieldsByTypeName { + const result = selectionSet.selections.reduce((acc, selection) => { + let nestedResolveTree = {}; + if (selection.kind === Kind.FRAGMENT_SPREAD) { + throw new Error("Fragment spreads are not supported in @customResolver requires"); + } + if (selection.kind === Kind.INLINE_FRAGMENT) { + if (!selection.selectionSet) { + return acc; + } + const fieldType = selection.typeCondition?.name.value; + if (!fieldType) { + throw new Error(INVALID_SELECTION_SET_ERROR); + } + const innerObjectFields = objects.find((obj) => obj.name.value === fieldType)?.fields; + if (!innerObjectFields) { + throw new Error(INVALID_SELECTION_SET_ERROR); + } + + const nestedResolveTree = nestedSelectionSetToResolveTrees( + innerObjectFields, + objects, + interfaces, + unions, + selection.selectionSet + ); - return { - requiredFields, - }; + return { + ...acc, + [fieldType]: nestedResolveTree, + }; + } + if (selection.selectionSet) { + const field = objectFields.find((field) => field.name.value === selection.name.value); + const fieldType = getNestedType(field?.type); + const innerObjectFields = getInnerObjectFields({ fieldType, objects, interfaces, unions }); + nestedResolveTree = nestedSelectionSetToResolveTrees( + innerObjectFields, + objects, + interfaces, + unions, + selection.selectionSet, + fieldType + ); + } + + validateRequiredField({ selection, outerFieldType, objectFields, objects }); + + if (outerFieldType) { + return { + ...acc, + [outerFieldType]: { + ...acc[outerFieldType], + ...generateResolveTree({ + name: selection.name.value, + fieldsByTypeName: nestedResolveTree, + }), + }, + }; + } + return { + ...acc, + ...generateResolveTree({ + name: selection.name.value, + fieldsByTypeName: nestedResolveTree, + }), + }; + }, {}); + return result; } -export default getCustomResolverMeta; +function getNestedType(type: TypeNode | undefined): string { + if (!type) { + throw new Error(INVALID_SELECTION_SET_ERROR); + } + if (type.kind !== Kind.NAMED_TYPE) { + return getNestedType(type.type); + } + return type.name.value; +} + +function getInnerObjectFields({ + fieldType, + objects, + interfaces, + unions, +}: { + fieldType: string; + objects: ObjectTypeDefinitionNode[]; + interfaces: InterfaceTypeDefinitionNode[]; + unions: UnionTypeDefinitionNode[]; +}) { + const unionImplementations = unions.find((union) => union.name.value === fieldType)?.types; + const innerObjectFields = + [...objects, ...interfaces].find((obj) => obj.name.value === fieldType)?.fields || + unionImplementations?.flatMap( + (implementation) => + [...objects, ...interfaces].find((obj) => obj.name.value === implementation.name.value)?.fields || [] + ); + if (!innerObjectFields) { + throw new Error(INVALID_SELECTION_SET_ERROR); + } + return innerObjectFields; +} + +function validateRequiredField({ + selection, + outerFieldType, + objectFields, + objects, +}: { + selection: FieldNode; + outerFieldType: string | undefined; + objectFields: ReadonlyArray; + objects: ObjectTypeDefinitionNode[]; +}): void { + const fieldImplementations = [objectFields.find((field) => field.name.value === selection.name.value)]; + const objectsImplementingInterface = objects.filter((obj) => + obj.interfaces?.find((inter) => inter.name.value === outerFieldType) + ); + objectsImplementingInterface.forEach((obj) => + obj.fields?.forEach((objField) => { + if (objField.name.value === selection.name.value) { + fieldImplementations.push(objField); + } + }) + ); + if ( + fieldImplementations.find((field) => + field?.directives?.find((directive) => INVALID_DIRECTIVES_TO_REQUIRE.includes(directive.name.value)) + ) + ) { + throw new Error(INVALID_REQUIRED_FIELD_ERROR); + } +} diff --git a/packages/graphql/src/schema/get-nodes.ts b/packages/graphql/src/schema/get-nodes.ts index 2e180e1d9e..e263886bee 100644 --- a/packages/graphql/src/schema/get-nodes.ts +++ b/packages/graphql/src/schema/get-nodes.ts @@ -141,31 +141,6 @@ function getNodes( validateResolvers: options.validateResolvers, }); - // Ensure that all required fields are returning either a scalar type or an enum - - const violativeRequiredField = nodeFields.customResolverFields - .filter((f) => f.requiredFields.length) - .map((f) => f.requiredFields) - .flat() - .find( - (requiredField) => - ![ - ...nodeFields.primitiveFields, - ...nodeFields.scalarFields, - ...nodeFields.enumFields, - ...nodeFields.temporalFields, - ...nodeFields.cypherFields.filter((field) => field.isScalar || field.isEnum), - ] - .map((x) => x.fieldName) - .includes(requiredField) - ); - - if (violativeRequiredField) { - throw new Error( - `Cannot have ${violativeRequiredField} as a required field on node ${definition.name.value}. Required fields must return a scalar type.` - ); - } - let fulltextDirective: FullText; if (fulltextDirectiveDefinition) { fulltextDirective = parseFulltextDirective({ diff --git a/packages/graphql/src/schema/get-obj-field-meta.ts b/packages/graphql/src/schema/get-obj-field-meta.ts index 4e64c65afb..5f51998b2a 100644 --- a/packages/graphql/src/schema/get-obj-field-meta.ts +++ b/packages/graphql/src/schema/get-obj-field-meta.ts @@ -38,7 +38,7 @@ import getAuth from "./get-auth"; import getAliasMeta from "./get-alias-meta"; import { getCypherMeta } from "./get-cypher-meta"; import getFieldTypeMeta from "./get-field-type-meta"; -import getCustomResolverMeta from "./get-custom-resolver-meta"; +import { getCustomResolverMeta } from "./get-custom-resolver-meta"; import getRelationshipMeta from "./get-relationship-meta"; import getUniqueMeta from "./parse/get-unique-meta"; import { SCALAR_TYPES } from "../constants"; @@ -132,13 +132,16 @@ function getObjFieldMeta({ const relationshipMeta = getRelationshipMeta(field, interfaceField); const cypherMeta = getCypherMeta(field, interfaceField); - const customResolverMeta = getCustomResolverMeta( + const customResolverMeta = getCustomResolverMeta({ field, - obj, + object: obj, + objects, + interfaces, + unions, validateResolvers, customResolvers, - interfaceField - ); + interfaceField, + }); const typeMeta = getFieldTypeMeta(field.type); const authDirective = directives.find((x) => x.name.value === "auth"); const idDirective = directives.find((x) => x.name.value === "id"); diff --git a/packages/graphql/src/schema/make-augmented-schema.test.ts b/packages/graphql/src/schema/make-augmented-schema.test.ts index b98d4f6591..d672e1a49d 100644 --- a/packages/graphql/src/schema/make-augmented-schema.test.ts +++ b/packages/graphql/src/schema/make-augmented-schema.test.ts @@ -23,7 +23,7 @@ import type { NamedTypeNode, ListTypeNode, NonNullTypeNode, - InputObjectTypeDefinitionNode + InputObjectTypeDefinitionNode, } from "graphql"; import { pluralize } from "graphql-compose"; import { gql } from "apollo-server"; @@ -160,7 +160,7 @@ describe("makeAugmentedSchema", () => { const neoSchema = makeAugmentedSchema(typeDefs, { enableRegex: true, - validateResolvers: true + validateResolvers: true, }); const document = neoSchema.typeDefs; diff --git a/packages/graphql/src/schema/validation/validate-custom-resolver-requires.ts b/packages/graphql/src/schema/validation/validate-custom-resolver-requires.ts new file mode 100644 index 0000000000..feb31c7514 --- /dev/null +++ b/packages/graphql/src/schema/validation/validate-custom-resolver-requires.ts @@ -0,0 +1,71 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mergeSchemas } from "@graphql-tools/schema"; +import { + DocumentNode, + GraphQLSchema, + InterfaceTypeDefinitionNode, + Kind, + ObjectTypeDefinitionNode, + parse, + validate, +} from "graphql"; +import { getDefinitionNodes } from "../get-definition-nodes"; + +export function validateCustomResolverRequires(document: DocumentNode, schema: GraphQLSchema) { + const definitionNodes = getDefinitionNodes(document); + definitionNodes.objectTypes.forEach((objType) => { + objType.fields?.forEach((field) => { + const customResolverDirective = field.directives?.find( + (directive) => directive.name.value === "customResolver" + ); + const requiresArg = customResolverDirective?.arguments?.find((arg) => arg.name.value === "requires"); + if (requiresArg) { + if (requiresArg?.value.kind !== Kind.STRING) { + throw new Error("@customResolver requires expects a string"); + } + const selectionSetDocument = parse(`{ ${requiresArg.value.value} }`); + validateSelectionSet(schema, objType, selectionSetDocument); + } + }); + }); +} + +function validateSelectionSet( + baseSchema: GraphQLSchema, + object: ObjectTypeDefinitionNode | InterfaceTypeDefinitionNode, + selectionSetDocument: DocumentNode +) { + const validationSchema = mergeSchemas({ + schemas: [baseSchema], + typeDefs: ` + schema { + query: ${object.name.value} + } + `, + assumeValid: true, + }); + const errors = validate(validationSchema, selectionSetDocument); + if (errors.length) { + throw new Error( + `Invalid selection set provided to @customResolver on ${object.name.value}:\n${errors.join("\n")}` + ); + } +} diff --git a/packages/graphql/src/schema/validation/validate-document.test.ts b/packages/graphql/src/schema/validation/validate-document.test.ts index cbbddf99cc..19f0bc7dcf 100644 --- a/packages/graphql/src/schema/validation/validate-document.test.ts +++ b/packages/graphql/src/schema/validation/validate-document.test.ts @@ -153,8 +153,7 @@ describe("validateDocument", () => { } `; - const res = validateDocument(doc); - expect(res).toBeUndefined(); + expect(() => validateDocument(doc)).not.toThrow(); }); test("should not throw error on use of internal node input types", () => { @@ -182,8 +181,7 @@ describe("validateDocument", () => { } `; - const res = validateDocument(doc); - expect(res).toBeUndefined(); + expect(() => validateDocument(doc)).not.toThrow(); }); describe("relationshipProperties directive", () => { @@ -204,8 +202,7 @@ describe("validateDocument", () => { } `; - const res = validateDocument(doc); - expect(res).toBeUndefined(); + expect(() => validateDocument(doc)).not.toThrow(); }); test("should throw if used on an object type", () => { @@ -310,8 +307,7 @@ describe("validateDocument", () => { } `; - const res = validateDocument(doc); - expect(res).toBeUndefined(); + expect(() => validateDocument(doc)).not.toThrow(); }); describe("Github Issue 158", () => { @@ -326,8 +322,7 @@ describe("validateDocument", () => { } `; - const res = validateDocument(doc); - expect(res).toBeUndefined(); + expect(() => validateDocument(doc)).not.toThrow(); }); }); @@ -344,8 +339,7 @@ describe("validateDocument", () => { } `; - const res = validateDocument(doc); - expect(res).toBeUndefined(); + expect(() => validateDocument(doc)).not.toThrow(); }); test("should not throw error on validation of schema if SortDirection used", () => { @@ -360,8 +354,7 @@ describe("validateDocument", () => { } `; - const res = validateDocument(doc); - expect(res).toBeUndefined(); + expect(() => validateDocument(doc)).not.toThrow(); }); }); @@ -494,8 +487,7 @@ describe("validateDocument", () => { } `; - const res = validateDocument(doc); - expect(res).toBeUndefined(); + expect(() => validateDocument(doc)).not.toThrow(); }); }); @@ -517,8 +509,7 @@ describe("validateDocument", () => { } `; - const res = validateDocument(doc); - expect(res).toBeUndefined(); + expect(() => validateDocument(doc)).not.toThrow(); }); }); @@ -547,8 +538,7 @@ describe("validateDocument", () => { name: String @alias(property: "dbName") } `; - const res = validateDocument(doc); - expect(res).toBeUndefined(); + expect(() => validateDocument(doc)).not.toThrow(); }); }); @@ -665,8 +655,7 @@ describe("validateDocument", () => { } `; - const res = validateDocument(doc); - expect(res).toBeUndefined(); + expect(() => validateDocument(doc)).not.toThrow(); }); }); @@ -686,8 +675,7 @@ describe("validateDocument", () => { } `; - const res = validateDocument(doc); - expect(res).toBeUndefined(); + expect(() => validateDocument(doc)).not.toThrow(); }); }); }); diff --git a/packages/graphql/src/schema/validation/validate-document.ts b/packages/graphql/src/schema/validation/validate-document.ts index 48230244e4..929c5a38bb 100644 --- a/packages/graphql/src/schema/validation/validate-document.ts +++ b/packages/graphql/src/schema/validation/validate-document.ts @@ -40,6 +40,7 @@ import { PointDistance } from "../../graphql/input-objects/PointDistance"; import { CartesianPointDistance } from "../../graphql/input-objects/CartesianPointDistance"; import { RESERVED_TYPE_NAMES } from "../../constants"; import { isRootType } from "../../utils/is-root-type"; +import { validateCustomResolverRequires } from "./validate-custom-resolver-requires"; function filterDocument(document: DocumentNode): DocumentNode { const nodeNames = document.definitions @@ -158,11 +159,12 @@ function filterDocument(document: DocumentNode): DocumentNode { }; } -function validateDocument( +function getBaseSchema( document: DocumentNode, + validateTypeDefs = true, additionalDirectives: Array = [], additionalTypes: Array = [] -): void { +): GraphQLSchema { const doc = filterDocument(document); const schemaToExtend = new GraphQLSchema({ @@ -180,15 +182,24 @@ function validateDocument( ], }); - const schema = extendSchema(schemaToExtend, doc); - - const errors = validateSchema(schema); - - const filteredErrors = errors.filter((e) => e.message !== "Query root type must be provided."); + return extendSchema(schemaToExtend, doc, { assumeValid: !validateTypeDefs }); +} - if (filteredErrors.length) { - throw new Error(filteredErrors.join("\n")); +function validateDocument( + document: DocumentNode, + validateTypeDefs = true, + additionalDirectives: Array = [], + additionalTypes: Array = [] +): void { + const schema = getBaseSchema(document, validateTypeDefs, additionalDirectives, additionalTypes); + if (validateTypeDefs) { + const errors = validateSchema(schema); + const filteredErrors = errors.filter((e) => e.message !== "Query root type must be provided."); + if (filteredErrors.length) { + throw new Error(filteredErrors.join("\n")); + } } + validateCustomResolverRequires(document, schema); } export default validateDocument; diff --git a/packages/graphql/src/translate/create-projection-and-params.ts b/packages/graphql/src/translate/create-projection-and-params.ts index 966ecc1263..37dc905acc 100644 --- a/packages/graphql/src/translate/create-projection-and-params.ts +++ b/packages/graphql/src/translate/create-projection-and-params.ts @@ -29,7 +29,7 @@ import mapToDbProperty from "../utils/map-to-db-property"; import { createFieldAggregation } from "./field-aggregations/create-field-aggregation"; import { addGlobalIdField } from "../utils/global-node-projection"; import { getCypherRelationshipDirection } from "../utils/get-relationship-direction"; -import { generateMissingOrAliasedFields, filterFieldsInSelection, generateProjectionField } from "./utils/resolveTree"; +import { generateMissingOrAliasedFields, filterFieldsInSelection, generateResolveTree } from "./utils/resolveTree"; import { removeDuplicates } from "../utils/utils"; import { createProjectionSubquery } from "./projection/subquery/create-projection-subquery"; import { collectUnionSubqueriesResults } from "./projection/subquery/collect-union-subqueries-results"; @@ -354,8 +354,8 @@ export default function createProjectionAndParams({ // If fieldname is not found in fields of selection set ...(!Object.values(existingProjection).find((field) => field.name === sortFieldName) ? // generate a basic resolve tree - generateProjectionField({ name: sortFieldName }) - : {}) + generateResolveTree({ name: sortFieldName }) + : {}), }), // and add it to existing fields for projection existingProjection @@ -373,7 +373,7 @@ export default function createProjectionAndParams({ const mergedFields: Record = mergeDeep[]>([ mergedSelectedFields, generateMissingOrAliasedSortFields({ selection: mergedSelectedFields, resolveTree }), - generateMissingOrAliasedRequiredFields({ selection: mergedSelectedFields, node }) + ...generateMissingOrAliasedRequiredFields({ selection: mergedSelectedFields, node }), ]); const { projection, params, meta, subqueries, subqueriesBeforeSort } = Object.values(mergedFields).reduce(reducer, { @@ -421,14 +421,14 @@ const generateMissingOrAliasedRequiredFields = ({ }: { node: Node; selection: Record; -}): Record => { +}): Record[] => { const requiredFields = removeDuplicates( filterFieldsInSelection({ fields: node.customResolverFields, selection }) .map((f) => f.requiredFields) .flat() ); - return generateMissingOrAliasedFields({ fieldNames: requiredFields, selection }); + return requiredFields; }; function createFulltextProjection({ diff --git a/packages/graphql/src/translate/utils/resolveTree.test.ts b/packages/graphql/src/translate/utils/resolveTree.test.ts index 9a5069c25c..1539531a56 100644 --- a/packages/graphql/src/translate/utils/resolveTree.test.ts +++ b/packages/graphql/src/translate/utils/resolveTree.test.ts @@ -21,7 +21,7 @@ import type { ResolveTree } from "graphql-parse-resolve-info"; import { generate } from "randomstring"; import { generateMissingOrAliasedFields, - generateProjectionField, + generateResolveTree, getAliasedResolveTreeByFieldName, getResolveTreeByFieldName, } from "./resolveTree"; @@ -60,7 +60,7 @@ describe("resolveTree", () => { test("generate projection field", () => { const name = generate({ charset: "alphabetic" }); - const field = generateProjectionField({ name }); + const field = generateResolveTree({ name }); expect(field).toStrictEqual({ [name]: { @@ -76,7 +76,7 @@ describe("resolveTree", () => { const alias = generate({ charset: "alphabetic" }); const name = generate({ charset: "alphabetic" }); - const resolveTree = generateProjectionField({ name, alias }); + const resolveTree = generateResolveTree({ name, alias }); expect(resolveTree).toStrictEqual({ [name]: { diff --git a/packages/graphql/src/translate/utils/resolveTree.ts b/packages/graphql/src/translate/utils/resolveTree.ts index 5f358fcb23..a2c269f706 100644 --- a/packages/graphql/src/translate/utils/resolveTree.ts +++ b/packages/graphql/src/translate/utils/resolveTree.ts @@ -56,7 +56,7 @@ export function filterFieldsInSelection({ } /** Generates a field to be used in creating projections */ -export function generateProjectionField({ +export function generateResolveTree({ name, alias, args = {}, @@ -84,7 +84,7 @@ export function generateMissingOrAliasedFields({ const exists = getResolveTreeByFieldName({ fieldName, selection }); const aliased = getAliasedResolveTreeByFieldName({ fieldName, selection }); if (!exists || aliased) { - return { ...acc, ...generateProjectionField({ name: fieldName }) }; + return { ...acc, ...generateResolveTree({ name: fieldName }) }; } return acc; }, {}); diff --git a/packages/graphql/src/types.ts b/packages/graphql/src/types.ts index 801c90ce77..2b1d67ecf3 100644 --- a/packages/graphql/src/types.ts +++ b/packages/graphql/src/types.ts @@ -201,7 +201,7 @@ export interface UnionField extends BaseField { } export interface CustomResolverField extends BaseField { - requiredFields: string[]; + requiredFields: Record; } export interface InterfaceField extends BaseField { diff --git a/packages/graphql/tests/e2e/federation/apollo-federation-subgraph-compatibility/apollo-federation-subgraph-compatibility.e2e.test.ts b/packages/graphql/tests/e2e/federation/apollo-federation-subgraph-compatibility/apollo-federation-subgraph-compatibility.e2e.test.ts index 1d20de7630..d4fa3c48f4 100644 --- a/packages/graphql/tests/e2e/federation/apollo-federation-subgraph-compatibility/apollo-federation-subgraph-compatibility.e2e.test.ts +++ b/packages/graphql/tests/e2e/federation/apollo-federation-subgraph-compatibility/apollo-federation-subgraph-compatibility.e2e.test.ts @@ -97,11 +97,15 @@ describe("Tests copied from https://github.com/apollographql/apollo-federation-s type Query { product(id: ID!): Product - @cypher(statement: "MATCH (product:Product) WHERE product.id = $id RETURN product") + @cypher( + statement: "MATCH (product:Product) WHERE product.id = $id RETURN product" + columnName: "product" + ) deprecatedProduct(sku: String!, package: String!): DeprecatedProduct @deprecated(reason: "Use product query instead") @cypher( statement: "MATCH (product:DeprecatedProduct) WHERE product.sku = $sku AND product.package = $package = $id RETURN product" + columnName: "product" ) } diff --git a/packages/graphql/tests/integration/config-options/startup-validation.int.test.ts b/packages/graphql/tests/integration/config-options/startup-validation.int.test.ts index 05e5547d03..3eeb4e9fef 100644 --- a/packages/graphql/tests/integration/config-options/startup-validation.int.test.ts +++ b/packages/graphql/tests/integration/config-options/startup-validation.int.test.ts @@ -33,7 +33,7 @@ describe("Startup Validation", () => { id: ID! firstName: String! lastName: String! - fullName: String @customResolver(requires: ["firstName", "lastName"]) + fullName: String @customResolver(requires: "firstName lastName") } `; @@ -49,7 +49,7 @@ describe("Startup Validation", () => { id: ID! firstName: String! lastName: String! - fullName: String @customResolver(requires: ["firstName", "lastName"]) + fullName: String @customResolver(requires: "firstName lastName") } type Point { diff --git a/packages/graphql/tests/integration/directives/customResolver.int.test.ts b/packages/graphql/tests/integration/directives/customResolver.int.test.ts index d8c7459d85..497875e9a7 100644 --- a/packages/graphql/tests/integration/directives/customResolver.int.test.ts +++ b/packages/graphql/tests/integration/directives/customResolver.int.test.ts @@ -20,9 +20,14 @@ import type { Driver } from "neo4j-driver"; import type { GraphQLSchema } from "graphql"; import { graphql } from "graphql"; +import { gql } from "apollo-server"; import Neo4j from "../neo4j"; import { Neo4jGraphQL } from "../../../src/classes"; import { UniqueType } from "../../utils/graphql-types"; +import { cleanNodes } from "../../utils/clean-nodes"; +import { Neo4jGraphQLAuthJWTPlugin } from "../../../../plugins/graphql-plugin-auth/src"; +import { createJwtRequest } from "../../utils/create-jwt-request"; +import { INVALID_REQUIRED_FIELD_ERROR } from "../../../src/schema/get-custom-resolver-meta"; describe("@customResolver directive", () => { let driver: Driver; @@ -42,7 +47,7 @@ describe("@customResolver directive", () => { id: ID! firstName: String! lastName: String! - ${customResolverField}: String @customResolver(requires: ["firstName", "lastName"]) + ${customResolverField}: String @customResolver(requires: "firstName lastName") } `; @@ -167,7 +172,7 @@ describe("@customResolver directive", () => { id: ID! firstName: String! @cypher(statement: "RETURN '${user.firstName}' as x", columnName: "x") lastName: String! @cypher(statement: "RETURN '${user.lastName}' as x", columnName: "x") - fullName: String @customResolver(requires: ["firstName", "lastName"]) + fullName: String @customResolver(requires: "firstName lastName") } `; @@ -287,7 +292,7 @@ describe("@customResolver directive", () => { const interfaceType = new UniqueType("UserInterface"); const typeDefs = ` interface ${interfaceType.name} { - ${customResolverField}: String @customResolver(requires: ["firstName", "lastName"]) + ${customResolverField}: String @customResolver(requires: "firstName lastName") } type ${testType} implements ${interfaceType.name} { @@ -311,3 +316,2225 @@ describe("@customResolver directive", () => { }); }); }); + +describe("Related Fields", () => { + let driver: Driver; + let neo4j: Neo4j; + + let Publication: UniqueType; + let Author: UniqueType; + let Book: UniqueType; + let Journal: UniqueType; + let User: UniqueType; + let Address: UniqueType; + let City: UniqueType; + let State: UniqueType; + + const userInput1 = { + id: "1", + firstName: "First", + lastName: "Last", + }; + const userInput2 = { + id: "2", + firstName: "New First", + lastName: "new-last", + }; + const addressInput1 = { + city: "some city", + street: "some street", + }; + const addressInput2 = { + city: "another-city", + street: "another-street", + }; + const cityInput1 = { + name: "city1 name!", + population: 8947975, + }; + const cityInput2 = { + name: "city2 name?", + population: 74, + }; + const stateInput = { + someValue: 4797, + }; + const authorInput1 = { + name: "some-author-name", + }; + const authorInput2 = { + name: "another author name", + }; + const bookInput1 = { + title: "a book name", + publicationYear: 12, + }; + const bookInput2 = { + title: "another-book-name", + publicationYear: 1074, + }; + const journalInput1 = { + subject: "a subject", + publicationYear: 573, + }; + const journalInput2 = { + subject: "a second subject", + publicationYear: 9087, + }; + const secret = "secret"; + + beforeAll(async () => { + neo4j = new Neo4j(); + driver = await neo4j.getDriver(); + }); + + beforeEach(() => { + Publication = new UniqueType("Publication"); + Author = new UniqueType("Author"); + Book = new UniqueType("Book"); + Journal = new UniqueType("Journal"); + User = new UniqueType("User"); + Address = new UniqueType("Address"); + City = new UniqueType("City"); + State = new UniqueType("State"); + }); + + afterEach(async () => { + const session = await neo4j.getSession(); + try { + await cleanNodes(session, [Publication, Author, Book, Journal, User, Address, City, State]); + } finally { + await session.close(); + } + }); + + afterAll(async () => { + await driver.close(); + }); + + test("should be able to require a field from a related type", async () => { + const session = await neo4j.getSession(); + try { + await session.run( + `CREATE (user:${User})-[:LIVES_AT]->(addr:${Address}) SET user = $userInput1, addr = $addressInput1`, + { userInput1, addressInput1 } + ); + } finally { + await session.close(); + } + + const typeDefs = gql` + type ${Address} { + street: String! + city: String! + } + + type ${User} { + id: ID! + firstName: String! + lastName: String! + address: ${Address} @relationship(type: "LIVES_AT", direction: OUT) + fullName: String @customResolver(requires: "firstName lastName address { city }") + } + `; + + const fullNameResolver = ({ firstName, lastName, address }) => `${firstName} ${lastName} from ${address.city}`; + + const resolvers = { + [User.name]: { + fullName: fullNameResolver, + }, + }; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + }); + + const query = ` + query ${User} { + ${User.plural} { + fullName + } + } + `; + + const result = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues(), + }); + + expect(result.errors).toBeFalsy(); + expect(result.data as any).toEqual({ + [User.plural]: [ + { + fullName: fullNameResolver({ + firstName: userInput1.firstName, + lastName: userInput1.lastName, + address: { city: addressInput1.city }, + }), + }, + ], + }); + }); + + test("should throw an error if requiring a field that does not exist", async () => { + const session = await neo4j.getSession(); + try { + await session.run( + `CREATE (user:${User})-[:LIVES_AT]->(addr:${Address}) SET user = $userInput1, addr = $addressInput1`, + { userInput1, addressInput1 } + ); + } finally { + await session.close(); + } + + const typeDefs = gql` + type ${Address} { + street: String! + city: String! + } + + type ${User} { + id: ID! + firstName: String! + lastName: String! + address: ${Address} @relationship(type: "LIVES_AT", direction: OUT) + fullName: String @customResolver(requires: "firstName lastName address { city } doesNotExist") + } + `; + + const fullNameResolver = ({ firstName, lastName, address }) => `${firstName} ${lastName} from ${address.city}`; + + const resolvers = { + [User.name]: { + fullName: fullNameResolver, + }, + }; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + }); + + await expect(neoSchema.getSchema()).rejects.toThrow( + `Invalid selection set provided to @customResolver on ${User}` + ); + }); + + test("should throw an error if requiring a related field that does not exist", async () => { + const session = await neo4j.getSession(); + try { + await session.run( + `CREATE (user:${User})-[:LIVES_AT]->(addr:${Address}) SET user = $userInput1, addr = $addressInput1`, + { userInput1, addressInput1 } + ); + } finally { + await session.close(); + } + + const typeDefs = gql` + type ${Address} { + street: String! + city: String! + } + + type ${User} { + id: ID! + firstName: String! + lastName: String! + address: ${Address} @relationship(type: "LIVES_AT", direction: OUT) + fullName: String @customResolver(requires: "firstName lastName address { city doesNotExist }") + } + `; + + const fullNameResolver = ({ firstName, lastName, address }) => `${firstName} ${lastName} from ${address.city}`; + + const resolvers = { + [User.name]: { + fullName: fullNameResolver, + }, + }; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + }); + + await expect(neoSchema.getSchema()).rejects.toThrow( + `Invalid selection set provided to @customResolver on ${User}` + ); + }); + + test("should fetch required fields when other fields are also selected", async () => { + const session = await neo4j.getSession(); + try { + await session.run( + `CREATE (user:${User})-[:LIVES_AT]->(addr:${Address}) SET user = $userInput1, addr = $addressInput1`, + { userInput1, addressInput1 } + ); + } finally { + await session.close(); + } + + const typeDefs = gql` + type ${Address} { + street: String! + city: String! + } + + type ${User} { + id: ID! + firstName: String! + lastName: String! + address: ${Address} @relationship(type: "LIVES_AT", direction: OUT) + fullName: String @customResolver(requires: "firstName lastName address { city }") + } + `; + + const fullNameResolver = ({ firstName, lastName, address }) => `${firstName} ${lastName} from ${address.city}`; + + const resolvers = { + [User.name]: { + fullName: fullNameResolver, + }, + }; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + }); + + const query = ` + query ${User} { + ${User.plural} { + id + fullName + address { + street + city + } + } + } + `; + + const result = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues(), + }); + + expect(result.errors).toBeFalsy(); + expect(result.data as any).toEqual({ + [User.plural]: [ + { + id: userInput1.id, + address: addressInput1, + fullName: fullNameResolver({ + firstName: userInput1.firstName, + lastName: userInput1.lastName, + address: { city: addressInput1.city }, + }), + }, + ], + }); + }); + + test("should fetch customResolver fields over multiple users", async () => { + const session = await neo4j.getSession(); + try { + await session.run( + ` + CREATE (user1:${User})-[:LIVES_AT]->(addr1:${Address}) SET user1 = $userInput1, addr1 = $addressInput1 + CREATE (user2:${User})-[:LIVES_AT]->(addr2:${Address}) SET user2 = $userInput2, addr2 = $addressInput2 + `, + { userInput1, addressInput1, userInput2, addressInput2 } + ); + } finally { + await session.close(); + } + + const typeDefs = gql` + type ${Address} { + street: String! + city: String! + } + + type ${User} { + id: ID! + firstName: String! + lastName: String! + address: ${Address} @relationship(type: "LIVES_AT", direction: OUT) + fullName: String @customResolver(requires: "firstName lastName address { city }") + } + `; + + const fullNameResolver = ({ firstName, lastName, address }) => `${firstName} ${lastName} from ${address.city}`; + + const resolvers = { + [User.name]: { + fullName: fullNameResolver, + }, + }; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + }); + + const query = ` + query ${User} { + ${User.plural} { + id + fullName + address { + street + city + } + } + } + `; + + const result = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues(), + }); + + expect(result.errors).toBeFalsy(); + expect(result.data as any).toEqual({ + [User.plural]: expect.toIncludeSameMembers([ + { + id: userInput1.id, + address: addressInput1, + fullName: fullNameResolver({ + firstName: userInput1.firstName, + lastName: userInput1.lastName, + address: { city: addressInput1.city }, + }), + }, + { + id: userInput2.id, + address: addressInput2, + fullName: fullNameResolver({ + firstName: userInput2.firstName, + lastName: userInput2.lastName, + address: { city: addressInput2.city }, + }), + }, + ]), + }); + }); + + test("should select related fields when not selected last", async () => { + const session = await neo4j.getSession(); + try { + await session.run( + ` + CREATE (user1:${User})-[:LIVES_AT]->(addr1:${Address}) SET user1 = $userInput1, addr1 = $addressInput1 + CREATE (user2:${User})-[:LIVES_AT]->(addr2:${Address}) SET user2 = $userInput2, addr2 = $addressInput2 + `, + { userInput1, addressInput1, userInput2, addressInput2 } + ); + } finally { + await session.close(); + } + + const typeDefs = gql` + type ${Address} { + street: String! + city: String! + } + + type ${User} { + id: ID! + firstName: String! + lastName: String! + address: ${Address} @relationship(type: "LIVES_AT", direction: OUT) + fullName: String @customResolver(requires: "firstName address { city } lastName") + } + `; + + const fullNameResolver = ({ firstName, lastName, address }) => `${firstName} ${lastName} from ${address.city}`; + + const resolvers = { + [User.name]: { + fullName: fullNameResolver, + }, + }; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + }); + + const query = ` + query ${User} { + ${User.plural} { + id + fullName + address { + street + city + } + } + } + `; + + const result = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues(), + }); + + expect(result.errors).toBeFalsy(); + expect(result.data as any).toEqual({ + [User.plural]: expect.toIncludeSameMembers([ + { + id: userInput1.id, + address: addressInput1, + fullName: fullNameResolver({ + firstName: userInput1.firstName, + lastName: userInput1.lastName, + address: { city: addressInput1.city }, + }), + }, + { + id: userInput2.id, + address: addressInput2, + fullName: fullNameResolver({ + firstName: userInput2.firstName, + lastName: userInput2.lastName, + address: { city: addressInput2.city }, + }), + }, + ]), + }); + }); + + test("should select fields from double nested related nodes", async () => { + const session = await neo4j.getSession(); + try { + await session.run( + ` + CREATE (user1:${User})-[:LIVES_AT]->(addr1:${Address})-[:IN_CITY]->(city1:${City}) + SET user1 = $userInput1, addr1 = $addressInput1, city1 = $cityInput1 + CREATE (user2:${User})-[:LIVES_AT]->(addr2:${Address})-[:IN_CITY]->(city2:${City}) + SET user2 = $userInput2, addr2 = $addressInput2, city2 = $cityInput2 + `, + { userInput1, addressInput1, userInput2, addressInput2, cityInput1, cityInput2 } + ); + } finally { + await session.close(); + } + + const typeDefs = gql` + type ${City} { + name: String! + population: Int + } + + type ${Address} { + street: String! + city: ${City}! @relationship(type: "IN_CITY", direction: OUT) + } + + type ${User} { + id: ID! + firstName: String! + lastName: String! + address: ${Address} @relationship(type: "LIVES_AT", direction: OUT) + fullName: String @customResolver(requires: "firstName lastName address { city { name population } }") + } + `; + + const fullNameResolver = ({ firstName, lastName, address }) => { + if (address.city.population) { + return `${firstName} ${lastName} from ${address.city.name} with population of ${address.city.population}`; + } + return `${firstName} ${lastName} from ${address.city.name}`; + }; + + const resolvers = { + [User.name]: { + fullName: fullNameResolver, + }, + }; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + }); + + const query = ` + query ${User} { + ${User.plural} { + fullName + address { + street + } + } + } + `; + + const result = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues(), + }); + + expect(result.errors).toBeFalsy(); + expect(result.data as any).toEqual({ + [User.plural]: expect.toIncludeSameMembers([ + { + address: { + street: addressInput1.street, + }, + fullName: fullNameResolver({ + firstName: userInput1.firstName, + lastName: userInput1.lastName, + address: { city: cityInput1 }, + }), + }, + { + address: { + street: addressInput2.street, + }, + fullName: fullNameResolver({ + firstName: userInput2.firstName, + lastName: userInput2.lastName, + address: { city: cityInput2 }, + }), + }, + ]), + }); + }); + + test("should select fields from triple nested related nodes", async () => { + const session = await neo4j.getSession(); + try { + await session.run( + ` + CREATE (user1:${User})-[:LIVES_AT]->(addr1:${Address})-[:IN_CITY]->(city1:${City}) + -[:IN_STATE]->(state:${State}) + SET user1 = $userInput1, addr1 = $addressInput1, city1 = $cityInput1, state = $stateInput + CREATE (user2:${User})-[:LIVES_AT]->(addr2:${Address})-[:IN_CITY]->(city2:${City}) + -[:IN_STATE]->(state) + SET user2 = $userInput2, addr2 = $addressInput2, city2 = $cityInput2 + `, + { userInput1, addressInput1, userInput2, addressInput2, cityInput1, cityInput2, stateInput } + ); + } finally { + await session.close(); + } + + const typeDefs = gql` + type ${State} { + someValue: Int! + } + + type ${City} { + name: String! + population: Int + state: ${State}! @relationship(type: "IN_STATE", direction: OUT) + } + + type ${Address} { + street: String! + city: ${City}! @relationship(type: "IN_CITY", direction: OUT) + } + + type ${User} { + id: ID! + firstName: String! + lastName: String! + address: ${Address} @relationship(type: "LIVES_AT", direction: OUT) + fullName: String @customResolver(requires: "firstName lastName address { city { name state { someValue } population } }") + } + `; + + const fullNameResolver = ({ firstName, lastName, address }) => { + if (address.city.population) { + return `${firstName} ${lastName} from ${address.city.name} with population of ${address.city.population} with ${address.city.state.someValue}`; + } + return `${firstName} ${lastName} from ${address.city.name}`; + }; + + const resolvers = { + [User.name]: { + fullName: fullNameResolver, + }, + }; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + }); + + const query = ` + query ${User} { + ${User.plural} { + fullName + address { + street + } + } + } + `; + + const result = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues(), + }); + + expect(result.errors).toBeFalsy(); + expect(result.data as any).toEqual({ + [User.plural]: expect.toIncludeSameMembers([ + { + address: { + street: addressInput1.street, + }, + fullName: fullNameResolver({ + firstName: userInput1.firstName, + lastName: userInput1.lastName, + address: { + city: { + name: cityInput1.name, + population: cityInput1.population, + state: stateInput, + }, + }, + }), + }, + { + address: { + street: addressInput2.street, + }, + fullName: fullNameResolver({ + firstName: userInput2.firstName, + lastName: userInput2.lastName, + address: { + city: { + name: cityInput2.name, + population: cityInput2.population, + state: stateInput, + }, + }, + }), + }, + ]), + }); + }); + + test("should be able to require fields from a related union", async () => { + const session = await neo4j.getSession(); + try { + await session.run( + ` + CREATE (author1:${Author})-[:WROTE]->(book1:${Book}) SET author1 = $authorInput1, book1 = $bookInput1 + CREATE (author2:${Author})-[:WROTE]->(journal1:${Journal}) SET author2 = $authorInput2, journal1 = $journalInput1 + CREATE (author2)-[:WROTE]->(journal2:${Journal}) SET journal2 = $journalInput2 + CREATE (author2)-[:WROTE]->(book2:${Book}) SET book2 = $bookInput2 + CREATE (author1)-[:WROTE]->(journal1) + `, + { authorInput1, authorInput2, bookInput1, bookInput2, journalInput1, journalInput2 } + ); + } finally { + await session.close(); + } + + const typeDefs = gql` + union ${Publication} = ${Book} | ${Journal} + + type ${Author} { + name: String! + publications: [${Publication}!]! @relationship(type: "WROTE", direction: OUT) + publicationsWithAuthor: [String!]! + @customResolver(requires: "name publications { ...on ${Book} { title } ... on ${Journal} { subject } }") + } + + type ${Book} { + title: String! + author: ${Author}! @relationship(type: "WROTE", direction: IN) + } + + type ${Journal} { + subject: String! + author: ${Author}! @relationship(type: "WROTE", direction: IN) + } + `; + + const publicationsWithAuthorResolver = ({ name, publications }) => + publications.map((publication) => `${publication.title || publication.subject} by ${name}`); + + const resolvers = { + [Author.name]: { + publicationsWithAuthor: publicationsWithAuthorResolver, + }, + }; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + }); + + const query = ` + query ${Author} { + ${Author.plural} { + publicationsWithAuthor + } + } + `; + + const result = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues(), + }); + + expect(result.errors).toBeFalsy(); + expect(result.data as any).toEqual({ + [Author.plural]: expect.toIncludeSameMembers([ + { + publicationsWithAuthor: expect.toIncludeSameMembers( + publicationsWithAuthorResolver({ + name: authorInput1.name, + publications: [bookInput1, journalInput1], + }) + ), + }, + { + publicationsWithAuthor: expect.toIncludeSameMembers( + publicationsWithAuthorResolver({ + name: authorInput2.name, + publications: [journalInput1, journalInput2, bookInput2], + }) + ), + }, + ]), + }); + }); + + test("should select @alias fields", async () => { + const session = await neo4j.getSession(); + try { + await session.run( + ` + CREATE (user1:${User})-[:LIVES_AT]->(addr1:${Address})-[:IN_CITY]->(city1:${City}) + SET user1 = $userInput1, addr1 = $addressInput1, city1 = $cityInput1 + CREATE (user2:${User})-[:LIVES_AT]->(addr2:${Address})-[:IN_CITY]->(city2:${City}) + SET user2 = $userInput2, addr2 = $addressInput2, city2 = $cityInput2 + `, + { + userInput1: { id: userInput1.id, first: userInput1.firstName, lastName: userInput1.lastName }, + addressInput1, + userInput2: { id: userInput2.id, first: userInput2.firstName, lastName: userInput2.lastName }, + addressInput2, + cityInput1: { name: cityInput1.name, cityPopulation: cityInput1.population }, + cityInput2: { name: cityInput2.name, cityPopulation: cityInput2.population }, + } + ); + } finally { + await session.close(); + } + + const typeDefs = gql` + type ${City} { + name: String! + population: Int @alias(property: "cityPopulation") + } + + type ${Address} { + street: String! + city: ${City}! @relationship(type: "IN_CITY", direction: OUT) + } + + type ${User} { + id: ID! + firstName: String! @alias(property: "first") + lastName: String! + address: ${Address} @relationship(type: "LIVES_AT", direction: OUT) + fullName: String @customResolver(requires: "firstName lastName address { city { name population } }") + } + `; + + const fullNameResolver = ({ firstName, lastName, address }) => { + if (address.city.population) { + return `${firstName} ${lastName} from ${address.city.name} with population of ${address.city.population}`; + } + return `${firstName} ${lastName} from ${address.city.name}`; + }; + + const resolvers = { + [User.name]: { + fullName: fullNameResolver, + }, + }; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + }); + + const query = ` + query ${User} { + ${User.plural} { + firstName + fullName + address { + street + } + } + } + `; + + const result = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues(), + }); + + expect(result.errors).toBeFalsy(); + expect(result.data as any).toEqual({ + [User.plural]: expect.toIncludeSameMembers([ + { + address: { + street: addressInput1.street, + }, + firstName: userInput1.firstName, + fullName: fullNameResolver({ + firstName: userInput1.firstName, + lastName: userInput1.lastName, + address: { city: cityInput1 }, + }), + }, + { + address: { + street: addressInput2.street, + }, + firstName: userInput2.firstName, + fullName: fullNameResolver({ + firstName: userInput2.firstName, + lastName: userInput2.lastName, + address: { city: cityInput2 }, + }), + }, + ]), + }); + }); + + test("should still prevent access to @auth fields when not authenticated", async () => { + const session = await neo4j.getSession(); + try { + await session.run( + ` + CREATE (user1:${User})-[:LIVES_AT]->(addr1:${Address})-[:IN_CITY]->(city1:${City}) + SET user1 = $userInput1, addr1 = $addressInput1, city1 = $cityInput1 + CREATE (user2:${User})-[:LIVES_AT]->(addr2:${Address})-[:IN_CITY]->(city2:${City}) + SET user2 = $userInput2, addr2 = $addressInput2, city2 = $cityInput2 + `, + { + userInput1, + addressInput1, + userInput2, + addressInput2, + cityInput1, + cityInput2, + } + ); + } finally { + await session.close(); + } + + const typeDefs = gql` + type ${City} { + name: String! @auth(rules: [{ roles: ["admin"] }]) + population: Int + } + + type ${Address} { + street: String! + city: ${City}! @relationship(type: "IN_CITY", direction: OUT) + } + + type ${User} { + id: ID! + firstName: String! @auth(rules: [{ allow: { id: "$jwt.sub" } }]) + lastName: String! + address: ${Address} @relationship(type: "LIVES_AT", direction: OUT) + fullName: String @customResolver(requires: "firstName lastName address { city { name population } }") + } + `; + + const fullNameResolver = ({ firstName, lastName, address }) => { + if (address.city.population) { + return `${firstName} ${lastName} from ${address.city.name} with population of ${address.city.population}`; + } + return `${firstName} ${lastName} from ${address.city.name}`; + }; + + const resolvers = { + [User.name]: { + fullName: fullNameResolver, + }, + }; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + }); + + const query = ` + query ${User} { + ${User.plural} { + firstName + fullName + address { + street + } + } + } + `; + + const result = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues(), + }); + + expect((result.errors as any[])[0].message).toBe("Unauthenticated"); + }); + + test("should prevent access to top-level @auth fields when rules are not met", async () => { + const session = await neo4j.getSession(); + try { + await session.run( + ` + CREATE (user1:${User})-[:LIVES_AT]->(addr1:${Address})-[:IN_CITY]->(city1:${City}) + SET user1 = $userInput1, addr1 = $addressInput1, city1 = $cityInput1 + CREATE (user2:${User})-[:LIVES_AT]->(addr2:${Address})-[:IN_CITY]->(city2:${City}) + SET user2 = $userInput2, addr2 = $addressInput2, city2 = $cityInput2 + `, + { + userInput1, + addressInput1, + userInput2, + addressInput2, + cityInput1, + cityInput2, + } + ); + } finally { + await session.close(); + } + + const typeDefs = gql` + type ${City} { + name: String! @auth(rules: [{ roles: ["admin"] }]) + population: Int + } + + type ${Address} { + street: String! + city: ${City}! @relationship(type: "IN_CITY", direction: OUT) + } + + type ${User} { + id: ID! + firstName: String! @auth(rules: [{ allow: { id: "$jwt.sub" } }]) + lastName: String! + address: ${Address} @relationship(type: "LIVES_AT", direction: OUT) + fullName: String @customResolver(requires: "firstName lastName address { city { name population } }") + } + `; + + const fullNameResolver = ({ firstName, lastName, address }) => { + if (address.city.population) { + return `${firstName} ${lastName} from ${address.city.name} with population of ${address.city.population}`; + } + return `${firstName} ${lastName} from ${address.city.name}`; + }; + + const resolvers = { + [User.name]: { + fullName: fullNameResolver, + }, + }; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + plugins: { + auth: new Neo4jGraphQLAuthJWTPlugin({ + secret: "secret", + }), + }, + }); + + const query = ` + query ${User} { + ${User.plural}(where: { id: "1" }) { + firstName + fullName + address { + street + } + } + } + `; + + const req = createJwtRequest(secret, { sub: "not 1", roles: ["admin"] }); + + const result = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues({ req }), + }); + + expect((result.errors as any[])[0].message).toBe("Forbidden"); + }); + + test("should prevent access to nested @auth fields when rules are not met", async () => { + const session = await neo4j.getSession(); + try { + await session.run( + ` + CREATE (user1:${User})-[:LIVES_AT]->(addr1:${Address})-[:IN_CITY]->(city1:${City}) + SET user1 = $userInput1, addr1 = $addressInput1, city1 = $cityInput1 + CREATE (user2:${User})-[:LIVES_AT]->(addr2:${Address})-[:IN_CITY]->(city2:${City}) + SET user2 = $userInput2, addr2 = $addressInput2, city2 = $cityInput2 + `, + { + userInput1, + addressInput1, + userInput2, + addressInput2, + cityInput1, + cityInput2, + } + ); + } finally { + await session.close(); + } + + const typeDefs = gql` + type ${City} { + name: String! @auth(rules: [{ roles: ["admin"] }]) + population: Int + } + + type ${Address} { + street: String! + city: ${City}! @relationship(type: "IN_CITY", direction: OUT) + } + + type ${User} { + id: ID! + firstName: String! @auth(rules: [{ allow: { id: "$jwt.sub" } }]) + lastName: String! + address: ${Address} @relationship(type: "LIVES_AT", direction: OUT) + fullName: String @customResolver(requires: "firstName lastName address { city { name population } }") + } + `; + + const fullNameResolver = ({ firstName, lastName, address }) => { + if (address.city.population) { + return `${firstName} ${lastName} from ${address.city.name} with population of ${address.city.population}`; + } + return `${firstName} ${lastName} from ${address.city.name}`; + }; + + const resolvers = { + [User.name]: { + fullName: fullNameResolver, + }, + }; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + plugins: { + auth: new Neo4jGraphQLAuthJWTPlugin({ + secret: "secret", + }), + }, + }); + + const query = ` + query ${User} { + ${User.plural}(where: { id: "1" }) { + firstName + fullName + address { + street + } + } + } + `; + + const req = createJwtRequest(secret, { sub: "1", roles: ["not-admin"] }); + + const result = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues({ req }), + }); + + expect((result.errors as any[])[0].message).toBe("Forbidden"); + }); + + test("should allow access to @auth fields when rules are met", async () => { + const session = await neo4j.getSession(); + try { + await session.run( + ` + CREATE (user1:${User})-[:LIVES_AT]->(addr1:${Address})-[:IN_CITY]->(city1:${City}) + SET user1 = $userInput1, addr1 = $addressInput1, city1 = $cityInput1 + CREATE (user2:${User})-[:LIVES_AT]->(addr2:${Address})-[:IN_CITY]->(city2:${City}) + SET user2 = $userInput2, addr2 = $addressInput2, city2 = $cityInput2 + `, + { + userInput1, + addressInput1, + userInput2, + addressInput2, + cityInput1, + cityInput2, + } + ); + } finally { + await session.close(); + } + + const typeDefs = gql` + type ${City} { + name: String! @auth(rules: [{ roles: ["admin"] }]) + population: Int + } + + type ${Address} { + street: String! + city: ${City}! @relationship(type: "IN_CITY", direction: OUT) + } + + type ${User} { + id: ID! + firstName: String! @auth(rules: [{ allow: { id: "$jwt.sub" } }]) + lastName: String! + address: ${Address} @relationship(type: "LIVES_AT", direction: OUT) + fullName: String @customResolver(requires: "firstName lastName address { city { name population } }") + } + `; + + const fullNameResolver = ({ firstName, lastName, address }) => { + if (address.city.population) { + return `${firstName} ${lastName} from ${address.city.name} with population of ${address.city.population}`; + } + return `${firstName} ${lastName} from ${address.city.name}`; + }; + + const resolvers = { + [User.name]: { + fullName: fullNameResolver, + }, + }; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + plugins: { + auth: new Neo4jGraphQLAuthJWTPlugin({ + secret: "secret", + }), + }, + }); + + const query = ` + query ${User} { + ${User.plural}(where: { id: "1" }) { + firstName + fullName + address { + street + } + } + } + `; + + const req = createJwtRequest(secret, { sub: "1", roles: ["admin"] }); + + const result = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues({ req }), + }); + + expect(result.errors).toBeFalsy(); + expect(result.data as any).toEqual({ + [User.plural]: expect.toIncludeSameMembers([ + { + address: { + street: addressInput1.street, + }, + firstName: userInput1.firstName, + fullName: fullNameResolver({ + firstName: userInput1.firstName, + lastName: userInput1.lastName, + address: { city: cityInput1 }, + }), + }, + ]), + }); + }); + + test("should be able to require fields from a related interface", async () => { + const session = await neo4j.getSession(); + try { + await session.run( + ` + CREATE (author1:${Author})-[:WROTE]->(book1:${Book}) SET author1 = $authorInput1, book1 = $bookInput1 + CREATE (author2:${Author})-[:WROTE]->(journal1:${Journal}) SET author2 = $authorInput2, journal1 = $journalInput1 + CREATE (author1)-[:WROTE]->(journal1) + `, + { authorInput1, authorInput2, bookInput1, journalInput1 } + ); + } finally { + await session.close(); + } + + const typeDefs = gql` + interface ${Publication} { + publicationYear: Int! + } + + type ${Author} { + name: String! + publications: [${Publication}!]! @relationship(type: "WROTE", direction: OUT) + publicationsWithAuthor: [String!]! + @customResolver( + requires: "name publications { publicationYear ...on ${Book} { title } ... on ${Journal} { subject } }" + ) + } + + type ${Book} implements ${Publication} { + title: String! + publicationYear: Int! + author: [${Author}!]! @relationship(type: "WROTE", direction: IN) + } + + type ${Journal} implements ${Publication} { + subject: String! + publicationYear: Int! + author: [${Author}!]! @relationship(type: "WROTE", direction: IN) + } + `; + + const publicationsWithAuthorResolver = ({ name, publications }) => + publications.map( + (publication) => + `${publication.title || publication.subject} by ${name} in ${publication.publicationYear}` + ); + + const resolvers = { + [Author.name]: { + publicationsWithAuthor: publicationsWithAuthorResolver, + }, + }; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + }); + + const query = ` + query ${Author} { + ${Author.plural} { + publicationsWithAuthor + } + } + `; + + const result = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues(), + }); + + expect(result.errors).toBeFalsy(); + expect(result.data as any).toEqual({ + [Author.plural]: expect.toIncludeSameMembers([ + { + publicationsWithAuthor: expect.toIncludeSameMembers( + publicationsWithAuthorResolver({ + name: authorInput1.name, + publications: [bookInput1, journalInput1], + }) + ), + }, + { + publicationsWithAuthor: expect.toIncludeSameMembers( + publicationsWithAuthorResolver({ + name: authorInput2.name, + publications: [journalInput1], + }) + ), + }, + ]), + }); + }); + + test("should be able to require fields from a nested related interface", async () => { + const session = await neo4j.getSession(); + try { + await session.run( + ` + CREATE (user1:${User})-[:FOLLOWS]->(author1:${Author})-[:WROTE]->(book1:${Book}) + SET user1 = $userInput1, author1 = $authorInput1, book1 = $bookInput1 + CREATE (user1)-[:FOLLOWS]->(author2:${Author})-[:WROTE]->(journal1:${Journal}) SET author2 = $authorInput2, journal1 = $journalInput1 + CREATE (author1)-[:WROTE]->(journal1) + `, + { userInput1, authorInput1, authorInput2, bookInput1, journalInput1 } + ); + } finally { + await session.close(); + } + + const typeDefs = gql` + interface ${Publication} { + publicationYear: Int! + } + + type ${User} { + id: ID! + firstName: String! + lastName: String! + followedAuthors: [${Author}!]! @relationship(type: "FOLLOWS", direction: OUT) + customResolverField: Int @customResolver(requires: "followedAuthors { name publications { publicationYear ...on ${Book} { title } ... on ${Journal} { subject } } } firstName") + } + + type ${Author} { + name: String! + publications: [${Publication}!]! @relationship(type: "WROTE", direction: OUT) + } + + type ${Book} implements ${Publication} { + title: String! + publicationYear: Int! + author: [${Author}!]! @relationship(type: "WROTE", direction: IN) + } + + type ${Journal} implements ${Publication} { + subject: String! + publicationYear: Int! + author: [${Author}!]! @relationship(type: "WROTE", direction: IN) + } + `; + + const customResolver = ({ firstName, followedAuthors }) => { + let count = 0; + count += firstName.length; + followedAuthors.forEach((author) => { + count += author.name.length; + author.publications.forEach((publication) => { + if (publication.name) count += publication.name.length; + if (publication.subject) count += publication.subject.length; + count += publication.publicationYear; + }); + }); + return count; + }; + + const resolvers = { + [User.name]: { + customResolverField: customResolver, + }, + }; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + }); + + const query = ` + query ${User} { + ${User.plural} { + customResolverField + } + } + `; + + const result = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues(), + }); + + expect(result.errors).toBeFalsy(); + expect(result.data as any).toEqual({ + [User.plural]: expect.toIncludeSameMembers([ + { + customResolverField: customResolver({ + firstName: userInput1.firstName, + followedAuthors: [ + { + name: authorInput1.name, + publications: [bookInput1, journalInput1], + }, + { + name: authorInput2.name, + publications: [journalInput1], + }, + ], + }), + }, + ]), + }); + }); + + test("should be able to require fields from a nested related union", async () => { + const session = await neo4j.getSession(); + try { + await session.run( + ` + CREATE (user1:${User})-[:FOLLOWS]->(author1:${Author})-[:WROTE]->(book1:${Book}) + SET user1 = $userInput1, author1 = $authorInput1, book1 = $bookInput1 + CREATE (user1)-[:FOLLOWS]->(author2:${Author})-[:WROTE]->(journal1:${Journal}) SET author2 = $authorInput2, journal1 = $journalInput1 + CREATE (author1)-[:WROTE]->(journal1) + `, + { userInput1, authorInput1, authorInput2, bookInput1, journalInput1 } + ); + } finally { + await session.close(); + } + + const typeDefs = gql` + type ${User} { + id: ID! + firstName: String! + lastName: String! + followedAuthors: [${Author}!]! @relationship(type: "FOLLOWS", direction: OUT) + customResolverField: Int @customResolver(requires: "followedAuthors { name publications { ...on ${Book} { title } ... on ${Journal} { subject } } } firstName") + } + + union ${Publication} = ${Book} | ${Journal} + + type ${Author} { + name: String! + publications: [${Publication}!]! @relationship(type: "WROTE", direction: OUT) + } + + type ${Book} { + title: String! + author: ${Author}! @relationship(type: "WROTE", direction: IN) + } + + type ${Journal} { + subject: String! + author: ${Author}! @relationship(type: "WROTE", direction: IN) + } + `; + + const customResolver = ({ firstName, followedAuthors }) => { + let count = 0; + count += firstName.length; + followedAuthors.forEach((author) => { + count += author.name.length; + author.publications.forEach((publication) => { + if (publication.name) count += publication.name.length; + if (publication.subject) count += publication.subject.length; + }); + }); + return count; + }; + + const resolvers = { + [User.name]: { + customResolverField: customResolver, + }, + }; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + }); + + const query = ` + query ${User} { + ${User.plural} { + customResolverField + } + } + `; + + const result = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues(), + }); + + expect(result.errors).toBeFalsy(); + expect(result.data as any).toEqual({ + [User.plural]: expect.toIncludeSameMembers([ + { + customResolverField: customResolver({ + firstName: userInput1.firstName, + followedAuthors: [ + { + name: authorInput1.name, + publications: [bookInput1, journalInput1], + }, + { + name: authorInput2.name, + publications: [journalInput1], + }, + ], + }), + }, + ]), + }); + }); + + test("should throw an error if not using ...on for related unions", async () => { + const session = await neo4j.getSession(); + try { + await session.run( + ` + CREATE (user1:${User})-[:FOLLOWS]->(author1:${Author})-[:WROTE]->(book1:${Book}) + SET user1 = $userInput1, author1 = $authorInput1, book1 = $bookInput1 + CREATE (user1)-[:FOLLOWS]->(author2:${Author})-[:WROTE]->(journal1:${Journal}) SET author2 = $authorInput2, journal1 = $journalInput1 + CREATE (author1)-[:WROTE]->(journal1) + `, + { userInput1, authorInput1, authorInput2, bookInput1, journalInput1 } + ); + } finally { + await session.close(); + } + + const typeDefs = gql` + type ${User} { + id: ID! + firstName: String! + lastName: String! + followedAuthors: [${Author}!]! @relationship(type: "FOLLOWS", direction: OUT) + customResolverField: Int @customResolver(requires: "followedAuthors { name publications { ...on ${Book} { title } subject } } firstName") + } + + union ${Publication} = ${Book} | ${Journal} + + type ${Author} { + name: String! + publications: [${Publication}!]! @relationship(type: "WROTE", direction: OUT) + } + + type ${Book} { + title: String! + author: ${Author}! @relationship(type: "WROTE", direction: IN) + } + + type ${Journal} { + subject: String! + author: ${Author}! @relationship(type: "WROTE", direction: IN) + } + `; + + const customResolver = ({ firstName, followedAuthors }) => { + let count = 0; + count += firstName.length; + followedAuthors.forEach((author) => { + count += author.name.length; + author.publications.forEach((publication) => { + if (publication.name) count += publication.name.length; + if (publication.subject) count += publication.subject.length; + }); + }); + return count; + }; + + const resolvers = { + [User.name]: { + customResolverField: customResolver, + }, + }; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + }); + + await expect(neoSchema.getSchema()).rejects.toThrow( + `Invalid selection set provided to @customResolver on ${User}` + ); + }); + + test("should throw an error when requiring another @customResolver field", async () => { + const typeDefs = gql` + interface ${Publication} { + publicationYear: Int! + } + + type ${User} { + id: ID! + firstName: String! @customResolver + lastName: String! + followedAuthors: [${Author}!]! @relationship(type: "FOLLOWS", direction: OUT) + customResolverField: Int @customResolver(requires: "followedAuthors { name publications { publicationYear ...on ${Book} { title } ... on ${Journal} { subject } } } firstName") + } + + type ${Author} { + name: String! + publications: [${Publication}!]! @relationship(type: "WROTE", direction: OUT) + } + + type ${Book} implements ${Publication} { + title: String! + publicationYear: Int! + author: [${Author}!]! @relationship(type: "WROTE", direction: IN) + } + + type ${Journal} implements ${Publication} { + subject: String! + publicationYear: Int! + author: [${Author}!]! @relationship(type: "WROTE", direction: IN) + } + `; + + const customResolver = ({ firstName, followedAuthors }) => { + let count = 0; + count += firstName.length; + followedAuthors.forEach((author) => { + count += author.name.length; + author.publications.forEach((publication) => { + if (publication.name) count += publication.name.length; + if (publication.subject) count += publication.subject.length; + count += publication.publicationYear; + }); + }); + return count; + }; + + const resolvers = { + [User.name]: { + customResolverField: customResolver, + firstName: customResolver, + }, + }; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + }); + + await expect(neoSchema.getSchema()).rejects.toThrow(INVALID_REQUIRED_FIELD_ERROR); + }); + + test("should throw an error when requiring another @customResolver field on a nested type", async () => { + const typeDefs = gql` + interface ${Publication} { + publicationYear: Int! @customResolver + } + + type ${User} { + id: ID! + firstName: String! + lastName: String! + followedAuthors: [${Author}!]! @relationship(type: "FOLLOWS", direction: OUT) + customResolverField: Int @customResolver(requires: "followedAuthors { name publications { publicationYear ...on ${Book} { title } ... on ${Journal} { subject } } } firstName") + } + + type ${Author} { + name: String! + publications: [${Publication}!]! @relationship(type: "WROTE", direction: OUT) + } + + type ${Book} implements ${Publication} { + title: String! + publicationYear: Int! + author: [${Author}!]! @relationship(type: "WROTE", direction: IN) + } + + type ${Journal} implements ${Publication} { + subject: String! + publicationYear: Int! + author: [${Author}!]! @relationship(type: "WROTE", direction: IN) + } + `; + + const customResolver = ({ firstName, followedAuthors }) => { + let count = 0; + count += firstName.length; + followedAuthors.forEach((author) => { + count += author.name.length; + author.publications.forEach((publication) => { + if (publication.name) count += publication.name.length; + if (publication.subject) count += publication.subject.length; + count += publication.publicationYear; + }); + }); + return count; + }; + + const resolvers = { + [User.name]: { + customResolverField: customResolver, + }, + [Publication.name]: { + publicationYear: customResolver, + }, + }; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + }); + + await expect(neoSchema.getSchema()).rejects.toThrow(INVALID_REQUIRED_FIELD_ERROR); + }); + + test("should throw an error when requiring another @customResolver field on an implementation of a nested interface", async () => { + const typeDefs = gql` + interface ${Publication} { + publicationYear: Int! + } + + type ${User} { + id: ID! + firstName: String! + lastName: String! + followedAuthors: [${Author}!]! @relationship(type: "FOLLOWS", direction: OUT) + customResolverField: Int @customResolver(requires: "followedAuthors { name publications { publicationYear ...on ${Book} { title } ... on ${Journal} { subject } } } firstName") + } + + type ${Author} { + name: String! + publications: [${Publication}!]! @relationship(type: "WROTE", direction: OUT) + } + + type ${Book} implements ${Publication} { + title: String! + publicationYear: Int! @customResolver + author: [${Author}!]! @relationship(type: "WROTE", direction: IN) + } + + type ${Journal} implements ${Publication} { + subject: String! + publicationYear: Int! + author: [${Author}!]! @relationship(type: "WROTE", direction: IN) + } + `; + + const customResolver = ({ firstName, followedAuthors }) => { + let count = 0; + count += firstName.length; + followedAuthors.forEach((author) => { + count += author.name.length; + author.publications.forEach((publication) => { + if (publication.name) count += publication.name.length; + if (publication.subject) count += publication.subject.length; + count += publication.publicationYear; + }); + }); + return count; + }; + + const resolvers = { + [User.name]: { + customResolverField: customResolver, + }, + [Book.name]: { + publicationYear: customResolver, + }, + }; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + }); + + await expect(neoSchema.getSchema()).rejects.toThrow(INVALID_REQUIRED_FIELD_ERROR); + }); + + test("should throw an error when requiring another @customResolver field using ...on on an implementation of a nested interface", async () => { + const typeDefs = gql` + interface ${Publication} { + publicationYear: Int! + } + + type ${User} { + id: ID! + firstName: String! + lastName: String! + followedAuthors: [${Author}!]! @relationship(type: "FOLLOWS", direction: OUT) + customResolverField: Int @customResolver(requires: "followedAuthors { name publications { ...on ${Book} { title publicationYear } ... on ${Journal} { subject } } } firstName") + } + + type ${Author} { + name: String! + publications: [${Publication}!]! @relationship(type: "WROTE", direction: OUT) + } + + type ${Book} implements ${Publication} { + title: String! + publicationYear: Int! @customResolver + author: [${Author}!]! @relationship(type: "WROTE", direction: IN) + } + + type ${Journal} implements ${Publication} { + subject: String! + publicationYear: Int! + author: [${Author}!]! @relationship(type: "WROTE", direction: IN) + } + `; + + const customResolver = ({ firstName, followedAuthors }) => { + let count = 0; + count += firstName.length; + followedAuthors.forEach((author) => { + count += author.name.length; + author.publications.forEach((publication) => { + if (publication.name) count += publication.name.length; + if (publication.subject) count += publication.subject.length; + count += publication.publicationYear; + }); + }); + return count; + }; + + const resolvers = { + [User.name]: { + customResolverField: customResolver, + }, + [Book.name]: { + publicationYear: customResolver, + }, + }; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + }); + + await expect(neoSchema.getSchema()).rejects.toThrow(INVALID_REQUIRED_FIELD_ERROR); + }); + + test("should not throw an error if there is another @customResolver field on the same type that is not required", async () => { + const typeDefs = gql` + interface ${Publication} { + publicationYear: Int! + } + + type ${User} { + id: ID! + firstName: String! + lastName: String! @customResolver + followedAuthors: [${Author}!]! @relationship(type: "FOLLOWS", direction: OUT) + customResolverField: Int @customResolver(requires: "followedAuthors { name publications { publicationYear ...on ${Book} { title } ... on ${Journal} { subject } } } firstName") + } + + type ${Author} { + name: String! + publications: [${Publication}!]! @relationship(type: "WROTE", direction: OUT) + } + + type ${Book} implements ${Publication} { + title: String! + publicationYear: Int! + author: [${Author}!]! @relationship(type: "WROTE", direction: IN) + } + + type ${Journal} implements ${Publication} { + subject: String! + publicationYear: Int! + author: [${Author}!]! @relationship(type: "WROTE", direction: IN) + } + `; + + const customResolver = ({ firstName, followedAuthors }) => { + let count = 0; + count += firstName.length; + followedAuthors.forEach((author) => { + count += author.name.length; + author.publications.forEach((publication) => { + if (publication.name) count += publication.name.length; + if (publication.subject) count += publication.subject.length; + count += publication.publicationYear; + }); + }); + return count; + }; + + const resolvers = { + [User.name]: { + customResolverField: customResolver, + lastName: customResolver, + }, + }; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + }); + + await expect(neoSchema.getSchema()).resolves.not.toThrow(); + }); + + test("should not throw an error if there is another @customResolver field on a different implementation of the same interface when using ...on", async () => { + const typeDefs = gql` + interface ${Publication} { + publicationYear: Int! + } + + type ${User} { + id: ID! + firstName: String! + lastName: String! + followedAuthors: [${Author}!]! @relationship(type: "FOLLOWS", direction: OUT) + customResolverField: Int @customResolver(requires: "followedAuthors { name publications { ...on ${Book} { title publicationYear } ... on ${Journal} { subject } } } firstName") + } + + type ${Author} { + name: String! + publications: [${Publication}!]! @relationship(type: "WROTE", direction: OUT) + } + + type ${Book} implements ${Publication} { + title: String! + publicationYear: Int! + author: [${Author}!]! @relationship(type: "WROTE", direction: IN) + } + + type ${Journal} implements ${Publication} { + subject: String! + publicationYear: Int! @customResolver + author: [${Author}!]! @relationship(type: "WROTE", direction: IN) + } + `; + + const customResolver = ({ firstName, followedAuthors }) => { + let count = 0; + count += firstName.length; + followedAuthors.forEach((author) => { + count += author.name.length; + author.publications.forEach((publication) => { + if (publication.name) count += publication.name.length; + if (publication.subject) count += publication.subject.length; + count += publication.publicationYear; + }); + }); + return count; + }; + + const resolvers = { + [User.name]: { + customResolverField: customResolver, + }, + [Journal.name]: { + publicationYear: customResolver, + }, + }; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + }); + + await expect(neoSchema.getSchema()).resolves.not.toThrow(); + }); + + test("should receive undefined for related fields that are not selected", async () => { + const session = await neo4j.getSession(); + try { + await session.run( + `CREATE (user:${User})-[:LIVES_AT]->(addr:${Address}) SET user = $userInput1, addr = $addressInput1`, + { userInput1, addressInput1 } + ); + } finally { + await session.close(); + } + + const typeDefs = gql` + type ${Address} { + houseNumber: Int! @cypher(statement: "RETURN 12 AS number", columnName: "number") + street: String! + city: String! + } + + type ${User} { + id: ID! + firstName: String! + lastName: String! + address: ${Address} @relationship(type: "LIVES_AT", direction: OUT) + fullName: String @customResolver(requires: "firstName lastName address { houseNumber street }") + } + `; + + const fullNameResolver = ({ firstName, lastName, address }) => + `${firstName} ${lastName} from ${address.houseNumber} ${address.street} ${address.city}`; + + const resolvers = { + [User.name]: { + fullName: fullNameResolver, + }, + }; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + }); + + const query = ` + query ${User} { + ${User.plural} { + fullName + } + } + `; + + const result = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues(), + }); + + expect(result.errors).toBeFalsy(); + expect(result.data as any).toEqual({ + [User.plural]: [ + { + fullName: fullNameResolver({ + firstName: userInput1.firstName, + lastName: userInput1.lastName, + address: { street: addressInput1.street, houseNumber: 12 }, + }), + }, + ], + }); + }); + + test("should be able to require a @cypher field on a related type", async () => { + const session = await neo4j.getSession(); + try { + await session.run( + `CREATE (user:${User})-[:LIVES_AT]->(addr:${Address}) SET user = $userInput1, addr = $addressInput1`, + { userInput1, addressInput1 } + ); + } finally { + await session.close(); + } + + const typeDefs = gql` + type ${Address} { + houseNumber: Int! @cypher(statement: "RETURN 12 AS number", columnName: "number") + street: String! + city: String! + } + + type ${User} { + id: ID! + firstName: String! + lastName: String! + address: ${Address} @relationship(type: "LIVES_AT", direction: OUT) + fullName: String @customResolver(requires: "firstName lastName address { houseNumber street }") + } + `; + + const fullNameResolver = ({ firstName, lastName, address }) => + `${firstName} ${lastName} from ${address.houseNumber} ${address.street}`; + + const resolvers = { + [User.name]: { + fullName: fullNameResolver, + }, + }; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + }); + + const query = ` + query ${User} { + ${User.plural} { + fullName + } + } + `; + + const result = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues(), + }); + + expect(result.errors).toBeFalsy(); + expect(result.data as any).toEqual({ + [User.plural]: [ + { + fullName: fullNameResolver({ + firstName: userInput1.firstName, + lastName: userInput1.lastName, + address: { street: addressInput1.street, houseNumber: 12 }, + }), + }, + ], + }); + }); + + test("should not throw an error for invalid type defs when startupValidation.typeDefs false", async () => { + const typeDefs = gql` + type ${Address} { + houseNumber: Int! @cypher(statement: "RETURN 12 AS number", columnName: "number") + street: String! + city: String! + } + + type ${User} { + id: ID! + firstName: String! + lastName: String! + address: ${Address} @relationship(type: "LIVES_AT", direction: OUT) + fullName: String @customResolver(requires: "firstName lastName address { houseNumber street }") + } + + type Point { + latitude: Float! + longitude: Float! + } + `; + + const fullNameResolver = ({ firstName, lastName, address }) => + `${firstName} ${lastName} from ${address.houseNumber} ${address.street}`; + + const resolvers = { + [User.name]: { + fullName: fullNameResolver, + }, + }; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + config: { + startupValidation: { + typeDefs: false, + }, + }, + }); + + const query = ` + query ${User} { + ${User.plural} { + fullName + } + } + `; + + const result = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: neo4j.getContextValues(), + }); + + expect(result.errors).toBeFalsy(); + }); +}); diff --git a/packages/graphql/tests/integration/issues/2560.int.test.ts b/packages/graphql/tests/integration/issues/2560.int.test.ts index 5881e204a2..2bfa9d4cf0 100644 --- a/packages/graphql/tests/integration/issues/2560.int.test.ts +++ b/packages/graphql/tests/integration/issues/2560.int.test.ts @@ -54,7 +54,7 @@ describe("https://github.com/neo4j/graphql/issues/2560", () => { type ${User} { firstName: String! lastName: String! - fullName: String! @customResolver(requires: ["firstName", "lastName"]) + fullName: String! @customResolver(requires: "firstName lastName") } `; @@ -116,12 +116,12 @@ describe("https://github.com/neo4j/graphql/issues/2560", () => { type ${User} { firstName: String! lastName: String! - fullName: String! @customResolver(requires: ["firstName", "lastName"]) + fullName: String! @customResolver(requires: "firstName lastName") } type ${Person} { firstName: String! - secondName: String! @customResolver(requires: ["firstName"]) + secondName: String! @customResolver(requires: "firstName") } `; diff --git a/packages/graphql/tests/tck/directives/customResolver.test.ts b/packages/graphql/tests/tck/directives/customResolver.test.ts index 3888fe841b..0d88e51895 100644 --- a/packages/graphql/tests/tck/directives/customResolver.test.ts +++ b/packages/graphql/tests/tck/directives/customResolver.test.ts @@ -23,75 +23,749 @@ import { Neo4jGraphQL } from "../../../src"; import { createJwtRequest } from "../../utils/create-jwt-request"; import { formatCypher, translateQuery, formatParams } from "../utils/tck-test-utils"; -describe("Cypher customResolver directive", () => { +describe("@customResolver directive", () => { let typeDefs: DocumentNode; let neoSchema: Neo4jGraphQL; - beforeAll(() => { - typeDefs = gql` - type User { - firstName: String! - lastName: String! - fullName: String! @customResolver(requires: ["firstName", "lastName"]) - } - `; - - const resolvers = { - User: { - fullName: () => "The user's full name", - }, - }; - - neoSchema = new Neo4jGraphQL({ - typeDefs, - resolvers, - config: { enableRegex: true }, + describe("Require fields on same type", () => { + beforeAll(() => { + typeDefs = gql` + type User { + firstName: String! + lastName: String! + fullName: String! @customResolver(requires: "firstName lastName") + } + `; + + const resolvers = { + User: { + fullName: () => "The user's full name", + }, + }; + + neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + config: { enableRegex: true }, + }); + }); + + test("should not fetch required fields if @customResolver field is not selected", async () => { + const query = gql` + { + users { + firstName + } + } + `; + + const req = createJwtRequest("secret", {}); + const result = await translateQuery(neoSchema, query, { + req, + }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:\`User\`) + RETURN this { .firstName } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(`"{}"`); + }); + + test("should not over fetch when all required fields are manually selected", async () => { + const query = gql` + { + users { + firstName + lastName + fullName + } + } + `; + + const req = createJwtRequest("secret", {}); + const result = await translateQuery(neoSchema, query, { + req, + }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:\`User\`) + RETURN this { .firstName, .lastName, .fullName } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(`"{}"`); + }); + + test("should not over fetch when some required fields are manually selected", async () => { + const query = gql` + { + users { + firstName + fullName + } + } + `; + + const req = createJwtRequest("secret", {}); + const result = await translateQuery(neoSchema, query, { + req, + }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:\`User\`) + RETURN this { .firstName, .fullName, .lastName } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(`"{}"`); + }); + + test("should not over fetch when no required fields are manually selected", async () => { + const query = gql` + { + users { + fullName + } + } + `; + + const req = createJwtRequest("secret", {}); + const result = await translateQuery(neoSchema, query, { + req, + }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:\`User\`) + RETURN this { .fullName, .firstName, .lastName } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(`"{}"`); }); }); - test("Fields in selection set", async () => { - const query = gql` - { - users { - firstName - lastName - fullName + describe("No required fields", () => { + beforeAll(() => { + typeDefs = gql` + type User { + firstName: String! + lastName: String! + fullName: String! @customResolver + } + `; + + const resolvers = { + User: { + fullName: () => "The user's full name", + }, + }; + + neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + config: { enableRegex: true }, + }); + }); + + test("should not over fetch when other fields are manually selected", async () => { + const query = gql` + { + users { + firstName + fullName + } } - } - `; + `; + + const req = createJwtRequest("secret", {}); + const result = await translateQuery(neoSchema, query, { + req, + }); - const req = createJwtRequest("secret", {}); - const result = await translateQuery(neoSchema, query, { - req, + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:\`User\`) + RETURN this { .firstName, .fullName } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(`"{}"`); }); - expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` - "MATCH (this:\`User\`) - RETURN this { .firstName, .lastName, .fullName } AS this" - `); + test("should not over fetch when no other fields are manually selected", async () => { + const query = gql` + { + users { + fullName + } + } + `; + + const req = createJwtRequest("secret", {}); + const result = await translateQuery(neoSchema, query, { + req, + }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:\`User\`) + RETURN this { .fullName } AS this" + `); - expect(formatParams(result.params)).toMatchInlineSnapshot(`"{}"`); + expect(formatParams(result.params)).toMatchInlineSnapshot(`"{}"`); + }); }); - test("Fields not in selection set", async () => { - const query = gql` - { - users { - fullName + describe("Require fields on nested types", () => { + beforeAll(() => { + typeDefs = gql` + type City { + name: String! + population: Int + } + + type Address { + street: String! + city: City! @relationship(type: "IN_CITY", direction: OUT) + } + + type User { + id: ID! + firstName: String! + lastName: String! + address: Address @relationship(type: "LIVES_AT", direction: OUT) + fullName: String + @customResolver(requires: "firstName lastName address { city { name population } }") + } + `; + + const resolvers = { + User: { + fullName: () => "The user's full name", + }, + }; + + neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + config: { enableRegex: true }, + }); + }); + + test("should not over fetch when all required fields are manually selected", async () => { + const query = gql` + { + users { + firstName + lastName + fullName + address { + city { + name + population + } + } + } + } + `; + + const req = createJwtRequest("secret", {}); + const result = await translateQuery(neoSchema, query, { + req, + }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:\`User\`) + CALL { + WITH this + MATCH (this)-[this0:LIVES_AT]->(this_address:\`Address\`) + CALL { + WITH this_address + MATCH (this_address)-[this1:IN_CITY]->(this_address_city:\`City\`) + WITH this_address_city { .name, .population } AS this_address_city + RETURN head(collect(this_address_city)) AS this_address_city + } + WITH this_address { city: this_address_city } AS this_address + RETURN head(collect(this_address)) AS this_address + } + RETURN this { .firstName, .lastName, .fullName, address: this_address } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(`"{}"`); + }); + + test("should not fetch required fields if @customResolver field is not selected", async () => { + const query = gql` + { + users { + firstName + } + } + `; + + const req = createJwtRequest("secret", {}); + const result = await translateQuery(neoSchema, query, { + req, + }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:\`User\`) + RETURN this { .firstName } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(`"{}"`); + }); + + test("should not over fetch when some required fields are manually selected", async () => { + const query = gql` + { + users { + lastName + fullName + address { + city { + population + } + } + } + } + `; + + const req = createJwtRequest("secret", {}); + const result = await translateQuery(neoSchema, query, { + req, + }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:\`User\`) + CALL { + WITH this + MATCH (this)-[this0:LIVES_AT]->(this_address:\`Address\`) + CALL { + WITH this_address + MATCH (this_address)-[this1:IN_CITY]->(this_address_city:\`City\`) + WITH this_address_city { .population, .name } AS this_address_city + RETURN head(collect(this_address_city)) AS this_address_city + } + WITH this_address { city: this_address_city } AS this_address + RETURN head(collect(this_address)) AS this_address + } + RETURN this { .lastName, .fullName, address: this_address, .firstName } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(`"{}"`); + }); + + test("should not over fetch when no required fields are manually selected", async () => { + const query = gql` + { + users { + fullName + } } - } - `; + `; - const req = createJwtRequest("secret", {}); - const result = await translateQuery(neoSchema, query, { - req, + const req = createJwtRequest("secret", {}); + const result = await translateQuery(neoSchema, query, { + req, + }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:\`User\`) + CALL { + WITH this + MATCH (this)-[this0:LIVES_AT]->(this_address:\`Address\`) + CALL { + WITH this_address + MATCH (this_address)-[this1:IN_CITY]->(this_address_city:\`City\`) + WITH this_address_city { .name, .population } AS this_address_city + RETURN head(collect(this_address_city)) AS this_address_city + } + WITH this_address { city: this_address_city } AS this_address + RETURN head(collect(this_address)) AS this_address + } + RETURN this { .fullName, .firstName, .lastName, address: this_address } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(`"{}"`); }); + }); - expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` - "MATCH (this:\`User\`) - RETURN this { .fullName, .firstName, .lastName } AS this" - `); + describe("Require fields on nested unions", () => { + beforeAll(() => { + typeDefs = gql` + union Publication = Book | Journal + + type Author { + name: String! + publications: [Publication!]! @relationship(type: "WROTE", direction: OUT) + publicationsWithAuthor: [String!]! + @customResolver( + requires: "name publications { ...on Book { title } ... on Journal { subject } }" + ) + } - expect(formatParams(result.params)).toMatchInlineSnapshot(`"{}"`); + type Book { + title: String! + author: Author! @relationship(type: "WROTE", direction: IN) + } + + type Journal { + subject: String! + author: Author! @relationship(type: "WROTE", direction: IN) + } + `; + + const resolvers = { + Author: { + publicationsWithAuthor: () => "Some custom resolver", + }, + }; + + neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + config: { enableRegex: true }, + }); + }); + + test("should not over fetch when all required fields are manually selected", async () => { + const query = gql` + { + authors { + name + publicationsWithAuthor + publications { + ... on Book { + title + } + ... on Journal { + subject + } + } + } + } + `; + + const req = createJwtRequest("secret", {}); + const result = await translateQuery(neoSchema, query, { + req, + }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:\`Author\`) + CALL { + WITH this + CALL { + WITH * + MATCH (this)-[this0:WROTE]->(this_publications:\`Book\`) + WITH this_publications { __resolveType: \\"Book\\", __id: id(this), .title } AS this_publications + RETURN this_publications AS this_publications + UNION + WITH * + MATCH (this)-[this1:WROTE]->(this_publications:\`Journal\`) + WITH this_publications { __resolveType: \\"Journal\\", __id: id(this), .subject } AS this_publications + RETURN this_publications AS this_publications + } + WITH this_publications + RETURN collect(this_publications) AS this_publications + } + RETURN this { .name, .publicationsWithAuthor, publications: this_publications } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(`"{}"`); + }); + + test("should not fetch required fields if @customResolver field is not selected", async () => { + const query = gql` + { + authors { + name + } + } + `; + + const req = createJwtRequest("secret", {}); + const result = await translateQuery(neoSchema, query, { + req, + }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:\`Author\`) + RETURN this { .name } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(`"{}"`); + }); + + test("should not over fetch when some required fields are manually selected", async () => { + const query = gql` + { + authors { + publicationsWithAuthor + publications { + ... on Book { + title + } + } + } + } + `; + + const req = createJwtRequest("secret", {}); + const result = await translateQuery(neoSchema, query, { + req, + }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:\`Author\`) + CALL { + WITH this + CALL { + WITH * + MATCH (this)-[this0:WROTE]->(this_publications:\`Book\`) + WITH this_publications { __resolveType: \\"Book\\", __id: id(this), .title } AS this_publications + RETURN this_publications AS this_publications + UNION + WITH * + MATCH (this)-[this1:WROTE]->(this_publications:\`Journal\`) + WITH this_publications { __resolveType: \\"Journal\\", __id: id(this), .subject } AS this_publications + RETURN this_publications AS this_publications + } + WITH this_publications + RETURN collect(this_publications) AS this_publications + } + RETURN this { .publicationsWithAuthor, publications: this_publications, .name } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(`"{}"`); + }); + + test("should not over fetch when no required fields are manually selected", async () => { + const query = gql` + { + authors { + publicationsWithAuthor + } + } + `; + + const req = createJwtRequest("secret", {}); + const result = await translateQuery(neoSchema, query, { + req, + }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:\`Author\`) + CALL { + WITH this + CALL { + WITH * + MATCH (this)-[this0:WROTE]->(this_publications:\`Book\`) + WITH this_publications { __resolveType: \\"Book\\", __id: id(this), .title } AS this_publications + RETURN this_publications AS this_publications + UNION + WITH * + MATCH (this)-[this1:WROTE]->(this_publications:\`Journal\`) + WITH this_publications { __resolveType: \\"Journal\\", __id: id(this), .subject } AS this_publications + RETURN this_publications AS this_publications + } + WITH this_publications + RETURN collect(this_publications) AS this_publications + } + RETURN this { .publicationsWithAuthor, .name, publications: this_publications } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(`"{}"`); + }); + }); + + describe("Require fields on nested interfaces", () => { + beforeAll(() => { + typeDefs = gql` + interface Publication { + publicationYear: Int! + } + + type Author { + name: String! + publications: [Publication!]! @relationship(type: "WROTE", direction: OUT) + publicationsWithAuthor: [String!]! + @customResolver( + requires: "name publications { publicationYear ...on Book { title } ... on Journal { subject } }" + ) + } + + type Book implements Publication { + title: String! + publicationYear: Int! + author: [Author!]! @relationship(type: "WROTE", direction: IN) + } + + type Journal implements Publication { + subject: String! + publicationYear: Int! + author: [Author!]! @relationship(type: "WROTE", direction: IN) + } + `; + + const resolvers = { + Author: { + publicationsWithAuthor: () => "Some custom resolver", + }, + }; + + neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + config: { enableRegex: true }, + }); + }); + + test("should not over fetch when all required fields are manually selected", async () => { + const query = gql` + { + authors { + name + publicationsWithAuthor + publications { + publicationYear + ... on Book { + title + } + ... on Journal { + subject + } + } + } + } + `; + + const req = createJwtRequest("secret", {}); + const result = await translateQuery(neoSchema, query, { + req, + }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:\`Author\`) + CALL { + WITH this + CALL { + WITH * + MATCH (this)-[this0:WROTE]->(this_publications:\`Book\`) + WITH this_publications { __resolveType: \\"Book\\", __id: id(this), .title, .publicationYear } AS this_publications + RETURN this_publications AS this_publications + UNION + WITH * + MATCH (this)-[this1:WROTE]->(this_publications:\`Journal\`) + WITH this_publications { __resolveType: \\"Journal\\", __id: id(this), .subject, .publicationYear } AS this_publications + RETURN this_publications AS this_publications + } + WITH this_publications + RETURN collect(this_publications) AS this_publications + } + RETURN this { .name, .publicationsWithAuthor, publications: this_publications } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(`"{}"`); + }); + + test("should not fetch required fields if @customResolver field is not selected", async () => { + const query = gql` + { + authors { + name + } + } + `; + + const req = createJwtRequest("secret", {}); + const result = await translateQuery(neoSchema, query, { + req, + }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:\`Author\`) + RETURN this { .name } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(`"{}"`); + }); + + test("should not over fetch when some required fields are manually selected", async () => { + const query = gql` + { + authors { + publicationsWithAuthor + publications { + ... on Book { + title + } + } + } + } + `; + + const req = createJwtRequest("secret", {}); + const result = await translateQuery(neoSchema, query, { + req, + }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:\`Author\`) + CALL { + WITH this + CALL { + WITH * + MATCH (this)-[this0:WROTE]->(this_publications:\`Book\`) + WITH this_publications { __resolveType: \\"Book\\", __id: id(this), .title, .publicationYear } AS this_publications + RETURN this_publications AS this_publications + UNION + WITH * + MATCH (this)-[this1:WROTE]->(this_publications:\`Journal\`) + WITH this_publications { __resolveType: \\"Journal\\", __id: id(this), .subject, .publicationYear } AS this_publications + RETURN this_publications AS this_publications + } + WITH this_publications + RETURN collect(this_publications) AS this_publications + } + RETURN this { .publicationsWithAuthor, publications: this_publications, .name } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(`"{}"`); + }); + + test("should not over fetch when no required fields are manually selected", async () => { + const query = gql` + { + authors { + publicationsWithAuthor + } + } + `; + + const req = createJwtRequest("secret", {}); + const result = await translateQuery(neoSchema, query, { + req, + }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:\`Author\`) + CALL { + WITH this + CALL { + WITH * + MATCH (this)-[this0:WROTE]->(this_publications:\`Book\`) + WITH this_publications { __resolveType: \\"Book\\", __id: id(this), .title, .publicationYear } AS this_publications + RETURN this_publications AS this_publications + UNION + WITH * + MATCH (this)-[this1:WROTE]->(this_publications:\`Journal\`) + WITH this_publications { __resolveType: \\"Journal\\", __id: id(this), .subject, .publicationYear } AS this_publications + RETURN this_publications AS this_publications + } + WITH this_publications + RETURN collect(this_publications) AS this_publications + } + RETURN this { .publicationsWithAuthor, .name, publications: this_publications } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(`"{}"`); + }); }); });