Add support for Value Expressions in @Query methods.

Closes #453
Original pull request: #505
This commit is contained in:
Marcin Grzejszczak
2024-10-15 17:36:06 +02:00
committed by Mark Paluch
parent c491b69e08
commit b88ce592d6
15 changed files with 1149 additions and 24 deletions

15
pom.xml
View File

@@ -21,6 +21,7 @@
<spring-ldap>3.2.8</spring-ldap>
<springdata.commons>3.5.0-SNAPSHOT</springdata.commons>
<java-module-name>spring.data.ldap</java-module-name>
<unboundid-ldapsdk>7.0.1</unboundid-ldapsdk>
</properties>
<developers>
@@ -109,6 +110,20 @@
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.ldap</groupId>
<artifactId>spring-ldap-test</artifactId>
<version>${spring-ldap}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.unboundid</groupId>
<artifactId>unboundid-ldapsdk</artifactId>
<version>${unboundid-ldapsdk}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>

View File

@@ -26,6 +26,7 @@ import org.springframework.data.mapping.model.EntityInstantiators;
import org.springframework.data.repository.query.QueryMethod;
import org.springframework.data.repository.query.RepositoryQuery;
import org.springframework.data.repository.query.ResultProcessor;
import org.springframework.data.repository.query.ValueExpressionDelegate;
import org.springframework.ldap.core.LdapOperations;
import org.springframework.ldap.query.LdapQuery;
import org.springframework.util.Assert;

View File

@@ -17,11 +17,14 @@ package org.springframework.data.ldap.repository.query;
import static org.springframework.ldap.query.LdapQueryBuilder.*;
import org.springframework.data.expression.ValueEvaluationContext;
import org.springframework.data.expression.ValueEvaluationContextProvider;
import org.springframework.data.ldap.repository.Query;
import org.springframework.data.mapping.PersistentEntity;
import org.springframework.data.mapping.PersistentProperty;
import org.springframework.data.mapping.context.MappingContext;
import org.springframework.data.mapping.model.EntityInstantiators;
import org.springframework.data.repository.query.ValueExpressionDelegate;
import org.springframework.ldap.core.LdapOperations;
import org.springframework.ldap.query.LdapQuery;
import org.springframework.util.Assert;
@@ -31,10 +34,14 @@ import org.springframework.util.Assert;
*
* @author Mattias Hellborg Arthursson
* @author Mark Paluch
* @author Marcin Grzejszczak
*/
public class AnnotatedLdapRepositoryQuery extends AbstractLdapRepositoryQuery {
private final Query queryAnnotation;
private final ValueExpressionDelegate valueExpressionDelegate;
private final StringBasedQuery stringBasedQuery;
private final StringBasedQuery stringBasedBase;
/**
* Construct a new instance.
@@ -44,26 +51,64 @@ public class AnnotatedLdapRepositoryQuery extends AbstractLdapRepositoryQuery {
* @param ldapOperations the LdapOperations instance to use.
* @param mappingContext must not be {@literal null}.
* @param instantiators must not be {@literal null}.
* @deprecated use the constructor with {@link ValueExpressionDelegate}
*/
@Deprecated(since = "3.4")
public AnnotatedLdapRepositoryQuery(LdapQueryMethod queryMethod, Class<?> entityType, LdapOperations ldapOperations,
MappingContext<? extends PersistentEntity<?, ?>, ? extends PersistentProperty<?>> mappingContext,
EntityInstantiators instantiators) {
this(queryMethod, entityType, ldapOperations, mappingContext, instantiators, ValueExpressionDelegate.create());
}
/**
* Construct a new instance.
*
* @param queryMethod the QueryMethod.
* @param entityType the managed class.
* @param ldapOperations the LdapOperations instance to use.
* @param mappingContext must not be {@literal null}.
* @param instantiators must not be {@literal null}.
* @param valueExpressionDelegate must not be {@literal null}
* @since 3.4
*/
public AnnotatedLdapRepositoryQuery(LdapQueryMethod queryMethod, Class<?> entityType, LdapOperations ldapOperations,
MappingContext<? extends PersistentEntity<?, ?>, ? extends PersistentProperty<?>> mappingContext,
EntityInstantiators instantiators, ValueExpressionDelegate valueExpressionDelegate) {
super(queryMethod, entityType, ldapOperations, mappingContext, instantiators);
Assert.notNull(queryMethod.getQueryAnnotation(), "Annotation must be present");
Assert.hasLength(queryMethod.getQueryAnnotation().value(), "Query filter must be specified");
queryAnnotation = queryMethod.getRequiredQueryAnnotation();
this.valueExpressionDelegate = valueExpressionDelegate;
stringBasedQuery = new StringBasedQuery(queryAnnotation.value(), queryMethod.getParameters(), valueExpressionDelegate);
stringBasedBase = new StringBasedQuery(queryAnnotation.base(), queryMethod.getParameters(), valueExpressionDelegate);
}
@Override
protected LdapQuery createQuery(LdapParameterAccessor parameters) {
return query().base(queryAnnotation.base()) //
ValueEvaluationContextProvider valueContextProvider = valueExpressionDelegate
.createValueContextProvider(getQueryMethod().getParameters());
String boundQuery = bind(parameters, valueContextProvider, stringBasedQuery);
String boundBase = bind(parameters, valueContextProvider, stringBasedBase);
return query().base(boundBase) //
.searchScope(queryAnnotation.searchScope()) //
.countLimit(queryAnnotation.countLimit()) //
.timeLimit(queryAnnotation.timeLimit()) //
.filter(queryAnnotation.value(), parameters.getBindableParameterValues());
.filter(boundQuery);
}
private String bind(LdapParameterAccessor parameters, ValueEvaluationContextProvider valueContextProvider, StringBasedQuery query) {
ValueEvaluationContext evaluationContext = valueContextProvider
.getEvaluationContext(parameters.getBindableParameterValues(), query.getExpressionDependencies());
return query.bindQuery(parameters,
new ContextualValueExpressionEvaluator(valueExpressionDelegate, evaluationContext));
}
}

