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

Filter by extension

Filter by extension

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

Add support for `@cypher` directive in relationship properties
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@
* limitations under the License.
*/

import type { ASTVisitor, FieldDefinitionNode } from "graphql";
import type { ASTNode, ASTVisitor, FieldDefinitionNode } from "graphql";
import { cypherDirective } from "../../../../graphql/directives";
import type { Neo4jValidationContext } from "../../Neo4jValidationContext";
import type { Neo4jValidationContext, TypeMapWithExtensions } from "../../Neo4jValidationContext";
import { assertValid, createGraphQLError, DocumentValidationError } from "../utils/document-validation-error";
import { fieldIsInNodeType } from "../utils/location-helpers/is-in-node-type";
import { fieldIsInRelationshipPropertiesType } from "../utils/location-helpers/is-in-relationship-properties-type";
import { fieldIsInRootType } from "../utils/location-helpers/is-in-root-type";
import { fieldIsInSubscriptionType } from "../utils/location-helpers/is-in-subscription-type";
import { getPathToNode } from "../utils/path-parser";
Expand All @@ -39,10 +40,8 @@ export function validateCypherDirective(context: Neo4jValidationContext): ASTVis
) {
return;
}
const isValidLocation =
(fieldIsInNodeType({ path, ancestors, typeMapWithExtensions }) ||
fieldIsInRootType({ path, ancestors, typeMapWithExtensions })) &&
!fieldIsInSubscriptionType({ path, ancestors, typeMapWithExtensions });

const isValidLocation = isCypherLocationValid({ path, ancestors, typeMapWithExtensions });

const { isValid, errorMsg } = assertValid(() => {
if (!isValidLocation) {
Expand All @@ -66,3 +65,19 @@ export function validateCypherDirective(context: Neo4jValidationContext): ASTVis
},
};
}

function isCypherLocationValid(directiveLocationData: {
path: readonly (string | number)[];
ancestors: readonly (ASTNode | readonly ASTNode[])[];
typeMapWithExtensions: TypeMapWithExtensions;
}): boolean {
if (fieldIsInSubscriptionType(directiveLocationData)) {
return false;
}

return (
fieldIsInNodeType(directiveLocationData) ||
fieldIsInRootType(directiveLocationData) ||
fieldIsInRelationshipPropertiesType(directiveLocationData)
);
}
85 changes: 0 additions & 85 deletions packages/graphql/src/schema/validation/validate-document.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5240,46 +5240,6 @@ describe("validation 2.0", () => {
expect(errors[0]).toHaveProperty("path", ["ActedIn", "actors", "@relationship"]);
});

test("should throw error if @cypher is used on relationship property", () => {
const relationshipProperties = gql`
type ActedIn @relationshipProperties {
id: ID @cypher(statement: "RETURN id(this) as id", columnName: "id")
roles: [String!]
}
`;
const doc = gql`
${relationshipProperties}
type Movie @node {
actors: [Actor!]! @relationship(type: "ACTED_IN", direction: OUT, properties: "ActedIn")
}

type Actor @node {
name: String
}
`;

const enums = [] as EnumTypeDefinitionNode[];
const interfaces = [] as InterfaceTypeDefinitionNode[];
const unions = [] as UnionTypeDefinitionNode[];
const objects = relationshipProperties.definitions as ObjectTypeDefinitionNode[];
const executeValidate = () =>
validateDocument({
document: doc,
additionalDefinitions: { enums, interfaces, unions, objects },
features: {},
});

const errors = getError(executeValidate);

expect(errors).toHaveLength(1);
expect(errors[0]).not.toBeInstanceOf(NoErrorThrownError);
expect(errors[0]).toHaveProperty(
"message",
'Directive "cypher" must be in a type with "@node" or on root types: Query, and Mutation'
);
expect(errors[0]).toHaveProperty("path", ["ActedIn", "id", "@cypher"]);
});

