Skip to content

Commit 70f60a2

Browse files
authored
Merge pull request #6554 from neo4j/6115-total-count-only-connection
6115 total count only connection
2 parents 249848d + 8726ceb commit 70f60a2

File tree

82 files changed

+405
-482
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

82 files changed

+405
-482
lines changed

.changeset/chatty-cobras-guess.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@neo4j/graphql": patch
3+
---
4+
5+
Optimize connection queries without `totalCount` or `pageInfo` such as:
6+
7+
```graphql
8+
query {
9+
moviesConnection(first: 20, sort: [{ title: ASC }]) {
10+
edges {
11+
node {
12+
title
13+
}
14+
}
15+
}
16+
}
17+
```
18+
19+
Will no longer calculate `totalCount` in the generated Cypher

.changeset/red-years-shine.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@neo4j/graphql": patch
3+
---
4+
5+
Improved performance for Connection queries for cases when only `totalCount` is requested.
6+
7+
```graphql
8+
query {
9+
moviesConnection(where: { title: { eq: "Forrest Gump" } }) {
10+
totalCount
11+
}
12+
}
13+
```

packages/graphql/src/translate/queryAST/ast/operations/ConnectionReadOperation.ts

Lines changed: 65 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ export class ConnectionReadOperation extends Operation {
5454

5555
protected selection: EntitySelection;
5656

57+
private hasTotalCount = false;
58+
5759
constructor({
5860
relationship,
5961
target,
@@ -69,6 +71,10 @@ export class ConnectionReadOperation extends Operation {
6971
this.selection = selection;
7072
}
7173

74+
public setHasTotalCount(value: boolean): void {
75+
this.hasTotalCount = value;
76+
}
77+
7278
public setNodeFields(fields: Field[]) {
7379
this.nodeFields = fields;
7480
}
@@ -129,13 +135,16 @@ export class ConnectionReadOperation extends Operation {
129135
nodeAndRelationshipMap.set("relationship", nestedContext.relationship);
130136
}
131137

132-
const extraColumnsVariables = extraColumns.map((c) => c[1]);
138+
const withClause = new Cypher.With();
139+
if (this.shouldProjectEdges()) {
140+
withClause.addColumns([Cypher.collect(nodeAndRelationshipMap), edgesVar]);
141+
}
142+
withClause.addColumns(...extraColumns);
133143

134-
return new Cypher.With([Cypher.collect(nodeAndRelationshipMap), edgesVar], ...extraColumns).with(
135-
edgesVar,
136-
[Cypher.size(edgesVar), totalCount],
137-
...extraColumnsVariables
138-
);
144+
if (this.hasTotalCount) {
145+
withClause.addColumns([Cypher.count(nestedContext.target), totalCount]);
146+
}
147+
return withClause;
139148
}
140149

141150
public transpile(context: QueryASTContext): OperationTranspileResult {
@@ -198,11 +207,28 @@ export class ConnectionReadOperation extends Operation {
198207
};
199208
}
200209

201-
const unwindAndProjectionSubquery = this.createUnwindAndProjectionSubquery(
202-
nestedContext,
203-
edgesVar,
204-
edgesProjectionVar
205-
);
210+
const hasProjectionFields = this.shouldProjectEdges();
211+
let unwindAndProjectionSubquery: Cypher.Call | undefined;
212+
if (hasProjectionFields) {
213+
const edgeVar = new Cypher.NamedVariable("edge");
214+
const { prePaginationSubqueries, postPaginationSubqueries } = this.getPreAndPostSubqueries(nestedContext);
215+
216+
const unwindClause = this.getUnwindClause(nestedContext, edgeVar, edgesVar);
217+
218+
const edgeProjectionMap = this.createProjectionMapForEdge(nestedContext);
219+
const paginationWith = this.generateSortAndPaginationClause(nestedContext);
220+
221+
unwindAndProjectionSubquery = new Cypher.Call(
222+
Cypher.utils.concat(
223+
unwindClause,
224+
...prePaginationSubqueries,
225+
paginationWith,
226+
...postPaginationSubqueries,
227+
new Cypher.Return([Cypher.collect(edgeProjectionMap), edgesProjectionVar])
228+
),
229+
[edgesVar]
230+
);
231+
}
206232

207233
let withWhere: Cypher.With | undefined;
208234

@@ -218,14 +244,21 @@ export class ConnectionReadOperation extends Operation {
218244
totalCount
219245
);
220246

221-
const returnClause = new Cypher.Return([
222-
new Cypher.Map({
223-
edges: edgesProjectionVar,
224-
totalCount: totalCount,
225-
...aggregationProjection,
226-
}),
227-
context.returnVariable,
228-
]);
247+
const projectionMap = new Cypher.Map();
248+
249+
if (hasProjectionFields) {
250+
projectionMap.set("edges", edgesProjectionVar);
251+
}
252+
253+
if (this.hasTotalCount) {
254+
projectionMap.set("totalCount", totalCount);
255+
}
256+
257+
projectionMap.set({
258+
...aggregationProjection,
259+
});
260+
261+
const returnClause = new Cypher.Return([projectionMap, context.returnVariable]);
229262
const validations = this.getValidations(nestedContext);
230263
let connectionClauses: Cypher.Clause = Cypher.utils.concat(
231264
...extraMatches,
@@ -238,10 +271,12 @@ export class ConnectionReadOperation extends Operation {
238271
);
239272

240273
if (aggregationSubqueries.length > 0) {
241-
connectionClauses = new Cypher.Call( // NOTE: this call is only needed when aggregate is used
242-
Cypher.utils.concat(connectionClauses, new Cypher.Return(edgesProjectionVar, totalCount)),
243-
"*"
244-
);
274+
const returnClause = new Cypher.Return(edgesProjectionVar);
275+
if (this.hasTotalCount) {
276+
returnClause.addColumns(totalCount);
277+
}
278+
279+
connectionClauses = new Cypher.Call(Cypher.utils.concat(connectionClauses, returnClause), "*"); // NOTE: this call is only needed when aggregate is used
245280
}
246281

247282
return {
@@ -250,6 +285,13 @@ export class ConnectionReadOperation extends Operation {
250285
};
251286
}
252287

288+
/** Defines if the query should project edges */
289+
protected shouldProjectEdges(): boolean {
290+
const hasPagination = Boolean(this.pagination);
291+
const hasFields = this.nodeFields.length + this.edgeFields.length > 0;
292+
return hasPagination || hasFields;
293+
}
294+
253295
protected getAuthFilterSubqueries(context: QueryASTContext): Cypher.Clause[] {
254296
return this.authFilters.flatMap((f) => f.getSubqueries(context));
255297
}

packages/graphql/src/translate/queryAST/ast/operations/FulltextOperation.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ export class FulltextOperation extends ConnectionReadOperation {
6262
return filterTruthy([...super.getChildren(), this.scoreField]);
6363
}
6464

65+
protected shouldProjectEdges(): boolean {
66+
return super.shouldProjectEdges() || Boolean(this.scoreField);
67+
}
68+
6569
protected createProjectionMapForEdge(context: QueryASTContext<Cypher.Node>): Cypher.Map {
6670
const edgeProjectionMap = new Cypher.Map();
6771

packages/graphql/src/translate/queryAST/factory/Operations/ConnectionFactory.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,11 @@ export class ConnectionFactory {
219219
totalCountEdgeField = totalCount;
220220
pageInfoEdgeField = pageInfo;
221221
}
222-
const operation = new ConnectionReadOperation({ relationship, target, selection });
222+
const operation = new ConnectionReadOperation({
223+
relationship,
224+
target,
225+
selection,
226+
});
223227

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

453457
const nodeFieldsRaw = findFieldsByNameInFieldsByTypeNameField(resolveTreeEdgeFields, "node");
454458
const propertiesFieldsRaw = findFieldsByNameInFieldsByTypeNameField(resolveTreeEdgeFields, "properties");
459+
460+
const { totalCount, pageInfo } = this.parseConnectionFields({
461+
entityOrRel,
462+
target,
463+
resolveTree,
464+
});
465+
466+
operation.setHasTotalCount(Boolean(totalCount || pageInfo));
467+
455468
this.hydrateConnectionOperationsASTWithSort({
456469
entityOrRel,
457470
resolveTree,

packages/graphql/tests/integration/directives/fulltext/fulltext-query.int.test.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
* limitations under the License.
1818
*/
1919

20+
import { GraphQLError } from "graphql";
2021
import { type Driver } from "neo4j-driver";
2122
import { generate } from "randomstring";
2223
import type { Neo4jGraphQL } from "../../../../src/classes";
@@ -26,7 +27,6 @@ import { createBearerToken } from "../../../utils/create-bearer-token";
2627
import type { UniqueType } from "../../../utils/graphql-types";
2728
import { isMultiDbUnsupportedError } from "../../../utils/is-multi-db-unsupported-error";
2829
import { TestHelper } from "../../../utils/tests-helper";
29-
import { GraphQLError } from "graphql";
3030

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

12881288
expect(gqlResult.errors).toHaveLength(1);
12891289
expect(gqlResult.errors).toIncludeSameMembers([
1290-
new GraphQLError(`Field "${queryType}" argument "first" of type "Int!" is required, but it was not provided.`),
1291-
]);
1290+
new GraphQLError(
1291+
`Field "${queryType}" argument "first" of type "Int!" is required, but it was not provided.`
1292+
),
1293+
]);
12921294
});
12931295

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

13181320
expect(gqlResult.errors).toHaveLength(1);
13191321
expect(gqlResult.errors).toIncludeSameMembers([
1320-
new GraphQLError(`Field "actedInMovies" argument "limit" of type "Int!" is required, but it was not provided.`),
1321-
]);
1322-
1322+
new GraphQLError(
1323+
`Field "actedInMovies" argument "limit" of type "Int!" is required, but it was not provided.`
1324+
),
1325+
]);
13231326
});
13241327
});
13251328
describe("Query tests with auth", () => {

packages/graphql/tests/performance/graphql/connections.graphql

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,9 @@ query NestedConnection {
2828
}
2929
}
3030
}
31+
32+
query totalCount {
33+
moviesConnection {
34+
totalCount
35+
}
36+
}