View File

@@ -0,0 +1,177 @@
/*
* Copyright 2020-2024 the original author or authors.
*
* 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
*
* https://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.
*/
package org.springframework.data.ldap.repository.query;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.springframework.data.mapping.model.ValueExpressionEvaluator;
import org.springframework.data.repository.query.Parameter;
import org.springframework.data.repository.query.ParameterAccessor;
import org.springframework.data.repository.query.Parameters;
import org.springframework.lang.Nullable;
import org.springframework.ldap.support.LdapEncoder;
import org.springframework.util.Assert;
/**
* Value object capturing the binding context to provide {@link #getBindingValues() binding values} for queries.
*
* @author Mark Paluch
* @since 3.4
*/
class BindingContext {
private final Parameters<?, ?> parameters;
private final ParameterAccessor parameterAccessor;
private final List<ParameterBinding> bindings;
private final ValueExpressionEvaluator evaluator;
/**
* Create new {@link BindingContext}.
*/
BindingContext(Parameters<?, ?> parameters, ParameterAccessor parameterAccessor,
List<ParameterBinding> bindings, ValueExpressionEvaluator evaluator) {
this.parameters = parameters;
this.parameterAccessor = parameterAccessor;
this.bindings = bindings;
this.evaluator = evaluator;
}
/**
* @return {@literal true} when list of bindings is not empty.
*/
private boolean hasBindings() {
return !bindings.isEmpty();
}
/**
* Bind values provided by {@link LdapParameterAccessor} to placeholders in {@link BindingContext} while
* considering potential conversions and parameter types.
*
* @return {@literal null} if given {@code raw} value is empty.
*/
public List<Object> getBindingValues() {
if (!hasBindings()) {
return Collections.emptyList();
}
List<Object> parameters = new ArrayList<>(bindings.size());
for (ParameterBinding binding : bindings) {
Object parameterValueForBinding = getParameterValueForBinding(binding);
parameters.add(parameterValueForBinding);
}
return parameters;
}
/**
* Return the value to be used for the given {@link ParameterBinding}.
*
* @param binding must not be {@literal null}.
* @return the value used for the given {@link ParameterBinding}.
*/
@Nullable
private Object getParameterValueForBinding(ParameterBinding binding) {
if (binding.isExpression()) {
return evaluator.evaluate(binding.getRequiredExpression());
}
Object value = binding.isNamed() ?
parameterAccessor.getBindableValue(getParameterIndex(parameters, binding.getRequiredParameterName())) :
parameterAccessor.getBindableValue(binding.getParameterIndex());
return value == null ? null : LdapEncoder.filterEncode(value.toString());
}
private int getParameterIndex(Parameters<?, ?> parameters, String parameterName) {
return parameters.stream() //
.filter(parameter -> parameter //
.getName().filter(s -> s.equals(parameterName)) //
.isPresent()) //
.mapToInt(Parameter::getIndex) //
.findFirst() //
.orElseThrow(() -> new IllegalArgumentException(
String.format("Invalid parameter name; Cannot resolve parameter [%s]", parameterName)));
}
/**
* A generic parameter binding with name or position information.
*
* @author Mark Paluch
*/
static class ParameterBinding {
private final int parameterIndex;
private final @Nullable String expression;
private final @Nullable String parameterName;
private ParameterBinding(int parameterIndex, @Nullable String expression, @Nullable String parameterName) {
this.parameterIndex = parameterIndex;
this.expression = expression;
this.parameterName = parameterName;
}
static ParameterBinding expression(String expression, boolean quoted) {
return new ParameterBinding(-1, expression, null);
}
static ParameterBinding indexed(int parameterIndex) {
return new ParameterBinding(parameterIndex, null, null);
}
static ParameterBinding named(String name) {
return new ParameterBinding(-1, null, name);
}
boolean isNamed() {
return (parameterName != null);
}
int getParameterIndex() {
return parameterIndex;
}
String getParameter() {
return ("?" + (isExpression() ? "expr" : "") + parameterIndex);
}
String getRequiredExpression() {
Assert.state(expression != null, "ParameterBinding is not an expression");
return expression;
}
boolean isExpression() {
return (this.expression != null);
}
String getRequiredParameterName() {
Assert.state(parameterName != null, "ParameterBinding is not named");
return parameterName;
}
}
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright 2024 the original author or authors.
*
* 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
*
* https://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.
*/
package org.springframework.data.ldap.repository.query;
import org.springframework.data.expression.ValueEvaluationContext;
import org.springframework.data.expression.ValueExpression;
import org.springframework.data.expression.ValueExpressionParser;
import org.springframework.data.mapping.model.ValueExpressionEvaluator;
/**
* @author Marcin Grzejszczak
* @author Mark Paluch
*/
class ContextualValueExpressionEvaluator implements ValueExpressionEvaluator {
private final ValueExpressionParser parser;
public ContextualValueExpressionEvaluator(ValueExpressionParser parser, ValueEvaluationContext evaluationContext) {
this.parser = parser;
this.evaluationContext = evaluationContext;
}
private final ValueEvaluationContext evaluationContext;
@SuppressWarnings("unchecked")
@Override
public <T> T evaluate(String expressionString) {
ValueExpression expression = parser.parse(expressionString);
return (T) expression.evaluate(evaluationContext);
}
}

View File

@@ -21,6 +21,8 @@ import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.data.ldap.repository.Query;
import org.springframework.data.projection.ProjectionFactory;
import org.springframework.data.repository.core.RepositoryMetadata;
import org.springframework.data.repository.query.Parameters;
import org.springframework.data.repository.query.ParametersSource;
import org.springframework.data.repository.query.QueryMethod;
import org.springframework.lang.Nullable;
@@ -85,4 +87,5 @@ public class LdapQueryMethod extends QueryMethod {
throw new IllegalStateException("Required @Query annotation is not present");
}
}

