Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/chatty-cobras-guess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@neo4j/graphql": patch
---

Optimize connection queries without `totalCount` or `pageInfo` such as:

```graphql
query {
moviesConnection(first: 20, sort: [{ title: ASC }]) {
edges {
node {
title
}
}
}
}
```

Will no longer calculate `totalCount` in the generated Cypher
13 changes: 13 additions & 0 deletions .changeset/red-years-shine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@neo4j/graphql": patch
---

Improved performance for Connection queries for cases when only `totalCount` is requested.

```graphql
query {
moviesConnection(where: { title: { eq: "Forrest Gump" } }) {
totalCount
}
}
```
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export class ConnectionReadOperation extends Operation {

protected selection: EntitySelection;

private hasTotalCount = false;

constructor({
relationship,
target,
Expand All @@ -69,6 +71,10 @@ export class ConnectionReadOperation extends Operation {
this.selection = selection;
}

public setHasTotalCount(value: boolean): void {
this.hasTotalCount = value;
}

public setNodeFields(fields: Field[]) {
this.nodeFields = fields;
}
Expand Down Expand Up @@ -129,13 +135,16 @@ export class ConnectionReadOperation extends Operation {
nodeAndRelationshipMap.set("relationship", nestedContext.relationship);
}

const extraColumnsVariables = extraColumns.map((c) => c[1]);
const withClause = new Cypher.With();
if (this.shouldProjectEdges()) {
withClause.addColumns([Cypher.collect(nodeAndRelationshipMap), edgesVar]);
}
withClause.addColumns(...extraColumns);

return new Cypher.With([Cypher.collect(nodeAndRelationshipMap), edgesVar], ...extraColumns).with(
edgesVar,
[Cypher.size(edgesVar), totalCount],
...extraColumnsVariables
);
if (this.hasTotalCount) {
withClause.addColumns([Cypher.count(nestedContext.target), totalCount]);
}
return withClause;
}

public transpile(context: QueryASTContext): OperationTranspileResult {
Expand Down Expand Up @@ -198,11 +207,28 @@ export class ConnectionReadOperation extends Operation {
};
}

const unwindAndProjectionSubquery = this.createUnwindAndProjectionSubquery(
nestedContext,
edgesVar,
edgesProjectionVar
);
const hasProjectionFields = this.shouldProjectEdges();
let unwindAndProjectionSubquery: Cypher.Call | undefined;
if (hasProjectionFields) {
const edgeVar = new Cypher.NamedVariable("edge");
const { prePaginationSubqueries, postPaginationSubqueries } = this.getPreAndPostSubqueries(nestedContext);

const unwindClause = this.getUnwindClause(nestedContext, edgeVar, edgesVar);

const edgeProjectionMap = this.createProjectionMapForEdge(nestedContext);
const paginationWith = this.generateSortAndPaginationClause(nestedContext);

unwindAndProjectionSubquery = new Cypher.Call(
Cypher.utils.concat(
unwindClause,
...prePaginationSubqueries,
paginationWith,
...postPaginationSubqueries,
new Cypher.Return([Cypher.collect(edgeProjectionMap), edgesProjectionVar])
),
[edgesVar]
);
}

let withWhere: Cypher.With | undefined;

Expand All @@ -218,14 +244,21 @@ export class ConnectionReadOperation extends Operation {
totalCount
);

const returnClause = new Cypher.Return([
new Cypher.Map({
edges: edgesProjectionVar,
totalCount: totalCount,
...aggregationProjection,
}),
context.returnVariable,
]);
const projectionMap = new Cypher.Map();

if (hasProjectionFields) {
projectionMap.set("edges", edgesProjectionVar);
}

if (this.hasTotalCount) {
projectionMap.set("totalCount", totalCount);
}

projectionMap.set({
...aggregationProjection,
});

const returnClause = new Cypher.Return([projectionMap, context.returnVariable]);
const validations = this.getValidations(nestedContext);
let connectionClauses: Cypher.Clause = Cypher.utils.concat(
...extraMatches,
Expand All @@ -238,10 +271,12 @@ export class ConnectionReadOperation extends Operation {
);

if (aggregationSubqueries.length > 0) {
connectionClauses = new Cypher.Call( // NOTE: this call is only needed when aggregate is used
Cypher.utils.concat(connectionClauses, new Cypher.Return(edgesProjectionVar, totalCount)),
"*"
);
const returnClause = new Cypher.Return(edgesProjectionVar);
if (this.hasTotalCount) {
returnClause.addColumns(totalCount);
}

connectionClauses = new Cypher.Call(Cypher.utils.concat(connectionClauses, returnClause), "*"); // NOTE: this call is only needed when aggregate is used
}

return {
Expand All @@ -250,6 +285,13 @@ export class ConnectionReadOperation extends Operation {
};
}

/** Defines if the query should project edges */
protected shouldProjectEdges(): boolean {
const hasPagination = Boolean(this.pagination);
const hasFields = this.nodeFields.length + this.edgeFields.length > 0;
return hasPagination || hasFields;
}

protected getAuthFilterSubqueries(context: QueryASTContext): Cypher.Clause[] {
return this.authFilters.flatMap((f) => f.getSubqueries(context));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ export class FulltextOperation extends ConnectionReadOperation {
return filterTruthy([...super.getChildren(), this.scoreField]);
}

protected shouldProjectEdges(): boolean {
return super.shouldProjectEdges() || Boolean(this.scoreField);
}

protected createProjectionMapForEdge(context: QueryASTContext<Cypher.Node>): Cypher.Map {
const edgeProjectionMap = new Cypher.Map();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,11 @@ export class ConnectionFactory {
totalCountEdgeField = totalCount;
pageInfoEdgeField = pageInfo;
}
const operation = new ConnectionReadOperation({ relationship, target, selection });
const operation = new ConnectionReadOperation({
relationship,
target,
selection,
});

if (Object.keys(resolveTreeEdgeFields).length === 0 && !totalCountEdgeField && !pageInfoEdgeField) {
operation.skipConnection = true;
Expand Down Expand Up @@ -452,6 +456,15 @@ export class ConnectionFactory {

const nodeFieldsRaw = findFieldsByNameInFieldsByTypeNameField(resolveTreeEdgeFields, "node");
const propertiesFieldsRaw = findFieldsByNameInFieldsByTypeNameField(resolveTreeEdgeFields, "properties");

const { totalCount, pageInfo } = this.parseConnectionFields({
entityOrRel,
target,
resolveTree,
});

operation.setHasTotalCount(Boolean(totalCount || pageInfo));

this.hydrateConnectionOperationsASTWithSort({
entityOrRel,
resolveTree,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
* limitations under the License.
*/

import { GraphQLError } from "graphql";
import { type Driver } from "neo4j-driver";
import { generate } from "randomstring";
import type { Neo4jGraphQL } from "../../../../src/classes";
Expand All @@ -26,7 +27,6 @@ import { createBearerToken } from "../../../utils/create-bearer-token";
import type { UniqueType } from "../../../utils/graphql-types";
import { isMultiDbUnsupportedError } from "../../../utils/is-multi-db-unsupported-error";
import { TestHelper } from "../../../utils/tests-helper";
import { GraphQLError } from "graphql";

function generatedTypeDefs(personType: UniqueType, movieType: UniqueType): string {
return `
Expand Down Expand Up @@ -1287,8 +1287,10 @@ describe("@fulltext directive", () => {

expect(gqlResult.errors).toHaveLength(1);
expect(gqlResult.errors).toIncludeSameMembers([
new GraphQLError(`Field "${queryType}" argument "first" of type "Int!" is required, but it was not provided.`),
]);
new GraphQLError(
`Field "${queryType}" argument "first" of type "Int!" is required, but it was not provided.`
),
]);
});

test("Limit not provided on nested field", async () => {
Expand Down Expand Up @@ -1317,9 +1319,10 @@ describe("@fulltext directive", () => {

expect(gqlResult.errors).toHaveLength(1);
expect(gqlResult.errors).toIncludeSameMembers([
new GraphQLError(`Field "actedInMovies" argument "limit" of type "Int!" is required, but it was not provided.`),
]);

new GraphQLError(
`Field "actedInMovies" argument "limit" of type "Int!" is required, but it was not provided.`
),
]);
});
});
describe("Query tests with auth", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,9 @@ query NestedConnection {
}
}
}

query totalCount {
moviesConnection {
totalCount
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,13 +103,12 @@ describe("Authorization with aggregation filter rule", () => {
WITH *
WHERE ($isAuthenticated = true AND var3 = true)
WITH collect({ node: this0 }) AS edges
WITH edges, size(edges) AS totalCount
CALL (edges) {
UNWIND edges AS edge
WITH edge.node AS this0
RETURN collect({ node: { content: this0.content, __resolveType: \\"Post\\" } }) AS var4
}
RETURN { edges: var4, totalCount: totalCount } AS this"
RETURN { edges: var4 } AS this"
`);

expect(formatParams(result.params)).toMatchInlineSnapshot(`
Expand Down
6 changes: 2 additions & 4 deletions packages/graphql/tests/tck/array-methods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,13 +514,12 @@ describe("Arrays Methods", () => {
CALL (this) {
MATCH (this)-[update_this3:ACTED_IN]->(update_this4:Movie)
WITH collect({ node: update_this4, relationship: update_this3 }) AS edges
WITH edges, size(edges) AS totalCount
CALL (edges) {
UNWIND edges AS edge
WITH edge.node AS update_this4, edge.relationship AS update_this3
RETURN collect({ properties: { pay: update_this3.pay, __resolveType: \\"ActedIn\\" }, node: { __id: id(update_this4), __resolveType: \\"Movie\\" } }) AS update_var5
}
RETURN { edges: update_var5, totalCount: totalCount } AS update_var6
RETURN { edges: update_var5 } AS update_var6
}
RETURN collect(DISTINCT this { .name, actedIn: update_var2, actedInConnection: update_var6 }) AS data"
`);
Expand Down Expand Up @@ -615,13 +614,12 @@ describe("Arrays Methods", () => {
CALL (this) {
MATCH (this)-[update_this3:ACTED_IN]->(update_this4:Movie)
WITH collect({ node: update_this4, relationship: update_this3 }) AS edges
WITH edges, size(edges) AS totalCount
CALL (edges) {
UNWIND edges AS edge
WITH edge.node AS update_this4, edge.relationship AS update_this3
RETURN collect({ properties: { pay: update_this3.pay, __resolveType: \\"ActedIn\\" }, node: { __id: id(update_this4), __resolveType: \\"Movie\\" } }) AS update_var5
}
RETURN { edges: update_var5, totalCount: totalCount } AS update_var6
RETURN { edges: update_var5 } AS update_var6
}
RETURN collect(DISTINCT this { .name, actedIn: update_var2, actedInConnection: update_var6 }) AS data"
`);
Expand Down
18 changes: 5 additions & 13 deletions packages/graphql/tests/tck/connections/alias.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,16 +64,10 @@ describe("Connections Alias", () => {
MATCH (this:Movie)
CALL (this) {
MATCH (this)<-[this0:ACTED_IN]-(this1:Actor)
WITH collect({ node: this1, relationship: this0 }) AS edges
WITH edges, size(edges) AS totalCount
CALL (edges) {
UNWIND edges AS edge
WITH edge.node AS this1, edge.relationship AS this0
RETURN collect({ node: { __id: id(this1), __resolveType: \\"Actor\\" } }) AS var2
}
RETURN { edges: var2, totalCount: totalCount } AS var3
WITH count(this1) AS totalCount
RETURN { totalCount: totalCount } AS var2
}
RETURN this { actors: var3 } AS this"
RETURN this { actors: var2 } AS this"
`);

expect(formatParams(result.params)).toMatchInlineSnapshot(`"{}"`);
Expand Down Expand Up @@ -118,25 +112,23 @@ describe("Connections Alias", () => {
MATCH (this)<-[this0:ACTED_IN]-(this1:Actor)
WHERE this1.name = $param1
WITH collect({ node: this1, relationship: this0 }) AS edges
WITH edges, size(edges) AS totalCount
CALL (edges) {
UNWIND edges AS edge
WITH edge.node AS this1, edge.relationship AS this0
RETURN collect({ properties: { screenTime: this0.screenTime, __resolveType: \\"ActedIn\\" }, node: { name: this1.name, __resolveType: \\"Actor\\" } }) AS var2
}
RETURN { edges: var2, totalCount: totalCount } AS var3
RETURN { edges: var2 } AS var3
}
CALL (this) {
MATCH (this)<-[this4:ACTED_IN]-(this5:Actor)
WHERE this5.name = $param2
WITH collect({ node: this5, relationship: this4 }) AS edges
WITH edges, size(edges) AS totalCount
CALL (edges) {
UNWIND edges AS edge
WITH edge.node AS this5, edge.relationship AS this4
RETURN collect({ properties: { screenTime: this4.screenTime, __resolveType: \\"ActedIn\\" }, node: { name: this5.name, __resolveType: \\"Actor\\" } }) AS var6
}
RETURN { edges: var6, totalCount: totalCount } AS var7
RETURN { edges: var6 } AS var7
}
RETURN this { .title, hanks: var3, jenny: var7 } AS this"
`);
Expand Down
Loading
Loading