Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pagination Cursors on Connections (Relay Specification) #282

Merged
merged 22 commits into from
Jul 2, 2021
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6d80d7b
skip and limit on connections
litewarp Jun 25, 2021
c3b3725
add pageinfo and pagecursor definitions
litewarp Jun 25, 2021
079ab96
add totalCount, return connections
litewarp Jun 26, 2021
847a21f
add skip, limit on unions, make all tests pass
litewarp Jun 27, 2021
80ca747
add global node resolution, green on tests
litewarp Jun 27, 2021
655a69c
add copyright header to connection.ts
litewarp Jun 27, 2021
f616b4c
minor cleanup
litewarp Jun 28, 2021
bbe51c3
cleanup
litewarp Jun 28, 2021
13c130e
remove before/last arguments; move pagination arguments into options
litewarp Jun 28, 2021
89c62a4
use one consistent node interface definition
litewarp Jun 28, 2021
7b2cbff
remove cursor Scalar
litewarp Jun 28, 2021
7427ad8
use isInt on totalCount check in make-augmented-schema connection res…
litewarp Jun 28, 2021
137aa40
remove redundant arraySlice check in createConnectionWithEdge properties
litewarp Jun 28, 2021
8ff4399
remove Node global resolution from this pr
litewarp Jun 29, 2021
e9633dc
Merge branch '2.0.0' of github.com:neo4j/graphql into relay-pagination
litewarp Jun 29, 2021
64205d0
remove erroneous yalc stuff, formatting cleanup
litewarp Jun 30, 2021
fbc1422
Merge branch '2.0.0' of github.com:neo4j/graphql into relay-pagination
litewarp Jun 30, 2021
5713930
hoist connection args to field level, fix tests from merge, simplify …
litewarp Jun 30, 2021
aec58b0
integration test for pagination, fix skipLimitStr
litewarp Jun 30, 2021
2a7bde1
add pagination helper tests, fix off-by-one error in cursor calculation
litewarp Jul 1, 2021
9cb88b3
remove erroneous console
litewarp Jul 1, 2021
5ca9af8
move pagination tests
litewarp Jul 1, 2021
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
1 change: 1 addition & 0 deletions packages/graphql/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"dot-prop": "^6.0.1",
"graphql-compose": "^7.25.1",
"graphql-parse-resolve-info": "^4.11.0",
"graphql-relay": "^0.7.0",
"jsonwebtoken": "^8.5.1",
"pluralize": "^8.0.0"
},
Expand Down
62 changes: 42 additions & 20 deletions packages/graphql/src/schema/make-augmented-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import pluralize from "pluralize";
import { Integer, isInt } from "neo4j-driver";
import { Node, Exclude } from "../classes";
import getAuth from "./get-auth";
import { PrimitiveField, Auth, CustomEnumField } from "../types";
import { PrimitiveField, Auth, CustomEnumField, ConnectionQueryArgs } from "../types";
import { findResolver, createResolver, deleteResolver, cypherResolver, updateResolver } from "./resolvers";
import checkNodeImplementsInterfaces from "./check-node-implements-interfaces";
import * as Scalars from "./scalars";
Expand All @@ -59,6 +59,7 @@ import getFieldTypeMeta from "./get-field-type-meta";
import Relationship, { RelationshipField } from "../classes/Relationship";
import getRelationshipFieldMeta from "./get-relationship-field-meta";
import getWhereFields from "./get-where-fields";
import { createConnectionWithEdgeProperties } from "./pagination";
// import validateTypeDefs from "./validation";

function makeAugmentedSchema(
Expand Down Expand Up @@ -115,6 +116,17 @@ function makeAugmentedSchema(
},
});

composer.createObjectTC({
name: "PageInfo",
description: "Pagination information (Relay)",
fields: {
hasNextPage: "Boolean!",
hasPreviousPage: "Boolean!",
startCursor: "String!",
endCursor: "String!",
},
});

const customResolvers = getCustomResolvers(document);