View File

@@ -0,0 +1,293 @@
/*
* Copyright 2016-2024 the original author or authors.
*
* 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
*
* https://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.
*/
package org.springframework.data.ldap.repository.query;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.data.ldap.repository.query.BindingContext.ParameterBinding;
import org.springframework.data.mapping.model.ValueExpressionEvaluator;
import org.springframework.data.repository.query.Parameters;
import org.springframework.data.repository.query.ValueExpressionDelegate;
import org.springframework.data.spel.ExpressionDependencies;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* String-based Query abstracting a query with parameter bindings.
*
* @author Marcin Grzejszczak
* @since 3.4
*/
class StringBasedQuery {
private final String query;
private final Parameters<?, ?> parameters;
private final ValueExpressionDelegate expressionParser;
private final List<ParameterBinding> queryParameterBindings = new ArrayList<>();
private final ExpressionDependencies expressionDependencies;
/**
* Create a new {@link StringBasedQuery} given {@code query}, {@link Parameters} and {@link ValueExpressionDelegate}.
*
* @param query must not be empty.
* @param parameters must not be {@literal null}.
* @param expressionParser must not be {@literal null}.
*/
StringBasedQuery(String query, Parameters<?, ?> parameters, ValueExpressionDelegate expressionParser) {
this.query = ParameterBindingParser.INSTANCE.parseAndCollectParameterBindingsFromQueryIntoBindings(query,
this.queryParameterBindings);
this.parameters = parameters;
this.expressionParser = expressionParser;
this.expressionDependencies = createExpressionDependencies();
}
private ExpressionDependencies createExpressionDependencies() {
if (queryParameterBindings.isEmpty()) {
return ExpressionDependencies.none();
}
List<ExpressionDependencies> dependencies = new ArrayList<>();
for (ParameterBinding binding : queryParameterBindings) {
if (binding.isExpression()) {
dependencies
.add(expressionParser.parse(binding.getRequiredExpression()).getExpressionDependencies());
}
}
return ExpressionDependencies.merged(dependencies);
}
/**
* Obtain {@link ExpressionDependencies} from the parsed query.
*
* @return the {@link ExpressionDependencies} from the parsed query.
*/
public ExpressionDependencies getExpressionDependencies() {
return expressionDependencies;
}
/**
* Bind the query to actual parameters using {@link LdapParameterAccessor},
*
* @param parameterAccessor must not be {@literal null}.
* @param evaluator must not be {@literal null}.
* @return the bound String query containing formatted parameters.
*/
String bindQuery(LdapParameterAccessor parameterAccessor, ValueExpressionEvaluator evaluator) {
Assert.notNull(parameterAccessor, "LdapParameterAccessor must not be null");
Assert.notNull(evaluator, "SpELExpressionEvaluator must not be null");
BindingContext bindingContext = new BindingContext(this.parameters, parameterAccessor, this.queryParameterBindings,
evaluator);
List<Object> arguments = bindingContext.getBindingValues();
return ParameterBinder.INSTANCE.bind(this.query, arguments);
}
/**
* A parser that extracts the parameter bindings from a given query string.
*
* @author Mark Paluch
*/
enum ParameterBinder {
INSTANCE;
private static final String ARGUMENT_PLACEHOLDER = "?_param_?";
private static final Pattern ARGUMENT_PLACEHOLDER_PATTERN = Pattern.compile(Pattern.quote(ARGUMENT_PLACEHOLDER));
public String bind(String input, List<Object> parameters) {
if (parameters.isEmpty()) {
return input;
}
StringBuilder result = new StringBuilder();
int startIndex = 0;
int currentPosition = 0;
int parameterIndex = 0;
Matcher matcher = ARGUMENT_PLACEHOLDER_PATTERN.matcher(input);
while (currentPosition < input.length()) {
if (!matcher.find()) {
break;
}
int exprStart = matcher.start();
result.append(input.subSequence(startIndex, exprStart)).append(parameters.get(parameterIndex));
parameterIndex++;
currentPosition = matcher.end();
startIndex = currentPosition;
}
return result.append(input.subSequence(currentPosition, input.length())).toString();
}
}
/**
* A parser that extracts the parameter bindings from a given query string.
*
* @author Mark Paluch
*/
enum ParameterBindingParser {
INSTANCE;
private static final char CURRLY_BRACE_OPEN = '{';
private static final char CURRLY_BRACE_CLOSE = '}';
private static final Pattern INDEX_PARAMETER_BINDING_PATTERN = Pattern.compile("\\?(\\d+)");
private static final Pattern NAMED_PARAMETER_BINDING_PATTERN = Pattern.compile("\\:(\\w+)");
private static final Pattern INDEX_BASED_EXPRESSION_PATTERN = Pattern.compile("\\?\\#\\{");
private static final Pattern NAME_BASED_EXPRESSION_PATTERN = Pattern.compile("\\:\\#\\{");
private static final Pattern INDEX_BASED_PROPERTY_PLACEHOLDER_PATTERN = Pattern.compile("\\?\\$\\{");
private static final Pattern NAME_BASED_PROPERTY_PLACEHOLDER_PATTERN = Pattern.compile("\\:\\$\\{");
private static final Set<Pattern> VALUE_EXPRESSION_PATTERNS = Set.of(INDEX_BASED_EXPRESSION_PATTERN, NAME_BASED_EXPRESSION_PATTERN, INDEX_BASED_PROPERTY_PLACEHOLDER_PATTERN, NAME_BASED_PROPERTY_PLACEHOLDER_PATTERN);
private static final String ARGUMENT_PLACEHOLDER = "?_param_?";
/**
* Returns a list of {@link ParameterBinding}s found in the given {@code input}.
*
* @param input can be {@literal null} or empty.
* @param bindings must not be {@literal null}.
* @return a list of {@link ParameterBinding}s found in the given {@code input}.
*/
public String parseAndCollectParameterBindingsFromQueryIntoBindings(String input, List<ParameterBinding> bindings) {
if (!StringUtils.hasText(input)) {
return input;
}
Assert.notNull(bindings, "Parameter bindings must not be null");
return transformQueryAndCollectExpressionParametersIntoBindings(input, bindings);
}
private static String transformQueryAndCollectExpressionParametersIntoBindings(String input,
List<ParameterBinding> bindings) {
StringBuilder result = new StringBuilder();
int startIndex = 0;
int currentPosition = 0;
while (currentPosition < input.length()) {
Matcher matcher = findNextBindingOrExpression(input, currentPosition);
// no expression parameter found
if (matcher == null) {
break;
}
int exprStart = matcher.start();
currentPosition = exprStart;
if (isValueExpression(matcher)) {
// eat parameter expression
int curlyBraceOpenCount = 1;
currentPosition += 3;
while (curlyBraceOpenCount > 0 && currentPosition < input.length()) {
switch (input.charAt(currentPosition++)) {
case CURRLY_BRACE_OPEN:
curlyBraceOpenCount++;
break;
case CURRLY_BRACE_CLOSE:
curlyBraceOpenCount--;
break;
default:
}
}
result.append(input.subSequence(startIndex, exprStart));
} else {
result.append(input.subSequence(startIndex, exprStart));
}
result.append(ARGUMENT_PLACEHOLDER);
if (isValueExpression(matcher)) {
bindings.add(
ParameterBinding
.expression(input.substring(exprStart + 1, currentPosition), true));
} else {
if (matcher.pattern() == INDEX_PARAMETER_BINDING_PATTERN) {
bindings
.add(ParameterBinding.indexed(Integer.parseInt(matcher.group(1))));
} else {
bindings.add(ParameterBinding.named(matcher.group(1)));
}
currentPosition = matcher.end();
}
startIndex = currentPosition;
}
return result.append(input.subSequence(currentPosition, input.length())).toString();
}
private static boolean isValueExpression(Matcher matcher) {
return VALUE_EXPRESSION_PATTERNS.contains(matcher.pattern());
}
@Nullable
private static Matcher findNextBindingOrExpression(String input, int startPosition) {
List<Matcher> matchers = new ArrayList<>(6);
matchers.add(INDEX_PARAMETER_BINDING_PATTERN.matcher(input));
matchers.add(NAMED_PARAMETER_BINDING_PATTERN.matcher(input));
matchers.add(INDEX_BASED_EXPRESSION_PATTERN.matcher(input));
matchers.add(NAME_BASED_EXPRESSION_PATTERN.matcher(input));
matchers.add(INDEX_BASED_PROPERTY_PLACEHOLDER_PATTERN.matcher(input));
matchers.add(NAME_BASED_PROPERTY_PLACEHOLDER_PATTERN.matcher(input));
Map<Integer, Matcher> matcherMap = new TreeMap<>();
for (Matcher matcher : matchers) {
if (matcher.find(startPosition)) {
matcherMap.put(matcher.start(), matcher);
}
}
return (matcherMap.isEmpty() ? null : matcherMap.values().iterator().next());
}
}
}

