Skip to content

Commit

Permalink
Merge pull request #5723 from mjfwebb/cypher-filtering-1-to-1-relatio…
Browse files Browse the repository at this point in the history
…nships

Cypher filtering 1 to 1 relationships

(cherry picked from commit d40590e)
  • Loading branch information
mjfwebb committed Nov 1, 2024
1 parent 3b37c3d commit c581f9e
Show file tree
Hide file tree
Showing 17 changed files with 3,772 additions and 60 deletions.
5 changes: 5 additions & 0 deletions .changeset/seven-bobcats-carry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@neo4j/graphql": minor
---

Add filtering on 1 to 1 relationship custom cypher fields
5 changes: 5 additions & 0 deletions packages/graphql/src/schema-model/Neo4jGraphQLSchemaModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ export class Neo4jGraphQLSchemaModel {
return concreteEntity ? new ConcreteEntityAdapter(concreteEntity) : undefined;
}

public getConcreteEntity(name: string): ConcreteEntity | undefined {
const concreteEntity = this.concreteEntities.find((entity) => entity.name === name);
return concreteEntity;
}

public getEntitiesByLabels(labels: string[]): ConcreteEntity[] {
return this.concreteEntities.filter((entity) => entity.matchLabels(labels));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@
* limitations under the License.
*/

import type { ConcreteEntity } from "../entity/ConcreteEntity";
import type { Annotation } from "./Annotation";

export class CypherAnnotation implements Annotation {
readonly name = "cypher";
public statement: string;
public columnName: string;
public targetEntity?: ConcreteEntity;

constructor({ statement, columnName }: { statement: string; columnName: string }) {
this.statement = statement;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,16 @@ export class AttributeAdapter {
);
}

isCypherRelationshipField(): boolean {
return this.isCypher() && Boolean(this.annotations.cypher?.targetEntity);
}

isWhereField(): boolean {
return (
(this.typeHelper.isEnum() || this.typeHelper.isSpatial() || this.typeHelper.isScalar()) &&
(this.typeHelper.isEnum() ||
this.typeHelper.isSpatial() ||
this.typeHelper.isScalar() ||
this.isCypherRelationshipField()) &&
this.isFilterable() &&
!this.isCustomResolvable()
);
Expand Down
32 changes: 28 additions & 4 deletions packages/graphql/src/schema-model/generate-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,23 +25,27 @@ import type {
UnionTypeDefinitionNode,
} from "graphql";
import { Neo4jGraphQLSchemaValidationError } from "../classes";
import { SCHEMA_CONFIGURATION_OBJECT_DIRECTIVES } from "./library-directives";
import {
declareRelationshipDirective,
nodeDirective,
privateDirective,
relationshipDirective,
} from "../graphql/directives";
import getFieldTypeMeta from "../schema/get-field-type-meta";
import { getInnerTypeName } from "../schema/validation/custom-rules/utils/utils";
import { isInArray } from "../utils/is-in-array";
import { filterTruthy } from "../utils/utils";
import type { Operations } from "./Neo4jGraphQLSchemaModel";
import { Neo4jGraphQLSchemaModel } from "./Neo4jGraphQLSchemaModel";
import { Operation } from "./Operation";
import type { Attribute } from "./attribute/Attribute";
import { ObjectType } from "./attribute/AttributeType";
import type { CompositeEntity } from "./entity/CompositeEntity";
import { ConcreteEntity } from "./entity/ConcreteEntity";
import type { Entity } from "./entity/Entity";
import { InterfaceEntity } from "./entity/InterfaceEntity";
import { UnionEntity } from "./entity/UnionEntity";
import { SCHEMA_CONFIGURATION_OBJECT_DIRECTIVES } from "./library-directives";
import type { DefinitionCollection } from "./parser/definition-collection";
import { getDefinitionCollection } from "./parser/definition-collection";
import { parseAnnotations } from "./parser/parse-annotation";
Expand All @@ -50,10 +54,7 @@ import { parseAttribute, parseAttributeArguments } from "./parser/parse-attribut
import { findDirective } from "./parser/utils";
import type { NestedOperation, QueryDirection, RelationshipDirection } from "./relationship/Relationship";
import { Relationship } from "./relationship/Relationship";
import { isInArray } from "../utils/is-in-array";
import { RelationshipDeclaration } from "./relationship/RelationshipDeclaration";
import type { Entity } from "./entity/Entity";
import { getInnerTypeName } from "../schema/validation/custom-rules/utils/utils";

export function generateModel(document: DocumentNode): Neo4jGraphQLSchemaModel {
const definitionCollection: DefinitionCollection = getDefinitionCollection(document);
Expand Down Expand Up @@ -111,6 +112,9 @@ export function generateModel(document: DocumentNode): Neo4jGraphQLSchemaModel {
annotations,
});
definitionCollection.nodes.forEach((def) => hydrateRelationships(def, schema, definitionCollection));

hydrateCypherAnnotations(schema, concreteEntities);

definitionCollection.interfaceTypes.forEach((def) =>
hydrateRelationshipDeclarations(def, schema, definitionCollection)
);
Expand All @@ -127,6 +131,25 @@ function addCompositeEntitiesToConcreteEntity(compositeEntities: CompositeEntity
});
}

function hydrateCypherAnnotations(schema: Neo4jGraphQLSchemaModel, concreteEntities: ConcreteEntity[]) {
for (const concreteEntity of concreteEntities) {
for (const attributeField of concreteEntity.attributes.values()) {
if (attributeField.annotations.cypher) {
if (attributeField.type instanceof ObjectType) {
const foundConcreteEntity = schema.getConcreteEntity(attributeField.type.name);
if (!foundConcreteEntity) {
throw new Neo4jGraphQLSchemaValidationError(
`Could not find concrete entity with name ${attributeField.type.name}`
);
}

attributeField.annotations.cypher.targetEntity = foundConcreteEntity;
}
}
}
}
}

function hydrateInterfacesToTypeNamesMap(definitionCollection: DefinitionCollection) {
return definitionCollection.nodes.forEach((node) => {
if (!node.interfaces) {
Expand Down Expand Up @@ -261,6 +284,7 @@ function hydrateRelationships(
}
}
}

function hydrateRelationshipDeclarations(
definition: InterfaceTypeDefinitionNode,
schema: Neo4jGraphQLSchemaModel,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@
*/
import type { DirectiveNode } from "graphql";
import { Neo4jGraphQLSchemaValidationError } from "../../../classes";
import { cypherDirective } from "../../../graphql/directives";
import { CypherAnnotation } from "../../annotation/CypherAnnotation";
import { parseArguments } from "../parse-arguments";
import { cypherDirective } from "../../../graphql/directives";

export function parseCypherAnnotation(directive: DirectiveNode): CypherAnnotation {
const { statement, columnName } = parseArguments(cypherDirective, directive);
Expand Down
6 changes: 3 additions & 3 deletions packages/graphql/src/schema/generation/augment-where-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import type { Directive, InputTypeComposerFieldConfigMapDefinition } from "graph
import type { RelationshipAdapter } from "../../schema-model/relationship/model-adapters/RelationshipAdapter";
import type { RelationshipDeclarationAdapter } from "../../schema-model/relationship/model-adapters/RelationshipDeclarationAdapter";

function augmentWhereInputType({
function augmentRelationshipWhereInputType({
whereType,
fieldName,
filters,
Expand Down Expand Up @@ -69,7 +69,7 @@ export function augmentWhereInputTypeWithRelationshipFields(
deprecatedDirectives: Directive[]
): InputTypeComposerFieldConfigMapDefinition {
const filters = relationshipAdapter.listFiltersModel?.filters;
return augmentWhereInputType({
return augmentRelationshipWhereInputType({
whereType: relationshipAdapter.target.operations.whereInputTypeName,
fieldName: relationshipAdapter.name,
filters,
Expand All @@ -83,7 +83,7 @@ export function augmentWhereInputTypeWithConnectionFields(
deprecatedDirectives: Directive[]
): InputTypeComposerFieldConfigMapDefinition {
const filters = relationshipAdapter.listFiltersModel?.connectionFilters;
return augmentWhereInputType({
return augmentRelationshipWhereInputType({
whereType: relationshipAdapter.operations.getConnectionWhereTypename(),
fieldName: relationshipAdapter.operations.connectionFieldName,
filters,
Expand Down
17 changes: 17 additions & 0 deletions packages/graphql/src/schema/get-where-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type { DirectiveNode } from "graphql";
import type { Directive } from "graphql-compose";
import { DEPRECATED } from "../constants";
import type { AttributeAdapter } from "../schema-model/attribute/model-adapters/AttributeAdapter";
import { ConcreteEntityAdapter } from "../schema-model/entity/model-adapters/ConcreteEntityAdapter";
import type { Neo4jFeaturesSettings } from "../types";
import { DEPRECATE_IMPLICIT_EQUAL_FILTERS } from "./constants";
import { shouldAddDeprecatedFields } from "./generation/utils";
Expand Down Expand Up @@ -69,6 +70,22 @@ export function getWhereFieldsForAttributes({
if (field.args.length > 0) {
continue;
}

if (field.annotations.cypher.targetEntity) {
const targetEntityAdapter = new ConcreteEntityAdapter(field.annotations.cypher.targetEntity);
result[field.name] = {
type: targetEntityAdapter.operations.whereInputTypeName,
directives: deprecatedDirectives,
};

if (shouldAddDeprecatedFields(features, "negationFilters")) {
result[`${field.name}_NOT`] = {
type: targetEntityAdapter.operations.whereInputTypeName,
directives: deprecatedDirectives.length ? deprecatedDirectives : [DEPRECATE_NOT],
};
}
continue;
}
}

if (shouldAddDeprecatedFields(features, "implicitEqualFilters")) {
Expand Down
11 changes: 10 additions & 1 deletion packages/graphql/src/translate/queryAST/ast/QueryASTContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ export class QueryASTContext<T extends Cypher.Node | undefined = Cypher.Node | u
public readonly shouldCollect: boolean; // temporary hack to describe if we should collect the return variable (used for mutation response)
public readonly shouldDistinct: boolean; // temporary hack to describe if we should distinct the return variable (used for mutation response)


public env: QueryASTEnv;
public neo4jGraphQLContext: Neo4jGraphQLTranslationContext;

Expand Down Expand Up @@ -143,4 +142,14 @@ export class QueryASTContext<T extends Cypher.Node | undefined = Cypher.Node | u
returnVariable: variable,
});
}

public setTarget(target: Cypher.Node): QueryASTContext<Cypher.Node> {
return new QueryASTContext({
source: this.target,
target,
env: this.env,
neo4jGraphQLContext: this.neo4jGraphQLContext,
returnVariable: this.returnVariable,
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
* 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 Cypher from "@neo4j/cypher-builder";
import type { AttributeAdapter } from "../../../../schema-model/attribute/model-adapters/AttributeAdapter";
import type { RelationshipWhereOperator } from "../../../where/types";
import type { QueryASTContext } from "../QueryASTContext";
import type { QueryASTNode } from "../QueryASTNode";
import type { CustomCypherSelection } from "../selection/CustomCypherSelection";
import { Filter } from "./Filter";

export class CypherOneToOneRelationshipFilter extends Filter {
private returnVariable: Cypher.Node;
private attribute: AttributeAdapter;
private selection: CustomCypherSelection;
private operator: RelationshipWhereOperator;
private targetNodeFilters: Filter[] = [];
private isNot: boolean;
private isNull: boolean;

constructor({
selection,
attribute,
operator,
isNot,
isNull,
returnVariable,
}: {
selection: CustomCypherSelection;
attribute: AttributeAdapter;
operator: RelationshipWhereOperator;
isNot: boolean;
isNull: boolean;
returnVariable: Cypher.Node;
}) {
super();
this.selection = selection;
this.attribute = attribute;
this.isNot = isNot;
this.isNull = isNull;
this.operator = operator;
this.returnVariable = returnVariable;
}

public getChildren(): QueryASTNode[] {
return [...this.targetNodeFilters, this.selection];
}

public addTargetNodeFilter(...filter: Filter[]): void {
this.targetNodeFilters.push(...filter);
}

public print(): string {
return `${super.print()} [${this.attribute.name}] <${this.isNot ? "NOT " : ""}${this.operator}>`;
}

public getSubqueries(context: QueryASTContext): Cypher.Clause[] {
const { selection, nestedContext } = this.selection.apply(context);

const cypherSubquery = selection.return([
Cypher.head(Cypher.collect(nestedContext.returnVariable)),
this.returnVariable,
]);

return [cypherSubquery];
}

public getPredicate(queryASTContext: QueryASTContext): Cypher.Predicate | undefined {
const context = queryASTContext.setTarget(this.returnVariable);

const predicate = this.createRelationshipOperation(context);
if (predicate) {
return this.wrapInNotIfNeeded(predicate);
}
}

private createRelationshipOperation(queryASTContext: QueryASTContext): Cypher.Predicate | undefined {
const targetNodePredicates = this.targetNodeFilters.map((c) => c.getPredicate(queryASTContext));
const innerPredicate = Cypher.and(...targetNodePredicates);

if (this.isNull) {
return Cypher.and(innerPredicate, Cypher.isNull(this.returnVariable));
}

return innerPredicate;
}

private wrapInNotIfNeeded(predicate: Cypher.Predicate): Cypher.Predicate {
if (this.isNot) {
return Cypher.not(predicate);
}

return predicate;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,17 @@ import type { AttributeAdapter } from "../../../schema-model/attribute/model-ada
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 { getEntityAdapter } from "../../../schema-model/utils/get-entity-adapter";
import type { GraphQLWhereArg } from "../../../types";
import type { Neo4jGraphQLTranslationContext } from "../../../types/neo4j-graphql-translation-context";
import { asArray } from "../../../utils/utils";
import { isLogicalOperator } from "../../utils/logical-operators";
import type { ConnectionFilter } from "../ast/filters/ConnectionFilter";
<<<<<<< HEAD
import type { Filter, FilterOperator, RelationshipWhereOperator } from "../ast/filters/Filter";
=======
import { isRelationshipOperator, type Filter } from "../ast/filters/Filter";
>>>>>>> d40590e36 (Merge pull request #5723 from mjfwebb/cypher-filtering-1-to-1-relationships)
import { LogicalFilter } from "../ast/filters/LogicalFilter";
import type { RelationshipFilter } from "../ast/filters/RelationshipFilter";
import { AuthConnectionFilter } from "../ast/filters/authorization-filters/AuthConnectionFilter";
Expand Down Expand Up @@ -140,8 +145,13 @@ export class AuthFilterFactory extends FilterFactory {
isNot: boolean;
attachedTo?: "node" | "relationship";
relationship?: RelationshipAdapter;
<<<<<<< HEAD
}): CypherFilter | PropertyFilter {
const filterOperator = operator ?? "EQ";
=======
}): Filter {
const filterOperator = operator || "EQ";
>>>>>>> d40590e36 (Merge pull request #5723 from mjfwebb/cypher-filtering-1-to-1-relationships)

const isCypherVariable =
comparisonValue instanceof Cypher.Variable ||
Expand All @@ -155,6 +165,28 @@ export class AuthFilterFactory extends FilterFactory {
isNested: true,
});

if (attribute.annotations.cypher?.targetEntity) {
const entityAdapter = getEntityAdapter(attribute.annotations.cypher.targetEntity);

if (operator && !isRelationshipOperator(operator)) {
throw new Error(`Invalid operator ${operator} for relationship`);
}

return new LogicalFilter({
operation: "AND",
filters: this.createCypherRelationshipFilter({
where: comparisonValue as GraphQLWhereArg,
selection,
target: entityAdapter,
filterOps: {
isNot,
operator,
},
attribute,
}),
});
}

if (isCypherVariable) {
return new CypherFilter({
selection,
Expand Down
Loading

0 comments on commit c581f9e

Please sign in to comment.