test("@relationshipProperties reserved field name", () => {
const relationshipProperties = gql`
type HasPost @relationshipProperties {
Expand Down Expand Up @@ -5318,51 +5278,6 @@ describe("validation 2.0", () => {
);
expect(errors[0]).toHaveProperty("path", ["HasPost", "cursor"]);
});

test("@cypher forbidden on @relationshipProperties field", () => {
const relationshipProperties = gql`
type HasPost @relationshipProperties {
review: Float
@cypher(
statement: """
WITH 2 as x RETURN x
"""
columnName: "x"
)
}
`;
const doc = gql`
${relationshipProperties}
type User @node {
name: String
posts: [Post!]! @relationship(type: "HAS_POST", direction: OUT, properties: "HasPost")
}
type Post @node {
title: String
}
`;

const enums = [] as EnumTypeDefinitionNode[];
const interfaces = [] as InterfaceTypeDefinitionNode[];
const unions = [] as UnionTypeDefinitionNode[];
const objects = relationshipProperties.definitions as ObjectTypeDefinitionNode[];
const executeValidate = () =>
validateDocument({
document: doc,
additionalDefinitions: { enums, interfaces, unions, objects },
features: {},
});

const errors = getError(executeValidate);

expect(errors).toHaveLength(1);
expect(errors[0]).not.toBeInstanceOf(NoErrorThrownError);
expect(errors[0]).toHaveProperty(
"message",
'Directive "cypher" must be in a type with "@node" or on root types: Query, and Mutation'
);
expect(errors[0]).toHaveProperty("path", ["HasPost", "review", "@cypher"]);
});
});

describe("valid", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import Cypher from "@neo4j/cypher-builder";
import type { ConcreteEntityAdapter } from "../../../../schema-model/entity/model-adapters/ConcreteEntityAdapter";
import type { InterfaceEntityAdapter } from "../../../../schema-model/entity/model-adapters/InterfaceEntityAdapter";
import type { RelationshipAdapter } from "../../../../schema-model/relationship/model-adapters/RelationshipAdapter";
import { filterTruthy } from "../../../../utils/utils";
import { hasTarget } from "../../utils/context-has-target";
import { getEntityLabels } from "../../utils/create-node-from-entity";
import { isConcreteEntity } from "../../utils/is-concrete-entity";
Expand Down Expand Up @@ -206,7 +207,7 @@ export class ConnectionFilter extends Filter {
const subqueries = this.innerFilters.flatMap((f) => {
const nestedSubqueries = f
.getSubqueries(queryASTContext)
.map((sq) => new Cypher.Call(sq, [queryASTContext.target]));
.map((sq) => new Cypher.Call(sq, filterTruthy([queryASTContext.target, queryASTContext.relationship])));
const selection = f.getSelection(queryASTContext);
const predicate = f.getPredicate(queryASTContext);
const clauses = [...selection, ...nestedSubqueries];
Expand All @@ -217,7 +218,6 @@ export class ConnectionFilter extends Filter {

return clauses;
});

if (subqueries.length === 0) return []; // Hack logic to change predicates logic

const comparisonValue = this.operator === "NONE" ? Cypher.false : Cypher.true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,11 +170,11 @@ export class ConnectionReadOperation extends Operation {
}

const authFilterSubqueries = this.getAuthFilterSubqueries(nestedContext).map((sq) => {
return new Cypher.Call(sq, [nestedContext.target]);
return new Cypher.Call(sq, filterTruthy([nestedContext.target, nestedContext.relationship]));
});

const normalFilterSubqueries = this.getFilterSubqueries(nestedContext).map((sq) => {
return new Cypher.Call(sq, [nestedContext.target]);
return new Cypher.Call(sq, filterTruthy([nestedContext.target, nestedContext.relationship]));
});

const filtersSubqueries = [...authFilterSubqueries, ...normalFilterSubqueries];
Expand Down Expand Up @@ -460,6 +460,7 @@ export class ConnectionReadOperation extends Operation {
throw new Error("No parent node found!");
}
const sortNodeFields = this.sortFields.flatMap((sf) => sf.node);
const sortEdgeFields = this.sortFields.flatMap((sf) => sf.edge);
/**
* cypherSortFieldsFlagMap is a Record<string, boolean> that holds the name of the sort field as key
* and a boolean flag defined as true when the field is a `@cypher` field.
Expand All @@ -473,8 +474,17 @@ export class ConnectionReadOperation extends Operation {
},
{}
);
const cypherSortFieldsEdgeFlagMap = sortEdgeFields.reduce<Record<string, boolean>>(
(sortFieldsFlagMap, sortField) => {
if (sortField instanceof CypherPropertySort) {
sortFieldsFlagMap[sortField.getFieldName()] = true;
}
return sortFieldsFlagMap;
},
{}
);

const preAndPostFields = this.nodeFields.reduce<Record<"Pre" | "Post", Field[]>>(
const preAndPostFields = [...this.nodeFields].reduce<Record<"Pre" | "Post", Field[]>>(
(acc, nodeField) => {
if (
nodeField instanceof OperationField &&
Expand All @@ -493,13 +503,49 @@ export class ConnectionReadOperation extends Operation {
},
{ Pre: [], Post: [] }
);

const preAndPostEdgeFields = [...this.edgeFields].reduce<Record<"Pre" | "Post", Field[]>>(
(acc, edgeField) => {
if (
edgeField instanceof OperationField &&
edgeField.isCypherField() &&
edgeField.operation instanceof CypherAttributeOperation
) {
const cypherFieldName = edgeField.operation.cypherAttributeField.name;
if (cypherSortFieldsEdgeFlagMap[cypherFieldName]) {
acc.Pre.push(edgeField);
return acc;
}
}

acc.Post.push(edgeField);
return acc;
},
{ Pre: [], Post: [] }
);
const preNodeSubqueries = wrapSubqueriesInCypherCalls(context, preAndPostFields.Pre, [context.target]);
const postNodeSubqueries = wrapSubqueriesInCypherCalls(context, preAndPostFields.Post, [context.target]);

let preEdgeSubqueries: Cypher.Clause[] = [];
let postEdgeSubqueries: Cypher.Clause[] = [];
let sortEdgeSubqueries: Cypher.Clause[] = [];
if (context.relationship) {
preEdgeSubqueries = wrapSubqueriesInCypherCalls(context, preAndPostEdgeFields.Pre, [context.relationship]);
postEdgeSubqueries = wrapSubqueriesInCypherCalls(context, preAndPostEdgeFields.Post, [
context.relationship,
]);
sortEdgeSubqueries = wrapSubqueriesInCypherCalls(context, sortEdgeFields, [context.relationship]);
}
const sortSubqueries = wrapSubqueriesInCypherCalls(context, sortNodeFields, [context.target]);

return {
prePaginationSubqueries: [...sortSubqueries, ...preNodeSubqueries],
postPaginationSubqueries: postNodeSubqueries,
prePaginationSubqueries: [
...sortSubqueries,
...sortEdgeSubqueries,
...preNodeSubqueries,
...preEdgeSubqueries,
],
postPaginationSubqueries: [...postNodeSubqueries, ...postEdgeSubqueries],
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ export class CompositeConnectionPartial extends ConnectionReadOperation {
const nodeProjectionSubqueries = wrapSubqueriesInCypherCalls(nestedContext, this.nodeFields, [
nestedContext.target,
]);

let edgeProjectionSubqueries: Array<Cypher.Clause> = [];
if (nestedContext.relationship) {
edgeProjectionSubqueries = wrapSubqueriesInCypherCalls(nestedContext, this.edgeFields, [
nestedContext.relationship,
]);
}
const nodeProjectionMap = new Cypher.Map();

// This bit is different than normal connection ops
Expand Down Expand Up @@ -131,6 +138,7 @@ export class CompositeConnectionPartial extends ConnectionReadOperation {
withWhere,
...validations,
...nodeProjectionSubqueries,
...edgeProjectionSubqueries,
projectionClauses
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,21 @@ export class CustomCypherSelection extends EntitySelection {
private rawArguments: Record<string, any>;
private cypherAnnotation: CypherAnnotation;
private isNested: boolean;
private attachedTo: "node" | "relationship";

/**
* @param targetRelationship - Should this selector use the relationship variable of the context as "this" target in the Cypher? (use it for edge props)
*/
constructor({
operationField,
rawArguments = {},
isNested,
attachedTo = "node",
}: {
operationField: AttributeAdapter;
rawArguments: Record<string, any>;
isNested: boolean;
attachedTo?: "node" | "relationship";
}) {
super();
this.operationField = operationField;
Expand All @@ -51,6 +57,7 @@ export class CustomCypherSelection extends EntitySelection {
throw new Error("Missing Cypher Annotation on Cypher field");
}
this.cypherAnnotation = this.operationField.annotations.cypher;
this.attachedTo = attachedTo;
}

public apply(context: QueryASTContext): {
Expand Down Expand Up @@ -80,10 +87,11 @@ export class CustomCypherSelection extends EntitySelection {

let statementSubquery: Cypher.Call;

if (this.isNested && context.target) {
const aliasTargetToPublicTarget = new Cypher.With([context.target, CYPHER_TARGET_VARIABLE]);
const nestedTarget = this.attachedTo === "relationship" ? context.relationship : context.target;
if (this.isNested && nestedTarget) {
const aliasTargetToPublicTarget = new Cypher.With([nestedTarget, CYPHER_TARGET_VARIABLE]);
statementSubquery = new Cypher.Call(Cypher.utils.concat(aliasTargetToPublicTarget, statementCypherQuery), [
context.target,
nestedTarget,
]);
} else {
statementSubquery = new Cypher.Call(statementCypherQuery);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export class CypherPropertySort extends Sort {
}

public getChildren(): QueryASTNode[] {
return [];
return [this.cypherOperation];
}

public print(): string {
Expand Down
Loading
Loading