View File

@@ -42,6 +42,7 @@ import org.springframework.data.repository.query.QueryLookupStrategy;
import org.springframework.data.repository.query.QueryLookupStrategy.Key;
import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider;
import org.springframework.data.repository.query.RepositoryQuery;
import org.springframework.data.repository.query.ValueExpressionDelegate;
import org.springframework.lang.Nullable;
import org.springframework.ldap.core.LdapOperations;
import org.springframework.util.Assert;
@@ -56,7 +57,6 @@ import org.springframework.util.Assert;
*/
public class LdapRepositoryFactory extends RepositoryFactorySupport {
private final LdapQueryLookupStrategy queryLookupStrategy;
private final LdapOperations ldapOperations;
private final MappingContext<? extends PersistentEntity<?, ?>, ? extends PersistentProperty<?>> mappingContext;
private final EntityInstantiators instantiators = new EntityInstantiators();
@@ -72,7 +72,6 @@ public class LdapRepositoryFactory extends RepositoryFactorySupport {
this.ldapOperations = ldapOperations;
this.mappingContext = new LdapMappingContext();
this.queryLookupStrategy = new LdapQueryLookupStrategy(ldapOperations, instantiators, mappingContext);
}
/**
@@ -87,7 +86,6 @@ public class LdapRepositoryFactory extends RepositoryFactorySupport {
Assert.notNull(ldapOperations, "LdapOperations must not be null");
Assert.notNull(mappingContext, "LdapMappingContext must not be null");
this.queryLookupStrategy = new LdapQueryLookupStrategy(ldapOperations, instantiators, mappingContext);
this.ldapOperations = ldapOperations;
this.mappingContext = mappingContext;
}
@@ -154,9 +152,9 @@ public class LdapRepositoryFactory extends RepositoryFactorySupport {
}
@Override
protected Optional<QueryLookupStrategy> getQueryLookupStrategy(@Nullable Key key,
QueryMethodEvaluationContextProvider evaluationContextProvider) {
return Optional.of(queryLookupStrategy);
protected Optional<QueryLookupStrategy> getQueryLookupStrategy(Key key,
ValueExpressionDelegate valueExpressionDelegate) {
return Optional.of(new LdapQueryLookupStrategy(ldapOperations, instantiators, mappingContext, valueExpressionDelegate));
}
/**
@@ -185,19 +183,9 @@ public class LdapRepositoryFactory extends RepositoryFactorySupport {
return acceptsMappingContext;
}
private static final class LdapQueryLookupStrategy implements QueryLookupStrategy {
private final LdapOperations ldapOperations;
private final EntityInstantiators instantiators;
private final MappingContext<? extends PersistentEntity<?, ?>, ? extends PersistentProperty<?>> mappingContext;
public LdapQueryLookupStrategy(LdapOperations ldapOperations, EntityInstantiators instantiators,
MappingContext<? extends PersistentEntity<?, ?>, ? extends PersistentProperty<?>> mappingContext) {
this.ldapOperations = ldapOperations;
this.instantiators = instantiators;
this.mappingContext = mappingContext;
}
private record LdapQueryLookupStrategy(LdapOperations ldapOperations,
EntityInstantiators instantiators, MappingContext<? extends PersistentEntity<?, ?>, ? extends PersistentProperty<?>> mappingContext,
ValueExpressionDelegate valueExpressionDelegate) implements QueryLookupStrategy {
@Override
public RepositoryQuery resolveQuery(Method method, RepositoryMetadata metadata, ProjectionFactory factory,
@@ -207,7 +195,7 @@ public class LdapRepositoryFactory extends RepositoryFactorySupport {
Class<?> domainType = metadata.getDomainType();
if (queryMethod.hasQueryAnnotation()) {
return new AnnotatedLdapRepositoryQuery(queryMethod, domainType, ldapOperations, mappingContext, instantiators);
return new AnnotatedLdapRepositoryQuery(queryMethod, domainType, ldapOperations, mappingContext, instantiators, valueExpressionDelegate);
} else {
return new PartTreeLdapRepositoryQuery(queryMethod, domainType, ldapOperations, mappingContext, instantiators);
}

View File

@@ -0,0 +1,47 @@
package org.springframework.data.ldap.config;
import java.util.ArrayList;
import java.util.List;
public class EmbeddedLdapProperties {
/**
* Embedded LDAP port.
*/
private int port = 0;
/**
* List of base DNs.
*/
private List<String> baseDn = new ArrayList<>();
/**
* Schema (LDIF) script resource reference.
*/
private String ldif = "classpath:schema.ldif";
public int getPort() {
return this.port;
}
public void setPort(int port) {
this.port = port;
}
public List<String> getBaseDn() {
return this.baseDn;
}
public void setBaseDn(List<String> baseDn) {
this.baseDn = baseDn;
}
public String getLdif() {
return this.ldif;
}
public void setLdif(String ldif) {
this.ldif = ldif;
}
}

