From 10d42e5806bfbfc186293c5c1f80cce612023398 Mon Sep 17 00:00:00 2001 From: Darrell Warde Date: Thu, 13 May 2021 16:21:53 +0100 Subject: [PATCH 1/3] Schema generation and Cypher translation for basic relationship property queries using GraphQL cursor connections Somewhat TCK tested, but still requires integration tests --- packages/graphql/package.json | 1 + packages/graphql/src/classes/Neo4jGraphQL.ts | 8 +- packages/graphql/src/classes/Node.ts | 5 + packages/graphql/src/classes/Relationship.ts | 54 +++ packages/graphql/src/classes/index.ts | 1 + .../graphql/src/schema/get-obj-field-meta.ts | 39 ++ .../get-relationship-field-meta.test.ts | 203 ++++++++++ .../src/schema/get-relationship-field-meta.ts | 289 ++++++++++++++ .../src/schema/get-relationship-meta.ts | 10 +- .../graphql/src/schema/get-where-fields.ts | 84 ++++ .../src/schema/make-augmented-schema.ts | 171 ++++++++- .../src/schema/validation/directives.ts | 4 + .../create-connection-and-params.test.ts | 213 +++++++++++ .../create-connection-and-params.ts | 219 +++++++++++ .../create-projection-and-params.test.ts | 1 + .../translate/create-projection-and-params.ts | 53 ++- .../elements/create-datetime-element.test.ts | 58 +++ .../elements/create-datetime-element.ts | 18 + .../elements/create-point-element.test.ts | 84 ++++ .../elements/create-point-element.ts | 33 ++ ...eate-relationship-property-element.test.ts | 160 ++++++++ .../create-relationship-property-element.ts | 30 ++ .../create-connection-where-and-params.ts | 119 ++++++ .../where/create-node-where-and-params.ts | 284 ++++++++++++++ .../create-relationship-where-and-params.ts | 198 ++++++++++ .../graphql/src/translate/translate-read.ts | 21 +- packages/graphql/src/types.ts | 25 ++ .../cypher/connections/mixed-nesting.md | 177 +++++++++ .../cypher/connections/where.md | 103 +++++ .../cypher/relationship-properties.md | 297 +++++++++++++++ .../schema/directives/exclude.md | 24 ++ .../tck/tck-test-files/schema/interfaces.md | 24 ++ .../tck/tck-test-files/schema/issues/#162.md | 48 +++ .../schema/relationship-properties.md | 358 ++++++++++++++++++ .../tck/tck-test-files/schema/relationship.md | 72 ++++ yarn.lock | 1 + 36 files changed, 3451 insertions(+), 38 deletions(-) create mode 100644 packages/graphql/src/classes/Relationship.ts create mode 100644 packages/graphql/src/schema/get-relationship-field-meta.test.ts create mode 100644 packages/graphql/src/schema/get-relationship-field-meta.ts create mode 100644 packages/graphql/src/schema/get-where-fields.ts create mode 100644 packages/graphql/src/translate/connection/create-connection-and-params.test.ts create mode 100644 packages/graphql/src/translate/connection/create-connection-and-params.ts create mode 100644 packages/graphql/src/translate/projection/elements/create-datetime-element.test.ts create mode 100644 packages/graphql/src/translate/projection/elements/create-datetime-element.ts create mode 100644 packages/graphql/src/translate/projection/elements/create-point-element.test.ts create mode 100644 packages/graphql/src/translate/projection/elements/create-point-element.ts create mode 100644 packages/graphql/src/translate/projection/elements/create-relationship-property-element.test.ts create mode 100644 packages/graphql/src/translate/projection/elements/create-relationship-property-element.ts create mode 100644 packages/graphql/src/translate/projection/where/create-connection-where-and-params.ts create mode 100644 packages/graphql/src/translate/projection/where/create-node-where-and-params.ts create mode 100644 packages/graphql/src/translate/projection/where/create-relationship-where-and-params.ts create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/mixed-nesting.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/where.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/relationship-properties.md create mode 100644 packages/graphql/tests/tck/tck-test-files/schema/relationship-properties.md diff --git a/packages/graphql/package.json b/packages/graphql/package.json index c45afa3054..21ee7880d5 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -46,6 +46,7 @@ "@types/pluralize": "0.0.29", "@types/randomstring": "1.1.6", "apollo-server": "2.21.0", + "dedent": "^0.7.0", "faker": "5.2.0", "graphql-tag": "2.11.0", "is-uuid": "1.0.2", diff --git a/packages/graphql/src/classes/Neo4jGraphQL.ts b/packages/graphql/src/classes/Neo4jGraphQL.ts index 1cb0b67198..e6bc5d7248 100644 --- a/packages/graphql/src/classes/Neo4jGraphQL.ts +++ b/packages/graphql/src/classes/Neo4jGraphQL.ts @@ -25,6 +25,7 @@ import { parseResolveInfo, ResolveTree } from "graphql-parse-resolve-info"; import type { DriverConfig } from "../types"; import { makeAugmentedSchema } from "../schema"; import Node from "./Node"; +import Relationship from "./Relationship"; import { checkNeo4jCompat } from "../utils"; import { getJWT } from "../auth/index"; import { DEBUG_GRAPHQL } from "../constants"; @@ -53,6 +54,8 @@ class Neo4jGraphQL { public nodes: Node[]; + public relationships: Relationship[]; + public document: DocumentNode; private driver?: Driver; @@ -61,11 +64,14 @@ class Neo4jGraphQL { constructor(input: Neo4jGraphQLConstructor) { const { config = {}, driver, ...schemaDefinition } = input; - const { nodes, schema } = makeAugmentedSchema(schemaDefinition, { enableRegex: config.enableRegex }); + const { nodes, relationships, schema } = makeAugmentedSchema(schemaDefinition, { + enableRegex: config.enableRegex, + }); this.driver = driver; this.config = config; this.nodes = nodes; + this.relationships = relationships; this.schema = this.createWrappedSchema({ schema, config }); this.document = parse(printSchema(schema)); } diff --git a/packages/graphql/src/classes/Node.ts b/packages/graphql/src/classes/Node.ts index 4253a24f62..4ffa57db15 100644 --- a/packages/graphql/src/classes/Node.ts +++ b/packages/graphql/src/classes/Node.ts @@ -20,6 +20,7 @@ import { DirectiveNode, NamedTypeNode } from "graphql"; import type { RelationField, + ConnectionField, CypherField, PrimitiveField, CustomEnumField, @@ -37,6 +38,7 @@ import Exclude from "./Exclude"; export interface NodeConstructor { name: string; relationFields: RelationField[]; + connectionFields: ConnectionField[]; cypherFields: CypherField[]; primitiveFields: PrimitiveField[]; scalarFields: CustomScalarField[]; @@ -59,6 +61,8 @@ class Node { public relationFields: RelationField[]; + public connectionFields: ConnectionField[]; + public cypherFields: CypherField[]; public primitiveFields: PrimitiveField[]; @@ -119,6 +123,7 @@ class Node { constructor(input: NodeConstructor) { this.name = input.name; this.relationFields = input.relationFields; + this.connectionFields = input.connectionFields; this.cypherFields = input.cypherFields; this.primitiveFields = input.primitiveFields; this.scalarFields = input.scalarFields; diff --git a/packages/graphql/src/classes/Relationship.ts b/packages/graphql/src/classes/Relationship.ts new file mode 100644 index 0000000000..4576e54c4b --- /dev/null +++ b/packages/graphql/src/classes/Relationship.ts @@ -0,0 +1,54 @@ +/* + * 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 type { PrimitiveField, DateTimeField, PointField } from "../types"; + +// export interface NodeConstructor { +// otherDirectives: DirectiveNode[]; +// ignoredFields: BaseField[]; +// } + +// TODO do CustomScalarField and CustomEnumField need to be in the mix? +export type RelationshipField = PrimitiveField | DateTimeField | PointField; + +export interface RelationshipConstructor { + name: string; + type: string; + description?: string; + fields: RelationshipField[]; +} + +class Relationship { + public name: string; + + public type: string; + + public description?: string; + + public fields: RelationshipField[]; + + constructor(input: RelationshipConstructor) { + this.name = input.name; + this.type = input.type; + this.description = input.description; + this.fields = input.fields; + } +} + +export default Relationship; diff --git a/packages/graphql/src/classes/index.ts b/packages/graphql/src/classes/index.ts index 1004293c31..77861da872 100644 --- a/packages/graphql/src/classes/index.ts +++ b/packages/graphql/src/classes/index.ts @@ -18,6 +18,7 @@ */ export { default as Node, NodeConstructor } from "./Node"; +export { default as Relationship } from "./Relationship"; export { default as Exclude, ExcludeConstructor } from "./Exclude"; export { default as Neo4jGraphQL, Neo4jGraphQLConstructor, Neo4jGraphQLConfig } from "./Neo4jGraphQL"; export * from "./Error"; diff --git a/packages/graphql/src/schema/get-obj-field-meta.ts b/packages/graphql/src/schema/get-obj-field-meta.ts index 34b6f45715..f1da2084f3 100644 --- a/packages/graphql/src/schema/get-obj-field-meta.ts +++ b/packages/graphql/src/schema/get-obj-field-meta.ts @@ -30,6 +30,7 @@ import { StringValueNode, UnionTypeDefinitionNode, } from "graphql"; +import { upperFirst } from "graphql-compose"; import getFieldTypeMeta from "./get-field-type-meta"; import getCypherMeta from "./get-cypher-meta"; import getAuth from "./get-auth"; @@ -47,11 +48,13 @@ import { DateTimeField, PointField, TimeStampOperations, + ConnectionField, } from "../types"; import parseValueNode from "./parse-value-node"; interface ObjectFields { relationFields: RelationField[]; + connectionFields: ConnectionField[]; primitiveFields: PrimitiveField[]; cypherFields: CypherField[]; scalarFields: CustomScalarField[]; @@ -162,6 +165,41 @@ function getObjFieldMeta({ } res.relationFields.push(relationField); + + if (obj.kind !== "InterfaceTypeDefinition") { + const connectionTypeName = `${obj.name.value}${upperFirst(`${baseField.fieldName}Connection`)}`; + const relationshipTypeName = `${obj.name.value}${upperFirst(`${baseField.fieldName}Relationship`)}`; + + const connectionField: ConnectionField = { + fieldName: `${baseField.fieldName}Connection`, + relationshipTypeName, + typeMeta: { + name: connectionTypeName, + required: true, + pretty: `${connectionTypeName}!`, + input: { + where: { + type: `${connectionTypeName}Where`, + pretty: `${connectionTypeName}Where`, + }, + create: { + type: "", + pretty: "", + }, + update: { + type: "", + pretty: "", + }, + }, + }, + otherDirectives: [], + arguments: [...(field.arguments || [])], + description: field.description?.value, + relationship: relationField, + }; + + res.connectionFields.push(connectionField); + } } else if (cypherMeta) { if (defaultDirective) { throw new Error("@default directive can only be used on primitive type fields"); @@ -390,6 +428,7 @@ function getObjFieldMeta({ }, { relationFields: [], + connectionFields: [], primitiveFields: [], cypherFields: [], scalarFields: [], diff --git a/packages/graphql/src/schema/get-relationship-field-meta.test.ts b/packages/graphql/src/schema/get-relationship-field-meta.test.ts new file mode 100644 index 0000000000..b1facd7e4c --- /dev/null +++ b/packages/graphql/src/schema/get-relationship-field-meta.test.ts @@ -0,0 +1,203 @@ +import { mergeTypeDefs } from "@graphql-tools/merge"; +import { InterfaceTypeDefinitionNode } from "graphql"; +import getRelationshipFieldMeta from "./get-relationship-field-meta"; + +describe("getRelationshipFieldMeta", () => { + test("returns expected field metadata", () => { + const typeDefs = ` + interface TestRelationship { + id: ID! + string: String! + int: Int! + float: Float! + dateTime: DateTime! + point: Point! + } + `; + + const documentNode = mergeTypeDefs([typeDefs]); + + const relationship = documentNode.definitions.find( + (d) => d.kind === "InterfaceTypeDefinition" + ) as InterfaceTypeDefinitionNode; + + const fieldMeta = getRelationshipFieldMeta({ relationship }); + + expect(fieldMeta).toEqual([ + { + fieldName: "id", + typeMeta: { + name: "ID", + array: false, + required: true, + pretty: "ID!", + arrayTypePretty: "", + input: { + create: { + type: "ID", + pretty: "ID!", + }, + update: { + type: "ID", + pretty: "ID", + }, + where: { + type: "ID", + pretty: "ID", + }, + }, + }, + otherDirectives: [], + arguments: [], + description: undefined, + readonly: false, + writeonly: false, + }, + { + fieldName: "string", + typeMeta: { + name: "String", + array: false, + required: true, + pretty: "String!", + arrayTypePretty: "", + input: { + create: { + type: "String", + pretty: "String!", + }, + update: { + type: "String", + pretty: "String", + }, + where: { + type: "String", + pretty: "String", + }, + }, + }, + otherDirectives: [], + arguments: [], + description: undefined, + readonly: false, + writeonly: false, + }, + { + fieldName: "int", + typeMeta: { + name: "Int", + array: false, + required: true, + pretty: "Int!", + arrayTypePretty: "", + input: { + create: { + type: "Int", + pretty: "Int!", + }, + update: { + type: "Int", + pretty: "Int", + }, + where: { + type: "Int", + pretty: "Int", + }, + }, + }, + otherDirectives: [], + arguments: [], + description: undefined, + readonly: false, + writeonly: false, + }, + { + fieldName: "float", + typeMeta: { + name: "Float", + array: false, + required: true, + pretty: "Float!", + arrayTypePretty: "", + input: { + create: { + type: "Float", + pretty: "Float!", + }, + update: { + type: "Float", + pretty: "Float", + }, + where: { + type: "Float", + pretty: "Float", + }, + }, + }, + otherDirectives: [], + arguments: [], + description: undefined, + readonly: false, + writeonly: false, + }, + { + fieldName: "dateTime", + typeMeta: { + name: "DateTime", + array: false, + required: true, + pretty: "DateTime!", + arrayTypePretty: "", + input: { + create: { + type: "DateTime", + pretty: "DateTime!", + }, + update: { + type: "DateTime", + pretty: "DateTime", + }, + where: { + type: "DateTime", + pretty: "DateTime", + }, + }, + }, + otherDirectives: [], + arguments: [], + description: undefined, + readonly: false, + writeonly: false, + }, + { + fieldName: "point", + typeMeta: { + name: "Point", + array: false, + required: true, + pretty: "Point!", + arrayTypePretty: "", + input: { + create: { + type: "Point", + pretty: "PointInput!", + }, + update: { + type: "Point", + pretty: "PointInput", + }, + where: { + type: "PointInput", + pretty: "PointInput", + }, + }, + }, + otherDirectives: [], + arguments: [], + description: undefined, + readonly: false, + writeonly: false, + }, + ]); + }); +}); diff --git a/packages/graphql/src/schema/get-relationship-field-meta.ts b/packages/graphql/src/schema/get-relationship-field-meta.ts new file mode 100644 index 0000000000..cea92d39da --- /dev/null +++ b/packages/graphql/src/schema/get-relationship-field-meta.ts @@ -0,0 +1,289 @@ +/* + * 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 { + BooleanValueNode, + FloatValueNode, + InterfaceTypeDefinitionNode, + IntValueNode, + Kind, + ListValueNode, + StringValueNode, +} from "graphql"; +import getFieldTypeMeta from "./get-field-type-meta"; +import { PrimitiveField, BaseField, DateTimeField, PointField, TimeStampOperations } from "../types"; +import { RelationshipField } from "../classes/Relationship"; +import parseValueNode from "./parse-value-node"; + +// interface ObjectFields { +// relationFields: RelationField[]; +// connectionFields: ConnectionField[]; +// primitiveFields: PrimitiveField[]; +// cypherFields: CypherField[]; +// scalarFields: CustomScalarField[]; +// enumFields: CustomEnumField[]; +// unionFields: UnionField[]; +// interfaceFields: InterfaceField[]; +// objectFields: ObjectField[]; +// dateTimeFields: DateTimeField[]; +// pointFields: PointField[]; +// ignoredFields: BaseField[]; +// } + +function getRelationshipFieldMeta({ + relationship, +}: // objects, +// interfaces, +// scalars, +// unions, +// enums, +{ + relationship: InterfaceTypeDefinitionNode; + // objects: ObjectTypeDefinitionNode[]; + // interfaces: InterfaceTypeDefinitionNode[]; + // unions: UnionTypeDefinitionNode[]; + // scalars: ScalarTypeDefinitionNode[]; + // enums: EnumTypeDefinitionNode[]; +}): RelationshipField[] { + // return relationship?.fields?.reduce( + // (res: ObjectFields, field) => + + return relationship.fields + ?.filter((field) => !field?.directives?.some((x) => x.name.value === "private")) + .map( + (field) => { + // if (field?.directives?.some((x) => x.name.value === "private")) { + // return res; + // } + + const typeMeta = getFieldTypeMeta(field); + const idDirective = field?.directives?.find((x) => x.name.value === "id"); + const defaultDirective = field?.directives?.find((x) => x.name.value === "default"); + const coalesceDirective = field?.directives?.find((x) => x.name.value === "coalesce"); + const timestampDirective = field?.directives?.find((x) => x.name.value === "timestamp"); + // const fieldScalar = scalars.find((x) => x.name.value === typeMeta.name); + // const fieldEnum = enums.find((x) => x.name.value === typeMeta.name); + + const baseField: BaseField = { + fieldName: field.name.value, + typeMeta, + otherDirectives: (field.directives || []).filter( + (x) => + !["id", "readonly", "writeonly", "ignore", "default", "coalesce", "timestamp"].includes( + x.name.value + ) + ), + arguments: [...(field.arguments || [])], + description: field.description?.value, + readonly: field?.directives?.some((d) => d.name.value === "readonly"), + writeonly: field?.directives?.some((d) => d.name.value === "writeonly"), + }; + + // if (fieldScalar) { + // if (defaultDirective) { + // throw new Error("@default directive can only be used on primitive type fields"); + // } + // const scalarField: CustomScalarField = { + // ...baseField, + // }; + // res.scalarFields.push(scalarField); + // } else if (fieldEnum) { + // if (defaultDirective) { + // throw new Error("@default directive can only be used on primitive type fields"); + // } + + // if (coalesceDirective) { + // throw new Error("@coalesce directive can only be used on primitive type fields"); + // } + + // const enumField: CustomEnumField = { + // ...baseField, + // }; + // res.enumFields.push(enumField); + // } else + + if (field.directives?.some((d) => d.name.value === "ignore")) { + baseField.ignored = true; + return baseField; + } + if (typeMeta.name === "DateTime") { + const dateTimeField: DateTimeField = { + ...baseField, + }; + + if (timestampDirective) { + if (baseField.typeMeta.array) { + throw new Error("cannot auto-generate an array"); + } + + const operations = timestampDirective?.arguments?.find((x) => x.name.value === "operations") + ?.value as ListValueNode; + + const timestamps = operations + ? (operations?.values.map((x) => parseValueNode(x)) as TimeStampOperations[]) + : (["CREATE", "UPDATE"] as TimeStampOperations[]); + + dateTimeField.timestamps = timestamps; + } + + if (defaultDirective) { + const value = defaultDirective.arguments?.find((a) => a.name.value === "value")?.value; + + if (Number.isNaN(Date.parse((value as StringValueNode).value))) { + throw new Error( + `Default value for ${relationship.name.value}.${dateTimeField.fieldName} is not a valid DateTime` + ); + } + + dateTimeField.defaultValue = (value as StringValueNode).value; + } + + if (coalesceDirective) { + throw new Error("@coalesce is not supported by DateTime fields at this time"); + } + + return dateTimeField; + } + + if (["Point", "CartesianPoint"].includes(typeMeta.name)) { + if (defaultDirective) { + throw new Error("@default directive can only be used on primitive type fields"); + } + + if (coalesceDirective) { + throw new Error("@coalesce directive can only be used on primitive type fields"); + } + + const pointField: PointField = { + ...baseField, + }; + return pointField; + } + const primitiveField: PrimitiveField = { + ...baseField, + }; + + if (idDirective) { + const autogenerate = idDirective.arguments?.find((a) => a.name.value === "autogenerate"); + if (!autogenerate || (autogenerate.value as BooleanValueNode).value) { + if (baseField.typeMeta.name !== "ID") { + throw new Error("cannot auto-generate a non ID field"); + } + + if (baseField.typeMeta.array) { + throw new Error("cannot auto-generate an array"); + } + + primitiveField.autogenerate = true; + } + } + + if (defaultDirective) { + const value = defaultDirective.arguments?.find((a) => a.name.value === "value")?.value; + + const checkKind = (kind: string) => { + if (value?.kind !== kind) { + throw new Error( + `Default value for ${relationship.name.value}.${primitiveField.fieldName} does not have matching type ${primitiveField.typeMeta.name}` + ); + } + }; + + switch (baseField.typeMeta.name) { + case "ID": + case "String": + checkKind(Kind.STRING); + primitiveField.defaultValue = (value as StringValueNode).value; + break; + case "Boolean": + checkKind(Kind.BOOLEAN); + primitiveField.defaultValue = (value as BooleanValueNode).value; + break; + case "Int": + checkKind(Kind.INT); + primitiveField.defaultValue = parseInt((value as IntValueNode).value, 10); + break; + case "Float": + checkKind(Kind.FLOAT); + primitiveField.defaultValue = parseFloat((value as FloatValueNode).value); + break; + default: + throw new Error( + "@default directive can only be used on types: Int | Float | String | Boolean | ID | DateTime" + ); + } + } + + if (coalesceDirective) { + const value = coalesceDirective.arguments?.find((a) => a.name.value === "value")?.value; + + const checkKind = (kind: string) => { + if (value?.kind !== kind) { + throw new Error( + `coalesce() value for ${relationship.name.value}.${primitiveField.fieldName} does not have matching type ${primitiveField.typeMeta.name}` + ); + } + }; + + switch (baseField.typeMeta.name) { + case "ID": + case "String": + checkKind(Kind.STRING); + primitiveField.coalesceValue = `"${(value as StringValueNode).value}"`; + break; + case "Boolean": + checkKind(Kind.BOOLEAN); + primitiveField.coalesceValue = (value as BooleanValueNode).value; + break; + case "Int": + checkKind(Kind.INT); + primitiveField.coalesceValue = parseInt((value as IntValueNode).value, 10); + break; + case "Float": + checkKind(Kind.FLOAT); + primitiveField.coalesceValue = parseFloat((value as FloatValueNode).value); + break; + default: + throw new Error( + "@coalesce directive can only be used on types: Int | Float | String | Boolean | ID | DateTime" + ); + } + } + + return primitiveField; + } + // { + // relationFields: [], + // connectionFields: [], + // primitiveFields: [], + // cypherFields: [], + // scalarFields: [], + // enumFields: [], + // unionFields: [], + // interfaceFields: [], + // objectFields: [], + // dateTimeFields: [], + // pointFields: [], + // ignoredFields: [], + // } + ) as RelationshipField[]; + // as ObjectFields; +} + +export default getRelationshipFieldMeta; diff --git a/packages/graphql/src/schema/get-relationship-meta.ts b/packages/graphql/src/schema/get-relationship-meta.ts index a5df7dbce2..ea6b7b8e89 100644 --- a/packages/graphql/src/schema/get-relationship-meta.ts +++ b/packages/graphql/src/schema/get-relationship-meta.ts @@ -17,11 +17,12 @@ * limitations under the License. */ -import { FieldDefinitionNode } from "graphql"; +import { FieldDefinitionNode, StringValueNode } from "graphql"; type RelationshipMeta = { direction: "IN" | "OUT"; type: string; + properties?: string; }; function getRelationshipMeta(field: FieldDefinitionNode): RelationshipMeta | undefined { @@ -49,12 +50,19 @@ function getRelationshipMeta(field: FieldDefinitionNode): RelationshipMeta | und throw new Error("@relationship type not a string"); } + const propertiesArg = directive.arguments?.find((x) => x.name.value === "properties"); + if (propertiesArg && propertiesArg.value.kind !== "StringValue") { + throw new Error("@relationship properties not a string"); + } + const direction = directionArg.value.value as "IN" | "OUT"; const type = typeArg.value.value; + const properties = (propertiesArg?.value as StringValueNode)?.value; return { direction, type, + properties, }; } diff --git a/packages/graphql/src/schema/get-where-fields.ts b/packages/graphql/src/schema/get-where-fields.ts new file mode 100644 index 0000000000..0f9fffb98c --- /dev/null +++ b/packages/graphql/src/schema/get-where-fields.ts @@ -0,0 +1,84 @@ +import { CustomEnumField, CustomScalarField, DateTimeField, PointField, PrimitiveField } from "../types"; + +interface Fields { + scalarFields: CustomScalarField[]; + enumFields: CustomEnumField[]; + primitiveFields: PrimitiveField[]; + dateTimeFields: DateTimeField[]; + pointFields: PointField[]; +} + +function getWhereFields({ typeName, fields, enableRegex }: { typeName: string; fields: Fields; enableRegex: boolean }) { + return { + OR: `[${typeName}Where!]`, + AND: `[${typeName}Where!]`, + // Custom scalar fields only support basic equality + ...fields.scalarFields.reduce((res, f) => { + res[f.fieldName] = f.typeMeta.array ? `[${f.typeMeta.name}]` : f.typeMeta.name; + return res; + }, {}), + ...[...fields.primitiveFields, ...fields.dateTimeFields, ...fields.enumFields, ...fields.pointFields].reduce( + (res, f) => { + // This is the only sensible place to flag whether Point and CartesianPoint are used + // if (f.typeMeta.name === "Point") { + // pointInTypeDefs = true; + // } else if (f.typeMeta.name === "CartesianPoint") { + // cartesianPointInTypeDefs = true; + // } + + res[f.fieldName] = f.typeMeta.input.where.pretty; + res[`${f.fieldName}_NOT`] = f.typeMeta.input.where.pretty; + + if (f.typeMeta.name === "Boolean") { + return res; + } + + if (f.typeMeta.array) { + res[`${f.fieldName}_INCLUDES`] = f.typeMeta.input.where.type; + res[`${f.fieldName}_NOT_INCLUDES`] = f.typeMeta.input.where.type; + return res; + } + + res[`${f.fieldName}_IN`] = `[${f.typeMeta.input.where.pretty}]`; + res[`${f.fieldName}_NOT_IN`] = `[${f.typeMeta.input.where.pretty}]`; + + if (["Float", "Int", "BigInt", "DateTime"].includes(f.typeMeta.name)) { + ["_LT", "_LTE", "_GT", "_GTE"].forEach((comparator) => { + res[`${f.fieldName}${comparator}`] = f.typeMeta.name; + }); + return res; + } + + if (["Point", "CartesianPoint"].includes(f.typeMeta.name)) { + ["_DISTANCE", "_LT", "_LTE", "_GT", "_GTE"].forEach((comparator) => { + res[`${f.fieldName}${comparator}`] = `${f.typeMeta.name}Distance`; + }); + return res; + } + + if (["String", "ID"].includes(f.typeMeta.name)) { + if (enableRegex) { + res[`${f.fieldName}_MATCHES`] = "String"; + } + + [ + "_CONTAINS", + "_NOT_CONTAINS", + "_STARTS_WITH", + "_NOT_STARTS_WITH", + "_ENDS_WITH", + "_NOT_ENDS_WITH", + ].forEach((comparator) => { + res[`${f.fieldName}${comparator}`] = f.typeMeta.name; + }); + return res; + } + + return res; + }, + {} + ), + }; +} + +export default getWhereFields; diff --git a/packages/graphql/src/schema/make-augmented-schema.ts b/packages/graphql/src/schema/make-augmented-schema.ts index a7c4741b7d..f7c6794539 100644 --- a/packages/graphql/src/schema/make-augmented-schema.ts +++ b/packages/graphql/src/schema/make-augmented-schema.ts @@ -53,18 +53,22 @@ import getCustomResolvers from "./get-custom-resolvers"; import getObjFieldMeta from "./get-obj-field-meta"; import * as point from "./point"; import { graphqlDirectivesToCompose, objectFieldsToComposeFields } from "./to-compose"; +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 validateTypeDefs from "./validation"; function makeAugmentedSchema( { typeDefs, resolvers, ...schemaDefinition }: IExecutableSchemaDefinition, { enableRegex }: { enableRegex?: boolean } = {} -): { schema: GraphQLSchema; nodes: Node[] } { +): { schema: GraphQLSchema; nodes: Node[]; relationships: Relationship[] } { const document = mergeTypeDefs(Array.isArray(typeDefs) ? (typeDefs as string[]) : [typeDefs as string]); /* - Issue caused by a combination of GraphQL Compose removing types and + Issue caused by a combination of GraphQL Compose removing types and that we are not adding Points to the validation schema. This should be a - temporary fix and does not detriment usability of the library. + temporary fix and does not detriment usability of the library. */ // validateTypeDefs(document); @@ -77,6 +81,8 @@ function makeAugmentedSchema( let pointInTypeDefs = false; let cartesianPointInTypeDefs = false; + const relationships: Relationship[] = []; + composer.createObjectTC({ name: "DeleteInfo", fields: { @@ -121,7 +127,7 @@ function makeAugmentedSchema( (x) => x.kind === "InputObjectTypeDefinition" ) as InputObjectTypeDefinitionNode[]; - const interfaces = document.definitions.filter( + let interfaces = document.definitions.filter( (x) => x.kind === "InterfaceTypeDefinition" ) as InterfaceTypeDefinitionNode[]; @@ -131,7 +137,21 @@ function makeAugmentedSchema( const unions = document.definitions.filter((x) => x.kind === "UnionTypeDefinition") as UnionTypeDefinitionNode[]; + const relationshipPropertyInterfaceNames = new Set(); + const nodes = objectNodes.map((definition) => { + if (definition.name.value === "PageInfo") { + throw new Error( + "Type name `PageInfo` reserved to support the pagination model of connections. See https://relay.dev/graphql/connections.htm#sec-Reserved-Types for more information." + ); + } + + if (definition.name.value.endsWith("Connection")) { + throw new Error( + 'Type names ending "Connection" are reserved to support the pagination model of connections. See https://relay.dev/graphql/connections.htm#sec-Reserved-Types for more information.' + ); + } + checkNodeImplementsInterfaces(definition, interfaces); const otherDirectives = (definition.directives || []).filter( @@ -160,6 +180,18 @@ function makeAugmentedSchema( objects: objectNodes, }); + nodeFields.relationFields.forEach((relationship) => { + if (relationship.properties) { + const propertiesInterface = interfaces.find((i) => i.name.value === relationship.properties); + if (!propertiesInterface) { + throw new Error( + `Cannot find interface specified in ${definition.name.value}.${relationship.fieldName}` + ); + } + relationshipPropertyInterfaceNames.add(relationship.properties); + } + }); + const node = new Node({ name: definition.name.value, interfaces: nodeInterfaces, @@ -175,8 +207,62 @@ function makeAugmentedSchema( return node; }); + const relationshipProperties = interfaces.filter((i) => relationshipPropertyInterfaceNames.has(i.name.value)); + interfaces = interfaces.filter((i) => !relationshipPropertyInterfaceNames.has(i.name.value)); + const nodeNames = nodes.map((x) => x.name); + const relationshipFields = new Map(); + + relationshipProperties.forEach((relationship) => { + const relationshipFieldMeta = getRelationshipFieldMeta({ relationship }); + + relationshipFields.set(relationship.name.value, relationshipFieldMeta); + + const propertiesInterface = composer.createInterfaceTC({ + name: relationship.name.value, + fields: { + ...relationship.fields?.reduce((res, f) => { + const typeMeta = getFieldTypeMeta(f); + + return { + ...res, + [f.name.value]: { + description: f.description?.value, + type: typeMeta.pretty, + }, + }; + }, {}), + }, + }); + + composer.createInputTC({ + name: `${relationship.name.value}Sort`, + fields: propertiesInterface.getFieldNames().reduce((res, f) => { + return { ...res, [f]: "SortDirection" }; + }, {}), + }); + + const relationshipWhereFields = getWhereFields({ + typeName: relationship.name.value, + fields: { + scalarFields: [], + enumFields: [], + dateTimeFields: relationshipFieldMeta.filter((f) => f.typeMeta.name === "DateTime"), + pointFields: relationshipFieldMeta.filter((f) => ["Point", "CartesianPoint"].includes(f.typeMeta.name)), + primitiveFields: relationshipFieldMeta.filter((f) => + ["ID", "String", "Int", "Float"].includes(f.typeMeta.name) + ), + }, + enableRegex: enableRegex || false, + }); + + composer.createInputTC({ + name: `${relationship.name.value}Where`, + fields: relationshipWhereFields, + }); + }); + nodes.forEach((node) => { const nodeFields = objectFieldsToComposeFields([ ...node.primitiveFields, @@ -620,6 +706,82 @@ function makeAugmentedSchema( }); }); + node.connectionFields + .filter((c) => !c.relationship.union) + .forEach((connectionField) => { + const relationship = composer.createObjectTC({ + name: connectionField.relationshipTypeName, + fields: { + node: `${connectionField.relationship.typeMeta.name}!`, + }, + }); + + const connectionSort = composer.createInputTC({ + name: `${connectionField.typeMeta.name}Sort`, + fields: { + node: `${connectionField.relationship.typeMeta.name}Sort`, + }, + }); + + const connectionWhereName = `${connectionField.typeMeta.name}Where`; + + const connectionWhere = composer.createInputTC({ + name: connectionWhereName, + fields: { + node: `${connectionField.relationship.typeMeta.name}Where`, + node_NOT: `${connectionField.relationship.typeMeta.name}Where`, + AND: `[${connectionWhereName}!]`, + OR: `[${connectionWhereName}!]`, + }, + }); + + if (connectionField.relationship.properties) { + const propertiesInterface = composer.getIFTC(connectionField.relationship.properties); + relationship.addInterface(propertiesInterface); + relationship.addFields(propertiesInterface.getFields()); + connectionSort.addFields({ + relationship: `${connectionField.relationship.properties}Sort`, + }); + connectionWhere.addFields({ + relationship: `${connectionField.relationship.properties}Where`, + relationship_NOT: `${connectionField.relationship.properties}Where`, + }); + } + + const connection = composer.createObjectTC({ + name: connectionField.typeMeta.name, + fields: { + edges: relationship.NonNull.List.NonNull, + }, + }); + + const connectionOptions = composer.createInputTC({ + name: `${connectionField.typeMeta.name}Options`, + fields: { + sort: connectionSort.NonNull.List, + }, + }); + + composeNode.addFields({ + [connectionField.fieldName]: { + type: connection.NonNull, + args: { + where: connectionWhere, + options: connectionOptions, + }, + }, + }); + + const r = new Relationship({ + name: connectionField.relationshipTypeName, + type: connectionField.relationship.type, + fields: connectionField.relationship.properties + ? (relationshipFields.get(connectionField.relationship.properties) as RelationshipField[]) + : [], + }); + relationships.push(r); + }); + if (!node.exclude?.operations.includes("read")) { composer.Query.addFields({ [pluralize(camelCase(node.name))]: findResolver({ node }), @@ -771,6 +933,7 @@ function makeAugmentedSchema( return { nodes, + relationships, schema, }; } diff --git a/packages/graphql/src/schema/validation/directives.ts b/packages/graphql/src/schema/validation/directives.ts index dfba104006..56420bbe51 100644 --- a/packages/graphql/src/schema/validation/directives.ts +++ b/packages/graphql/src/schema/validation/directives.ts @@ -128,6 +128,10 @@ export const relationshipDirective = new GraphQLDirective({ direction: { type: new GraphQLNonNull(RelationshipDirectionEnum), }, + properties: { + type: GraphQLString, + description: "The name of the interface containing the properties for this relationship.", + }, }, }); diff --git a/packages/graphql/src/translate/connection/create-connection-and-params.test.ts b/packages/graphql/src/translate/connection/create-connection-and-params.test.ts new file mode 100644 index 0000000000..5212760edc --- /dev/null +++ b/packages/graphql/src/translate/connection/create-connection-and-params.test.ts @@ -0,0 +1,213 @@ +import { ResolveTree } from "graphql-parse-resolve-info"; +import dedent from "dedent"; +import { mocked } from "ts-jest/utils"; +import { ConnectionField, Context } from "../../types"; +import createConnectionAndParams from "./create-connection-and-params"; +import Neo4jGraphQL from "../../classes/Neo4jGraphQL"; + +jest.mock("../../classes/Neo4jGraphQL"); + +describe("createConnectionAndParams", () => { + test("Returns entry with no 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: {}, + fieldsByTypeName: { + MovieActorsConnection: { + edges: { + alias: "edges", + name: "edges", + args: {}, + fieldsByTypeName: { + MovieActorsRelationship: { + screenTime: { + alias: "screenTime", + name: "screenTime", + args: {}, + fieldsByTypeName: {}, + }, + // node: { + // alias: "node", + // name: "node", + // args: {}, + // fieldsByTypeName: { + // Actor: { + // name: { + // alias: "name", + // name: "name", + // args: {}, + // fieldsByTypeName: {}, + // }, + // }, + // }, + // }, + }, + }, + }, + }, + }, + }; + + // @ts-ignore + 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 + RETURN { edges: edges } AS actorsConnection + }`); + }); + + test("Returns entry with sort arg", () => { + // @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: { + options: { + sort: [ + { + node: { + name: "ASC", + }, + relationship: { + screenTime: "DESC", + }, + }, + ], + }, + }, + fieldsByTypeName: { + MovieActorsConnection: { + edges: { + alias: "edges", + name: "edges", + args: {}, + fieldsByTypeName: { + MovieActorsRelationship: { + screenTime: { + alias: "screenTime", + name: "screenTime", + args: {}, + fieldsByTypeName: {}, + }, + // node: { + // alias: "node", + // name: "node", + // args: {}, + // fieldsByTypeName: { + // Actor: { + // name: { + // alias: "name", + // name: "name", + // args: {}, + // fieldsByTypeName: {}, + // }, + // }, + // }, + // }, + }, + }, + }, + }, + }, + }; + + // @ts-ignore + 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 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 + }`); + }); +}); diff --git a/packages/graphql/src/translate/connection/create-connection-and-params.ts b/packages/graphql/src/translate/connection/create-connection-and-params.ts new file mode 100644 index 0000000000..a0a3f9159a --- /dev/null +++ b/packages/graphql/src/translate/connection/create-connection-and-params.ts @@ -0,0 +1,219 @@ +import { ResolveTree } from "graphql-parse-resolve-info"; +import { ConnectionField, ConnectionOptionsArg, ConnectionWhereArg, Context } from "../../types"; +import { Node } from "../../classes"; +import createProjectionAndParams from "../create-projection-and-params"; +import Relationship from "../../classes/Relationship"; +import createRelationshipPropertyElement from "../projection/elements/create-relationship-property-element"; +import createConnectionWhereAndParams from "../projection/where/create-connection-where-and-params"; + +/* +input: + +{ + actorsConnection: { + alias: "actorsConnection" + name: "actorsConnection" + args: { where, options }???? + fieldsByTypeName: { + MovieActorsConnection: { + edges: { + alias: "edges" + name: "edges" + args: { } + fieldsByTypeName: { + MovieActorsRelationship: { + screenTime: { + alias: "screenTime" + name: "screenTime" + } + node: { + alias: "node" + name: "node" + fieldsByTypeName: { PASS ME BACK TO create-projection-and-params + .......... + } + } + } + } + } + } + } + } +} + +output: + +actorsConnection: apoc.cypher.runFirstColumn( + " + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actors:Actor) + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actors.name }}) as edges + RETURN { edges: edges } + ", + {this: this}, + true + ) + +*/ +function createConnectionAndParams({ + resolveTree, + field, + context, + nodeVariable, + parameterPrefix, +}: { + resolveTree: ResolveTree; + field: ConnectionField; + context: Context; + nodeVariable: string; + parameterPrefix?: string; +}): [string, any] { + let legacyProjectionWhereParams; + let connectionWhereParams; + let nestedConnectionFieldParams; + + const subquery = ["CALL {", `WITH ${nodeVariable}`]; + + const sortInput = (resolveTree.args.options as ConnectionOptionsArg)?.sort; + const whereInput = resolveTree.args.where as ConnectionWhereArg; + + const relationshipVariable = `${nodeVariable}_${field.relationship.type.toLowerCase()}`; + const relatedNodeVariable = `${nodeVariable}_${field.relationship.typeMeta.name.toLowerCase()}`; + + const relatedNode = context.neoSchema.nodes.find((x) => x.name === field.relationship.typeMeta.name) as Node; + const relationship = context.neoSchema.relationships.find( + (r) => r.name === field.relationshipTypeName + ) as Relationship; + + const inStr = field.relationship.direction === "IN" ? "<-" : "-"; + const relTypeStr = `[${relationshipVariable}:${field.relationship.type}]`; + const outStr = field.relationship.direction === "OUT" ? "->" : "-"; + const nodeOutStr = `(${relatedNodeVariable}:${field.relationship.typeMeta.name})`; + + /* + MATCH clause, example: + + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + */ + subquery.push(`MATCH (${nodeVariable})${inStr}${relTypeStr}${outStr}${nodeOutStr}`); + + if (whereInput) { + const where = createConnectionWhereAndParams({ + whereInput, + node: relatedNode, + nodeVariable: relatedNodeVariable, + relationship, + relationshipVariable, + context, + parameterPrefix: `${parameterPrefix ? `${parameterPrefix}.` : `${nodeVariable}_`}${ + resolveTree.name + }.args.where`, + }); + const [whereClause, whereParams] = where; + subquery.push(`WHERE ${whereClause}`); + connectionWhereParams = whereParams; + } + + if (sortInput && sortInput.length) { + const sort = sortInput.map((s) => + [ + ...Object.entries(s.relationship || []).map( + ([f, direction]) => `${relationshipVariable}.${f} ${direction}` + ), + ...Object.entries(s.node || []).map(([f, direction]) => `${relatedNodeVariable}.${f} ${direction}`), + ].join(", ") + ); + subquery.push(`WITH ${relationshipVariable}, ${relatedNodeVariable}`); + subquery.push(`ORDER BY ${sort.join(", ")}`); + } + + const connection = resolveTree.fieldsByTypeName[field.typeMeta.name]; + const { edges } = connection; + + const relationshipFieldsByTypeName = edges.fieldsByTypeName[field.relationshipTypeName]; + + const relationshipProperties = Object.values(relationshipFieldsByTypeName).filter((v) => v.name !== "node"); + const node = Object.values(relationshipFieldsByTypeName).find((v) => v.name === "node") as ResolveTree; + + const elementsToCollect: string[] = []; + + if (relationshipProperties.length) { + const relationshipPropertyEntries = relationshipProperties.map((v) => + createRelationshipPropertyElement({ resolveTree: v, relationship, relationshipVariable }) + ); + elementsToCollect.push(relationshipPropertyEntries.join(", ")); + } + + const nestedSubqueries: string[] = []; + + if (node) { + const nodeProjectionAndParams = createProjectionAndParams({ + fieldsByTypeName: node?.fieldsByTypeName, + node: relatedNode, + context, + varName: relatedNodeVariable, + literalElements: true, + }); + const [nodeProjection, nodeProjectionParams] = nodeProjectionAndParams; + elementsToCollect.push(`node: ${nodeProjection}`); + legacyProjectionWhereParams = nodeProjectionParams; + + if (nodeProjectionAndParams[2]?.connectionFields?.length) { + nodeProjectionAndParams[2].connectionFields.forEach((connectionResolveTree) => { + const connectionField = relatedNode.connectionFields.find( + (x) => x.fieldName === connectionResolveTree.name + ) as ConnectionField; + const nestedConnection = createConnectionAndParams({ + resolveTree: connectionResolveTree, + field: connectionField, + context, + nodeVariable: relatedNodeVariable, + parameterPrefix: `${parameterPrefix ? `${parameterPrefix}.` : `${nodeVariable}_`}${ + resolveTree.name + }.edges.node`, + }); + nestedSubqueries.push(nestedConnection[0]); + // nestedConnectionFieldParams.push(nestedConnection[1].); + + legacyProjectionWhereParams = { + ...legacyProjectionWhereParams, + ...Object.entries(nestedConnection[1]).reduce>((res, [k, v]) => { + if (k !== `${relatedNodeVariable}_${connectionResolveTree.name}`) { + res[k] = v; + } + return res; + }, {}), + }; + + if (nestedConnection[1][`${relatedNodeVariable}_${connectionResolveTree.name}`]) { + if (!nestedConnectionFieldParams) nestedConnectionFieldParams = {}; + nestedConnectionFieldParams = { + ...nestedConnectionFieldParams, + ...{ + [connectionResolveTree.name]: + nestedConnection[1][`${relatedNodeVariable}_${connectionResolveTree.name}`], + }, + }; + } + }); + } + } + + if (nestedSubqueries.length) subquery.push(nestedSubqueries.join("\n")); + subquery.push(`WITH collect({ ${elementsToCollect.join(", ")} }) AS edges`); + subquery.push(`RETURN { edges: edges } AS ${resolveTree.alias}`); + subquery.push("}"); + + const params = { + ...legacyProjectionWhereParams, + ...((connectionWhereParams || nestedConnectionFieldParams) && { + [`${nodeVariable}_${resolveTree.name}`]: { + ...(connectionWhereParams && { args: { where: connectionWhereParams } }), + ...(nestedConnectionFieldParams && { edges: { node: { ...nestedConnectionFieldParams } } }), + }, + }), + }; + + return [subquery.join("\n"), params]; +} + +export default createConnectionAndParams; diff --git a/packages/graphql/src/translate/create-projection-and-params.test.ts b/packages/graphql/src/translate/create-projection-and-params.test.ts index 4e9e25a0e0..2d01e3279f 100644 --- a/packages/graphql/src/translate/create-projection-and-params.test.ts +++ b/packages/graphql/src/translate/create-projection-and-params.test.ts @@ -42,6 +42,7 @@ describe("createProjectionAndParams", () => { const node: Node = { name: "Movie", relationFields: [], + connectionFields: [], cypherFields: [], enumFields: [], unionFields: [], diff --git a/packages/graphql/src/translate/create-projection-and-params.ts b/packages/graphql/src/translate/create-projection-and-params.ts index 6ca3b987a3..afdc64adfa 100644 --- a/packages/graphql/src/translate/create-projection-and-params.ts +++ b/packages/graphql/src/translate/create-projection-and-params.ts @@ -17,21 +17,24 @@ * limitations under the License. */ -import { FieldsByTypeName } from "graphql-parse-resolve-info"; +import { FieldsByTypeName, ResolveTree } from "graphql-parse-resolve-info"; import { Node } from "../classes"; import createWhereAndParams from "./create-where-and-params"; import { GraphQLOptionsArg, GraphQLSortArg, GraphQLWhereArg, Context } from "../types"; import createAuthAndParams from "./create-auth-and-params"; import { AUTH_FORBIDDEN_ERROR } from "../constants"; +import createDatetimeElement from "./projection/elements/create-datetime-element"; +import createPointElement from "./projection/elements/create-point-element"; interface Res { projection: string[]; params: any; - meta?: ProjectionMeta; + meta: ProjectionMeta; } interface ProjectionMeta { authValidateStrs?: string[]; + connectionFields?: ResolveTree[]; } function createSkipLimitStr({ skip, limit }: { skip?: number; limit?: number }): string { @@ -132,12 +135,14 @@ function createProjectionAndParams({ context, chainStr, varName, + literalElements, }: { fieldsByTypeName: FieldsByTypeName; node: Node; context: Context; chainStr?: string; varName: string; + literalElements?: boolean; }): [string, any, ProjectionMeta?] { function reducer(res: Res, [k, field]: [string, any]): Res { let key = k; @@ -159,6 +164,7 @@ function createProjectionAndParams({ const fieldFields = (field.fieldsByTypeName as unknown) as FieldsByTypeName; const cypherField = node.cypherFields.find((x) => x.fieldName === key); const relationField = node.relationFields.find((x) => x.fieldName === key); + const connectionField = node.connectionFields.find((x) => x.fieldName === key); const pointField = node.pointFields.find((x) => x.fieldName === key); const dateTimeField = node.dateTimeFields.find((x) => x.fieldName === key); const authableField = node.authableFields.find((x) => x.fieldName === key); @@ -172,10 +178,10 @@ function createProjectionAndParams({ allow: { parentNode: node, varName, chainStr: param }, }); if (allowAndParams[0]) { - if (!res.meta) { - res.meta = { authValidateStrs: [] }; + if (!res.meta.authValidateStrs) { + res.meta.authValidateStrs = []; } - res.meta?.authValidateStrs?.push(allowAndParams[0]); + res.meta.authValidateStrs?.push(allowAndParams[0]); res.params = { ...res.params, ...allowAndParams[1] }; } } @@ -390,35 +396,19 @@ function createProjectionAndParams({ return res; } - if (pointField) { - const isArray = pointField.typeMeta.array; - - const { crs, ...point } = fieldFields[pointField.typeMeta.name]; - const fields: string[] = []; - - // Sadly need to select the whole point object due to the risk of height/z - // being selected on a 2D point, to which the database will throw an error - if (point) { - fields.push(isArray ? "point:p" : `point: ${varName}.${key}`); - } - - if (crs) { - fields.push(isArray ? "crs: p.crs" : `crs: ${varName}.${key}.crs`); - } + if (connectionField) { + if (!res.meta.connectionFields) res.meta.connectionFields = []; + res.meta.connectionFields.push(field as ResolveTree); + res.projection.push(literalElements ? `${field.name}: ${field.name}` : `${field.name}`); + return res; + } - res.projection.push( - isArray - ? `${key}: [p in ${varName}.${key} | { ${fields.join(", ")} }]` - : `${key}: { ${fields.join(", ")} }` - ); + if (pointField) { + res.projection.push(createPointElement({ resolveTree: field, field: pointField, variable: varName })); } else if (dateTimeField) { - res.projection.push( - dateTimeField.typeMeta.array - ? `${key}: [ dt in ${varName}.${key} | apoc.date.convertFormat(toString(dt), "iso_zoned_date_time", "iso_offset_date_time") ]` - : `${key}: apoc.date.convertFormat(toString(${varName}.${key}), "iso_zoned_date_time", "iso_offset_date_time")` - ); + res.projection.push(createDatetimeElement({ resolveTree: field, field: dateTimeField, variable: varName })); } else { - res.projection.push(`.${key}`); + res.projection.push(literalElements ? `${key}: ${varName}.${key}` : `.${key}`); } return res; @@ -429,6 +419,7 @@ function createProjectionAndParams({ { projection: [], params: {}, + meta: {}, } ); diff --git a/packages/graphql/src/translate/projection/elements/create-datetime-element.test.ts b/packages/graphql/src/translate/projection/elements/create-datetime-element.test.ts new file mode 100644 index 0000000000..14fbcc208e --- /dev/null +++ b/packages/graphql/src/translate/projection/elements/create-datetime-element.test.ts @@ -0,0 +1,58 @@ +import { ResolveTree } from "graphql-parse-resolve-info"; +import { DateTimeField } from "../../../types"; +import createDatetimeElement from "./create-datetime-element"; + +describe("createDatetimeElement", () => { + test("returns projection element for single datetime value", () => { + const resolveTree: ResolveTree = { + name: "datetime", + alias: "datetime", + args: {}, + fieldsByTypeName: {}, + }; + + const field: DateTimeField = { + // @ts-ignore + typeMeta: { + name: "Point", + }, + }; + + const element = createDatetimeElement({ + resolveTree, + field, + variable: "this", + }); + + expect(element).toEqual( + 'datetime: apoc.date.convertFormat(toString(this.datetime), "iso_zoned_date_time", "iso_offset_date_time")' + ); + }); + + test("returns projection element for array of datetime values", () => { + const resolveTree: ResolveTree = { + name: "datetimes", + alias: "datetimes", + args: {}, + fieldsByTypeName: {}, + }; + + const field: DateTimeField = { + // @ts-ignore + typeMeta: { + name: "Point", + array: true, + }, + }; + + const element = createDatetimeElement({ + resolveTree, + field, + variable: "this", + }); + + expect(element).toEqual( + 'datetimes: [ dt in this.datetimes | apoc.date.convertFormat(toString(dt), "iso_zoned_date_time", "iso_offset_date_time") ]' + ); + }); +}); diff --git a/packages/graphql/src/translate/projection/elements/create-datetime-element.ts b/packages/graphql/src/translate/projection/elements/create-datetime-element.ts new file mode 100644 index 0000000000..59d930680a --- /dev/null +++ b/packages/graphql/src/translate/projection/elements/create-datetime-element.ts @@ -0,0 +1,18 @@ +import { ResolveTree } from "graphql-parse-resolve-info"; +import { DateTimeField } from "../../../types"; + +function createDatetimeElement({ + resolveTree, + field, + variable, +}: { + resolveTree: ResolveTree; + field: DateTimeField; + variable: string; +}): string { + return field.typeMeta.array + ? `${resolveTree.alias}: [ dt in ${variable}.${resolveTree.name} | apoc.date.convertFormat(toString(dt), "iso_zoned_date_time", "iso_offset_date_time") ]` + : `${resolveTree.alias}: apoc.date.convertFormat(toString(${variable}.${resolveTree.name}), "iso_zoned_date_time", "iso_offset_date_time")`; +} + +export default createDatetimeElement; diff --git a/packages/graphql/src/translate/projection/elements/create-point-element.test.ts b/packages/graphql/src/translate/projection/elements/create-point-element.test.ts new file mode 100644 index 0000000000..151452c88b --- /dev/null +++ b/packages/graphql/src/translate/projection/elements/create-point-element.test.ts @@ -0,0 +1,84 @@ +import { ResolveTree } from "graphql-parse-resolve-info"; +import { PointField } from "../../../types"; +import createPointElement from "./create-point-element"; + +describe("createPointElement", () => { + test("returns projection element for single point value", () => { + const resolveTree: ResolveTree = { + name: "point", + alias: "point", + args: {}, + fieldsByTypeName: { + Point: { + crs: { + alias: "crs", + name: "crs", + args: {}, + fieldsByTypeName: {}, + }, + point: { + alias: "point", + name: "point", + args: {}, + fieldsByTypeName: {}, + }, + }, + }, + }; + + const field: PointField = { + // @ts-ignore + typeMeta: { + name: "Point", + }, + }; + + const element = createPointElement({ + resolveTree, + field, + variable: "this", + }); + + expect(element).toEqual("point: { point: this.point, crs: this.point.crs }"); + }); + + test("returns projection element for array of point values", () => { + const resolveTree: ResolveTree = { + name: "points", + alias: "points", + args: {}, + fieldsByTypeName: { + Point: { + crs: { + alias: "crs", + name: "crs", + args: {}, + fieldsByTypeName: {}, + }, + point: { + alias: "point", + name: "point", + args: {}, + fieldsByTypeName: {}, + }, + }, + }, + }; + + const field: PointField = { + // @ts-ignore + typeMeta: { + name: "Point", + array: true, + }, + }; + + const element = createPointElement({ + resolveTree, + field, + variable: "this", + }); + + expect(element).toEqual("points: [p in this.points | { point:p, crs: p.crs }]"); + }); +}); diff --git a/packages/graphql/src/translate/projection/elements/create-point-element.ts b/packages/graphql/src/translate/projection/elements/create-point-element.ts new file mode 100644 index 0000000000..0d28785e00 --- /dev/null +++ b/packages/graphql/src/translate/projection/elements/create-point-element.ts @@ -0,0 +1,33 @@ +import { ResolveTree } from "graphql-parse-resolve-info"; +import { PointField } from "../../../types"; + +function createPointElement({ + resolveTree, + field, + variable, +}: { + resolveTree: ResolveTree; + field: PointField; + variable: string; +}): string { + const isArray = field.typeMeta.array; + + const { crs, ...point } = resolveTree.fieldsByTypeName[field.typeMeta.name]; + const fields: string[] = []; + + // Sadly need to select the whole point object due to the risk of height/z + // being selected on a 2D point, to which the database will throw an error + if (point) { + fields.push(isArray ? "point:p" : `point: ${variable}.${resolveTree.name}`); + } + + if (crs) { + fields.push(isArray ? "crs: p.crs" : `crs: ${variable}.${resolveTree.name}.crs`); + } + + return isArray + ? `${resolveTree.alias}: [p in ${variable}.${resolveTree.name} | { ${fields.join(", ")} }]` + : `${resolveTree.alias}: { ${fields.join(", ")} }`; +} + +export default createPointElement; diff --git a/packages/graphql/src/translate/projection/elements/create-relationship-property-element.test.ts b/packages/graphql/src/translate/projection/elements/create-relationship-property-element.test.ts new file mode 100644 index 0000000000..82b2664654 --- /dev/null +++ b/packages/graphql/src/translate/projection/elements/create-relationship-property-element.test.ts @@ -0,0 +1,160 @@ +import { ResolveTree } from "graphql-parse-resolve-info"; +import Relationship from "../../../classes/Relationship"; +import { DateTimeField, PointField, PrimitiveField } from "../../../types"; +import createRelationshipPropertyElement from "./create-relationship-property-element"; + +describe("createRelationshipPropertyElement", () => { + let relationship: Relationship; + + beforeAll(() => { + relationship = new Relationship({ + name: "TestRelationship", + type: "TEST_RELATIONSHIP", + fields: [ + { + fieldName: "int", + typeMeta: { + name: "Int", + array: false, + required: true, + pretty: "Int!", + arrayTypePretty: "", + input: { + create: { + type: "Int", + pretty: "Int!", + }, + update: { + type: "Int", + pretty: "Int", + }, + where: { + type: "Int", + pretty: "Int", + }, + }, + }, + otherDirectives: [], + arguments: [], + description: undefined, + readonly: false, + writeonly: false, + } as PrimitiveField, + { + fieldName: "datetime", + typeMeta: { + name: "DateTime", + array: false, + required: true, + pretty: "DateTime!", + arrayTypePretty: "", + input: { + create: { + type: "DateTime", + pretty: "DateTime!", + }, + update: { + type: "DateTime", + pretty: "DateTime", + }, + where: { + type: "DateTime", + pretty: "DateTime", + }, + }, + }, + otherDirectives: [], + arguments: [], + description: undefined, + readonly: false, + writeonly: false, + } as DateTimeField, + { + fieldName: "point", + typeMeta: { + name: "Point", + array: false, + required: true, + pretty: "Point!", + arrayTypePretty: "", + input: { + create: { + type: "Point", + pretty: "PointInput!", + }, + update: { + type: "Point", + pretty: "PointInput", + }, + where: { + type: "PointInput", + pretty: "PointInput", + }, + }, + }, + otherDirectives: [], + arguments: [], + description: undefined, + readonly: false, + writeonly: false, + } as PointField, + ], + }); + }); + + test("returns an element for a primitive property", () => { + const resolveTree: ResolveTree = { + alias: "int", + name: "int", + args: {}, + fieldsByTypeName: {}, + }; + + const element = createRelationshipPropertyElement({ resolveTree, relationship, relationshipVariable: "this" }); + + expect(element).toEqual("int: this.int"); + }); + + test("returns an element for a datetime property", () => { + const resolveTree: ResolveTree = { + alias: "datetime", + name: "datetime", + args: {}, + fieldsByTypeName: {}, + }; + + const element = createRelationshipPropertyElement({ resolveTree, relationship, relationshipVariable: "this" }); + + expect(element).toEqual( + 'datetime: apoc.date.convertFormat(toString(this.datetime), "iso_zoned_date_time", "iso_offset_date_time")' + ); + }); + + test("returns an element for a point property", () => { + const resolveTree: ResolveTree = { + name: "point", + alias: "point", + args: {}, + fieldsByTypeName: { + Point: { + crs: { + alias: "crs", + name: "crs", + args: {}, + fieldsByTypeName: {}, + }, + point: { + alias: "point", + name: "point", + args: {}, + fieldsByTypeName: {}, + }, + }, + }, + }; + + const element = createRelationshipPropertyElement({ resolveTree, relationship, relationshipVariable: "this" }); + + expect(element).toEqual("point: { point: this.point, crs: this.point.crs }"); + }); +}); diff --git a/packages/graphql/src/translate/projection/elements/create-relationship-property-element.ts b/packages/graphql/src/translate/projection/elements/create-relationship-property-element.ts new file mode 100644 index 0000000000..4bf839dbea --- /dev/null +++ b/packages/graphql/src/translate/projection/elements/create-relationship-property-element.ts @@ -0,0 +1,30 @@ +import { ResolveTree } from "graphql-parse-resolve-info"; +import Relationship from "../../../classes/Relationship"; +import createDatetimeElement from "./create-datetime-element"; +import createPointElement from "./create-point-element"; + +function createRelationshipPropertyEntry({ + resolveTree, + relationship, + relationshipVariable, +}: { + resolveTree: ResolveTree; + relationship: Relationship; + relationshipVariable: string; +}): string { + const datetimeField = relationship.fields.find( + (f) => f.fieldName === resolveTree.name && f.typeMeta.name === "DateTime" + ); + const pointField = relationship.fields.find( + (f) => f.fieldName === resolveTree.name && ["Point", "CartesianPoint"].includes(f.typeMeta.name) + ); + + if (datetimeField) + return createDatetimeElement({ resolveTree, field: datetimeField, variable: relationshipVariable }); + + if (pointField) return createPointElement({ resolveTree, field: pointField, variable: relationshipVariable }); + + return `${resolveTree.alias}: ${relationshipVariable}.${resolveTree.name}`; +} + +export default createRelationshipPropertyEntry; diff --git a/packages/graphql/src/translate/projection/where/create-connection-where-and-params.ts b/packages/graphql/src/translate/projection/where/create-connection-where-and-params.ts new file mode 100644 index 0000000000..6c619ff548 --- /dev/null +++ b/packages/graphql/src/translate/projection/where/create-connection-where-and-params.ts @@ -0,0 +1,119 @@ +import { Node, Relationship } from "../../../classes"; +import { ConnectionWhereArg, Context } from "../../../types"; +import createRelationshipWhereAndParams from "./create-relationship-where-and-params"; +import createNodeWhereAndParams from "./create-node-where-and-params"; + +function createConnectionWhereAndParams({ + whereInput, + context, + node, + nodeVariable, + relationship, + relationshipVariable, + parameterPrefix, +}: { + whereInput: ConnectionWhereArg; + context: Context; + node: Node; + nodeVariable: string; + relationship: Relationship; + relationshipVariable: string; + parameterPrefix: string; +}): [string, any] { + const whereStrs: string[] = []; + let params = {}; + + if (whereInput.node) { + const nodeWhere = createNodeWhereAndParams({ + whereInput: whereInput.node, + node, + nodeVariable, + context, + parameterPrefix: `${parameterPrefix}.node`, + }); + whereStrs.push(nodeWhere[0]); + params = { ...params, node: nodeWhere[1] }; + } + + if (whereInput.node_NOT) { + const nodeWhere = createNodeWhereAndParams({ + whereInput: whereInput.node_NOT, + node, + nodeVariable, + context, + parameterPrefix: `${parameterPrefix}.node_NOT`, + }); + whereStrs.push(`(NOT ${nodeWhere[0]})`); + params = { ...params, node_NOT: nodeWhere[1] }; + } + + if (whereInput.relationship) { + const relationshipWhere = createRelationshipWhereAndParams({ + whereInput: whereInput.relationship, + relationship, + relationshipVariable, + context, + parameterPrefix: `${parameterPrefix}.relationship`, + }); + whereStrs.push(relationshipWhere[0]); + params = { ...params, relationship: relationshipWhere[1] }; + } + + if (whereInput.relationship_NOT) { + const relationshipWhere = createRelationshipWhereAndParams({ + whereInput: whereInput.relationship_NOT, + relationship, + relationshipVariable, + context, + parameterPrefix: `${parameterPrefix}.relationship_NOT`, + }); + whereStrs.push(`(NOT ${relationshipWhere[0]})`); + params = { ...params, relationship_NOT: relationshipWhere[1] }; + } + + if (whereInput.AND) { + const innerClauses: string[] = []; + + whereInput.AND.forEach((a, i) => { + const and = createConnectionWhereAndParams({ + whereInput: a, + node, + nodeVariable, + relationship, + relationshipVariable, + context, + parameterPrefix: `${parameterPrefix}.AND[${i}]`, + }); + + innerClauses.push(`${and[0]}`); + params = { ...params, ...and[1] }; + }); + + whereStrs.push(`(${innerClauses.join(" AND ")})`); + } + + if (whereInput.OR) { + const innerClauses: string[] = []; + + whereInput.OR.forEach((o, i) => { + const or = createConnectionWhereAndParams({ + whereInput: o, + node, + nodeVariable, + relationship, + relationshipVariable, + context, + parameterPrefix: `${parameterPrefix}.OR[${i}]`, + }); + + innerClauses.push(`${or[0]}`); + params = { ...params, ...or[1] }; + }); + + whereStrs.push(`(${innerClauses.join(" OR ")})`); + } + + return [whereStrs.join(" AND "), params]; +} + +export default createConnectionWhereAndParams; diff --git a/packages/graphql/src/translate/projection/where/create-node-where-and-params.ts b/packages/graphql/src/translate/projection/where/create-node-where-and-params.ts new file mode 100644 index 0000000000..90d6ffbed9 --- /dev/null +++ b/packages/graphql/src/translate/projection/where/create-node-where-and-params.ts @@ -0,0 +1,284 @@ +/* + * 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 { GraphQLWhereArg, Context } from "../../../types"; +import { Node } from "../../../classes"; + +interface Res { + clauses: string[]; + params: any; +} + +function createNodeWhereAndParams({ + whereInput, + node, + nodeVariable, + context, + parameterPrefix, +}: { + whereInput: GraphQLWhereArg; + node: Node; + nodeVariable: string; + context: Context; + parameterPrefix: string; +}): [string, any] { + if (!Object.keys(whereInput).length) { + return ["", {}]; + } + + function reducer(res: Res, [key, value]: [string, GraphQLWhereArg]): Res { + const param = `${parameterPrefix}.${key}`; + + const operators = { + INCLUDES: "IN", + IN: "IN", + MATCHES: "=~", + CONTAINS: "CONTAINS", + STARTS_WITH: "STARTS WITH", + ENDS_WITH: "ENDS WITH", + LT: "<", + GT: ">", + GTE: ">=", + LTE: "<=", + DISTANCE: "=", + }; + + const re = /(?[_A-Za-z][_0-9A-Za-z]*?)(?:_(?NOT))?(?:_(?INCLUDES|IN|MATCHES|CONTAINS|STARTS_WITH|ENDS_WITH|LT|GT|GTE|LTE|DISTANCE))?$/gm; + + const match = re.exec(key); + + const fieldName = match?.groups?.field; + const not = !!match?.groups?.not; + const operator = match?.groups?.operator; + + const pointField = node.pointFields.find((x) => x.fieldName === fieldName); + + const coalesceValue = [...node.primitiveFields, ...node.dateTimeFields].find((f) => fieldName === f.fieldName) + ?.coalesceValue; + + const property = + coalesceValue !== undefined + ? `coalesce(${nodeVariable}.${fieldName}, ${coalesceValue})` + : `${nodeVariable}.${fieldName}`; + + if (fieldName && ["AND", "OR"].includes(fieldName)) { + const innerClauses: string[] = []; + const nestedParams: any[] = []; + + value.forEach((v: any, i) => { + const recurse = createNodeWhereAndParams({ + whereInput: v, + node, + nodeVariable, + // chainStr: `${param}${i > 0 ? i : ""}`, + context, + // recursing: true, + parameterPrefix: `${parameterPrefix}.${fieldName}[${i}]`, + }); + + innerClauses.push(`(${recurse[0]})`); + // res.params = { ...res.params, ...recurse[1] }; + nestedParams.push(recurse[1]); + }); + + res.clauses.push(`(${innerClauses.join(` ${fieldName} `)})`); + res.params = { ...res.params, [fieldName]: nestedParams }; + + return res; + } + + // Equality/inequality + if (!operator) { + const relationField = node.relationFields.find((x) => fieldName === x.fieldName); + + if (relationField) { + const refNode = context.neoSchema.nodes.find((x) => x.name === relationField.typeMeta.name) as Node; + const inStr = relationField.direction === "IN" ? "<-" : "-"; + const outStr = relationField.direction === "OUT" ? "->" : "-"; + const relTypeStr = `[:${relationField.type}]`; + const relatedNodeVariable = `${nodeVariable}_${relationField.fieldName}`; + + if (value === null) { + let clause = `EXISTS((${nodeVariable})${inStr}${relTypeStr}${outStr}(:${relationField.typeMeta.name}))`; + if (!not) clause = `NOT ${clause}`; + res.clauses.push(clause); + return res; + } + + let resultStr = [ + `EXISTS((${nodeVariable})${inStr}${relTypeStr}${outStr}(:${relationField.typeMeta.name}))`, + `AND ${ + not ? "NONE" : "ANY" + }(${param} IN [(${nodeVariable})${inStr}${relTypeStr}${outStr}(${relatedNodeVariable}:${ + relationField.typeMeta.name + }) | ${param}] INNER_WHERE `, + ].join(" "); + + const recurse = createNodeWhereAndParams({ + whereInput: value, + node: refNode, + nodeVariable: relatedNodeVariable, + context, + parameterPrefix: `${parameterPrefix}.${fieldName}`, + }); + + resultStr += recurse[0]; + resultStr += ")"; // close NONE/ANY + res.clauses.push(resultStr); + res.params = { ...res.params, fieldName: recurse[1] }; + return res; + } + + if (value === null) { + res.clauses.push( + not ? `${nodeVariable}.${fieldName} IS NOT NULL` : `${nodeVariable}.${fieldName} IS NULL` + ); + return res; + } + + if (pointField) { + if (pointField.typeMeta.array) { + let clause = `${nodeVariable}.${fieldName} = [p in $${param} | point(p)]`; + if (not) clause = `(NOT ${clause})`; + res.clauses.push(clause); + } else { + let clause = `${nodeVariable}.${fieldName} = point($${param})`; + if (not) clause = `(NOT ${clause})`; + res.clauses.push(clause); + } + } else { + let clause = `${property} = $${param}`; + if (not) clause = `(NOT ${clause})`; + res.clauses.push(clause); + } + + res.params[key] = value; + return res; + } + + if (operator === "IN") { + const relationField = node.relationFields.find((x) => fieldName === x.fieldName); + + if (relationField) { + const refNode = context.neoSchema.nodes.find((x) => x.name === relationField.typeMeta.name) as Node; + const inStr = relationField.direction === "IN" ? "<-" : "-"; + const outStr = relationField.direction === "OUT" ? "->" : "-"; + const relTypeStr = `[:${relationField.type}]`; + const relatedNodeVariable = `${nodeVariable}_${relationField.fieldName}`; + + let resultStr = [ + `EXISTS((${nodeVariable})${inStr}${relTypeStr}${outStr}(:${relationField.typeMeta.name}))`, + `AND ALL(${param} IN [(${nodeVariable})${inStr}${relTypeStr}${outStr}(${relatedNodeVariable}:${relationField.typeMeta.name}) | ${param}] INNER_WHERE `, + ].join(" "); + + if (not) resultStr += "NOT("; + + const inner: string[] = []; + + const nestedParams: any[] = []; + + (value as any[]).forEach((v, i) => { + const recurse = createNodeWhereAndParams({ + whereInput: v, + // chainStr: `${param}${i}`, + node: refNode, + nodeVariable: relatedNodeVariable, + context, + parameterPrefix: `${parameterPrefix}${fieldName}[${i}]`, + }); + + inner.push(recurse[0]); + nestedParams.push(recurse[1]); + }); + + resultStr += inner.join(" OR "); + if (not) resultStr += ")"; // close NOT + resultStr += ")"; // close ALL + res.clauses.push(resultStr); + res.params = { ...res.params, fieldName: nestedParams }; + } else if (pointField) { + let clause = `${nodeVariable}.${fieldName} IN [p in $${param} | point(p)]`; + if (not) clause = `(NOT ${clause})`; + res.clauses.push(clause); + res.params[key] = value; + } else { + let clause = `${property} IN $${param}`; + if (not) clause = `(NOT ${clause})`; + res.clauses.push(clause); + res.params[key] = value; + } + + return res; + } + + if (operator === "INCLUDES") { + let clause = pointField ? `point($${param}) IN ${nodeVariable}.${fieldName}` : `$${param} IN ${property}`; + + if (not) clause = `(NOT ${clause})`; + + res.clauses.push(clause); + res.params[key] = value; + + return res; + } + + if (key.endsWith("_MATCHES")) { + res.clauses.push(`${property} =~ $${param}`); + res.params[key] = value; + + return res; + } + + if (operator && ["CONTAINS", "STARTS_WITH", "ENDS_WITH"].includes(operator)) { + let clause = `${property} ${operators[operator]} $${param}`; + if (not) clause = `(NOT ${clause})`; + res.clauses.push(clause); + res.params[key] = value; + return res; + } + + if (operator && ["LT", "LTE", "GTE", "GT"].includes(operator)) { + res.clauses.push( + pointField + ? `distance(${nodeVariable}.${fieldName}, point($${param}.point)) ${operators[operator]} $${param}.distance` + : `${property} ${operators[operator]} $${param}` + ); + res.params[key] = value; + return res; + } + + if (key.endsWith("_DISTANCE")) { + res.clauses.push(`distance(${nodeVariable}.${fieldName}, point($${param}.point)) = $${param}.distance`); + res.params[key] = value; + + return res; + } + + // Necessary for TypeScript, but should never reach here + return res; + } + + const { clauses, params } = Object.entries(whereInput).reduce(reducer, { clauses: [], params: {} }); + // let where = `${!recursing ? "WHERE " : ""}`; + const where = clauses.join(" AND ").replace(/INNER_WHERE/gi, "WHERE"); + + return [where, params]; +} + +export default createNodeWhereAndParams; diff --git a/packages/graphql/src/translate/projection/where/create-relationship-where-and-params.ts b/packages/graphql/src/translate/projection/where/create-relationship-where-and-params.ts new file mode 100644 index 0000000000..ce262be013 --- /dev/null +++ b/packages/graphql/src/translate/projection/where/create-relationship-where-and-params.ts @@ -0,0 +1,198 @@ +import Relationship from "../../../classes/Relationship"; +import { GraphQLWhereArg, Context, PrimitiveField } from "../../../types"; + +interface Res { + clauses: string[]; + params: any; +} + +function createRelationshipWhereAndParams({ + whereInput, + context, + relationship, + relationshipVariable, + parameterPrefix, +}: // chainStr, +{ + whereInput: GraphQLWhereArg; + context: Context; + relationship: Relationship; + relationshipVariable: string; + parameterPrefix: string; + // authValidateStrs?: string[]; + // chainStr?: string; +}): [string, any] { + if (!Object.keys(whereInput).length) { + return ["", {}]; + } + + function reducer(res: Res, [key, value]: [string, GraphQLWhereArg]): Res { + const param = `${parameterPrefix}.${key}`; + + const operators = { + INCLUDES: "IN", + IN: "IN", + MATCHES: "=~", + CONTAINS: "CONTAINS", + STARTS_WITH: "STARTS WITH", + ENDS_WITH: "ENDS WITH", + LT: "<", + GT: ">", + GTE: ">=", + LTE: "<=", + DISTANCE: "=", + }; + + const re = /(?[_A-Za-z][_0-9A-Za-z]*?)(?:_(?NOT))?(?:_(?INCLUDES|IN|MATCHES|CONTAINS|STARTS_WITH|ENDS_WITH|LT|GT|GTE|LTE|DISTANCE))?$/gm; + + const match = re.exec(key); + + const fieldName = match?.groups?.field; + const not = !!match?.groups?.not; + const operator = match?.groups?.operator; + + const pointField = relationship.fields.find( + (f) => f.fieldName === fieldName && ["Point", "CartesianPoint"].includes(f.typeMeta.name) + ); + + const coalesceValue = (relationship.fields.find( + (f) => f.fieldName === fieldName && "coalesce" in f + ) as PrimitiveField)?.coalesceValue; + + const property = + coalesceValue !== undefined + ? `coalesce(${relationshipVariable}.${fieldName}, ${coalesceValue})` + : `${relationshipVariable}.${fieldName}`; + + if (fieldName && ["AND", "OR"].includes(fieldName)) { + const innerClauses: string[] = []; + const nestedParams: any[] = []; + + value.forEach((v: any, i) => { + const recurse = createRelationshipWhereAndParams({ + whereInput: v, + relationship, + relationshipVariable, + // chainStr: `${param}${i > 0 ? i : ""}`, + context, + // recursing: true, + parameterPrefix: `${parameterPrefix}.${fieldName}[${i}]`, + }); + + innerClauses.push(`(${recurse[0]})`); + // res.params = { ...res.params, ...recurse[1] }; + nestedParams.push(recurse[1]); + }); + + res.clauses.push(`(${innerClauses.join(` ${fieldName} `)})`); + res.params = { ...res.params, [fieldName]: nestedParams }; + + return res; + } + + // Equality/inequality + if (!operator) { + if (value === null) { + res.clauses.push( + not + ? `${relationshipVariable}.${fieldName} IS NOT NULL` + : `${relationshipVariable}.${fieldName} IS NULL` + ); + return res; + } + + if (pointField) { + if (pointField.typeMeta.array) { + let clause = `${relationshipVariable}.${fieldName} = [p in $${param} | point(p)]`; + if (not) clause = `(NOT ${clause})`; + res.clauses.push(clause); + } else { + let clause = `${relationshipVariable}.${fieldName} = point($${param})`; + if (not) clause = `(NOT ${clause})`; + res.clauses.push(clause); + } + } else { + let clause = `${property} = $${param}`; + if (not) clause = `(NOT ${clause})`; + res.clauses.push(clause); + } + + res.params[key] = value; + return res; + } + + if (operator === "IN") { + if (pointField) { + let clause = `${relationshipVariable}.${fieldName} IN [p in $${param} | point(p)]`; + if (not) clause = `(NOT ${clause})`; + res.clauses.push(clause); + res.params[key] = value; + } else { + let clause = `${property} IN $${param}`; + if (not) clause = `(NOT ${clause})`; + res.clauses.push(clause); + res.params[key] = value; + } + + return res; + } + + if (operator === "INCLUDES") { + let clause = pointField + ? `point($${param}) IN ${relationshipVariable}.${fieldName}` + : `$${param} IN ${property}`; + + if (not) clause = `(NOT ${clause})`; + + res.clauses.push(clause); + res.params[key] = value; + + return res; + } + + if (key.endsWith("_MATCHES")) { + res.clauses.push(`${property} =~ $${param}`); + res.params[key] = value; + + return res; + } + + if (operator && ["CONTAINS", "STARTS_WITH", "ENDS_WITH"].includes(operator)) { + let clause = `${property} ${operators[operator]} $${param}`; + if (not) clause = `(NOT ${clause})`; + res.clauses.push(clause); + res.params[key] = value; + return res; + } + + if (operator && ["LT", "LTE", "GTE", "GT"].includes(operator)) { + res.clauses.push( + pointField + ? `distance(${relationshipVariable}.${fieldName}, point($${param}.point)) ${operators[operator]} $${param}.distance` + : `${property} ${operators[operator]} $${param}` + ); + res.params[key] = value; + return res; + } + + if (key.endsWith("_DISTANCE")) { + res.clauses.push( + `distance(${relationshipVariable}.${fieldName}, point($${param}.point)) = $${param}.distance` + ); + res.params[key] = value; + + return res; + } + + // Necessary for TypeScript, but should never reach here + return res; + } + + const { clauses, params } = Object.entries(whereInput).reduce(reducer, { clauses: [], params: {} }); + // let where = `${!recursing ? "WHERE " : ""}`; + const where = clauses.join(" AND ").replace(/INNER_WHERE/gi, "WHERE"); + + return [where, params]; +} + +export default createRelationshipWhereAndParams; diff --git a/packages/graphql/src/translate/translate-read.ts b/packages/graphql/src/translate/translate-read.ts index 7440a651db..7c12883fce 100644 --- a/packages/graphql/src/translate/translate-read.ts +++ b/packages/graphql/src/translate/translate-read.ts @@ -20,9 +20,10 @@ import { Node } from "../classes"; import createWhereAndParams from "./create-where-and-params"; import createProjectionAndParams from "./create-projection-and-params"; -import { GraphQLWhereArg, GraphQLOptionsArg, GraphQLSortArg, Context } from "../types"; +import { GraphQLWhereArg, GraphQLOptionsArg, GraphQLSortArg, Context, ConnectionField } from "../types"; import createAuthAndParams from "./create-auth-and-params"; import { AUTH_FORBIDDEN_ERROR } from "../constants"; +import createConnectionAndParams from "./connection/create-connection-and-params"; function translateRead({ node, context }: { context: Context; node: Node }): [string, any] { const { resolveTree } = context; @@ -41,6 +42,7 @@ function translateRead({ node, context }: { context: Context; node: Node }): [st let projStr = ""; let cypherParams: { [k: string]: any } = {}; const whereStrs: string[] = []; + const connectionStrs: string[] = []; const projection = createProjectionAndParams({ node, @@ -56,6 +58,22 @@ function translateRead({ node, context }: { context: Context; node: Node }): [st )}), "${AUTH_FORBIDDEN_ERROR}", [0])`; } + if (projection[2]?.connectionFields?.length) { + projection[2].connectionFields.forEach((connectionResolveTree) => { + const connectionField = node.connectionFields.find( + (x) => x.fieldName === connectionResolveTree.name + ) as ConnectionField; + const connection = createConnectionAndParams({ + resolveTree: connectionResolveTree, + field: connectionField, + context, + nodeVariable: varName, + }); + connectionStrs.push(connection[0]); + cypherParams = { ...cypherParams, ...connection[1] }; + }); + } + if (whereInput) { const where = createWhereAndParams({ whereInput, @@ -133,6 +151,7 @@ function translateRead({ node, context }: { context: Context; node: Node }): [st authStr, ...(sortStr ? [`WITH ${varName}`, sortStr] : []), ...(projAuth ? [`WITH ${varName}`, projAuth] : []), + ...connectionStrs, `RETURN ${varName} ${projStr} as ${varName}`, skipStr, limitStr, diff --git a/packages/graphql/src/types.ts b/packages/graphql/src/types.ts index 07d15732ba..ee1c1a46da 100644 --- a/packages/graphql/src/types.ts +++ b/packages/graphql/src/types.ts @@ -93,6 +93,7 @@ export interface BaseField { description?: string; readonly?: boolean; writeonly?: boolean; + ignored?: boolean; } /** @@ -101,9 +102,15 @@ export interface BaseField { export interface RelationField extends BaseField { direction: "OUT" | "IN"; type: string; + properties?: string; union?: UnionField; } +export interface ConnectionField extends BaseField { + relationship: RelationField; + relationshipTypeName: string; +} + /** * Representation of the `@cypher` directive and its meta. */ @@ -146,6 +153,11 @@ export interface GraphQLSortArg { [field: string]: SortDirection; } +export interface ConnectionSortArg { + node?: GraphQLSortArg; + relationship?: GraphQLSortArg; +} + /** * Representation of the options arg * passed to resolvers. @@ -156,6 +168,10 @@ export interface GraphQLOptionsArg { sort?: GraphQLSortArg[]; } +export interface ConnectionOptionsArg { + sort?: ConnectionSortArg[]; +} + /** * Representation of the where arg * passed to resolvers. @@ -166,6 +182,15 @@ export interface GraphQLWhereArg { OR?: GraphQLWhereArg[]; } +export interface ConnectionWhereArg { + node?: GraphQLWhereArg; + node_NOT?: GraphQLWhereArg; + relationship?: GraphQLWhereArg; + relationship_NOT?: GraphQLWhereArg; + AND?: ConnectionWhereArg[]; + OR?: ConnectionWhereArg[]; +} + export type AuthOperations = "CREATE" | "READ" | "UPDATE" | "DELETE" | "CONNECT" | "DISCONNECT"; export type AuthOrders = "pre" | "post"; diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/mixed-nesting.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/mixed-nesting.md new file mode 100644 index 0000000000..bb3d8a8dec --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/mixed-nesting.md @@ -0,0 +1,177 @@ +## Mixed nesting + +Schema: + +```schema +type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +### Connection -> Relationship + +**GraphQL input** + +```graphql +query { + movies(where: { title: "Forrest Gump" }) { + title + actorsConnection(where: { node: { name: "Tom Hanks" } }) { + edges { + screenTime + node { + name + movies(where: { title_NOT: "Forrest Gump" }) { + title + } + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_actor.name = $this_actorsConnection.args.where.node.name + WITH collect({ + screenTime: this_acted_in.screenTime, + node: { + name: this_actor.name, + movies: [ (this_actor)-[:ACTED_IN]->(this_actor_movies:Movie) WHERE (NOT this_actor_movies.title = $this_actor_movies_title_NOT) | this_actor_movies { .title } ] + } + }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_title": "Forrest Gump", + "this_actor_movies_title_NOT": "Forrest Gump", + "this_actorsConnection": { + "args": { + "where": { + "node": { + "name": "Tom Hanks" + } + } + } + } +} +``` + +--- + +### Connection -> Connection -> Relationship + +**GraphQL input** + +```graphql +query { + movies(where: { title: "Forrest Gump" }) { + title + actorsConnection(where: { node: { name: "Tom Hanks" } }) { + edges { + screenTime + node { + name + moviesConnection( + where: { node: { title_NOT: "Forrest Gump" } } + ) { + edges { + node { + title + actors(where: { name_NOT: "Tom Hanks" }) { + name + } + } + } + } + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_actor.name = $this_actorsConnection.args.where.node.name + CALL { + WITH this_actor + MATCH (this_actor)-[this_actor_acted_in:ACTED_IN]->(this_actor_movie:Movie) + WHERE (NOT this_actor_movie.title = $this_actorsConnection.edges.node.moviesConnection.args.where.node.title_NOT) + WITH collect({ + node: { + title: this_actor_movie.title, + actors: [ (this_actor_movie)<-[:ACTED_IN]-(this_actor_movie_actors:Actor) WHERE (NOT this_actor_movie_actors.name = $this_actor_movie_actors_name_NOT) | this_actor_movie_actors { .name } ] + } + }) AS edges + RETURN { edges: edges } AS moviesConnection + } + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name, moviesConnection: moviesConnection } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_title": "Forrest Gump", + "this_actor_movie_actors_name_NOT": "Tom Hanks", + "this_actorsConnection": { + "args": { + "where": { + "node": { + "name": "Tom Hanks" + } + } + }, + "edges": { + "node": { + "moviesConnection": { + "args": { + "where": { + "node": { + "title_NOT": "Forrest Gump" + } + } + } + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/where.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/where.md new file mode 100644 index 0000000000..5c22a00d03 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/where.md @@ -0,0 +1,103 @@ +## Cursor Connections Where + +Schema: + +```schema +type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + firstName: String! + lastName: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +### Connection where + +**GraphQL input** + +```graphql +query { + movies(where: { title: "Forrest Gump" }) { + title + actorsConnection( + where: { + node: { AND: [{ firstName: "Tom" }, { lastName: "Hanks" }] } + relationship: { + AND: [{ screenTime_GT: 30 }, { screenTime_LT: 90 }] + } + } + ) { + edges { + screenTime + node { + firstName + lastName + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE ((this_actor.firstName = $this_actorsConnection.args.where.node.AND[0].firstName) AND (this_actor.lastName = $this_actorsConnection.args.where.node.AND[1].lastName)) AND ((this_acted_in.screenTime > $this_actorsConnection.args.where.relationship.AND[0].screenTime_GT) AND (this_acted_in.screenTime < $this_actorsConnection.args.where.relationship.AND[1].screenTime_LT)) + WITH collect({ screenTime: this_acted_in.screenTime, node: { firstName: this_actor.firstName, lastName: this_actor.lastName } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_title": "Forrest Gump", + "this_actorsConnection": { + "args": { + "where": { + "node": { + "AND": [ + { "firstName": "Tom" }, + { "lastName": "Hanks" } + ] + }, + "relationship": { + "AND": [ + { + "screenTime_GT": { + "high": 0, + "low": 30 + } + }, + { + "screenTime_LT": { + "high": 0, + "low": 90 + } + } + ] + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/relationship-properties.md b/packages/graphql/tests/tck/tck-test-files/cypher/relationship-properties.md new file mode 100644 index 0000000000..389e6b0e98 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/relationship-properties.md @@ -0,0 +1,297 @@ +## Relationship Properties Cypher + +Schema: + +```schema +type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +### Projecting node and relationship properties with no arguments + +**GraphQL input** + +```graphql +query { + movies(where: { title: "Forrest Gump" }) { + title + actorsConnection { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_title": "Forrest Gump" +} +``` + +--- + +### Projecting node and relationship properties with where argument + +**GraphQL input** + +```graphql +query { + movies(where: { title: "Forrest Gump" }) { + title + actorsConnection(where: { node: { name: "Tom Hanks" } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_actor.name = $this_actorsConnection.args.where.node.name + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_title": "Forrest Gump", + "this_actorsConnection": { + "args": { + "where": { + "node": { + "name": "Tom Hanks" + } + } + } + } +} +``` + +--- + +### Projecting node and relationship properties with sort argument + +**GraphQL input** + +```graphql +query { + movies(where: { title: "Forrest Gump" }) { + title + actorsConnection( + options: { sort: { relationship: { screenTime: DESC } } } + ) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WITH this_acted_in, this_actor + ORDER BY this_acted_in.screenTime DESC + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_title": "Forrest Gump" +} +``` + +--- + +### Projecting twice nested node and relationship properties with no arguments + +**GraphQL input** + +```graphql +query { + movies(where: { title: "Forrest Gump" }) { + title + actorsConnection { + edges { + screenTime + node { + name + moviesConnection { + edges { + screenTime + node { + title + } + } + } + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + CALL { + WITH this_actor + MATCH (this_actor)-[this_actor_acted_in:ACTED_IN]->(this_actor_movie:Movie) + WITH collect({ screenTime: this_actor_acted_in.screenTime, node: { title: this_actor_movie.title } }) AS edges + RETURN { edges: edges } AS moviesConnection + } + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name, moviesConnection: moviesConnection } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_title": "Forrest Gump" +} +``` + +--- + +### Projecting thrice nested node and relationship properties with no arguments + +**GraphQL input** + +```graphql +query { + movies(where: { title: "Forrest Gump" }) { + title + actorsConnection { + edges { + screenTime + node { + name + moviesConnection { + edges { + screenTime + node { + title + actorsConnection { + edges { + screenTime + node { + name + } + } + } + } + } + } + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + CALL { + WITH this_actor + MATCH (this_actor)-[this_actor_acted_in:ACTED_IN]->(this_actor_movie:Movie) + CALL { + WITH this_actor_movie + MATCH (this_actor_movie)<-[this_actor_movie_acted_in:ACTED_IN]-(this_actor_movie_actor:Actor) + WITH collect({ screenTime: this_actor_movie_acted_in.screenTime, node: { name: this_actor_movie_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection + } + WITH collect({ screenTime: this_actor_acted_in.screenTime, node: { title: this_actor_movie.title, actorsConnection: actorsConnection } }) AS edges + RETURN { edges: edges } AS moviesConnection + } + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name, moviesConnection: moviesConnection } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_title": "Forrest Gump" +} +``` + +--- 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 95452c9c36..50c7164219 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 @@ -475,6 +475,30 @@ type DeleteInfo { type Movie { title: String actors(options: ActorOptions, where: ActorWhere): [Actor] + actorsConnection(options: MovieActorsConnectionOptions, where: MovieActorsConnectionWhere): MovieActorsConnection! +} + +type MovieActorsRelationship { + node: Actor! +} + +type MovieActorsConnection { + edges: [MovieActorsRelationship!]! +} + +input MovieActorsConnectionOptions { + sort: [MovieActorsConnectionSort!] +} + +input MovieActorsConnectionSort { + node: ActorSort +} + +input MovieActorsConnectionWhere { + AND: [MovieActorsConnectionWhere!] + OR: [MovieActorsConnectionWhere!] + node: ActorWhere + node_NOT: ActorWhere } input MovieActorsFieldInput { 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 cb37b21423..0564c8991e 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/interfaces.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/interfaces.md @@ -49,9 +49,33 @@ type Movie implements Node { id: ID nodes: [Node] movies(options: MovieOptions, where: MovieWhere): [Movie] + moviesConnection(options: MovieMoviesConnectionOptions, where: MovieMoviesConnectionWhere): MovieMoviesConnection! customQuery: [Movie] } +type MovieMoviesRelationship { + node: Movie! +} + +type MovieMoviesConnection { + edges: [MovieMoviesRelationship!]! +} + +input MovieMoviesConnectionOptions { + sort: [MovieMoviesConnectionSort!] +} + +input MovieMoviesConnectionSort { + node: MovieSort +} + +input MovieMoviesConnectionWhere { + AND: [MovieMoviesConnectionWhere!] + OR: [MovieMoviesConnectionWhere!] + node: MovieWhere + node_NOT: MovieWhere +} + type DeleteInfo { nodesDeleted: Int! relationshipsDeleted: Int! 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 2f8c91bf15..f2806d4ae3 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 @@ -27,6 +27,52 @@ type TigerJawLevel2Part1 { **Output** ```schema-output +type TigerJawLevel2Part1Relationship { + node: TigerJawLevel2Part1! +} + +type TigerJawLevel2Part1Connection { + edges: [TigerJawLevel2Part1Relationship!]! +} + +input TigerJawLevel2Part1ConnectionSort { + node: TigerJawLevel2Part1Sort +} + +input TigerJawLevel2Part1ConnectionOptions { + sort: [TigerJawLevel2Part1ConnectionSort!] +} + +input TigerJawLevel2Part1ConnectionWhere { + AND: [TigerJawLevel2Part1ConnectionWhere!] + OR: [TigerJawLevel2Part1ConnectionWhere!] + node: TigerJawLevel2Part1Where + node_NOT: TigerJawLevel2Part1Where +} + +type TigerJawLevel2Part1TigerRelationship { + node: Tiger! +} + +type TigerJawLevel2Part1TigerConnection { + edges: [TigerJawLevel2Part1TigerRelationship!]! +} + +input TigerJawLevel2Part1TigerConnectionOptions { + sort: [TigerJawLevel2Part1TigerConnectionSort!] +} + +input TigerJawLevel2Part1TigerConnectionSort { + node: TigerSort +} + +input TigerJawLevel2Part1TigerConnectionWhere { + AND: [TigerJawLevel2Part1TigerConnectionWhere!] + OR: [TigerJawLevel2Part1TigerConnectionWhere!] + node: TigerWhere + node_NOT: TigerWhere +} + type CreateTigerJawLevel2Part1sMutationResponse { tigerJawLevel2Part1s: [TigerJawLevel2Part1!]! } @@ -103,6 +149,7 @@ input TigerDisconnectFieldInput { type TigerJawLevel2 { id: ID part1(where: TigerJawLevel2Part1Where, options: TigerJawLevel2Part1Options): TigerJawLevel2Part1 + part1Connection(options: TigerJawLevel2Part1ConnectionOptions, where: TigerJawLevel2Part1ConnectionWhere): TigerJawLevel2Part1Connection! } input TigerJawLevel2ConnectInput { @@ -134,6 +181,7 @@ input TigerJawLevel2Options { type TigerJawLevel2Part1 { id: ID tiger(where: TigerWhere, options: TigerOptions): Tiger + tigerConnection(options: TigerJawLevel2Part1TigerConnectionOptions, where: TigerJawLevel2Part1TigerConnectionWhere): TigerJawLevel2Part1TigerConnection! } input TigerJawLevel2Part1ConnectFieldInput { 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 new file mode 100644 index 0000000000..1d531cc4f9 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/schema/relationship-properties.md @@ -0,0 +1,358 @@ +## Schema Relationship Properties + +Tests that the provided typeDefs return the correct schema (with relationships). + +--- + +### Relationship Properties + +**TypeDefs** + +```typedefs-input +type Actor { + name: String! + movies: [Movie] @relationship(type: "ACTED_IN", direction: OUT, properties: "ActedIn") +} + +type Movie { + title: String! + actors: [Actor]! @relationship(type: "ACTED_IN", direction: IN, properties: "ActedIn") +} + +interface ActedIn { + screenTime: Int! +} +``` + +**Output** + +```schema-output +enum SortDirection { + """Sort by field values in ascending order.""" + ASC + """Sort by field values in descending order.""" + DESC +} + +type Actor { + name: String! + movies(where: MovieWhere, options: MovieOptions): [Movie] + moviesConnection(where: ActorMoviesConnectionWhere, options: ActorMoviesConnectionOptions): ActorMoviesConnection! +} + +input ActorConnectFieldInput { + where: ActorWhere + connect: ActorConnectInput +} + +input ActorConnectInput { + movies: [MovieConnectFieldInput!] +} + +input ActorCreateInput { + name: String! + movies: ActorMoviesFieldInput +} + +input ActorRelationInput { + movies: [MovieCreateInput!] +} + +input ActorDisconnectFieldInput { + where: ActorWhere + disconnect: ActorDisconnectInput +} + +input ActorDisconnectInput { + movies: [MovieDisconnectFieldInput!] +} + +input ActorMoviesFieldInput { + create: [MovieCreateInput!] + connect: [MovieConnectFieldInput!] +} + +input ActorMoviesUpdateFieldInput { + where: MovieWhere + update: MovieUpdateInput + connect: [MovieConnectFieldInput!] + create: [MovieCreateInput!] + disconnect: [MovieDisconnectFieldInput!] + delete: [MovieDeleteFieldInput!] +} + +input ActorOptions { + """Specify one or more ActorSort objects to sort Actors by. The sorts will be applied in the order in which they are arranged in the array.""" +sort: [ActorSort] + limit: Int + skip: Int +} + +"""Fields to sort Actors by. The order in which sorts are applied is not guaranteed when specifying many fields in one ActorSort object.""" +input ActorSort { + name: SortDirection +} + +input ActorUpdateInput { + name: String + movies: [ActorMoviesUpdateFieldInput!] +} + +input ActorWhere { + OR: [ActorWhere!] + AND: [ActorWhere!] + name: String + name_IN: [String] + name_NOT: String + name_NOT_IN: [String] + name_CONTAINS: String + name_NOT_CONTAINS: String + name_STARTS_WITH: String + name_NOT_STARTS_WITH: String + name_ENDS_WITH: String + name_NOT_ENDS_WITH: String + movies: MovieWhere + movies_NOT: MovieWhere +} + +type DeleteInfo { + nodesDeleted: Int! + relationshipsDeleted: Int! +} + +type Movie { + title: String! + actors(where: ActorWhere, options: ActorOptions): [Actor]! + actorsConnection(where: MovieActorsConnectionWhere, options: MovieActorsConnectionOptions): MovieActorsConnection! +} + +input MovieActorsFieldInput { + create: [ActorCreateInput!] + connect: [ActorConnectFieldInput!] +} + +input MovieActorsUpdateFieldInput { + where: ActorWhere + update: ActorUpdateInput + create: [ActorCreateInput!] + connect: [ActorConnectFieldInput!] + disconnect: [ActorDisconnectFieldInput!] + delete: [ActorDeleteFieldInput!] +} + +input MovieConnectFieldInput { + where: MovieWhere + connect: MovieConnectInput +} + +input MovieConnectInput { + actors: [ActorConnectFieldInput!] +} + +input MovieCreateInput { + title: String! + actors: MovieActorsFieldInput +} + +input MovieDisconnectFieldInput { + where: MovieWhere + disconnect: MovieDisconnectInput +} + +input MovieDisconnectInput { + actors: [ActorDisconnectFieldInput!] +} + +input MovieOptions { + """Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array.""" +sort: [MovieSort] + limit: Int + skip: Int +} + +input MovieRelationInput { + actors: [ActorCreateInput!] +} + +"""Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object.""" +input MovieSort { + title: SortDirection +} + +input MovieActorsDeleteFieldInput { + where: ActorWhere + delete: ActorDeleteInput +} + +input ActorMoviesDeleteFieldInput { + where: MovieWhere + delete: MovieDeleteInput +} + +input MovieDeleteInput { + actors: [MovieActorsDeleteFieldInput!] +} + +input MovieDeleteFieldInput { + where: MovieWhere + delete: MovieDeleteInput +} + +input ActorDeleteInput { + movies: [ActorMoviesDeleteFieldInput!] +} + +input ActorDeleteFieldInput { + where: ActorWhere + delete: ActorDeleteInput +} + +input MovieUpdateInput { + title: String + actors: [MovieActorsUpdateFieldInput!] +} + +input MovieWhere { + OR: [MovieWhere!] + AND: [MovieWhere!] + title: String + title_IN: [String] + title_NOT: String + title_NOT_IN: [String] + title_CONTAINS: String + title_NOT_CONTAINS: String + title_STARTS_WITH: String + title_NOT_STARTS_WITH: String + title_ENDS_WITH: String + title_NOT_ENDS_WITH: String + actors: ActorWhere + actors_NOT: ActorWhere +} + +interface ActedIn { + screenTime: Int! +} + +input ActedInWhere { + screenTime: Int + screenTime_NOT: Int + screenTime_LT: Int + screenTime_LTE: Int + screenTime_GT: Int + screenTime_GTE: Int + screenTime_IN: [Int] + screenTime_NOT_IN: [Int] + AND: [ActedInWhere!] + OR: [ActedInWhere!] +} + +input ActedInSort { + screenTime: SortDirection +} + +input ActorMoviesConnectionWhere { + relationship: ActedInWhere + relationship_NOT: ActedInWhere + node: MovieWhere + node_NOT: MovieWhere + AND: [ActorMoviesConnectionWhere!] + OR: [ActorMoviesConnectionWhere!] +} + +input ActorMoviesConnectionSort { + relationship: ActedInSort + node: MovieSort +} + +input ActorMoviesConnectionOptions { + sort: [ActorMoviesConnectionSort!] +} + +type ActorMoviesRelationship implements ActedIn { + screenTime: Int! + node: Movie! +} + +type ActorMoviesConnection { + edges: [ActorMoviesRelationship!]! +} + +input MovieActorsConnectionWhere { + relationship: ActedInWhere + relationship_NOT: ActedInWhere + node: ActorWhere + node_NOT: ActorWhere + AND: [MovieActorsConnectionWhere!] + OR: [MovieActorsConnectionWhere!] +} + +input MovieActorsConnectionSort { + relationship: ActedInSort + node: ActorSort +} + +input MovieActorsConnectionOptions { + sort: [MovieActorsConnectionSort!] +} + +type MovieActorsRelationship implements ActedIn { + screenTime: Int! + node: Actor! +} + +type MovieActorsConnection { + edges: [MovieActorsRelationship!]! +} + +type CreateMoviesMutationResponse { + movies: [Movie!]! +} + +type UpdateMoviesMutationResponse { + movies: [Movie!]! +} + +type CreateActorsMutationResponse { + actors: [Actor!]! +} + +type UpdateActorsMutationResponse { + actors: [Actor!]! +} + +type Mutation { + createActors(input: [ActorCreateInput!]!): CreateActorsMutationResponse! + deleteActors( + where: ActorWhere + delete: ActorDeleteInput + ): DeleteInfo! + updateActors( + where: ActorWhere + update: ActorUpdateInput + connect: ActorConnectInput + disconnect: ActorDisconnectInput + create: ActorRelationInput + delete: ActorDeleteInput + ): UpdateActorsMutationResponse! + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteMovies( + where: MovieWhere + delete: MovieDeleteInput + ): DeleteInfo! + updateMovies( + where: MovieWhere + update: MovieUpdateInput + connect: MovieConnectInput + disconnect: MovieDisconnectInput + create: MovieRelationInput + delete: MovieDeleteInput + ): UpdateMoviesMutationResponse! +} + +type Query { + actors(where: ActorWhere, options: ActorOptions): [Actor]! + movies(where: MovieWhere, options: MovieOptions): [Movie]! +} +``` + +--- 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 e080c87faf..c16daa41be 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/relationship.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/relationship.md @@ -80,6 +80,30 @@ input ActorDisconnectFieldInput { type Movie { id: ID actors(where: ActorWhere, options: ActorOptions): [Actor]! + actorsConnection(options: MovieActorsConnectionOptions, where: MovieActorsConnectionWhere): MovieActorsConnection! +} + +type MovieActorsRelationship { + node: Actor! +} + +type MovieActorsConnection { + edges: [MovieActorsRelationship!]! +} + +input MovieActorsConnectionOptions { + sort: [MovieActorsConnectionSort!] +} + +input MovieActorsConnectionSort { + node: ActorSort +} + +input MovieActorsConnectionWhere { + AND: [MovieActorsConnectionWhere!] + OR: [MovieActorsConnectionWhere!] + node: ActorWhere + node_NOT: ActorWhere } input MovieActorsFieldInput { @@ -228,6 +252,7 @@ enum SortDirection { type Actor { name: String movies(where: MovieWhere, options: MovieOptions): [Movie] + moviesConnection(options: ActorMoviesConnectionOptions, where: ActorMoviesConnectionWhere): ActorMoviesConnection! } input ActorConnectFieldInput { @@ -257,6 +282,29 @@ input ActorDisconnectInput { movies: [MovieDisconnectFieldInput!] } +type ActorMoviesRelationship { + node: Movie! +} + +type ActorMoviesConnection { + edges: [ActorMoviesRelationship!]! +} + +input ActorMoviesConnectionOptions { + sort: [ActorMoviesConnectionSort!] +} + +input ActorMoviesConnectionSort { + node: MovieSort +} + +input ActorMoviesConnectionWhere { + AND: [ActorMoviesConnectionWhere!] + OR: [ActorMoviesConnectionWhere!] + node: MovieWhere + node_NOT: MovieWhere +} + input ActorMoviesFieldInput { create: [MovieCreateInput!] connect: [MovieConnectFieldInput!] @@ -313,6 +361,30 @@ type DeleteInfo { type Movie { id: ID actors(where: ActorWhere, options: ActorOptions): [Actor]! + actorsConnection(options: MovieActorsConnectionOptions, where: MovieActorsConnectionWhere): MovieActorsConnection! +} + +type MovieActorsRelationship { + node: Actor! +} + +type MovieActorsConnection { + edges: [MovieActorsRelationship!]! +} + +input MovieActorsConnectionOptions { + sort: [MovieActorsConnectionSort!] +} + +input MovieActorsConnectionSort { + node: ActorSort +} + +input MovieActorsConnectionWhere { + AND: [MovieActorsConnectionWhere!] + OR: [MovieActorsConnectionWhere!] + node: ActorWhere + node_NOT: ActorWhere } input MovieActorsFieldInput { diff --git a/yarn.lock b/yarn.lock index d00e22bd79..a627e712e6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -942,6 +942,7 @@ __metadata: apollo-server: 2.21.0 camelcase: ^6.2.0 debug: ^4.3.1 + dedent: ^0.7.0 deep-equal: ^2.0.5 dot-prop: ^6.0.1 faker: 5.2.0 From 5834a926e54a2f464579401c6a314feb623d497e Mon Sep 17 00:00:00 2001 From: Darrell Warde Date: Fri, 14 May 2021 16:36:22 +0100 Subject: [PATCH 2/3] Connection fields now returned for both queries and mutations An array of new TCK tests validating the new filtering code and projections of connections --- .../src/schema/make-augmented-schema.ts | 53 ++- .../create-connection-and-params.ts | 2 +- .../graphql/src/translate/translate-create.ts | 48 ++- .../graphql/src/translate/translate-update.ts | 22 +- .../create-connection-where-and-params.ts | 12 +- .../src/translate/where/create-filter.test.ts | 64 +++ .../src/translate/where/create-filter.ts | 40 ++ .../where/create-node-where-and-params.ts | 39 +- .../create-relationship-where-and-params.ts | 107 ++--- .../relationship-properties/read.int.test.ts | 393 +++++++++++++++++ .../{where.md => filtering/composite.md} | 4 +- .../cypher/connections/filtering/node/and.md | 86 ++++ .../connections/filtering/node/arrays.md | 240 +++++++++++ .../connections/filtering/node/equality.md | 125 ++++++ .../connections/filtering/node/numerical.md | 246 +++++++++++ .../cypher/connections/filtering/node/or.md | 86 ++++ .../connections/filtering/node/points.md | 93 ++++ .../connections/filtering/node/string.md | 389 +++++++++++++++++ .../connections/filtering/relationship/and.md | 91 ++++ .../filtering/relationship/arrays.md | 262 ++++++++++++ .../filtering/relationship/equality.md | 131 ++++++ .../filtering/relationship/numerical.md | 241 +++++++++++ .../connections/filtering/relationship/or.md | 91 ++++ .../filtering/relationship/points.md | 93 ++++ .../filtering/relationship/string.md | 402 ++++++++++++++++++ .../cypher/connections/projections/create.md | 212 +++++++++ .../cypher/connections/projections/update.md | 67 +++ .../relationship-properties.md | 0 .../generate-test-cases-from-md.utils.ts | 6 +- 29 files changed, 3521 insertions(+), 124 deletions(-) rename packages/graphql/src/translate/{projection => }/where/create-connection-where-and-params.ts (90%) create mode 100644 packages/graphql/src/translate/where/create-filter.test.ts create mode 100644 packages/graphql/src/translate/where/create-filter.ts rename packages/graphql/src/translate/{projection => }/where/create-node-where-and-params.ts (87%) rename packages/graphql/src/translate/{projection => }/where/create-relationship-where-and-params.ts (58%) create mode 100644 packages/graphql/tests/integration/relationship-properties/read.int.test.ts rename packages/graphql/tests/tck/tck-test-files/cypher/connections/{where.md => filtering/composite.md} (97%) create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/and.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/arrays.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/equality.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/numerical.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/or.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/points.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/string.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/and.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/arrays.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/equality.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/numerical.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/or.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/points.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/string.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/projections/create.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/projections/update.md rename packages/graphql/tests/tck/tck-test-files/cypher/{ => connections}/relationship-properties.md (100%) diff --git a/packages/graphql/src/schema/make-augmented-schema.ts b/packages/graphql/src/schema/make-augmented-schema.ts index f7c6794539..44fe24cbc7 100644 --- a/packages/graphql/src/schema/make-augmented-schema.ts +++ b/packages/graphql/src/schema/make-augmented-schema.ts @@ -192,6 +192,13 @@ function makeAugmentedSchema( } }); + if (!pointInTypeDefs) { + pointInTypeDefs = nodeFields.pointFields.some((field) => field.typeMeta.name === "Point"); + } + if (!cartesianPointInTypeDefs) { + cartesianPointInTypeDefs = nodeFields.pointFields.some((field) => field.typeMeta.name === "CartesianPoint"); + } + const node = new Node({ name: definition.name.value, interfaces: nodeInterfaces, @@ -217,6 +224,13 @@ function makeAugmentedSchema( relationshipProperties.forEach((relationship) => { const relationshipFieldMeta = getRelationshipFieldMeta({ relationship }); + if (!pointInTypeDefs) { + pointInTypeDefs = relationshipFieldMeta.some((field) => field.typeMeta.name === "Point"); + } + if (!cartesianPointInTypeDefs) { + cartesianPointInTypeDefs = relationshipFieldMeta.some((field) => field.typeMeta.name === "CartesianPoint"); + } + relationshipFields.set(relationship.name.value, relationshipFieldMeta); const propertiesInterface = composer.createInterfaceTC({ @@ -263,6 +277,22 @@ function makeAugmentedSchema( }); }); + if (pointInTypeDefs) { + // Every field (apart from CRS) in Point needs a custom resolver + // to deconstruct the point objects we fetch from the database + composer.createObjectTC(point.point); + composer.createInputTC(point.pointInput); + composer.createInputTC(point.pointDistance); + } + + if (cartesianPointInTypeDefs) { + // Every field (apart from CRS) in CartesianPoint needs a custom resolver + // to deconstruct the point objects we fetch from the database + composer.createObjectTC(point.cartesianPoint); + composer.createInputTC(point.cartesianPointInput); + composer.createInputTC(point.cartesianPointDistance); + } + nodes.forEach((node) => { const nodeFields = objectFieldsToComposeFields([ ...node.primitiveFields, @@ -343,13 +373,6 @@ function makeAugmentedSchema( }, {}), ...[...node.primitiveFields, ...node.dateTimeFields, ...node.enumFields, ...node.pointFields].reduce( (res, f) => { - // This is the only sensible place to flag whether Point and CartesianPoint are used - if (f.typeMeta.name === "Point") { - pointInTypeDefs = true; - } else if (f.typeMeta.name === "CartesianPoint") { - cartesianPointInTypeDefs = true; - } - res[f.fieldName] = f.typeMeta.input.where.pretty; res[`${f.fieldName}_NOT`] = f.typeMeta.input.where.pretty; @@ -881,22 +904,6 @@ function makeAugmentedSchema( Object.keys(Scalars).forEach((scalar) => composer.addTypeDefs(`scalar ${scalar}`)); - if (pointInTypeDefs) { - // Every field (apart from CRS) in Point needs a custom resolver - // to deconstruct the point objects we fetch from the database - composer.createObjectTC(point.point); - composer.createInputTC(point.pointInput); - composer.createInputTC(point.pointDistance); - } - - if (cartesianPointInTypeDefs) { - // Every field (apart from CRS) in CartesianPoint needs a custom resolver - // to deconstruct the point objects we fetch from the database - composer.createObjectTC(point.cartesianPoint); - composer.createInputTC(point.cartesianPointInput); - composer.createInputTC(point.cartesianPointDistance); - } - if (!Object.values(composer.Mutation.getFields()).length) { composer.delete("Mutation"); } diff --git a/packages/graphql/src/translate/connection/create-connection-and-params.ts b/packages/graphql/src/translate/connection/create-connection-and-params.ts index a0a3f9159a..1f1ddfba5b 100644 --- a/packages/graphql/src/translate/connection/create-connection-and-params.ts +++ b/packages/graphql/src/translate/connection/create-connection-and-params.ts @@ -4,7 +4,7 @@ import { Node } from "../../classes"; import createProjectionAndParams from "../create-projection-and-params"; import Relationship from "../../classes/Relationship"; import createRelationshipPropertyElement from "../projection/elements/create-relationship-property-element"; -import createConnectionWhereAndParams from "../projection/where/create-connection-where-and-params"; +import createConnectionWhereAndParams from "../where/create-connection-where-and-params"; /* input: diff --git a/packages/graphql/src/translate/translate-create.ts b/packages/graphql/src/translate/translate-create.ts index acf8493a50..156560441c 100644 --- a/packages/graphql/src/translate/translate-create.ts +++ b/packages/graphql/src/translate/translate-create.ts @@ -22,10 +22,14 @@ import pluralize from "pluralize"; import { Node } from "../classes"; import createProjectionAndParams from "./create-projection-and-params"; import createCreateAndParams from "./create-create-and-params"; -import { Context } from "../types"; +import { Context, ConnectionField } from "../types"; import { AUTH_FORBIDDEN_ERROR } from "../constants"; +import createConnectionAndParams from "./connection/create-connection-and-params"; function translateCreate({ context, node }: { context: Context; node: Node }): [string, any] { + const connectionStrs: string[] = []; + let connectionParams: any; + const { resolveTree } = context; const { fieldsByTypeName } = resolveTree.fieldsByTypeName[`Create${pluralize(node.name)}MutationResponse`][ @@ -78,6 +82,44 @@ function translateCreate({ context, node }: { context: Context; node: Node }): [ return { ...res, [key.replace("REPLACE_ME", "projection")]: value }; }, {}); + if (projection[2]?.connectionFields?.length) { + projection[2].connectionFields.forEach((connectionResolveTree) => { + const connectionField = node.connectionFields.find( + (x) => x.fieldName === connectionResolveTree.name + ) as ConnectionField; + const connection = createConnectionAndParams({ + resolveTree: connectionResolveTree, + field: connectionField, + context, + nodeVariable: "REPLACE_ME", + }); + connectionStrs.push(connection[0]); + if (!connectionParams) connectionParams = {}; + connectionParams = { ...connectionParams, ...connection[1] }; + }); + } + + const replacedConnectionStrs = connectionStrs.length + ? createStrs.map((_, i) => { + return connectionStrs + .map((connectionStr) => { + return connectionStr.replace(/REPLACE_ME/g, `this${i}`); + }) + .join("\n"); + }) + : []; + + const replacedConnectionParams = connectionParams + ? createStrs.reduce((res1, _, i) => { + return { + ...res1, + ...Object.entries(connectionParams).reduce((res2, [key, value]) => { + return { ...res2, [key.replace("REPLACE_ME", `this${i}`)]: value }; + }, {}), + }; + }, {}) + : {}; + const projectionStr = createStrs .map( (_, i) => @@ -91,9 +133,9 @@ function translateCreate({ context, node }: { context: Context; node: Node }): [ .map((_, i) => projAuth.replace(/\$REPLACE_ME/g, "$projection").replace(/REPLACE_ME/g, `this${i}`)) .join("\n"); - const cypher = [`${createStrs.join("\n")}`, authCalls, `\nRETURN ${projectionStr}`]; + const cypher = [`${createStrs.join("\n")}`, authCalls, ...replacedConnectionStrs, `\nRETURN ${projectionStr}`]; - return [cypher.filter(Boolean).join("\n"), { ...params, ...replacedProjectionParams }]; + return [cypher.filter(Boolean).join("\n"), { ...params, ...replacedProjectionParams, ...replacedConnectionParams }]; } export default translateCreate; diff --git a/packages/graphql/src/translate/translate-update.ts b/packages/graphql/src/translate/translate-update.ts index 9a9c16c568..4f6b87d19c 100644 --- a/packages/graphql/src/translate/translate-update.ts +++ b/packages/graphql/src/translate/translate-update.ts @@ -20,7 +20,7 @@ import camelCase from "camelcase"; import pluralize from "pluralize"; import { Node } from "../classes"; -import { Context, GraphQLWhereArg, RelationField } from "../types"; +import { Context, GraphQLWhereArg, RelationField, ConnectionField } from "../types"; import createWhereAndParams from "./create-where-and-params"; import createProjectionAndParams from "./create-projection-and-params"; import createCreateAndParams from "./create-create-and-params"; @@ -30,6 +30,7 @@ import createConnectAndParams from "./create-connect-and-params"; import createDisconnectAndParams from "./create-disconnect-and-params"; import { AUTH_FORBIDDEN_ERROR } from "../constants"; import createDeleteAndParams from "./create-delete-and-params"; +import createConnectionAndParams from "./connection/create-connection-and-params"; function translateUpdate({ node, context }: { node: Node; context: Context }): [string, any] { const { resolveTree } = context; @@ -51,6 +52,8 @@ function translateUpdate({ node, context }: { node: Node; context: Context }): [ let projStr = ""; let cypherParams: { [k: string]: any } = {}; const whereStrs: string[] = []; + const connectionStrs: string[] = []; + const { fieldsByTypeName } = resolveTree.fieldsByTypeName[`Update${pluralize(node.name)}MutationResponse`][ pluralize(camelCase(node.name)) ]; @@ -190,6 +193,22 @@ function translateUpdate({ node, context }: { node: Node; context: Context }): [ )}), "${AUTH_FORBIDDEN_ERROR}", [0])`; } + if (projection[2]?.connectionFields?.length) { + projection[2].connectionFields.forEach((connectionResolveTree) => { + const connectionField = node.connectionFields.find( + (x) => x.fieldName === connectionResolveTree.name + ) as ConnectionField; + const connection = createConnectionAndParams({ + resolveTree: connectionResolveTree, + field: connectionField, + context, + nodeVariable: varName, + }); + connectionStrs.push(connection[0]); + cypherParams = { ...cypherParams, ...connection[1] }; + }); + } + const cypher = [ matchStr, whereStr, @@ -199,6 +218,7 @@ function translateUpdate({ node, context }: { node: Node; context: Context }): [ createStrs.join("\n"), deleteStr, ...(projAuth ? [`WITH ${varName}`, projAuth] : []), + ...connectionStrs, `RETURN ${varName} ${projStr} AS ${varName}`, ]; diff --git a/packages/graphql/src/translate/projection/where/create-connection-where-and-params.ts b/packages/graphql/src/translate/where/create-connection-where-and-params.ts similarity index 90% rename from packages/graphql/src/translate/projection/where/create-connection-where-and-params.ts rename to packages/graphql/src/translate/where/create-connection-where-and-params.ts index 6c619ff548..0a72953172 100644 --- a/packages/graphql/src/translate/projection/where/create-connection-where-and-params.ts +++ b/packages/graphql/src/translate/where/create-connection-where-and-params.ts @@ -1,5 +1,5 @@ -import { Node, Relationship } from "../../../classes"; -import { ConnectionWhereArg, Context } from "../../../types"; +import { Node, Relationship } from "../../classes"; +import { ConnectionWhereArg, Context } from "../../types"; import createRelationshipWhereAndParams from "./create-relationship-where-and-params"; import createNodeWhereAndParams from "./create-node-where-and-params"; @@ -73,6 +73,7 @@ function createConnectionWhereAndParams({ if (whereInput.AND) { const innerClauses: string[] = []; + const innerParams: any[] = []; whereInput.AND.forEach((a, i) => { const and = createConnectionWhereAndParams({ @@ -86,14 +87,16 @@ function createConnectionWhereAndParams({ }); innerClauses.push(`${and[0]}`); - params = { ...params, ...and[1] }; + innerParams.push(and[1]); }); whereStrs.push(`(${innerClauses.join(" AND ")})`); + params = { ...params, AND: innerParams }; } if (whereInput.OR) { const innerClauses: string[] = []; + const innerParams: any[] = []; whereInput.OR.forEach((o, i) => { const or = createConnectionWhereAndParams({ @@ -107,10 +110,11 @@ function createConnectionWhereAndParams({ }); innerClauses.push(`${or[0]}`); - params = { ...params, ...or[1] }; + innerParams.push(or[1]); }); whereStrs.push(`(${innerClauses.join(" OR ")})`); + params = { ...params, OR: innerParams }; } return [whereStrs.join(" AND "), params]; diff --git a/packages/graphql/src/translate/where/create-filter.test.ts b/packages/graphql/src/translate/where/create-filter.test.ts new file mode 100644 index 0000000000..9770c8b091 --- /dev/null +++ b/packages/graphql/src/translate/where/create-filter.test.ts @@ -0,0 +1,64 @@ +import createFilter, { Operator } from "./create-filter"; + +describe("createFilter", () => { + const left = "left"; + const right = "right"; + const notFilters = ["INCLUDES", "IN", "CONTAINS", "STARTS_WITH", "ENDS_WITH"]; + + const validFilters = [ + ...Object.entries(Operator).map(([k, v]) => ({ + input: { + left, + operator: k, + right, + }, + expected: `${left} ${v} ${right}`, + })), + ...Object.entries(Operator) + .filter(([k]) => notFilters.includes(k)) + .map(([k, v]) => ({ + input: { + left, + operator: k, + right, + not: true, + }, + expected: `(NOT ${left} ${v} ${right})`, + })), + ]; + + validFilters.forEach((valid) => { + test(`should create filter ${valid.input.operator}`, () => { + const filter = createFilter(valid.input); + expect(filter).toBe(valid.expected); + }); + }); + + const invalidFilters = [ + { + input: { + left, + operator: "UNKNOWN", + right, + }, + expectedErrorMessage: `Invalid filter operator UNKNOWN`, + }, + ...Object.keys(Operator) + .filter((k) => !notFilters.includes(k)) + .map((k) => ({ + input: { + left, + operator: k, + right, + not: true, + }, + expectedErrorMessage: `Invalid filter operator NOT_${k}`, + })), + ]; + + invalidFilters.forEach((invalid) => { + test(`should throw an error for filter ${invalid.input.operator}`, () => { + expect(() => createFilter(invalid.input)).toThrow(invalid.expectedErrorMessage); + }); + }); +}); diff --git a/packages/graphql/src/translate/where/create-filter.ts b/packages/graphql/src/translate/where/create-filter.ts new file mode 100644 index 0000000000..4030ee4e84 --- /dev/null +++ b/packages/graphql/src/translate/where/create-filter.ts @@ -0,0 +1,40 @@ +export enum Operator { + INCLUDES = "IN", + IN = "IN", + MATCHES = "=~", + CONTAINS = "CONTAINS", + STARTS_WITH = "STARTS WITH", + ENDS_WITH = "ENDS WITH", + LT = "<", + GT = ">", + GTE = ">=", + LTE = "<=", + DISTANCE = "=", +} + +function createFilter({ + left, + operator, + right, + not, +}: { + left: string; + operator: string; + right: string; + not?: boolean; +}): string { + if (!Operator[operator]) { + throw new Error(`Invalid filter operator ${operator}`); + } + + if (not && ["MATCHES", "LT", "GT", "GTE", "LTE", "DISTANCE"].includes(operator)) { + throw new Error(`Invalid filter operator NOT_${operator}`); + } + + let filter = `${left} ${Operator[operator]} ${right}`; + if (not) filter = `(NOT ${filter})`; + + return filter; +} + +export default createFilter; diff --git a/packages/graphql/src/translate/projection/where/create-node-where-and-params.ts b/packages/graphql/src/translate/where/create-node-where-and-params.ts similarity index 87% rename from packages/graphql/src/translate/projection/where/create-node-where-and-params.ts rename to packages/graphql/src/translate/where/create-node-where-and-params.ts index 90d6ffbed9..65b8665c3e 100644 --- a/packages/graphql/src/translate/projection/where/create-node-where-and-params.ts +++ b/packages/graphql/src/translate/where/create-node-where-and-params.ts @@ -17,8 +17,9 @@ * limitations under the License. */ -import { GraphQLWhereArg, Context } from "../../../types"; -import { Node } from "../../../classes"; +import { GraphQLWhereArg, Context } from "../../types"; +import { Node } from "../../classes"; +import createFilter from "./create-filter"; interface Res { clauses: string[]; @@ -146,19 +147,17 @@ function createNodeWhereAndParams({ } if (value === null) { - res.clauses.push( - not ? `${nodeVariable}.${fieldName} IS NOT NULL` : `${nodeVariable}.${fieldName} IS NULL` - ); + res.clauses.push(not ? `${property} IS NOT NULL` : `${property} IS NULL`); return res; } if (pointField) { if (pointField.typeMeta.array) { - let clause = `${nodeVariable}.${fieldName} = [p in $${param} | point(p)]`; + let clause = `${property} = [p in $${param} | point(p)]`; if (not) clause = `(NOT ${clause})`; res.clauses.push(clause); } else { - let clause = `${nodeVariable}.${fieldName} = point($${param})`; + let clause = `${property} = point($${param})`; if (not) clause = `(NOT ${clause})`; res.clauses.push(clause); } @@ -212,14 +211,20 @@ function createNodeWhereAndParams({ resultStr += ")"; // close ALL res.clauses.push(resultStr); res.params = { ...res.params, fieldName: nestedParams }; - } else if (pointField) { - let clause = `${nodeVariable}.${fieldName} IN [p in $${param} | point(p)]`; - if (not) clause = `(NOT ${clause})`; - res.clauses.push(clause); - res.params[key] = value; + // } else if (pointField) { + // let clause = `${property} IN [p in $${param} | point(p)]`; + // if (not) clause = `(NOT ${clause})`; + // res.clauses.push(clause); + // res.params[key] = value; } else { - let clause = `${property} IN $${param}`; - if (not) clause = `(NOT ${clause})`; + // let clause = `${property} IN $${param}`; + // if (not) clause = `(NOT ${clause})`; + const clause = createFilter({ + left: property, + operator, + right: pointField ? `[p in $${param} | point(p)]` : `$${param}`, + not, + }); res.clauses.push(clause); res.params[key] = value; } @@ -228,7 +233,7 @@ function createNodeWhereAndParams({ } if (operator === "INCLUDES") { - let clause = pointField ? `point($${param}) IN ${nodeVariable}.${fieldName}` : `$${param} IN ${property}`; + let clause = pointField ? `point($${param}) IN ${property}` : `$${param} IN ${property}`; if (not) clause = `(NOT ${clause})`; @@ -256,7 +261,7 @@ function createNodeWhereAndParams({ if (operator && ["LT", "LTE", "GTE", "GT"].includes(operator)) { res.clauses.push( pointField - ? `distance(${nodeVariable}.${fieldName}, point($${param}.point)) ${operators[operator]} $${param}.distance` + ? `distance(${property}, point($${param}.point)) ${operators[operator]} $${param}.distance` : `${property} ${operators[operator]} $${param}` ); res.params[key] = value; @@ -264,7 +269,7 @@ function createNodeWhereAndParams({ } if (key.endsWith("_DISTANCE")) { - res.clauses.push(`distance(${nodeVariable}.${fieldName}, point($${param}.point)) = $${param}.distance`); + res.clauses.push(`distance(${property}, point($${param}.point)) = $${param}.distance`); res.params[key] = value; return res; diff --git a/packages/graphql/src/translate/projection/where/create-relationship-where-and-params.ts b/packages/graphql/src/translate/where/create-relationship-where-and-params.ts similarity index 58% rename from packages/graphql/src/translate/projection/where/create-relationship-where-and-params.ts rename to packages/graphql/src/translate/where/create-relationship-where-and-params.ts index ce262be013..327ad8d34b 100644 --- a/packages/graphql/src/translate/projection/where/create-relationship-where-and-params.ts +++ b/packages/graphql/src/translate/where/create-relationship-where-and-params.ts @@ -1,5 +1,6 @@ -import Relationship from "../../../classes/Relationship"; -import { GraphQLWhereArg, Context, PrimitiveField } from "../../../types"; +import Relationship from "../../classes/Relationship"; +import { GraphQLWhereArg, Context, PrimitiveField } from "../../types"; +import createFilter from "./create-filter"; interface Res { clauses: string[]; @@ -12,15 +13,12 @@ function createRelationshipWhereAndParams({ relationship, relationshipVariable, parameterPrefix, -}: // chainStr, -{ +}: { whereInput: GraphQLWhereArg; context: Context; relationship: Relationship; relationshipVariable: string; parameterPrefix: string; - // authValidateStrs?: string[]; - // chainStr?: string; }): [string, any] { if (!Object.keys(whereInput).length) { return ["", {}]; @@ -29,20 +27,6 @@ function createRelationshipWhereAndParams({ function reducer(res: Res, [key, value]: [string, GraphQLWhereArg]): Res { const param = `${parameterPrefix}.${key}`; - const operators = { - INCLUDES: "IN", - IN: "IN", - MATCHES: "=~", - CONTAINS: "CONTAINS", - STARTS_WITH: "STARTS WITH", - ENDS_WITH: "ENDS WITH", - LT: "<", - GT: ">", - GTE: ">=", - LTE: "<=", - DISTANCE: "=", - }; - const re = /(?[_A-Za-z][_0-9A-Za-z]*?)(?:_(?NOT))?(?:_(?INCLUDES|IN|MATCHES|CONTAINS|STARTS_WITH|ENDS_WITH|LT|GT|GTE|LTE|DISTANCE))?$/gm; const match = re.exec(key); @@ -73,14 +57,11 @@ function createRelationshipWhereAndParams({ whereInput: v, relationship, relationshipVariable, - // chainStr: `${param}${i > 0 ? i : ""}`, context, - // recursing: true, parameterPrefix: `${parameterPrefix}.${fieldName}[${i}]`, }); innerClauses.push(`(${recurse[0]})`); - // res.params = { ...res.params, ...recurse[1] }; nestedParams.push(recurse[1]); }); @@ -93,21 +74,17 @@ function createRelationshipWhereAndParams({ // Equality/inequality if (!operator) { if (value === null) { - res.clauses.push( - not - ? `${relationshipVariable}.${fieldName} IS NOT NULL` - : `${relationshipVariable}.${fieldName} IS NULL` - ); + res.clauses.push(not ? `${property} IS NOT NULL` : `${property} IS NULL`); return res; } if (pointField) { if (pointField.typeMeta.array) { - let clause = `${relationshipVariable}.${fieldName} = [p in $${param} | point(p)]`; + let clause = `${property} = [p in $${param} | point(p)]`; if (not) clause = `(NOT ${clause})`; res.clauses.push(clause); } else { - let clause = `${relationshipVariable}.${fieldName} = point($${param})`; + let clause = `${property} = point($${param})`; if (not) clause = `(NOT ${clause})`; res.clauses.push(clause); } @@ -122,65 +99,51 @@ function createRelationshipWhereAndParams({ } if (operator === "IN") { - if (pointField) { - let clause = `${relationshipVariable}.${fieldName} IN [p in $${param} | point(p)]`; - if (not) clause = `(NOT ${clause})`; - res.clauses.push(clause); - res.params[key] = value; - } else { - let clause = `${property} IN $${param}`; - if (not) clause = `(NOT ${clause})`; - res.clauses.push(clause); - res.params[key] = value; - } - - return res; - } - - if (operator === "INCLUDES") { - let clause = pointField - ? `point($${param}) IN ${relationshipVariable}.${fieldName}` - : `$${param} IN ${property}`; - - if (not) clause = `(NOT ${clause})`; - + const clause = createFilter({ + left: property, + operator, + right: pointField ? `[p in $${param} | point(p)]` : `$${param}`, + not, + }); res.clauses.push(clause); res.params[key] = value; return res; } - if (key.endsWith("_MATCHES")) { - res.clauses.push(`${property} =~ $${param}`); + if (operator === "INCLUDES") { + const clause = createFilter({ + left: pointField ? `point($${param})` : `$${param}`, + operator, + right: property, + not, + }); + res.clauses.push(clause); res.params[key] = value; return res; } - if (operator && ["CONTAINS", "STARTS_WITH", "ENDS_WITH"].includes(operator)) { - let clause = `${property} ${operators[operator]} $${param}`; - if (not) clause = `(NOT ${clause})`; + if (operator && ["MATCHES", "CONTAINS", "STARTS_WITH", "ENDS_WITH"].includes(operator)) { + const clause = createFilter({ + left: property, + operator, + right: `$${param}`, + not, + }); res.clauses.push(clause); res.params[key] = value; return res; } - if (operator && ["LT", "LTE", "GTE", "GT"].includes(operator)) { - res.clauses.push( - pointField - ? `distance(${relationshipVariable}.${fieldName}, point($${param}.point)) ${operators[operator]} $${param}.distance` - : `${property} ${operators[operator]} $${param}` - ); - res.params[key] = value; - return res; - } - - if (key.endsWith("_DISTANCE")) { - res.clauses.push( - `distance(${relationshipVariable}.${fieldName}, point($${param}.point)) = $${param}.distance` - ); + if (operator && ["DISTANCE", "LT", "LTE", "GTE", "GT"].includes(operator)) { + const clause = createFilter({ + left: pointField ? `distance(${property}, point($${param}.point))` : property, + operator, + right: pointField ? `$${param}.distance` : `$${param}`, + }); + res.clauses.push(clause); res.params[key] = value; - return res; } diff --git a/packages/graphql/tests/integration/relationship-properties/read.int.test.ts b/packages/graphql/tests/integration/relationship-properties/read.int.test.ts new file mode 100644 index 0000000000..e4458eab35 --- /dev/null +++ b/packages/graphql/tests/integration/relationship-properties/read.int.test.ts @@ -0,0 +1,393 @@ +/* + * 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("Relationship properties - read", () => { + let driver: Driver; + const typeDefs = gql` + type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) + } + + type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) + } + + interface ActedIn { + screenTime: Int! + } + `; + + beforeAll(async () => { + driver = await neo4j(); + const session = driver.session(); + + try { + await session.run( + "CREATE (:Actor { name: 'Tom Hanks' })-[:ACTED_IN { screenTime: 105 }]->(:Movie { title: 'Forrest Gump'})" + ); + // Another couple of actors to test sorting and filtering + await session.run( + "MATCH (m:Movie) WHERE m.title = 'Forrest Gump' CREATE (m)<-[:ACTED_IN { screenTime: 105 }]-(:Actor { name: 'Robin Wright' })" + ); + await session.run( + "MATCH (m:Movie) WHERE m.title = 'Forrest Gump' CREATE (m)<-[:ACTED_IN { screenTime: 5 }]-(:Actor { name: 'Sally Field' })" + ); + } finally { + await session.close(); + } + }); + + afterAll(async () => { + const session = driver.session(); + + try { + await session.run("MATCH (a:Actor) WHERE a.name = 'Tom Hanks' DETACH DELETE a"); + await session.run("MATCH (a:Actor) WHERE a.name = 'Robin Wright' DETACH DELETE a"); + await session.run("MATCH (a:Actor) WHERE a.name = 'Sally Field' DETACH DELETE a"); + await session.run("MATCH (m:Movie) WHERE m.title = 'Forrest Gump' DETACH DELETE m"); + } finally { + await session.close(); + } + + await driver.close(); + }); + + test("Projecting node and relationship properties with no arguments", async () => { + const session = driver.session(); + + const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); + + const query = ` + query { + movies(where: { title: "Forrest Gump" }) { + title + actorsConnection { + edges { + screenTime + node { + name + } + } + } + } + } + `; + + try { + await neoSchema.checkNeo4jCompat(); + + const result = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver }, + }); + + expect(result.errors).toBeFalsy(); + + expect(result?.data?.movies).toEqual([ + { + title: "Forrest Gump", + actorsConnection: { + edges: [ + { + screenTime: 5, + node: { + name: "Sally Field", + }, + }, + { + screenTime: 105, + node: { + name: "Robin Wright", + }, + }, + { + screenTime: 105, + node: { + name: "Tom Hanks", + }, + }, + ], + }, + }, + ]); + } finally { + await session.close(); + } + }); + + test("With `where` argument", async () => { + const session = driver.session(); + + const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); + + const query = ` + query { + movies(where: { title: "Forrest Gump" }) { + title + actorsConnection( + where: { AND: [{ relationship: { screenTime_GT: 60 } }, { node: { name_STARTS_WITH: "Tom" } }] } + ) { + edges { + screenTime + node { + name + } + } + } + } + } + `; + + try { + await neoSchema.checkNeo4jCompat(); + + const result = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver }, + }); + + expect(result.errors).toBeFalsy(); + + expect(result?.data?.movies).toEqual([ + { + title: "Forrest Gump", + actorsConnection: { + edges: [ + { + screenTime: 105, + node: { + name: "Tom Hanks", + }, + }, + ], + }, + }, + ]); + } finally { + await session.close(); + } + }); + + test("With `sort` argument", async () => { + const session = driver.session(); + + const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); + + const query = ` + query ConnectionWithSort($nameSort: SortDirection) { + movies(where: { title: "Forrest Gump" }) { + title + actorsConnection( + options: { sort: [{ relationship: { screenTime: DESC } }, { node: { name: $nameSort } }] } + ) { + edges { + screenTime + node { + name + } + } + } + } + } + `; + + try { + await neoSchema.checkNeo4jCompat(); + + const ascResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver }, + variableValues: { nameSort: "ASC" }, + }); + + expect(ascResult.errors).toBeFalsy(); + + expect(ascResult?.data?.movies).toEqual([ + { + title: "Forrest Gump", + actorsConnection: { + edges: [ + { + screenTime: 105, + node: { + name: "Robin Wright", + }, + }, + { + screenTime: 105, + node: { + name: "Tom Hanks", + }, + }, + { + screenTime: 5, + node: { + name: "Sally Field", + }, + }, + ], + }, + }, + ]); + + const descResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver }, + variableValues: { nameSort: "DESC" }, + }); + + expect(descResult.errors).toBeFalsy(); + + expect(descResult?.data?.movies).toEqual([ + { + title: "Forrest Gump", + actorsConnection: { + edges: [ + { + screenTime: 105, + node: { + name: "Tom Hanks", + }, + }, + { + screenTime: 105, + node: { + name: "Robin Wright", + }, + }, + { + screenTime: 5, + node: { + name: "Sally Field", + }, + }, + ], + }, + }, + ]); + } finally { + await session.close(); + } + }); + + test("With `where` and `sort` arguments", async () => { + const session = driver.session(); + + const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); + + const query = ` + query ConnectionWithSort($nameSort: SortDirection) { + movies(where: { title: "Forrest Gump" }) { + title + actorsConnection( + where: { relationship: { screenTime_GT: 60 } } + options: { sort: [{ node: { name: $nameSort } }] } + ) { + edges { + screenTime + node { + name + } + } + } + } + } + `; + + try { + await neoSchema.checkNeo4jCompat(); + + const ascResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver }, + variableValues: { nameSort: "ASC" }, + }); + + expect(ascResult.errors).toBeFalsy(); + + expect(ascResult?.data?.movies).toEqual([ + { + title: "Forrest Gump", + actorsConnection: { + edges: [ + { + screenTime: 105, + node: { + name: "Robin Wright", + }, + }, + { + screenTime: 105, + node: { + name: "Tom Hanks", + }, + }, + ], + }, + }, + ]); + + const descResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver }, + variableValues: { nameSort: "DESC" }, + }); + + expect(descResult.errors).toBeFalsy(); + + expect(descResult?.data?.movies).toEqual([ + { + title: "Forrest Gump", + actorsConnection: { + edges: [ + { + screenTime: 105, + node: { + name: "Tom Hanks", + }, + }, + { + screenTime: 105, + node: { + name: "Robin Wright", + }, + }, + ], + }, + }, + ]); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/where.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/composite.md similarity index 97% rename from packages/graphql/tests/tck/tck-test-files/cypher/connections/where.md rename to packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/composite.md index 5c22a00d03..a9e5a763e2 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/connections/where.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/composite.md @@ -1,4 +1,4 @@ -## Cursor Connections Where +## Cypher -> Connections -> Filtering -> Composite Schema: @@ -21,7 +21,7 @@ interface ActedIn { --- -### Connection where +### Composite **GraphQL input** diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/and.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/and.md new file mode 100644 index 0000000000..e0bb4c24d6 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/and.md @@ -0,0 +1,86 @@ +## Cypher -> Connections -> Filtering -> Node -> AND + +Schema: + +```schema +type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + firstName: String! + lastName: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +### AND + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection( + where: { + node: { AND: [{ firstName: "Tom" }, { lastName: "Hanks" }] } + } + ) { + edges { + screenTime + node { + firstName + lastName + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE ((this_actor.firstName = $this_actorsConnection.args.where.node.AND[0].firstName) AND (this_actor.lastName = $this_actorsConnection.args.where.node.AND[1].lastName)) + WITH collect({ screenTime: this_acted_in.screenTime, node: { firstName: this_actor.firstName, lastName: this_actor.lastName } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "AND": [ + { + "firstName": "Tom" + }, + { + "lastName": "Hanks" + } + ] + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/arrays.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/arrays.md new file mode 100644 index 0000000000..5f54d03e6d --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/arrays.md @@ -0,0 +1,240 @@ +## Cypher -> Connections -> Filtering -> Node -> Arrays + +Schema: + +```schema +type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + favouriteColours: [String!] + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +### IN + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection( + where: { node: { name_IN: ["Tom Hanks", "Robin Wright"] } } + ) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_actor.name IN $this_actorsConnection.args.where.node.name_IN + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "name_IN": ["Tom Hanks", "Robin Wright"] + } + } + } + } +} +``` + +--- + +### NOT_IN + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection( + where: { node: { name_NOT_IN: ["Tom Hanks", "Robin Wright"] } } + ) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE (NOT this_actor.name IN $this_actorsConnection.args.where.node.name_NOT_IN) + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "name_NOT_IN": ["Tom Hanks", "Robin Wright"] + } + } + } + } +} +``` + +--- + +### INCLUDES + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection( + where: { node: { favouriteColours_INCLUDES: "Blue" } } + ) { + edges { + screenTime + node { + name + favouriteColours + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE $this_actorsConnection.args.where.node.favouriteColours_INCLUDES IN this_actor.favouriteColours + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name, favouriteColours: this_actor.favouriteColours } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "favouriteColours_INCLUDES": "Blue" + } + } + } + } +} +``` + +--- + +### NOT_INCLUDES + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection( + where: { node: { favouriteColours_NOT_INCLUDES: "Blue" } } + ) { + edges { + screenTime + node { + name + favouriteColours + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE (NOT $this_actorsConnection.args.where.node.favouriteColours_NOT_INCLUDES IN this_actor.favouriteColours) + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name, favouriteColours: this_actor.favouriteColours } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "favouriteColours_NOT_INCLUDES": "Blue" + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/equality.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/equality.md new file mode 100644 index 0000000000..5ee9fc0e5e --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/equality.md @@ -0,0 +1,125 @@ +## Cypher -> Connections -> Filtering -> Node -> Equality + +Schema: + +```schema +type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +### Equality + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection(where: { node: { name: "Tom Hanks" } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_actor.name = $this_actorsConnection.args.where.node.name + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "name": "Tom Hanks" + } + } + } + } +} +``` + +--- + +### Inequality + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection(where: { node: { name_NOT: "Tom Hanks" } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE (NOT this_actor.name = $this_actorsConnection.args.where.node.name_NOT) + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "name_NOT": "Tom Hanks" + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/numerical.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/numerical.md new file mode 100644 index 0000000000..a414f673db --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/numerical.md @@ -0,0 +1,246 @@ +## Cypher -> Connections -> Filtering -> Node -> Numerical + +Schema: + +```schema +type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + age: Int! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +### LT + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection(where: { node: { age_LT: 60 } }) { + edges { + screenTime + node { + name + age + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_actor.age < $this_actorsConnection.args.where.node.age_LT + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name, age: this_actor.age } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "age_LT": { + "high": 0, + "low": 60 + } + } + } + } + } +} +``` + +--- + +### LTE + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection(where: { node: { age_LTE: 60 } }) { + edges { + screenTime + node { + name + age + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_actor.age <= $this_actorsConnection.args.where.node.age_LTE + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name, age: this_actor.age } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "age_LTE": { + "high": 0, + "low": 60 + } + } + } + } + } +} +``` + +--- + +### GT + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection(where: { node: { age_GT: 60 } }) { + edges { + screenTime + node { + name + age + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_actor.age > $this_actorsConnection.args.where.node.age_GT + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name, age: this_actor.age } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "age_GT": { + "high": 0, + "low": 60 + } + } + } + } + } +} +``` + +--- + +### GTE + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection(where: { node: { age_GTE: 60 } }) { + edges { + screenTime + node { + name + age + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_actor.age >= $this_actorsConnection.args.where.node.age_GTE + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name, age: this_actor.age } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "age_GTE": { + "high": 0, + "low": 60 + } + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/or.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/or.md new file mode 100644 index 0000000000..8249d39ee2 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/or.md @@ -0,0 +1,86 @@ +## Cypher -> Connections -> Filtering -> Node -> OR + +Schema: + +```schema +type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + firstName: String! + lastName: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +### OR + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection( + where: { + node: { OR: [{ firstName: "Tom" }, { lastName: "Hanks" }] } + } + ) { + edges { + screenTime + node { + firstName + lastName + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE ((this_actor.firstName = $this_actorsConnection.args.where.node.OR[0].firstName) OR (this_actor.lastName = $this_actorsConnection.args.where.node.OR[1].lastName)) + WITH collect({ screenTime: this_acted_in.screenTime, node: { firstName: this_actor.firstName, lastName: this_actor.lastName } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "OR": [ + { + "firstName": "Tom" + }, + { + "lastName": "Hanks" + } + ] + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/points.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/points.md new file mode 100644 index 0000000000..40bfe22d01 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/points.md @@ -0,0 +1,93 @@ +## Cypher -> Connections -> Filtering -> Node -> Points + +Schema: + +```schema +type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + currentLocation: Point! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +### DISTANCE + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection( + where: { + node: { + currentLocation_DISTANCE: { + point: { longitude: 1.0, latitude: 2.0 } + distance: 3.0 + } + } + } + ) { + edges { + screenTime + node { + name + currentLocation { + latitude + longitude + } + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE distance(this_actor.currentLocation, point($this_actorsConnection.args.where.node.currentLocation_DISTANCE.point)) = $this_actorsConnection.args.where.node.currentLocation_DISTANCE.distance + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name, currentLocation: { point: this_actor.currentLocation } } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "currentLocation_DISTANCE": { + "distance": 3, + "point": { + "latitude": 2, + "longitude": 1 + } + } + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/string.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/string.md new file mode 100644 index 0000000000..d600f20b5a --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/string.md @@ -0,0 +1,389 @@ +## Cypher -> Connections -> Filtering -> Node -> String + +Schema: + +```schema +type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +```env +NEO4J_GRAPHQL_ENABLE_REGEX=1 +``` + +--- + +### CONTAINS + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection(where: { node: { name_CONTAINS: "Tom" } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_actor.name CONTAINS $this_actorsConnection.args.where.node.name_CONTAINS + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "name_CONTAINS": "Tom" + } + } + } + } +} +``` + +--- + +### NOT_CONTAINS + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection(where: { node: { name_NOT_CONTAINS: "Tom" } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE (NOT this_actor.name CONTAINS $this_actorsConnection.args.where.node.name_NOT_CONTAINS) + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "name_NOT_CONTAINS": "Tom" + } + } + } + } +} +``` + +--- + +### STARTS_WITH + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection(where: { node: { name_STARTS_WITH: "Tom" } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_actor.name STARTS WITH $this_actorsConnection.args.where.node.name_STARTS_WITH + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "name_STARTS_WITH": "Tom" + } + } + } + } +} +``` + +--- + +### NOT_STARTS_WITH + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection(where: { node: { name_NOT_STARTS_WITH: "Tom" } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE (NOT this_actor.name STARTS WITH $this_actorsConnection.args.where.node.name_NOT_STARTS_WITH) + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "name_NOT_STARTS_WITH": "Tom" + } + } + } + } +} +``` + +--- + +### ENDS_WITH + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection(where: { node: { name_ENDS_WITH: "Hanks" } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_actor.name ENDS WITH $this_actorsConnection.args.where.node.name_ENDS_WITH + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "name_ENDS_WITH": "Hanks" + } + } + } + } +} +``` + +--- + +### NOT_ENDS_WITH + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection(where: { node: { name_NOT_ENDS_WITH: "Hanks" } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE (NOT this_actor.name ENDS WITH $this_actorsConnection.args.where.node.name_NOT_ENDS_WITH) + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "name_NOT_ENDS_WITH": "Hanks" + } + } + } + } +} +``` + +--- + +### MATCHES + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection(where: { node: { name_MATCHES: "Tom.+" } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_actor.name =~ $this_actorsConnection.args.where.node.name_MATCHES + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "name_MATCHES": "Tom.+" + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/and.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/and.md new file mode 100644 index 0000000000..86fde78d14 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/and.md @@ -0,0 +1,91 @@ +## Cypher -> Connections -> Filtering -> Relationship -> AND + +Schema: + +```schema +type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + role: String! + screenTime: Int! +} +``` + +--- + +### AND + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection( + where: { + relationship: { + AND: [{ role_ENDS_WITH: "Gump" }, { screenTime_LT: 60 }] + } + } + ) { + edges { + role + screenTime + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE ((this_acted_in.role ENDS WITH $this_actorsConnection.args.where.relationship.AND[0].role_ENDS_WITH) AND (this_acted_in.screenTime < $this_actorsConnection.args.where.relationship.AND[1].screenTime_LT)) + WITH collect({ role: this_acted_in.role, screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "relationship": { + "AND": [ + { + "role_ENDS_WITH": "Gump" + }, + { + "screenTime_LT": { + "high": 0, + "low": 60 + } + } + ] + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/arrays.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/arrays.md new file mode 100644 index 0000000000..2d30c561a0 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/arrays.md @@ -0,0 +1,262 @@ +## Cypher -> Connections -> Filtering -> Relationship -> Arrays + +Schema: + +```schema +type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! + quotes: [String!] +} +``` + +--- + +### IN + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection(where: { relationship: { screenTime_IN: [60, 70] } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_acted_in.screenTime IN $this_actorsConnection.args.where.relationship.screenTime_IN + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "relationship": { + "screenTime_IN": [ + { + "high": 0, + "low": 60 + }, + { + "high": 0, + "low": 70 + } + ] + } + } + } + } +} +``` + +--- + +### NOT_IN + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection( + where: { relationship: { screenTime_NOT_IN: [60, 70] } } + ) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE (NOT this_acted_in.screenTime IN $this_actorsConnection.args.where.relationship.screenTime_NOT_IN) + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "relationship": { + "screenTime_NOT_IN": [ + { + "high": 0, + "low": 60 + }, + { + "high": 0, + "low": 70 + } + ] + } + } + } + } +} +``` + +--- + +### INCLUDES + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection( + where: { + relationship: { + quotes_INCLUDES: "Life is like a box of chocolates" + } + } + ) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE $this_actorsConnection.args.where.relationship.quotes_INCLUDES IN this_acted_in.quotes + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "relationship": { + "quotes_INCLUDES": "Life is like a box of chocolates" + } + } + } + } +} +``` + +--- + +### NOT_INCLUDES + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection( + where: { + relationship: { + quotes_NOT_INCLUDES: "Life is like a box of chocolates" + } + } + ) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE (NOT $this_actorsConnection.args.where.relationship.quotes_NOT_INCLUDES IN this_acted_in.quotes) + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "relationship": { + "quotes_NOT_INCLUDES": "Life is like a box of chocolates" + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/equality.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/equality.md new file mode 100644 index 0000000000..2c67a958d9 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/equality.md @@ -0,0 +1,131 @@ +## Cypher -> Connections -> Filtering -> Relationship -> Equality + +Schema: + +```schema +type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +### Equality + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection(where: { relationship: { screenTime: 60 } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_acted_in.screenTime = $this_actorsConnection.args.where.relationship.screenTime + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "relationship": { + "screenTime": { + "high": 0, + "low": 60 + } + } + } + } + } +} +``` + +--- + +### Inequality + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection(where: { relationship: { screenTime_NOT: 60 } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE (NOT this_acted_in.screenTime = $this_actorsConnection.args.where.relationship.screenTime_NOT) + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "relationship": { + "screenTime_NOT": { + "high": 0, + "low": 60 + } + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/numerical.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/numerical.md new file mode 100644 index 0000000000..048fb92d32 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/numerical.md @@ -0,0 +1,241 @@ +## Cypher -> Connections -> Filtering -> Relationship -> Numerical + +Schema: + +```schema +type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +### LT + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection(where: { relationship: { screenTime_LT: 60 } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_acted_in.screenTime < $this_actorsConnection.args.where.relationship.screenTime_LT + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "relationship": { + "screenTime_LT": { + "high": 0, + "low": 60 + } + } + } + } + } +} +``` + +--- + +### LTE + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection(where: { relationship: { screenTime_LTE: 60 } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_acted_in.screenTime <= $this_actorsConnection.args.where.relationship.screenTime_LTE + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "relationship": { + "screenTime_LTE": { + "high": 0, + "low": 60 + } + } + } + } + } +} +``` + +--- + +### GT + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection(where: { relationship: { screenTime_GT: 60 } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_acted_in.screenTime > $this_actorsConnection.args.where.relationship.screenTime_GT + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "relationship": { + "screenTime_GT": { + "high": 0, + "low": 60 + } + } + } + } + } +} +``` + +--- + +### GTE + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection(where: { relationship: { screenTime_GTE: 60 } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_acted_in.screenTime >= $this_actorsConnection.args.where.relationship.screenTime_GTE + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "relationship": { + "screenTime_GTE": { + "high": 0, + "low": 60 + } + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/or.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/or.md new file mode 100644 index 0000000000..aef388272e --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/or.md @@ -0,0 +1,91 @@ +## Cypher -> Connections -> Filtering -> Relationship -> OR + +Schema: + +```schema +type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + role: String! + screenTime: Int! +} +``` + +--- + +### OR + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection( + where: { + relationship: { + OR: [{ role_ENDS_WITH: "Gump" }, { screenTime_LT: 60 }] + } + } + ) { + edges { + role + screenTime + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE ((this_acted_in.role ENDS WITH $this_actorsConnection.args.where.relationship.OR[0].role_ENDS_WITH) OR (this_acted_in.screenTime < $this_actorsConnection.args.where.relationship.OR[1].screenTime_LT)) + WITH collect({ role: this_acted_in.role, screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "relationship": { + "OR": [ + { + "role_ENDS_WITH": "Gump" + }, + { + "screenTime_LT": { + "high": 0, + "low": 60 + } + } + ] + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/points.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/points.md new file mode 100644 index 0000000000..047e64fecf --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/points.md @@ -0,0 +1,93 @@ +## Cypher -> Connections -> Filtering -> Relationship -> Points + +Schema: + +```schema +type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! + location: Point! +} +``` + +--- + +### DISTANCE + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection( + where: { + relationship: { + location_DISTANCE: { + point: { longitude: 1.0, latitude: 2.0 } + distance: 3.0 + } + } + } + ) { + edges { + screenTime + location { + latitude + longitude + } + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE distance(this_acted_in.location, point($this_actorsConnection.args.where.relationship.location_DISTANCE.point)) = $this_actorsConnection.args.where.relationship.location_DISTANCE.distance + WITH collect({ screenTime: this_acted_in.screenTime, location: { point: this_acted_in.location }, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "relationship": { + "location_DISTANCE": { + "distance": 3, + "point": { + "latitude": 2, + "longitude": 1 + } + } + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/string.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/string.md new file mode 100644 index 0000000000..51b9ea96e7 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/string.md @@ -0,0 +1,402 @@ +## Cypher -> Connections -> Filtering -> Relationship -> String + +Schema: + +```schema +type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + role: String! + screenTime: Int! +} +``` + +```env +NEO4J_GRAPHQL_ENABLE_REGEX=1 +``` + +--- + +### CONTAINS + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection( + where: { relationship: { role_CONTAINS: "Forrest" } } + ) { + edges { + role + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_acted_in.role CONTAINS $this_actorsConnection.args.where.relationship.role_CONTAINS + WITH collect({ role: this_acted_in.role, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "relationship": { + "role_CONTAINS": "Forrest" + } + } + } + } +} +``` + +--- + +### NOT_CONTAINS + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection( + where: { relationship: { role_NOT_CONTAINS: "Forrest" } } + ) { + edges { + role + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE (NOT this_acted_in.role CONTAINS $this_actorsConnection.args.where.relationship.role_NOT_CONTAINS) + WITH collect({ role: this_acted_in.role, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "relationship": { + "role_NOT_CONTAINS": "Forrest" + } + } + } + } +} +``` + +--- + +### STARTS_WITH + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection( + where: { relationship: { role_STARTS_WITH: "Forrest" } } + ) { + edges { + role + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_acted_in.role STARTS WITH $this_actorsConnection.args.where.relationship.role_STARTS_WITH + WITH collect({ role: this_acted_in.role, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "relationship": { + "role_STARTS_WITH": "Forrest" + } + } + } + } +} +``` + +--- + +### NOT_STARTS_WITH + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection( + where: { relationship: { role_NOT_STARTS_WITH: "Forrest" } } + ) { + edges { + role + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE (NOT this_acted_in.role STARTS WITH $this_actorsConnection.args.where.relationship.role_NOT_STARTS_WITH) + WITH collect({ role: this_acted_in.role, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "relationship": { + "role_NOT_STARTS_WITH": "Forrest" + } + } + } + } +} +``` + +--- + +### ENDS_WITH + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection(where: { relationship: { role_ENDS_WITH: "Gump" } }) { + edges { + role + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_acted_in.role ENDS WITH $this_actorsConnection.args.where.relationship.role_ENDS_WITH + WITH collect({ role: this_acted_in.role, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "relationship": { + "role_ENDS_WITH": "Gump" + } + } + } + } +} +``` + +--- + +### NOT_ENDS_WITH + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection( + where: { relationship: { role_NOT_ENDS_WITH: "Gump" } } + ) { + edges { + role + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE (NOT this_acted_in.role ENDS WITH $this_actorsConnection.args.where.relationship.role_NOT_ENDS_WITH) + WITH collect({ role: this_acted_in.role, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "relationship": { + "role_NOT_ENDS_WITH": "Gump" + } + } + } + } +} +``` + +--- + +### MATCHES + +**GraphQL input** + +```graphql +query { + movies { + title + actorsConnection( + where: { relationship: { role_MATCHES: "Forrest.+" } } + ) { + edges { + role + node { + name + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_acted_in.role =~ $this_actorsConnection.args.where.relationship.role_MATCHES + WITH collect({ role: this_acted_in.role, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_actorsConnection": { + "args": { + "where": { + "relationship": { + "role_MATCHES": "Forrest.+" + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/projections/create.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/projections/create.md new file mode 100644 index 0000000000..595d1f7024 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/projections/create.md @@ -0,0 +1,212 @@ +## Cypher -> Connections -> Projections -> Create + +Schema: + +```schema +type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +### Connection can be selected following the creation of a single node + +**GraphQL input** + +```graphql +mutation { + createMovies(input: [{ title: "Forrest Gump" }]) { + movies { + title + actorsConnection { + edges { + screenTime + node { + name + } + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +CALL { + CREATE (this0:Movie) + SET this0.title = $this0_title + RETURN this0 +} +CALL { + WITH this0 + MATCH (this0)<-[this0_acted_in:ACTED_IN]-(this0_actor:Actor) + WITH collect({ screenTime: this0_acted_in.screenTime, node: { name: this0_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this0 { .title, actorsConnection } AS this0 +``` + +**Expected Cypher params** + +```cypher-params +{ + "this0_title": "Forrest Gump" +} +``` + +--- + +### Connection can be selected following the creation of a multiple nodes + +**GraphQL input** + +```graphql +mutation { + createMovies(input: [{ title: "Forrest Gump" }, { title: "Toy Story" }]) { + movies { + title + actorsConnection { + edges { + screenTime + node { + name + } + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +CALL { + CREATE (this0:Movie) + SET this0.title = $this0_title + RETURN this0 +} +CALL { + CREATE (this1:Movie) + SET this1.title = $this1_title + RETURN this1 +} +CALL { + WITH this0 + MATCH (this0)<-[this0_acted_in:ACTED_IN]-(this0_actor:Actor) + WITH collect({ screenTime: this0_acted_in.screenTime, node: { name: this0_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +CALL { + WITH this1 + MATCH (this1)<-[this1_acted_in:ACTED_IN]-(this1_actor:Actor) + WITH collect({ screenTime: this1_acted_in.screenTime, node: { name: this1_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this0 { .title, actorsConnection } AS this0, this1 { .title, actorsConnection } AS this1 +``` + +**Expected Cypher params** + +```cypher-params +{ + "this0_title": "Forrest Gump", + "this1_title": "Toy Story" +} +``` + +--- + +### Connection can be selected and filtered following the creation of a multiple nodes + +**GraphQL input** + +```graphql +mutation { + createMovies(input: [{ title: "Forrest Gump" }, { title: "Toy Story" }]) { + movies { + title + actorsConnection(where: { node: { name: "Tom Hanks" } }) { + edges { + screenTime + node { + name + } + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +CALL { + CREATE (this0:Movie) + SET this0.title = $this0_title + RETURN this0 +} +CALL { + CREATE (this1:Movie) + SET this1.title = $this1_title + RETURN this1 +} +CALL { + WITH this0 + MATCH (this0)<-[this0_acted_in:ACTED_IN]-(this0_actor:Actor) + WHERE this0_actor.name = $this0_actorsConnection.args.where.node.name + WITH collect({ screenTime: this0_acted_in.screenTime, node: { name: this0_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +CALL { + WITH this1 + MATCH (this1)<-[this1_acted_in:ACTED_IN]-(this1_actor:Actor) + WHERE this1_actor.name = $this1_actorsConnection.args.where.node.name + WITH collect({ screenTime: this1_acted_in.screenTime, node: { name: this1_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this0 { .title, actorsConnection } AS this0, this1 { .title, actorsConnection } AS this1 +``` + +**Expected Cypher params** + +```cypher-params +{ + "this0_title": "Forrest Gump", + "this1_title": "Toy Story", + "this0_actorsConnection": { + "args": { + "where": { + "node": { + "name": "Tom Hanks" + } + } + } + }, + "this1_actorsConnection": { + "args": { + "where": { + "node": { + "name": "Tom Hanks" + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/projections/update.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/projections/update.md new file mode 100644 index 0000000000..632ba1a80c --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/projections/update.md @@ -0,0 +1,67 @@ +## Cypher -> Connections -> Projections -> Update + +Schema: + +```schema +type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +### Connection can be selected following update Mutation + +**GraphQL input** + +```graphql +mutation { + updateMovies(where: { title: "Forrest Gump" }) { + movies { + title + actorsConnection { + edges { + screenTime + node { + name + } + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges } AS actorsConnection +} +RETURN this { .title, actorsConnection } AS this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_title": "Forrest Gump" +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/relationship-properties.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/relationship-properties.md similarity index 100% rename from packages/graphql/tests/tck/tck-test-files/cypher/relationship-properties.md rename to packages/graphql/tests/tck/tck-test-files/cypher/connections/relationship-properties.md diff --git a/packages/graphql/tests/tck/utils/generate-test-cases-from-md.utils.ts b/packages/graphql/tests/tck/utils/generate-test-cases-from-md.utils.ts index e5857d06fe..19df71062a 100644 --- a/packages/graphql/tests/tck/utils/generate-test-cases-from-md.utils.ts +++ b/packages/graphql/tests/tck/utils/generate-test-cases-from-md.utils.ts @@ -138,7 +138,11 @@ function generateTests(filePath, kind: string): TestCase { export function generateTestCasesFromMd(dir: string, kind = ""): TestCase[] { const files = fs.readdirSync(dir, { withFileTypes: true }).reduce((res: TestCase[], item) => { if (item.isFile()) { - return [...res, generateTests(path.join(dir, item.name), kind)]; + try { + return [...res, generateTests(path.join(dir, item.name), kind)]; + } catch { + throw new Error(`Error generating test ${path.join(dir, item.name)}`); + } } if (item.isDirectory()) { From ca569c8ef82d1573693ed9439b7ed8ad946cc40c Mon Sep 17 00:00:00 2001 From: Darrell Warde Date: Tue, 18 May 2021 14:41:39 +0100 Subject: [PATCH 3/3] Union support --- .../src/schema/make-augmented-schema.ts | 124 ++-- .../create-connection-and-params.ts | 284 +++++++--- .../create-connection-where-and-params.ts | 151 ++--- .../cypher/connections/filtering/composite.md | 2 +- .../cypher/connections/unions.md | 168 ++++++ .../schema/connections/unions.md | 536 ++++++++++++++++++ .../tests/tck/tck-test-files/schema/unions.md | 18 + 7 files changed, 1052 insertions(+), 231 deletions(-) create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/unions.md create mode 100644 packages/graphql/tests/tck/tck-test-files/schema/connections/unions.md diff --git a/packages/graphql/src/schema/make-augmented-schema.ts b/packages/graphql/src/schema/make-augmented-schema.ts index 44fe24cbc7..af21e1b72a 100644 --- a/packages/graphql/src/schema/make-augmented-schema.ts +++ b/packages/graphql/src/schema/make-augmented-schema.ts @@ -729,14 +729,62 @@ function makeAugmentedSchema( }); }); - node.connectionFields - .filter((c) => !c.relationship.union) - .forEach((connectionField) => { - const relationship = composer.createObjectTC({ - name: connectionField.relationshipTypeName, - fields: { - node: `${connectionField.relationship.typeMeta.name}!`, - }, + node.connectionFields.forEach((connectionField) => { + const relationship = composer.createObjectTC({ + name: connectionField.relationshipTypeName, + fields: { + node: `${connectionField.relationship.typeMeta.name}!`, + }, + }); + + const connectionWhereName = `${connectionField.typeMeta.name}Where`; + + const connectionWhere = composer.createInputTC({ + name: connectionWhereName, + fields: { + AND: `[${connectionWhereName}!]`, + OR: `[${connectionWhereName}!]`, + }, + }); + + const connection = composer.createObjectTC({ + name: connectionField.typeMeta.name, + fields: { + edges: relationship.NonNull.List.NonNull, + }, + }); + + if (connectionField.relationship.properties) { + const propertiesInterface = composer.getIFTC(connectionField.relationship.properties); + relationship.addInterface(propertiesInterface); + relationship.addFields(propertiesInterface.getFields()); + + connectionWhere.addFields({ + relationship: `${connectionField.relationship.properties}Where`, + relationship_NOT: `${connectionField.relationship.properties}Where`, + }); + } + + let composeNodeArgs: { + where: any; + options?: any; + } = { + where: connectionWhere, + }; + + if (connectionField.relationship.union) { + const relatedNodes = nodes.filter((n) => connectionField.relationship.union?.nodes?.includes(n.name)); + + relatedNodes.forEach((n) => { + connectionWhere.addFields({ + [n.name]: `${n.name}Where`, + [`${n.name}_NOT`]: `${n.name}Where`, + }); + }); + } else { + connectionWhere.addFields({ + node: `${connectionField.relationship.typeMeta.name}Where`, + node_NOT: `${connectionField.relationship.typeMeta.name}Where`, }); const connectionSort = composer.createInputTC({ @@ -746,38 +794,12 @@ function makeAugmentedSchema( }, }); - const connectionWhereName = `${connectionField.typeMeta.name}Where`; - - const connectionWhere = composer.createInputTC({ - name: connectionWhereName, - fields: { - node: `${connectionField.relationship.typeMeta.name}Where`, - node_NOT: `${connectionField.relationship.typeMeta.name}Where`, - AND: `[${connectionWhereName}!]`, - OR: `[${connectionWhereName}!]`, - }, - }); - if (connectionField.relationship.properties) { - const propertiesInterface = composer.getIFTC(connectionField.relationship.properties); - relationship.addInterface(propertiesInterface); - relationship.addFields(propertiesInterface.getFields()); connectionSort.addFields({ relationship: `${connectionField.relationship.properties}Sort`, }); - connectionWhere.addFields({ - relationship: `${connectionField.relationship.properties}Where`, - relationship_NOT: `${connectionField.relationship.properties}Where`, - }); } - const connection = composer.createObjectTC({ - name: connectionField.typeMeta.name, - fields: { - edges: relationship.NonNull.List.NonNull, - }, - }); - const connectionOptions = composer.createInputTC({ name: `${connectionField.typeMeta.name}Options`, fields: { @@ -785,26 +807,26 @@ function makeAugmentedSchema( }, }); - composeNode.addFields({ - [connectionField.fieldName]: { - type: connection.NonNull, - args: { - where: connectionWhere, - options: connectionOptions, - }, - }, - }); + composeNodeArgs = { ...composeNodeArgs, options: connectionOptions }; + } - const r = new Relationship({ - name: connectionField.relationshipTypeName, - type: connectionField.relationship.type, - fields: connectionField.relationship.properties - ? (relationshipFields.get(connectionField.relationship.properties) as RelationshipField[]) - : [], - }); - relationships.push(r); + composeNode.addFields({ + [connectionField.fieldName]: { + type: connection.NonNull, + args: composeNodeArgs, + }, }); + const r = new Relationship({ + name: connectionField.relationshipTypeName, + type: connectionField.relationship.type, + fields: connectionField.relationship.properties + ? (relationshipFields.get(connectionField.relationship.properties) as RelationshipField[]) + : [], + }); + relationships.push(r); + }); + if (!node.exclude?.operations.includes("read")) { composer.Query.addFields({ [pluralize(camelCase(node.name))]: findResolver({ node }), diff --git a/packages/graphql/src/translate/connection/create-connection-and-params.ts b/packages/graphql/src/translate/connection/create-connection-and-params.ts index 1f1ddfba5b..7178e39b51 100644 --- a/packages/graphql/src/translate/connection/create-connection-and-params.ts +++ b/packages/graphql/src/translate/connection/create-connection-and-params.ts @@ -1,4 +1,4 @@ -import { ResolveTree } from "graphql-parse-resolve-info"; +import { FieldsByTypeName, ResolveTree } from "graphql-parse-resolve-info"; import { ConnectionField, ConnectionOptionsArg, ConnectionWhereArg, Context } from "../../types"; import { Node } from "../../classes"; import createProjectionAndParams from "../create-projection-and-params"; @@ -68,7 +68,6 @@ function createConnectionAndParams({ parameterPrefix?: string; }): [string, any] { let legacyProjectionWhereParams; - let connectionWhereParams; let nestedConnectionFieldParams; const subquery = ["CALL {", `WITH ${nodeVariable}`]; @@ -77,9 +76,7 @@ function createConnectionAndParams({ const whereInput = resolveTree.args.where as ConnectionWhereArg; const relationshipVariable = `${nodeVariable}_${field.relationship.type.toLowerCase()}`; - const relatedNodeVariable = `${nodeVariable}_${field.relationship.typeMeta.name.toLowerCase()}`; - const relatedNode = context.neoSchema.nodes.find((x) => x.name === field.relationship.typeMeta.name) as Node; const relationship = context.neoSchema.relationships.find( (r) => r.name === field.relationshipTypeName ) as Relationship; @@ -87,44 +84,6 @@ function createConnectionAndParams({ const inStr = field.relationship.direction === "IN" ? "<-" : "-"; const relTypeStr = `[${relationshipVariable}:${field.relationship.type}]`; const outStr = field.relationship.direction === "OUT" ? "->" : "-"; - const nodeOutStr = `(${relatedNodeVariable}:${field.relationship.typeMeta.name})`; - - /* - MATCH clause, example: - - MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) - */ - subquery.push(`MATCH (${nodeVariable})${inStr}${relTypeStr}${outStr}${nodeOutStr}`); - - if (whereInput) { - const where = createConnectionWhereAndParams({ - whereInput, - node: relatedNode, - nodeVariable: relatedNodeVariable, - relationship, - relationshipVariable, - context, - parameterPrefix: `${parameterPrefix ? `${parameterPrefix}.` : `${nodeVariable}_`}${ - resolveTree.name - }.args.where`, - }); - const [whereClause, whereParams] = where; - subquery.push(`WHERE ${whereClause}`); - connectionWhereParams = whereParams; - } - - if (sortInput && sortInput.length) { - const sort = sortInput.map((s) => - [ - ...Object.entries(s.relationship || []).map( - ([f, direction]) => `${relationshipVariable}.${f} ${direction}` - ), - ...Object.entries(s.node || []).map(([f, direction]) => `${relatedNodeVariable}.${f} ${direction}`), - ].join(", ") - ); - subquery.push(`WITH ${relationshipVariable}, ${relatedNodeVariable}`); - subquery.push(`ORDER BY ${sort.join(", ")}`); - } const connection = resolveTree.fieldsByTypeName[field.typeMeta.name]; const { edges } = connection; @@ -143,71 +102,216 @@ function createConnectionAndParams({ elementsToCollect.push(relationshipPropertyEntries.join(", ")); } - const nestedSubqueries: string[] = []; + if (field.relationship.union) { + const unionNodes = context.neoSchema.nodes.filter((n) => field.relationship.union?.nodes?.includes(n.name)); + const unionSubqueries: string[] = []; - if (node) { - const nodeProjectionAndParams = createProjectionAndParams({ - fieldsByTypeName: node?.fieldsByTypeName, - node: relatedNode, - context, - varName: relatedNodeVariable, - literalElements: true, - }); - const [nodeProjection, nodeProjectionParams] = nodeProjectionAndParams; - elementsToCollect.push(`node: ${nodeProjection}`); - legacyProjectionWhereParams = nodeProjectionParams; - - if (nodeProjectionAndParams[2]?.connectionFields?.length) { - nodeProjectionAndParams[2].connectionFields.forEach((connectionResolveTree) => { - const connectionField = relatedNode.connectionFields.find( - (x) => x.fieldName === connectionResolveTree.name - ) as ConnectionField; - const nestedConnection = createConnectionAndParams({ - resolveTree: connectionResolveTree, - field: connectionField, + unionNodes.forEach((n) => { + const relatedNodeVariable = `${nodeVariable}_${n.name}`; + const nodeOutStr = `(${relatedNodeVariable}:${n.name})`; + + const unionSubquery: string[] = []; + const unionSubqueryElementsToCollect = [...elementsToCollect]; + + const nestedSubqueries: string[] = []; + + if (node) { + // Doing this for unions isn't necessary, but this would also work for interfaces if we decided to take that direction + const nodeFieldsByTypeName: FieldsByTypeName = { + [n.name]: { + ...node?.fieldsByTypeName[n.name], + ...node?.fieldsByTypeName[field.relationship.typeMeta.name], + }, + }; + + const nodeProjectionAndParams = createProjectionAndParams({ + fieldsByTypeName: nodeFieldsByTypeName, + node: n, context, + varName: relatedNodeVariable, + literalElements: true, + }); + const [nodeProjection, nodeProjectionParams] = nodeProjectionAndParams; + unionSubqueryElementsToCollect.push(`node: ${nodeProjection}`); + legacyProjectionWhereParams = nodeProjectionParams; + + if (nodeProjectionAndParams[2]?.connectionFields?.length) { + nodeProjectionAndParams[2].connectionFields.forEach((connectionResolveTree) => { + const connectionField = n.connectionFields.find( + (x) => x.fieldName === connectionResolveTree.name + ) as ConnectionField; + const nestedConnection = createConnectionAndParams({ + resolveTree: connectionResolveTree, + field: connectionField, + context, + nodeVariable: relatedNodeVariable, + parameterPrefix: `${parameterPrefix ? `${parameterPrefix}.` : `${nodeVariable}_`}${ + resolveTree.name + }.edges.node`, + }); + nestedSubqueries.push(nestedConnection[0]); + + legacyProjectionWhereParams = { + ...legacyProjectionWhereParams, + ...Object.entries(nestedConnection[1]).reduce>((res, [k, v]) => { + if (k !== `${relatedNodeVariable}_${connectionResolveTree.name}`) { + res[k] = v; + } + return res; + }, {}), + }; + + if (nestedConnection[1][`${relatedNodeVariable}_${connectionResolveTree.name}`]) { + if (!nestedConnectionFieldParams) nestedConnectionFieldParams = {}; + nestedConnectionFieldParams = { + ...nestedConnectionFieldParams, + ...{ + [connectionResolveTree.name]: + nestedConnection[1][`${relatedNodeVariable}_${connectionResolveTree.name}`], + }, + }; + } + }); + } + } + + unionSubquery.push(`WITH ${nodeVariable}`); + unionSubquery.push(`OPTIONAL MATCH (${nodeVariable})${inStr}${relTypeStr}${outStr}${nodeOutStr}`); + + if (whereInput) { + const where = createConnectionWhereAndParams({ + whereInput, + node: n, nodeVariable: relatedNodeVariable, + relationship, + relationshipVariable, + context, parameterPrefix: `${parameterPrefix ? `${parameterPrefix}.` : `${nodeVariable}_`}${ resolveTree.name - }.edges.node`, + }.args.where`, }); - nestedSubqueries.push(nestedConnection[0]); - // nestedConnectionFieldParams.push(nestedConnection[1].); - - legacyProjectionWhereParams = { - ...legacyProjectionWhereParams, - ...Object.entries(nestedConnection[1]).reduce>((res, [k, v]) => { - if (k !== `${relatedNodeVariable}_${connectionResolveTree.name}`) { - res[k] = v; - } - return res; - }, {}), - }; + const [whereClause] = where; + unionSubquery.push(`WHERE ${whereClause}`); + } - if (nestedConnection[1][`${relatedNodeVariable}_${connectionResolveTree.name}`]) { - if (!nestedConnectionFieldParams) nestedConnectionFieldParams = {}; - nestedConnectionFieldParams = { - ...nestedConnectionFieldParams, - ...{ - [connectionResolveTree.name]: - nestedConnection[1][`${relatedNodeVariable}_${connectionResolveTree.name}`], - }, - }; - } + if (nestedSubqueries.length) { + unionSubquery.push(nestedSubqueries.join("\n")); + } + + unionSubquery.push(`WITH { ${unionSubqueryElementsToCollect.join(", ")} } AS edge`); + unionSubquery.push("RETURN edge"); + + unionSubqueries.push(unionSubquery.join("\n")); + }); + + subquery.push(["CALL {", unionSubqueries.join("\nUNION\n"), "}", "WITH collect(edge) as edges"].join("\n")); + } else { + const relatedNodeVariable = `${nodeVariable}_${field.relationship.typeMeta.name.toLowerCase()}`; + const nodeOutStr = `(${relatedNodeVariable}:${field.relationship.typeMeta.name})`; + const relatedNode = context.neoSchema.nodes.find((x) => x.name === field.relationship.typeMeta.name) as Node; + + /* + MATCH clause, example: + + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + */ + subquery.push(`MATCH (${nodeVariable})${inStr}${relTypeStr}${outStr}${nodeOutStr}`); + + if (whereInput) { + const where = createConnectionWhereAndParams({ + whereInput, + node: relatedNode, + nodeVariable: relatedNodeVariable, + relationship, + relationshipVariable, + context, + parameterPrefix: `${parameterPrefix ? `${parameterPrefix}.` : `${nodeVariable}_`}${ + resolveTree.name + }.args.where`, + }); + const [whereClause] = where; + subquery.push(`WHERE ${whereClause}`); + } + + if (sortInput && sortInput.length) { + const sort = sortInput.map((s) => + [ + ...Object.entries(s.relationship || []).map( + ([f, direction]) => `${relationshipVariable}.${f} ${direction}` + ), + ...Object.entries(s.node || []).map(([f, direction]) => `${relatedNodeVariable}.${f} ${direction}`), + ].join(", ") + ); + subquery.push(`WITH ${relationshipVariable}, ${relatedNodeVariable}`); + subquery.push(`ORDER BY ${sort.join(", ")}`); + } + + const nestedSubqueries: string[] = []; + + if (node) { + const nodeProjectionAndParams = createProjectionAndParams({ + fieldsByTypeName: node?.fieldsByTypeName, + node: relatedNode, + context, + varName: relatedNodeVariable, + literalElements: true, }); + const [nodeProjection, nodeProjectionParams] = nodeProjectionAndParams; + elementsToCollect.push(`node: ${nodeProjection}`); + legacyProjectionWhereParams = nodeProjectionParams; + + if (nodeProjectionAndParams[2]?.connectionFields?.length) { + nodeProjectionAndParams[2].connectionFields.forEach((connectionResolveTree) => { + const connectionField = relatedNode.connectionFields.find( + (x) => x.fieldName === connectionResolveTree.name + ) as ConnectionField; + const nestedConnection = createConnectionAndParams({ + resolveTree: connectionResolveTree, + field: connectionField, + context, + nodeVariable: relatedNodeVariable, + parameterPrefix: `${parameterPrefix ? `${parameterPrefix}.` : `${nodeVariable}_`}${ + resolveTree.name + }.edges.node`, + }); + nestedSubqueries.push(nestedConnection[0]); + + legacyProjectionWhereParams = { + ...legacyProjectionWhereParams, + ...Object.entries(nestedConnection[1]).reduce>((res, [k, v]) => { + if (k !== `${relatedNodeVariable}_${connectionResolveTree.name}`) { + res[k] = v; + } + return res; + }, {}), + }; + + if (nestedConnection[1][`${relatedNodeVariable}_${connectionResolveTree.name}`]) { + if (!nestedConnectionFieldParams) nestedConnectionFieldParams = {}; + nestedConnectionFieldParams = { + ...nestedConnectionFieldParams, + ...{ + [connectionResolveTree.name]: + nestedConnection[1][`${relatedNodeVariable}_${connectionResolveTree.name}`], + }, + }; + } + }); + } } + + if (nestedSubqueries.length) subquery.push(nestedSubqueries.join("\n")); + subquery.push(`WITH collect({ ${elementsToCollect.join(", ")} }) AS edges`); } - if (nestedSubqueries.length) subquery.push(nestedSubqueries.join("\n")); - subquery.push(`WITH collect({ ${elementsToCollect.join(", ")} }) AS edges`); subquery.push(`RETURN { edges: edges } AS ${resolveTree.alias}`); subquery.push("}"); const params = { ...legacyProjectionWhereParams, - ...((connectionWhereParams || nestedConnectionFieldParams) && { + ...((whereInput || nestedConnectionFieldParams) && { [`${nodeVariable}_${resolveTree.name}`]: { - ...(connectionWhereParams && { args: { where: connectionWhereParams } }), + ...(whereInput && { args: { where: whereInput } }), ...(nestedConnectionFieldParams && { edges: { node: { ...nestedConnectionFieldParams } } }), }, }), diff --git a/packages/graphql/src/translate/where/create-connection-where-and-params.ts b/packages/graphql/src/translate/where/create-connection-where-and-params.ts index 0a72953172..d7832dc2d4 100644 --- a/packages/graphql/src/translate/where/create-connection-where-and-params.ts +++ b/packages/graphql/src/translate/where/create-connection-where-and-params.ts @@ -20,104 +20,77 @@ function createConnectionWhereAndParams({ relationshipVariable: string; parameterPrefix: string; }): [string, any] { - const whereStrs: string[] = []; - let params = {}; + const reduced = Object.entries(whereInput).reduce<{ whereStrs: string[]; params: any }>( + (res, [k, v]) => { + if (["AND", "OR"].includes(k)) { + const innerClauses: string[] = []; + const innerParams: any[] = []; - if (whereInput.node) { - const nodeWhere = createNodeWhereAndParams({ - whereInput: whereInput.node, - node, - nodeVariable, - context, - parameterPrefix: `${parameterPrefix}.node`, - }); - whereStrs.push(nodeWhere[0]); - params = { ...params, node: nodeWhere[1] }; - } + v.forEach((o, i) => { + const or = createConnectionWhereAndParams({ + whereInput: o, + node, + nodeVariable, + relationship, + relationshipVariable, + context, + parameterPrefix: `${parameterPrefix}.${k}[${i}]`, + }); - if (whereInput.node_NOT) { - const nodeWhere = createNodeWhereAndParams({ - whereInput: whereInput.node_NOT, - node, - nodeVariable, - context, - parameterPrefix: `${parameterPrefix}.node_NOT`, - }); - whereStrs.push(`(NOT ${nodeWhere[0]})`); - params = { ...params, node_NOT: nodeWhere[1] }; - } + innerClauses.push(`${or[0]}`); + innerParams.push(or[1]); + }); - if (whereInput.relationship) { - const relationshipWhere = createRelationshipWhereAndParams({ - whereInput: whereInput.relationship, - relationship, - relationshipVariable, - context, - parameterPrefix: `${parameterPrefix}.relationship`, - }); - whereStrs.push(relationshipWhere[0]); - params = { ...params, relationship: relationshipWhere[1] }; - } + // whereStrs.push(`(${innerClauses.join(` ${k} `)})`); + // params = { ...params, [k]: innerParams }; - if (whereInput.relationship_NOT) { - const relationshipWhere = createRelationshipWhereAndParams({ - whereInput: whereInput.relationship_NOT, - relationship, - relationshipVariable, - context, - parameterPrefix: `${parameterPrefix}.relationship_NOT`, - }); - whereStrs.push(`(NOT ${relationshipWhere[0]})`); - params = { ...params, relationship_NOT: relationshipWhere[1] }; - } + const whereStrs = [...res.whereStrs, `(${innerClauses.filter((clause) => !!clause).join(` ${k} `)})`]; + const params = { ...res.params, [k]: innerParams }; + res = { whereStrs, params }; + return res; + } - if (whereInput.AND) { - const innerClauses: string[] = []; - const innerParams: any[] = []; + if (k.startsWith("relationship")) { + const relationshipWhere = createRelationshipWhereAndParams({ + whereInput: v, + relationship, + relationshipVariable, + context, + parameterPrefix: `${parameterPrefix}.${k}`, + }); + // whereStrs.push(k === "relationship_NOT" ? `(NOT ${relationshipWhere[0]})` : relationshipWhere[0]); + // params = { ...params, [k]: relationshipWhere[1] }; - whereInput.AND.forEach((a, i) => { - const and = createConnectionWhereAndParams({ - whereInput: a, - node, - nodeVariable, - relationship, - relationshipVariable, - context, - parameterPrefix: `${parameterPrefix}.AND[${i}]`, - }); + const whereStrs = [ + ...res.whereStrs, + k === "relationship_NOT" ? `(NOT ${relationshipWhere[0]})` : relationshipWhere[0], + ]; + const params = { ...res.params, [k]: relationshipWhere[1] }; + res = { whereStrs, params }; + return res; + } - innerClauses.push(`${and[0]}`); - innerParams.push(and[1]); - }); + if (k.startsWith("node") || k.startsWith(node.name)) { + const nodeWhere = createNodeWhereAndParams({ + whereInput: v, + node, + nodeVariable, + context, + parameterPrefix: `${parameterPrefix}.${k}`, + }); + // whereStrs.push(k.endsWith("_NOT") ? `(NOT ${nodeWhere[0]})` : nodeWhere[0]); + // params = { ...params, [k]: nodeWhere[1] }; - whereStrs.push(`(${innerClauses.join(" AND ")})`); - params = { ...params, AND: innerParams }; - } + const whereStrs = [...res.whereStrs, k.endsWith("_NOT") ? `(NOT ${nodeWhere[0]})` : nodeWhere[0]]; + const params = { ...res.params, [k]: nodeWhere[1] }; + res = { whereStrs, params }; + } + return res; + }, + { whereStrs: [], params: {} } + ); - if (whereInput.OR) { - const innerClauses: string[] = []; - const innerParams: any[] = []; - - whereInput.OR.forEach((o, i) => { - const or = createConnectionWhereAndParams({ - whereInput: o, - node, - nodeVariable, - relationship, - relationshipVariable, - context, - parameterPrefix: `${parameterPrefix}.OR[${i}]`, - }); - - innerClauses.push(`${or[0]}`); - innerParams.push(or[1]); - }); - - whereStrs.push(`(${innerClauses.join(" OR ")})`); - params = { ...params, OR: innerParams }; - } - - return [whereStrs.join(" AND "), params]; + return [reduced.whereStrs.join(" AND "), reduced.params]; } export default createConnectionWhereAndParams; diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/composite.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/composite.md index a9e5a763e2..b38c0fb28c 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/composite.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/composite.md @@ -57,7 +57,7 @@ WHERE this.title = $this_title CALL { WITH this MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) - WHERE ((this_actor.firstName = $this_actorsConnection.args.where.node.AND[0].firstName) AND (this_actor.lastName = $this_actorsConnection.args.where.node.AND[1].lastName)) AND ((this_acted_in.screenTime > $this_actorsConnection.args.where.relationship.AND[0].screenTime_GT) AND (this_acted_in.screenTime < $this_actorsConnection.args.where.relationship.AND[1].screenTime_LT)) + WHERE ((this_acted_in.screenTime > $this_actorsConnection.args.where.relationship.AND[0].screenTime_GT) AND (this_acted_in.screenTime < $this_actorsConnection.args.where.relationship.AND[1].screenTime_LT)) AND ((this_actor.firstName = $this_actorsConnection.args.where.node.AND[0].firstName) AND (this_actor.lastName = $this_actorsConnection.args.where.node.AND[1].lastName)) WITH collect({ screenTime: this_acted_in.screenTime, node: { firstName: this_actor.firstName, lastName: this_actor.lastName } }) AS edges RETURN { edges: edges } AS actorsConnection } diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/unions.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/unions.md new file mode 100644 index 0000000000..6c93e0c93d --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/unions.md @@ -0,0 +1,168 @@ +## Cypher -> Connetions -> Unions + +Schema: + +```schema +union Publication = Book | Journal + +type Author { + name: String! + publications: [Publication] @relationship(type: "WROTE", direction: OUT, properties: "Wrote") +} + +type Book { + title: String! + author: [Author!]! @relationship(type: "WROTE", direction: IN, properties: "Wrote") +} + +type Journal { + subject: String! + author: [Author!]! @relationship(type: "WROTE", direction: IN, properties: "Wrote") +} + +interface Wrote { + words: Int! +} +``` + +--- + +### Projecting union node and relationship properties with no arguments + +**GraphQL input** + +```graphql +query { + authors { + name + publicationsConnection { + edges { + words + node { + ... on Book { + title + } + ... on Journal { + subject + } + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Author) +CALL { + WITH this + CALL { + WITH this + OPTIONAL MATCH (this)-[this_wrote:WROTE]->(this_Book:Book) + WITH { words: this_wrote.words, node: { title: this_Book.title } } AS edge + RETURN edge + UNION + WITH this + OPTIONAL MATCH (this)-[this_wrote:WROTE]->(this_Journal:Journal) + WITH { words: this_wrote.words, node: { subject: this_Journal.subject } } AS edge + RETURN edge + } + WITH collect(edge) as edges + RETURN { edges: edges } AS publicationsConnection +} +RETURN this { .name, publicationsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{} +``` + +--- + +### Projecting union node and relationship properties with where argument + +**GraphQL input** + +```graphql +query { + authors { + name + publicationsConnection( + where: { + OR: [ + { Book: { title: "Book Title" } } + { Journal: { subject: "Journal Subject" } } + ] + } + ) { + edges { + words + node { + ... on Book { + title + } + ... on Journal { + subject + } + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Author) +CALL { + WITH this + CALL { + WITH this + OPTIONAL MATCH (this)-[this_wrote:WROTE]->(this_Book:Book) + WHERE (this_Book.title = $this_publicationsConnection.args.where.OR[0].Book.title) + WITH { words: this_wrote.words, node: { title: this_Book.title } } AS edge + RETURN edge + UNION + WITH this + OPTIONAL MATCH (this)-[this_wrote:WROTE]->(this_Journal:Journal) + WHERE (this_Journal.subject = $this_publicationsConnection.args.where.OR[1].Journal.subject) + WITH { words: this_wrote.words, node: { subject: this_Journal.subject } } AS edge + RETURN edge + } + WITH collect(edge) as edges + RETURN { edges: edges } AS publicationsConnection +} +RETURN this { .name, publicationsConnection } as this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_publicationsConnection": { + "args": { + "where": { + "OR": [ + { + "Book": { + "title": "Book Title" + } + }, + { + "Journal": { + "subject": "Journal Subject" + } + } + ] + } + } + } +} +``` + +--- 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 new file mode 100644 index 0000000000..026ad2e2e0 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/schema/connections/unions.md @@ -0,0 +1,536 @@ +## Schema -> Connections -> Unions + +Tests that the provided typeDefs return the correct schema (with relationships). + +--- + +### Relationship Properties + +**TypeDefs** + +```typedefs-input +union Publication = Book | Journal + +type Author { + name: String! + publications: [Publication] @relationship(type: "WROTE", direction: OUT, properties: "Wrote") +} + +type Book { + title: String! + author: [Author!]! @relationship(type: "WROTE", direction: IN, properties: "Wrote") +} + +type Journal { + subject: String! + author: [Author!]! @relationship(type: "WROTE", direction: IN, properties: "Wrote") +} + +interface Wrote { + words: Int! +} +``` + +**Output** + +```schema-output +type Author { + name: String! + publications( + options: QueryOptions + Book: BookWhere + Journal: JournalWhere + ): [Publication] + publicationsConnection( + where: AuthorPublicationsConnectionWhere + ): AuthorPublicationsConnection! +} + +input AuthorConnectFieldInput { + where: AuthorWhere + connect: AuthorConnectInput +} + +input AuthorConnectInput { + publications_Book: [BookConnectFieldInput!] + publications_Journal: [JournalConnectFieldInput!] +} + +input AuthorCreateInput { + name: String! + publications_Book: AuthorPublicationsBookFieldInput + publications_Journal: AuthorPublicationsJournalFieldInput +} + +input AuthorDeleteFieldInput { + where: AuthorWhere + delete: AuthorDeleteInput +} + +input AuthorDeleteInput { + publications_Book: [AuthorPublicationsBookDeleteFieldInput!] + publications_Journal: [AuthorPublicationsJournalDeleteFieldInput!] +} + +input AuthorDisconnectFieldInput { + where: AuthorWhere + disconnect: AuthorDisconnectInput +} + +input AuthorDisconnectInput { + publications_Book: [BookDisconnectFieldInput!] + publications_Journal: [JournalDisconnectFieldInput!] +} + +input AuthorOptions { + # Specify one or more AuthorSort objects to sort Authors by. The sorts will be applied in the order in which they are arranged in the array. + sort: [AuthorSort] + limit: Int + skip: Int +} + +input AuthorPublicationsBookDeleteFieldInput { + where: BookWhere + delete: BookDeleteInput +} + +input AuthorPublicationsBookFieldInput { + create: [BookCreateInput!] + connect: [BookConnectFieldInput!] +} + +input AuthorPublicationsBookUpdateFieldInput { + where: BookWhere + update: BookUpdateInput + connect: [BookConnectFieldInput!] + disconnect: [BookDisconnectFieldInput!] + create: [BookCreateInput!] + delete: [BookDeleteFieldInput!] +} + +type AuthorPublicationsConnection { + edges: [AuthorPublicationsRelationship!]! +} + +input AuthorPublicationsConnectionWhere { + AND: [AuthorPublicationsConnectionWhere!] + OR: [AuthorPublicationsConnectionWhere!] + relationship: WroteWhere + relationship_NOT: WroteWhere + Book: BookWhere + Book_NOT: BookWhere + Journal: JournalWhere + Journal_NOT: JournalWhere +} + +input AuthorPublicationsJournalDeleteFieldInput { + where: JournalWhere + delete: JournalDeleteInput +} + +input AuthorPublicationsJournalFieldInput { + create: [JournalCreateInput!] + connect: [JournalConnectFieldInput!] +} + +input AuthorPublicationsJournalUpdateFieldInput { + where: JournalWhere + update: JournalUpdateInput + connect: [JournalConnectFieldInput!] + disconnect: [JournalDisconnectFieldInput!] + create: [JournalCreateInput!] + delete: [JournalDeleteFieldInput!] +} + +type AuthorPublicationsRelationship implements Wrote { + node: Publication! + words: Int! +} + +input AuthorRelationInput { + publications_Book: [BookCreateInput!] + publications_Journal: [JournalCreateInput!] +} + +# Fields to sort Authors by. The order in which sorts are applied is not guaranteed when specifying many fields in one AuthorSort object. +input AuthorSort { + name: SortDirection +} + +input AuthorUpdateInput { + name: String + publications_Book: [AuthorPublicationsBookUpdateFieldInput!] + publications_Journal: [AuthorPublicationsJournalUpdateFieldInput!] +} + +input AuthorWhere { + OR: [AuthorWhere!] + AND: [AuthorWhere!] + name: String + name_NOT: String + name_IN: [String] + name_NOT_IN: [String] + name_CONTAINS: String + name_NOT_CONTAINS: String + name_STARTS_WITH: String + name_NOT_STARTS_WITH: String + name_ENDS_WITH: String + name_NOT_ENDS_WITH: String +} + +type Book { + title: String! + author(where: AuthorWhere, options: AuthorOptions): [Author!]! + authorConnection( + where: BookAuthorConnectionWhere + options: BookAuthorConnectionOptions + ): BookAuthorConnection! +} + +type BookAuthorConnection { + edges: [BookAuthorRelationship!]! +} + +input BookAuthorConnectionOptions { + sort: [BookAuthorConnectionSort!] +} + +input BookAuthorConnectionSort { + node: AuthorSort + relationship: WroteSort +} + +input BookAuthorConnectionWhere { + AND: [BookAuthorConnectionWhere!] + OR: [BookAuthorConnectionWhere!] + relationship: WroteWhere + relationship_NOT: WroteWhere + node: AuthorWhere + node_NOT: AuthorWhere +} + +input BookAuthorDeleteFieldInput { + where: AuthorWhere + delete: AuthorDeleteInput +} + +input BookAuthorFieldInput { + create: [AuthorCreateInput!] + connect: [AuthorConnectFieldInput!] +} + +type BookAuthorRelationship implements Wrote { + node: Author! + words: Int! +} + +input BookAuthorUpdateFieldInput { + where: AuthorWhere + update: AuthorUpdateInput + connect: [AuthorConnectFieldInput!] + disconnect: [AuthorDisconnectFieldInput!] + create: [AuthorCreateInput!] + delete: [AuthorDeleteFieldInput!] +} + +input BookConnectFieldInput { + where: BookWhere + connect: BookConnectInput +} + +input BookConnectInput { + author: [AuthorConnectFieldInput!] +} + +input BookCreateInput { + title: String! + author: BookAuthorFieldInput +} + +input BookDeleteFieldInput { + where: BookWhere + delete: BookDeleteInput +} + +input BookDeleteInput { + author: [BookAuthorDeleteFieldInput!] +} + +input BookDisconnectFieldInput { + where: BookWhere + disconnect: BookDisconnectInput +} + +input BookDisconnectInput { + author: [AuthorDisconnectFieldInput!] +} + +input BookOptions { + # Specify one or more BookSort objects to sort Books by. The sorts will be applied in the order in which they are arranged in the array. + sort: [BookSort] + limit: Int + skip: Int +} + +input BookRelationInput { + author: [AuthorCreateInput!] +} + +# Fields to sort Books by. The order in which sorts are applied is not guaranteed when specifying many fields in one BookSort object. +input BookSort { + title: SortDirection +} + +input BookUpdateInput { + title: String + author: [BookAuthorUpdateFieldInput!] +} + +input BookWhere { + OR: [BookWhere!] + AND: [BookWhere!] + title: String + title_NOT: String + title_IN: [String] + title_NOT_IN: [String] + title_CONTAINS: String + title_NOT_CONTAINS: String + title_STARTS_WITH: String + title_NOT_STARTS_WITH: String + title_ENDS_WITH: String + title_NOT_ENDS_WITH: String + author: AuthorWhere + author_NOT: AuthorWhere +} + +type CreateAuthorsMutationResponse { + authors: [Author!]! +} + +type CreateBooksMutationResponse { + books: [Book!]! +} + +type CreateJournalsMutationResponse { + journals: [Journal!]! +} + +type DeleteInfo { + nodesDeleted: Int! + relationshipsDeleted: Int! +} + +type Journal { + subject: String! + author(where: AuthorWhere, options: AuthorOptions): [Author!]! + authorConnection( + where: JournalAuthorConnectionWhere + options: JournalAuthorConnectionOptions + ): JournalAuthorConnection! +} + +type JournalAuthorConnection { + edges: [JournalAuthorRelationship!]! +} + +input JournalAuthorConnectionOptions { + sort: [JournalAuthorConnectionSort!] +} + +input JournalAuthorConnectionSort { + node: AuthorSort + relationship: WroteSort +} + +input JournalAuthorConnectionWhere { + AND: [JournalAuthorConnectionWhere!] + OR: [JournalAuthorConnectionWhere!] + relationship: WroteWhere + relationship_NOT: WroteWhere + node: AuthorWhere + node_NOT: AuthorWhere +} + +input JournalAuthorDeleteFieldInput { + where: AuthorWhere + delete: AuthorDeleteInput +} + +input JournalAuthorFieldInput { + create: [AuthorCreateInput!] + connect: [AuthorConnectFieldInput!] +} + +type JournalAuthorRelationship implements Wrote { + node: Author! + words: Int! +} + +input JournalAuthorUpdateFieldInput { + where: AuthorWhere + update: AuthorUpdateInput + connect: [AuthorConnectFieldInput!] + disconnect: [AuthorDisconnectFieldInput!] + create: [AuthorCreateInput!] + delete: [AuthorDeleteFieldInput!] +} + +input JournalConnectFieldInput { + where: JournalWhere + connect: JournalConnectInput +} + +input JournalConnectInput { + author: [AuthorConnectFieldInput!] +} + +input JournalCreateInput { + subject: String! + author: JournalAuthorFieldInput +} + +input JournalDeleteFieldInput { + where: JournalWhere + delete: JournalDeleteInput +} + +input JournalDeleteInput { + author: [JournalAuthorDeleteFieldInput!] +} + +input JournalDisconnectFieldInput { + where: JournalWhere + disconnect: JournalDisconnectInput +} + +input JournalDisconnectInput { + author: [AuthorDisconnectFieldInput!] +} + +input JournalOptions { + # Specify one or more JournalSort objects to sort Journals by. The sorts will be applied in the order in which they are arranged in the array. + sort: [JournalSort] + limit: Int + skip: Int +} + +input JournalRelationInput { + author: [AuthorCreateInput!] +} + +# Fields to sort Journals by. The order in which sorts are applied is not guaranteed when specifying many fields in one JournalSort object. +input JournalSort { + subject: SortDirection +} + +input JournalUpdateInput { + subject: String + author: [JournalAuthorUpdateFieldInput!] +} + +input JournalWhere { + OR: [JournalWhere!] + AND: [JournalWhere!] + subject: String + subject_NOT: String + subject_IN: [String] + subject_NOT_IN: [String] + subject_CONTAINS: String + subject_NOT_CONTAINS: String + subject_STARTS_WITH: String + subject_NOT_STARTS_WITH: String + subject_ENDS_WITH: String + subject_NOT_ENDS_WITH: String + author: AuthorWhere + author_NOT: AuthorWhere +} + +type Mutation { + createAuthors(input: [AuthorCreateInput!]!): CreateAuthorsMutationResponse! + deleteAuthors(where: AuthorWhere, delete: AuthorDeleteInput): DeleteInfo! + updateAuthors( + where: AuthorWhere + update: AuthorUpdateInput + connect: AuthorConnectInput + disconnect: AuthorDisconnectInput + create: AuthorRelationInput + delete: AuthorDeleteInput + ): UpdateAuthorsMutationResponse! + createBooks(input: [BookCreateInput!]!): CreateBooksMutationResponse! + deleteBooks(where: BookWhere, delete: BookDeleteInput): DeleteInfo! + updateBooks( + where: BookWhere + update: BookUpdateInput + connect: BookConnectInput + disconnect: BookDisconnectInput + create: BookRelationInput + delete: BookDeleteInput + ): UpdateBooksMutationResponse! + createJournals(input: [JournalCreateInput!]!): CreateJournalsMutationResponse! + deleteJournals(where: JournalWhere, delete: JournalDeleteInput): DeleteInfo! + updateJournals( + where: JournalWhere + update: JournalUpdateInput + connect: JournalConnectInput + disconnect: JournalDisconnectInput + create: JournalRelationInput + delete: JournalDeleteInput + ): UpdateJournalsMutationResponse! +} + +union Publication = Book | Journal + +type Query { + authors(where: AuthorWhere, options: AuthorOptions): [Author]! + books(where: BookWhere, options: BookOptions): [Book]! + journals(where: JournalWhere, options: JournalOptions): [Journal]! +} + +input QueryOptions { + skip: Int + limit: Int +} + +enum SortDirection { + # Sort by field values in ascending order. + ASC + + # Sort by field values in descending order. + DESC +} + +type UpdateAuthorsMutationResponse { + authors: [Author!]! +} + +type UpdateBooksMutationResponse { + books: [Book!]! +} + +type UpdateJournalsMutationResponse { + journals: [Journal!]! +} + +interface Wrote { + words: Int! +} + +input WroteSort { + words: SortDirection +} + +input WroteWhere { + OR: [WroteWhere!] + AND: [WroteWhere!] + words: Int + words_NOT: Int + words_IN: [Int] + words_NOT_IN: [Int] + words_LT: Int + words_LTE: Int + words_GT: Int + words_GTE: 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 010952bab4..b1be539a62 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/unions.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/unions.md @@ -89,9 +89,27 @@ input GenreWhere { type Movie { id: ID search(options: QueryOptions, Genre: GenreWhere, Movie: MovieWhere): [Search] + searchConnection(where: MovieSearchConnectionWhere): MovieSearchConnection! searchNoDirective: Search } +type MovieSearchConnection { + edges: [MovieSearchRelationship!]! +} + +input MovieSearchConnectionWhere { + AND: [MovieSearchConnectionWhere!] + Genre: GenreWhere + Genre_NOT: GenreWhere + Movie: MovieWhere + Movie_NOT: MovieWhere + OR: [MovieSearchConnectionWhere!] +} + +type MovieSearchRelationship { + node: Search! +} + input MovieConnectFieldInput { where: MovieWhere connect: MovieConnectInput