From 75388d4ab64987aa356428647e4e706f1214279a Mon Sep 17 00:00:00 2001 From: Liam Doodson Date: Wed, 22 Feb 2023 08:32:18 +0000 Subject: [PATCH 01/26] Docs update --- docs/modules/ROOT/pages/custom-resolvers.adoc | 94 ++++++++++++++++--- .../ROOT/pages/guides/v4-migration/index.adoc | 69 ++++++++++++++ 2 files changed, 149 insertions(+), 14 deletions(-) diff --git a/docs/modules/ROOT/pages/custom-resolvers.adoc b/docs/modules/ROOT/pages/custom-resolvers.adoc index aba6fd1581..cf07f5c67c 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,81 @@ 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 use the `... on` syntax 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) +} +---- + +==== 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 +177,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 From c0bdf5c1567da3e0a428d2b4ff6c9b8ae5e6d7be Mon Sep 17 00:00:00 2001 From: Liam Doodson Date: Wed, 22 Feb 2023 08:47:02 +0000 Subject: [PATCH 02/26] Parse selection sets --- packages/graphql/src/classes/Neo4jGraphQL.ts | 18 +- .../src/graphql/directives/customResolver.ts | 6 +- .../schema/get-custom-resolver-meta.test.ts | 748 ++++++++++++++++-- .../src/schema/get-custom-resolver-meta.ts | 291 ++++++- packages/graphql/src/schema/get-nodes.ts | 29 +- .../graphql/src/schema/get-obj-field-meta.ts | 15 +- .../src/schema/make-augmented-schema.ts | 10 +- .../parse/parse-fulltext-directive.test.ts | 5 +- .../schema/validation/validate-document.ts | 35 +- .../src/translate/utils/resolveTree.ts | 4 +- packages/graphql/src/types.ts | 2 +- 11 files changed, 1014 insertions(+), 149 deletions(-) diff --git a/packages/graphql/src/classes/Neo4jGraphQL.ts b/packages/graphql/src/classes/Neo4jGraphQL.ts index 40979aa855..62a0a5cd25 100644 --- a/packages/graphql/src/classes/Neo4jGraphQL.ts +++ b/packages/graphql/src/classes/Neo4jGraphQL.ts @@ -32,7 +32,7 @@ import type { Neo4jGraphQLPlugins, Neo4jGraphQLCallbacks, Neo4jFeaturesSettings, - StartupValidationConfig + StartupValidationConfig, } from "../types"; import { makeAugmentedSchema } from "../schema"; import type Node from "./Node"; @@ -170,7 +170,7 @@ class Neo4jGraphQL { driver, driverConfig, nodes: this.nodes, - options: input.options + options: input.options, }); } @@ -196,7 +196,7 @@ class Neo4jGraphQL { private async getNeo4jDatabaseInfo(driver: Driver, driverConfig?: DriverConfig): Promise { const executorConstructorParam: ExecutorConstructorParam = { - executionContext: driver + executionContext: driver, }; if (driverConfig?.database) { @@ -221,13 +221,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 @@ -248,7 +248,7 @@ class Neo4jGraphQL { validateResolvers, generateSubscriptions: Boolean(this.plugins?.subscriptions), callbacks: this.config.callbacks, - userCustomResolvers: this.resolvers + userCustomResolvers: this.resolvers, }); const schemaModel = generateModel(document); @@ -263,7 +263,7 @@ class Neo4jGraphQL { const schema = makeExecutableSchema({ typeDefs, - resolvers: wrappedResolvers + resolvers: wrappedResolvers, }); resolve(this.addDefaultFieldResolvers(schema)); @@ -280,7 +280,7 @@ class Neo4jGraphQL { if (this.config?.startupValidation === false) { return { validateTypeDefs: false, - validateResolvers: false + validateResolvers: false, }; } @@ -294,7 +294,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..611ca90ba4 100644 --- a/packages/graphql/src/graphql/directives/customResolver.ts +++ b/packages/graphql/src/graphql/directives/customResolver.ts @@ -17,7 +17,7 @@ * limitations under the License. */ -import { DirectiveLocation, GraphQLDirective, GraphQLList, GraphQLNonNull, GraphQLString } from "graphql"; +import { DirectiveLocation, GraphQLDirective, GraphQLString } from "graphql"; export const customResolverDirective = new GraphQLDirective({ name: "customResolver", @@ -27,8 +27,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 that the custom resolver will depend on. These are passed as an object to the first argument of the custom resolver.", + type: GraphQLString, }, }, }); 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..a85d454b6c 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,464 @@ * limitations under the License. */ -import type { FieldDefinitionNode, ObjectTypeDefinitionNode } from "graphql"; -import { Kind } from "graphql"; -import getCustomResolverMeta, { ERROR_MESSAGE } from "./get-custom-resolver-meta"; +import { + DocumentNode, + extendSchema, + FieldDefinitionNode, + GraphQLSchema, + InterfaceTypeDefinitionNode, + ObjectTypeDefinitionNode, + specifiedDirectives, + UnionTypeDefinitionNode, + Kind, +} from "graphql"; +import { directives } from ".."; +import { generateResolveTree } from "../translate/utils/resolveTree"; +import getCustomResolverMeta 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 document: DocumentNode = { + kind: Kind.DOCUMENT, + definitions: [...objects, ...interfaces, ...unions], }; + const baseSchema = extendSchema( + new GraphQLSchema({ + directives: [...Object.values(directives), ...specifiedDirectives], + }), + document + ); + + 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 +498,69 @@ describe("getCustomResolverMeta", () => { ], name: { kind: Kind.NAME, - value: fieldName, + value: customResolverField, }, }; - const result = getCustomResolverMeta(field, object, true, resolvers); + const result = getCustomResolverMeta({ + baseSchema, + 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: [ + { + // @ts-ignore + name: { + value: "customResolver", + // @ts-ignore + }, + }, + { + // @ts-ignore + name: { value: "RANDOM 2" }, + }, + { + // @ts-ignore + name: { value: "RANDOM 3" }, + }, + { + // @ts-ignore + name: { value: "RANDOM 4" }, + }, + ], + name: { + kind: Kind.NAME, + value: customResolverField, + }, + }; + + const result = getCustomResolverMeta({ + baseSchema, + field, + object, + objects, + validateResolvers: true, + interfaces, + unions, + customResolvers: resolvers, + }); + + expect(result).toEqual({ + requiredFields: {}, + }); + }); + test("should return the correct meta when a list of required fields is provided", () => { + const requiredFields = ["name"]; const field: FieldDefinitionNode = { directives: [ { @@ -90,7 +574,13 @@ describe("getCustomResolverMeta", () => { // @ts-ignore name: { value: "requires" }, // @ts-ignore - value: { kind: Kind.BOOLEAN }, + value: { + kind: Kind.LIST, + values: requiredFields.map((requiredField) => ({ + kind: Kind.STRING, + value: requiredField, + })), + }, }, ], }, @@ -109,13 +599,31 @@ describe("getCustomResolverMeta", () => { ], name: { kind: Kind.NAME, - value: fieldName, + value: customResolverField, }, }; - expect(() => getCustomResolverMeta(field, object, true, resolvers)).toThrow(ERROR_MESSAGE); + const result = getCustomResolverMeta({ + baseSchema, + field, + object, + objects, + validateResolvers: true, + interfaces, + unions, + customResolvers: resolvers, + }); + + expect(result).toEqual({ + requiredFields: requiredFields.reduce((res, field) => { + res = { ...res, ...generateResolveTree({ name: field }) }; + return res; + }, {}), + }); }); - test("should throw if requires not a list of strings", () => { + + test("should not throw an error if a list of required fields is provided that contains invalid fields", () => { + const requiredFields = ["field1", "field2", "field3"]; const field: FieldDefinitionNode = { directives: [ { @@ -131,11 +639,10 @@ describe("getCustomResolverMeta", () => { // @ts-ignore value: { kind: Kind.LIST, - values: [ - { kind: Kind.STRING, value: "field1" }, - { kind: Kind.STRING, value: "field2" }, - { kind: Kind.BOOLEAN, value: true }, - ], + values: requiredFields.map((requiredField) => ({ + kind: Kind.STRING, + value: requiredField, + })), }, }, ], @@ -155,13 +662,31 @@ describe("getCustomResolverMeta", () => { ], name: { kind: Kind.NAME, - value: fieldName, + value: customResolverField, }, }; - expect(() => getCustomResolverMeta(field, object, true, resolvers)).toThrow(ERROR_MESSAGE); + const result = getCustomResolverMeta({ + baseSchema, + field, + object, + objects, + validateResolvers: true, + interfaces, + unions, + customResolvers: resolvers, + }); + + expect(result).toEqual({ + requiredFields: requiredFields.reduce((res, field) => { + res = { ...res, ...generateResolveTree({ name: field }) }; + return res; + }, {}), + }); }); - 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 +695,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 +722,66 @@ describe("getCustomResolverMeta", () => { ], name: { kind: Kind.NAME, - value: fieldName, + value: customResolverField, }, }; - const result = getCustomResolverMeta(field, object, true, resolvers); + const result = getCustomResolverMeta({ + baseSchema, + 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 +796,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,16 +817,24 @@ describe("getCustomResolverMeta", () => { ], name: { kind: Kind.NAME, - value: fieldName, + value: customResolverField, }, }; - const result = getCustomResolverMeta(field, object, true, resolvers); - - expect(result).toMatchObject({ - requiredFields, - }); + expect(() => + getCustomResolverMeta({ + baseSchema, + field, + object, + objects, + validateResolvers: true, + interfaces, + unions, + customResolvers: resolvers, + }) + ).toThrow(`Invalid selection set provided to @customResolver on ${authorType}`); }); + test("Check throws error if customResolver is not provided", () => { const requiredFields = ["field1", "field2", "field3"]; const field: FieldDefinitionNode = { @@ -286,15 +875,24 @@ 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({ + baseSchema, + 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"]; @@ -336,19 +934,28 @@ 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({ + baseSchema, + 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", () => { @@ -391,16 +998,27 @@ 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({ + baseSchema, + 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..195f81a9af 100644 --- a/packages/graphql/src/schema/get-custom-resolver-meta.ts +++ b/packages/graphql/src/schema/get-custom-resolver-meta.ts @@ -17,32 +17,61 @@ * limitations under the License. */ +import { mergeSchemas } from "@graphql-tools/schema"; import type { IResolvers } from "@graphql-tools/utils"; -import type { +import { gql } from "apollo-server-express"; +import { FieldDefinitionNode, - StringValueNode, InterfaceTypeDefinitionNode, ObjectTypeDefinitionNode, + DocumentNode, + SelectionSetNode, + TypeNode, + UnionTypeDefinitionNode, + validate, + Kind, + parse, + GraphQLSchema, + 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", "computed"]; +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(", ")}`; +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 default function getCustomResolverMeta({ + baseSchema, + field, + object, + objects, + validateResolvers, + interfaces, + unions, + customResolvers, + interfaceField, +}: { + baseSchema: GraphQLSchema; + 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 +80,235 @@ 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} }`); + validateSelectionSet(baseSchema, object, selectionSetDocument); + const requiredFieldsResolveTree = selectionSetToResolveTree( + object.fields || [], + objects, + interfaces, + unions, + selectionSetDocument ); + if (requiredFieldsResolveTree) { + return { + requiredFields: requiredFieldsResolveTree, + }; + } +} + +function validateSelectionSet( + baseSchema: GraphQLSchema, + object: ObjectTypeDefinitionNode | InterfaceTypeDefinitionNode, + selectionSetDocument: DocumentNode +) { + const validationSchema = mergeSchemas({ + schemas: [baseSchema], + typeDefs: gql` + 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}: ${errors.join(", ")}` + ); + } +} + +function selectionSetToResolveTree( + objectFields: ReadonlyArray, + objects: ObjectTypeDefinitionNode[], + interfaces: InterfaceTypeDefinitionNode[], + unions: UnionTypeDefinitionNode[], + document: DocumentNode +) { + if (document.definitions.length !== 1) { + throw new Error(INVALID_SELECTION_SET_ERROR); + } - return { - requiredFields, - }; + 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 + ); } -export default getCustomResolverMeta; +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 { + ...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; +} + +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..06c19b146a 100644 --- a/packages/graphql/src/schema/get-nodes.ts +++ b/packages/graphql/src/schema/get-nodes.ts @@ -18,7 +18,7 @@ */ import type { IResolvers } from "@graphql-tools/utils"; -import type { DirectiveNode, NamedTypeNode } from "graphql"; +import type { DirectiveNode, GraphQLSchema, NamedTypeNode } from "graphql"; import type { Exclude } from "../classes"; import { Node } from "../classes"; import type { NodeDirective } from "../classes/NodeDirective"; @@ -44,6 +44,7 @@ type Nodes = { }; function getNodes( + baseSchema: GraphQLSchema, definitionNodes: DefinitionNodes, options: { callbacks?: Neo4jGraphQLCallbacks; @@ -130,6 +131,7 @@ function getNodes( ] as IResolvers; const nodeFields = getObjFieldMeta({ + baseSchema, obj: definition, enums: definitionNodes.enumTypes, interfaces: definitionNodes.interfaceTypes, @@ -141,31 +143,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..bd89ea0276 100644 --- a/packages/graphql/src/schema/get-obj-field-meta.ts +++ b/packages/graphql/src/schema/get-obj-field-meta.ts @@ -32,6 +32,7 @@ import type { EnumValueNode, UnionTypeDefinitionNode, ValueNode, + GraphQLSchema, } from "graphql"; import { Kind } from "graphql"; import getAuth from "./get-auth"; @@ -87,6 +88,7 @@ export interface ObjectFields { let callbackDeprecatedWarningShown = false; function getObjFieldMeta({ + baseSchema, obj, objects, interfaces, @@ -97,6 +99,7 @@ function getObjFieldMeta({ customResolvers, validateResolvers, }: { + baseSchema: GraphQLSchema; obj: ObjectTypeDefinitionNode | InterfaceTypeDefinitionNode; objects: ObjectTypeDefinitionNode[]; interfaces: InterfaceTypeDefinitionNode[]; @@ -132,13 +135,17 @@ function getObjFieldMeta({ const relationshipMeta = getRelationshipMeta(field, interfaceField); const cypherMeta = getCypherMeta(field, interfaceField); - const customResolverMeta = getCustomResolverMeta( + const customResolverMeta = getCustomResolverMeta({ + baseSchema, 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.ts b/packages/graphql/src/schema/make-augmented-schema.ts index 6e171cce71..746d2a975e 100644 --- a/packages/graphql/src/schema/make-augmented-schema.ts +++ b/packages/graphql/src/schema/make-augmented-schema.ts @@ -111,9 +111,7 @@ function makeAugmentedSchema( typeDefs: DocumentNode; resolvers: IResolvers; } { - if (validateTypeDefs) { - validateDocument(document); - } + const baseSchema = validateDocument(document, validateTypeDefs); const composer = new SchemaComposer(); @@ -155,7 +153,7 @@ function makeAugmentedSchema( composer.addTypeDefs(print({ kind: Kind.DOCUMENT, definitions: extraDefinitions })); } - const getNodesResult = getNodes(definitionNodes, { callbacks, userCustomResolvers, validateResolvers }); + const getNodesResult = getNodes(baseSchema, definitionNodes, { callbacks, userCustomResolvers, validateResolvers }); const { nodes, relationshipPropertyInterfaceNames, interfaceRelationshipNames, floatWhereInTypeDefs } = getNodesResult; @@ -200,6 +198,7 @@ function makeAugmentedSchema( }); const relFields = getObjFieldMeta({ + baseSchema, enums: enumTypes, interfaces: interfaceTypes, objects: objectTypes, @@ -289,6 +288,7 @@ function makeAugmentedSchema( ); const interfaceFields = getObjFieldMeta({ + baseSchema, enums: enumTypes, interfaces: [...interfaceTypes, ...interfaceRelationships], objects: objectTypes, @@ -797,6 +797,7 @@ function makeAugmentedSchema( if (cypherType) { const objectFields = getObjFieldMeta({ + baseSchema, obj: cypherType, scalars: scalarTypes, enums: enumTypes, @@ -836,6 +837,7 @@ function makeAugmentedSchema( interfaceTypes.forEach((inter) => { const objectFields = getObjFieldMeta({ + baseSchema, obj: inter, scalars: scalarTypes, enums: enumTypes, diff --git a/packages/graphql/src/schema/parse/parse-fulltext-directive.test.ts b/packages/graphql/src/schema/parse/parse-fulltext-directive.test.ts index 3d2b2a28a1..33a4df3503 100644 --- a/packages/graphql/src/schema/parse/parse-fulltext-directive.test.ts +++ b/packages/graphql/src/schema/parse/parse-fulltext-directive.test.ts @@ -18,7 +18,7 @@ */ import { gql } from "apollo-server-core"; -import type { DirectiveNode, ObjectTypeDefinitionNode } from "graphql"; +import { DirectiveNode, GraphQLSchema, ObjectTypeDefinitionNode } from "graphql"; import getObjFieldMeta from "../get-obj-field-meta"; import parseFulltextDirective from "./parse-fulltext-directive"; @@ -38,6 +38,7 @@ describe("parseFulltextDirective", () => { const directive = (definition.directives || [])[0] as DirectiveNode; const nodeFields = getObjFieldMeta({ + baseSchema: new GraphQLSchema({}), obj: definition, enums: [], interfaces: [], @@ -68,6 +69,7 @@ describe("parseFulltextDirective", () => { const directive = (definition.directives || [])[0] as DirectiveNode; const nodeFields = getObjFieldMeta({ + baseSchema: new GraphQLSchema({}), obj: definition, enums: [], interfaces: [], @@ -106,6 +108,7 @@ describe("parseFulltextDirective", () => { const directive = (definition.directives || [])[0] as DirectiveNode; const nodeFields = getObjFieldMeta({ + baseSchema: new GraphQLSchema({}), obj: definition, enums: [], interfaces: [], diff --git a/packages/graphql/src/schema/validation/validate-document.ts b/packages/graphql/src/schema/validation/validate-document.ts index 97f33eb343..d4fb8417c4 100644 --- a/packages/graphql/src/schema/validation/validate-document.ts +++ b/packages/graphql/src/schema/validation/validate-document.ts @@ -24,6 +24,8 @@ import type { InputValueDefinitionNode, FieldDefinitionNode, TypeNode, + GraphQLDirective, + GraphQLNamedType, } from "graphql"; import { GraphQLSchema, extendSchema, validateSchema, specifiedDirectives, Kind } from "graphql"; import pluralize from "pluralize"; @@ -156,11 +158,16 @@ function filterDocument(document: DocumentNode): DocumentNode { }; } -function validateDocument(document: DocumentNode): void { +function getBaseSchema( + document: DocumentNode, + validateTypeDefs: boolean, + additionalDirectives: Array = [], + additionalTypes: Array = [] +): GraphQLSchema { const doc = filterDocument(document); const schemaToExtend = new GraphQLSchema({ - directives: [...Object.values(directives), ...specifiedDirectives], + directives: [...Object.values(directives), ...specifiedDirectives, ...additionalDirectives], types: [ ...Object.values(scalars), Point, @@ -170,18 +177,32 @@ function validateDocument(document: DocumentNode): void { CartesianPointInput, CartesianPointDistance, SortDirection, + ...additionalTypes, ], }); - const schema = extendSchema(schemaToExtend, doc); + return extendSchema(schemaToExtend, doc, { assumeValid: !validateTypeDefs }); +} + +function validateDocument( + document: DocumentNode, + validateTypeDefs: boolean, + additionalDirectives: Array = [], + additionalTypes: Array = [] +): GraphQLSchema { + const schema = getBaseSchema(document, validateTypeDefs, additionalDirectives, additionalTypes); - const errors = validateSchema(schema); + if (validateTypeDefs) { + const errors = validateSchema(schema); - const filteredErrors = errors.filter((e) => e.message !== "Query root type must be provided."); + const filteredErrors = errors.filter((e) => e.message !== "Query root type must be provided."); - if (filteredErrors.length) { - throw new Error(filteredErrors.join("\n")); + if (filteredErrors.length) { + throw new Error(filteredErrors.join("\n")); + } } + + return schema; } export default validateDocument; 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 65f3c07fb6..76eaf9c3b6 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 { From 66d2e38c60b7688fab5f10b88a1cee0160589873 Mon Sep 17 00:00:00 2001 From: Liam Doodson Date: Wed, 22 Feb 2023 09:04:30 +0000 Subject: [PATCH 03/26] Bugfix --- .../src/translate/create-projection-and-params.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/graphql/src/translate/create-projection-and-params.ts b/packages/graphql/src/translate/create-projection-and-params.ts index 1f2e9eb9b8..707842ca6e 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 { getRelationshipDirection } 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"; @@ -352,7 +352,7 @@ 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 @@ -371,7 +371,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, { @@ -419,14 +419,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({ From ca21dfaccf39c809374cbc7f06b081d0d0d93ca4 Mon Sep 17 00:00:00 2001 From: Liam Doodson Date: Wed, 22 Feb 2023 09:04:37 +0000 Subject: [PATCH 04/26] UT fix --- .../validation/validate-document.test.ts | 74 ++++++++----------- 1 file changed, 31 insertions(+), 43 deletions(-) diff --git a/packages/graphql/src/schema/validation/validate-document.test.ts b/packages/graphql/src/schema/validation/validate-document.test.ts index cbbddf99cc..190b0c6185 100644 --- a/packages/graphql/src/schema/validation/validate-document.test.ts +++ b/packages/graphql/src/schema/validation/validate-document.test.ts @@ -29,7 +29,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc)).toThrow('Directive "@coalesce" may not be used on OBJECT.'); + expect(() => validateDocument(doc, true)).toThrow('Directive "@coalesce" may not be used on OBJECT.'); }); test("should throw an error if a directive is missing an argument", () => { @@ -39,7 +39,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc)).toThrow( + expect(() => validateDocument(doc, true)).toThrow( 'Directive "@coalesce" argument "value" of type "ScalarOrEnum!" is required, but it was not provided.' ); }); @@ -51,7 +51,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc)).toThrow('Unknown type "Unknown".'); + expect(() => validateDocument(doc, true)).toThrow('Unknown type "Unknown".'); }); test("should throw an error if a user tries to pass in their own Point definition", () => { @@ -66,7 +66,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc)).toThrow( + expect(() => validateDocument(doc, true)).toThrow( 'Type "Point" already exists in the schema. It cannot also be defined in this type definition.' ); }); @@ -80,7 +80,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc)).toThrow( + expect(() => validateDocument(doc, true)).toThrow( 'Type "DateTime" already exists in the schema. It cannot also be defined in this type definition.' ); }); @@ -94,7 +94,7 @@ describe("validateDocument", () => { extend type User @fulltext `; - expect(() => validateDocument(doc)).toThrow( + expect(() => validateDocument(doc, true)).toThrow( 'Directive "@fulltext" argument "indexes" of type "[FullTextInput]!" is required, but it was not provided.' ); }); @@ -111,7 +111,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc)).toThrow( + expect(() => validateDocument(doc, true)).toThrow( 'Type "PointInput" already exists in the schema. It cannot also be defined in this type definition.' ); }); @@ -127,7 +127,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc)).toThrow( + expect(() => validateDocument(doc, true)).toThrow( "Interface field UserInterface.age expected but User does not provide it." ); }); @@ -141,7 +141,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc)).toThrow( + expect(() => validateDocument(doc, true)).toThrow( 'Directive "@relationship" already exists in the schema. It cannot be redefined.' ); }); @@ -153,8 +153,7 @@ describe("validateDocument", () => { } `; - const res = validateDocument(doc); - expect(res).toBeUndefined(); + expect(() => validateDocument(doc, true)).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, true)).not.toThrow(); }); describe("relationshipProperties directive", () => { @@ -204,8 +202,7 @@ describe("validateDocument", () => { } `; - const res = validateDocument(doc); - expect(res).toBeUndefined(); + expect(() => validateDocument(doc, true)).not.toThrow(); }); test("should throw if used on an object type", () => { @@ -215,7 +212,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc)).toThrow( + expect(() => validateDocument(doc, true)).toThrow( 'Directive "@relationshipProperties" may not be used on OBJECT.' ); }); @@ -227,7 +224,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc)).toThrow( + expect(() => validateDocument(doc, true)).toThrow( 'Directive "@relationshipProperties" may not be used on FIELD_DEFINITION.' ); }); @@ -310,8 +307,7 @@ describe("validateDocument", () => { } `; - const res = validateDocument(doc); - expect(res).toBeUndefined(); + expect(() => validateDocument(doc, true)).not.toThrow(); }); describe("Github Issue 158", () => { @@ -326,8 +322,7 @@ describe("validateDocument", () => { } `; - const res = validateDocument(doc); - expect(res).toBeUndefined(); + expect(() => validateDocument(doc, true)).not.toThrow(); }); }); @@ -344,8 +339,7 @@ describe("validateDocument", () => { } `; - const res = validateDocument(doc); - expect(res).toBeUndefined(); + expect(() => validateDocument(doc, true)).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, true)).not.toThrow(); }); }); @@ -494,8 +487,7 @@ describe("validateDocument", () => { } `; - const res = validateDocument(doc); - expect(res).toBeUndefined(); + expect(() => validateDocument(doc, true)).not.toThrow(); }); }); @@ -517,8 +509,7 @@ describe("validateDocument", () => { } `; - const res = validateDocument(doc); - expect(res).toBeUndefined(); + expect(() => validateDocument(doc, true)).not.toThrow(); }); }); @@ -529,7 +520,7 @@ describe("validateDocument", () => { name: String @alias } `; - expect(() => validateDocument(doc)).toThrow( + expect(() => validateDocument(doc, true)).toThrow( 'Directive "@alias" argument "property" of type "String!" is required, but it was not provided.' ); }); @@ -539,7 +530,7 @@ describe("validateDocument", () => { name: String } `; - expect(() => validateDocument(doc)).toThrow('Directive "@alias" may not be used on OBJECT.'); + expect(() => validateDocument(doc, true)).toThrow('Directive "@alias" may not be used on OBJECT.'); }); test("should not throw when used correctly", () => { const doc = gql` @@ -547,8 +538,7 @@ describe("validateDocument", () => { name: String @alias(property: "dbName") } `; - const res = validateDocument(doc); - expect(res).toBeUndefined(); + expect(() => validateDocument(doc, true)).not.toThrow(); }); }); @@ -561,7 +551,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc)).toThrow( + expect(() => validateDocument(doc, true)).toThrow( RESERVED_TYPE_NAMES.find((x) => x.regex.test("PageInfo"))?.error ); }); @@ -573,7 +563,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc)).toThrow( + expect(() => validateDocument(doc, true)).toThrow( RESERVED_TYPE_NAMES.find((x) => x.regex.test("NodeConnection"))?.error ); }); @@ -585,7 +575,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc)).toThrow( + expect(() => validateDocument(doc, true)).toThrow( RESERVED_TYPE_NAMES.find((x) => x.regex.test("Node"))?.error ); }); @@ -608,7 +598,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc)).toThrow( + expect(() => validateDocument(doc, true)).toThrow( RESERVED_TYPE_NAMES.find((x) => x.regex.test("PageInfo"))?.error ); }); @@ -629,7 +619,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc)).toThrow( + expect(() => validateDocument(doc, true)).toThrow( RESERVED_TYPE_NAMES.find((x) => x.regex.test("NodeConnection"))?.error ); }); @@ -650,7 +640,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc)).toThrow( + expect(() => validateDocument(doc, true)).toThrow( RESERVED_TYPE_NAMES.find((x) => x.regex.test("Node"))?.error ); }); @@ -665,8 +655,7 @@ describe("validateDocument", () => { } `; - const res = validateDocument(doc); - expect(res).toBeUndefined(); + expect(() => validateDocument(doc, true)).not.toThrow(); }); }); @@ -686,8 +675,7 @@ describe("validateDocument", () => { } `; - const res = validateDocument(doc); - expect(res).toBeUndefined(); + expect(() => validateDocument(doc, true)).not.toThrow(); }); }); }); From 8023b0cc5e8f2882a61c3925fbe754f7e495c756 Mon Sep 17 00:00:00 2001 From: Liam Doodson Date: Wed, 22 Feb 2023 09:06:42 +0000 Subject: [PATCH 05/26] UT fix --- .../integration/config-options/startup-validation.int.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 { From 8332223b65e850c21f7327a1db5101a64709b039 Mon Sep 17 00:00:00 2001 From: Liam Doodson Date: Wed, 22 Feb 2023 09:21:55 +0000 Subject: [PATCH 06/26] UT fix --- .../schema/get-custom-resolver-meta.test.ts | 104 +++--------------- .../src/schema/get-custom-resolver-meta.ts | 2 +- 2 files changed, 14 insertions(+), 92 deletions(-) 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 a85d454b6c..eb2c4762cc 100644 --- a/packages/graphql/src/schema/get-custom-resolver-meta.test.ts +++ b/packages/graphql/src/schema/get-custom-resolver-meta.test.ts @@ -560,7 +560,7 @@ describe("getCustomResolverMeta", () => { }); }); test("should return the correct meta when a list of required fields is provided", () => { - const requiredFields = ["name"]; + const requiredFields = "name"; const field: FieldDefinitionNode = { directives: [ { @@ -575,74 +575,8 @@ describe("getCustomResolverMeta", () => { name: { value: "requires" }, // @ts-ignore value: { - kind: Kind.LIST, - values: requiredFields.map((requiredField) => ({ - kind: Kind.STRING, - value: requiredField, - })), - }, - }, - ], - }, - { - // @ts-ignore - name: { value: "RANDOM 2" }, - }, - { - // @ts-ignore - name: { value: "RANDOM 3" }, - }, - { - // @ts-ignore - name: { value: "RANDOM 4" }, - }, - ], - name: { - kind: Kind.NAME, - value: customResolverField, - }, - }; - - const result = getCustomResolverMeta({ - baseSchema, - field, - object, - objects, - validateResolvers: true, - interfaces, - unions, - customResolvers: resolvers, - }); - - expect(result).toEqual({ - requiredFields: requiredFields.reduce((res, field) => { - res = { ...res, ...generateResolveTree({ name: field }) }; - return res; - }, {}), - }); - }); - - test("should not throw an error if a list of required fields is provided that contains invalid fields", () => { - const requiredFields = ["field1", "field2", "field3"]; - const field: FieldDefinitionNode = { - directives: [ - { - // @ts-ignore - name: { - value: "customResolver", - // @ts-ignore - }, - arguments: [ - { - // @ts-ignore - name: { value: "requires" }, - // @ts-ignore - value: { - kind: Kind.LIST, - values: requiredFields.map((requiredField) => ({ - kind: Kind.STRING, - value: requiredField, - })), + kind: Kind.STRING, + value: requiredFields, }, }, ], @@ -678,10 +612,7 @@ describe("getCustomResolverMeta", () => { }); expect(result).toEqual({ - requiredFields: requiredFields.reduce((res, field) => { - res = { ...res, ...generateResolveTree({ name: field }) }; - return res; - }, {}), + requiredFields: generateResolveTree({ name: requiredFields }), }); }); @@ -836,7 +767,7 @@ describe("getCustomResolverMeta", () => { }); test("Check throws error if customResolver is not provided", () => { - const requiredFields = ["field1", "field2", "field3"]; + const requiredFields = "name"; const field: FieldDefinitionNode = { directives: [ { @@ -851,11 +782,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, }, }, ], @@ -895,7 +823,7 @@ describe("getCustomResolverMeta", () => { ).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: [ { @@ -910,11 +838,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, }, }, ], @@ -959,7 +884,7 @@ describe("getCustomResolverMeta", () => { }); test("Check does not throw error if validateResolvers false", () => { - const requiredFields = ["field1", "field2", "field3"]; + const requiredFields = "name"; const field: FieldDefinitionNode = { directives: [ { @@ -974,11 +899,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, }, }, ], diff --git a/packages/graphql/src/schema/get-custom-resolver-meta.ts b/packages/graphql/src/schema/get-custom-resolver-meta.ts index 195f81a9af..a79a78004d 100644 --- a/packages/graphql/src/schema/get-custom-resolver-meta.ts +++ b/packages/graphql/src/schema/get-custom-resolver-meta.ts @@ -125,7 +125,7 @@ function validateSelectionSet( const errors = validate(validationSchema, selectionSetDocument); if (errors.length) { throw new Error( - `Invalid selection set provided to @customResolver on ${object.name.value}: ${errors.join(", ")}` + `Invalid selection set provided to @customResolver on ${object.name.value}:\n${errors.join("\n")}` ); } } From 57f7e534592d944f60d18a93fbdebe0e1ddf0952 Mon Sep 17 00:00:00 2001 From: Liam Doodson Date: Wed, 22 Feb 2023 09:23:00 +0000 Subject: [PATCH 07/26] UT fix --- packages/graphql/src/translate/utils/resolveTree.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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]: { From 9f9de8dd9f81c9b47d16806cbe96e34ecb13e059 Mon Sep 17 00:00:00 2001 From: Liam Doodson Date: Wed, 22 Feb 2023 09:26:56 +0000 Subject: [PATCH 08/26] Updated int tests --- .../directives/customResolver.int.test.ts | 2233 ++++++++++++++++- .../tests/integration/issues/2560.int.test.ts | 6 +- 2 files changed, 2233 insertions(+), 6 deletions(-) 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") } `; From f6bf93fb92ede803f7c3916a016808199f829083 Mon Sep 17 00:00:00 2001 From: Liam Doodson Date: Wed, 22 Feb 2023 09:29:35 +0000 Subject: [PATCH 09/26] TCK tests --- .../tck/directives/customResolver.test.ts | 772 ++++++++++++++++-- 1 file changed, 720 insertions(+), 52 deletions(-) diff --git a/packages/graphql/tests/tck/directives/customResolver.test.ts b/packages/graphql/tests/tck/directives/customResolver.test.ts index 3888fe841b..b304cf5c2f 100644 --- a/packages/graphql/tests/tck/directives/customResolver.test.ts +++ b/packages/graphql/tests/tck/directives/customResolver.test.ts @@ -23,75 +23,743 @@ 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(`"{}"`); + }); + }); + + 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, + }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:\`User\`) + RETURN this { .firstName, .fullName } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(`"{}"`); + }); + + 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(`"{}"`); + }); + }); + + 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, + }); + + 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(`"{}"`); }); }); - test("Fields in selection set", async () => { - const query = gql` - { - users { - firstName - lastName - fullName + 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 } }" + ) } - } - `; - const req = createJwtRequest("secret", {}); - const result = await translateQuery(neoSchema, query, { - req, + 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 }, + }); }); - expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` - "MATCH (this:\`User\`) - RETURN this { .firstName, .lastName, .fullName } AS this" - `); + 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\\", .title } AS this_publications + RETURN this_publications AS this_publications + UNION + WITH * + MATCH (this)-[this1:WROTE]->(this_publications:\`Journal\`) + WITH this_publications { __resolveType: \\"Journal\\", .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\\", .title } AS this_publications + RETURN this_publications AS this_publications + UNION + WITH * + MATCH (this)-[this1:WROTE]->(this_publications:\`Journal\`) + WITH this_publications { __resolveType: \\"Journal\\", .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\\", .title } AS this_publications + RETURN this_publications AS this_publications + UNION + WITH * + MATCH (this)-[this1:WROTE]->(this_publications:\`Journal\`) + WITH this_publications { __resolveType: \\"Journal\\", .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(`"{}"`); + expect(formatParams(result.params)).toMatchInlineSnapshot(`"{}"`); + }); }); - test("Fields not in selection set", async () => { - const query = gql` - { - users { - fullName + 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 req = createJwtRequest("secret", {}); - const result = await translateQuery(neoSchema, query, { - req, + const resolvers = { + Author: { + publicationsWithAuthor: () => "Some custom resolver", + }, + }; + + neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + config: { enableRegex: true }, + }); }); - expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` - "MATCH (this:\`User\`) - RETURN this { .fullName, .firstName, .lastName } AS this" - `); + 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\`) + WITH * + CALL { + WITH * + CALL { + WITH this + MATCH (this)-[this0:WROTE]->(this_Book:\`Book\`) + RETURN { __resolveType: \\"Book\\", title: this_Book.title, publicationYear: this_Book.publicationYear } AS this_publications + UNION + WITH this + MATCH (this)-[this1:WROTE]->(this_Journal:\`Journal\`) + RETURN { __resolveType: \\"Journal\\", subject: this_Journal.subject, publicationYear: this_Journal.publicationYear } AS 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(formatParams(result.params)).toMatchInlineSnapshot(`"{}"`); + 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\`) + WITH * + CALL { + WITH * + CALL { + WITH this + MATCH (this)-[this0:WROTE]->(this_Book:\`Book\`) + RETURN { __resolveType: \\"Book\\", title: this_Book.title, publicationYear: this_Book.publicationYear } AS this_publications + UNION + WITH this + MATCH (this)-[this1:WROTE]->(this_Journal:\`Journal\`) + RETURN { __resolveType: \\"Journal\\", subject: this_Journal.subject, publicationYear: this_Journal.publicationYear } AS 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\`) + WITH * + CALL { + WITH * + CALL { + WITH this + MATCH (this)-[this0:WROTE]->(this_Book:\`Book\`) + RETURN { __resolveType: \\"Book\\", title: this_Book.title, publicationYear: this_Book.publicationYear } AS this_publications + UNION + WITH this + MATCH (this)-[this1:WROTE]->(this_Journal:\`Journal\`) + RETURN { __resolveType: \\"Journal\\", subject: this_Journal.subject, publicationYear: this_Journal.publicationYear } AS this_publications + } + RETURN collect(this_publications) AS this_publications + } + RETURN this { .publicationsWithAuthor, .name, publications: this_publications } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(`"{}"`); + }); }); }); From 7743399d320b26126bb6e83bcd498c1c78517a83 Mon Sep 17 00:00:00 2001 From: Liam Doodson Date: Wed, 22 Feb 2023 11:43:06 +0000 Subject: [PATCH 10/26] Changeset --- .changeset/old-pigs-float.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/old-pigs-float.md 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. From 1b8c7a79d3933f58bb13b675ac950bff0574d108 Mon Sep 17 00:00:00 2001 From: Liam Doodson Date: Thu, 23 Feb 2023 16:33:11 +0000 Subject: [PATCH 11/26] Bugfix --- packages/graphql/src/schema/get-custom-resolver-meta.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/graphql/src/schema/get-custom-resolver-meta.ts b/packages/graphql/src/schema/get-custom-resolver-meta.ts index a79a78004d..7bb0d82850 100644 --- a/packages/graphql/src/schema/get-custom-resolver-meta.ts +++ b/packages/graphql/src/schema/get-custom-resolver-meta.ts @@ -19,7 +19,6 @@ import { mergeSchemas } from "@graphql-tools/schema"; import type { IResolvers } from "@graphql-tools/utils"; -import { gql } from "apollo-server-express"; import { FieldDefinitionNode, InterfaceTypeDefinitionNode, @@ -115,7 +114,7 @@ function validateSelectionSet( ) { const validationSchema = mergeSchemas({ schemas: [baseSchema], - typeDefs: gql` + typeDefs: ` schema { query: ${object.name.value} } From fe71ce5b968db7140f6440872945db2fdad6e357 Mon Sep 17 00:00:00 2001 From: Liam Doodson Date: Fri, 24 Feb 2023 16:25:35 +0000 Subject: [PATCH 12/26] Merge fix --- packages/graphql/src/classes/Neo4jGraphQL.ts | 15 +++++++-------- .../src/schema/make-augmented-schema.test.ts | 8 +++++--- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/graphql/src/classes/Neo4jGraphQL.ts b/packages/graphql/src/classes/Neo4jGraphQL.ts index 557262a59b..1838e1a0bf 100644 --- a/packages/graphql/src/classes/Neo4jGraphQL.ts +++ b/packages/graphql/src/classes/Neo4jGraphQL.ts @@ -270,9 +270,7 @@ class Neo4jGraphQL { const { validateTypeDefs, validateResolvers } = this.parseStartupValidationConfig(); - if (validateTypeDefs) { - validateDocument(document); - } + const baseSchema = validateDocument(document, validateTypeDefs); const { nodes, relationships, typeDefs, resolvers } = makeAugmentedSchema(document, { features: this.features, @@ -281,6 +279,7 @@ class Neo4jGraphQL { generateSubscriptions: Boolean(this.plugins?.subscriptions), callbacks: this.config.callbacks, userCustomResolvers: this.resolvers, + baseSchema, }); this.schemaModel = generateModel(document); @@ -310,9 +309,8 @@ class Neo4jGraphQL { const { validateTypeDefs, validateResolvers } = this.parseStartupValidationConfig(); - if (validateTypeDefs) { - validateDocument(document, directives, types); - } + const baseSchema = validateDocument(document, validateTypeDefs, directives, types); + const { nodes, relationships, typeDefs, resolvers } = makeAugmentedSchema(document, { features: this.features, enableRegex: this.config?.enableRegex, @@ -320,7 +318,8 @@ class Neo4jGraphQL { generateSubscriptions: Boolean(this.plugins?.subscriptions), callbacks: this.config.callbacks, userCustomResolvers: this.resolvers, - subgraph + subgraph, + baseSchema, }); this.schemaModel = generateModel(document); @@ -334,7 +333,7 @@ class Neo4jGraphQL { const schema = subgraph.buildSchema({ typeDefs: subgraphTypeDefs, - resolvers: wrappedResolvers as Record + resolvers: wrappedResolvers as Record, }); return this.addDefaultFieldResolvers(schema); diff --git a/packages/graphql/src/schema/make-augmented-schema.test.ts b/packages/graphql/src/schema/make-augmented-schema.test.ts index b98d4f6591..d631648add 100644 --- a/packages/graphql/src/schema/make-augmented-schema.test.ts +++ b/packages/graphql/src/schema/make-augmented-schema.test.ts @@ -18,12 +18,13 @@ */ import camelCase from "camelcase"; -import type { +import { ObjectTypeDefinitionNode, NamedTypeNode, ListTypeNode, NonNullTypeNode, - InputObjectTypeDefinitionNode + InputObjectTypeDefinitionNode, + GraphQLSchema, } from "graphql"; import { pluralize } from "graphql-compose"; import { gql } from "apollo-server"; @@ -160,7 +161,8 @@ describe("makeAugmentedSchema", () => { const neoSchema = makeAugmentedSchema(typeDefs, { enableRegex: true, - validateResolvers: true + validateResolvers: true, + baseSchema: new GraphQLSchema({}), }); const document = neoSchema.typeDefs; From 1fb63187b5b6835b680bc363e0ddec0172c7070d Mon Sep 17 00:00:00 2001 From: Liam Doodson Date: Fri, 24 Feb 2023 16:34:59 +0000 Subject: [PATCH 13/26] Test fixes --- ...eration-subgraph-compatibility.e2e.test.ts | 6 +- .../tck/directives/customResolver.test.ts | 84 ++++++++++--------- 2 files changed, 50 insertions(+), 40 deletions(-) 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/tck/directives/customResolver.test.ts b/packages/graphql/tests/tck/directives/customResolver.test.ts index b304cf5c2f..0d88e51895 100644 --- a/packages/graphql/tests/tck/directives/customResolver.test.ts +++ b/packages/graphql/tests/tck/directives/customResolver.test.ts @@ -454,12 +454,12 @@ describe("@customResolver directive", () => { CALL { WITH * MATCH (this)-[this0:WROTE]->(this_publications:\`Book\`) - WITH this_publications { __resolveType: \\"Book\\", .title } AS this_publications + 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\\", .subject } AS this_publications + WITH this_publications { __resolveType: \\"Journal\\", __id: id(this), .subject } AS this_publications RETURN this_publications AS this_publications } WITH this_publications @@ -519,12 +519,12 @@ describe("@customResolver directive", () => { CALL { WITH * MATCH (this)-[this0:WROTE]->(this_publications:\`Book\`) - WITH this_publications { __resolveType: \\"Book\\", .title } AS this_publications + 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\\", .subject } AS this_publications + WITH this_publications { __resolveType: \\"Journal\\", __id: id(this), .subject } AS this_publications RETURN this_publications AS this_publications } WITH this_publications @@ -557,12 +557,12 @@ describe("@customResolver directive", () => { CALL { WITH * MATCH (this)-[this0:WROTE]->(this_publications:\`Book\`) - WITH this_publications { __resolveType: \\"Book\\", .title } AS this_publications + 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\\", .subject } AS this_publications + WITH this_publications { __resolveType: \\"Journal\\", __id: id(this), .subject } AS this_publications RETURN this_publications AS this_publications } WITH this_publications @@ -643,19 +643,21 @@ describe("@customResolver directive", () => { expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` "MATCH (this:\`Author\`) - WITH * CALL { - WITH * - CALL { - WITH this - MATCH (this)-[this0:WROTE]->(this_Book:\`Book\`) - RETURN { __resolveType: \\"Book\\", title: this_Book.title, publicationYear: this_Book.publicationYear } AS this_publications - UNION WITH this - MATCH (this)-[this1:WROTE]->(this_Journal:\`Journal\`) - RETURN { __resolveType: \\"Journal\\", subject: this_Journal.subject, publicationYear: this_Journal.publicationYear } AS this_publications - } - RETURN collect(this_publications) AS this_publications + 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" `); @@ -706,19 +708,21 @@ describe("@customResolver directive", () => { expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` "MATCH (this:\`Author\`) - WITH * CALL { - WITH * - CALL { - WITH this - MATCH (this)-[this0:WROTE]->(this_Book:\`Book\`) - RETURN { __resolveType: \\"Book\\", title: this_Book.title, publicationYear: this_Book.publicationYear } AS this_publications - UNION WITH this - MATCH (this)-[this1:WROTE]->(this_Journal:\`Journal\`) - RETURN { __resolveType: \\"Journal\\", subject: this_Journal.subject, publicationYear: this_Journal.publicationYear } AS this_publications - } - RETURN collect(this_publications) AS this_publications + 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" `); @@ -742,19 +746,21 @@ describe("@customResolver directive", () => { expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` "MATCH (this:\`Author\`) - WITH * - CALL { - WITH * CALL { WITH this - MATCH (this)-[this0:WROTE]->(this_Book:\`Book\`) - RETURN { __resolveType: \\"Book\\", title: this_Book.title, publicationYear: this_Book.publicationYear } AS this_publications - UNION - WITH this - MATCH (this)-[this1:WROTE]->(this_Journal:\`Journal\`) - RETURN { __resolveType: \\"Journal\\", subject: this_Journal.subject, publicationYear: this_Journal.publicationYear } AS this_publications - } - RETURN collect(this_publications) AS this_publications + 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" `); From cba95a8c88a0ac423b20bc180c1ca58019791d79 Mon Sep 17 00:00:00 2001 From: Liam Doodson Date: Fri, 24 Feb 2023 16:42:17 +0000 Subject: [PATCH 14/26] More test fixes --- .../apollo-federation-subgraph-compatibility/src/type-defs.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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" ) } From a62ddea9234d09d671d5b13c9c78416013a22ab9 Mon Sep 17 00:00:00 2001 From: Liam Doodson Date: Mon, 27 Feb 2023 11:14:00 +0000 Subject: [PATCH 15/26] Removed baseSchema --- packages/graphql/src/classes/Neo4jGraphQL.ts | 10 +-- .../schema/get-custom-resolver-meta.test.ts | 29 +-------- .../src/schema/get-custom-resolver-meta.ts | 7 +-- packages/graphql/src/schema/get-nodes.ts | 4 +- .../graphql/src/schema/get-obj-field-meta.ts | 4 -- .../src/schema/make-augmented-schema.test.ts | 4 +- .../src/schema/make-augmented-schema.ts | 12 +--- .../parse/parse-fulltext-directive.test.ts | 5 +- .../validation/validate-document.test.ts | 62 +++++++++---------- .../schema/validation/validate-document.ts | 23 +++---- 10 files changed, 54 insertions(+), 106 deletions(-) diff --git a/packages/graphql/src/classes/Neo4jGraphQL.ts b/packages/graphql/src/classes/Neo4jGraphQL.ts index 1838e1a0bf..22be9300c9 100644 --- a/packages/graphql/src/classes/Neo4jGraphQL.ts +++ b/packages/graphql/src/classes/Neo4jGraphQL.ts @@ -270,7 +270,9 @@ class Neo4jGraphQL { const { validateTypeDefs, validateResolvers } = this.parseStartupValidationConfig(); - const baseSchema = validateDocument(document, validateTypeDefs); + if (validateTypeDefs) { + validateDocument(document); + } const { nodes, relationships, typeDefs, resolvers } = makeAugmentedSchema(document, { features: this.features, @@ -279,7 +281,6 @@ class Neo4jGraphQL { generateSubscriptions: Boolean(this.plugins?.subscriptions), callbacks: this.config.callbacks, userCustomResolvers: this.resolvers, - baseSchema, }); this.schemaModel = generateModel(document); @@ -309,7 +310,9 @@ class Neo4jGraphQL { const { validateTypeDefs, validateResolvers } = this.parseStartupValidationConfig(); - const baseSchema = validateDocument(document, validateTypeDefs, directives, types); + if (validateTypeDefs) { + validateDocument(document, directives, types); + } const { nodes, relationships, typeDefs, resolvers } = makeAugmentedSchema(document, { features: this.features, @@ -319,7 +322,6 @@ class Neo4jGraphQL { callbacks: this.config.callbacks, userCustomResolvers: this.resolvers, subgraph, - baseSchema, }); this.schemaModel = generateModel(document); 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 eb2c4762cc..50b945065a 100644 --- a/packages/graphql/src/schema/get-custom-resolver-meta.test.ts +++ b/packages/graphql/src/schema/get-custom-resolver-meta.test.ts @@ -18,19 +18,14 @@ */ import { - DocumentNode, - extendSchema, FieldDefinitionNode, - GraphQLSchema, InterfaceTypeDefinitionNode, ObjectTypeDefinitionNode, - specifiedDirectives, UnionTypeDefinitionNode, Kind, } from "graphql"; -import { directives } from ".."; import { generateResolveTree } from "../translate/utils/resolveTree"; -import getCustomResolverMeta from "./get-custom-resolver-meta"; +import getCustomResolverMeta, { INVALID_SELECTION_SET_ERROR } from "./get-custom-resolver-meta"; describe("getCustomResolverMeta", () => { const authorType = "Author"; @@ -457,18 +452,6 @@ describe("getCustomResolverMeta", () => { const unions: UnionTypeDefinitionNode[] = []; - const document: DocumentNode = { - kind: Kind.DOCUMENT, - definitions: [...objects, ...interfaces, ...unions], - }; - - const baseSchema = extendSchema( - new GraphQLSchema({ - directives: [...Object.values(directives), ...specifiedDirectives], - }), - document - ); - const object = objects.find((obj) => obj.name.value === authorType) as ObjectTypeDefinitionNode; const resolvers = { @@ -503,7 +486,6 @@ describe("getCustomResolverMeta", () => { }; const result = getCustomResolverMeta({ - baseSchema, field, object, objects, @@ -545,7 +527,6 @@ describe("getCustomResolverMeta", () => { }; const result = getCustomResolverMeta({ - baseSchema, field, object, objects, @@ -601,7 +582,6 @@ describe("getCustomResolverMeta", () => { }; const result = getCustomResolverMeta({ - baseSchema, field, object, objects, @@ -658,7 +638,6 @@ describe("getCustomResolverMeta", () => { }; const result = getCustomResolverMeta({ - baseSchema, field, object, objects, @@ -754,7 +733,6 @@ describe("getCustomResolverMeta", () => { expect(() => getCustomResolverMeta({ - baseSchema, field, object, objects, @@ -763,7 +741,7 @@ describe("getCustomResolverMeta", () => { unions, customResolvers: resolvers, }) - ).toThrow(`Invalid selection set provided to @customResolver on ${authorType}`); + ).toThrow(INVALID_SELECTION_SET_ERROR); }); test("Check throws error if customResolver is not provided", () => { @@ -811,7 +789,6 @@ describe("getCustomResolverMeta", () => { expect(() => getCustomResolverMeta({ - baseSchema, field, object, objects, @@ -871,7 +848,6 @@ describe("getCustomResolverMeta", () => { expect(() => getCustomResolverMeta({ - baseSchema, field, object, objects, @@ -932,7 +908,6 @@ describe("getCustomResolverMeta", () => { expect(() => getCustomResolverMeta({ - baseSchema, field, object, objects, diff --git a/packages/graphql/src/schema/get-custom-resolver-meta.ts b/packages/graphql/src/schema/get-custom-resolver-meta.ts index 7bb0d82850..3feed51da1 100644 --- a/packages/graphql/src/schema/get-custom-resolver-meta.ts +++ b/packages/graphql/src/schema/get-custom-resolver-meta.ts @@ -44,10 +44,9 @@ const INVALID_DIRECTIVES_TO_REQUIRE = ["customResolver", "computed"]; 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(", ")}`; -const INVALID_SELECTION_SET_ERROR = "Invalid selection set passed to @customResolver required"; +export const INVALID_SELECTION_SET_ERROR = "Invalid selection set passed to @customResolver required"; export default function getCustomResolverMeta({ - baseSchema, field, object, objects, @@ -57,7 +56,6 @@ export default function getCustomResolverMeta({ customResolvers, interfaceField, }: { - baseSchema: GraphQLSchema; field: FieldDefinitionNode; object: ObjectTypeDefinitionNode | InterfaceTypeDefinitionNode; objects: ObjectTypeDefinitionNode[]; @@ -92,7 +90,6 @@ export default function getCustomResolverMeta({ } const selectionSetDocument = parse(`{ ${directiveRequiresArgument.value.value} }`); - validateSelectionSet(baseSchema, object, selectionSetDocument); const requiredFieldsResolveTree = selectionSetToResolveTree( object.fields || [], objects, @@ -107,7 +104,7 @@ export default function getCustomResolverMeta({ } } -function validateSelectionSet( +export function validateCustomResolverRequires( baseSchema: GraphQLSchema, object: ObjectTypeDefinitionNode | InterfaceTypeDefinitionNode, selectionSetDocument: DocumentNode diff --git a/packages/graphql/src/schema/get-nodes.ts b/packages/graphql/src/schema/get-nodes.ts index 06c19b146a..e263886bee 100644 --- a/packages/graphql/src/schema/get-nodes.ts +++ b/packages/graphql/src/schema/get-nodes.ts @@ -18,7 +18,7 @@ */ import type { IResolvers } from "@graphql-tools/utils"; -import type { DirectiveNode, GraphQLSchema, NamedTypeNode } from "graphql"; +import type { DirectiveNode, NamedTypeNode } from "graphql"; import type { Exclude } from "../classes"; import { Node } from "../classes"; import type { NodeDirective } from "../classes/NodeDirective"; @@ -44,7 +44,6 @@ type Nodes = { }; function getNodes( - baseSchema: GraphQLSchema, definitionNodes: DefinitionNodes, options: { callbacks?: Neo4jGraphQLCallbacks; @@ -131,7 +130,6 @@ function getNodes( ] as IResolvers; const nodeFields = getObjFieldMeta({ - baseSchema, obj: definition, enums: definitionNodes.enumTypes, interfaces: definitionNodes.interfaceTypes, diff --git a/packages/graphql/src/schema/get-obj-field-meta.ts b/packages/graphql/src/schema/get-obj-field-meta.ts index bd89ea0276..9da6083be4 100644 --- a/packages/graphql/src/schema/get-obj-field-meta.ts +++ b/packages/graphql/src/schema/get-obj-field-meta.ts @@ -32,7 +32,6 @@ import type { EnumValueNode, UnionTypeDefinitionNode, ValueNode, - GraphQLSchema, } from "graphql"; import { Kind } from "graphql"; import getAuth from "./get-auth"; @@ -88,7 +87,6 @@ export interface ObjectFields { let callbackDeprecatedWarningShown = false; function getObjFieldMeta({ - baseSchema, obj, objects, interfaces, @@ -99,7 +97,6 @@ function getObjFieldMeta({ customResolvers, validateResolvers, }: { - baseSchema: GraphQLSchema; obj: ObjectTypeDefinitionNode | InterfaceTypeDefinitionNode; objects: ObjectTypeDefinitionNode[]; interfaces: InterfaceTypeDefinitionNode[]; @@ -136,7 +133,6 @@ function getObjFieldMeta({ const relationshipMeta = getRelationshipMeta(field, interfaceField); const cypherMeta = getCypherMeta(field, interfaceField); const customResolverMeta = getCustomResolverMeta({ - baseSchema, field, object: obj, objects, diff --git a/packages/graphql/src/schema/make-augmented-schema.test.ts b/packages/graphql/src/schema/make-augmented-schema.test.ts index d631648add..d672e1a49d 100644 --- a/packages/graphql/src/schema/make-augmented-schema.test.ts +++ b/packages/graphql/src/schema/make-augmented-schema.test.ts @@ -18,13 +18,12 @@ */ import camelCase from "camelcase"; -import { +import type { ObjectTypeDefinitionNode, NamedTypeNode, ListTypeNode, NonNullTypeNode, InputObjectTypeDefinitionNode, - GraphQLSchema, } from "graphql"; import { pluralize } from "graphql-compose"; import { gql } from "apollo-server"; @@ -162,7 +161,6 @@ describe("makeAugmentedSchema", () => { const neoSchema = makeAugmentedSchema(typeDefs, { enableRegex: true, validateResolvers: true, - baseSchema: new GraphQLSchema({}), }); const document = neoSchema.typeDefs; diff --git a/packages/graphql/src/schema/make-augmented-schema.ts b/packages/graphql/src/schema/make-augmented-schema.ts index 70a73f499d..d9cf8c54cc 100644 --- a/packages/graphql/src/schema/make-augmented-schema.ts +++ b/packages/graphql/src/schema/make-augmented-schema.ts @@ -26,7 +26,7 @@ import type { NameNode, ObjectTypeDefinitionNode, } from "graphql"; -import { GraphQLID, GraphQLNonNull, GraphQLSchema, Kind, parse, print } from "graphql"; +import { GraphQLID, GraphQLNonNull, Kind, parse, print } from "graphql"; import type { ObjectTypeComposer } from "graphql-compose"; import { SchemaComposer } from "graphql-compose"; import pluralize from "pluralize"; @@ -96,7 +96,6 @@ function makeAugmentedSchema( callbacks, userCustomResolvers, subgraph, - baseSchema, }: { features?: Neo4jFeaturesSettings; enableRegex?: boolean; @@ -105,8 +104,7 @@ function makeAugmentedSchema( callbacks?: Neo4jGraphQLCallbacks; userCustomResolvers?: IResolvers | Array; subgraph?: Subgraph; - baseSchema: GraphQLSchema; - } = { validateResolvers: true, baseSchema: new GraphQLSchema({}) } + } = { validateResolvers: true } ): { nodes: Node[]; relationships: Relationship[]; @@ -164,7 +162,7 @@ function makeAugmentedSchema( composer.addTypeDefs(print({ kind: Kind.DOCUMENT, definitions: extraDefinitions })); } - const getNodesResult = getNodes(baseSchema, definitionNodes, { callbacks, userCustomResolvers, validateResolvers }); + const getNodesResult = getNodes(definitionNodes, { callbacks, userCustomResolvers, validateResolvers }); const { nodes, relationshipPropertyInterfaceNames, interfaceRelationshipNames, floatWhereInTypeDefs } = getNodesResult; @@ -209,7 +207,6 @@ function makeAugmentedSchema( }); const relFields = getObjFieldMeta({ - baseSchema, enums: enumTypes, interfaces: interfaceTypes, objects: objectTypes, @@ -299,7 +296,6 @@ function makeAugmentedSchema( ); const interfaceFields = getObjFieldMeta({ - baseSchema, enums: enumTypes, interfaces: [...interfaceTypes, ...interfaceRelationships], objects: objectTypes, @@ -819,7 +815,6 @@ function makeAugmentedSchema( if (cypherType) { const objectFields = getObjFieldMeta({ - baseSchema, obj: cypherType, scalars: scalarTypes, enums: enumTypes, @@ -859,7 +854,6 @@ function makeAugmentedSchema( interfaceTypes.forEach((inter) => { const objectFields = getObjFieldMeta({ - baseSchema, obj: inter, scalars: scalarTypes, enums: enumTypes, diff --git a/packages/graphql/src/schema/parse/parse-fulltext-directive.test.ts b/packages/graphql/src/schema/parse/parse-fulltext-directive.test.ts index 33a4df3503..3d2b2a28a1 100644 --- a/packages/graphql/src/schema/parse/parse-fulltext-directive.test.ts +++ b/packages/graphql/src/schema/parse/parse-fulltext-directive.test.ts @@ -18,7 +18,7 @@ */ import { gql } from "apollo-server-core"; -import { DirectiveNode, GraphQLSchema, ObjectTypeDefinitionNode } from "graphql"; +import type { DirectiveNode, ObjectTypeDefinitionNode } from "graphql"; import getObjFieldMeta from "../get-obj-field-meta"; import parseFulltextDirective from "./parse-fulltext-directive"; @@ -38,7 +38,6 @@ describe("parseFulltextDirective", () => { const directive = (definition.directives || [])[0] as DirectiveNode; const nodeFields = getObjFieldMeta({ - baseSchema: new GraphQLSchema({}), obj: definition, enums: [], interfaces: [], @@ -69,7 +68,6 @@ describe("parseFulltextDirective", () => { const directive = (definition.directives || [])[0] as DirectiveNode; const nodeFields = getObjFieldMeta({ - baseSchema: new GraphQLSchema({}), obj: definition, enums: [], interfaces: [], @@ -108,7 +106,6 @@ describe("parseFulltextDirective", () => { const directive = (definition.directives || [])[0] as DirectiveNode; const nodeFields = getObjFieldMeta({ - baseSchema: new GraphQLSchema({}), obj: definition, enums: [], interfaces: [], diff --git a/packages/graphql/src/schema/validation/validate-document.test.ts b/packages/graphql/src/schema/validation/validate-document.test.ts index 190b0c6185..19f0bc7dcf 100644 --- a/packages/graphql/src/schema/validation/validate-document.test.ts +++ b/packages/graphql/src/schema/validation/validate-document.test.ts @@ -29,7 +29,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc, true)).toThrow('Directive "@coalesce" may not be used on OBJECT.'); + expect(() => validateDocument(doc)).toThrow('Directive "@coalesce" may not be used on OBJECT.'); }); test("should throw an error if a directive is missing an argument", () => { @@ -39,7 +39,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc, true)).toThrow( + expect(() => validateDocument(doc)).toThrow( 'Directive "@coalesce" argument "value" of type "ScalarOrEnum!" is required, but it was not provided.' ); }); @@ -51,7 +51,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc, true)).toThrow('Unknown type "Unknown".'); + expect(() => validateDocument(doc)).toThrow('Unknown type "Unknown".'); }); test("should throw an error if a user tries to pass in their own Point definition", () => { @@ -66,7 +66,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc, true)).toThrow( + expect(() => validateDocument(doc)).toThrow( 'Type "Point" already exists in the schema. It cannot also be defined in this type definition.' ); }); @@ -80,7 +80,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc, true)).toThrow( + expect(() => validateDocument(doc)).toThrow( 'Type "DateTime" already exists in the schema. It cannot also be defined in this type definition.' ); }); @@ -94,7 +94,7 @@ describe("validateDocument", () => { extend type User @fulltext `; - expect(() => validateDocument(doc, true)).toThrow( + expect(() => validateDocument(doc)).toThrow( 'Directive "@fulltext" argument "indexes" of type "[FullTextInput]!" is required, but it was not provided.' ); }); @@ -111,7 +111,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc, true)).toThrow( + expect(() => validateDocument(doc)).toThrow( 'Type "PointInput" already exists in the schema. It cannot also be defined in this type definition.' ); }); @@ -127,7 +127,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc, true)).toThrow( + expect(() => validateDocument(doc)).toThrow( "Interface field UserInterface.age expected but User does not provide it." ); }); @@ -141,7 +141,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc, true)).toThrow( + expect(() => validateDocument(doc)).toThrow( 'Directive "@relationship" already exists in the schema. It cannot be redefined.' ); }); @@ -153,7 +153,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc, true)).not.toThrow(); + expect(() => validateDocument(doc)).not.toThrow(); }); test("should not throw error on use of internal node input types", () => { @@ -181,7 +181,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc, true)).not.toThrow(); + expect(() => validateDocument(doc)).not.toThrow(); }); describe("relationshipProperties directive", () => { @@ -202,7 +202,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc, true)).not.toThrow(); + expect(() => validateDocument(doc)).not.toThrow(); }); test("should throw if used on an object type", () => { @@ -212,7 +212,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc, true)).toThrow( + expect(() => validateDocument(doc)).toThrow( 'Directive "@relationshipProperties" may not be used on OBJECT.' ); }); @@ -224,7 +224,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc, true)).toThrow( + expect(() => validateDocument(doc)).toThrow( 'Directive "@relationshipProperties" may not be used on FIELD_DEFINITION.' ); }); @@ -307,7 +307,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc, true)).not.toThrow(); + expect(() => validateDocument(doc)).not.toThrow(); }); describe("Github Issue 158", () => { @@ -322,7 +322,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc, true)).not.toThrow(); + expect(() => validateDocument(doc)).not.toThrow(); }); }); @@ -339,7 +339,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc, true)).not.toThrow(); + expect(() => validateDocument(doc)).not.toThrow(); }); test("should not throw error on validation of schema if SortDirection used", () => { @@ -354,7 +354,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc, true)).not.toThrow(); + expect(() => validateDocument(doc)).not.toThrow(); }); }); @@ -487,7 +487,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc, true)).not.toThrow(); + expect(() => validateDocument(doc)).not.toThrow(); }); }); @@ -509,7 +509,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc, true)).not.toThrow(); + expect(() => validateDocument(doc)).not.toThrow(); }); }); @@ -520,7 +520,7 @@ describe("validateDocument", () => { name: String @alias } `; - expect(() => validateDocument(doc, true)).toThrow( + expect(() => validateDocument(doc)).toThrow( 'Directive "@alias" argument "property" of type "String!" is required, but it was not provided.' ); }); @@ -530,7 +530,7 @@ describe("validateDocument", () => { name: String } `; - expect(() => validateDocument(doc, true)).toThrow('Directive "@alias" may not be used on OBJECT.'); + expect(() => validateDocument(doc)).toThrow('Directive "@alias" may not be used on OBJECT.'); }); test("should not throw when used correctly", () => { const doc = gql` @@ -538,7 +538,7 @@ describe("validateDocument", () => { name: String @alias(property: "dbName") } `; - expect(() => validateDocument(doc, true)).not.toThrow(); + expect(() => validateDocument(doc)).not.toThrow(); }); }); @@ -551,7 +551,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc, true)).toThrow( + expect(() => validateDocument(doc)).toThrow( RESERVED_TYPE_NAMES.find((x) => x.regex.test("PageInfo"))?.error ); }); @@ -563,7 +563,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc, true)).toThrow( + expect(() => validateDocument(doc)).toThrow( RESERVED_TYPE_NAMES.find((x) => x.regex.test("NodeConnection"))?.error ); }); @@ -575,7 +575,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc, true)).toThrow( + expect(() => validateDocument(doc)).toThrow( RESERVED_TYPE_NAMES.find((x) => x.regex.test("Node"))?.error ); }); @@ -598,7 +598,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc, true)).toThrow( + expect(() => validateDocument(doc)).toThrow( RESERVED_TYPE_NAMES.find((x) => x.regex.test("PageInfo"))?.error ); }); @@ -619,7 +619,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc, true)).toThrow( + expect(() => validateDocument(doc)).toThrow( RESERVED_TYPE_NAMES.find((x) => x.regex.test("NodeConnection"))?.error ); }); @@ -640,7 +640,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc, true)).toThrow( + expect(() => validateDocument(doc)).toThrow( RESERVED_TYPE_NAMES.find((x) => x.regex.test("Node"))?.error ); }); @@ -655,7 +655,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc, true)).not.toThrow(); + expect(() => validateDocument(doc)).not.toThrow(); }); }); @@ -675,7 +675,7 @@ describe("validateDocument", () => { } `; - expect(() => validateDocument(doc, true)).not.toThrow(); + 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 d4fb8417c4..bfb7bc7683 100644 --- a/packages/graphql/src/schema/validation/validate-document.ts +++ b/packages/graphql/src/schema/validation/validate-document.ts @@ -160,7 +160,6 @@ function filterDocument(document: DocumentNode): DocumentNode { function getBaseSchema( document: DocumentNode, - validateTypeDefs: boolean, additionalDirectives: Array = [], additionalTypes: Array = [] ): GraphQLSchema { @@ -181,28 +180,20 @@ function getBaseSchema( ], }); - return extendSchema(schemaToExtend, doc, { assumeValid: !validateTypeDefs }); + return extendSchema(schemaToExtend, doc); } function validateDocument( document: DocumentNode, - validateTypeDefs: boolean, additionalDirectives: Array = [], additionalTypes: Array = [] -): GraphQLSchema { - 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")); - } +): void { + const schema = getBaseSchema(document, additionalDirectives, additionalTypes); + 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")); } - - return schema; } export default validateDocument; From a09aecf557cebf61023f263c0e6ca06998b89534 Mon Sep 17 00:00:00 2001 From: Liam Doodson Date: Mon, 27 Feb 2023 11:54:17 +0000 Subject: [PATCH 16/26] Re-added validation for customResolver requires --- packages/graphql/src/classes/Neo4jGraphQL.ts | 9 +++ .../src/schema/get-custom-resolver-meta.ts | 25 ------- .../validation/validate-augmented-schema.ts | 25 +++++++ .../validate-custom-resolver-requires.ts | 71 +++++++++++++++++++ 4 files changed, 105 insertions(+), 25 deletions(-) create mode 100644 packages/graphql/src/schema/validation/validate-augmented-schema.ts create mode 100644 packages/graphql/src/schema/validation/validate-custom-resolver-requires.ts diff --git a/packages/graphql/src/classes/Neo4jGraphQL.ts b/packages/graphql/src/classes/Neo4jGraphQL.ts index 22be9300c9..d6f9ed3b8b 100644 --- a/packages/graphql/src/classes/Neo4jGraphQL.ts +++ b/packages/graphql/src/classes/Neo4jGraphQL.ts @@ -48,6 +48,7 @@ import { Executor, ExecutorConstructorParam } from "./Executor"; import { generateModel } from "../schema-model/generate-model"; import type { Neo4jGraphQLSchemaModel } from "../schema-model/Neo4jGraphQLSchemaModel"; import { validateDocument } from "../schema/validation"; +import { validateAugmentedSchema } from "../schema/validation/validate-augmented-schema"; export interface Neo4jGraphQLConfig { driverConfig?: DriverConfig; @@ -296,6 +297,10 @@ class Neo4jGraphQL { resolvers: wrappedResolvers, }); + if (validateTypeDefs) { + validateAugmentedSchema(document, schema); + } + resolve(this.addDefaultFieldResolvers(schema)); }); } @@ -338,6 +343,10 @@ class Neo4jGraphQL { resolvers: wrappedResolvers as Record, }); + if (validateTypeDefs) { + validateAugmentedSchema(document, schema); + } + return this.addDefaultFieldResolvers(schema); } diff --git a/packages/graphql/src/schema/get-custom-resolver-meta.ts b/packages/graphql/src/schema/get-custom-resolver-meta.ts index 3feed51da1..b0dbd08699 100644 --- a/packages/graphql/src/schema/get-custom-resolver-meta.ts +++ b/packages/graphql/src/schema/get-custom-resolver-meta.ts @@ -17,7 +17,6 @@ * limitations under the License. */ -import { mergeSchemas } from "@graphql-tools/schema"; import type { IResolvers } from "@graphql-tools/utils"; import { FieldDefinitionNode, @@ -27,10 +26,8 @@ import { SelectionSetNode, TypeNode, UnionTypeDefinitionNode, - validate, Kind, parse, - GraphQLSchema, FieldNode, } from "graphql"; import type { FieldsByTypeName, ResolveTree } from "graphql-parse-resolve-info"; @@ -104,28 +101,6 @@ export default function getCustomResolverMeta({ } } -export function validateCustomResolverRequires( - 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")}` - ); - } -} - function selectionSetToResolveTree( objectFields: ReadonlyArray, objects: ObjectTypeDefinitionNode[], diff --git a/packages/graphql/src/schema/validation/validate-augmented-schema.ts b/packages/graphql/src/schema/validation/validate-augmented-schema.ts new file mode 100644 index 0000000000..b148724fea --- /dev/null +++ b/packages/graphql/src/schema/validation/validate-augmented-schema.ts @@ -0,0 +1,25 @@ +/* + * 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 type { DocumentNode, GraphQLSchema } from "graphql"; +import { validateCustomResolverRequires } from "./validate-custom-resolver-requires"; + +export function validateAugmentedSchema(document: DocumentNode, schema: GraphQLSchema) { + validateCustomResolverRequires(document, schema); +} 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")}` + ); + } +} From d62b745d02c9fcb620b79a6952f85f87a119aeef Mon Sep 17 00:00:00 2001 From: Liam Doodson Date: Mon, 27 Feb 2023 13:32:53 +0000 Subject: [PATCH 17/26] Moved validation to within validate document --- packages/graphql/src/classes/Neo4jGraphQL.ts | 9 ------- .../validation/validate-augmented-schema.ts | 25 ------------------- .../schema/validation/validate-document.ts | 2 ++ 3 files changed, 2 insertions(+), 34 deletions(-) delete mode 100644 packages/graphql/src/schema/validation/validate-augmented-schema.ts diff --git a/packages/graphql/src/classes/Neo4jGraphQL.ts b/packages/graphql/src/classes/Neo4jGraphQL.ts index d6f9ed3b8b..22be9300c9 100644 --- a/packages/graphql/src/classes/Neo4jGraphQL.ts +++ b/packages/graphql/src/classes/Neo4jGraphQL.ts @@ -48,7 +48,6 @@ import { Executor, ExecutorConstructorParam } from "./Executor"; import { generateModel } from "../schema-model/generate-model"; import type { Neo4jGraphQLSchemaModel } from "../schema-model/Neo4jGraphQLSchemaModel"; import { validateDocument } from "../schema/validation"; -import { validateAugmentedSchema } from "../schema/validation/validate-augmented-schema"; export interface Neo4jGraphQLConfig { driverConfig?: DriverConfig; @@ -297,10 +296,6 @@ class Neo4jGraphQL { resolvers: wrappedResolvers, }); - if (validateTypeDefs) { - validateAugmentedSchema(document, schema); - } - resolve(this.addDefaultFieldResolvers(schema)); }); } @@ -343,10 +338,6 @@ class Neo4jGraphQL { resolvers: wrappedResolvers as Record, }); - if (validateTypeDefs) { - validateAugmentedSchema(document, schema); - } - return this.addDefaultFieldResolvers(schema); } diff --git a/packages/graphql/src/schema/validation/validate-augmented-schema.ts b/packages/graphql/src/schema/validation/validate-augmented-schema.ts deleted file mode 100644 index b148724fea..0000000000 --- a/packages/graphql/src/schema/validation/validate-augmented-schema.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * 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 type { DocumentNode, GraphQLSchema } from "graphql"; -import { validateCustomResolverRequires } from "./validate-custom-resolver-requires"; - -export function validateAugmentedSchema(document: DocumentNode, schema: GraphQLSchema) { - validateCustomResolverRequires(document, schema); -} diff --git a/packages/graphql/src/schema/validation/validate-document.ts b/packages/graphql/src/schema/validation/validate-document.ts index bfb7bc7683..599bb66b22 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 @@ -194,6 +195,7 @@ function validateDocument( if (filteredErrors.length) { throw new Error(filteredErrors.join("\n")); } + validateCustomResolverRequires(document, schema); } export default validateDocument; From cac759794f66fbbc79c139567111dfabb2193f64 Mon Sep 17 00:00:00 2001 From: Liam-Doodson <114480811+Liam-Doodson@users.noreply.github.com> Date: Mon, 27 Feb 2023 13:35:00 +0000 Subject: [PATCH 18/26] Apply docs suggestions from code review Co-authored-by: Darrell Warde <8117355+darrellwarde@users.noreply.github.com> --- docs/modules/ROOT/pages/custom-resolvers.adoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/modules/ROOT/pages/custom-resolvers.adoc b/docs/modules/ROOT/pages/custom-resolvers.adoc index cf07f5c67c..991a9af4cc 100644 --- a/docs/modules/ROOT/pages/custom-resolvers.adoc +++ b/docs/modules/ROOT/pages/custom-resolvers.adoc @@ -90,9 +90,9 @@ const neoSchema = new Neo4jGraphQL({ }); ---- -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. +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 use the `... on` syntax to conditionally select fields from interface/union types: +It is also possible to inline fragments to conditionally select fields from interface/union types: [source, graphql, indent=0] ---- From ff2c175686139e4bc50b163afe1422cde2b16102 Mon Sep 17 00:00:00 2001 From: Liam-Doodson <114480811+Liam-Doodson@users.noreply.github.com> Date: Mon, 27 Feb 2023 13:35:32 +0000 Subject: [PATCH 19/26] Update packages/graphql/src/schema/get-custom-resolver-meta.ts Co-authored-by: Darrell Warde <8117355+darrellwarde@users.noreply.github.com> --- packages/graphql/src/schema/get-custom-resolver-meta.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/graphql/src/schema/get-custom-resolver-meta.ts b/packages/graphql/src/schema/get-custom-resolver-meta.ts index b0dbd08699..fbee29d91b 100644 --- a/packages/graphql/src/schema/get-custom-resolver-meta.ts +++ b/packages/graphql/src/schema/get-custom-resolver-meta.ts @@ -43,7 +43,7 @@ export const INVALID_REQUIRED_FIELD_ERROR = `It is not possible to require field ).join(", ")}`; export const INVALID_SELECTION_SET_ERROR = "Invalid selection set passed to @customResolver required"; -export default function getCustomResolverMeta({ +export function getCustomResolverMeta({ field, object, objects, From 723a084dae878618006392bc3a39422372984330 Mon Sep 17 00:00:00 2001 From: Liam Doodson Date: Mon, 27 Feb 2023 13:46:00 +0000 Subject: [PATCH 20/26] Docs update --- docs/modules/ROOT/pages/custom-resolvers.adoc | 33 ++++++++++++++++++- .../graphql/src/schema/get-obj-field-meta.ts | 2 +- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/docs/modules/ROOT/pages/custom-resolvers.adoc b/docs/modules/ROOT/pages/custom-resolvers.adoc index 991a9af4cc..0e3f9c9cac 100644 --- a/docs/modules/ROOT/pages/custom-resolvers.adoc +++ b/docs/modules/ROOT/pages/custom-resolvers.adoc @@ -90,7 +90,7 @@ const neoSchema = new Neo4jGraphQL({ }); ---- -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. +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: @@ -122,6 +122,37 @@ type Journal implements Publication { } ---- +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. diff --git a/packages/graphql/src/schema/get-obj-field-meta.ts b/packages/graphql/src/schema/get-obj-field-meta.ts index 9da6083be4..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"; From 3c894fe3ea74aa94d6d6c9abd61acef05f82dfff Mon Sep 17 00:00:00 2001 From: Liam Doodson Date: Mon, 27 Feb 2023 13:53:00 +0000 Subject: [PATCH 21/26] Bugfix --- packages/graphql/src/schema/get-custom-resolver-meta.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 50b945065a..6c244933bc 100644 --- a/packages/graphql/src/schema/get-custom-resolver-meta.test.ts +++ b/packages/graphql/src/schema/get-custom-resolver-meta.test.ts @@ -25,7 +25,7 @@ import { Kind, } from "graphql"; import { generateResolveTree } from "../translate/utils/resolveTree"; -import getCustomResolverMeta, { INVALID_SELECTION_SET_ERROR } from "./get-custom-resolver-meta"; +import { getCustomResolverMeta, INVALID_SELECTION_SET_ERROR } from "./get-custom-resolver-meta"; describe("getCustomResolverMeta", () => { const authorType = "Author"; From c5a5127cfbe43fffa12ca56759f1899cb1031bc8 Mon Sep 17 00:00:00 2001 From: Liam Doodson Date: Mon, 27 Feb 2023 14:20:48 +0000 Subject: [PATCH 22/26] Custom SelectionSet scalar --- .../src/graphql/directives/customResolver.ts | 7 ++- .../src/graphql/scalars/SelectionSet.ts | 58 +++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 packages/graphql/src/graphql/scalars/SelectionSet.ts diff --git a/packages/graphql/src/graphql/directives/customResolver.ts b/packages/graphql/src/graphql/directives/customResolver.ts index 611ca90ba4..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, 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: - "Selection set that the custom resolver will depend on. These are passed as an object to the first argument of the custom resolver.", - type: 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..1a68280fc6 --- /dev/null +++ b/packages/graphql/src/graphql/scalars/SelectionSet.ts @@ -0,0 +1,58 @@ +/* + * 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"; + +// A BigInt value up to 64 bits in size, which can be a number or a string if used inline, or a string only if used as a variable. Always returned as a string. +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}`); + } +} From c0ae6125113721d3a861cc4fe4e91ef848c389d1 Mon Sep 17 00:00:00 2001 From: Liam Doodson Date: Mon, 27 Feb 2023 14:44:29 +0000 Subject: [PATCH 23/26] Removed check for removed directive --- packages/graphql/src/schema/get-custom-resolver-meta.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/graphql/src/schema/get-custom-resolver-meta.ts b/packages/graphql/src/schema/get-custom-resolver-meta.ts index fbee29d91b..da70b20c7e 100644 --- a/packages/graphql/src/schema/get-custom-resolver-meta.ts +++ b/packages/graphql/src/schema/get-custom-resolver-meta.ts @@ -37,7 +37,7 @@ type CustomResolverMeta = { requiredFields: Record; }; -const INVALID_DIRECTIVES_TO_REQUIRE = ["customResolver", "computed"]; +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(", ")}`; From 409d5f331059d0a7ee106ded8e032b0b2c5dc3e3 Mon Sep 17 00:00:00 2001 From: Liam Doodson Date: Tue, 28 Feb 2023 12:34:53 +0000 Subject: [PATCH 24/26] PR comments --- packages/graphql/src/classes/Neo4jGraphQL.ts | 11 +++++----- .../src/graphql/scalars/SelectionSet.ts | 1 - .../schema/validation/validate-document.ts | 21 +++++++++++-------- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/packages/graphql/src/classes/Neo4jGraphQL.ts b/packages/graphql/src/classes/Neo4jGraphQL.ts index 22be9300c9..03386f5c0e 100644 --- a/packages/graphql/src/classes/Neo4jGraphQL.ts +++ b/packages/graphql/src/classes/Neo4jGraphQL.ts @@ -48,6 +48,7 @@ import { Executor, ExecutorConstructorParam } from "./Executor"; import { generateModel } from "../schema-model/generate-model"; import type { Neo4jGraphQLSchemaModel } from "../schema-model/Neo4jGraphQLSchemaModel"; import { validateDocument } from "../schema/validation"; +import { validateCustomResolverRequires } from "../schema/validation/validate-custom-resolver-requires"; export interface Neo4jGraphQLConfig { driverConfig?: DriverConfig; @@ -270,9 +271,8 @@ class Neo4jGraphQL { const { validateTypeDefs, validateResolvers } = this.parseStartupValidationConfig(); - if (validateTypeDefs) { - validateDocument(document); - } + const baseSchema = validateDocument(document, validateTypeDefs); + validateCustomResolverRequires(document, baseSchema); const { nodes, relationships, typeDefs, resolvers } = makeAugmentedSchema(document, { features: this.features, @@ -310,9 +310,8 @@ class Neo4jGraphQL { const { validateTypeDefs, validateResolvers } = this.parseStartupValidationConfig(); - if (validateTypeDefs) { - validateDocument(document, directives, types); - } + const baseSchema = validateDocument(document, validateTypeDefs, directives, types); + validateCustomResolverRequires(document, baseSchema); const { nodes, relationships, typeDefs, resolvers } = makeAugmentedSchema(document, { features: this.features, diff --git a/packages/graphql/src/graphql/scalars/SelectionSet.ts b/packages/graphql/src/graphql/scalars/SelectionSet.ts index 1a68280fc6..373c201bc8 100644 --- a/packages/graphql/src/graphql/scalars/SelectionSet.ts +++ b/packages/graphql/src/graphql/scalars/SelectionSet.ts @@ -19,7 +19,6 @@ import { parse, ValueNode, GraphQLError, GraphQLScalarType, Kind } from "graphql"; -// A BigInt value up to 64 bits in size, which can be a number or a string if used inline, or a string only if used as a variable. Always returned as a string. export const GraphQLSelectionSet = new GraphQLScalarType({ name: "SelectionSet", description: diff --git a/packages/graphql/src/schema/validation/validate-document.ts b/packages/graphql/src/schema/validation/validate-document.ts index 599bb66b22..b1eb9963e9 100644 --- a/packages/graphql/src/schema/validation/validate-document.ts +++ b/packages/graphql/src/schema/validation/validate-document.ts @@ -40,7 +40,6 @@ 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 @@ -161,6 +160,7 @@ function filterDocument(document: DocumentNode): DocumentNode { function getBaseSchema( document: DocumentNode, + validateTypeDefs = true, additionalDirectives: Array = [], additionalTypes: Array = [] ): GraphQLSchema { @@ -181,21 +181,24 @@ function getBaseSchema( ], }); - return extendSchema(schemaToExtend, doc); + return extendSchema(schemaToExtend, doc, { assumeValid: !validateTypeDefs }); } function validateDocument( document: DocumentNode, + validateTypeDefs = true, additionalDirectives: Array = [], additionalTypes: Array = [] -): void { - const schema = getBaseSchema(document, additionalDirectives, additionalTypes); - 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")); +): GraphQLSchema { + 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); + return schema; } export default validateDocument; From 7462df80755167ee6e825f48cd52118c08d09d88 Mon Sep 17 00:00:00 2001 From: Liam-Doodson <114480811+Liam-Doodson@users.noreply.github.com> Date: Tue, 28 Feb 2023 13:16:41 +0000 Subject: [PATCH 25/26] Apply suggestions from code review Co-authored-by: Darrell Warde <8117355+darrellwarde@users.noreply.github.com> --- packages/graphql/src/classes/Neo4jGraphQL.ts | 3 +-- packages/graphql/src/schema/validation/validate-document.ts | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/graphql/src/classes/Neo4jGraphQL.ts b/packages/graphql/src/classes/Neo4jGraphQL.ts index 03386f5c0e..c09d2bcf4e 100644 --- a/packages/graphql/src/classes/Neo4jGraphQL.ts +++ b/packages/graphql/src/classes/Neo4jGraphQL.ts @@ -310,8 +310,7 @@ class Neo4jGraphQL { const { validateTypeDefs, validateResolvers } = this.parseStartupValidationConfig(); - const baseSchema = validateDocument(document, validateTypeDefs, directives, types); - validateCustomResolverRequires(document, baseSchema); + validateDocument(document, validateTypeDefs, directives, types); const { nodes, relationships, typeDefs, resolvers } = makeAugmentedSchema(document, { features: this.features, diff --git a/packages/graphql/src/schema/validation/validate-document.ts b/packages/graphql/src/schema/validation/validate-document.ts index b1eb9963e9..a075c1ea31 100644 --- a/packages/graphql/src/schema/validation/validate-document.ts +++ b/packages/graphql/src/schema/validation/validate-document.ts @@ -189,7 +189,7 @@ function validateDocument( validateTypeDefs = true, additionalDirectives: Array = [], additionalTypes: Array = [] -): GraphQLSchema { +): void { const schema = getBaseSchema(document, validateTypeDefs, additionalDirectives, additionalTypes); if (validateTypeDefs) { const errors = validateSchema(schema); @@ -198,7 +198,7 @@ function validateDocument( throw new Error(filteredErrors.join("\n")); } } - return schema; + validateCustomResolverRequires(document, schema); } export default validateDocument; From ded2b683accb59bc75d98c207dbbaf81b7e0815b Mon Sep 17 00:00:00 2001 From: Liam Doodson Date: Tue, 28 Feb 2023 13:18:15 +0000 Subject: [PATCH 26/26] PR comments --- packages/graphql/src/classes/Neo4jGraphQL.ts | 4 +--- packages/graphql/src/schema/validation/validate-document.ts | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/graphql/src/classes/Neo4jGraphQL.ts b/packages/graphql/src/classes/Neo4jGraphQL.ts index c09d2bcf4e..2f0a1b47fa 100644 --- a/packages/graphql/src/classes/Neo4jGraphQL.ts +++ b/packages/graphql/src/classes/Neo4jGraphQL.ts @@ -48,7 +48,6 @@ import { Executor, ExecutorConstructorParam } from "./Executor"; import { generateModel } from "../schema-model/generate-model"; import type { Neo4jGraphQLSchemaModel } from "../schema-model/Neo4jGraphQLSchemaModel"; import { validateDocument } from "../schema/validation"; -import { validateCustomResolverRequires } from "../schema/validation/validate-custom-resolver-requires"; export interface Neo4jGraphQLConfig { driverConfig?: DriverConfig; @@ -271,8 +270,7 @@ class Neo4jGraphQL { const { validateTypeDefs, validateResolvers } = this.parseStartupValidationConfig(); - const baseSchema = validateDocument(document, validateTypeDefs); - validateCustomResolverRequires(document, baseSchema); + validateDocument(document, validateTypeDefs); const { nodes, relationships, typeDefs, resolvers } = makeAugmentedSchema(document, { features: this.features, diff --git a/packages/graphql/src/schema/validation/validate-document.ts b/packages/graphql/src/schema/validation/validate-document.ts index a075c1ea31..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