View File

@@ -0,0 +1,145 @@
/*
* Copyright 2024 the original author or authors.
*
* 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
*
* https://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.
*/
package org.springframework.data.ldap.config;
import jakarta.annotation.PreDestroy;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.core.env.Environment;
import org.springframework.core.env.MapPropertySource;
import org.springframework.core.env.MutablePropertySources;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.Resource;
import org.springframework.ldap.core.LdapTemplate;
import org.springframework.ldap.core.support.LdapContextSource;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldif.LDIFReader;
/**
* Taken from Spring Boot
*/
@Configuration(proxyBeanMethods = false)
public class InMemoryLdapConfiguration {
private static final String PROPERTY_SOURCE_NAME = "ldap.ports";
private InMemoryDirectoryServer server;
private final EmbeddedLdapProperties embeddedProperties;
public InMemoryLdapConfiguration(EmbeddedLdapProperties embeddedLdapProperties) {
this.embeddedProperties = embeddedLdapProperties;
}
@Bean
public InMemoryDirectoryServer directoryServer(ApplicationContext applicationContext)
throws LDAPException {
String[] baseDn = StringUtils.toStringArray(this.embeddedProperties.getBaseDn());
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(baseDn);
setSchema(config);
InMemoryListenerConfig listenerConfig = InMemoryListenerConfig.createLDAPConfig("LDAP",
this.embeddedProperties.getPort());
config.setListenerConfigs(listenerConfig);
this.server = new InMemoryDirectoryServer(config);
importLdif(applicationContext);
this.server.startListening();
setPortProperty(applicationContext, this.server.getListenPort());
return this.server;
}
@Bean
@DependsOn("directoryServer")
LdapContextSource ldapContextSource(Environment environment, EmbeddedLdapProperties properties,
EmbeddedLdapProperties embeddedProperties) {
LdapContextSource source = new LdapContextSource();
Assert.notEmpty(properties.getBaseDn(), "Base DN must be set with at least one value");
source.setBase(properties.getBaseDn().get(0));
source.setUrls(determineUrls(environment, properties.getPort()));
return source;
}
@Bean
LdapTemplate ldapTemplate(LdapContextSource ldapContextSource) {
return new LdapTemplate(ldapContextSource);
}
private String[] determineUrls(Environment environment, int port) {
return new String[] { "ldap://localhost:" + (port != 0 ? port : environment.getProperty("local.ldap.port")) };
}
private void setSchema(InMemoryDirectoryServerConfig config) {
config.setSchema(null);
}
private void importLdif(ApplicationContext applicationContext) {
String location = this.embeddedProperties.getLdif();
if (StringUtils.hasText(location)) {
try {
Resource resource = applicationContext.getResource(location);
if (resource.exists()) {
try (InputStream inputStream = resource.getInputStream()) {
this.server.importFromLDIF(true, new LDIFReader(inputStream));
}
}
}
catch (Exception ex) {
throw new IllegalStateException("Unable to load LDIF " + location, ex);
}
}
}
private void setPortProperty(ApplicationContext context, int port) {
if (context instanceof ConfigurableApplicationContext configurableContext) {
MutablePropertySources sources = configurableContext.getEnvironment().getPropertySources();
getLdapPorts(sources).put("local.ldap.port", port);
}
if (context.getParent() != null) {
setPortProperty(context.getParent(), port);
}
}
@SuppressWarnings("unchecked")
private Map<String, Object> getLdapPorts(MutablePropertySources sources) {
PropertySource<?> propertySource = sources.get(PROPERTY_SOURCE_NAME);
if (propertySource == null) {
propertySource = new MapPropertySource(PROPERTY_SOURCE_NAME, new HashMap<>());
sources.addFirst(propertySource);
}
return (Map<String, Object>) propertySource.getSource();
}
@PreDestroy
public void close() {
if (this.server != null) {
this.server.shutDown(true);
}
}
}