const scalars = document.definitions.filter((x) => x.kind === "ScalarTypeDefinition") as ScalarTypeDefinitionNode[];
Expand Down Expand Up @@ -841,11 +853,7 @@ function makeAugmentedSchema(
name: nodeFieldDeleteInputName,
fields: {
where: `${node.name}${upperFirst(rel.fieldName)}ConnectionWhere`,
...(n.relationFields.length
? {
delete: `${n.name}DeleteInput`,
}
: {}),
...(n.relationFields.length ? { delete: `${n.name}DeleteInput` } : {}),
},
});
}
Expand All @@ -855,11 +863,7 @@ function makeAugmentedSchema(
name: nodeFieldDisconnectInputName,
fields: {
where: `${node.name}${upperFirst(rel.fieldName)}ConnectionWhere`,
...(n.relationFields.length
? {
disconnect: `${n.name}DisconnectInput`,
}
: {}),
...(n.relationFields.length ? { disconnect: `${n.name}DisconnectInput` } : {}),
},
});
}
Expand Down Expand Up @@ -895,6 +899,7 @@ function makeAugmentedSchema(
const relationship = composer.createObjectTC({
name: connectionField.relationshipTypeName,
fields: {
cursor: "String!",
node: `${connectionField.relationship.typeMeta.name}!`,
},
});
Expand All @@ -913,6 +918,8 @@ function makeAugmentedSchema(
name: connectionField.typeMeta.name,
fields: {
edges: relationship.NonNull.List.NonNull,
totalCount: "Int!",
pageInfo: "PageInfo!",
},
});

Expand All @@ -929,7 +936,9 @@ function makeAugmentedSchema(

let composeNodeArgs: {
where: any;
options?: any;
sort?: any;
first?: any;
after?: any;
} = {
where: connectionWhere,
};
Expand Down Expand Up @@ -962,20 +971,32 @@ function makeAugmentedSchema(
});
}

const connectionOptions = composer.createInputTC({
name: `${connectionField.typeMeta.name}Options`,
fields: {
sort: connectionSort.NonNull.List,
composeNodeArgs = {
...composeNodeArgs,
sort: connectionSort.NonNull.List,
first: {
type: "Int",
},
});

composeNodeArgs = { ...composeNodeArgs, options: connectionOptions };
after: {
type: "String",
},
};
}

composeNode.addFields({
[connectionField.fieldName]: {
type: connection.NonNull,
args: composeNodeArgs,
resolve: (source, args: ConnectionQueryArgs) => {
const { totalCount: count, edges } = source[connectionField.fieldName];

const totalCount = isInt(count) ? count.toNumber() : count;

return {
totalCount,
...createConnectionWithEdgeProperties(edges, args, totalCount),
};
},
},
});

