diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 3646883832..221dfae160 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -56,6 +56,7 @@ "rimraf": "3.0.2", "semver": "7.3.5", "ts-jest": "26.1.4", + "ts-node": "^10.0.0", "typescript": "3.9.7" }, "dependencies": { diff --git a/packages/graphql/src/classes/Neo4jGraphQL.ts b/packages/graphql/src/classes/Neo4jGraphQL.ts index 0c82d29fff..20920a7fe2 100644 --- a/packages/graphql/src/classes/Neo4jGraphQL.ts +++ b/packages/graphql/src/classes/Neo4jGraphQL.ts @@ -20,7 +20,7 @@ import Debug from "debug"; import { Driver } from "neo4j-driver"; import { DocumentNode, GraphQLResolveInfo, GraphQLSchema, parse, printSchema, print } from "graphql"; -import { addSchemaLevelResolver, IExecutableSchemaDefinition } from "@graphql-tools/schema"; +import { addResolversToSchema, addSchemaLevelResolver, IExecutableSchemaDefinition } from "@graphql-tools/schema"; import type { DriverConfig } from "../types"; import { makeAugmentedSchema } from "../schema"; import Node from "./Node"; @@ -28,6 +28,7 @@ import { checkNeo4jCompat } from "../utils"; import { getJWT } from "../auth/index"; import { DEBUG_GRAPHQL } from "../constants"; import getNeo4jResolveTree from "../utils/get-neo4j-resolve-tree"; +import createAuthParam from "../translate/create-auth-param"; const debug = Debug(DEBUG_GRAPHQL); @@ -60,13 +61,26 @@ class Neo4jGraphQL { public config?: Neo4jGraphQLConfig; constructor(input: Neo4jGraphQLConstructor) { - const { config = {}, driver, ...schemaDefinition } = input; + const { config = {}, driver, resolvers, ...schemaDefinition } = input; const { nodes, schema } = makeAugmentedSchema(schemaDefinition, { enableRegex: config.enableRegex }); this.driver = driver; this.config = config; this.nodes = nodes; - this.schema = this.createWrappedSchema({ schema, config }); + this.schema = schema; + /* + addResolversToSchema must be first, so that custom resolvers also get schema level resolvers + */ + if (resolvers) { + if (Array.isArray(resolvers)) { + resolvers.forEach((r) => { + this.schema = addResolversToSchema(this.schema, r); + }); + } else { + this.schema = addResolversToSchema(this.schema, resolvers); + } + } + this.schema = this.createWrappedSchema({ schema: this.schema, config }); this.document = parse(printSchema(schema)); } @@ -118,6 +132,8 @@ class Neo4jGraphQL { context.resolveTree = getNeo4jResolveTree(resolveInfo); context.jwt = getJWT(context); + + context.auth = createAuthParam({ context }); }); } diff --git a/packages/graphql/src/schema/make-augmented-schema.ts b/packages/graphql/src/schema/make-augmented-schema.ts index 1dd953cd0f..cb08bf447b 100644 --- a/packages/graphql/src/schema/make-augmented-schema.ts +++ b/packages/graphql/src/schema/make-augmented-schema.ts @@ -48,7 +48,6 @@ import { findResolver, createResolver, deleteResolver, cypherResolver, updateRes import checkNodeImplementsInterfaces from "./check-node-implements-interfaces"; import * as Scalars from "./scalars"; import parseExcludeDirective from "./parse-exclude-directive"; -import wrapCustomResolvers from "./wrap-custom-resolvers"; import getCustomResolvers from "./get-custom-resolvers"; import getObjFieldMeta from "./get-obj-field-meta"; import * as point from "./point"; @@ -56,7 +55,7 @@ import { graphqlDirectivesToCompose, objectFieldsToComposeFields } from "./to-co // import validateTypeDefs from "./validation"; function makeAugmentedSchema( - { typeDefs, resolvers, ...schemaDefinition }: IExecutableSchemaDefinition, + { typeDefs, ...schemaDefinition }: IExecutableSchemaDefinition, { enableRegex }: { enableRegex?: boolean } = {} ): { schema: GraphQLSchema; nodes: Node[] } { const document = mergeTypeDefs(Array.isArray(typeDefs) ? (typeDefs as string[]) : [typeDefs as string]); @@ -175,8 +174,6 @@ function makeAugmentedSchema( return node; }); - const nodeNames = nodes.map((x) => x.name); - nodes.forEach((node) => { const nodeFields = objectFieldsToComposeFields([ ...node.primitiveFields, @@ -741,7 +738,7 @@ function makeAugmentedSchema( } const generatedTypeDefs = composer.toSDL(); - let generatedResolvers: any = { + const generatedResolvers = { ...composer.getResolveMethods(), ...Object.entries(Scalars).reduce((res, [name, scalar]) => { if (generatedTypeDefs.includes(`scalar ${name}\n`)) { @@ -751,16 +748,7 @@ function makeAugmentedSchema( }, {}), }; - if (resolvers) { - generatedResolvers = wrapCustomResolvers({ - generatedResolvers, - nodeNames, - resolvers, - }); - } - unions.forEach((union) => { - // eslint-disable-next-line no-underscore-dangle if (!generatedResolvers[union.name.value]) { generatedResolvers[union.name.value] = { __resolveType: (root) => root.__resolveType }; } diff --git a/packages/graphql/src/schema/wrap-custom-resolvers.ts b/packages/graphql/src/schema/wrap-custom-resolvers.ts deleted file mode 100644 index 764f52807a..0000000000 --- a/packages/graphql/src/schema/wrap-custom-resolvers.ts +++ /dev/null @@ -1,132 +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 { IExecutableSchemaDefinition } from "@graphql-tools/schema"; -import createAuthParam from "../translate/create-auth-param"; - -type IResolvers = IExecutableSchemaDefinition["resolvers"]; - -function wrapCustomResolvers({ - resolvers, - generatedResolvers, - nodeNames, -}: { - resolvers: IResolvers; - nodeNames: string[]; - generatedResolvers: any; -}): IResolvers { - let newResolvers: any = {}; - - const { - Query: customQueries = {}, - Mutation: customMutations = {}, - Subscription: customSubscriptions = {}, - ...rest - } = resolvers as Record; - - if (customQueries) { - if (generatedResolvers.Query) { - newResolvers.Query = { ...generatedResolvers.Query, ...customQueries }; - } else { - newResolvers.Query = customQueries; - } - } - - if (customMutations) { - if (generatedResolvers.Mutation) { - newResolvers.Mutation = { ...generatedResolvers.Mutation, ...customMutations }; - } else { - newResolvers.Mutation = customMutations; - } - } - - if (Object.keys(customSubscriptions).length) { - newResolvers.Subscription = customSubscriptions; - } - - const typeResolvers = Object.entries(rest).reduce((r, entry) => { - const [key, value] = entry; - - if (!nodeNames.includes(key)) { - return r; - } - - return { - ...r, - [key]: { - ...generatedResolvers[key], - ...value, - }, - }; - }, {}); - newResolvers = { - ...newResolvers, - ...typeResolvers, - }; - - (function wrapResolvers(obj) { - Object.entries(obj).forEach(([key, value]) => { - if (typeof value === "function") { - obj[key] = (...args) => { - const { driver } = args[2]; - if (!driver) { - throw new Error("context.diver missing"); - } - - const auth = createAuthParam({ context: args[2] }); - - args[2] = { ...args[2], auth }; - - return value(...args); - }; - - return; - } - - if (typeof value === "object") { - obj[key] = value; - wrapResolvers(value); - } - }); - - return obj; - })(newResolvers); - - // Not to wrap the scalars and directives - const otherResolvers = Object.entries(rest).reduce((r, entry) => { - const [key, value] = entry; - - if (nodeNames.includes(key)) { - return r; - } - - return { - ...r, - [key]: value, - }; - }, {}); - newResolvers = { - ...newResolvers, - ...otherResolvers, - }; - - return newResolvers; -} - -export default wrapCustomResolvers; diff --git a/packages/graphql/tests/integration/issues/#207.int.test.ts b/packages/graphql/tests/integration/issues/#207.int.test.ts new file mode 100644 index 0000000000..abe4135e77 --- /dev/null +++ b/packages/graphql/tests/integration/issues/#207.int.test.ts @@ -0,0 +1,112 @@ +/* + * 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 { Driver } from "neo4j-driver"; +import { graphql } from "graphql"; +import { gql } from "apollo-server"; +import neo4j from "../neo4j"; +import { Neo4jGraphQL } from "../../../src/classes"; + +describe("https://github.com/neo4j/graphql/issues/207", () => { + let driver: Driver; + const typeDefs = gql` + union Result = Book | Author + + type Book { + title: String + } + + type Author { + name: String + } + + type Query { + search: [Result] + } + `; + const resolvers = { + Result: { + __resolveType(obj) { + if (obj.name) { + return "Author"; + } + if (obj.title) { + return "Book"; + } + return null; // GraphQLError is thrown + }, + }, + Query: { + search: () => [{ title: "GraphQL Unions for Dummies" }, { name: "Darrell" }], + }, + }; + + beforeAll(async () => { + driver = await neo4j(); + }); + + afterAll(async () => { + await driver.close(); + }); + + test("__resolveType resolvers are correctly evaluated", async () => { + const session = driver.session(); + + const neoSchema = new Neo4jGraphQL({ typeDefs, resolvers, driver }); + + const mutation = ` + query GetSearchResults { + search { + __typename + ... on Book { + title + } + ... on Author { + name + } + } + } + `; + + try { + await neoSchema.checkNeo4jCompat(); + + const result = await graphql({ + schema: neoSchema.schema, + source: mutation, + contextValue: { driver }, + }); + + expect(result.errors).toBeFalsy(); + + expect(result?.data?.search).toEqual([ + { + __typename: "Book", + title: "GraphQL Unions for Dummies", + }, + { + __typename: "Author", + name: "Darrell", + }, + ]); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/graphql/tests/integration/issues/#283.int.test.ts b/packages/graphql/tests/integration/issues/#283.int.test.ts new file mode 100644 index 0000000000..724908dcd9 --- /dev/null +++ b/packages/graphql/tests/integration/issues/#283.int.test.ts @@ -0,0 +1,103 @@ +/* + * 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 { Driver } from "neo4j-driver"; +import { graphql } from "graphql"; +import { gql } from "apollo-server"; +import { generate } from "randomstring"; +import neo4j from "../neo4j"; +import { Neo4jGraphQL } from "../../../src/classes"; + +describe("https://github.com/neo4j/graphql/issues/283", () => { + let driver: Driver; + const typeDefs = gql` + type Mutation { + login: String + createPost(input: PostCreateInput!): Post! + @cypher( + statement: """ + CREATE (post:Post) + SET + post = $input, + post.datetime = datetime(), + post.id = randomUUID() + RETURN post + """ + ) + } + + type Post { + id: ID! @id + title: String! + datetime: DateTime @readonly @timestamp(operations: [CREATE]) + } + `; + // Presence of a custom resolver was causing the bug + const resolvers = { + Mutation: { + login: () => { + return { token: "token" }; + }, + }, + }; + + beforeAll(async () => { + driver = await neo4j(); + }); + + afterAll(async () => { + await driver.close(); + }); + + test("DateTime values return correctly when using custom resolvers in the schema", async () => { + const session = driver.session(); + + const neoSchema = new Neo4jGraphQL({ typeDefs, resolvers, driver }); + + const title = generate({ charset: "alphabetic" }); + + const mutation = ` + mutation { + createPost(input: { title: "${title}" }) { + id + title + datetime + } + } + `; + + try { + await neoSchema.checkNeo4jCompat(); + + const result = await graphql({ + schema: neoSchema.schema, + source: mutation, + contextValue: { driver }, + }); + + expect(result.errors).toBeFalsy(); + + expect(typeof result?.data?.createPost?.datetime).toBe("string"); + + await session.run(`MATCH (p:Post) WHERE p.title = "${title}" DELETE p`); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/graphql/tests/integration/types/point.int.test.ts b/packages/graphql/tests/integration/types/point.int.test.ts index 8213498206..779dfcc290 100644 --- a/packages/graphql/tests/integration/types/point.int.test.ts +++ b/packages/graphql/tests/integration/types/point.int.test.ts @@ -36,8 +36,18 @@ describe("Point", () => { size: Int! location: Point! } + + type Query { + custom: String! + } `; - neoSchema = new Neo4jGraphQL({ typeDefs }); + // Dummy custom resolvers to validate fix for https://github.com/neo4j/graphql/issues/278 + const resolvers = { + Query: { + custom: () => "hello", + }, + }; + neoSchema = new Neo4jGraphQL({ typeDefs, resolvers }); }); beforeEach(() => { diff --git a/yarn.lock b/yarn.lock index ac9bacb471..e2921d6aaf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -957,6 +957,7 @@ __metadata: rimraf: 3.0.2 semver: 7.3.5 ts-jest: 26.1.4 + ts-node: ^10.0.0 typescript: 3.9.7 peerDependencies: graphql: ^15.0.0 @@ -1160,6 +1161,34 @@ __metadata: languageName: node linkType: hard +"@tsconfig/node10@npm:^1.0.7": + version: 1.0.8 + resolution: "@tsconfig/node10@npm:1.0.8" + checksum: 0336493b89fb7c06409a1247a3fb00fac2755f21f3f8ae4b9dd2457859abfc5e8ca42b6d9ca5a279fe81bc70fe1f3450eef61e5dd5a63a7b4a6946ff31874816 + languageName: node + linkType: hard + +"@tsconfig/node12@npm:^1.0.7": + version: 1.0.9 + resolution: "@tsconfig/node12@npm:1.0.9" + checksum: 5532bfb5df47ed3a507da533c731a2fb80ee2e886edadbf20e664dcd3172d5c159577a281d15733b8d0c30bfa4e6b48496bef0704192c085520bc76bb9938068 + languageName: node + linkType: hard + +"@tsconfig/node14@npm:^1.0.0": + version: 1.0.1 + resolution: "@tsconfig/node14@npm:1.0.1" + checksum: d0068287dba46dc98e7d49c229b0fee034fbac2bb4bc2efe12cc67227a1c68ec0728ca1e535dff7f033f7455de6c67e9b8f9d90f4fc3bb07c0d9ac08186fe65c + languageName: node + linkType: hard + +"@tsconfig/node16@npm:^1.0.1": + version: 1.0.1 + resolution: "@tsconfig/node16@npm:1.0.1" + checksum: c389a4a81c291a27b96705de7fbe46d29aa4eb771450a41dfc075d89e1fdd63141898043a0d9f627460a1c409d06635a044dc4b3a4516173769a7d0a1558c51d + languageName: node + linkType: hard + "@types/accepts@npm:*, @types/accepts@npm:^1.3.5": version: 1.3.5 resolution: "@types/accepts@npm:1.3.5" @@ -13158,6 +13187,40 @@ resolve@^2.0.0-next.3: languageName: node linkType: hard +"ts-node@npm:^10.0.0": + version: 10.0.0 + resolution: "ts-node@npm:10.0.0" + dependencies: + "@tsconfig/node10": ^1.0.7 + "@tsconfig/node12": ^1.0.7 + "@tsconfig/node14": ^1.0.0 + "@tsconfig/node16": ^1.0.1 + arg: ^4.1.0 + create-require: ^1.1.0 + diff: ^4.0.1 + make-error: ^1.1.1 + source-map-support: ^0.5.17 + yn: 3.1.1 + peerDependencies: + "@swc/core": ">=1.2.45" + "@swc/wasm": ">=1.2.45" + "@types/node": "*" + typescript: ">=2.7" + peerDependenciesMeta: + "@swc/core": + optional: true + "@swc/wasm": + optional: true + bin: + ts-node: dist/bin.js + ts-node-cwd: dist/bin-cwd.js + ts-node-script: dist/bin-script.js + ts-node-transpile-only: dist/bin-transpile.js + ts-script: dist/bin-script-deprecated.js + checksum: dc461e2b9b931b00ff065530a0247f86da1d035e72a7ef6d7ed072dd8e6b236d1879f113dcc73a354d240c81b6b845445c3d32b16eeb68022ed27ab6d130c049 + languageName: node + linkType: hard + "tsconfig-paths@npm:^3.9.0": version: 3.9.0 resolution: "tsconfig-paths@npm:3.9.0"