diff --git a/docs/antora/content-nav.adoc b/docs/antora/content-nav.adoc index 4d97c2d846..2bcd1eabc7 100644 --- a/docs/antora/content-nav.adoc +++ b/docs/antora/content-nav.adoc @@ -32,6 +32,7 @@ *** xref:ogm/methods/index.adoc[] **** xref:ogm/methods/create/index.adoc[] **** xref:ogm/methods/find/index.adoc[] +**** xref:ogm/methods/count/index.adoc[] **** xref:ogm/methods/update/index.adoc[] **** xref:ogm/methods/delete/index.adoc[] *** xref:ogm/private/index.adoc[] diff --git a/docs/asciidoc/ogm/api-reference.adoc b/docs/asciidoc/ogm/api-reference.adoc index be34ea7cbc..cabcfa5d4f 100644 --- a/docs/asciidoc/ogm/api-reference.adoc +++ b/docs/asciidoc/ogm/api-reference.adoc @@ -48,6 +48,9 @@ const model = ogm.model("name") ==== `find()` Reference: <> +==== `count()` +Reference: <> + ==== `create()` Reference: <> diff --git a/docs/asciidoc/ogm/methods/count.adoc b/docs/asciidoc/ogm/methods/count.adoc new file mode 100644 index 0000000000..3d9cd8688d --- /dev/null +++ b/docs/asciidoc/ogm/methods/count.adoc @@ -0,0 +1,20 @@ +[[ogm-methods-count]] += Count + +Use to count nodes. + +== Usage + +=== Basic + +[source, javascript] +---- +const User = ogm.model("User"); + +const usersCount = await User.count(); +---- + +== Args + +=== `where` +JavaScript object representation of the GraphQL `where` input type, used for <>. diff --git a/docs/asciidoc/ogm/methods/index.adoc b/docs/asciidoc/ogm/methods/index.adoc index 1bb1ba428a..42a02f1bdf 100644 --- a/docs/asciidoc/ogm/methods/index.adoc +++ b/docs/asciidoc/ogm/methods/index.adoc @@ -5,6 +5,7 @@ You can call the following on a model; . <> . <> +. <> . <> . <> diff --git a/docs/asciidoc/schema/queries.adoc b/docs/asciidoc/schema/queries.adoc index 4509362720..e11f9023b3 100644 --- a/docs/asciidoc/schema/queries.adoc +++ b/docs/asciidoc/schema/queries.adoc @@ -32,6 +32,8 @@ The following Query fields will be automatically generated: type Query { posts(where: PostWhere, options: PostOptions): [Post!]! users(where: UserWhere, options: UserOptions): [User!]! + countPosts(where: PostWhere): Int! + usersCount(where: UserWhere): Int! } ---- @@ -64,6 +66,18 @@ query { } ---- + +=== Counting all Users + +The following Query will count all users and return a number. + +[source, graphql] +---- +query { + usersCount +} +---- + === Filtering See <> for details on how data can be filtered whilst querying. diff --git a/docs/docbook/content-map.xml b/docs/docbook/content-map.xml index 70280ccf55..11c0a4eb6c 100644 --- a/docs/docbook/content-map.xml +++ b/docs/docbook/content-map.xml @@ -112,6 +112,9 @@ + + + @@ -163,4 +166,4 @@ - + \ No newline at end of file diff --git a/packages/graphql/src/schema/make-augmented-schema.ts b/packages/graphql/src/schema/make-augmented-schema.ts index 572dcd6639..415f8f96dc 100644 --- a/packages/graphql/src/schema/make-augmented-schema.ts +++ b/packages/graphql/src/schema/make-augmented-schema.ts @@ -46,7 +46,14 @@ import { Integer, isInt } from "neo4j-driver"; import { Node, Exclude } from "../classes"; import getAuth from "./get-auth"; import { PrimitiveField, Auth, CustomEnumField, ConnectionQueryArgs } from "../types"; -import { findResolver, createResolver, deleteResolver, cypherResolver, updateResolver } from "./resolvers"; +import { + findResolver, + createResolver, + deleteResolver, + cypherResolver, + updateResolver, + countResolver, +} from "./resolvers"; import checkNodeImplementsInterfaces from "./check-node-implements-interfaces"; import * as Scalars from "./scalars"; import parseExcludeDirective from "./parse-exclude-directive"; @@ -1153,6 +1160,10 @@ function makeAugmentedSchema( composer.Query.addFields({ [pluralize(camelCase(node.name))]: findResolver({ node }), }); + + composer.Query.addFields({ + [`${pluralize(camelCase(node.name))}Count`]: countResolver({ node }), + }); } if (!node.exclude?.operations.includes("create")) { diff --git a/packages/graphql/src/schema/resolvers/count.test.ts b/packages/graphql/src/schema/resolvers/count.test.ts new file mode 100644 index 0000000000..3761cfe6ef --- /dev/null +++ b/packages/graphql/src/schema/resolvers/count.test.ts @@ -0,0 +1,37 @@ +/* + * 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 { Node } from "../../classes"; +import countResolver from "./count"; + +describe("Count resolver", () => { + test("should return the correct; type, args and resolve", () => { + // @ts-ignore + const node: Node = { + name: "Movie", + }; + + const result = countResolver({ node }); + expect(result.type).toEqual("Int!"); + expect(result.resolve).toBeInstanceOf(Function); + expect(result.args).toMatchObject({ + where: "MovieWhere", + }); + }); +}); diff --git a/packages/graphql/src/schema/resolvers/count.ts b/packages/graphql/src/schema/resolvers/count.ts new file mode 100644 index 0000000000..f199afc90f --- /dev/null +++ b/packages/graphql/src/schema/resolvers/count.ts @@ -0,0 +1,45 @@ +/* + * 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 { execute } from "../../utils"; +import { translateCount } from "../../translate"; +import { Node } from "../../classes"; +import { Context } from "../../types"; + +export default function countResolver({ node }: { node: Node }) { + async function resolve(_root: any, _args: any, _context: unknown) { + const context = _context as Context; + const [cypher, params] = translateCount({ context, node }); + + const result = await execute({ + cypher, + params, + defaultAccessMode: "READ", + context, + raw: true, + }); + + return result.records[0]._fields[0].toNumber(); + } + + return { + type: `Int!`, + resolve, + args: { where: `${node.name}Where` }, + }; +} diff --git a/packages/graphql/src/schema/resolvers/index.ts b/packages/graphql/src/schema/resolvers/index.ts index 5bf65f9c50..b5ffdfed2e 100644 --- a/packages/graphql/src/schema/resolvers/index.ts +++ b/packages/graphql/src/schema/resolvers/index.ts @@ -22,3 +22,4 @@ export { default as findResolver } from "./read"; export { default as updateResolver } from "./update"; export { default as deleteResolver } from "./delete"; export { default as cypherResolver } from "./cypher"; +export { default as countResolver } from "./count"; diff --git a/packages/graphql/src/translate/index.ts b/packages/graphql/src/translate/index.ts index 1eeb556c9d..c12e91f4d8 100644 --- a/packages/graphql/src/translate/index.ts +++ b/packages/graphql/src/translate/index.ts @@ -21,3 +21,4 @@ export { default as translateCreate } from "./translate-create"; export { default as translateRead } from "./translate-read"; export { default as translateUpdate } from "./translate-update"; export { default as translateDelete } from "./translate-delete"; +export { default as translateCount } from "./translate-count"; diff --git a/packages/graphql/src/translate/translate-count.ts b/packages/graphql/src/translate/translate-count.ts new file mode 100644 index 0000000000..813137ad8b --- /dev/null +++ b/packages/graphql/src/translate/translate-count.ts @@ -0,0 +1,83 @@ +/* + * 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 { Node } from "../classes"; +import { AUTH_FORBIDDEN_ERROR } from "../constants"; +import { Context, GraphQLWhereArg } from "../types"; +import createAuthAndParams from "./create-auth-and-params"; +import createWhereAndParams from "./create-where-and-params"; + +function translateCount({ node, context }: { node: Node; context: Context }): [string, any] { + const whereInput = context.resolveTree.args.where as GraphQLWhereArg; + const varName = "this"; + let cypherParams: { [k: string]: any } = {}; + const whereStrs: string[] = []; + const cypherStrs: string[] = []; + + cypherStrs.push(`MATCH (${varName}:${node.name})`); + + if (whereInput) { + const where = createWhereAndParams({ + whereInput, + varName, + node, + context, + recursing: true, + }); + if (where[0]) { + whereStrs.push(where[0]); + cypherParams = { ...cypherParams, ...where[1] }; + } + } + + const whereAuth = createAuthAndParams({ + operation: "READ", + entity: node, + context, + where: { varName, node }, + }); + if (whereAuth[0]) { + whereStrs.push(whereAuth[0]); + cypherParams = { ...cypherParams, ...whereAuth[1] }; + } + + const allowAuth = createAuthAndParams({ + operation: "READ", + entity: node, + context, + allow: { + parentNode: node, + varName, + }, + }); + if (allowAuth[0]) { + cypherParams = { ...cypherParams, ...allowAuth[1] }; + cypherStrs.push(`CALL apoc.util.validate(NOT(${allowAuth[0]}), "${AUTH_FORBIDDEN_ERROR}", [0])`); + } + + if (whereStrs.length) { + cypherStrs.push(`WHERE ${whereStrs.join(" AND ")}`); + } + + cypherStrs.push(`RETURN count(${varName})`); + + return [cypherStrs.filter(Boolean).join("\n"), cypherParams]; +} + +export default translateCount; diff --git a/packages/graphql/tests/integration/count.int.test.ts b/packages/graphql/tests/integration/count.int.test.ts new file mode 100644 index 0000000000..47db1ed5c7 --- /dev/null +++ b/packages/graphql/tests/integration/count.int.test.ts @@ -0,0 +1,279 @@ +/* + * 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 { generate } from "randomstring"; +import neo4j from "./neo4j"; +import { Neo4jGraphQL } from "../../src/classes"; +import pluralize from "pluralize"; +import { IncomingMessage } from "http"; +import { Socket } from "net"; +import jsonwebtoken from "jsonwebtoken"; +import camelCase from "camelcase"; + +describe("count", () => { + let driver: Driver; + + beforeAll(async () => { + driver = await neo4j(); + }); + + afterAll(async () => { + await driver.close(); + }); + + test("should count nodes", async () => { + const session = driver.session(); + + const randomType = `${generate({ + charset: "alphabetic", + readable: true, + })}Movie`; + + const pluralRandomType = pluralize(camelCase(randomType)); + + const typeDefs = ` + type ${randomType} { + id: ID + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + try { + await session.run( + ` + CREATE (:${randomType} {id: randomUUID()}) + CREATE (:${randomType} {id: randomUUID()}) + ` + ); + + const query = ` + { + ${pluralRandomType}Count + } + `; + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver }, + }); + + if (gqlResult.errors) { + console.log(JSON.stringify(gqlResult.errors, null, 2)); + } + + expect(gqlResult.errors).toBeUndefined(); + + expect((gqlResult.data as any)[`${pluralRandomType}Count`]).toEqual(2); + } finally { + await session.close(); + } + }); + + test("should movie nodes with where predicate", async () => { + const session = driver.session(); + + const typeDefs = ` + type Movie { + id: ID + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const id1 = generate({ + charset: "alphabetic", + }); + + const id2 = generate({ + charset: "alphabetic", + }); + + try { + await session.run( + ` + CREATE (:Movie {id: $id1}) + CREATE (:Movie {id: $id2}) + `, + { id1, id2 } + ); + + const query = ` + { + moviesCount(where: { OR: [{id: "${id1}"}, {id: "${id2}"}] }) + } + `; + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver }, + }); + + if (gqlResult.errors) { + console.log(JSON.stringify(gqlResult.errors, null, 2)); + } + + expect(gqlResult.errors).toBeUndefined(); + + expect((gqlResult.data as any).moviesCount).toEqual(2); + } finally { + await session.close(); + } + }); + + test("should add auth where (read) to count query", async () => { + const session = driver.session(); + + const typeDefs = ` + type User { + id: ID + } + + type Post { + id: ID + creator: User @relationship(type: "POSTED", direction: IN) + } + + extend type Post @auth(rules: [{ where: { creator: { id: "$jwt.sub" } } }]) + `; + + const userId = generate({ + charset: "alphabetic", + }); + + const post1 = generate({ + charset: "alphabetic", + }); + + const post2 = generate({ + charset: "alphabetic", + }); + + const secret = "secret"; + + const token = jsonwebtoken.sign( + { + roles: [], + sub: userId, + }, + secret + ); + + const neoSchema = new Neo4jGraphQL({ typeDefs, config: { jwt: { secret } } }); + + try { + await session.run( + ` + CREATE (u:User {id: $userId}) + CREATE (u)-[:POSTED]->(:Post {id: $post1}) + CREATE (u)-[:POSTED]->(:Post {id: $post2}) + CREATE (:Post {id: randomUUID()}) + `, + { userId, post1, post2 } + ); + + const query = ` + { + postsCount + } + `; + + const socket = new Socket({ readable: true }); + const req = new IncomingMessage(socket); + req.headers.authorization = `Bearer ${token}`; + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, req }, + }); + + if (gqlResult.errors) { + console.log(JSON.stringify(gqlResult.errors, null, 2)); + } + + expect(gqlResult.errors).toBeUndefined(); + + expect((gqlResult.data as any).postsCount).toEqual(2); + } finally { + await session.close(); + } + }); + + test("should throw forbidden with invalid allow on auth (read) while counting", async () => { + const session = driver.session(); + + const typeDefs = ` + type User { + id: ID + } + + extend type User @auth(rules: [{ allow: { id: "$jwt.sub" } }]) + `; + + const userId = generate({ + charset: "alphabetic", + }); + + const secret = "secret"; + + const token = jsonwebtoken.sign( + { + roles: [], + sub: "invalid", + }, + secret + ); + + const neoSchema = new Neo4jGraphQL({ typeDefs, config: { jwt: { secret } } }); + + try { + await session.run( + ` + CREATE (u:User {id: $userId}) + `, + { userId } + ); + + const query = ` + { + usersCount + } + `; + + const socket = new Socket({ readable: true }); + const req = new IncomingMessage(socket); + req.headers.authorization = `Bearer ${token}`; + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, req }, + }); + + expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/count.md b/packages/graphql/tests/tck/tck-test-files/cypher/count.md new file mode 100644 index 0000000000..8d36fff1ca --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/count.md @@ -0,0 +1,66 @@ +# Cypher Count + +Tests for queries using count + +Schema: + +```graphql +type Movie { + title: String! +} +``` + +--- + +## Simple Count + +### GraphQL Input + +```graphql +{ + moviesCount +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +RETURN count(this) +``` + +### Expected Cypher Params + +```json +{} +``` + +--- + +## Count with WHERE + +### GraphQL Input + +```graphql +{ + moviesCount(where: { title: "some-title" }) +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +RETURN count(this) +``` + +### Expected Cypher Params + +```json +{ + "this_title": "some-title" +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/schema/arrays.md b/packages/graphql/tests/tck/tck-test-files/schema/arrays.md index 98ff58f5d0..a04c12f609 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/arrays.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/arrays.md @@ -116,6 +116,7 @@ type Mutation { type Query { movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/comments.md b/packages/graphql/tests/tck/tck-test-files/schema/comments.md index eeb915ecdf..8388b6ec65 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/comments.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/comments.md @@ -193,6 +193,7 @@ type Mutation { type Query { movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/connections/enums.md b/packages/graphql/tests/tck/tck-test-files/schema/connections/enums.md index 46c2d39521..512f382c74 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/connections/enums.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/connections/enums.md @@ -397,6 +397,8 @@ type PageInfo { type Query { actors(where: ActorWhere, options: ActorOptions): [Actor!]! movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! + actorsCount(where: ActorWhere): Int! } enum SortDirection { diff --git a/packages/graphql/tests/tck/tck-test-files/schema/connections/sort.md b/packages/graphql/tests/tck/tck-test-files/schema/connections/sort.md index 76ed60ccda..89ebb8cfa1 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/connections/sort.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/connections/sort.md @@ -304,8 +304,10 @@ type PageInfo { } type Query { - node1s(where: Node1Where, options: Node1Options): [Node1!]! - node2s(where: Node2Where, options: Node2Options): [Node2!]! + node1s(options: Node1Options, where: Node1Where): [Node1!]! + node1sCount(where: Node1Where): Int! + node2s(options: Node2Options, where: Node2Where): [Node2!]! + node2sCount(where: Node2Where): Int! } enum SortDirection { diff --git a/packages/graphql/tests/tck/tck-test-files/schema/connections/unions.md b/packages/graphql/tests/tck/tck-test-files/schema/connections/unions.md index 9c1ddb04a3..31bb246990 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/connections/unions.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/connections/unions.md @@ -628,6 +628,9 @@ type Query { authors(where: AuthorWhere, options: AuthorOptions): [Author!]! books(where: BookWhere, options: BookOptions): [Book!]! journals(where: JournalWhere, options: JournalOptions): [Journal!]! + authorsCount(where: AuthorWhere): Int! + booksCount(where: BookWhere): Int! + journalsCount(where: JournalWhere): Int! } input QueryOptions { diff --git a/packages/graphql/tests/tck/tck-test-files/schema/custom-mutations.md b/packages/graphql/tests/tck/tck-test-files/schema/custom-mutations.md index d7c618925f..093ba42989 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/custom-mutations.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/custom-mutations.md @@ -119,6 +119,7 @@ type Mutation { type Query { movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! testQuery(input: ExampleInput): String testCypherQuery(input: ExampleInput): String } diff --git a/packages/graphql/tests/tck/tck-test-files/schema/directive-preserve.md b/packages/graphql/tests/tck/tck-test-files/schema/directive-preserve.md index 27d3520767..43c4617ad1 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/directive-preserve.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/directive-preserve.md @@ -123,6 +123,7 @@ type Mutation { type Query { movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/directives/access-directives.md b/packages/graphql/tests/tck/tck-test-files/schema/directives/access-directives.md index 58fedc8769..93ce6d8363 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/directives/access-directives.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/directives/access-directives.md @@ -123,6 +123,7 @@ type Mutation { type Query { users(where: UserWhere, options: UserOptions): [User!]! + usersCount(where: UserWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/directives/autogenerate.md b/packages/graphql/tests/tck/tck-test-files/schema/directives/autogenerate.md index 376809a41d..6f062de964 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/directives/autogenerate.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/directives/autogenerate.md @@ -108,6 +108,7 @@ type Mutation { type Query { movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/directives/cypher.md b/packages/graphql/tests/tck/tck-test-files/schema/directives/cypher.md index c69e87a03c..b3a5f257d3 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/directives/cypher.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/directives/cypher.md @@ -166,6 +166,8 @@ type Mutation { type Query { actors(where: ActorWhere, options: ActorOptions): [Actor!]! movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! + actorsCount(where: ActorWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/directives/default.md b/packages/graphql/tests/tck/tck-test-files/schema/directives/default.md index 4746f9a974..c9a994f29f 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/directives/default.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/directives/default.md @@ -161,6 +161,7 @@ type Mutation { type Query { users(where: UserWhere, options: UserOptions): [User!]! + usersCount(where: UserWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/directives/exclude.md b/packages/graphql/tests/tck/tck-test-files/schema/directives/exclude.md index 8a24edcb2f..70f209810a 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/directives/exclude.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/directives/exclude.md @@ -140,6 +140,7 @@ type Mutation { type Query { movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` @@ -227,6 +228,7 @@ type Mutation { type Query { actors(where: ActorWhere, options: ActorOptions): [Actor!]! + actorsCount(where: ActorWhere): Int! } ``` @@ -327,6 +329,7 @@ type Mutation { type Query { movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` @@ -436,6 +439,7 @@ type Mutation { type Query { customActorQuery: Actor movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` @@ -668,6 +672,7 @@ type PageInfo { type Query { movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } enum SortDirection { @@ -780,6 +785,7 @@ type Mutation { type Query { actors(where: ActorWhere, options: ActorOptions): [Actor!]! + actorsCount(where: ActorWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/directives/ignore.md b/packages/graphql/tests/tck/tck-test-files/schema/directives/ignore.md index 1b5810d9a9..40301ff544 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/directives/ignore.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/directives/ignore.md @@ -127,6 +127,7 @@ type Mutation { type Query { users(where: UserWhere, options: UserOptions): [User!]! + usersCount(where: UserWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/directives/private.md b/packages/graphql/tests/tck/tck-test-files/schema/directives/private.md index a6ae4d245a..a289d1b315 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/directives/private.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/directives/private.md @@ -96,6 +96,7 @@ type Mutation { type Query { users(where: UserWhere, options: UserOptions): [User!]! + usersCount(where: UserWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/directives/timestamps.md b/packages/graphql/tests/tck/tck-test-files/schema/directives/timestamps.md index 69fbd74af6..06a9ea7f53 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/directives/timestamps.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/directives/timestamps.md @@ -122,6 +122,7 @@ type Mutation { type Query { movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/enum.md b/packages/graphql/tests/tck/tck-test-files/schema/enum.md index 97288fbbb0..a1ee731ce4 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/enum.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/enum.md @@ -101,6 +101,7 @@ type Mutation { type Query { movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/extend.md b/packages/graphql/tests/tck/tck-test-files/schema/extend.md index d422d3010b..e6ea22c3bd 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/extend.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/extend.md @@ -113,6 +113,7 @@ type Mutation { type Query { movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/inputs.md b/packages/graphql/tests/tck/tck-test-files/schema/inputs.md index 7002e39aab..04cb54ce90 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/inputs.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/inputs.md @@ -107,6 +107,7 @@ type Mutation { type Query { movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! name(input: NodeInput): String } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/interfaces.md b/packages/graphql/tests/tck/tck-test-files/schema/interfaces.md index 1dd5505705..9babdcab16 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/interfaces.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/interfaces.md @@ -216,6 +216,7 @@ type PageInfo { type Query { movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } enum SortDirection { diff --git a/packages/graphql/tests/tck/tck-test-files/schema/issues/#200.md b/packages/graphql/tests/tck/tck-test-files/schema/issues/#200.md index 21dad7a851..4ecdd14955 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/issues/#200.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/issues/#200.md @@ -118,6 +118,7 @@ type Mutation { type Query { categories(where: CategoryWhere, options: CategoryOptions): [Category!]! + categoriesCount(where: CategoryWhere): Int! } enum SortDirection { diff --git a/packages/graphql/tests/tck/tck-test-files/schema/issues/162.md b/packages/graphql/tests/tck/tck-test-files/schema/issues/162.md index d7c2215e40..55f11c4dff 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/issues/162.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/issues/162.md @@ -94,15 +94,18 @@ type PageInfo { } type Query { - tigers(where: TigerWhere, options: TigerOptions): [Tiger!]! - tigerJawLevel2s( - where: TigerJawLevel2Where - options: TigerJawLevel2Options - ): [TigerJawLevel2!]! tigerJawLevel2Part1s( - where: TigerJawLevel2Part1Where options: TigerJawLevel2Part1Options + where: TigerJawLevel2Part1Where ): [TigerJawLevel2Part1!]! + tigerJawLevel2Part1sCount(where: TigerJawLevel2Part1Where): Int! + tigerJawLevel2s( + options: TigerJawLevel2Options + where: TigerJawLevel2Where + ): [TigerJawLevel2!]! + tigerJawLevel2sCount(where: TigerJawLevel2Where): Int! + tigers(options: TigerOptions, where: TigerWhere): [Tiger!]! + tigersCount(where: TigerWhere): Int! } enum SortDirection { diff --git a/packages/graphql/tests/tck/tck-test-files/schema/null.md b/packages/graphql/tests/tck/tck-test-files/schema/null.md index f986f815d0..df12f45c3e 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/null.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/null.md @@ -240,6 +240,7 @@ input PointInput { type Query { movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } type UpdateMoviesMutationResponse { diff --git a/packages/graphql/tests/tck/tck-test-files/schema/relationship-properties.md b/packages/graphql/tests/tck/tck-test-files/schema/relationship-properties.md index 37b1a06836..d472cf54bc 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/relationship-properties.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/relationship-properties.md @@ -391,6 +391,8 @@ type PageInfo { type Query { actors(where: ActorWhere, options: ActorOptions): [Actor!]! movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! + actorsCount(where: ActorWhere): Int! } enum SortDirection { diff --git a/packages/graphql/tests/tck/tck-test-files/schema/relationship.md b/packages/graphql/tests/tck/tck-test-files/schema/relationship.md index 220c202726..e46b689229 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/relationship.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/relationship.md @@ -242,6 +242,8 @@ type PageInfo { type Query { actors(where: ActorWhere, options: ActorOptions): [Actor!]! movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! + actorsCount(where: ActorWhere): Int! } enum SortDirection { @@ -625,6 +627,8 @@ type PageInfo { type Query { actors(where: ActorWhere, options: ActorOptions): [Actor!]! movies(where: MovieWhere, options: MovieOptions): [Movie!]! + actorsCount(where: ActorWhere): Int! + moviesCount(where: MovieWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/scalar.md b/packages/graphql/tests/tck/tck-test-files/schema/scalar.md index 712cc7826d..348a7b7a52 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/scalar.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/scalar.md @@ -105,6 +105,7 @@ type Mutation { type Query { movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/simple.md b/packages/graphql/tests/tck/tck-test-files/schema/simple.md index e68f0bc667..d84a397159 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/simple.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/simple.md @@ -128,6 +128,7 @@ type Mutation { type Query { movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/types/bigint.md b/packages/graphql/tests/tck/tck-test-files/schema/types/bigint.md index 1bf6fbac67..5bd4ef4f54 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/types/bigint.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/types/bigint.md @@ -113,6 +113,7 @@ type Mutation { type Query { files(where: FileWhere, options: FileOptions): [File!]! + filesCount(where: FileWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/types/date.md b/packages/graphql/tests/tck/tck-test-files/schema/types/date.md index 914b8b637b..981ead6696 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/types/date.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/types/date.md @@ -113,6 +113,7 @@ type Mutation { type Query { movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/types/datetime.md b/packages/graphql/tests/tck/tck-test-files/schema/types/datetime.md index 6b97d13bf2..466705ba80 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/types/datetime.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/types/datetime.md @@ -113,6 +113,7 @@ type Mutation { type Query { movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/types/points.md b/packages/graphql/tests/tck/tck-test-files/schema/types/points.md index 20f2761084..796daf3a35 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/types/points.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/types/points.md @@ -116,6 +116,7 @@ type Mutation { type Query { movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` @@ -232,6 +233,7 @@ type Mutation { type Query { machines(where: MachineWhere, options: MachineOptions): [Machine!]! + machinesCount(where: MachineWhere): Int! } ``` @@ -314,6 +316,7 @@ type Mutation { type Query { movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` @@ -398,6 +401,7 @@ type Mutation { type Query { machines(where: MachineWhere, options: MachineOptions): [Machine!]! + machinesCount(where: MachineWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/unions.md b/packages/graphql/tests/tck/tck-test-files/schema/unions.md index 7bebae7e62..c657e6c84a 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/unions.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/unions.md @@ -332,6 +332,8 @@ type PageInfo { type Query { genres(where: GenreWhere, options: GenreOptions): [Genre!]! movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! + genresCount(where: GenreWhere): Int! } input QueryOptions { diff --git a/packages/graphql/tests/tck/tck.test.ts b/packages/graphql/tests/tck/tck.test.ts index 6e4ca4980f..4a4b47e62a 100644 --- a/packages/graphql/tests/tck/tck.test.ts +++ b/packages/graphql/tests/tck/tck.test.ts @@ -36,7 +36,7 @@ import { IncomingMessage } from "http"; import { Socket } from "net"; // import { parseResolveInfo, ResolveTree } from "graphql-parse-resolve-info"; import { SchemaDirectiveVisitor, printSchemaWithDirectives } from "@graphql-tools/utils"; -import { translateCreate, translateDelete, translateRead, translateUpdate } from "../../src/translate"; +import { translateCount, translateCreate, translateDelete, translateRead, translateUpdate } from "../../src/translate"; import { Context } from "../../src/types"; import { Neo4jGraphQL } from "../../src"; import { @@ -161,6 +161,32 @@ describe("TCK Generated tests", () => { return []; }, + [`${pluralize(camelCase(def.name.value))}Count`]: ( + _root: any, + _params: any, + context: Context, + info: GraphQLResolveInfo + ) => { + const resolveTree = getNeo4jResolveTree(info); + + context.neoSchema = neoSchema; + context.resolveTree = resolveTree; + + const mergedContext = { ...context, ...defaultContext }; + + const [cQuery, cQueryParams] = translateCount({ + context: mergedContext, + node: neoSchema.nodes.find((x) => x.name === def.name.value) as Node, + }); + + compare( + { expected: cQuery, recived: cypherQuery }, + { expected: cQueryParams, recived: cypherParams }, + mergedContext + ); + + return 1; + }, }; }, {}); diff --git a/packages/ogm/src/classes/Model.ts b/packages/ogm/src/classes/Model.ts index 7a3ef7849c..7accc80142 100644 --- a/packages/ogm/src/classes/Model.ts +++ b/packages/ogm/src/classes/Model.ts @@ -108,6 +108,32 @@ class Model { return (result.data as any)[this.camelCaseName] as T; } + async count({ + where, + }: { + where?: GraphQLWhereArg; + } = {}): Promise { + const argDefinitions = [`${where ? `($where: ${this.name}Where)` : ""}`]; + + const argsApply = [`${where ? `(where: $where)` : ""}`]; + + const query = ` + query ${argDefinitions.join(" ")}{ + ${this.camelCaseName}Count${argsApply.join(" ")} + } + `; + + const variableValues = { where }; + + const result = await graphql(this.neoSchema.schema, query, null, {}, variableValues); + + if (result.errors?.length) { + throw new Error(result.errors[0].message); + } + + return (result.data as any)[`${this.camelCaseName}Count`] as number; + } + async create({ input, selectionSet, diff --git a/packages/ogm/tests/integration/ogm.int.test.ts b/packages/ogm/tests/integration/ogm.int.test.ts index 4fe75d7ae9..829d93e1c1 100644 --- a/packages/ogm/tests/integration/ogm.int.test.ts +++ b/packages/ogm/tests/integration/ogm.int.test.ts @@ -197,6 +197,76 @@ describe("OGM", () => { }); }); + describe("count", () => { + test("should count nodes", async () => { + const session = driver.session(); + + const randomType = `${generate({ + charset: "alphabetic", + readable: true, + })}Movie`; + + const typeDefs = ` + type ${randomType} { + id: ID + } + `; + + const ogm = new OGM({ typeDefs, driver }); + + try { + await session.run( + ` + CREATE (:${randomType} {id: randomUUID()}) + CREATE (:${randomType} {id: randomUUID()}) + ` + ); + + const model = ogm.model(randomType); + + const count = await model?.count(); + + expect(count).toEqual(2); + } finally { + await session.close(); + } + }); + + test("should count movies with a where predicate", async () => { + const session = driver.session(); + + const typeDefs = ` + type Movie { + id: ID + } + `; + + const ogm = new OGM({ typeDefs, driver }); + + const id = generate({ + charset: "alphabetic", + }); + + try { + await ogm.checkNeo4jCompat(); + + await session.run(` + CREATE (:Movie {id: "${id}"}) + CREATE (:Movie {id: randomUUID()}) + CREATE (:Movie {id: randomUUID()}) + `); + + const Movie = ogm.model("Movie"); + + const count = await Movie?.count({ where: { id } }); + + expect(count).toEqual(1); + } finally { + await session.close(); + } + }); + }); + describe("create", () => { test("should create a single node", async () => { const session = driver.session();