GH-2681 - Add anyOf / allOf to SpEL.

Currently we only have the literals clause to render an arbitrary string into a custom Cypher statement.
Although one could prepare the string upfront manually and joining multiple strings on | or &
and pass this to the query method, it would be more convenient to add one or two functions directly.

Namely those functions will be
anyOf : renders input ["a","b", ..] to A|B|..
allOf : renders input ["a","b", ..] to A&B&..
The usage would be MATCH (n::#{allOf(#labels)}) RETURN n, for example.

Closes #2681
This commit is contained in:
Gerrit Meier
2023-02-21 15:26:25 +01:00
parent 7b30ecf72f
commit 3667fb5588
4 changed files with 93 additions and 2 deletions

View File

@@ -420,11 +420,16 @@ public interface MyPersonRepository extends Neo4jRepository<Person, Long> {
<.> A `Pageable` has always the name `pageable` inside the SpEL context.
<.> A `Sort` has always the name `sort` inside the SpEL context.
[[spel-extensions]]
=== Spring Expression Language extensions
[[literal-extension]]
==== Literal extension
The `literal` extension can be used to make things like labels or relationship-types "dynamic" in custom queries.
Neither labels nor relationship types can be parameterized in Cypher, so they must be given literal.
[source,java]
[[literal-extension]]
.literal-Extension
----
interface BaseClassRepository extends Neo4jRepository<Inheritance.BaseClass, Long> {
@@ -440,6 +445,25 @@ If you pass in `SomeLabel` as a parameter to the method, `MATCH (n:``SomeLabel``
will be generated. Ticks have been added to correctly escape values. SDN won't do this
for you as this is probably not what you want in all cases.
[[list-extensions]]
==== List extensions
For more than one value there are `allOf` and `anyOf` in place that would render
either a `&` or `|` concatenated list of all values.
[source,java]
.List extensions
----
interface BaseClassRepository extends Neo4jRepository<Inheritance.BaseClass, Long> {
@Query("MATCH (n:`:#{allOf(#label)}`) RETURN n")
List<Inheritance.BaseClass> findByLabels(List<String> labels);
@Query("MATCH (n:`:#{anyOf(#label)}`) RETURN n")
List<Inheritance.BaseClass> findByLabels(List<String> labels);
}
----
=== Referring to Labels
You already know how to map a Node to a domain object:

View File

@@ -15,6 +15,7 @@
*/
package org.springframework.data.neo4j.repository.query;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
@@ -23,7 +24,9 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.apache.commons.logging.LogFactory;
import org.apiguardian.api.API;
import org.springframework.core.log.LogAccessor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.neo4j.core.mapping.CypherGenerator;
@@ -49,8 +52,12 @@ import org.springframework.util.Assert;
public final class Neo4jSpelSupport {
public static String FUNCTION_LITERAL = "literal";
public static String FUNCTION_ANY_OF = "anyOf";
public static String FUNCTION_ALL_OF = "allOf";
public static String FUNCTION_ORDER_BY = "orderBy";
private static final LogAccessor LOG = new LogAccessor(LogFactory.getLog(Neo4jSpelSupport.class));
/**
* Takes {@code arg} and tries to either extract a {@link Sort sort} from it or cast it to a sort. That sort is
* than past to the {@link CypherGenerator} that renders a valid order by fragment which replaces the SpEL placeholder
@@ -87,6 +94,34 @@ public final class Neo4jSpelSupport {
return literalReplacement;
}
public static LiteralReplacement anyOf(@Nullable Object arg) {
return labels(arg, "|");
}
public static LiteralReplacement allOf(@Nullable Object arg) {
return labels(arg, "&");
}
private static LiteralReplacement labels(@Nullable Object arg, String joinOn) {
return StringBasedLiteralReplacement
.withTargetAndValue(LiteralReplacement.Target.UNSPECIFIED,
arg == null ? "" : joinStrings(arg, joinOn)
);
}
private static String joinStrings(Object arg, String joinOn) {
if (arg instanceof Collection) {
return ((Collection<?>) arg).stream().map(Object::toString).collect(Collectors.joining(joinOn));
}
// we are so kind and also accept plain strings instead of collection<string>
if (arg instanceof String) {
return (String) arg;
}
throw new IllegalArgumentException(
String.format("Cannot process argument %s. Please note that only Collection<String> and String are supported types.", arg));
}
/**
* A marker interface that indicates a literal replacement in a query instead of a parameter replacement. This
* comes in handy in places where non-parameterizable things should be created dynamic, for example matching on

View File

@@ -45,12 +45,15 @@ public final class Neo4jEvaluationContextExtension implements EvaluationContextE
@Override
public Map<String, Function> getFunctions() {
Map<String, Function> functions = new HashMap<>();
functions.put(Neo4jSpelSupport.FUNCTION_ORDER_BY, new Function(ReflectionUtils
.findRequiredMethod(Neo4jSpelSupport.class, Neo4jSpelSupport.FUNCTION_ORDER_BY, Object.class)));
functions.put(Neo4jSpelSupport.FUNCTION_LITERAL, new Function(ReflectionUtils
.findRequiredMethod(Neo4jSpelSupport.class, Neo4jSpelSupport.FUNCTION_LITERAL, Object.class)));
functions.put(Neo4jSpelSupport.FUNCTION_ANY_OF, new Function(ReflectionUtils
.findRequiredMethod(Neo4jSpelSupport.class, Neo4jSpelSupport.FUNCTION_ANY_OF, Object.class)));
functions.put(Neo4jSpelSupport.FUNCTION_ALL_OF, new Function(ReflectionUtils
.findRequiredMethod(Neo4jSpelSupport.class, Neo4jSpelSupport.FUNCTION_ALL_OF, Object.class)));
return functions;
}

View File

@@ -3842,6 +3842,29 @@ class RepositoryIT {
.first().isInstanceOf(Inheritance.ConcreteClassB.class)
.extracting(Inheritance.BaseClass::getName)
.isEqualTo("cc2");
List<String> labels = new ArrayList<>();
labels.add("ConcreteClassA");
labels.add("ConcreteClassB");
assertThat(baseClassRepository.findByOrLabels(labels)).hasSize(2)
.hasOnlyElementsOfTypes(Inheritance.ConcreteClassA.class, Inheritance.ConcreteClassB.class)
.extracting(Inheritance.BaseClass::getName)
.containsExactlyInAnyOrder("cc1", "cc2");
assertThat(baseClassRepository.findByAndLabels(labels)).hasSize(0);
String labelsString = "ConcreteClassA";
assertThat(baseClassRepository.findByAndLabels(labelsString)).hasSize(1)
.first().isInstanceOf(Inheritance.ConcreteClassA.class)
.extracting(Inheritance.BaseClass::getName)
.isEqualTo("cc1");
assertThatExceptionOfType(RuntimeException.class).isThrownBy(() -> baseClassRepository.findByAndLabels(1))
.havingRootCause()
.isInstanceOf(IllegalArgumentException.class)
.withMessageContaining("Cannot process argument");
}
@Test
@@ -4485,6 +4508,12 @@ class RepositoryIT {
@Query("MATCH (n::#{literal(#label)}) RETURN n")
List<Inheritance.BaseClass> findByLabel(@Param("label") String label);
@Query("MATCH (n::#{anyOf(#label)}) RETURN n")
List<Inheritance.BaseClass> findByOrLabels(@Param("label") List<String> labels);
@Query("MATCH (n::#{allOf(#label)}) RETURN n")
List<Inheritance.BaseClass> findByAndLabels(@Param("label") Object labels);
}
interface SuperBaseClassRepository extends Neo4jRepository<Inheritance.SuperBaseClass, Long> {