packages/graphql/tests/tck/aggregations/where/authorization-with-aggregation-filter.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,13 +103,12 @@ describe("Authorization with aggregation filter rule", () => {
103103
WITH *
104104
WHERE ($isAuthenticated = true AND var3 = true)
105105
WITH collect({ node: this0 }) AS edges
106-
WITH edges, size(edges) AS totalCount
107106
CALL (edges) {
108107
UNWIND edges AS edge
109108
WITH edge.node AS this0
110109
RETURN collect({ node: { content: this0.content, __resolveType: \\"Post\\" } }) AS var4
111110
}
112-
RETURN { edges: var4, totalCount: totalCount } AS this"
111+
RETURN { edges: var4 } AS this"
113112
`);
114113

115114
expect(formatParams(result.params)).toMatchInlineSnapshot(`

packages/graphql/tests/tck/array-methods.test.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -514,13 +514,12 @@ describe("Arrays Methods", () => {
514514
CALL (this) {
515515
MATCH (this)-[update_this3:ACTED_IN]->(update_this4:Movie)
516516
WITH collect({ node: update_this4, relationship: update_this3 }) AS edges
517-
WITH edges, size(edges) AS totalCount
518517
CALL (edges) {
519518
UNWIND edges AS edge
520519
WITH edge.node AS update_this4, edge.relationship AS update_this3
521520
RETURN collect({ properties: { pay: update_this3.pay, __resolveType: \\"ActedIn\\" }, node: { __id: id(update_this4), __resolveType: \\"Movie\\" } }) AS update_var5
522521
}
523-
RETURN { edges: update_var5, totalCount: totalCount } AS update_var6
522+
RETURN { edges: update_var5 } AS update_var6
524523
}
525524
RETURN collect(DISTINCT this { .name, actedIn: update_var2, actedInConnection: update_var6 }) AS data"
526525
`);
@@ -615,13 +614,12 @@ describe("Arrays Methods", () => {
615614
CALL (this) {
616615
MATCH (this)-[update_this3:ACTED_IN]->(update_this4:Movie)
617616
WITH collect({ node: update_this4, relationship: update_this3 }) AS edges
618-
WITH edges, size(edges) AS totalCount
619617
CALL (edges) {
620618
UNWIND edges AS edge
621619
WITH edge.node AS update_this4, edge.relationship AS update_this3
622620
RETURN collect({ properties: { pay: update_this3.pay, __resolveType: \\"ActedIn\\" }, node: { __id: id(update_this4), __resolveType: \\"Movie\\" } }) AS update_var5
623621
}
624-
RETURN { edges: update_var5, totalCount: totalCount } AS update_var6
622+
RETURN { edges: update_var5 } AS update_var6
625623
}
626624
RETURN collect(DISTINCT this { .name, actedIn: update_var2, actedInConnection: update_var6 }) AS data"
627625
`);

packages/graphql/tests/tck/connections/alias.test.ts

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -64,16 +64,10 @@ describe("Connections Alias", () => {
6464
MATCH (this:Movie)
6565
CALL (this) {
6666
MATCH (this)<-[this0:ACTED_IN]-(this1:Actor)
67-
WITH collect({ node: this1, relationship: this0 }) AS edges
68-
WITH edges, size(edges) AS totalCount
69-
CALL (edges) {
70-
UNWIND edges AS edge
71-
WITH edge.node AS this1, edge.relationship AS this0
72-
RETURN collect({ node: { __id: id(this1), __resolveType: \\"Actor\\" } }) AS var2
73-
}
74-
RETURN { edges: var2, totalCount: totalCount } AS var3
67+
WITH count(this1) AS totalCount
68+
RETURN { totalCount: totalCount } AS var2
7569
}
76-
RETURN this { actors: var3 } AS this"
70+
RETURN this { actors: var2 } AS this"
7771
`);
7872

7973
expect(formatParams(result.params)).toMatchInlineSnapshot(`"{}"`);
@@ -118,25 +112,23 @@ describe("Connections Alias", () => {
118112
MATCH (this)<-[this0:ACTED_IN]-(this1:Actor)
119113
WHERE this1.name = $param1
120114
WITH collect({ node: this1, relationship: this0 }) AS edges
121-
WITH edges, size(edges) AS totalCount
122115
CALL (edges) {
123116
UNWIND edges AS edge
124117
WITH edge.node AS this1, edge.relationship AS this0
125118
RETURN collect({ properties: { screenTime: this0.screenTime, __resolveType: \\"ActedIn\\" }, node: { name: this1.name, __resolveType: \\"Actor\\" } }) AS var2
126119
}
127-
RETURN { edges: var2, totalCount: totalCount } AS var3
120+
RETURN { edges: var2 } AS var3
128121
}
129122
CALL (this) {
130123
MATCH (this)<-[this4:ACTED_IN]-(this5:Actor)
131124
WHERE this5.name = $param2
132125
WITH collect({ node: this5, relationship: this4 }) AS edges
133-
WITH edges, size(edges) AS totalCount
134126
CALL (edges) {
135127
UNWIND edges AS edge
136128
WITH edge.node AS this5, edge.relationship AS this4
137129
RETURN collect({ properties: { screenTime: this4.screenTime, __resolveType: \\"ActedIn\\" }, node: { name: this5.name, __resolveType: \\"Actor\\" } }) AS var6
138130
}
139-
RETURN { edges: var6, totalCount: totalCount } AS var7
131+
RETURN { edges: var6 } AS var7
140132
}
141133
RETURN this { .title, hanks: var3, jenny: var7 } AS this"
142134
`);

0 commit comments

Comments
 (0)