diff --git a/pom.xml b/pom.xml index f10c1266..ecabc3e6 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-ldap - 3.4.0-SNAPSHOT + 3.4.0-GH-453-SNAPSHOT Spring Data LDAP Spring Data integration for LDAP @@ -21,6 +21,7 @@ 3.2.6 3.4.0-SNAPSHOT spring.data.ldap + 7.0.1 @@ -109,6 +110,20 @@ test + + org.springframework.ldap + spring-ldap-test + ${spring-ldap} + test + + + + com.unboundid + unboundid-ldapsdk + ${unboundid-ldapsdk} + test + + diff --git a/src/main/java/org/springframework/data/ldap/repository/query/AbstractLdapRepositoryQuery.java b/src/main/java/org/springframework/data/ldap/repository/query/AbstractLdapRepositoryQuery.java index 4c4d1f93..d35e6fee 100644 --- a/src/main/java/org/springframework/data/ldap/repository/query/AbstractLdapRepositoryQuery.java +++ b/src/main/java/org/springframework/data/ldap/repository/query/AbstractLdapRepositoryQuery.java @@ -26,6 +26,7 @@ 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; diff --git a/src/main/java/org/springframework/data/ldap/repository/query/AnnotatedLdapRepositoryQuery.java b/src/main/java/org/springframework/data/ldap/repository/query/AnnotatedLdapRepositoryQuery.java index 49d721b7..11b764a8 100644 --- a/src/main/java/org/springframework/data/ldap/repository/query/AnnotatedLdapRepositoryQuery.java +++ b/src/main/java/org/springframework/data/ldap/repository/query/AnnotatedLdapRepositoryQuery.java @@ -17,11 +17,14 @@ 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; @@ -35,6 +38,8 @@ public class AnnotatedLdapRepositoryQuery extends AbstractLdapRepositoryQuery { private final Query queryAnnotation; + private final ValueExpressionDelegate valueExpressionDelegate; + private final StringBasedQuery stringBasedQuery; /** * Construct a new instance. @@ -44,26 +49,58 @@ 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 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 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(); + String queryValue = queryAnnotation.value(); + this.valueExpressionDelegate = valueExpressionDelegate; + stringBasedQuery = new StringBasedQuery(queryValue, queryMethod.getParameters(), valueExpressionDelegate); } @Override protected LdapQuery createQuery(LdapParameterAccessor parameters) { + ValueEvaluationContextProvider valueContextProvider = valueExpressionDelegate + .createValueContextProvider(getQueryMethod().getParameters()); + + ValueEvaluationContext evaluationContext = valueContextProvider + .getEvaluationContext(parameters.getBindableParameterValues(), stringBasedQuery.getExpressionDependencies()); + + String boundQuery = stringBasedQuery.bindQuery(parameters, + new ContextualValueExpressionEvaluator(valueExpressionDelegate, evaluationContext)); + return query().base(queryAnnotation.base()) // .searchScope(queryAnnotation.searchScope()) // .countLimit(queryAnnotation.countLimit()) // .timeLimit(queryAnnotation.timeLimit()) // - .filter(queryAnnotation.value(), parameters.getBindableParameterValues()); + .filter(boundQuery); } } diff --git a/src/main/java/org/springframework/data/ldap/repository/query/BindingContext.java b/src/main/java/org/springframework/data/ldap/repository/query/BindingContext.java new file mode 100644 index 00000000..50299e60 --- /dev/null +++ b/src/main/java/org/springframework/data/ldap/repository/query/BindingContext.java @@ -0,0 +1,174 @@ +/* + * 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.lang.Nullable; +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 LdapParameters parameters; + + private final ParameterAccessor parameterAccessor; + + private final List bindings; + + private final ValueExpressionEvaluator evaluator; + + /** + * Create new {@link BindingContext}. + */ + BindingContext(LdapParameters parameters, ParameterAccessor parameterAccessor, + List 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 getBindingValues() { + + if (!hasBindings()) { + return Collections.emptyList(); + } + + List 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()); + } + + return binding.isNamed() + ? parameterAccessor.getBindableValue(getParameterIndex(parameters, binding.getRequiredParameterName())) + : parameterAccessor.getBindableValue(binding.getParameterIndex()); + } + + private int getParameterIndex(LdapParameters parameters, String parameterName) { + + return parameters.stream() // + .filter(cassandraParameter -> cassandraParameter // + .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; + } + } +} diff --git a/src/main/java/org/springframework/data/ldap/repository/query/ContextualValueExpressionEvaluator.java b/src/main/java/org/springframework/data/ldap/repository/query/ContextualValueExpressionEvaluator.java new file mode 100644 index 00000000..66dd4544 --- /dev/null +++ b/src/main/java/org/springframework/data/ldap/repository/query/ContextualValueExpressionEvaluator.java @@ -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 evaluate(String expressionString) { + ValueExpression expression = parser.parse(expressionString); + return (T) expression.evaluate(evaluationContext); + } +} diff --git a/src/main/java/org/springframework/data/ldap/repository/query/LdapParameters.java b/src/main/java/org/springframework/data/ldap/repository/query/LdapParameters.java new file mode 100644 index 00000000..dedc81e7 --- /dev/null +++ b/src/main/java/org/springframework/data/ldap/repository/query/LdapParameters.java @@ -0,0 +1,119 @@ +/* + * 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.lang.reflect.Method; +import java.util.List; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.data.repository.query.Parameter; +import org.springframework.data.repository.query.Parameters; +import org.springframework.data.repository.query.ParametersSource; +import org.springframework.data.repository.util.QueryExecutionConverters; +import org.springframework.data.repository.util.ReactiveWrapperConverters; +import org.springframework.data.repository.util.ReactiveWrappers; +import org.springframework.data.util.TypeInformation; + +/** + * Custom extension of {@link Parameters} discovering additional properties of query method parameters. + * + * @author Marcin Grzejszczak + */ +public class LdapParameters extends Parameters { + + /** + * Create a new {@link LdapParameters} instance from the given {@link Method}. + * + * @param parametersSource must not be {@literal null}. + */ + public LdapParameters(ParametersSource parametersSource) { + super(parametersSource, + methodParameter -> new LdapParameter(methodParameter, parametersSource.getDomainTypeInformation())); + } + + private LdapParameters(List originals) { + + super(originals); + } + + @Override + protected LdapParameters createFrom(List parameters) { + return new LdapParameters(parameters); + } + + /** + * Custom {@link Parameter}. + * + * @author Marcin Grzejszczak + */ + static class LdapParameter extends Parameter { + + private final Class parameterType; + + LdapParameter(MethodParameter parameter, TypeInformation domainType) { + + super(parameter, domainType); + + parameterType = potentiallyUnwrapParameterType(parameter); + } + + @Override + public Class getType() { + return this.parameterType; + } + + /** + * Returns the component type if the given {@link MethodParameter} is a wrapper type and the wrapper should be + * unwrapped. + * + * @param parameter must not be {@literal null}. + */ + private static Class potentiallyUnwrapParameterType(MethodParameter parameter) { + + Class originalType = parameter.getParameterType(); + + if (isWrapped(parameter) && shouldUnwrap(parameter)) { + return ResolvableType.forMethodParameter(parameter).getGeneric(0).getRawClass(); + } + + return originalType; + } + + /** + * Returns whether the {@link MethodParameter} is wrapped in a wrapper type. + * + * @param parameter must not be {@literal null}. + * @see QueryExecutionConverters + */ + private static boolean isWrapped(MethodParameter parameter) { + return QueryExecutionConverters.supports(parameter.getParameterType()) + || ReactiveWrapperConverters.supports(parameter.getParameterType()); + } + + /** + * Returns whether the {@link MethodParameter} should be unwrapped. + * + * @param parameter must not be {@literal null}. + * @see QueryExecutionConverters + */ + private static boolean shouldUnwrap(MethodParameter parameter) { + return QueryExecutionConverters.supportsUnwrapping(parameter.getParameterType()) + || ReactiveWrappers.supports(parameter.getParameterType()); + } + } + +} diff --git a/src/main/java/org/springframework/data/ldap/repository/query/LdapQueryMethod.java b/src/main/java/org/springframework/data/ldap/repository/query/LdapQueryMethod.java index 96c2be96..2a4c0d57 100644 --- a/src/main/java/org/springframework/data/ldap/repository/query/LdapQueryMethod.java +++ b/src/main/java/org/springframework/data/ldap/repository/query/LdapQueryMethod.java @@ -21,6 +21,8 @@ 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,14 @@ Query getRequiredQueryAnnotation() { throw new IllegalStateException("Required @Query annotation is not present"); } + + @Override + public LdapParameters getParameters() { + return (LdapParameters) super.getParameters(); + } + + @Override + protected Parameters createParameters(ParametersSource parametersSource) { + return new LdapParameters(parametersSource); + } } diff --git a/src/main/java/org/springframework/data/ldap/repository/query/StringBasedQuery.java b/src/main/java/org/springframework/data/ldap/repository/query/StringBasedQuery.java new file mode 100644 index 00000000..9b657897 --- /dev/null +++ b/src/main/java/org/springframework/data/ldap/repository/query/StringBasedQuery.java @@ -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 LdapParameters parameters; + + private final ValueExpressionDelegate expressionParser; + + private final List 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, LdapParameters 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 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, "CassandraParameterAccessor must not be null"); + Assert.notNull(evaluator, "SpELExpressionEvaluator must not be null"); + + BindingContext bindingContext = new BindingContext(this.parameters, parameterAccessor, this.queryParameterBindings, + evaluator); + + List 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 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 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 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 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 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 matcherMap = new TreeMap<>(); + + for (Matcher matcher : matchers) { + if (matcher.find(startPosition)) { + matcherMap.put(matcher.start(), matcher); + } + } + + return (matcherMap.isEmpty() ? null : matcherMap.values().iterator().next()); + } + } +} diff --git a/src/main/java/org/springframework/data/ldap/repository/support/LdapRepositoryFactory.java b/src/main/java/org/springframework/data/ldap/repository/support/LdapRepositoryFactory.java index 65764748..85cbc6de 100644 --- a/src/main/java/org/springframework/data/ldap/repository/support/LdapRepositoryFactory.java +++ b/src/main/java/org/springframework/data/ldap/repository/support/LdapRepositoryFactory.java @@ -42,6 +42,7 @@ 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 @@ */ public class LdapRepositoryFactory extends RepositoryFactorySupport { - private final LdapQueryLookupStrategy queryLookupStrategy; private final LdapOperations ldapOperations; private final MappingContext, ? extends PersistentProperty> mappingContext; private final EntityInstantiators instantiators = new EntityInstantiators(); @@ -72,7 +72,6 @@ public LdapRepositoryFactory(LdapOperations ldapOperations) { this.ldapOperations = ldapOperations; this.mappingContext = new LdapMappingContext(); - this.queryLookupStrategy = new LdapQueryLookupStrategy(ldapOperations, instantiators, mappingContext); } /** @@ -87,7 +86,6 @@ public LdapRepositoryFactory(LdapOperations ldapOperations) { 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 @@ protected Object getTargetRepository(RepositoryInformation information) { } @Override - protected Optional getQueryLookupStrategy(@Nullable Key key, - QueryMethodEvaluationContextProvider evaluationContextProvider) { - return Optional.of(queryLookupStrategy); + protected Optional getQueryLookupStrategy(Key key, + ValueExpressionDelegate valueExpressionDelegate) { + return Optional.of(new LdapQueryLookupStrategy(ldapOperations, instantiators, mappingContext, valueExpressionDelegate)); } /** @@ -185,19 +183,9 @@ private static boolean acceptsMappingContext(RepositoryInformation information) return acceptsMappingContext; } - private static final class LdapQueryLookupStrategy implements QueryLookupStrategy { - - private final LdapOperations ldapOperations; - private final EntityInstantiators instantiators; - private final MappingContext, ? extends PersistentProperty> mappingContext; - - public LdapQueryLookupStrategy(LdapOperations ldapOperations, EntityInstantiators instantiators, - MappingContext, ? extends PersistentProperty> mappingContext) { - - this.ldapOperations = ldapOperations; - this.instantiators = instantiators; - this.mappingContext = mappingContext; - } + private record LdapQueryLookupStrategy(LdapOperations ldapOperations, + EntityInstantiators instantiators, MappingContext, ? extends PersistentProperty> mappingContext, + ValueExpressionDelegate valueExpressionDelegate) implements QueryLookupStrategy { @Override public RepositoryQuery resolveQuery(Method method, RepositoryMetadata metadata, ProjectionFactory factory, @@ -207,7 +195,7 @@ public RepositoryQuery resolveQuery(Method method, RepositoryMetadata metadata, 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); } diff --git a/src/test/java/org/springframework/data/ldap/config/EmbeddedLdapProperties.java b/src/test/java/org/springframework/data/ldap/config/EmbeddedLdapProperties.java new file mode 100644 index 00000000..f9ad3aea --- /dev/null +++ b/src/test/java/org/springframework/data/ldap/config/EmbeddedLdapProperties.java @@ -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 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 getBaseDn() { + return this.baseDn; + } + + public void setBaseDn(List baseDn) { + this.baseDn = baseDn; + } + + public String getLdif() { + return this.ldif; + } + + public void setLdif(String ldif) { + this.ldif = ldif; + } + +} diff --git a/src/test/java/org/springframework/data/ldap/config/InMemoryLdapConfiguration.java b/src/test/java/org/springframework/data/ldap/config/InMemoryLdapConfiguration.java new file mode 100644 index 00000000..9db2450f --- /dev/null +++ b/src/test/java/org/springframework/data/ldap/config/InMemoryLdapConfiguration.java @@ -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 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) propertySource.getSource(); + } + + @PreDestroy + public void close() { + if (this.server != null) { + this.server.shutDown(true); + } + } +} diff --git a/src/test/java/org/springframework/data/ldap/repository/SimpleLdapRepositoryTests.java b/src/test/java/org/springframework/data/ldap/repository/SimpleLdapRepositoryTests.java index 921c8ceb..4ddec8cf 100644 --- a/src/test/java/org/springframework/data/ldap/repository/SimpleLdapRepositoryTests.java +++ b/src/test/java/org/springframework/data/ldap/repository/SimpleLdapRepositoryTests.java @@ -60,8 +60,6 @@ class SimpleLdapRepositoryTests { @BeforeEach void prepareTestedInstance() { tested = new SimpleLdapRepository<>(ldapOperationsMock, odmMock, Object.class); - - } @Test diff --git a/src/test/java/org/springframework/data/ldap/repository/query/SchemaEntry.java b/src/test/java/org/springframework/data/ldap/repository/query/SchemaEntry.java new file mode 100644 index 00000000..aa3aca7f --- /dev/null +++ b/src/test/java/org/springframework/data/ldap/repository/query/SchemaEntry.java @@ -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; + } +} diff --git a/src/test/java/org/springframework/data/ldap/repository/query/ValueExpressionLdapRepositoryQueryTests.java b/src/test/java/org/springframework/data/ldap/repository/query/ValueExpressionLdapRepositoryQueryTests.java new file mode 100644 index 00000000..a41174f8 --- /dev/null +++ b/src/test/java/org/springframework/data/ldap/repository/query/ValueExpressionLdapRepositoryQueryTests.java @@ -0,0 +1,124 @@ +/* + * 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") +class ValueExpressionLdapRepositoryQueryTests { + + @Autowired private QueryRepository queryRepository; + + @Test + void shouldWorkWithNamedParameters() { + + List objects = queryRepository.namedParameters("John Doe", "Bar"); + + assertThatReturnedObjectIsJohnDoe(objects); + } + + @Test + void usingQueryLanguageCharsShouldNotFail() { + + List objects = queryRepository.namedParameters("John)(cn=Doe)", "Bar"); + + assertThat(objects).isEmpty(); + } + + @Test + void shouldWorkWithIndexParameters() { + + List objects = queryRepository.indexedParameters("John Doe", "Bar"); + + assertThatReturnedObjectIsJohnDoe(objects); + } + + @Test + void shouldWorkWithSpelExpressions() { + + List objects = queryRepository.spelParameters(); + + assertThatReturnedObjectIsJohnDoe(objects); + } + + @Test + void shouldWorkWithPropertyPlaceholders() { + + List objects = queryRepository.propertyPlaceholderParameters(); + + assertThatReturnedObjectIsJohnDoe(objects); + } + + private static void assertThatReturnedObjectIsJohnDoe(List 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 { + + @Query(value = "(cn=:fullName)") + List namedParameters(@Param("fullName") String fullName, @Param("lastName") String lastName); + + @Query(value = "(cn=?0)") + List indexedParameters(String fullName, String lastName); + + @Query(value = "(cn=:#{'John ' + 'Doe'})") + List spelParameters(); + + @Query(value = "(cn=?${full.name})") + List propertyPlaceholderParameters(); + + } +} diff --git a/src/test/resources/schema.ldif b/src/test/resources/schema.ldif new file mode 100644 index 00000000..e98b78f4 --- /dev/null +++ b/src/test/resources/schema.ldif @@ -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