Expand Down Expand Up @@ -1064,6 +1085,7 @@ function makeAugmentedSchema(

composer.createInterfaceTC({
name: inter.name.value,
description: inter.description?.value,
fields: objectComposeFields,
extensions: {
directives: graphqlDirectivesToCompose((inter.directives || []).filter((x) => x.name.value !== "auth")),
Expand All @@ -1074,7 +1096,6 @@ function makeAugmentedSchema(
if (!Object.values(composer.Mutation.getFields()).length) {
composer.delete("Mutation");
}

const generatedTypeDefs = composer.toSDL();
let generatedResolvers: any = {
...composer.getResolveMethods(),
Expand All @@ -1096,6 +1117,7 @@ function makeAugmentedSchema(

unions.forEach((union) => {
if (!generatedResolvers[union.name.value]) {
// eslint-disable-next-line no-underscore-dangle
generatedResolvers[union.name.value] = { __resolveType: (root) => root.__resolveType };
}
});
Expand Down
86 changes: 86 additions & 0 deletions packages/graphql/src/schema/pagination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
* 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 { getOffsetWithDefault, offsetToCursor } from "graphql-relay/connection/arrayConnection";
import { Integer, isInt } from "neo4j-driver";

/**
* Adapted from graphql-relay-js ConnectionFromArraySlice
*/
export function createConnectionWithEdgeProperties(
arraySlice: { node: Record<string, any>; [key: string]: any }[],
args: { after?: string; first?: number } = {},
totalCount: number
) {
const { after, first } = args;

if ((first as number) < 0) {
throw new Error('Argument "first" must be a non-negative integer');
}

// after returns the last cursor in the previous set or -1 if invalid
const lastEdgeCursor = getOffsetWithDefault(after, -1);

// increment the last cursor position by one for the sliceStart
const sliceStart = lastEdgeCursor + 1;

const sliceEnd = sliceStart + ((first as number) || arraySlice.length);

const edges = arraySlice.map((value, index) => {
return {
...value,
cursor: offsetToCursor(sliceStart + index),
};
});

const firstEdge = edges[0];
const lastEdge = edges[edges.length - 1];
return {
edges,
pageInfo: {
startCursor: firstEdge.cursor,
endCursor: lastEdge.cursor,
hasPreviousPage: lastEdgeCursor > 0,
hasNextPage: typeof first === "number" ? sliceEnd < totalCount : false,
},
};
}

export function createSkipLimitStr({ skip, limit }: { skip?: number | Integer; limit?: number | Integer }): string {
const hasSkip = typeof skip !== "undefined" && skip !== 0;
const hasLimit = typeof limit !== "undefined" && limit !== 0;
let skipLimitStr = "";

if (hasSkip && !hasLimit) {
skipLimitStr = `[${skip}..]`;
}

if (hasLimit && !hasSkip) {
skipLimitStr = `[..${limit}]`;
}

if (hasLimit && hasSkip) {
const sliceStart = isInt(skip as Integer) ? (skip as Integer).toNumber() : skip;
const itemsToGrab = isInt(limit as Integer) ? (limit as Integer).toNumber() : limit;
const sliceEnd = (sliceStart as number) + (itemsToGrab as number);
skipLimitStr = `[${skip}..${sliceEnd}]`;
}

return skipLimitStr;
}
Comment on lines +65 to +86
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably be in the translate directory, but we can easily shuffle things around when merged, so not a blocker.

Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
*/

import { ResolveTree } from "graphql-parse-resolve-info";
import { offsetToCursor } from "graphql-relay";
import dedent from "dedent";
import { mocked } from "ts-jest/utils";
import { ConnectionField, Context } from "../../types";
Expand Down Expand Up @@ -117,7 +118,7 @@ describe("createConnectionAndParams", () => {
WITH this
MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor)
WITH collect({ screenTime: this_acted_in.screenTime }) AS edges
RETURN { edges: edges } AS actorsConnection
RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection
}`);
});

Expand All @@ -144,18 +145,16 @@ describe("createConnectionAndParams", () => {
alias: "actorsConnection",
name: "actorsConnection",
args: {
options: {
sort: [
{
node: {
name: "ASC",
},
relationship: {
screenTime: "DESC",
},
sort: [
{
node: {
name: "ASC",
},
],
},
relationship: {
screenTime: "DESC",
},
},
],
},
fieldsByTypeName: {
MovieActorsConnection: {
Expand Down Expand Up @@ -226,7 +225,89 @@ describe("createConnectionAndParams", () => {
WITH this_acted_in, this_actor
ORDER BY this_acted_in.screenTime DESC, this_actor.name ASC
WITH collect({ screenTime: this_acted_in.screenTime }) AS edges
RETURN { edges: edges } AS actorsConnection
RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection
}`);
});

test("Returns an entry with skip and limit args", () => {
// @ts-ignore
const mockedNeo4jGraphQL = mocked(new Neo4jGraphQL(), true);
// @ts-ignore
mockedNeo4jGraphQL.nodes = [
// @ts-ignore
{
name: "Actor",
},
];
// @ts-ignore
mockedNeo4jGraphQL.relationships = [
// @ts-ignore
{
name: "MovieActorsRelationship",
fields: [],
},
];

const resolveTree: ResolveTree = {
alias: "actorsConnection",
name: "actorsConnection",
args: {
first: 10,
after: offsetToCursor(10),
},
fieldsByTypeName: {
MovieActorsConnection: {
edges: {
alias: "edges",
name: "edges",
args: {},
fieldsByTypeName: {
MovieActorsRelationship: {
screenTime: {
alias: "screenTime",
name: "screenTime",
args: {},
fieldsByTypeName: {},
},
},
},
},
},
},
};

const field: ConnectionField = {
fieldName: "actorsConnection",
relationshipTypeName: "MovieActorsRelationship",
// @ts-ignore
typeMeta: {
name: "MovieActorsConnection",
required: true,
},
otherDirectives: [],
// @ts-ignore
relationship: {
fieldName: "actors",
type: "ACTED_IN",
direction: "IN",
// @ts-ignore
typeMeta: {
name: "Actor",
},
},
};

// @ts-ignore
const context: Context = { neoSchema: mockedNeo4jGraphQL };

const entry = createConnectionAndParams({ resolveTree, field, context, nodeVariable: "this" });

expect(dedent(entry[0])).toEqual(dedent`CALL {
WITH this
MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor)
WITH collect({ screenTime: this_acted_in.screenTime }) AS edges
WITH edges, size(edges) AS totalCount, edges[11..21] AS limitedSelection
RETURN { edges: limitedSelection, totalCount: totalCount } AS actorsConnection
}`);
});
});
Loading