View File

@@ -60,8 +60,6 @@ class SimpleLdapRepositoryTests {
@BeforeEach
void prepareTestedInstance() {
tested = new SimpleLdapRepository<>(ldapOperationsMock, odmMock, Object.class);
}
@Test

View File

@@ -0,0 +1,83 @@
/*
* Copyright 2016-2024 the original author or authors.
*
* 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
*
* https://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.
*/
package org.springframework.data.ldap.repository.query;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.data.ldap.core.mapping.LdapMappingContext;
import org.springframework.data.ldap.repository.LdapRepository;
import org.springframework.data.ldap.repository.Query;
import org.springframework.data.mapping.model.EntityInstantiators;
import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
import org.springframework.data.repository.core.support.DefaultRepositoryMetadata;
import org.springframework.data.repository.query.Param;
import org.springframework.data.repository.query.ValueExpressionDelegate;
import org.springframework.ldap.core.LdapOperations;
import org.springframework.ldap.query.LdapQuery;
import static org.assertj.core.api.Assertions.assertThat;
class AnnotatedLdapRepositoryQueryTests {
LdapOperations ldapOperations = Mockito.mock();
ValueExpressionDelegate valueExpressionDelegate = ValueExpressionDelegate.create();
@Test
void shouldEncodeQuery() throws NoSuchMethodException {
LdapQueryMethod method = queryMethod("namedParameters");
AnnotatedLdapRepositoryQuery query = repositoryQuery(method);
LdapQuery ldapQuery = query.createQuery(
new LdapParametersParameterAccessor(method, new Object[] { "John)(cn=Doe)", "foo" }));
assertThat(ldapQuery.filter().encode()).isEqualTo("(cn=John\\29\\28cn=Doe\\29)");
}
@Test
void shouldEncodeBase() throws NoSuchMethodException {
LdapQueryMethod method = queryMethod("baseNamedParameters");
AnnotatedLdapRepositoryQuery query = repositoryQuery(method);
LdapQuery ldapQuery = query.createQuery(
new LdapParametersParameterAccessor(method, new Object[] { "foo", "cn=John)" }));
assertThat(ldapQuery.base()).hasToString("cn=John\\29");
}
private LdapQueryMethod queryMethod(String methodName) throws NoSuchMethodException {
return new LdapQueryMethod(QueryRepository.class.getMethod(methodName, String.class, String.class),
new DefaultRepositoryMetadata(QueryRepository.class), new SpelAwareProxyProjectionFactory());
}
private AnnotatedLdapRepositoryQuery repositoryQuery(LdapQueryMethod method) {
return new AnnotatedLdapRepositoryQuery(method, SchemaEntry.class, ldapOperations, new LdapMappingContext(),
new EntityInstantiators(), valueExpressionDelegate);
}
interface QueryRepository extends LdapRepository<SchemaEntry> {
@Query(value = "(cn=:fullName)")
List<SchemaEntry> namedParameters(@Param("fullName") String fullName, @Param("lastName") String lastName);
@Query(base = ":dc", value = "(cn=:fullName)")
List<SchemaEntry> baseNamedParameters(@Param("fullName") String fullName, @Param("dc") String dc);
}
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright 2016-2024 the original author or authors.
*
* 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
*
* https://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.
*/
package org.springframework.data.ldap.repository.query;
import javax.naming.Name;
import org.springframework.ldap.odm.annotations.Attribute;
import org.springframework.ldap.odm.annotations.DnAttribute;
import org.springframework.ldap.odm.annotations.Entry;
import org.springframework.ldap.odm.annotations.Id;
/**
* @author Marcin Grzejszczak
*/
@Entry(objectClasses = { "inetOrgPerson", "organizationalPerson", "person", "top" })
public class SchemaEntry {
@Id
Name dn;
@Attribute(name = "cn")
String fullName;
@Attribute
String lastName;
public SchemaEntry() {}
public SchemaEntry(Name dn, String fullName, String lastName) {
this.dn = dn;
this.fullName = fullName;
this.lastName = lastName;
}
}

View File

@@ -0,0 +1,167 @@
/*
* Copyright 2016-2024 the original author or authors.
*
* 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
*
* https://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.
*/
package org.springframework.data.ldap.repository.query;
import static org.assertj.core.api.Assertions.*;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.data.ldap.config.EmbeddedLdapProperties;
import org.springframework.data.ldap.config.InMemoryLdapConfiguration;
import org.springframework.data.ldap.repository.LdapRepository;
import org.springframework.data.ldap.repository.Query;
import org.springframework.data.ldap.repository.config.EnableLdapRepositories;
import org.springframework.data.repository.query.Param;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
/**
* Integration tests for Repositories using value expressions.
*
* @author Marcin Grzejszczak
*/
@SpringJUnitConfig
@TestPropertySource(properties = { "full.name=John Doe", "dc.name=memorynotfound" })
class ValueExpressionLdapRepositoryQueryTests {
@Autowired private QueryRepository queryRepository;
@Test
void shouldWorkWithNamedParameters() {
List<SchemaEntry> objects = queryRepository.namedParameters("John Doe", "Bar");
assertThatReturnedObjectIsJohnDoe(objects);
}
@Test
void usingQueryLanguageCharsShouldNotFail() {
List<SchemaEntry> objects = queryRepository.namedParameters("John)(cn=Doe)", "Bar");
assertThat(objects).isEmpty();
}
@Test
void shouldWorkWithIndexParameters() {
List<SchemaEntry> objects = queryRepository.indexedParameters("John Doe", "Bar");
assertThatReturnedObjectIsJohnDoe(objects);
}
@Test
void shouldWorkWithSpelExpressions() {
List<SchemaEntry> objects = queryRepository.spelParameters();
assertThatReturnedObjectIsJohnDoe(objects);
}
@Test
void shouldWorkWithPropertyPlaceholders() {
List<SchemaEntry> objects = queryRepository.propertyPlaceholderParameters();
assertThatReturnedObjectIsJohnDoe(objects);
}
@Test
void shouldWorkWithNamedParametersForBase() {
List<SchemaEntry> objects = queryRepository.baseNamedParameters("John Doe", "dc=memorynotfound");
assertThatReturnedObjectIsJohnDoe(objects);
}
@Test
void shouldWorkWithIndexParametersForBase() {
List<SchemaEntry> objects = queryRepository.baseIndexedParameters("John Doe", "memorynotfound");
assertThatReturnedObjectIsJohnDoe(objects);
}
@Test
void shouldWorkWithSpelExpressionsForBase() {
List<SchemaEntry> objects = queryRepository.baseSpelParameters();
assertThatReturnedObjectIsJohnDoe(objects);
}
@Test
void shouldWorkWithPropertyPlaceholdersForBase() {
List<SchemaEntry> objects = queryRepository.basePropertyPlaceholderParameters();
assertThatReturnedObjectIsJohnDoe(objects);
}
private static void assertThatReturnedObjectIsJohnDoe(List<SchemaEntry> objects) {
assertThat(objects).hasSize(1);
assertThat(objects.get(0).fullName).isEqualTo("John Doe");
assertThat(objects.get(0).lastName).isEqualTo("Doe");
}
@Configuration(proxyBeanMethods = false)
@Import(InMemoryLdapConfiguration.class)
@EnableLdapRepositories(considerNestedRepositories = true)
static class TestConfig {
@Bean
EmbeddedLdapProperties embeddedLdapProperties() {
EmbeddedLdapProperties embeddedLdapProperties = new EmbeddedLdapProperties();
embeddedLdapProperties.setBaseDn(Arrays.asList("dc=com", "dc=memorynotfound"));
return embeddedLdapProperties;
}
}
interface QueryRepository extends LdapRepository<SchemaEntry> {
@Query(value = "(cn=:fullName)")
List<SchemaEntry> namedParameters(@Param("fullName") String fullName, @Param("lastName") String lastName);
@Query(value = "(cn=?0)")
List<SchemaEntry> indexedParameters(String fullName, String lastName);
@Query(value = "(cn=:#{'John ' + 'Doe'})")
List<SchemaEntry> spelParameters();
@Query(value = "(cn=?${full.name})")
List<SchemaEntry> propertyPlaceholderParameters();
@Query(base = ":dc", value = "(cn=:fullName)")
List<SchemaEntry> baseNamedParameters(@Param("fullName") String fullName, @Param("dc") String dc);
@Query(base = "dc=?1", value = "(cn=?0)")
List<SchemaEntry> baseIndexedParameters(String fullName, String dc);
@Query(base = "dc=:#{'memory' + 'notfound'}", value = "(cn=:#{'John ' + 'Doe'})")
List<SchemaEntry> baseSpelParameters();
@Query(base = "dc=?${dc.name}", value = "(cn=?${full.name})")
List<SchemaEntry> basePropertyPlaceholderParameters();
}
}

View File

@@ -0,0 +1,71 @@
dn: dc=com
objectclass: top
objectclass: domain
dc: com
dn: dc=memorynotfound,dc=com
objectclass: top
objectclass: domain
objectclass: extensibleObject
dc: memorynotfound
# Organizational Units
dn: ou=groups,dc=memorynotfound,dc=com
objectclass: top
objectclass: organizationalUnit
ou: groups
dn: ou=people,dc=memorynotfound,dc=com
objectclass: top
objectclass: organizationalUnit
ou: people
# Create People
dn: uid=john,ou=people,dc=memorynotfound,dc=com
objectclass: top
objectclass: person
objectclass: organizationalPerson
objectclass: inetOrgPerson
cn: John Doe
sn: John
uid: john
fullName: John Doe
lastName: Doe
dn: uid=jihn,ou=people,dc=memorynotfound,dc=com
objectclass: top
objectclass: person
objectclass: organizationalPerson
objectclass: inetOrgPerson
cn: Jihn Die
sn: Jihn
uid: jihn
fullName: Jihn Die
lastName: Die
dn: uid=jahn,ou=people,dc=memorynotfound,dc=com
objectclass: top
objectclass: person
objectclass: organizationalPerson
objectclass: inetOrgPerson
cn: Jahn Dae
sn: Jahn
uid: jahn
fullName: Jahn Die
lastName: Dae
# Create Groups
dn: cn=developers,ou=groups,dc=memorynotfound,dc=com
objectclass: top
objectclass: groupOfUniqueNames
cn: developers
ou: developer
uniqueMember: uid=john,ou=people,dc=memorynotfound,dc=com
uniqueMember: uid=jihn,ou=people,dc=memorynotfound,dc=com
dn: cn=managers,ou=groups,dc=memorynotfound,dc=com
objectclass: top
objectclass: groupOfUniqueNames
cn: managers
ou: manager
uniqueMember: uid=jahn,ou=people,dc=memorynotfound,dc=com