Skip to content

Commit

Permalink
feat: Add support for the COUNT {} sub-query expressions. (#546)
Browse files Browse the repository at this point in the history
This adds support for create Neo4j 5.x compatible `COUNT {}` sub-query expressions to the DSL. Expressions of that type can be built via `Expressions#count`. This change adds the general infrastructure for this feature and supports all the use-cases from the official Neo4j documentation. Anything not working or missing will be added in subsequent changes.

Closes #525.
  • Loading branch information
michael-simons authored Jan 6, 2023
1 parent a3baa05 commit 251838b
Show file tree
Hide file tree
Showing 22 changed files with 463 additions and 92 deletions.
4 changes: 2 additions & 2 deletions neo4j-cypher-dsl-parser/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -433,8 +433,8 @@ MATCH (person:`Person`) MERGE (city:`City` {name: person.bornIn}) MERGE (person)
MATCH (person:`Person`) MERGE (person)-[r:`HAS_CHAUFFEUR`]->(chauffeur:`Chauffeur` {name: person.chauffeurName}) RETURN person.name, person.chauffeurName, chauffeur
MATCH p = (a)-->(b)-->(c) WHERE (a.name = 'Alice' AND b.name = 'Bob' AND c.name = 'Daniel') RETURN reduce(totalAge = 0, n IN nodes(p) | (totalAge + n.age)) AS reduction
MATCH (p:`Person`)-[r:`IS_FRIENDS_WITH`]->(friend:`Person`) WHERE exists((p)-[:`WORKS_FOR`]->(:`Company` {name: 'Neo4j'})) RETURN p, r, friend
MATCH (p:`Person`)-[r:`IS_FRIENDS_WITH`]->(friend:`Person`) WHERE EXISTS {MATCH (p)-[:`WORKS_FOR`]->(:`Company` {name: 'Neo4j'})} RETURN p, r, friend
MATCH (person:`Person`)-[:`WORKS_FOR`]->(company) WHERE (company.name STARTS WITH 'Company' AND EXISTS {MATCH (person)-[:`LIKES`]->(t:`Technology`) WHERE size((t)<-[:`LIKES`]-()) >= 3}) RETURN person.name AS person, company.name AS company
MATCH (p:`Person`)-[r:`IS_FRIENDS_WITH`]->(friend:`Person`) WHERE EXISTS { MATCH (p)-[:`WORKS_FOR`]->(:`Company` {name: 'Neo4j'}) } RETURN p, r, friend
MATCH (person:`Person`)-[:`WORKS_FOR`]->(company) WHERE (company.name STARTS WITH 'Company' AND EXISTS { MATCH (person)-[:`LIKES`]->(t:`Technology`) WHERE size((t)<-[:`LIKES`]-()) >= 3 }) RETURN person.name AS person, company.name AS company
CALL {MATCH (p:`Person`)-[:`LIKES`]->(:`Technology` {type: 'Java'}) RETURN p UNION MATCH (p:`Person`) WHERE size((p)-[:`IS_FRIENDS_WITH`]->()) > 1 RETURN p} RETURN p.name AS person, p.birthdate AS dob ORDER BY dob DESC
MATCH p = (start)-[*]->(finish) WHERE (start.name = 'A' AND finish.name = 'D') FOREACH (n IN nodes(p) | SET n.marked = true)
MATCH (a) WHERE a.name = 'Eskil' RETURN a.array, [x IN a.array WHERE size(x) = 3]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,12 @@
import org.neo4j.cypherdsl.core.Clauses;
import org.neo4j.cypherdsl.core.Condition;
import org.neo4j.cypherdsl.core.Conditions;
import org.neo4j.cypherdsl.core.CountExpression;
import org.neo4j.cypherdsl.core.Cypher;
import org.neo4j.cypherdsl.core.ExposesPatternLengthAccessors;
import org.neo4j.cypherdsl.core.ExposesProperties;
import org.neo4j.cypherdsl.core.ExposesRelationships;
import org.neo4j.cypherdsl.core.Expression;
import org.neo4j.cypherdsl.core.Expressions;
import org.neo4j.cypherdsl.core.FunctionInvocation;
import org.neo4j.cypherdsl.core.Functions;
import org.neo4j.cypherdsl.core.Hint;
Expand Down Expand Up @@ -1412,7 +1412,13 @@ public Expression countExpression(InputPosition p, List<PatternElement> patternE
condition = capturedCondition.get();
}

return CountExpression.of(elementsAndWhere.elements(), Optional.ofNullable(condition));

var elements = elementsAndWhere.elements();
var count = Expressions.count(elements.get(0), elements.subList(1, elements.size()).toArray(PatternElement[]::new));
if (condition == null) {
return count;
}
return count.where(condition.asCondition());
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public static Clause match(boolean optional, List<PatternElement> patternElement
@Nullable Where optionalWhere,
@Nullable List<Hint> optionalHints) {

return new Match(optional, new Pattern(patternElements), optionalWhere, optionalHints);
return new Match(optional, Pattern.of(patternElements), optionalWhere, optionalHints);
}

/**
Expand Down Expand Up @@ -105,7 +105,7 @@ public static Return returning(boolean distinct, List<Expression> expressions,
@NotNull
public static Clause create(List<PatternElement> patternElements) {

return new Create(new Pattern(patternElements));
return new Create(Pattern.of(patternElements));
}

/**
Expand All @@ -118,7 +118,7 @@ public static Clause create(List<PatternElement> patternElements) {
@NotNull
public static Clause merge(List<PatternElement> patternElements, @Nullable List<MergeAction> mergeActions) {

return new Merge(new Pattern(patternElements), mergeActions == null ? Collections.emptyList() : mergeActions);
return new Merge(Pattern.of(patternElements), mergeActions == null ? Collections.emptyList() : mergeActions);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@

import static org.apiguardian.api.API.Status.STABLE;

import java.util.List;
import java.util.Optional;

import org.apiguardian.api.API;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.neo4j.cypherdsl.core.ast.Visitable;
import org.neo4j.cypherdsl.core.ast.Visitor;

/**
Expand All @@ -34,30 +35,51 @@
* @since 2023.0.0
*/
@API(status = STABLE, since = "2023.0.0")
public final class CountExpression implements Expression {
@Neo4jVersion(minimum = "5.0")
public final class CountExpression implements SubqueryExpression, ExposesWhere<Expression> {

public static CountExpression of(List<PatternElement> elements, Optional<Expression> where) {
return new CountExpression(new Pattern(elements), where
.map(Expression::asCondition)
.map(Where::new)
.orElse(null)
);
}
private final With optionalWith;

private final Pattern pattern;
private final Visitable patternOrUnion;

private final Where optionalWhere;

private CountExpression(Pattern pattern, Where optionalWhere) {
this.pattern = pattern;
this.optionalWhere = optionalWhere;
CountExpression(@Nullable With optionalWith, Visitable patternOrUnion, @Nullable Where optionalWhere) {

if (patternOrUnion instanceof Statement.UnionQuery && optionalWhere != null) {
throw new IllegalArgumentException("Cannot use a UNION with a WHERE clause inside a COUNT {} expression");
}
this.optionalWith = optionalWith;
if (optionalWith != null && patternOrUnion instanceof Pattern pattern) {
this.patternOrUnion = new Match(false, pattern, optionalWhere, null);
this.optionalWhere = null;
} else {
this.patternOrUnion = patternOrUnion;
this.optionalWhere = optionalWhere;
}
}

/**
* Creates a new {@link CountExpression count expression} with additional conditions
*
* @param condition the condition to apply in the count expression
* @return A new {@link CountExpression}
*/
@NotNull @Contract(pure = true)
public CountExpression where(Condition condition) {

var exisitingPatternOrUnion = patternOrUnion instanceof Match match ? match.pattern : patternOrUnion;
return new CountExpression(optionalWith, exisitingPatternOrUnion, new Where(condition));
}

@Override
public void accept(Visitor visitor) {

visitor.enter(this);
this.pattern.accept(visitor);
if (optionalWith != null) {
this.optionalWith.accept(visitor);
}
this.patternOrUnion.accept(visitor);
if (optionalWhere != null) {
this.optionalWhere.accept(visitor);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
import org.neo4j.cypherdsl.core.Literal.UnsupportedLiteralException;
import org.neo4j.cypherdsl.core.PatternComprehension.OngoingDefinitionWithPattern;
import org.neo4j.cypherdsl.core.Statement.SingleQuery;
import org.neo4j.cypherdsl.core.Statement.UnionQuery;
import org.neo4j.cypherdsl.core.Statement.UseStatement;
import org.neo4j.cypherdsl.core.StatementBuilder.OngoingStandaloneCallWithoutArguments;
import org.neo4j.cypherdsl.core.utils.Assertions;
Expand Down Expand Up @@ -490,7 +491,7 @@ public static StatementBuilder.OrderableOngoingReadingAndWithWithoutWhere with(I
* can be unwound later on etc. A leading {@code WITH} cannot be used with patterns obviously and needs its
* arguments to have an alias.
*
* @param expressions One ore more aliased expressions.
* @param expressions One or more aliased expressions.
* @return An ongoing with clause.
* @since 2021.2.2
*/
Expand All @@ -508,7 +509,7 @@ public static StatementBuilder.OrderableOngoingReadingAndWithWithoutWhere with(A
* This method takes both aliased and non-aliased expression. The later will produce only valid Cypher when used in
* combination with a correlated subquery via {@link Cypher#call(Statement)}.
*
* @param expressions One ore more expressions.
* @param expressions One or more expressions.
* @return An ongoing with clause.
*/
@NotNull @Contract(pure = true)
Expand Down Expand Up @@ -767,7 +768,7 @@ public static Literal<Void> literalNull() {
* @return A union statement.
*/
@NotNull @Contract(pure = true)
public static Statement union(Statement... statements) {
public static UnionQuery union(Statement... statements) {
return unionImpl(false, statements);
}

Expand All @@ -779,7 +780,7 @@ public static Statement union(Statement... statements) {
* @since 2021.2.2
*/
@NotNull @Contract(pure = true)
public static Statement union(Collection<Statement> statements) {
public static UnionQuery union(Collection<Statement> statements) {
return union(statements.toArray(new Statement[] {}));
}

Expand Down Expand Up @@ -1175,16 +1176,16 @@ public static LoadCSVStatementBuilder.OngoingLoadCSV loadCSV(URI from, boolean w
return LoadCSVStatementBuilder.loadCSV(from, withHeaders);
}

private static Statement unionImpl(boolean unionAll, Statement... statements) {
private static UnionQuery unionImpl(boolean unionAll, Statement... statements) {

Assertions.isTrue(statements != null && statements.length >= 2, "At least two statements are required!");

int i = 0;
UnionQuery existingUnionQuery = null;
UnionQueryImpl existingUnionQuery = null;
@SuppressWarnings("squid:S2259") // Really, we asserted it 4 lines above this one. Thank you, sonar.
boolean isUnionQuery = statements[0] instanceof UnionQuery;
boolean isUnionQuery = statements[0] instanceof UnionQueryImpl;
if (isUnionQuery) {
existingUnionQuery = (UnionQuery) statements[0];
existingUnionQuery = (UnionQueryImpl) statements[0];
Assertions.isTrue(existingUnionQuery.isAll() == unionAll, "Cannot mix union and union all!");
i = 1;
}
Expand All @@ -1197,7 +1198,7 @@ private static Statement unionImpl(boolean unionAll, Statement... statements) {
} while (++i < statements.length);

if (existingUnionQuery == null) {
return UnionQuery.create(unionAll, listOfQueries);
return UnionQueryImpl.create(unionAll, listOfQueries);
} else {
return existingUnionQuery.addAdditionalQueries(listOfQueries);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1227,7 +1227,7 @@ private abstract static class AbstractUpdatingClauseBuilder<T extends UpdatingCl

@Override
public T build() {
return getUpdatingClauseProvider().apply(new Pattern(patternElements));
return getUpdatingClauseProvider().apply(Pattern.of(patternElements));
}

static class CreateBuilder extends AbstractUpdatingClauseBuilder<Create> {
Expand Down Expand Up @@ -1631,7 +1631,7 @@ public ProcedureCall build() {
}

private static final class YieldingStandaloneCallBuilder extends AbstractCallBuilder
implements ExposesWhere, ExposesReturning, OngoingStandaloneCallWithReturnFields {
implements ExposesWhere<StatementBuilder.OngoingReadingWithWhere>, ExposesReturning, OngoingStandaloneCallWithReturnFields {

private final YieldItems yieldItems;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
*/
@API(status = STABLE, since = "2020.1.2")
@Neo4jVersion(minimum = "4.0.0")
public final class ExistentialSubquery implements Condition {
public final class ExistentialSubquery implements SubqueryExpression, Condition {

static ExistentialSubquery exists(Match fragment) {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,12 @@
* A step exposing a several {@code where} methods that are provide entry points of adding conditions.
*
* @author Michael J. Simons
* @param <T> The type of the owner exposing the {@literal WHERE} clause
* @soundtrack Smoke Blow - Dark Angel
* @since 2020.0.1
*/
@API(status = STABLE, since = "2020.0.1")
public interface ExposesWhere {
public interface ExposesWhere<T> {

/**
* Adds a where clause to this fragement.
Expand All @@ -43,7 +44,7 @@ public interface ExposesWhere {
* @return A match or call restricted by a where clause with no return items yet.
*/
@NotNull @CheckReturnValue
StatementBuilder.OngoingReadingWithWhere where(Condition condition);
T where(Condition condition);

/**
* Adds a where clause based on a path pattern to this match.
Expand All @@ -56,7 +57,7 @@ public interface ExposesWhere {
* @since 1.0.1
*/
@NotNull @CheckReturnValue
default StatementBuilder.OngoingReadingWithWhere where(RelationshipPattern pathPattern) {
default T where(RelationshipPattern pathPattern) {

Assertions.notNull(pathPattern, "The path pattern must not be null.");
return this.where(RelationshipPatternCondition.of(pathPattern));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,111 @@
*/
package org.neo4j.cypherdsl.core;

import static org.apiguardian.api.API.Status.INTERNAL;
import static org.apiguardian.api.API.Status.STABLE;

import java.util.Arrays;

import org.apiguardian.api.API;
import org.jetbrains.annotations.NotNull;
import org.neo4j.cypherdsl.core.Statement.UnionQuery;

/**
* Utility methods for dealing with expressions.
*
* @author Michael J. Simons
* @since 1.0
*/
@API(status = INTERNAL, since = "1.0") final class Expressions {
@API(status = STABLE, since = "1.0")
public final class Expressions {

/**
* Something that can build counting sub-queries. Might be used in the future for existential sub-queries, too.
*
* @since 2023.0.0
*/
public interface SubqueryExpressionBuilder {

/**
* Creates a {@literal COUNT} sub-query expressions from at least one pattern.
*
* @param requiredPattern One pattern is required
* @param patternElement Optional pattern
* @return The immutable {@link CountExpression}
*/
@NotNull
CountExpression count(PatternElement requiredPattern, PatternElement... patternElement);

/**
* Creates a {@literal COUNT} with an inner {@literal UNION} sub-query.
*
* @param union The union that will be the source of the {@literal COUNT} sub-query
* @return The immutable {@link CountExpression}
* @since 2023.0.0
*/
@NotNull
CountExpression count(UnionQuery union);
}

/**
* Creates a {@literal COUNT} sub-query expressions from at least one pattern.
*
* @param requiredPattern One pattern is required
* @param patternElement Optional pattern
* @return The immutable {@link CountExpression}
* @since 2023.0.0
*/
@NotNull
public static CountExpression count(PatternElement requiredPattern, PatternElement... patternElement) {
return new CountExpression(null, Pattern.of(requiredPattern, patternElement), null);
}

/**
* Creates a {@literal COUNT} with an inner {@literal UNION} sub-query.
*
* @param union The union that will be the source of the {@literal COUNT} sub-query
* @return The immutable {@link CountExpression}
* @since 2023.0.0
*/
@NotNull
public static CountExpression count(UnionQuery union) {
return new CountExpression(null, union, null);
}

/**
* Start building a new sub-query expression by importing variables into the scope with a {@literal WITH} clause.
*
* @param identifiableElements The identifiable elements to import
* @return A builder for creating the concrete sub-query
* @since 2023.0.0
*/
public static SubqueryExpressionBuilder with(String... identifiableElements) {

return with(Arrays.stream(identifiableElements).map(SymbolicName::of).toArray(SymbolicName[]::new));
}

/**
* Start building a new sub-query expression by importing variables into the scope with a {@literal WITH} clause.
*
* @param identifiableElements The identifiable elements to import
* @return A builder for creating the concrete sub-query
* @since 2023.0.0
*/
public static SubqueryExpressionBuilder with(IdentifiableElement... identifiableElements) {

var returnItems = new ExpressionList(Arrays.stream(identifiableElements).map(IdentifiableElement::asExpression).toList());
var with = new With(false, returnItems, null, null, null, null);
return new SubqueryExpressionBuilder() {
@Override @NotNull
public CountExpression count(PatternElement requiredPattern, PatternElement... patternElement) {
return new CountExpression(with, Pattern.of(requiredPattern, patternElement), null);
}

@Override @NotNull
public CountExpression count(UnionQuery union) {
return new CountExpression(with, union, null);
}
};
}

/**
* @param expression Possibly named with a non-empty symbolic name.
Expand Down
Loading

0 comments on commit 251838b

Please sign in to comment.