diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEnhancer.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEnhancer.groovy index efadd5c1cc0..0d0650f27ed 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEnhancer.groovy +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEnhancer.groovy @@ -30,15 +30,11 @@ import org.springframework.transaction.TransactionSystemException import grails.gorm.MultiTenant import grails.gorm.multitenancy.Tenants -import org.grails.datastore.gorm.finders.CountByFinder -import org.grails.datastore.gorm.finders.FindAllByBooleanFinder -import org.grails.datastore.gorm.finders.FindAllByFinder -import org.grails.datastore.gorm.finders.FindByBooleanFinder -import org.grails.datastore.gorm.finders.FindByFinder -import org.grails.datastore.gorm.finders.FindOrCreateByFinder -import org.grails.datastore.gorm.finders.FindOrSaveByFinder +import org.grails.datastore.gorm.finders.CountFinder import org.grails.datastore.gorm.finders.FinderMethod import org.grails.datastore.gorm.finders.ListOrderByFinder +import org.grails.datastore.gorm.finders.ListResultFinder +import org.grails.datastore.gorm.finders.SingleResultFinder import org.grails.datastore.gorm.internal.InstanceMethodInvokingClosure import org.grails.datastore.gorm.internal.StaticMethodInvokingClosure import org.grails.datastore.mapping.core.Datastore @@ -698,13 +694,13 @@ class GormEnhancer implements Closeable { @CompileStatic protected List createDynamicFinders(Datastore targetDatastore) { - [new FindOrCreateByFinder(targetDatastore), - new FindOrSaveByFinder(targetDatastore), - new FindByFinder(targetDatastore), - new FindAllByFinder(targetDatastore), - new FindAllByBooleanFinder(targetDatastore), - new FindByBooleanFinder(targetDatastore), - new CountByFinder(targetDatastore), + [SingleResultFinder.findOrCreateBy(targetDatastore), + SingleResultFinder.findOrSaveBy(targetDatastore), + SingleResultFinder.findBy(targetDatastore), + ListResultFinder.findAllBy(targetDatastore), + ListResultFinder.findAllByBoolean(targetDatastore), + SingleResultFinder.findByBoolean(targetDatastore), + CountFinder.countBy(targetDatastore), new ListOrderByFinder(targetDatastore)] as List } } diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/AbstractFindByFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/AbstractFindByFinder.java deleted file mode 100644 index e2b03e51e0a..00000000000 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/AbstractFindByFinder.java +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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.grails.datastore.gorm.finders; - -import java.util.regex.Pattern; - -import org.grails.datastore.mapping.core.Datastore; -import org.grails.datastore.mapping.core.Session; -import org.grails.datastore.mapping.core.SessionCallback; -import org.grails.datastore.mapping.model.MappingContext; -import org.grails.datastore.mapping.query.Query; - -public abstract class AbstractFindByFinder extends DynamicFinder { - public static final String OPERATOR_OR = "Or"; - public static final String OPERATOR_AND = "And"; - public static final String[] OPERATORS = { OPERATOR_AND, OPERATOR_OR }; - - protected AbstractFindByFinder(Pattern pattern, Datastore datastore) { - super(pattern, OPERATORS, datastore); - } - - protected AbstractFindByFinder(Pattern pattern, MappingContext mappingContext) { - super(pattern, OPERATORS, mappingContext); - } - - @Override - protected Object doInvokeInternal(final DynamicFinderInvocation invocation) { - return execute(new SessionCallback<>() { - public Object doInSession(final Session session) { - Query query = buildQuery(invocation, session); - adjustQuery(query); - return invokeQuery(query); - } - }); - } - - protected Object invokeQuery(Query q) { - return q.singleResult(); - } - - public boolean firstExpressionIsRequiredBoolean() { - return super.firstExpressionIsRequiredBoolean(); - } - - protected void adjustQuery(Query query) { - } - -} diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/CountByFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/CountByFinder.java deleted file mode 100644 index 499c07cd453..00000000000 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/CountByFinder.java +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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.grails.datastore.gorm.finders; - -import java.util.regex.Pattern; - -import org.grails.datastore.mapping.core.Datastore; -import org.grails.datastore.mapping.core.Session; -import org.grails.datastore.mapping.core.SessionCallback; -import org.grails.datastore.mapping.model.MappingContext; -import org.grails.datastore.mapping.query.Query; - -/** - * Supports counting objects. For example Book.countByTitle("The Stand") - */ -public class CountByFinder extends DynamicFinder implements QueryBuildingFinder { - - private static final String OPERATOR_OR = "Or"; - private static final String OPERATOR_AND = "And"; - - private static final Pattern METHOD_PATTERN = Pattern.compile("(countBy)(\\w+)"); - private static final String[] OPERATORS = { OPERATOR_AND, OPERATOR_OR }; - - public CountByFinder(final Datastore datastore) { - super(METHOD_PATTERN, OPERATORS, datastore); - } - - public CountByFinder(MappingContext mappingContext) { - super(METHOD_PATTERN, OPERATORS, mappingContext); - } - - @Override - protected Object doInvokeInternal(final DynamicFinderInvocation invocation) { - return execute(new SessionCallback() { - public Object doInSession(final Session session) { - Query query = buildQuery(invocation, session); - return invokeQuery(query); - } - }); - } - - protected Object invokeQuery(Query q) { - return q.singleResult(); - } - - public Query buildQuery(DynamicFinderInvocation invocation, Session session) { - final Class clazz = invocation.getJavaClass(); - Query q = session.createQuery(clazz); - return buildQuery(invocation, clazz, q); - } - - protected Query buildQuery(DynamicFinderInvocation invocation, Class clazz, Query q) { - applyAdditionalCriteria(q, invocation.getCriteria()); - applyDetachedCriteria(q, invocation.getDetachedCriteria()); - configureQueryWithArguments(clazz, q, invocation.getArguments()); - - String operatorInUse = invocation.getOperator(); - if (operatorInUse != null && operatorInUse.equals(OPERATOR_OR)) { - Query.Junction disjunction = q.disjunction(); - - for (MethodExpression expression : invocation.getExpressions()) { - q.add(disjunction, expression.createCriterion()); - } - } - else { - for (MethodExpression expression : invocation.getExpressions()) { - q.add(expression.createCriterion()); - } - } - - q.projections().count(); - return q; - } -} diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/CountFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/CountFinder.java new file mode 100644 index 00000000000..4a532c8b913 --- /dev/null +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/CountFinder.java @@ -0,0 +1,152 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.datastore.gorm.finders; + +import java.util.regex.Pattern; + +import groovy.lang.Closure; + +import grails.gorm.DetachedCriteria; +import org.grails.datastore.mapping.core.Datastore; +import org.grails.datastore.mapping.core.Session; +import org.grails.datastore.mapping.model.MappingContext; +import org.grails.datastore.mapping.query.Query; + +/** + * Implements {@code countBy*}. Kept as its own dedicated class rather than a variant of {@link + * SingleResultFinder}: its {@link #buildQuery} does not reuse {@link DynamicFinder#getJunction} at + * all - for the {@code Or} operator it extends the query's own top-level junction directly via + * {@code Query.disjunction()}/{@code Query.add(Junction, Criterion)}, rather than building a + * standalone {@code Disjunction} added as one nested criterion the way every other finder's + * shared {@code buildQuery} does. This divergence is preserved exactly as it existed before this + * class existed, not unified with the shared grammar's junction-building. + */ +public class CountFinder implements FinderMethod, QueryBuildingFinder { + + private static final Pattern METHOD_PATTERN = Pattern.compile("(countBy)(\\w+)"); + private static final String[] OPERATORS = {"And", "Or"}; + private static final String OPERATOR_OR = "Or"; + + private final Datastore datastore; + private final DynamicFinder grammar; + + private CountFinder(Datastore datastore, DynamicFinder grammar) { + this.datastore = datastore; + this.grammar = grammar; + } + + public static CountFinder countBy(Datastore datastore) { + return new CountFinder(datastore, new DynamicFinder(METHOD_PATTERN, OPERATORS, datastore.getMappingContext(), false)); + } + + public static CountFinder countBy(MappingContext mappingContext) { + return new CountFinder(null, new DynamicFinder(METHOD_PATTERN, OPERATORS, mappingContext, false)); + } + + @Override + public void setPattern(String pattern) { + grammar.setPattern(pattern); + } + + @Override + public boolean isMethodMatch(String methodName) { + return grammar.isMethodMatch(methodName); + } + + @Override + @SuppressWarnings("rawtypes") + public Object invoke(Class clazz, String methodName, Object[] arguments) { + return invoke(clazz, methodName, (Closure) null, arguments); + } + + @Override + @SuppressWarnings("rawtypes") + public Object invoke(Class clazz, String methodName, Closure additionalCriteria, Object[] arguments) { + DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, additionalCriteria, arguments); + return doInvoke(invocation); + } + + /** + * Not part of {@link FinderMethod} - called reflectively (dynamic Groovy dispatch) by {@link + * org.grails.datastore.gorm.query.criteria.AbstractDetachedCriteria#methodMissing}. See {@link + * SingleResultFinder#invoke(Class, String, DetachedCriteria, Object[])} for the full rationale. + * + * @param clazz The persistent class + * @param methodName The method name + * @param detachedCriteria The detached criteria to merge into the built query + * @param arguments The method call arguments + * @return The result of the method call + */ + @SuppressWarnings("rawtypes") + public Object invoke(Class clazz, String methodName, DetachedCriteria detachedCriteria, Object[] arguments) { + DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, null, arguments); + if (detachedCriteria != null) { + invocation.setDetachedCriteria(detachedCriteria); + } + return doInvoke(invocation); + } + + private Object doInvoke(final DynamicFinderInvocation invocation) { + return FinderSupport.execute(datastore, session -> { + Query query = buildQuery(invocation, session); + return query.singleResult(); + }); + } + + @Override + public Query buildQuery(DynamicFinderInvocation invocation, Session session) { + final Class clazz = invocation.getJavaClass(); + Query query = session.createQuery(clazz); + return applyCriteriaAndCount(invocation, clazz, query); + } + + /** + * Applies this finder's independent (non-{@link DynamicFinder#getJunction}) And/Or criteria + * handling and the {@code count()} projection to an already-created query. Exposed as a public + * static helper so other query-building strategies (e.g. {@code grails-datamapping-rx}'s + * count finder, which builds its query outside a {@link Session}) can reuse this logic rather + * than duplicating it. + * + * @param invocation The invocation + * @param clazz The persistent class + * @param query The already-created query to apply criteria to + * @return The same query, for chaining + */ + public static Query applyCriteriaAndCount(DynamicFinderInvocation invocation, Class clazz, Query query) { + DynamicFinder.applyAdditionalCriteria(query, invocation.getCriteria()); + DynamicFinder.applyDetachedCriteria(query, invocation.getDetachedCriteria()); + DynamicFinder.configureQueryWithArguments(clazz, query, invocation.getArguments()); + + String operatorInUse = invocation.getOperator(); + if (OPERATOR_OR.equals(operatorInUse)) { + Query.Junction disjunction = query.disjunction(); + for (MethodExpression expression : invocation.getExpressions()) { + query.add(disjunction, expression.createCriterion()); + } + } + else { + for (MethodExpression expression : invocation.getExpressions()) { + query.add(expression.createCriterion()); + } + } + + query.projections().count(); + return query; + } +} diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinder.java index 8792a485d81..37a873855d0 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinder.java +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinder.java @@ -20,6 +20,8 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -35,7 +37,7 @@ import org.springframework.core.convert.ConversionService; import org.springframework.util.StringUtils; -import grails.gorm.DetachedCriteria; +import grails.gorm.CriteriaBuilder; import org.grails.datastore.gorm.finders.MethodExpression.Between; import org.grails.datastore.gorm.finders.MethodExpression.Equal; import org.grails.datastore.gorm.finders.MethodExpression.GreaterThan; @@ -54,7 +56,6 @@ import org.grails.datastore.gorm.finders.MethodExpression.NotInList; import org.grails.datastore.gorm.finders.MethodExpression.Rlike; import org.grails.datastore.gorm.query.criteria.AbstractDetachedCriteria; -import org.grails.datastore.mapping.core.Datastore; import org.grails.datastore.mapping.core.Session; import org.grails.datastore.mapping.model.MappingContext; import org.grails.datastore.mapping.model.PersistentEntity; @@ -66,13 +67,16 @@ import org.grails.datastore.mapping.reflect.NameUtils; /** - * Abstract base class for dynamic finders. + * Parses a dynamic finder method name into a {@link DynamicFinderInvocation}, builds the AND/OR + * junction of criteria for it, and exposes the shared argument-map/fetch/sort/detached-criteria + * handling used by every finder implementation. Composed (not extended) by the concrete finder + * classes in this package and in {@code grails-datamapping-rx} - see {@link FinderGrammar}. * * @author Graeme Rocher * @since 1.0 */ -@SuppressWarnings({"rawtypes", "unchecked"}) -public abstract class DynamicFinder extends AbstractFinder implements QueryBuildingFinder { +@SuppressWarnings({"rawtypes", "unchecked", "ResultOfMethodCallIgnored"}) +public class DynamicFinder implements FinderGrammar { public static final String ARGUMENT_FETCH_SIZE = "fetchSize"; public static final String ARGUMENT_TIMEOUT = "timeout"; @@ -93,8 +97,8 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild private static final String OPERATOR_OR = "Or"; private static final String OPERATOR_AND = "And"; private static final String[] DEFAULT_OPERATORS = {OPERATOR_AND, OPERATOR_OR}; - private Pattern[] operatorPatterns; - private String[] operators; + private final Pattern[] operatorPatterns; + private final String[] operators; private static Pattern methodExpressinPattern; @@ -102,8 +106,9 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild private static final Object[] EMPTY_OBJECT_ARRAY = {}; private static final String NOT = "Not"; - private static final Map methodExpressions = new LinkedHashMap(); + private static final Map methodExpressions = new LinkedHashMap<>(); protected final MappingContext mappingContext; + private final boolean firstExpressionIsRequiredBoolean; static { defaultOperationPatterns = new Pattern[2]; @@ -123,30 +128,27 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild for (Class c : classes) { methodExpressions.put(c.getSimpleName(), c.getConstructor(constructorParamTypes)); } - } catch (SecurityException e) { - // ignore - } catch (NoSuchMethodException e) { + } catch (SecurityException | NoSuchMethodException e) { // ignore } resetMethodExpressionPattern(); } - protected DynamicFinder(final Pattern pattern, final String[] operators, final Datastore datastore) { - super(datastore); - this.mappingContext = datastore.getMappingContext(); - this.pattern = pattern; - this.operators = operators; - this.operatorPatterns = new Pattern[operators.length]; - populateOperators(operators); - } - - protected DynamicFinder(final Pattern pattern, final String[] operators, final MappingContext mappingContext) { - super(null); + /** + * @param pattern The method-name pattern this grammar matches + * @param operators The junction operators this grammar splits on (e.g. {@code {"And", "Or"}}) + * @param mappingContext The mapping context used to resolve persistent entities/properties + * @param firstExpressionIsRequiredBoolean Whether the first parsed expression is a required + * boolean clause (the "find<booleanProperty>By*"/"findAll<booleanProperty>By*" forms) + */ + public DynamicFinder(final Pattern pattern, final String[] operators, final MappingContext mappingContext, + final boolean firstExpressionIsRequiredBoolean) { this.mappingContext = mappingContext; this.pattern = pattern; this.operators = operators; this.operatorPatterns = new Pattern[operators.length]; + this.firstExpressionIsRequiredBoolean = firstExpressionIsRequiredBoolean; populateOperators(operators); } @@ -161,11 +163,7 @@ public static void registerNewMethodExpression(Class methodExpression) { methodExpressions.put(methodExpression.getSimpleName(), methodExpression.getConstructor( Class.class, String.class)); resetMethodExpressionPattern(); - } catch (SecurityException e) { - throw new IllegalArgumentException("Class [" + methodExpression + - "] does not provide a constructor that takes parameters of type Class and String: " + - e.getMessage(), e); - } catch (NoSuchMethodException e) { + } catch (SecurityException | NoSuchMethodException e) { throw new IllegalArgumentException("Class [" + methodExpression + "] does not provide a constructor that takes parameters of type Class and String: " + e.getMessage(), e); @@ -183,54 +181,52 @@ public static void registerNewMethodExpression(Class methodExpression) { public static MatchSpec buildMatchSpec(String prefix, String methodName, int parameterCount) { String methodPattern = "(" + prefix + ")([A-Z]\\w*)"; Matcher matcher = Pattern.compile(methodPattern).matcher(methodName); - if (matcher.find()) { + if (matcher.find() && matcher.groupCount() == 2) { int totalRequiredArguments = 0; List expressions = new ArrayList<>(); - if (matcher.groupCount() == 2) { - String querySequence = matcher.group(2); - String operatorInUse; - boolean containsOperator = false; - String[] queryParameters; - for (int i = 0; i < DEFAULT_OPERATORS.length; i++) { - Matcher currentMatcher = defaultOperationPatterns[i].matcher(querySequence); - if (currentMatcher.find()) { - containsOperator = true; - operatorInUse = DEFAULT_OPERATORS[i]; - - queryParameters = querySequence.split(operatorInUse); - // loop through query parameters and create expressions - // calculating the number of arguments required for the expression - for (String queryParameter : queryParameters) { - MethodExpression currentExpression = findMethodExpression(queryParameter); - // add to list of expressions - totalRequiredArguments += currentExpression.argumentsRequired; - expressions.add(currentExpression); - } - break; - } - } - - // otherwise there is only one expression - if (!containsOperator && querySequence != null) { - MethodExpression solo = findMethodExpression(querySequence); + String querySequence = matcher.group(2); + String operatorInUse; + boolean containsOperator = false; + String[] queryParameters; + for (int i = 0; i < DEFAULT_OPERATORS.length; i++) { + Matcher currentMatcher = defaultOperationPatterns[i].matcher(querySequence); + if (currentMatcher.find()) { + containsOperator = true; + operatorInUse = DEFAULT_OPERATORS[i]; - final int requiredArguments = solo.getArgumentsRequired(); - if (requiredArguments > parameterCount) { - return null; + queryParameters = querySequence.split(operatorInUse); + // loop through query parameters and create expressions + // calculating the number of arguments required for the expression + for (String queryParameter : queryParameters) { + MethodExpression currentExpression = findMethodExpression(queryParameter); + // add to list of expressions + totalRequiredArguments += currentExpression.argumentsRequired; + expressions.add(currentExpression); } - - totalRequiredArguments += requiredArguments; - expressions.add(solo); + break; } + } + + // otherwise there is only one expression + if (!containsOperator) { + MethodExpression solo = findMethodExpression(querySequence); - // if the total of all the arguments necessary does not equal the number of arguments - // return null - if (totalRequiredArguments > parameterCount) { + final int requiredArguments = solo.getArgumentsRequired(); + if (requiredArguments > parameterCount) { return null; } - else { - return new MatchSpec(methodName, prefix, querySequence, totalRequiredArguments, expressions); - } + + totalRequiredArguments += requiredArguments; + expressions.add(solo); + } + + // if the total of all the arguments necessary does not equal the number of arguments + // return null + if (totalRequiredArguments > parameterCount) { + return null; + } + else { + return new MatchSpec(methodName, prefix, querySequence, totalRequiredArguments, expressions); } } return null; @@ -250,23 +246,12 @@ public void setPattern(String pattern) { * @param methodName The method name * @return True if it is */ + @Override public boolean isMethodMatch(String methodName) { return pattern.matcher(methodName.subSequence(0, methodName.length())).find(); } - public Object invoke(final Class clazz, String methodName, Closure additionalCriteria, Object[] arguments) { - DynamicFinderInvocation invocation = createFinderInvocation(clazz, methodName, additionalCriteria, arguments); - return doInvokeInternal(invocation); - } - - public Object invoke(final Class clazz, String methodName, DetachedCriteria detachedCriteria, Object[] arguments) { - DynamicFinderInvocation invocation = createFinderInvocation(clazz, methodName, null, arguments); - if (detachedCriteria != null) { - invocation.setDetachedCriteria(detachedCriteria); - } - return doInvokeInternal(invocation); - } - + @Override public DynamicFinderInvocation createFinderInvocation(Class clazz, String methodName, Closure additionalCriteria, Object[] arguments) { @@ -334,7 +319,7 @@ public DynamicFinderInvocation createFinderInvocation(Class clazz, String method for (int k = 0; k < requiredArgs; k++, argumentCursor++) { currentArguments[k] = arguments[argumentCursor]; } - currentExpression = getInitializedExpression(currentExpression, currentArguments); + getInitializedExpression(currentExpression, currentArguments); PersistentEntity persistentEntity = mappingContext.getPersistentEntity(clazz.getName()); try { @@ -361,9 +346,7 @@ public DynamicFinderInvocation createFinderInvocation(Class clazz, String method } totalRequiredArguments += requiredArguments; - Object[] soloArgs = new Object[requiredArguments]; - System.arraycopy(arguments, 0, soloArgs, 0, requiredArguments); - solo = getInitializedExpression(solo, arguments); + getInitializedExpression(solo, arguments); PersistentEntity persistentEntity = mappingContext.getPersistentEntity(clazz.getName()); try { solo.convertArguments(persistentEntity); @@ -393,10 +376,6 @@ public DynamicFinderInvocation createFinderInvocation(Class clazz, String method expressions, additionalCriteria, operatorInUse); } - public Object invoke(final Class clazz, String methodName, Object[] arguments) { - return invoke(clazz, methodName, (Closure) null, arguments); - } - /** * Populates arguments for the given query form the given map * @param query The query @@ -408,54 +387,31 @@ public static void populateArgumentsForCriteria(BuildableCriteria query, Map arg } String orderParam = (String) argMap.get(ARGUMENT_ORDER); - Object fetchObj = argMap.get(ARGUMENT_FETCH); - if (fetchObj instanceof Map) { - Map fetch = (Map) fetchObj; - for (Object o : fetch.keySet()) { - String associationName = (String) o; - Object fetchValue = fetch.get(associationName); - if (fetchValue instanceof FetchType) { - FetchType fetchType = (FetchType) fetchValue; - handleFetchType(query, associationName, fetchType); - } - else if (fetchValue instanceof JoinType) { - JoinType joinType = (JoinType) fetchValue; - query.join(associationName, joinType); - } else { - FetchType fetchType = getFetchMode(fetchValue); - handleFetchType(query, associationName, fetchType); - } - } - } - - if (argMap.containsKey(ARGUMENT_CACHE)) { - query.cache(ClassUtils.getBooleanFromMap(ARGUMENT_CACHE, argMap)); - } + applyFetchAndCacheArguments(argMap, + (associationName, fetchType) -> handleFetchType(query, associationName, fetchType), + query::join, + query::cache); Object sortObject = argMap.get(ARGUMENT_SORT); boolean ignoreCase = !argMap.containsKey(ARGUMENT_IGNORE_CASE) || ClassUtils.getBooleanFromMap(ARGUMENT_IGNORE_CASE, argMap); - if (sortObject != null) { - if (sortObject instanceof CharSequence) { - final String sort = sortObject.toString(); + if (sortObject instanceof CharSequence) { + final String sort = sortObject.toString(); + final Query.Order order = ORDER_DESC.equalsIgnoreCase(orderParam) ? Query.Order.desc(sort) : Query.Order.asc(sort); + if (ignoreCase) { + order.ignoreCase(); + } + query.order(order); + } + else if (sortObject instanceof Map sortMap) { + for (Object key : sortMap.keySet()) { + String sort = key.toString(); final Query.Order order = ORDER_DESC.equalsIgnoreCase(orderParam) ? Query.Order.desc(sort) : Query.Order.asc(sort); if (ignoreCase) { order.ignoreCase(); } query.order(order); } - else if (sortObject instanceof Map) { - Map sortMap = (Map) sortObject; - for (Object key : sortMap.keySet()) { - Object value = sortMap.get(key); - String sort = key.toString(); - final Query.Order order = ORDER_DESC.equalsIgnoreCase(orderParam) ? Query.Order.desc(sort) : Query.Order.asc(sort); - if (ignoreCase) { - order.ignoreCase(); - } - query.order(order); - } - } } if (query instanceof QueryArgumentsAware) { @@ -465,11 +421,12 @@ else if (sortObject instanceof Map) { /** * Populates arguments for the given query form the given map + * @param targetClass Unused - kept for call-site/API compatibility with existing callers in + * grails-data-hibernate5/7 and grails-data-mongodb * @param query The query * @param argMap The query arguments */ - //TODO: Change {code}Class{code} to {class} once GROOVY-9460 is fixed. - public static void populateArgumentsForCriteria(Class targetClass, Query query, Map argMap) { + public static void populateArgumentsForCriteria(@SuppressWarnings("unused") Class targetClass, Query query, Map argMap) { if (argMap == null) { return; } @@ -485,29 +442,10 @@ public static void populateArgumentsForCriteria(Class targetCl } String orderParam = (String) argMap.get(ARGUMENT_ORDER); - Object fetchObj = argMap.get(ARGUMENT_FETCH); - if (fetchObj instanceof Map) { - Map fetch = (Map) fetchObj; - for (Object o : fetch.keySet()) { - String associationName = (String) o; - Object fetchValue = fetch.get(associationName); - if (fetchValue instanceof FetchType) { - FetchType fetchType = (FetchType) fetchValue; - handleFetchType(query, associationName, fetchType); - } - else if (fetchValue instanceof JoinType) { - JoinType joinType = (JoinType) fetchValue; - query.join(associationName, joinType); - } else { - FetchType fetchType = getFetchMode(fetchValue); - handleFetchType(query, associationName, fetchType); - } - } - } - - if (argMap.containsKey(ARGUMENT_CACHE)) { - query.cache(ClassUtils.getBooleanFromMap(ARGUMENT_CACHE, argMap)); - } + applyFetchAndCacheArguments(argMap, + (associationName, fetchType) -> handleFetchType(query, associationName, fetchType), + query::join, + query::cache); if (argMap.containsKey(ARGUMENT_LOCK)) { query.lock(ClassUtils.getBooleanFromMap(ARGUMENT_LOCK, argMap)); } @@ -523,16 +461,13 @@ else if (fetchValue instanceof JoinType) { Object sortObject = argMap.get(ARGUMENT_SORT); boolean ignoreCase = !argMap.containsKey(ARGUMENT_IGNORE_CASE) || ClassUtils.getBooleanFromMap(ARGUMENT_IGNORE_CASE, argMap); - if (sortObject != null) { - if (sortObject instanceof CharSequence) { - final String sort = sortObject.toString(); - final String order = ORDER_DESC.equalsIgnoreCase(orderParam) ? ORDER_DESC : ORDER_ASC; - addSimpleSort(query, sort, order, ignoreCase); - } - else if (sortObject instanceof Map) { - Map sortMap = (Map) sortObject; - applySortForMap(query, sortMap, ignoreCase); - } + if (sortObject instanceof CharSequence) { + final String sort = sortObject.toString(); + final String order = ORDER_DESC.equalsIgnoreCase(orderParam) ? ORDER_DESC : ORDER_ASC; + addSimpleSort(query, sort, order, ignoreCase); + } + else if (sortObject instanceof Map sortMap) { + applySortForMap(query, sortMap, ignoreCase); } if (query instanceof QueryArgumentsAware) { @@ -540,6 +475,41 @@ else if (sortObject instanceof Map) { } } + /** + * Shared by both {@code populateArgumentsForCriteria} overloads: applies the {@code fetch}/ + * {@code cache} argument-map entries via the caller-supplied handlers, since {@link Query} and + * {@link BuildableCriteria} don't share a common supertype exposing {@code join}/{@code select}/ + * {@code cache}. + * + * @param argMap The query arguments + * @param fetchHandler Applies a resolved {@link FetchType} to an association + * @param joinHandler Applies an explicit {@link JoinType} to an association + * @param cacheSetter Applies the {@code cache} argument, when present + */ + private static void applyFetchAndCacheArguments(Map argMap, BiConsumer fetchHandler, + BiConsumer joinHandler, Consumer cacheSetter) { + Object fetchObj = argMap.get(ARGUMENT_FETCH); + if (fetchObj instanceof Map fetch) { + for (Object o : fetch.keySet()) { + String associationName = (String) o; + Object fetchValue = fetch.get(associationName); + if (fetchValue instanceof FetchType fetchType) { + fetchHandler.accept(associationName, fetchType); + } + else if (fetchValue instanceof JoinType joinType) { + joinHandler.accept(associationName, joinType); + } + else { + fetchHandler.accept(associationName, getFetchMode(fetchValue)); + } + } + } + + if (argMap.containsKey(ARGUMENT_CACHE)) { + cacheSetter.accept(ClassUtils.getBooleanFromMap(ARGUMENT_CACHE, argMap)); + } + } + /** * Applies sorting logic to the given query from the given map * @param query The query @@ -567,9 +537,6 @@ public static FetchType getFetchMode(Object object) { if (name.equalsIgnoreCase(FetchType.EAGER.toString()) || name.equalsIgnoreCase("join")) { return FetchType.EAGER; } - if (name.equalsIgnoreCase(FetchType.LAZY.toString()) || name.equalsIgnoreCase("select")) { - return FetchType.LAZY; - } return FetchType.LAZY; } @@ -613,7 +580,21 @@ public static void applyDetachedCriteria(Query query, AbstractDetachedCriteria d } } - protected abstract Object doInvokeInternal(DynamicFinderInvocation invocation); + /** + * Applies the given additional-criteria closure to the given query by building it through a + * {@link CriteriaBuilder} for the query's entity/session. + * + * @param query The query + * @param additionalCriteria The additional criteria closure, or null for a no-op + */ + public static void applyAdditionalCriteria(Query query, Closure additionalCriteria) { + if (additionalCriteria == null) { + return; + } + + CriteriaBuilder builder = new CriteriaBuilder(query.getEntity().getJavaClass(), query.getSession(), query); + builder.build(additionalCriteria); + } private static void handleFetchType(Query q, String associationName, FetchType fetchType) { switch (fetchType) { @@ -708,7 +689,7 @@ private static void handleFetchType(BuildableCriteria q, String associationName, } private static void resetMethodExpressionPattern() { - String expressionPattern = DefaultGroovyMethods.join((Iterable) methodExpressions.keySet(), "|"); + String expressionPattern = DefaultGroovyMethods.join(methodExpressions.keySet(), "|"); methodExpressinPattern = Pattern.compile("\\p{Upper}[\\p{Lower}\\d]+(" + expressionPattern + ")"); } @@ -732,14 +713,13 @@ private void populateOperators(String[] operators) { } } - protected void configureQueryWithArguments(Class clazz, Query query, Object[] arguments) { - if (arguments.length == 0 || !(arguments[0] instanceof Map)) { - populateArgumentsForCriteria(clazz, query, Collections.emptyMap()); + public static void configureQueryWithArguments(Class clazz, Query query, Object[] arguments) { + if (arguments.length > 0 && arguments[0] instanceof Map argMap) { + populateArgumentsForCriteria(clazz, query, argMap); return; } - Map argMap = (Map) arguments[0]; - populateArgumentsForCriteria(clazz, query, argMap); + populateArgumentsForCriteria(clazz, query, Collections.emptyMap()); } private static String calcPropertyName(String queryParameter, String clause) { @@ -756,34 +736,32 @@ private static String calcPropertyName(String queryParameter, String clause) { } /** - * Initializes the arguments of the specified expression with the specified arguments. If the - * expression is an Equal expression and the argument is null then a new expression is created - * and returned of type IsNull. + * Initializes the arguments of the specified expression with the specified arguments. * * @param expression expression to initialize * @param arguments arguments to the expression - * @return the initialized expression */ - private MethodExpression getInitializedExpression(MethodExpression expression, Object[] arguments) { + private void getInitializedExpression(MethodExpression expression, Object[] arguments) { // if (expression instanceof Equal && arguments.length == 1 && arguments[0] == null) { // logic moved directly to Equal.createCriterion // expression = new IsNull(expression.propertyName); // } else { expression.setArguments(arguments); // } - return expression; } + @Override public boolean firstExpressionIsRequiredBoolean() { - return false; + return firstExpressionIsRequiredBoolean; } - protected Query.Junction getJunction(DynamicFinderInvocation invocation) { + @Override + public Query.Junction getJunction(DynamicFinderInvocation invocation) { var criteria = invocation.getExpressions().stream().map(MethodExpression::createCriterion).collect(Collectors.toList()); Query.Junction junction; - if (FindAllByFinder.OPERATOR_OR.equals(invocation.getOperator())) { + if (OPERATOR_OR.equals(invocation.getOperator())) { if (firstExpressionIsRequiredBoolean()) { junction = new Query.Conjunction(); - junction.add(criteria.remove(0)); + junction.add(criteria.removeFirst()); var disjunction = new Query.Disjunction(); criteria.forEach(disjunction::add); junction.add(disjunction); @@ -800,6 +778,16 @@ protected Query.Junction getJunction(DynamicFinderInvocation invocation) { return junction; } + /** + * Builds a query for the given invocation using this grammar's {@link #getJunction} and the + * shared additional-criteria/detached-criteria/argument-map handling. This is the default, + * {@code Session}-based query builder used by the synchronous single-result and list finders; + * {@link CountFinder} builds its own query independently of this method. + * + * @param invocation The invocation + * @param session The session + * @return The built query + */ public Query buildQuery(DynamicFinderInvocation invocation, Session session) { final Class clazz = invocation.getJavaClass(); var query = session.createQuery(clazz); diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinderInvocation.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinderInvocation.java index f086faf7182..daf4a340556 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinderInvocation.java +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinderInvocation.java @@ -33,12 +33,12 @@ @SuppressWarnings("rawtypes") public class DynamicFinderInvocation { - private Class javaClass; - private String methodName; - private Object[] arguments; - private List expressions; - private Closure criteria; - private String operator; + private final Class javaClass; + private final String methodName; + private final Object[] arguments; + private final List expressions; + private final Closure criteria; + private final String operator; private DetachedCriteria detachedCriteria; public DynamicFinderInvocation(Class javaClass, String methodName, Object[] arguments, diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindAllByBooleanFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindAllByBooleanFinder.java deleted file mode 100644 index ce3e03aa1dd..00000000000 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindAllByBooleanFinder.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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.grails.datastore.gorm.finders; - -import org.grails.datastore.mapping.core.Datastore; -import org.grails.datastore.mapping.model.MappingContext; - -/** - * The "findAll<booleanProperty>By*" static persistent method. This method allows querying for - * instances of grails domain classes based on a boolean property and any other arbitrary - * properties. - * - * eg. - * Account.findAllActiveByHolder("Joe Blogs"); // Where class "Account" has a properties called "active" and "holder" - * Account.findAllActiveByHolderAndBranch("Joe Blogs", "London"); // Where class "Account" has a properties called "active', "holder" and "branch" - * - * In both of those queries, the query will only select Account objects where active=true. - * - * @author Jeff Brown - * @author Graeme Rocher - */ -public class FindAllByBooleanFinder extends FindAllByFinder { - public static final String METHOD_PATTERN = "(findAll)((\\w+)(By)([A-Z]\\w*)|(\\w+))"; - - public FindAllByBooleanFinder(Datastore datastore) { - super(datastore); - setPattern(METHOD_PATTERN); - } - - public FindAllByBooleanFinder(MappingContext mappingContext) { - super(mappingContext); - setPattern(METHOD_PATTERN); - } - - @Override - public boolean firstExpressionIsRequiredBoolean() { - return true; - } -} diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindAllByFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindAllByFinder.java deleted file mode 100644 index ed1eaede5e9..00000000000 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindAllByFinder.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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.grails.datastore.gorm.finders; - -import java.util.regex.Pattern; - -import org.grails.datastore.mapping.core.Datastore; -import org.grails.datastore.mapping.core.Session; -import org.grails.datastore.mapping.core.SessionCallback; -import org.grails.datastore.mapping.model.MappingContext; -import org.grails.datastore.mapping.query.Query; - -/** - * Finder used to return multiple results. Eg. Book.findAllBy..(..) - */ -public class FindAllByFinder extends DynamicFinder { - - protected static final String OPERATOR_OR = "Or"; - protected static final String OPERATOR_AND = "And"; - private static final String METHOD_PATTERN = "(findAllBy)([A-Z]\\w*)"; - protected static final String[] OPERATORS = { OPERATOR_AND, OPERATOR_OR }; - - public FindAllByFinder(final Datastore datastore) { - super(Pattern.compile(METHOD_PATTERN), OPERATORS, datastore); - } - - public FindAllByFinder(final MappingContext mappingContext) { - super(Pattern.compile(METHOD_PATTERN), OPERATORS, mappingContext); - } - - @Override - protected Object doInvokeInternal(final DynamicFinderInvocation invocation) { - return execute(new SessionCallback() { - public Object doInSession(final Session session) { - Query query = buildQuery(invocation, session); - adjustQuery(query); - return invokeQuery(query); - } - }); - } - - protected Object invokeQuery(Query q) { - return q.list(); - } - - protected void adjustQuery(Query query) { - query.projections().distinct(); - } - -} diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindByBooleanFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindByBooleanFinder.java deleted file mode 100644 index c1591f9398e..00000000000 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindByBooleanFinder.java +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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.grails.datastore.gorm.finders; - -import org.grails.datastore.mapping.core.Datastore; -import org.grails.datastore.mapping.model.MappingContext; - -/** - * - *

The "find<booleanProperty>By*" static persistent method. This method allows querying for - * instances of grails domain classes based on a boolean property and any other arbitrary - * properties. This method returns the first result of the query.

- * - *

- * eg.
- * Account.findActiveByHolder("Joe Blogs"); // Where class "Account" has a properties called "active" and "holder"
- * Account.findActiveByHolderAndBranch("Joe Blogs", "London"); // Where class "Account" has a properties called "active', "holder" and "branch"
- * 
- * - *

- * In both of those queries, the query will only select Account objects where active=true. - *

- * - * @author Graeme Rocher - * @author Jeff Brown - */ -public class FindByBooleanFinder extends FindByFinder { - public static final String METHOD_PATTERN = "(find)((\\w+)(By)([A-Z]\\w*)|(\\w++))"; - - public FindByBooleanFinder(Datastore datastore) { - super(datastore); - setPattern(METHOD_PATTERN); - } - - public FindByBooleanFinder(MappingContext mappingContext) { - super(mappingContext); - setPattern(METHOD_PATTERN); - } - - @Override - public boolean firstExpressionIsRequiredBoolean() { - return true; - } -} diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindByFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindByFinder.java deleted file mode 100644 index 5e8ee5cf614..00000000000 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindByFinder.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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.grails.datastore.gorm.finders; - -import java.util.regex.Pattern; - -import org.grails.datastore.mapping.core.Datastore; -import org.grails.datastore.mapping.model.MappingContext; - -/** - * Finder used to return a single result - */ -public class FindByFinder extends AbstractFindByFinder { - - private static final String METHOD_PATTERN = "(findBy)([A-Z]\\w*)"; - - public FindByFinder(final Datastore datastore) { - super(Pattern.compile(METHOD_PATTERN), datastore); - } - - public FindByFinder(MappingContext mappingContext) { - super(Pattern.compile(METHOD_PATTERN), mappingContext); - } -} diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindOrCreateByFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindOrCreateByFinder.java deleted file mode 100644 index 8205dec00b2..00000000000 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindOrCreateByFinder.java +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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.grails.datastore.gorm.finders; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.regex.Pattern; - -import groovy.lang.GroovySystem; -import groovy.lang.MetaClass; -import groovy.lang.MissingMethodException; - -import org.springframework.core.convert.ConversionException; - -import org.grails.datastore.mapping.core.Datastore; -import org.grails.datastore.mapping.core.exceptions.ConfigurationException; -import org.grails.datastore.mapping.model.MappingContext; - -/** - * Finder used to return a single result - */ -public class FindOrCreateByFinder extends AbstractFindByFinder { - - public static final String METHOD_PATTERN = "(findOrCreateBy)([A-Z]\\w*)"; - - public FindOrCreateByFinder(final String methodPattern, final Datastore datastore) { - super(Pattern.compile(methodPattern), datastore); - } - - public FindOrCreateByFinder(final Datastore datastore) { - this(METHOD_PATTERN, datastore); - } - - public FindOrCreateByFinder(MappingContext mappingContext) { - super(Pattern.compile(METHOD_PATTERN), mappingContext); - } - - public FindOrCreateByFinder(final String methodPattern, MappingContext mappingContext) { - super(Pattern.compile(methodPattern), mappingContext); - } - - @Override - @SuppressWarnings({"rawtypes", "unchecked"}) - protected Object doInvokeInternal(final DynamicFinderInvocation invocation) { - - if (OPERATOR_OR.equals(invocation.getOperator())) { - throw new MissingMethodException(invocation.getMethodName(), invocation.getJavaClass(), invocation.getArguments()); - } - validateInvocation(invocation); - - Object result; - try { - result = super.doInvokeInternal(invocation); - } catch (ConversionException e) { // TODO this is not the right place to deal with this... - throw new MissingMethodException(invocation.getMethodName(), invocation.getJavaClass(), invocation.getArguments()); - } - if (result == null) { - Map m = new HashMap(); - List expressions = invocation.getExpressions(); - for (MethodExpression me : expressions) { - if (!(me instanceof MethodExpression.Equal)) { - throw new MissingMethodException(invocation.getMethodName(), invocation.getJavaClass(), invocation.getArguments()); - } - String propertyName = me.propertyName; - Object[] arguments = me.getArguments(); - m.put(propertyName, arguments[0]); - } - MetaClass metaClass = GroovySystem.getMetaClassRegistry().getMetaClass(invocation.getJavaClass()); - result = metaClass.invokeConstructor(new Object[]{m}); - if (shouldSaveOnCreate()) { - metaClass.invokeMethod(result, "save", null); - } - } - return result; - } - - protected void validateInvocation(DynamicFinderInvocation invocation) { - for (MethodExpression methodExpression : invocation.getExpressions()) { - if (methodExpression instanceof MethodExpression.GreaterThan || - methodExpression instanceof MethodExpression.LessThan || - methodExpression instanceof MethodExpression.GreaterThanEquals || - methodExpression instanceof MethodExpression.LessThanEquals) { - throw new ConfigurationException("Only equality-based expressions are supported for " + invocation.getMethodName()); - } - } - } - - protected boolean shouldSaveOnCreate() { - return false; - } -} diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindOrSaveByFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindOrSaveByFinder.java deleted file mode 100644 index 3552214b8f9..00000000000 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindOrSaveByFinder.java +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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.grails.datastore.gorm.finders; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import groovy.lang.GroovySystem; -import groovy.lang.MetaClass; -import groovy.lang.MissingMethodException; - -import org.grails.datastore.mapping.core.Datastore; -import org.grails.datastore.mapping.model.MappingContext; - -public class FindOrSaveByFinder extends FindOrCreateByFinder { - - public static final String METHOD_PATTERN = "(findOrSaveBy)([A-Z]\\w*)"; - - public FindOrSaveByFinder(final Datastore datastore) { - super(METHOD_PATTERN, datastore); - } - - public FindOrSaveByFinder(final MappingContext mappingContext) { - super(METHOD_PATTERN, mappingContext); - } - - @Override - @SuppressWarnings({"rawtypes", "unchecked"}) - protected Object doInvokeInternal(final DynamicFinderInvocation invocation) { - if (OPERATOR_OR.equals(invocation.getOperator())) { - throw new MissingMethodException(invocation.getMethodName(), invocation.getJavaClass(), invocation.getArguments()); - } - - Object result = super.doInvokeInternal(invocation); - if (result == null) { - Map m = new HashMap(); - List expressions = invocation.getExpressions(); - for (MethodExpression me : expressions) { - if (!(me instanceof MethodExpression.Equal)) { - throw new MissingMethodException(invocation.getMethodName(), invocation.getJavaClass(), invocation.getArguments()); - } - String propertyName = me.propertyName; - Object[] arguments = me.getArguments(); - m.put(propertyName, arguments[0]); - } - MetaClass metaClass = GroovySystem.getMetaClassRegistry().getMetaClass(invocation.getJavaClass()); - result = metaClass.invokeConstructor(new Object[]{m}); - } - return result; - } - - @Override - protected boolean shouldSaveOnCreate() { - return true; - } -} diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FinderGrammar.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FinderGrammar.java new file mode 100644 index 00000000000..ccec871bdce --- /dev/null +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FinderGrammar.java @@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.datastore.gorm.finders; + +import groovy.lang.Closure; + +import org.grails.datastore.mapping.query.Query; + +/** + * The composition seam finder implementations use to parse a dynamic finder method name into a + * {@link DynamicFinderInvocation} and build the resulting {@link Query.Junction}, without + * subclassing {@link DynamicFinder}. Implemented by {@link DynamicFinder} itself; finder + * implementations (in this package and in {@code grails-datamapping-rx}) hold a {@code DynamicFinder} + * instance as a composed collaborator rather than extending it. + */ +@SuppressWarnings("rawtypes") +public interface FinderGrammar { + + /** + * @param methodName The method name + * @return True if the given method name matches this grammar's pattern + */ + boolean isMethodMatch(String methodName); + + /** + * Parses a dynamic finder method invocation into its constituent {@link MethodExpression}s. + * + * @param clazz The persistent class the finder targets + * @param methodName The full method name + * @param additionalCriteria An optional additional-criteria closure + * @param arguments The method call arguments + * @return The parsed invocation + */ + DynamicFinderInvocation createFinderInvocation(Class clazz, String methodName, Closure additionalCriteria, Object[] arguments); + + /** + * Builds the AND/OR junction of criteria for the given invocation. + * + * @param invocation The invocation + * @return The junction + */ + Query.Junction getJunction(DynamicFinderInvocation invocation); + + /** + * @return Whether the first parsed expression is a required boolean clause (the + * "find<booleanProperty>By*"/"findAll<booleanProperty>By*" finder forms) + */ + boolean firstExpressionIsRequiredBoolean(); +} diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/AbstractFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FinderSupport.java similarity index 53% rename from grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/AbstractFinder.java rename to grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FinderSupport.java index fc23f59795a..fd8530d32dd 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/AbstractFinder.java +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FinderSupport.java @@ -18,54 +18,50 @@ */ package org.grails.datastore.gorm.finders; -import groovy.lang.Closure; - -import grails.gorm.CriteriaBuilder; import org.grails.datastore.mapping.core.Datastore; import org.grails.datastore.mapping.core.DatastoreUtils; import org.grails.datastore.mapping.core.SessionCallback; import org.grails.datastore.mapping.core.VoidSessionCallback; -import org.grails.datastore.mapping.query.Query; /** - * Abstract base class for finders. - * - * @author Burt Beckwith + * Shared session-execution helper for the synchronous finder implementations in this package. + * Not a base class - each finder holds a {@link Datastore} field and calls these statically, + * rather than inheriting an {@code execute} method. */ -@SuppressWarnings("rawtypes") -public abstract class AbstractFinder implements FinderMethod { - - protected final Datastore datastore; +public final class FinderSupport { - public AbstractFinder(final Datastore datastore) { - this.datastore = datastore; + private FinderSupport() { } - protected T execute(final SessionCallback callback) { + /** + * Executes the given callback within a session bound to the given datastore. + * + * @param datastore The datastore, or null for stateless mode + * @param callback The callback + * @param The callback's result type + * @return The callback's result + * @throws IllegalStateException if datastore is null (stateless mode) + */ + public static T execute(final Datastore datastore, final SessionCallback callback) { if (datastore != null) { return DatastoreUtils.execute(datastore, callback); } - else { - throw new IllegalStateException("Cannot execute session query in stateless mode"); - } + throw new IllegalStateException("Cannot execute session query in stateless mode"); } - protected void execute(final VoidSessionCallback callback) { + /** + * Executes the given void callback within a session bound to the given datastore. + * + * @param datastore The datastore, or null for stateless mode + * @param callback The callback + * @throws IllegalStateException if datastore is null (stateless mode) + */ + public static void execute(final Datastore datastore, final VoidSessionCallback callback) { if (datastore != null) { DatastoreUtils.execute(datastore, callback); } else { throw new IllegalStateException("Cannot execute session query in stateless mode"); } - - } - - protected void applyAdditionalCriteria(Query query, Closure additionalCriteria) { - if (additionalCriteria == null) { - return; - } - - CriteriaBuilder builder = new CriteriaBuilder(query.getEntity().getJavaClass(), query.getSession(), query); - builder.build(additionalCriteria); } } diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/ListOrderByFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/ListOrderByFinder.java index 1f522b95543..9c6b35e5c79 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/ListOrderByFinder.java +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/ListOrderByFinder.java @@ -26,38 +26,45 @@ import groovy.lang.Closure; import org.grails.datastore.mapping.core.Datastore; -import org.grails.datastore.mapping.core.Session; import org.grails.datastore.mapping.core.SessionCallback; import org.grails.datastore.mapping.query.Query; import org.grails.datastore.mapping.reflect.NameUtils; /** * The "listOrderBy*" static persistent method. Allows ordered listing of instances based on their properties. - * * eg. * Account.listOrderByHolder(); * Account.listOrderByHolder(max); // max results * + *

Never shared {@link DynamicFinder}'s grammar (no And/Or/operator-suffix parsing - just a + * single trailing property name), so it stays its own standalone implementation, composing + * nothing beyond {@link FinderSupport} for session execution. + * * @author Graeme Rocher */ -public class ListOrderByFinder extends AbstractFinder { +public class ListOrderByFinder implements FinderMethod { + private static final Pattern METHOD_PATTERN = Pattern.compile("(listOrderBy)(\\w+)"); + private final Datastore datastore; private Pattern pattern = METHOD_PATTERN; public ListOrderByFinder(Datastore datastore) { - super(datastore); + this.datastore = datastore; } + @Override public void setPattern(String pattern) { this.pattern = Pattern.compile(pattern); } + @Override @SuppressWarnings("rawtypes") public Object invoke(final Class clazz, final String methodName, final Object[] arguments) { return invoke(clazz, methodName, null, arguments); } - @SuppressWarnings("rawtypes") + @Override + @SuppressWarnings({"rawtypes", "unchecked", "ResultOfMethodCallIgnored"}) public Object invoke(final Class clazz, final String methodName, final Closure additionalCriteria, final Object[] arguments) { Matcher match = pattern.matcher(methodName); @@ -66,34 +73,28 @@ public Object invoke(final Class clazz, final String methodName, final Closure a String nameInSignature = match.group(2); final String propertyName = NameUtils.decapitalizeFirstChar(nameInSignature); - return execute(new SessionCallback<>() { - public Object doInSession(final Session session) { - Query q = session.createQuery(clazz); - applyAdditionalCriteria(q, additionalCriteria); + return FinderSupport.execute(datastore, (SessionCallback) session -> { + Query q = session.createQuery(clazz); + DynamicFinder.applyAdditionalCriteria(q, additionalCriteria); - boolean ascending = true; - if (arguments.length > 0 && (arguments[0] instanceof Map)) { - final Map args = new LinkedHashMap((Map) arguments[0]); - final Object order = args.remove(DynamicFinder.ARGUMENT_ORDER); - if (order != null && "desc".equalsIgnoreCase(order.toString())) { - ascending = false; - } - DynamicFinder.populateArgumentsForCriteria(clazz, q, args); + boolean ascending = true; + if (arguments.length > 0 && (arguments[0] instanceof Map)) { + final Map args = new LinkedHashMap((Map) arguments[0]); + final Object order = args.remove(DynamicFinder.ARGUMENT_ORDER); + if (order != null && "desc".equalsIgnoreCase(order.toString())) { + ascending = false; } - - q.order(ascending ? Query.Order.asc(propertyName) : Query.Order.desc(propertyName)); - q.projections().distinct(); - return invokeQuery(q); + DynamicFinder.populateArgumentsForCriteria(clazz, q, args); } - }); - } - protected Object invokeQuery(Query q) { - return q.list(); + q.order(ascending ? Query.Order.asc(propertyName) : Query.Order.desc(propertyName)); + q.projections().distinct(); + return q.list(); + }); } + @Override public boolean isMethodMatch(String methodName) { return pattern.matcher(methodName.subSequence(0, methodName.length())).find(); } - } diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/ListResultFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/ListResultFinder.java new file mode 100644 index 00000000000..9bd97f16f63 --- /dev/null +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/ListResultFinder.java @@ -0,0 +1,126 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.datastore.gorm.finders; + +import java.util.regex.Pattern; + +import groovy.lang.Closure; + +import grails.gorm.DetachedCriteria; +import org.grails.datastore.mapping.core.Datastore; +import org.grails.datastore.mapping.core.Session; +import org.grails.datastore.mapping.core.SessionCallback; +import org.grails.datastore.mapping.model.MappingContext; +import org.grails.datastore.mapping.query.Query; + +/** + * Implements every dynamic finder that returns a list of results: {@code findAllBy*} and the + * {@code findAllBy*} boolean-clause form - configured via static factory methods + * rather than subclassed, composing a {@link DynamicFinder} grammar instead of extending it. + */ +public class ListResultFinder implements FinderMethod, QueryBuildingFinder { + + private static final String FIND_ALL_BY_PATTERN = "(findAllBy)([A-Z]\\w*)"; + private static final String FIND_ALL_BY_BOOLEAN_PATTERN = "(findAll)((\\w+)(By)([A-Z]\\w*)|(\\w+))"; + private static final String[] OPERATORS = {"And", "Or"}; + + private final Datastore datastore; + private final DynamicFinder grammar; + + private ListResultFinder(Datastore datastore, DynamicFinder grammar) { + this.datastore = datastore; + this.grammar = grammar; + } + + public static ListResultFinder findAllBy(Datastore datastore) { + return new ListResultFinder(datastore, grammar(FIND_ALL_BY_PATTERN, datastore.getMappingContext(), false)); + } + + public static ListResultFinder findAllBy(MappingContext mappingContext) { + return new ListResultFinder(null, grammar(FIND_ALL_BY_PATTERN, mappingContext, false)); + } + + public static ListResultFinder findAllByBoolean(Datastore datastore) { + return new ListResultFinder(datastore, grammar(FIND_ALL_BY_BOOLEAN_PATTERN, datastore.getMappingContext(), true)); + } + + public static ListResultFinder findAllByBoolean(MappingContext mappingContext) { + return new ListResultFinder(null, grammar(FIND_ALL_BY_BOOLEAN_PATTERN, mappingContext, true)); + } + + private static DynamicFinder grammar(String pattern, MappingContext mappingContext, boolean booleanClause) { + return new DynamicFinder(Pattern.compile(pattern), OPERATORS, mappingContext, booleanClause); + } + + @Override + public void setPattern(String pattern) { + grammar.setPattern(pattern); + } + + @Override + public boolean isMethodMatch(String methodName) { + return grammar.isMethodMatch(methodName); + } + + @Override + @SuppressWarnings("rawtypes") + public Object invoke(Class clazz, String methodName, Object[] arguments) { + return invoke(clazz, methodName, (Closure) null, arguments); + } + + @Override + @SuppressWarnings("rawtypes") + public Object invoke(Class clazz, String methodName, Closure additionalCriteria, Object[] arguments) { + DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, additionalCriteria, arguments); + return doInvoke(invocation); + } + + /** + * Not part of {@link FinderMethod} - called reflectively (dynamic Groovy dispatch) by {@link + * org.grails.datastore.gorm.query.criteria.AbstractDetachedCriteria#methodMissing}. See {@link + * SingleResultFinder#invoke(Class, String, DetachedCriteria, Object[])} for the full rationale. + * + * @param clazz The persistent class + * @param methodName The method name + * @param detachedCriteria The detached criteria to merge into the built query + * @param arguments The method call arguments + * @return The result of the method call + */ + @SuppressWarnings("rawtypes") + public Object invoke(Class clazz, String methodName, DetachedCriteria detachedCriteria, Object[] arguments) { + DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, null, arguments); + if (detachedCriteria != null) { + invocation.setDetachedCriteria(detachedCriteria); + } + return doInvoke(invocation); + } + + private Object doInvoke(final DynamicFinderInvocation invocation) { + return FinderSupport.execute(datastore, (SessionCallback) session -> { + Query query = buildQuery(invocation, session); + query.projections().distinct(); + return query.list(); + }); + } + + @Override + public Query buildQuery(DynamicFinderInvocation invocation, Session session) { + return grammar.buildQuery(invocation, session); + } +} diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/MethodExpression.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/MethodExpression.java index ea1c597f28c..c580720aea2 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/MethodExpression.java +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/MethodExpression.java @@ -38,6 +38,13 @@ /** * Method expression used to evaluate a dynamic finder. + * + *

Every concrete subclass below provides both a {@code (Class, String)} and a {@code (String)} + * constructor. Neither shape is ever called as a literal {@code new Xxx(...)} expression for every + * subclass - {@link DynamicFinder}'s registry invokes the {@code (Class, String)} constructor + * reflectively via {@code Constructor.newInstance}, and {@code MethodExpressionSpec} exercises both + * shapes reflectively via {@code Class.getConstructor(...).newInstance(...)} - so static usage + * analysis can't see either call site even though both are genuinely exercised. */ public abstract class MethodExpression { @@ -52,7 +59,7 @@ public abstract class MethodExpression { public abstract Query.Criterion createCriterion(); - protected MethodExpression(Class targetClass, String propertyName) { + protected MethodExpression(@SuppressWarnings("unused") Class targetClass, String propertyName) { this.propertyName = propertyName; } @@ -67,13 +74,7 @@ public int getArgumentsRequired() { public void convertArguments(PersistentEntity persistentEntity) { ConversionService conversionService = persistentEntity .getMappingContext().getConversionService(); - PersistentProperty prop = persistentEntity - .getPropertyByName(propertyName); - if (prop == null) { - if (propertyName.equals(persistentEntity.getIdentity().getName())) { - prop = persistentEntity.getIdentity(); - } - } + PersistentProperty prop = resolveProperty(persistentEntity, propertyName); if (prop != null && arguments != null && argumentsRequired > 0) { Class type = prop.getType(); for (int i = 0; i < argumentsRequired; i++) { @@ -188,10 +189,12 @@ public Query.Criterion createCriterion() { } public static class Ilike extends MethodExpression { + @SuppressWarnings("unused") public Ilike(Class targetClass, String propertyName) { super(targetClass, propertyName); } + @SuppressWarnings("unused") public Ilike(String propertyName) { super(propertyName); } @@ -203,10 +206,12 @@ public Query.Criterion createCriterion() { } public static class Rlike extends MethodExpression { + @SuppressWarnings("unused") public Rlike(Class targetClass, String propertyName) { super(targetClass, propertyName); } + @SuppressWarnings("unused") public Rlike(String propertyName) { super(propertyName); } @@ -218,6 +223,7 @@ public Query.Criterion createCriterion() { } public static class NotInList extends MethodExpression { + @SuppressWarnings("unused") public NotInList(Class targetClass, String propertyName) { super(targetClass, propertyName); } @@ -248,16 +254,14 @@ public void setArguments(Object[] arguments) { public void convertArguments(PersistentEntity persistentEntity) { ConversionService conversionService = persistentEntity .getMappingContext().getConversionService(); - String propertyName = this.propertyName; - PersistentProperty prop = persistentEntity - .getPropertyByName(propertyName); - Object[] arguments = this.arguments; - convertArgumentsForProp(persistentEntity, prop, propertyName, arguments, conversionService); + PersistentProperty prop = resolveProperty(persistentEntity, propertyName); + convertArgumentsForProp(prop, arguments, conversionService); } } public static class InList extends MethodExpression { + @SuppressWarnings("unused") public InList(Class targetClass, String propertyName) { super(targetClass, propertyName); } @@ -286,9 +290,8 @@ public void setArguments(Object[] arguments) { public void convertArguments(PersistentEntity persistentEntity) { ConversionService conversionService = persistentEntity .getMappingContext().getConversionService(); - PersistentProperty prop = persistentEntity - .getPropertyByName(propertyName); - convertArgumentsForProp(persistentEntity, prop, propertyName, arguments, conversionService); + PersistentProperty prop = resolveProperty(persistentEntity, propertyName); + convertArgumentsForProp(prop, arguments, conversionService); } } @@ -323,6 +326,7 @@ public void setArguments(Object[] arguments) { public static class InRange extends MethodExpression { + @SuppressWarnings("unused") public InRange(Class targetClass, String propertyName) { super(targetClass, propertyName); argumentsRequired = 1; @@ -357,11 +361,13 @@ public void setArguments(Object[] arguments) { public static class IsNull extends MethodExpression { + @SuppressWarnings("unused") public IsNull(Class targetClass, String propertyName) { super(targetClass, propertyName); argumentsRequired = 0; } + @SuppressWarnings("unused") public IsNull(String propertyName) { super(propertyName); argumentsRequired = 0; @@ -376,11 +382,13 @@ public Criterion createCriterion() { public static class IsNotNull extends MethodExpression { + @SuppressWarnings("unused") public IsNotNull(Class targetClass, String propertyName) { super(targetClass, propertyName); argumentsRequired = 0; } + @SuppressWarnings("unused") public IsNotNull(String propertyName) { super(propertyName); argumentsRequired = 0; @@ -395,11 +403,13 @@ public Criterion createCriterion() { public static class IsEmpty extends MethodExpression { + @SuppressWarnings("unused") public IsEmpty(Class targetClass, String propertyName) { super(targetClass, propertyName); argumentsRequired = 0; } + @SuppressWarnings("unused") public IsEmpty(String propertyName) { super(propertyName); argumentsRequired = 0; @@ -414,11 +424,13 @@ public Criterion createCriterion() { public static class IsNotEmpty extends MethodExpression { + @SuppressWarnings("unused") public IsNotEmpty(Class targetClass, String propertyName) { super(targetClass, propertyName); argumentsRequired = 0; } + @SuppressWarnings("unused") public IsNotEmpty(String propertyName) { super(propertyName); argumentsRequired = 0; @@ -455,6 +467,7 @@ public Query.Criterion createCriterion() { public static class NotEqual extends MethodExpression { + @SuppressWarnings("unused") public NotEqual(Class targetClass, String propertyName) { super(targetClass, propertyName); } @@ -475,12 +488,21 @@ public Query.Criterion createCriterion() { } - private static void convertArgumentsForProp(PersistentEntity persistentEntity, PersistentProperty prop, String propertyName, Object[] arguments, ConversionService conversionService) { - if (prop == null) { - if (propertyName.equals(persistentEntity.getIdentity().getName())) { - prop = persistentEntity.getIdentity(); - } + /** + * Resolves the given property name against the entity, falling back to the identity property + * when there is no regular property by that name (e.g. {@code findByIdInList}). Shared by the + * base {@link #convertArguments} and {@link #convertArgumentsForProp} so the fallback exists in + * exactly one place. + */ + private static PersistentProperty resolveProperty(PersistentEntity persistentEntity, String propertyName) { + PersistentProperty prop = persistentEntity.getPropertyByName(propertyName); + if (prop == null && propertyName.equals(persistentEntity.getIdentity().getName())) { + return persistentEntity.getIdentity(); } + return prop; + } + + private static void convertArgumentsForProp(PersistentProperty prop, Object[] arguments, ConversionService conversionService) { if (prop != null) { Class type = prop.getType(); Collection collection = (Collection) arguments[0]; diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/SingleResultFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/SingleResultFinder.java new file mode 100644 index 00000000000..57024c17103 --- /dev/null +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/SingleResultFinder.java @@ -0,0 +1,228 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.datastore.gorm.finders; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.regex.Pattern; + +import groovy.lang.Closure; +import groovy.lang.GroovySystem; +import groovy.lang.MetaClass; +import groovy.lang.MissingMethodException; + +import org.springframework.core.convert.ConversionException; + +import grails.gorm.DetachedCriteria; +import org.grails.datastore.mapping.core.Datastore; +import org.grails.datastore.mapping.core.Session; +import org.grails.datastore.mapping.core.exceptions.ConfigurationException; +import org.grails.datastore.mapping.model.MappingContext; +import org.grails.datastore.mapping.query.Query; + +/** + * Implements every dynamic finder that returns a single result: {@code findBy*}, the + * {@code findBy*} boolean-clause form, {@code findOrCreateBy*} and + * {@code findOrSaveBy*} - configured via static factory methods rather than subclassed, composing + * a {@link DynamicFinder} grammar instead of extending it. + */ +public class SingleResultFinder implements FinderMethod, QueryBuildingFinder { + + private static final String FIND_BY_PATTERN = "(findBy)([A-Z]\\w*)"; + private static final String FIND_BY_BOOLEAN_PATTERN = "(find)((\\w+)(By)([A-Z]\\w*)|(\\w++))"; + private static final String FIND_OR_CREATE_BY_PATTERN = "(findOrCreateBy)([A-Z]\\w*)"; + private static final String FIND_OR_SAVE_BY_PATTERN = "(findOrSaveBy)([A-Z]\\w*)"; + private static final String[] OPERATORS = {"And", "Or"}; + private static final String OPERATOR_OR = "Or"; + + private final Datastore datastore; + private final DynamicFinder grammar; + private final Consumer validate; + private final Function onNullResult; + + private SingleResultFinder(Datastore datastore, DynamicFinder grammar, + Consumer validate, Function onNullResult) { + this.datastore = datastore; + this.grammar = grammar; + this.validate = validate; + this.onNullResult = onNullResult; + } + + public static SingleResultFinder findBy(Datastore datastore) { + return new SingleResultFinder(datastore, grammar(FIND_BY_PATTERN, datastore.getMappingContext(), false), null, null); + } + + public static SingleResultFinder findBy(MappingContext mappingContext) { + return new SingleResultFinder(null, grammar(FIND_BY_PATTERN, mappingContext, false), null, null); + } + + public static SingleResultFinder findByBoolean(Datastore datastore) { + return new SingleResultFinder(datastore, grammar(FIND_BY_BOOLEAN_PATTERN, datastore.getMappingContext(), true), null, null); + } + + public static SingleResultFinder findByBoolean(MappingContext mappingContext) { + return new SingleResultFinder(null, grammar(FIND_BY_BOOLEAN_PATTERN, mappingContext, true), null, null); + } + + public static SingleResultFinder findOrCreateBy(Datastore datastore) { + return findOrCreateOrSave(datastore, datastore.getMappingContext(), false); + } + + public static SingleResultFinder findOrCreateBy(MappingContext mappingContext) { + return findOrCreateOrSave(null, mappingContext, false); + } + + public static SingleResultFinder findOrSaveBy(Datastore datastore) { + return findOrCreateOrSave(datastore, datastore.getMappingContext(), true); + } + + public static SingleResultFinder findOrSaveBy(MappingContext mappingContext) { + return findOrCreateOrSave(null, mappingContext, true); + } + + private static SingleResultFinder findOrCreateOrSave(Datastore datastore, MappingContext mappingContext, boolean save) { + String pattern = save ? FIND_OR_SAVE_BY_PATTERN : FIND_OR_CREATE_BY_PATTERN; + return new SingleResultFinder(datastore, grammar(pattern, mappingContext, false), + SingleResultFinder::rejectOrAndComparisonOperators, + invocation -> constructFromEqualExpressions(invocation, save)); + } + + private static DynamicFinder grammar(String pattern, MappingContext mappingContext, boolean booleanClause) { + return new DynamicFinder(Pattern.compile(pattern), OPERATORS, mappingContext, booleanClause); + } + + /** + * Shared by {@code findOrCreateBy}/{@code findOrSaveBy}: rejects the {@code Or} operator and + * any comparison-based expression (only equality-based finds can be created/saved). + */ + private static void rejectOrAndComparisonOperators(DynamicFinderInvocation invocation) { + if (OPERATOR_OR.equals(invocation.getOperator())) { + throw new MissingMethodException(invocation.getMethodName(), invocation.getJavaClass(), invocation.getArguments()); + } + for (MethodExpression methodExpression : invocation.getExpressions()) { + if (methodExpression instanceof MethodExpression.GreaterThan || + methodExpression instanceof MethodExpression.LessThan || + methodExpression instanceof MethodExpression.GreaterThanEquals || + methodExpression instanceof MethodExpression.LessThanEquals) { + throw new ConfigurationException("Only equality-based expressions are supported for " + invocation.getMethodName()); + } + } + } + + /** + * Shared by {@code findOrCreateBy}/{@code findOrSaveBy}: constructs a new instance from the + * invocation's {@code Equal}-typed expressions when the underlying query returns null, saving + * it too when {@code save} is true. The single implementation this helper provides is what + * prevents the two finder kinds from ever diverging into duplicate, driftable copies. + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + private static Object constructFromEqualExpressions(DynamicFinderInvocation invocation, boolean save) { + Map m = new HashMap(); + List expressions = invocation.getExpressions(); + for (MethodExpression me : expressions) { + if (!(me instanceof MethodExpression.Equal)) { + throw new MissingMethodException(invocation.getMethodName(), invocation.getJavaClass(), invocation.getArguments()); + } + String propertyName = me.propertyName; + Object[] arguments = me.getArguments(); + m.put(propertyName, arguments[0]); + } + MetaClass metaClass = GroovySystem.getMetaClassRegistry().getMetaClass(invocation.getJavaClass()); + Object result = metaClass.invokeConstructor(new Object[]{m}); + if (save) { + metaClass.invokeMethod(result, "save", null); + } + return result; + } + + @Override + public void setPattern(String pattern) { + grammar.setPattern(pattern); + } + + @Override + public boolean isMethodMatch(String methodName) { + return grammar.isMethodMatch(methodName); + } + + @Override + @SuppressWarnings("rawtypes") + public Object invoke(Class clazz, String methodName, Object[] arguments) { + return invoke(clazz, methodName, (Closure) null, arguments); + } + + @Override + @SuppressWarnings("rawtypes") + public Object invoke(Class clazz, String methodName, Closure additionalCriteria, Object[] arguments) { + DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, additionalCriteria, arguments); + return doInvoke(invocation); + } + + /** + * Not part of {@link FinderMethod} - called reflectively (dynamic Groovy dispatch) by {@link + * org.grails.datastore.gorm.query.criteria.AbstractDetachedCriteria#methodMissing}, which + * invokes a matched dynamic finder with the detached criteria instance itself as the "extra" + * argument so its accumulated criteria/projections/orders get merged into the built query. + * + * @param clazz The persistent class + * @param methodName The method name + * @param detachedCriteria The detached criteria to merge into the built query + * @param arguments The method call arguments + * @return The result of the method call + */ + @SuppressWarnings("rawtypes") + public Object invoke(Class clazz, String methodName, DetachedCriteria detachedCriteria, Object[] arguments) { + DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, null, arguments); + if (detachedCriteria != null) { + invocation.setDetachedCriteria(detachedCriteria); + } + return doInvoke(invocation); + } + + private Object doInvoke(final DynamicFinderInvocation invocation) { + if (validate != null) { + validate.accept(invocation); + } + return FinderSupport.execute(datastore, session -> { + Object result; + if (onNullResult != null) { + try { + result = buildQuery(invocation, session).singleResult(); + } catch (ConversionException e) { + throw new MissingMethodException(invocation.getMethodName(), invocation.getJavaClass(), invocation.getArguments()); + } + if (result == null) { + result = onNullResult.apply(invocation); + } + } + else { + result = buildQuery(invocation, session).singleResult(); + } + return result; + }); + } + + @Override + public Query buildQuery(DynamicFinderInvocation invocation, Session session) { + return grammar.buildQuery(invocation, session); + } +} diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/CountFinderSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/CountFinderSpec.groovy new file mode 100644 index 00000000000..207f841e392 --- /dev/null +++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/CountFinderSpec.groovy @@ -0,0 +1,220 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.datastore.gorm.finders + +import org.grails.datastore.mapping.core.Datastore +import org.grails.datastore.mapping.core.Session +import org.grails.datastore.mapping.model.MappingContext +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.datastore.mapping.model.PersistentProperty +import org.grails.datastore.mapping.query.Query +import org.springframework.core.convert.support.DefaultConversionService +import spock.lang.Specification +import spock.lang.Unroll + +/** + * Exercises {@link CountFinder}'s own independent {@code buildQuery} - unlike every other finder + * in this package, it does NOT reuse {@code DynamicFinder.getJunction()} at all: for the And + * operator (or no operator) each expression's criterion is added directly to the query, while for + * Or each criterion is added to an explicit {@code disjunction()} via the two-arg + * {@code Query.add(Junction, Criterion)} overload - and a {@code count()} projection is always + * applied regardless of operator. + */ +class CountFinderSpec extends Specification { + + PersistentProperty nameProperty = Stub(PersistentProperty) { + getName() >> 'name' + getType() >> String + } + MappingContext mappingContext = Stub(MappingContext) + PersistentEntity persistentEntity = Stub(PersistentEntity) + Datastore datastore = Stub(Datastore) { + getMappingContext() >> mappingContext + } + CountFinder countFinder = CountFinder.countBy(datastore) + + // See DynamicFinderSpec's setup() for why this circular wiring can't be done via inline field + // initializers. + void setup() { + mappingContext.getConversionService() >> new DefaultConversionService() + mappingContext.getPersistentEntity(FinderTestEntity.name) >> persistentEntity + persistentEntity.getMappingContext() >> mappingContext + persistentEntity.getPropertyByName('name') >> nameProperty + } + + private static DynamicFinderInvocation twoEqualsInvocation(String operator) { + MethodExpression.Equal nameExpression = new MethodExpression.Equal('name') + nameExpression.setArguments(['Bob'] as Object[]) + MethodExpression.Equal ageExpression = new MethodExpression.Equal('age') + ageExpression.setArguments([42] as Object[]) + new DynamicFinderInvocation(FinderTestEntity, 'countByNameAndAge', [] as Object[], + [nameExpression, ageExpression], null, operator) + } + + @Unroll + void "buildQuery adds each expression's criterion directly to the query, without a disjunction, for operator #operator"() { + given: + Query.ProjectionList projectionList = Mock(Query.ProjectionList) + Query query = Mock(Query) { + getEntity() >> persistentEntity + projections() >> projectionList + } + + when: + countFinder.buildQuery(twoEqualsInvocation(operator), Stub(Session) { createQuery(FinderTestEntity) >> query }) + + then: + 2 * query.add(_) + 0 * query.disjunction() + 1 * projectionList.count() + + where: + operator << [null, 'And'] + } + + void "buildQuery adds each expression's criterion to an explicit disjunction for the Or operator"() { + given: + Query.Junction disjunctionMock = Mock(Query.Junction) + Query.ProjectionList projectionList = Mock(Query.ProjectionList) + Query query = Mock(Query) { + getEntity() >> persistentEntity + projections() >> projectionList + } + Session session = Stub(Session) { + createQuery(FinderTestEntity) >> query + } + + when: + countFinder.buildQuery(twoEqualsInvocation('Or'), session) + + then: + // Return value is specified here, not via a separate stub, since a bare cardinality + // interaction for the same method declared later would shadow an earlier stub's return + // value and silently default to returning null. + 1 * query.disjunction() >> disjunctionMock + 2 * query.add(disjunctionMock, _) + 0 * query.add(_) + 1 * projectionList.count() + } + + void "isMethodMatch matches countBy* method names"() { + expect: + countFinder.isMethodMatch('countByName') + countFinder.isMethodMatch('countByNameAndAge') + !countFinder.isMethodMatch('somethingElse') + } + + void "setPattern delegates to the grammar"() { + expect: + countFinder.isMethodMatch('countByName') + !countFinder.isMethodMatch('customPrefixName') + + when: + countFinder.setPattern('(customPrefix)(\\w+)') + + then: + countFinder.isMethodMatch('customPrefixName') + !countFinder.isMethodMatch('countByName') + } + + void "invoke runs the full round trip through the real DatastoreUtils.execute seam and returns the count"() { + given: + Query.ProjectionList projectionList = Mock(Query.ProjectionList) + Query query = Mock(Query) { + getEntity() >> persistentEntity + projections() >> projectionList + singleResult() >> 2L + } + Session session = Stub(Session) { + createQuery(FinderTestEntity) >> query + } + datastore.hasCurrentSession() >> true + datastore.getCurrentSession() >> session + + when: + Object result = countFinder.invoke(FinderTestEntity, 'countByName', ['Bob'] as Object[]) + + then: + result == 2L + 1 * projectionList.count() + } + + void "invoke(Class, methodName, DetachedCriteria, Object[]) merges the detached criteria onto the built query"() { + given: + // Called reflectively (dynamic Groovy dispatch, not part of FinderMethod) by + // AbstractDetachedCriteria#methodMissing. + Query.ProjectionList projectionList = Mock(Query.ProjectionList) + Query query = Mock(Query) { + getEntity() >> persistentEntity + projections() >> projectionList + singleResult() >> 2L + } + Session session = Stub(Session) { + createQuery(FinderTestEntity) >> query + } + datastore.hasCurrentSession() >> true + datastore.getCurrentSession() >> session + grails.gorm.DetachedCriteria detachedCriteria = Stub(grails.gorm.DetachedCriteria) { + getFetchStrategies() >> [:] + getCriteria() >> [org.grails.datastore.mapping.query.Restrictions.eq('age', 42)] + getProjections() >> [] + getOrders() >> [] + } + + when: + Object result = countFinder.invoke(FinderTestEntity, 'countByName', detachedCriteria, ['Bob'] as Object[]) + + then: + result == 2L + // One from the detached criteria's merged criterion, one from the real "name" expression. + 2 * query.add({ it instanceof Query.PropertyCriterion }) + 1 * projectionList.count() + } + + void "invoke(Class, methodName, DetachedCriteria, Object[]) with a null detachedCriteria never merges anything onto the built query"() { + given: + Query.ProjectionList projectionList = Mock(Query.ProjectionList) + Query query = Mock(Query) { + getEntity() >> persistentEntity + projections() >> projectionList + singleResult() >> 2L + } + Session session = Stub(Session) { + createQuery(FinderTestEntity) >> query + } + datastore.hasCurrentSession() >> true + datastore.getCurrentSession() >> session + + when: + Object result = countFinder.invoke(FinderTestEntity, 'countByName', (grails.gorm.DetachedCriteria) null, ['Bob'] as Object[]) + + then: + result == 2L + 1 * query.add({ it instanceof Query.PropertyCriterion }) + 1 * projectionList.count() + } + + void "invoke throws IllegalStateException when constructed in stateless mode"() { + when: + CountFinder.countBy(mappingContext).invoke(FinderTestEntity, 'countByName', ['Bob'] as Object[]) + + then: + thrown(IllegalStateException) + } +} diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DynamicFinderInvocationSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DynamicFinderInvocationSpec.groovy new file mode 100644 index 00000000000..d14dd289daf --- /dev/null +++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DynamicFinderInvocationSpec.groovy @@ -0,0 +1,67 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.datastore.gorm.finders + +import grails.gorm.DetachedCriteria +import spock.lang.Specification + +/** + * DynamicFinderInvocation is an otherwise-immutable value object holding the pieces the + * DynamicFinder classes need to build and invoke a query - this spec confirms the constructor + * populates every getter, and that detachedCriteria (the one mutable field, wired in after + * construction by {@code DynamicFinder.invoke(Class, String, DetachedCriteria, Object[])}) starts + * out null and reflects whatever is later set on it. + */ +class DynamicFinderInvocationSpec extends Specification { + + void "constructor populates every getter"() { + given: + MethodExpression.Equal expression = new MethodExpression.Equal('name') + Closure criteria = {} + Object[] arguments = ['Bob'] as Object[] + + when: + DynamicFinderInvocation invocation = new DynamicFinderInvocation( + FinderTestEntity, 'findByName', arguments, [expression], criteria, 'And') + + then: + invocation.javaClass == FinderTestEntity + invocation.methodName == 'findByName' + invocation.arguments.is(arguments) + invocation.expressions == [expression] + invocation.criteria.is(criteria) + invocation.operator == 'And' + } + + void "getDetachedCriteria is null until explicitly set"() { + given: + DynamicFinderInvocation invocation = new DynamicFinderInvocation( + FinderTestEntity, 'findByName', [] as Object[], [], null, null) + + expect: + invocation.detachedCriteria == null + + when: + DetachedCriteria detachedCriteria = new DetachedCriteria(FinderTestEntity) + invocation.setDetachedCriteria(detachedCriteria) + + then: + invocation.detachedCriteria.is(detachedCriteria) + } +} diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DynamicFinderSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DynamicFinderSpec.groovy index 23e0bdecebb..bb65b2b703a 100644 --- a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DynamicFinderSpec.groovy +++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DynamicFinderSpec.groovy @@ -18,13 +18,41 @@ */ package org.grails.datastore.gorm.finders +import groovy.lang.MissingMethodException +import jakarta.persistence.FetchType +import jakarta.persistence.criteria.JoinType +import org.grails.datastore.gorm.query.criteria.AbstractDetachedCriteria +import org.grails.datastore.mapping.core.Session +import org.grails.datastore.mapping.model.MappingContext +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.datastore.mapping.model.PersistentProperty +import org.grails.datastore.mapping.model.types.Basic +import org.grails.datastore.mapping.query.Query +import org.grails.datastore.mapping.query.api.BuildableCriteria +import org.grails.datastore.mapping.query.api.QueryArgumentsAware +import org.springframework.core.convert.ConverterNotFoundException +import org.springframework.core.convert.TypeDescriptor +import org.springframework.core.convert.support.DefaultConversionService import spock.lang.Specification +import spock.lang.Unroll + +import java.util.regex.Pattern /** - * Created by graemerocher on 06/02/2017. + * Exercises {@link DynamicFinder}'s method-name grammar and its static parsing/argument-handling/ + * junction-building surface. {@code DynamicFinder} is a plain, non-abstract, non-inheritable + * collaborator - constructed directly here with the same patterns the real finder classes + * ({@link SingleResultFinder}, {@link ListResultFinder}, {@link CountFinder}) use, rather than via + * a concrete finder subclass (there are none any more). Collaborators (MappingContext/ + * PersistentEntity/Query) are mocked - this package's own responsibility is translating dynamic- + * finder method calls into {@code Query} objects, not persistence itself. */ class DynamicFinderSpec extends Specification { + private static final Pattern FIND_BY_PATTERN = Pattern.compile('(findBy)([A-Z]\\w*)') + private static final Pattern FIND_BY_BOOLEAN_PATTERN = Pattern.compile('(find)((\\w+)(By)([A-Z]\\w*)|(\\w++))') + private static final String[] OPERATORS = ['And', 'Or'] as String[] + void "test build match spec"() { given: MatchSpec spec = DynamicFinder.buildMatchSpec(prefix, methodName, parameters) @@ -35,11 +63,806 @@ class DynamicFinderSpec extends Specification { spec.requiredArguments == parameters spec.prefix == prefix spec.queryExpression == queryExpression + spec.propertyNames == propertyNames + + where: + prefix | methodName | parameters | expressions | queryExpression | propertyNames + "findBy" | "findByTitle" | 1 | 1 | "Title" | ['title'] + "findBy" | "findByTitleBetween" | 2 | 1 | "TitleBetween" | ['title'] + "findBy" | "findByTitleAndAuthor" | 2 | 2 | "TitleAndAuthor" | ['title', 'author'] + "findBy" | "findByTitleOrAuthor" | 2 | 2 | "TitleOrAuthor" | ['title', 'author'] + "findBy" | "findByAgeGreaterThanEquals" | 1 | 1 | "AgeGreaterThanEquals" | ['age'] + "findBy" | "findByAgeLessThanEquals" | 1 | 1 | "AgeLessThanEquals" | ['age'] + "findBy" | "findByActiveIsNull" | 0 | 1 | "ActiveIsNull" | ['active'] + "findBy" | "findByActiveIsNotNull" | 0 | 1 | "ActiveIsNotNull" | ['active'] + "findBy" | "findByAuthorNot" | 1 | 1 | "AuthorNot" | ['author'] + } + + void "buildMatchSpec returns null when there are fewer parameters than the expression requires"() { + expect: + DynamicFinder.buildMatchSpec("findBy", "findByTitleBetween", 1) == null + } + + void "buildMatchSpec returns null when a lone expression's arguments exceed the given parameter count"() { + expect: + DynamicFinder.buildMatchSpec("findBy", "findByTitle", 0) == null + } + + void "buildMatchSpec returns null when the method name does not match the prefix pattern at all"() { + expect: + DynamicFinder.buildMatchSpec("findBy", "getSomethingUnrelated", 1) == null + } + + void "buildMatchSpec returns null when an And-combined expression's total arguments exceed the given parameter count"() { + expect: + // "TitleAndAuthorBetween" splits into Equal(title, 1 arg) + Between(author, 2 args) = 3 + // required arguments total - only reachable via the post-loop check since neither + // individual expression alone exceeds parameterCount. + DynamicFinder.buildMatchSpec("findBy", "findByTitleAndAuthorBetween", 2) == null + } + + void "buildMatchSpec resolves a GreaterThanEquals suffix without truncating it to GreaterThan"() { + given: + MatchSpec spec = DynamicFinder.buildMatchSpec("findBy", "findByAgeGreaterThanEquals", 1) + + expect: + spec.methodCallExpressions[0] instanceof MethodExpression.GreaterThanEquals + } + + void "buildMatchSpec wraps a Not-suffixed clause's criterion in a Negation"() { + given: + MatchSpec spec = DynamicFinder.buildMatchSpec("findBy", "findByAuthorNot", 1) + MethodExpression expression = spec.methodCallExpressions[0] + expression.setArguments(['Bob'] as Object[]) + + when: + Query.Criterion criterion = expression.createCriterion() + + then: + criterion instanceof Query.Negation + Query.Negation negation = (Query.Negation) criterion + negation.criteria.size() == 1 + negation.criteria[0] instanceof Query.Equals + negation.criteria[0].property == 'author' + } + + void "buildMatchSpec throws when the derived property name is empty because the property itself shares the operator's name"() { + when: + // "findByLikeLike" means "property 'Like', operator Like" - but calcPropertyName finds the + // FIRST occurrence of "Like" in "LikeLike" (index 0, the property's own name), not the + // occurrence the regex actually matched, so the derived property name comes out empty. + DynamicFinder.buildMatchSpec("findBy", "findByLikeLike", 1) + + then: + thrown(IllegalArgumentException) + } + + void "buildMatchSpec's naive literal split on the And/Or operator can split inside an unrelated property name"() { + when: + // "findByAndroidVersionAndTitle" is meant to be "androidVersion And title", but + // querySequence.split("And") splits on every literal occurrence of "And", including the + // one inside "Android" itself, producing an empty leading segment - a real parsing bug in + // both buildMatchSpec and createFinderInvocation's identical split logic, not a + // hypothetical. Documented here as current behavior, not fixed as part of this pass. + DynamicFinder.buildMatchSpec("findBy", "findByAndroidVersionAndTitle", 2) + + then: + thrown(IllegalArgumentException) + } + + void "registerNewMethodExpression rejects a class without a (Class, String) constructor"() { + when: + DynamicFinder.registerNewMethodExpression(String) + + then: + thrown(IllegalArgumentException) + } + + void "registerNewMethodExpression registers a custom operator that buildMatchSpec can then resolve"() { + given: + DynamicFinder.registerNewMethodExpression(ZzzCustomTest) + + when: + // the registered alternative is matched against the CLASS'S OWN SIMPLE NAME, not some + // separately-declared suffix string - same convention every built-in operator uses + // (e.g. MethodExpression.GreaterThan's suffix is "GreaterThan", not a custom string). + MatchSpec spec = DynamicFinder.buildMatchSpec("findBy", "findByTitleZzzCustomTest", 1) + + then: + spec.methodCallExpressions[0] instanceof ZzzCustomTest + spec.propertyNames == ['title'] + } + + static class ZzzCustomTest extends MethodExpression { + ZzzCustomTest(Class targetClass, String propertyName) { + super(targetClass, propertyName) + } + + @Override + Query.Criterion createCriterion() { + return org.grails.datastore.mapping.query.Restrictions.eq(propertyName, arguments[0]) + } + } + + PersistentProperty idProperty = Stub(PersistentProperty) { + getName() >> 'id' + getType() >> Long + } + PersistentProperty nameProperty = Stub(PersistentProperty) { + getName() >> 'name' + getType() >> String + } + PersistentProperty ageProperty = Stub(PersistentProperty) { + getName() >> 'age' + getType() >> Integer + } + PersistentProperty authorProperty = Stub(PersistentProperty) { + getName() >> 'author' + getType() >> String + } + PersistentProperty activeProperty = Stub(PersistentProperty) { + getName() >> 'active' + getType() >> Boolean + } + PersistentProperty tagsProperty = Stub(Basic) { + getName() >> 'tags' + getType() >> List + } + MappingContext mappingContext = Stub(MappingContext) + PersistentEntity persistentEntity = Stub(PersistentEntity) + + // Wired in setup() rather than inline field initializers: mappingContext.getPersistentEntity() + // needs to return `persistentEntity`, and persistentEntity.getMappingContext() needs to return + // `mappingContext` right back - a genuine circular reference that inline field initializers + // can't express (a Stub(...) { } closure captures the RHS field's value at the moment its own + // field initializer runs, so whichever field is declared second would still be null). + void setup() { + mappingContext.getConversionService() >> new DefaultConversionService() + mappingContext.getPersistentEntity(FinderTestEntity.name) >> persistentEntity + persistentEntity.getIdentity() >> idProperty + persistentEntity.getMappingContext() >> mappingContext + persistentEntity.getPropertyByName('name') >> nameProperty + persistentEntity.getPropertyByName('age') >> ageProperty + persistentEntity.getPropertyByName('author') >> authorProperty + persistentEntity.getPropertyByName('active') >> activeProperty + persistentEntity.getPropertyByName('tags') >> tagsProperty + } + + DynamicFinder findByGrammar = new DynamicFinder(FIND_BY_PATTERN, OPERATORS, mappingContext, false) + DynamicFinder findByBooleanGrammar = new DynamicFinder(FIND_BY_BOOLEAN_PATTERN, OPERATORS, mappingContext, true) + + @Unroll + void "isMethodMatch('#methodName') == #matches"() { + expect: + findByGrammar.isMethodMatch(methodName) == matches + + where: + methodName | matches + 'findByName' | true + 'findByNameAndAge' | true + 'somethingElse' | false + } + + void "firstExpressionIsRequiredBoolean reflects the constructor flag"() { + expect: + !findByGrammar.firstExpressionIsRequiredBoolean() + findByBooleanGrammar.firstExpressionIsRequiredBoolean() + } + + void "createFinderInvocation resolves a single equality expression"() { + when: + DynamicFinderInvocation invocation = findByGrammar.createFinderInvocation(FinderTestEntity, 'findByName', null, ['Bob'] as Object[]) + + then: + invocation.expressions.size() == 1 + invocation.expressions[0] instanceof MethodExpression.Equal + invocation.expressions[0].propertyName == 'name' + invocation.arguments.length == 0 + } + + void "createFinderInvocation splits an And junction into two expressions and records the operator"() { + when: + DynamicFinderInvocation invocation = findByGrammar.createFinderInvocation( + FinderTestEntity, 'findByNameAndAge', null, ['Bob', 42] as Object[]) + + then: + invocation.operator == 'And' + invocation.expressions.size() == 2 + invocation.expressions[0].propertyName == 'name' + invocation.expressions[1].propertyName == 'age' + } + + void "createFinderInvocation splits an Or junction and records the Or operator"() { + when: + DynamicFinderInvocation invocation = findByGrammar.createFinderInvocation( + FinderTestEntity, 'findByNameOrAge', null, ['Bob', 42] as Object[]) + + then: + invocation.operator == 'Or' + invocation.expressions.size() == 2 + } + + void "createFinderInvocation wraps a Not-suffixed clause's expression so its criterion negates"() { + when: + DynamicFinderInvocation invocation = findByGrammar.createFinderInvocation( + FinderTestEntity, 'findByAuthorNot', null, ['Bob'] as Object[]) + Query.Criterion criterion = invocation.expressions[0].createCriterion() + + then: + invocation.expressions.size() == 1 + criterion instanceof Query.Negation + } + + void "createFinderInvocation throws MissingMethodException when too few arguments are supplied"() { + when: + findByGrammar.createFinderInvocation(FinderTestEntity, 'findByNameAndAge', null, ['Bob'] as Object[]) + + then: + thrown(MissingMethodException) + } + + void "createFinderInvocation's naive literal split on the And/Or operator can split inside an unrelated property name"() { + when: + // Mirrors the "buildMatchSpec's naive literal split..." test above, but through the actual + // runtime entry point real finders call, proving the split logic really is identical - not + // merely asserted to be by comment. "findByAndroidVersionAndTitle" is meant to be + // "androidVersion And title", but querySequence.split("And") splits on every literal + // occurrence of "And", including the one starting "AndroidVersion" itself, producing an + // empty leading segment. + findByGrammar.createFinderInvocation(FinderTestEntity, 'findByAndroidVersionAndTitle', null, ['x', 'y'] as Object[]) + + then: + thrown(IllegalArgumentException) + } + + void "createFinderInvocation rethrows a ConversionException as MissingMethodException for a non-Basic property"() { + given: + // "age" (a plain Integer, non-Basic property) with an argument that genuinely needs + // conversion - convertArguments only ever calls the ConversionService when the argument's + // type isn't already assignable to the property's type, so an already-matching argument + // (e.g. a String for a String property) would never reach the ConversionService at all. + mappingContext.getConversionService() >> Stub(org.springframework.core.convert.ConversionService) { + canConvert(_, _) >> true + convert(_, _) >> { throw new ConverterNotFoundException(TypeDescriptor.valueOf(String), TypeDescriptor.valueOf(Integer)) } + } + + when: + findByGrammar.createFinderInvocation(FinderTestEntity, 'findByAge', null, ['not-a-number'] as Object[]) + + then: + thrown(MissingMethodException) + } + + void "createFinderInvocation swallows a ConversionException for a Basic property"() { + given: + // Real Basic-typed properties always model a to-many/collection shape (Basic extends + // ToMany), and MethodExpression's own conversion logic skips the ConversionService + // entirely for a raw collection-typed property - so a scalar-typed Basic stub is used + // here purely to exercise DynamicFinder's own "instanceof Basic" dispatch in isolation, + // not to model a realistic Basic property. + PersistentProperty basicFlagProperty = Stub(Basic) { + getName() >> 'flag' + getType() >> Boolean + } + persistentEntity.getPropertyByName('flag') >> basicFlagProperty + mappingContext.getConversionService() >> Stub(org.springframework.core.convert.ConversionService) { + canConvert(_, _) >> true + convert(_, _) >> { throw new ConverterNotFoundException(TypeDescriptor.valueOf(String), TypeDescriptor.valueOf(Boolean)) } + } + + when: + DynamicFinderInvocation invocation = findByGrammar.createFinderInvocation(FinderTestEntity, 'findByFlag', null, ['not-a-boolean'] as Object[]) + + then: + noExceptionThrown() + invocation.expressions.size() == 1 + } + + void "createFinderInvocation resolves the boolean-plus-By form, defaulting the boolean argument to TRUE"() { + when: + DynamicFinderInvocation invocation = findByBooleanGrammar.createFinderInvocation( + FinderTestEntity, 'findActiveByName', null, ['Bob'] as Object[]) + + then: + invocation.expressions.size() == 2 + invocation.expressions[0].propertyName == 'active' + invocation.expressions[0].getArguments()[0] == Boolean.TRUE + invocation.expressions[1].propertyName == 'name' + } + + void "createFinderInvocation resolves the lone-boolean form with no By clause and no consumed arguments"() { + when: + DynamicFinderInvocation invocation = findByBooleanGrammar.createFinderInvocation( + FinderTestEntity, 'findActive', null, [] as Object[]) + + then: + invocation.expressions.size() == 1 + invocation.expressions[0].propertyName == 'active' + invocation.expressions[0].getArguments()[0] == Boolean.TRUE + } + + void "createFinderInvocation flips the boolean argument to FALSE for a Not-prefixed boolean clause"() { + when: + DynamicFinderInvocation invocation = findByBooleanGrammar.createFinderInvocation( + FinderTestEntity, 'findNotActiveByName', null, ['Bob'] as Object[]) + + then: + invocation.expressions[0].propertyName == 'active' + invocation.expressions[0].getArguments()[0] == Boolean.FALSE + } + + void "getJunction builds a Conjunction for the And operator"() { + given: + MethodExpression name = new MethodExpression.Equal('name') + name.setArguments(['Bob'] as Object[]) + MethodExpression age = new MethodExpression.Equal('age') + age.setArguments([42] as Object[]) + DynamicFinderInvocation invocation = new DynamicFinderInvocation(FinderTestEntity, 'findByNameAndAge', [] as Object[], [name, age], null, 'And') + + when: + Query.Junction junction = findByGrammar.getJunction(invocation) + + then: + junction instanceof Query.Conjunction + junction.criteria.size() == 2 + } + + void "getJunction builds a Disjunction for the Or operator when no boolean clause is required"() { + given: + MethodExpression name = new MethodExpression.Equal('name') + name.setArguments(['Bob'] as Object[]) + MethodExpression age = new MethodExpression.Equal('age') + age.setArguments([42] as Object[]) + DynamicFinderInvocation invocation = new DynamicFinderInvocation(FinderTestEntity, 'findByNameOrAge', [] as Object[], [name, age], null, 'Or') + + when: + Query.Junction junction = findByGrammar.getJunction(invocation) + + then: + junction instanceof Query.Disjunction + junction.criteria.size() == 2 + } + + void "getJunction keeps a required boolean clause AND'd even when the rest of the clauses use Or"() { + given: + MethodExpression active = new MethodExpression.Equal('active') + active.setArguments([Boolean.TRUE] as Object[]) + MethodExpression name = new MethodExpression.Equal('name') + name.setArguments(['Bob'] as Object[]) + MethodExpression age = new MethodExpression.Equal('age') + age.setArguments([42] as Object[]) + DynamicFinderInvocation invocation = new DynamicFinderInvocation( + FinderTestEntity, 'findActiveByNameOrAge', [] as Object[], [active, name, age], null, 'Or') + + when: + Query.Junction junction = findByBooleanGrammar.getJunction(invocation) + + then: + junction instanceof Query.Conjunction + junction.criteria.size() == 2 + junction.criteria[1] instanceof Query.Disjunction + junction.criteria[1].criteria.size() == 2 + } + + void "populateArgumentsForCriteria(Query) is a no-op for a null argument map"() { + given: + Query query = Mock(Query) + + when: + DynamicFinder.populateArgumentsForCriteria(FinderTestEntity, query, null) + + then: + 0 * query._ + } + + void "populateArgumentsForCriteria(Query) applies max and offset, converting String values"() { + given: + Query query = Mock(Query) { + getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext } + } + + when: + DynamicFinder.populateArgumentsForCriteria(FinderTestEntity, query, [max: '10', offset: '5']) + + then: + 1 * query.max(10) + 1 * query.offset(5) + } + + void "populateArgumentsForCriteria(Query) applies a String sort, defaulting to ascending and ignoring case"() { + given: + Query query = Mock(Query) { + getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext } + } + + when: + DynamicFinder.populateArgumentsForCriteria(FinderTestEntity, query, [sort: 'age']) + + then: + 1 * query.order({ Query.Order order -> + order.property == 'age' && order.direction == Query.Order.Direction.ASC && order.ignoreCase + }) + } + + void "populateArgumentsForCriteria(Query) applies a descending order and honours ignoreCase: false"() { + given: + Query query = Mock(Query) { + getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext } + } + + when: + DynamicFinder.populateArgumentsForCriteria(FinderTestEntity, query, [sort: 'age', order: 'desc', ignoreCase: false]) + + then: + 1 * query.order({ Query.Order order -> + order.property == 'age' && order.direction == Query.Order.Direction.DESC && !order.ignoreCase + }) + } + + void "populateArgumentsForCriteria(Query) applies a multi-field sort Map"() { + given: + Query query = Mock(Query) { + getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext } + } + + when: + DynamicFinder.populateArgumentsForCriteria(FinderTestEntity, query, [sort: [name: 'asc', age: 'desc']]) + + then: + 1 * query.order({ Query.Order order -> order.property == 'name' && order.direction == Query.Order.Direction.ASC }) + 1 * query.order({ Query.Order order -> order.property == 'age' && order.direction == Query.Order.Direction.DESC }) + } + + @Unroll + void "populateArgumentsForCriteria(Query) applies fetch entry #fetchValue as #expectedMethod"() { + given: + Query query = Mock(Query) { + getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext } + } + + when: + DynamicFinder.populateArgumentsForCriteria(FinderTestEntity, query, [fetch: [author: fetchValue]]) + + then: + 1 * query."$expectedMethod"('author') + + where: + fetchValue | expectedMethod + FetchType.EAGER | 'join' + FetchType.LAZY | 'select' + 'eager' | 'join' + 'join' | 'join' + 'lazy' | 'select' + 'select' | 'select' + 'unrecognised' | 'select' + } + + void "populateArgumentsForCriteria(Query) applies an explicit JoinType fetch entry via the two-arg join"() { + given: + Query query = Mock(Query) { + getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext } + } + + when: + DynamicFinder.populateArgumentsForCriteria(FinderTestEntity, query, [fetch: [author: JoinType.LEFT]]) + + then: + 1 * query.join('author', JoinType.LEFT) + } + + void "populateArgumentsForCriteria(Query) applies cache and lock flags"() { + given: + Query query = Mock(Query) { + getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext } + } + + when: + DynamicFinder.populateArgumentsForCriteria(FinderTestEntity, query, [cache: true, lock: true]) + + then: + 1 * query.cache(true) + 1 * query.lock(true) + } + + void "populateArgumentsForCriteria(Query) passes the raw argument map to a QueryArgumentsAware query"() { + given: + Query query = Mock(Query, additionalInterfaces: [QueryArgumentsAware]) { + getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext } + } + Map argMap = [sort: 'name'] + + when: + DynamicFinder.populateArgumentsForCriteria(FinderTestEntity, query, argMap) + + then: + 1 * ((QueryArgumentsAware) query).setArguments(argMap) + } + + void "populateArgumentsForCriteria(BuildableCriteria) is a no-op for a null argument map"() { + given: + BuildableCriteria criteria = Mock(BuildableCriteria) + + when: + DynamicFinder.populateArgumentsForCriteria(criteria, null) + + then: + 0 * criteria._ + } + + void "populateArgumentsForCriteria(BuildableCriteria) applies cache and sort, but has no lock/max/offset support"() { + given: + BuildableCriteria criteria = Mock(BuildableCriteria) + + when: + DynamicFinder.populateArgumentsForCriteria(criteria, [cache: true, sort: 'name']) + + then: + 1 * criteria.cache(true) + 1 * criteria.order({ Query.Order order -> order.property == 'name' }) + 0 * criteria.lock(_) + 0 * criteria.max(_) + 0 * criteria.offset(_) + } + + void "populateArgumentsForCriteria(BuildableCriteria) applies a descending order for a single-field CharSequence sort"() { + given: + BuildableCriteria criteria = Mock(BuildableCriteria) + + when: + DynamicFinder.populateArgumentsForCriteria(criteria, [sort: 'name', order: 'desc']) + + then: + 1 * criteria.order({ Query.Order order -> order.property == 'name' && order.direction == Query.Order.Direction.DESC && order.ignoreCase }) + } + + void "populateArgumentsForCriteria(BuildableCriteria) honours ignoreCase: false for a single-field CharSequence sort"() { + given: + BuildableCriteria criteria = Mock(BuildableCriteria) + + when: + DynamicFinder.populateArgumentsForCriteria(criteria, [sort: 'name', ignoreCase: false]) + + then: + 1 * criteria.order({ Query.Order order -> order.property == 'name' && order.direction == Query.Order.Direction.ASC && !order.ignoreCase }) + } + + void "populateArgumentsForCriteria(BuildableCriteria) applies fetch strategies for EAGER and LAZY associations"() { + given: + BuildableCriteria criteria = Mock(BuildableCriteria) + + when: + DynamicFinder.populateArgumentsForCriteria(criteria, [fetch: [author: FetchType.EAGER, tags: FetchType.LAZY]]) + + then: + 1 * criteria.join('author') + 1 * criteria.select('tags') + } + + void "populateArgumentsForCriteria(BuildableCriteria) applies a multi-field sort Map, but ignores each entry's own direction"() { + given: + // Real, surprising inconsistency between the two populateArgumentsForCriteria overloads: + // the Query-overload's multi-field sort (applySortForMap, tested above) reads EACH entry's + // own value ('asc'/'desc') to pick that field's direction. This BuildableCriteria-overload + // has its own separate inline loop that does NOT do that - it reads sortMap.get(key) into + // `value` but never uses it, applying a single direction (from the top-level "order" arg, + // defaulting to ascending) uniformly to every entry in the map instead. A per-field 'desc' + // here is silently ignored. + BuildableCriteria criteria = Mock(BuildableCriteria) + + when: + DynamicFinder.populateArgumentsForCriteria(criteria, [sort: [name: 'asc', age: 'desc']]) + + then: + 1 * criteria.order({ Query.Order order -> order.property == 'name' && order.direction == Query.Order.Direction.ASC }) + 1 * criteria.order({ Query.Order order -> order.property == 'age' && order.direction == Query.Order.Direction.ASC }) + } + + void "populateArgumentsForCriteria(BuildableCriteria) applies the top-level order argument uniformly to every sort Map entry"() { + given: + BuildableCriteria criteria = Mock(BuildableCriteria) + + when: + DynamicFinder.populateArgumentsForCriteria(criteria, [sort: [name: 'asc', age: 'desc'], order: 'desc']) + + then: + 1 * criteria.order({ Query.Order order -> order.property == 'name' && order.direction == Query.Order.Direction.DESC }) + 1 * criteria.order({ Query.Order order -> order.property == 'age' && order.direction == Query.Order.Direction.DESC }) + } + + void "populateArgumentsForCriteria(BuildableCriteria) passes the raw argument map to a QueryArgumentsAware criteria"() { + given: + BuildableCriteria criteria = Mock(BuildableCriteria, additionalInterfaces: [QueryArgumentsAware]) + Map argMap = [sort: 'name'] + + when: + DynamicFinder.populateArgumentsForCriteria(criteria, argMap) + + then: + 1 * ((QueryArgumentsAware) criteria).setArguments(argMap) + } + + void "getFetchMode resolves the FetchType for every recognised alias, defaulting to LAZY"() { + expect: + DynamicFinder.getFetchMode(input) == expected where: - prefix | methodName | parameters | expressions | queryExpression | propertyNames - "findBy" | "findByTitle" | 1 | 1 | "Title" | ['title'] - "findBy" | "findByTitleBetween" | 2 | 1 | "TitleBetween" | ['title'] - "findBy" | "findByTitleAndAuthor" | 2 | 2 | "TitleAndAuthor" | ['title', 'author'] + input | expected + FetchType.EAGER | FetchType.EAGER + FetchType.LAZY | FetchType.LAZY + 'eager' | FetchType.EAGER + 'join' | FetchType.EAGER + 'lazy' | FetchType.LAZY + 'select' | FetchType.LAZY + 'anything-else' | FetchType.LAZY + null | FetchType.LAZY + } + + void "applySortForMap applies ascending/descending per-entry order values, defaulting to ascending"() { + given: + Query query = Mock(Query) + + when: + DynamicFinder.applySortForMap(query, [name: 'asc', age: 'desc', title: null], true) + + then: + 1 * query.order({ Query.Order o -> o.property == 'name' && o.direction == Query.Order.Direction.ASC }) + 1 * query.order({ Query.Order o -> o.property == 'age' && o.direction == Query.Order.Direction.DESC }) + 1 * query.order({ Query.Order o -> o.property == 'title' && o.direction == Query.Order.Direction.ASC }) + } + + void "applyDetachedCriteria is a no-op for a null detached criteria"() { + given: + Query query = Mock(Query) + + when: + DynamicFinder.applyDetachedCriteria(query, null) + + then: + 0 * query._ + } + + void "applyDetachedCriteria merges fetch strategies, criteria, projections and orders onto the live query"() { + given: + Query query = Mock(Query) + MethodExpression.Equal expression = new MethodExpression.Equal('name') + expression.setArguments(['Bob'] as Object[]) + Query.Criterion criterion = expression.createCriterion() + Query.Projection projection = Mock(Query.Projection) + Query.Order order = Query.Order.asc('age') + AbstractDetachedCriteria detachedCriteria = Stub(AbstractDetachedCriteria) { + getFetchStrategies() >> [author: FetchType.EAGER, tags: FetchType.LAZY] + getJoinTypes() >> [author: JoinType.LEFT] + getCriteria() >> [criterion] + getProjections() >> [projection] + getOrders() >> [order] + } + Query.ProjectionList projectionList = Mock(Query.ProjectionList) + query.projections() >> projectionList + + when: + DynamicFinder.applyDetachedCriteria(query, detachedCriteria) + + then: + 1 * query.join('author', JoinType.LEFT) + 1 * query.select('tags') + 1 * query.add(criterion) + 1 * projectionList.add(projection) + 1 * query.order(order) + } + + void "applyDetachedCriteria joins without an explicit JoinType when none is configured for the association"() { + given: + Query query = Mock(Query) + AbstractDetachedCriteria detachedCriteria = Stub(AbstractDetachedCriteria) { + getFetchStrategies() >> [author: FetchType.EAGER] + getJoinTypes() >> [:] + getCriteria() >> [] + getProjections() >> [] + getOrders() >> [] + } + + when: + DynamicFinder.applyDetachedCriteria(query, detachedCriteria) + + then: + 1 * query.join('author') + 0 * query.join('author', _) + } + + void "applyAdditionalCriteria is a no-op for a null closure"() { + given: + Query query = Mock(Query) + + when: + DynamicFinder.applyAdditionalCriteria(query, null) + + then: + 0 * query._ + } + + void "applyAdditionalCriteria builds a real CriteriaBuilder from the query and adds the closure's criterion"() { + given: + PersistentEntity entity = Stub(PersistentEntity) { + getJavaClass() >> FinderTestEntity + getPropertyByName('name') >> nameProperty + } + Session session = Stub(Session) { + getMappingContext() >> mappingContext + } + Query query = Mock(Query) { + getEntity() >> entity + getSession() >> session + } + mappingContext.getPersistentEntity(FinderTestEntity.name) >> entity + + when: + DynamicFinder.applyAdditionalCriteria(query, { eq('name', 'Bob') }) + + then: + 1 * query.add({ Query.Criterion criterion -> + criterion instanceof Query.Equals && criterion.property == 'name' && criterion.value == 'Bob' + }) + } + + void "buildQuery creates a query from the session, applies additional criteria, detached criteria, arguments and the junction"() { + given: + Query query = Mock(Query) { + getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext } + } + Session session = Stub(Session) { + createQuery(FinderTestEntity) >> query + getMappingContext() >> mappingContext + } + DynamicFinderInvocation invocation = findByGrammar.createFinderInvocation( + FinderTestEntity, 'findByName', null, ['Bob'] as Object[]) + + when: + Query result = findByGrammar.buildQuery(invocation, session) + + then: + result.is(query) + 1 * query.add({ it instanceof Query.Conjunction }) + } + + void "buildQuery consumes the remaining argument map via configureQueryWithArguments"() { + given: + Query query = Mock(Query) { + getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext } + } + Session session = Stub(Session) { + createQuery(FinderTestEntity) >> query + getMappingContext() >> mappingContext + } + DynamicFinderInvocation invocation = findByGrammar.createFinderInvocation( + FinderTestEntity, 'findByName', null, ['Bob', [max: 5]] as Object[]) + + when: + findByGrammar.buildQuery(invocation, session) + + then: + 1 * query.max(5) + } + + void "buildQuery ignores a remaining argument that is not a Map"() { + given: + // configureQueryWithArguments only inspects arguments[0] as a criteria Map when it actually + // is one - a non-Map remaining argument must fall through exactly like having no remaining + // arguments at all. + Query query = Mock(Query) { + getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext } + } + Session session = Stub(Session) { + createQuery(FinderTestEntity) >> query + getMappingContext() >> mappingContext + } + DynamicFinderInvocation invocation = findByGrammar.createFinderInvocation( + FinderTestEntity, 'findByName', null, ['Bob', 'unexpectedExtra'] as Object[]) + + when: + findByGrammar.buildQuery(invocation, session) + + then: + 0 * query.max(_) + 0 * query.offset(_) } } diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/FinderSupportSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/FinderSupportSpec.groovy new file mode 100644 index 00000000000..b7adf3c57bc --- /dev/null +++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/FinderSupportSpec.groovy @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.datastore.gorm.finders + +import org.grails.datastore.mapping.core.Session +import org.grails.datastore.mapping.core.SessionCallback +import org.grails.datastore.mapping.core.VoidSessionCallback +import spock.lang.Specification + +/** + * Exercises {@link FinderSupport#execute}'s "stateless mode" guard - the shared session-execution + * helper every synchronous finder in this package calls with its own {@code Datastore} field, + * rather than inheriting an {@code execute} method. + */ +class FinderSupportSpec extends Specification { + + void "execute(SessionCallback) throws IllegalStateException when the datastore is null"() { + given: + SessionCallback callback = { Session session -> 'result' } as SessionCallback + + when: + FinderSupport.execute(null, callback) + + then: + IllegalStateException e = thrown() + e.message == 'Cannot execute session query in stateless mode' + } + + void "execute(VoidSessionCallback) throws IllegalStateException when the datastore is null"() { + given: + VoidSessionCallback callback = { Session session -> } as VoidSessionCallback + + when: + FinderSupport.execute(null, callback) + + then: + IllegalStateException e = thrown() + e.message == 'Cannot execute session query in stateless mode' + } +} diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/FinderTestEntity.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/FinderTestEntity.groovy new file mode 100644 index 00000000000..7c35f3e6992 --- /dev/null +++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/FinderTestEntity.groovy @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.datastore.gorm.finders + +/** + * Shared fixture domain class for unit-testing the {@code finders} package's classes + * (MethodExpression, DynamicFinder and its subclasses) against a real {@code PersistentEntity} + * shape rather than a fully mocked one. Not persisted anywhere - the finders package only + * translates method calls into {@code Query.Criterion}/{@code Query} objects, it never touches + * a real datastore itself. + */ +class FinderTestEntity { + Long id + String name + String title + String author + Integer age + BigDecimal price + Boolean active + List tags + + // Not a real GORM enhancement - FinderTestEntity is a plain Groovy class, never registered + // with a datastore. This stand-in exists purely so FindOrSaveByFinderSpec can observe that + // FindOrSaveByFinder actually invokes save() on the instance it constructs (via + // metaClass.invokeMethod(result, "save", null)), which would otherwise fail with a + // MissingMethodException against an unenhanced class. + boolean saved = false + + def save(args = null) { + saved = true + this + } +} diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/ListOrderByFinderSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/ListOrderByFinderSpec.groovy new file mode 100644 index 00000000000..adbeb0483fd --- /dev/null +++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/ListOrderByFinderSpec.groovy @@ -0,0 +1,159 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.datastore.gorm.finders + +import org.grails.datastore.mapping.core.Datastore +import org.grails.datastore.mapping.core.Session +import org.grails.datastore.mapping.model.MappingContext +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.datastore.mapping.query.Query +import org.springframework.core.convert.support.DefaultConversionService +import spock.lang.Specification +import spock.lang.Unroll + +/** + * Exercises ListOrderByFinder - unlike every other finder in this package, it never shared + * {@link DynamicFinder}'s grammar, with its own regex matching and its own {@code invoke()} + * method. Collaborators are wired using the same Datastore/Session/Query mock idiom as elsewhere + * in this package: the "existing bound session" branch of {@code DatastoreUtils.execute} + * (Datastore.hasCurrentSession() == true) is the simplest reliable seam for exercising the real + * {@code invoke()} round trip. + */ +class ListOrderByFinderSpec extends Specification { + + MappingContext mappingContext = Stub(MappingContext) { + getConversionService() >> new DefaultConversionService() + } + Datastore datastore = Stub(Datastore) + ListOrderByFinder finder = new ListOrderByFinder(datastore) + + @Unroll + void "isMethodMatch('#methodName') == #matches"() { + expect: + finder.isMethodMatch(methodName) == matches + + where: + methodName | matches + 'listOrderByName' | true + 'somethingElse' | false + } + + void "invoke defaults to ascending order when no Map argument is supplied, and lists distinct results"() { + given: + Query.ProjectionList projectionList = Mock(Query.ProjectionList) + Query query = Mock(Query) { + projections() >> projectionList + } + Session session = Stub(Session) { + createQuery(FinderTestEntity) >> query + } + datastore.hasCurrentSession() >> true + datastore.getCurrentSession() >> session + + when: + finder.invoke(FinderTestEntity, 'listOrderByAge', [] as Object[]) + + then: + 1 * query.order({ Query.Order order -> order.property == 'age' && order.direction == Query.Order.Direction.ASC }) + 1 * projectionList.distinct() + 1 * query.list() + 0 * query.singleResult() + } + + void "invoke ignores a non-Map first argument, defaulting to ascending order without delegating to DynamicFinder.populateArgumentsForCriteria"() { + given: + // Only a Map first argument is ever inspected for order/criteria options - anything else + // (e.g. a plain scalar) must fall through exactly like the no-arguments case. + Query.ProjectionList projectionList = Mock(Query.ProjectionList) + Query query = Mock(Query) { + projections() >> projectionList + } + Session session = Stub(Session) { + createQuery(FinderTestEntity) >> query + } + datastore.hasCurrentSession() >> true + datastore.getCurrentSession() >> session + + when: + finder.invoke(FinderTestEntity, 'listOrderByAge', ['not a map'] as Object[]) + + then: + 1 * query.order({ Query.Order order -> order.property == 'age' && order.direction == Query.Order.Direction.ASC }) + 1 * projectionList.distinct() + 1 * query.list() + } + + @Unroll + void "invoke stays ascending when the Map's order entry is '#order'"() { + given: + Query.ProjectionList projectionList = Mock(Query.ProjectionList) + PersistentEntity persistentEntity = Stub(PersistentEntity) { + getMappingContext() >> mappingContext + } + Query query = Mock(Query) { + getEntity() >> persistentEntity + projections() >> projectionList + } + Session session = Stub(Session) { + createQuery(FinderTestEntity) >> query + } + datastore.hasCurrentSession() >> true + datastore.getCurrentSession() >> session + + when: + finder.invoke(FinderTestEntity, 'listOrderByAge', [order != null ? [order: order] : [:]] as Object[]) + + then: + 1 * query.order({ Query.Order o -> o.property == 'age' && o.direction == Query.Order.Direction.ASC }) + 1 * projectionList.distinct() + 1 * query.list() + + where: + order << [null, 'asc'] + } + + void "invoke flips to descending order and delegates the remaining Map entries to DynamicFinder.populateArgumentsForCriteria"() { + given: + Query.ProjectionList projectionList = Mock(Query.ProjectionList) + PersistentEntity persistentEntity = Stub(PersistentEntity) { + getMappingContext() >> mappingContext + } + Query query = Mock(Query) { + getEntity() >> persistentEntity + projections() >> projectionList + } + Session session = Stub(Session) { + createQuery(FinderTestEntity) >> query + } + datastore.hasCurrentSession() >> true + datastore.getCurrentSession() >> session + + when: + // "max: 5" alongside "order: desc" proves the remaining-arguments Map is actually + // delegated to DynamicFinder.populateArgumentsForCriteria (which applies max()), not just + // that the order flag itself is honoured. + finder.invoke(FinderTestEntity, 'listOrderByAge', [[order: 'desc', max: 5]] as Object[]) + + then: + 1 * query.order({ Query.Order order -> order.property == 'age' && order.direction == Query.Order.Direction.DESC }) + 1 * query.max(5) + 1 * projectionList.distinct() + 1 * query.list() + } +} diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/ListResultFinderSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/ListResultFinderSpec.groovy new file mode 100644 index 00000000000..bc0823ec7a1 --- /dev/null +++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/ListResultFinderSpec.groovy @@ -0,0 +1,187 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.datastore.gorm.finders + +import org.grails.datastore.mapping.core.Datastore +import org.grails.datastore.mapping.core.Session +import org.grails.datastore.mapping.model.MappingContext +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.datastore.mapping.query.Query +import org.springframework.core.convert.support.DefaultConversionService +import spock.lang.Specification +import spock.lang.Unroll + +/** + * Exercises {@link ListResultFinder} - the composed replacement for the deleted + * FindAllByFinder/FindAllByBooleanFinder pair. + */ +class ListResultFinderSpec extends Specification { + + MappingContext mappingContext = Stub(MappingContext) + PersistentEntity persistentEntity = Stub(PersistentEntity) + Datastore datastore = Stub(Datastore) { + getMappingContext() >> mappingContext + } + + void setup() { + mappingContext.getConversionService() >> new DefaultConversionService() + mappingContext.getPersistentEntity(FinderTestEntity.name) >> persistentEntity + persistentEntity.getMappingContext() >> mappingContext + persistentEntity.getPropertyByName('name') >> Stub(org.grails.datastore.mapping.model.PersistentProperty) { + getName() >> 'name' + getType() >> String + } + } + + @Unroll + void "findAllBy isMethodMatch('#methodName') == #matches"() { + expect: + ListResultFinder.findAllBy(datastore).isMethodMatch(methodName) == matches + + where: + methodName | matches + 'findAllByName' | true + 'findAllByNameAndAge' | true + 'somethingElse' | false + } + + @Unroll + void "findAllByBoolean isMethodMatch('#methodName') == #matches"() { + expect: + ListResultFinder.findAllByBoolean(datastore).isMethodMatch(methodName) == matches + + where: + methodName | matches + 'findAllActiveByName' | true + 'findAllActive' | true + 'somethingElse' | false + } + + void "setPattern delegates to the grammar"() { + given: + ListResultFinder finder = ListResultFinder.findAllBy(datastore) + + expect: + finder.isMethodMatch('findAllByName') + !finder.isMethodMatch('customPrefixName') + + when: + finder.setPattern('(customPrefix)([A-Z]\\w*)') + + then: + finder.isMethodMatch('customPrefixName') + !finder.isMethodMatch('findAllByName') + } + + void "findAllBy runs the full round trip, applies distinct and returns the full list"() { + given: + List expected = [new FinderTestEntity(name: 'Bob')] + Query.ProjectionList projectionList = Mock(Query.ProjectionList) + Query query = Mock(Query) { + getEntity() >> persistentEntity + projections() >> projectionList + } + Session session = Stub(Session) { + createQuery(FinderTestEntity) >> query + } + datastore.hasCurrentSession() >> true + datastore.getCurrentSession() >> session + + when: + Object result = ListResultFinder.findAllBy(datastore).invoke(FinderTestEntity, 'findAllByName', ['Bob'] as Object[]) + + then: + result.is(expected) + 1 * projectionList.distinct() + 1 * query.list() >> expected + 0 * query.singleResult() + } + + void "invoke(Class, methodName, DetachedCriteria, Object[]) merges the detached criteria onto the built query"() { + given: + // Called reflectively (dynamic Groovy dispatch, not part of FinderMethod) by + // AbstractDetachedCriteria#methodMissing. + List expected = [new FinderTestEntity(name: 'Bob')] + Query.ProjectionList projectionList = Mock(Query.ProjectionList) + Query query = Mock(Query) { + getEntity() >> persistentEntity + projections() >> projectionList + } + Session session = Stub(Session) { + createQuery(FinderTestEntity) >> query + } + datastore.hasCurrentSession() >> true + datastore.getCurrentSession() >> session + grails.gorm.DetachedCriteria detachedCriteria = Stub(grails.gorm.DetachedCriteria) { + getFetchStrategies() >> [:] + getCriteria() >> [org.grails.datastore.mapping.query.Restrictions.eq('age', 42)] + getProjections() >> [] + getOrders() >> [] + } + + when: + Object result = ListResultFinder.findAllBy(datastore).invoke(FinderTestEntity, 'findAllByName', detachedCriteria, ['Bob'] as Object[]) + + then: + result.is(expected) + 1 * query.add({ it instanceof Query.PropertyCriterion }) + 1 * projectionList.distinct() + 1 * query.list() >> expected + } + + void "invoke(Class, methodName, DetachedCriteria, Object[]) with a null detachedCriteria never merges anything onto the built query"() { + given: + List expected = [new FinderTestEntity(name: 'Bob')] + Query.ProjectionList projectionList = Mock(Query.ProjectionList) + Query query = Mock(Query) { + getEntity() >> persistentEntity + projections() >> projectionList + } + Session session = Stub(Session) { + createQuery(FinderTestEntity) >> query + } + datastore.hasCurrentSession() >> true + datastore.getCurrentSession() >> session + + when: + Object result = ListResultFinder.findAllBy(datastore).invoke(FinderTestEntity, 'findAllByName', (grails.gorm.DetachedCriteria) null, ['Bob'] as Object[]) + + then: + result.is(expected) + 0 * query.add({ it instanceof Query.PropertyCriterion }) + 1 * projectionList.distinct() + 1 * query.list() >> expected + } + + void "invoke throws IllegalStateException when constructed in stateless mode"() { + when: + ListResultFinder.findAllBy(mappingContext).invoke(FinderTestEntity, 'findAllByName', ['Bob'] as Object[]) + + then: + thrown(IllegalStateException) + } + + void "findAllByBoolean invoke throws IllegalStateException when constructed in stateless mode"() { + when: + ListResultFinder.findAllByBoolean(mappingContext).invoke(FinderTestEntity, 'findAllActiveByName', ['Bob'] as Object[]) + + then: + thrown(IllegalStateException) + } +} diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/MethodExpressionSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/MethodExpressionSpec.groovy new file mode 100644 index 00000000000..1343e5a6ec3 --- /dev/null +++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/MethodExpressionSpec.groovy @@ -0,0 +1,574 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.datastore.gorm.finders + +import org.grails.datastore.mapping.model.MappingContext +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.datastore.mapping.model.PersistentProperty +import org.grails.datastore.mapping.query.Query +import org.springframework.core.convert.support.DefaultConversionService +import spock.lang.Specification +import spock.lang.Unroll + +/** + * Exercises every {@link MethodExpression} nested operator class - the DSL surface that dynamic + * finder method-name suffixes (GreaterThan, Like, InList, Between, ...) get parsed into by + * {@link DynamicFinder}. Collaborators (PersistentEntity/MappingContext) are stubbed with a real + * Spring {@link DefaultConversionService} since {@code convertArguments}' coercion behavior + * (GString -> String, collection/array element skip, delegated ConversionService conversion) is + * itself part of what several of these tests verify. + */ +class MethodExpressionSpec extends Specification { + + PersistentProperty idProperty = Stub(PersistentProperty) { + getName() >> 'id' + getType() >> Long + } + PersistentProperty nameProperty = Stub(PersistentProperty) { + getName() >> 'name' + getType() >> String + } + PersistentProperty ageProperty = Stub(PersistentProperty) { + getName() >> 'age' + getType() >> Integer + } + PersistentProperty tagsProperty = Stub(PersistentProperty) { + getName() >> 'tags' + getType() >> List + } + MappingContext mappingContext = Stub(MappingContext) { + getConversionService() >> new DefaultConversionService() + } + PersistentEntity persistentEntity = Stub(PersistentEntity) { + getIdentity() >> idProperty + getMappingContext() >> mappingContext + getPropertyByName('name') >> nameProperty + getPropertyByName('age') >> ageProperty + getPropertyByName('tags') >> tagsProperty + getPropertyByName('id') >> null + getPropertyByName('missing') >> null + } + + @Unroll + void "#expressionType.simpleName createCriterion builds the matching Query.Criterion"() { + given: + MethodExpression expression = expressionType.getConstructor(String).newInstance('name') + expression.setArguments(['Bob'] as Object[]) + + when: + Query.Criterion criterion = expression.createCriterion() + + then: + criterionType.isInstance(criterion) + criterion.property == 'name' + criterion.value == 'Bob' + + where: + expressionType | criterionType + MethodExpression.GreaterThan | Query.GreaterThan + MethodExpression.GreaterThanEquals | Query.GreaterThanEquals + MethodExpression.LessThan | Query.LessThan + MethodExpression.LessThanEquals | Query.LessThanEquals + } + + @Unroll + void "#expressionType.simpleName createCriterion builds the matching pattern criterion"() { + given: + MethodExpression expression = expressionType.getConstructor(String).newInstance('name') + expression.setArguments(['Bo%'] as Object[]) + + when: + Query.Criterion criterion = expression.createCriterion() + + then: + criterionType.isInstance(criterion) + criterion.property == 'name' + criterion.pattern == 'Bo%' + + where: + expressionType | criterionType + MethodExpression.Like | Query.Like + MethodExpression.Ilike | Query.ILike + MethodExpression.Rlike | Query.RLike + } + + void "Like coerces a non-String argument via toString()"() { + given: + MethodExpression expression = new MethodExpression.Like('age') + expression.setArguments([42] as Object[]) + + expect: + expression.createCriterion().pattern == '42' + } + + void "Equal builds an Equals criterion when the argument is non-null"() { + given: + MethodExpression expression = new MethodExpression.Equal('name') + expression.setArguments(['Bob'] as Object[]) + + when: + Query.Criterion criterion = expression.createCriterion() + + then: + criterion instanceof Query.Equals + criterion.property == 'name' + criterion.value == 'Bob' + } + + void "Equal falls back to an IsNull criterion when the argument is null"() { + given: + MethodExpression expression = new MethodExpression.Equal('name') + expression.setArguments([null] as Object[]) + + when: + Query.Criterion criterion = expression.createCriterion() + + then: + criterion instanceof Query.IsNull + criterion.property == 'name' + } + + void "NotEqual builds a NotEquals criterion when the argument is non-null"() { + given: + MethodExpression expression = new MethodExpression.NotEqual('name') + expression.setArguments(['Bob'] as Object[]) + + when: + Query.Criterion criterion = expression.createCriterion() + + then: + criterion instanceof Query.NotEquals + criterion.property == 'name' + criterion.value == 'Bob' + } + + void "NotEqual falls back to an IsNotNull criterion when the argument is null"() { + given: + MethodExpression expression = new MethodExpression.NotEqual('name') + expression.setArguments([null] as Object[]) + + when: + Query.Criterion criterion = expression.createCriterion() + + then: + criterion instanceof Query.IsNotNull + criterion.property == 'name' + } + + @Unroll + void "#expressionType.simpleName requires zero arguments and builds a bare property criterion"() { + given: + MethodExpression expression = expressionType.getConstructor(String).newInstance('name') + + expect: + expression.argumentsRequired == 0 + + when: + Query.Criterion criterion = expression.createCriterion() + + then: + criterionType.isInstance(criterion) + criterion.property == 'name' + + where: + expressionType | criterionType + MethodExpression.IsNull | Query.IsNull + MethodExpression.IsNotNull | Query.IsNotNull + MethodExpression.IsEmpty | Query.IsEmpty + MethodExpression.IsNotEmpty | Query.IsNotEmpty + } + + void "InList builds an In criterion from a Collection argument"() { + given: + MethodExpression expression = new MethodExpression.InList('name') + expression.setArguments([['a', 'b']] as Object[]) + + when: + Query.Criterion criterion = expression.createCriterion() + + then: + criterion instanceof Query.In + criterion.property == 'name' + // Query.In.getValues() returns Collections.unmodifiableCollection(), which - unlike + // unmodifiableList()/unmodifiableSet() - does not delegate equals(), so it must be + // materialized before comparing. + criterion.values.toList() == ['a', 'b'] + } + + void "InList setArguments rejects a non-Collection, non-null argument"() { + given: + MethodExpression expression = new MethodExpression.InList('name') + + when: + expression.setArguments(['not-a-collection'] as Object[]) + + then: + thrown(IllegalArgumentException) + } + + void "InList setArguments accepts a null argument"() { + given: + MethodExpression expression = new MethodExpression.InList('name') + + when: + expression.setArguments([null] as Object[]) + + then: + noExceptionThrown() + } + + void "InList setArguments rejects an empty argument array"() { + given: + MethodExpression expression = new MethodExpression.InList('name') + + when: + expression.setArguments([] as Object[]) + + then: + thrown(IllegalArgumentException) + } + + void "InList convertArguments converts each collection element individually"() { + given: + MethodExpression expression = new MethodExpression.InList('age') + expression.setArguments([['1', '2']] as Object[]) + + when: + expression.convertArguments(persistentEntity) + + then: + expression.getArguments()[0] == [1, 2] + } + + void "InList convertArguments converts a null collection into an empty list"() { + given: + MethodExpression expression = new MethodExpression.InList('age') + expression.setArguments([null] as Object[]) + + when: + expression.convertArguments(persistentEntity) + + then: + expression.getArguments()[0] == [] + } + + void "InList convertArguments is a no-op when the property cannot be resolved"() { + given: + MethodExpression expression = new MethodExpression.InList('missing') + expression.setArguments([['1', '2']] as Object[]) + + when: + expression.convertArguments(persistentEntity) + + then: + expression.getArguments()[0] == ['1', '2'] + } + + void "NotInList wraps an In criterion in a Negation"() { + given: + MethodExpression expression = new MethodExpression.NotInList('name') + expression.setArguments([['a', 'b']] as Object[]) + + when: + Query.Criterion criterion = expression.createCriterion() + + then: + criterion instanceof Query.Negation + Query.Negation negation = (Query.Negation) criterion + negation.criteria.size() == 1 + Query.In wrapped = negation.criteria[0] + wrapped.property == 'name' + wrapped.values.toList() == ['a', 'b'] + } + + void "NotInList setArguments applies the same Collection-or-null validation as InList"() { + given: + MethodExpression expression = new MethodExpression.NotInList('name') + + when: + expression.setArguments(['not-a-collection'] as Object[]) + + then: + thrown(IllegalArgumentException) + } + + void "NotInList convertArguments converts each collection element individually"() { + given: + MethodExpression expression = new MethodExpression.NotInList('age') + expression.setArguments([['1', '2']] as Object[]) + + when: + expression.convertArguments(persistentEntity) + + then: + expression.getArguments()[0] == [1, 2] + } + + void "Between requires two arguments"() { + given: + MethodExpression expression = new MethodExpression.Between('age') + + expect: + expression.argumentsRequired == 2 + } + + void "Between builds a Between criterion from two Comparable arguments"() { + given: + MethodExpression expression = new MethodExpression.Between('age') + expression.setArguments([1, 10] as Object[]) + + when: + Query.Criterion criterion = expression.createCriterion() + + then: + criterion instanceof Query.Between + criterion.property == 'age' + criterion.from == 1 + criterion.to == 10 + } + + void "Between setArguments rejects fewer than two arguments"() { + given: + MethodExpression expression = new MethodExpression.Between('age') + + when: + expression.setArguments([1] as Object[]) + + then: + thrown(IllegalArgumentException) + } + + @Unroll + void "Between setArguments rejects a non-Comparable argument (#description)"() { + given: + MethodExpression expression = new MethodExpression.Between('age') + + when: + expression.setArguments([first, second] as Object[]) + + then: + thrown(IllegalArgumentException) + + where: + description | first | second + 'non-Comparable first argument' | new Object() | 10 + 'non-Comparable second argument' | 1 | new Object() + } + + void "InRange requires exactly one argument"() { + given: + MethodExpression expression = new MethodExpression.InRange('age') + + expect: + expression.argumentsRequired == 1 + } + + void "InRange builds a Between criterion from a Range's from/to bounds"() { + given: + MethodExpression expression = new MethodExpression.InRange('age') + expression.setArguments([1..10] as Object[]) + + when: + Query.Criterion criterion = expression.createCriterion() + + then: + criterion instanceof Query.Between + criterion.property == 'age' + criterion.from == 1 + criterion.to == 10 + } + + void "InRange setArguments rejects a non-Range argument"() { + given: + MethodExpression expression = new MethodExpression.InRange('age') + + when: + expression.setArguments(['not-a-range'] as Object[]) + + then: + thrown(IllegalArgumentException) + } + + void "InRange setArguments rejects more than one argument"() { + given: + MethodExpression expression = new MethodExpression.InRange('age') + + when: + expression.setArguments([1..10, 'extra'] as Object[]) + + then: + thrown(IllegalArgumentException) + } + + void "InRange convertArguments is a no-op, trusting setArguments already validated the Range"() { + given: + MethodExpression expression = new MethodExpression.InRange('age') + def range = 1..10 + expression.setArguments([range] as Object[]) + + when: + expression.convertArguments(persistentEntity) + + then: + expression.getArguments()[0].is(range) + } + + void "convertArguments coerces a GString argument to a real String"() { + given: + MethodExpression expression = new MethodExpression.Equal('name') + def suffix = 'Bo' + expression.setArguments(["${suffix}b"] as Object[]) + + when: + expression.convertArguments(persistentEntity) + + then: + expression.getArguments()[0] == 'Bob' + expression.getArguments()[0] instanceof String + } + + void "convertArguments delegates to the ConversionService for a convertible mismatched type"() { + given: + MethodExpression expression = new MethodExpression.Equal('age') + expression.setArguments(['42'] as Object[]) + + when: + expression.convertArguments(persistentEntity) + + then: + expression.getArguments()[0] == 42 + } + + void "convertArguments leaves an already-assignable argument untouched"() { + given: + MethodExpression expression = new MethodExpression.Equal('name') + String value = 'Bob' + expression.setArguments([value] as Object[]) + + when: + expression.convertArguments(persistentEntity) + + then: + expression.getArguments()[0].is(value) + } + + void "convertArguments skips a Collection argument whose elements already match the declared element type"() { + given: + MethodExpression expression = new MethodExpression.Equal('tags') + List value = ['a', 'b'] + expression.setArguments([value] as Object[]) + + when: + expression.convertArguments(persistentEntity) + + then: + expression.getArguments()[0].is(value) + } + + void "convertArguments resolves the identity property when propertyName matches it"() { + given: + MethodExpression expression = new MethodExpression.Equal('id') + expression.setArguments(['42'] as Object[]) + + when: + expression.convertArguments(persistentEntity) + + then: + expression.getArguments()[0] == 42 + } + + void "InList convertArguments resolves the identity property when propertyName matches it"() { + given: + // InList/NotInList's convertArguments delegates to the private static + // convertArgumentsForProp helper, which has its own COPY of the identity-property fallback + // used by the base convertArguments above - a separate branch, separately worth covering. + MethodExpression expression = new MethodExpression.InList('id') + expression.setArguments([['42', '43']] as Object[]) + + when: + expression.convertArguments(persistentEntity) + + then: + expression.getArguments()[0] == [42L, 43L] + } + + void "convertArguments is a no-op when the property cannot be resolved at all"() { + given: + MethodExpression expression = new MethodExpression.Equal('missing') + expression.setArguments(['unchanged'] as Object[]) + + when: + expression.convertArguments(persistentEntity) + + then: + expression.getArguments()[0] == 'unchanged' + } + + void "getArguments returns a defensive copy, not the live backing array"() { + given: + MethodExpression expression = new MethodExpression.Equal('name') + expression.setArguments(['Bob'] as Object[]) + + when: + Object[] copy = expression.getArguments() + copy[0] = 'Mutated' + + then: + expression.getArguments()[0] == 'Bob' + } + + void "getPropertyName returns the property this expression targets"() { + expect: + new MethodExpression.Equal('name').propertyName == 'name' + } + + @Unroll + void "#expressionType.simpleName's (Class, String) constructor delegates identically to its (String) constructor"() { + given: + // DynamicFinder always constructs operators via the (Class, String) overload (the Class + // arg feeds the deprecated, unused `targetClass` field) - this confirms every concrete + // operator's second constructor sets propertyName the same way the first one does, rather + // than only ever exercising the (String)-only overload used elsewhere in this spec. + MethodExpression expression = expressionType.getConstructor(Class, String).newInstance(FinderTestEntity, 'name') + + expect: + expression.propertyName == 'name' + expression.argumentsRequired == argumentsRequired + + where: + expressionType | argumentsRequired + MethodExpression.Equal | 1 + MethodExpression.NotEqual | 1 + MethodExpression.GreaterThan | 1 + MethodExpression.GreaterThanEquals | 1 + MethodExpression.LessThan | 1 + MethodExpression.LessThanEquals | 1 + MethodExpression.Like | 1 + MethodExpression.Ilike | 1 + MethodExpression.Rlike | 1 + MethodExpression.InList | 1 + MethodExpression.NotInList | 1 + MethodExpression.Between | 2 + MethodExpression.InRange | 1 + MethodExpression.IsNull | 0 + MethodExpression.IsNotNull | 0 + MethodExpression.IsEmpty | 0 + MethodExpression.IsNotEmpty | 0 + } +} diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/SingleResultFinderSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/SingleResultFinderSpec.groovy new file mode 100644 index 00000000000..9c66799c0a4 --- /dev/null +++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/SingleResultFinderSpec.groovy @@ -0,0 +1,371 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.datastore.gorm.finders + +import groovy.lang.MissingMethodException +import org.grails.datastore.mapping.core.Datastore +import org.grails.datastore.mapping.core.Session +import org.grails.datastore.mapping.core.exceptions.ConfigurationException +import org.grails.datastore.mapping.model.MappingContext +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.datastore.mapping.model.PersistentProperty +import org.grails.datastore.mapping.query.Query +import org.springframework.core.convert.support.DefaultConversionService +import spock.lang.Specification +import spock.lang.Unroll + +/** + * Exercises {@link SingleResultFinder} - the composed replacement for the deleted + * AbstractFindByFinder/FindByFinder/FindByBooleanFinder/FindOrCreateByFinder/FindOrSaveByFinder + * class hierarchy. Every factory method builds its own {@link DynamicFinder} grammar instance + * (never shared across finders, since a shared instance would be a real thread-safety hazard + * given finder instances are long-lived and invoked from arbitrary request threads), so each + * test constructs the specific factory under test directly rather than reusing a single shared + * field. + */ +class SingleResultFinderSpec extends Specification { + + PersistentProperty nameProperty = Stub(PersistentProperty) { + getName() >> 'name' + getType() >> String + } + PersistentProperty ageProperty = Stub(PersistentProperty) { + getName() >> 'age' + getType() >> Integer + } + MappingContext mappingContext = Stub(MappingContext) + PersistentEntity persistentEntity = Stub(PersistentEntity) + Datastore datastore = Stub(Datastore) { + getMappingContext() >> mappingContext + } + + // Every property lookup a test's method name touches must be stubbed explicitly: Spock's Stub + // "smart null" returns a DUMMY PersistentProperty (not null) for any unstubbed + // getPropertyByName(...) call, since PersistentProperty is an interface - that dummy's own + // unstubbed getType() then needs a dummy java.lang.Class, which Spock cannot create (final + // class), crashing every test that reaches convertArguments for an unstubbed property. + void setup() { + mappingContext.getConversionService() >> new DefaultConversionService() + mappingContext.getPersistentEntity(FinderTestEntity.name) >> persistentEntity + persistentEntity.getMappingContext() >> mappingContext + persistentEntity.getPropertyByName('name') >> nameProperty + persistentEntity.getPropertyByName('age') >> ageProperty + } + + @Unroll + void "findBy isMethodMatch('#methodName') == #matches"() { + expect: + SingleResultFinder.findBy(datastore).isMethodMatch(methodName) == matches + + where: + methodName | matches + 'findByName' | true + 'findByNameAndAge' | true + 'somethingElse' | false + } + + @Unroll + void "findByBoolean isMethodMatch('#methodName') == #matches"() { + expect: + SingleResultFinder.findByBoolean(datastore).isMethodMatch(methodName) == matches + + where: + methodName | matches + 'findActiveByName' | true + 'findActive' | true + 'somethingElse' | false + } + + void "setPattern delegates to the grammar"() { + given: + SingleResultFinder finder = SingleResultFinder.findBy(datastore) + + expect: + finder.isMethodMatch('findByName') + !finder.isMethodMatch('customPrefixName') + + when: + finder.setPattern('(customPrefix)([A-Z]\\w*)') + + then: + finder.isMethodMatch('customPrefixName') + !finder.isMethodMatch('findByName') + } + + void "findBy runs the full round trip through the real DatastoreUtils.execute seam and returns singleResult()"() { + given: + FinderTestEntity expected = new FinderTestEntity(name: 'Bob') + Query query = Mock(Query) { + getEntity() >> persistentEntity + singleResult() >> expected + } + Session session = Stub(Session) { + createQuery(FinderTestEntity) >> query + } + // DatastoreUtils.execute() takes the "existing bound session" branch when + // Datastore.hasCurrentSession() answers true, invoking the callback directly with that + // session - the simplest reliable seam for exercising the real round trip without wiring + // Spring's transaction synchronization machinery. + datastore.hasCurrentSession() >> true + datastore.getCurrentSession() >> session + + when: + Object result = SingleResultFinder.findBy(datastore).invoke(FinderTestEntity, 'findByName', ['Bob'] as Object[]) + + then: + result.is(expected) + 1 * query.add({ it instanceof Query.Conjunction }) + } + + void "invoke(Class, methodName, DetachedCriteria, Object[]) merges the detached criteria onto the built query"() { + given: + // Called reflectively (dynamic Groovy dispatch, not part of FinderMethod) by + // AbstractDetachedCriteria#methodMissing - this is the seam a real + // `SomeDetachedCriteria.findByName(...)` call goes through. + FinderTestEntity expected = new FinderTestEntity(name: 'Bob') + Query query = Mock(Query) { + getEntity() >> persistentEntity + singleResult() >> expected + } + Session session = Stub(Session) { + createQuery(FinderTestEntity) >> query + } + datastore.hasCurrentSession() >> true + datastore.getCurrentSession() >> session + grails.gorm.DetachedCriteria detachedCriteria = Stub(grails.gorm.DetachedCriteria) { + getFetchStrategies() >> [:] + getCriteria() >> [org.grails.datastore.mapping.query.Restrictions.eq('age', 42)] + getProjections() >> [] + getOrders() >> [] + } + + when: + Object result = SingleResultFinder.findBy(datastore).invoke(FinderTestEntity, 'findByName', detachedCriteria, ['Bob'] as Object[]) + + then: + result.is(expected) + 1 * query.add({ it instanceof Query.PropertyCriterion }) + 1 * query.add({ it instanceof Query.Conjunction }) + } + + void "invoke(Class, methodName, DetachedCriteria, Object[]) with a null detachedCriteria never merges anything onto the built query"() { + given: + // Same reflective entry point as above, but for the case AbstractDetachedCriteria's own + // detached criteria has nothing to merge - proving the null-check actually guards the merge + // rather than it happening to be a no-op some other way. + FinderTestEntity expected = new FinderTestEntity(name: 'Bob') + Query query = Mock(Query) { + getEntity() >> persistentEntity + singleResult() >> expected + } + Session session = Stub(Session) { + createQuery(FinderTestEntity) >> query + } + datastore.hasCurrentSession() >> true + datastore.getCurrentSession() >> session + + when: + Object result = SingleResultFinder.findBy(datastore).invoke(FinderTestEntity, 'findByName', (grails.gorm.DetachedCriteria) null, ['Bob'] as Object[]) + + then: + result.is(expected) + 0 * query.add({ it instanceof Query.PropertyCriterion }) + 1 * query.add({ it instanceof Query.Conjunction }) + } + + void "invoke(Class, methodName) throws IllegalStateException when constructed in stateless mode"() { + when: + SingleResultFinder.findBy(mappingContext).invoke(FinderTestEntity, 'findByName', ['Bob'] as Object[]) + + then: + thrown(IllegalStateException) + } + + void "findByBoolean invoke throws IllegalStateException when constructed in stateless mode"() { + when: + SingleResultFinder.findByBoolean(mappingContext).invoke(FinderTestEntity, 'findActiveByName', ['Bob'] as Object[]) + + then: + thrown(IllegalStateException) + } + + void "findOrCreateBy rejects the Or operator without ever touching the datastore"() { + given: + Datastore datastoreThatMustNotBeUsed = Mock(Datastore) { + getMappingContext() >> mappingContext + } + + when: + SingleResultFinder.findOrCreateBy(datastoreThatMustNotBeUsed).invoke( + FinderTestEntity, 'findOrCreateByNameOrAge', ['Bob', 42] as Object[]) + + then: + thrown(MissingMethodException) + 0 * datastoreThatMustNotBeUsed.hasCurrentSession() + 0 * datastoreThatMustNotBeUsed.connect() + } + + @Unroll + void "findOrCreateBy rejects a #expressionType.simpleName expression as not equality-based"() { + given: + Datastore datastoreThatMustNotBeUsed = Mock(Datastore) { + getMappingContext() >> mappingContext + } + + when: + SingleResultFinder.findOrCreateBy(datastoreThatMustNotBeUsed).invoke( + FinderTestEntity, "findOrCreateByAge${expressionType.simpleName}", [18] as Object[]) + + then: + thrown(ConfigurationException) + + where: + expressionType << [MethodExpression.GreaterThan, MethodExpression.LessThan, + MethodExpression.GreaterThanEquals, MethodExpression.LessThanEquals] + } + + void "findOrCreateBy constructs a new instance via the Map constructor when the query returns null, without saving it"() { + given: + Query query = Mock(Query) { + getEntity() >> persistentEntity + singleResult() >> null + } + Session session = Stub(Session) { + createQuery(FinderTestEntity) >> query + } + datastore.hasCurrentSession() >> true + datastore.getCurrentSession() >> session + + when: + Object result = SingleResultFinder.findOrCreateBy(datastore).invoke(FinderTestEntity, 'findOrCreateByName', ['Bob'] as Object[]) + + then: + result instanceof FinderTestEntity + result.name == 'Bob' + !result.saved + } + + void "findOrCreateBy wraps a ConversionException from singleResult() into a MissingMethodException"() { + given: + // Guards the onNullResult-configured path (findOrCreateBy/findOrSaveBy only): if the query + // itself fails to convert an argument rather than simply returning null, that failure must + // still surface as a MissingMethodException, not propagate as a raw ConversionException. + Query query = Mock(Query) { + getEntity() >> persistentEntity + singleResult() >> { throw new org.springframework.core.convert.ConversionFailedException( + org.springframework.core.convert.TypeDescriptor.valueOf(String), org.springframework.core.convert.TypeDescriptor.valueOf(Integer), 'Bob', new IllegalArgumentException()) } + } + Session session = Stub(Session) { + createQuery(FinderTestEntity) >> query + } + datastore.hasCurrentSession() >> true + datastore.getCurrentSession() >> session + + when: + SingleResultFinder.findOrCreateBy(datastore).invoke(FinderTestEntity, 'findOrCreateByName', ['Bob'] as Object[]) + + then: + thrown(MissingMethodException) + } + + void "findOrCreateBy invoke throws IllegalStateException when constructed in stateless mode"() { + when: + SingleResultFinder.findOrCreateBy(mappingContext).invoke(FinderTestEntity, 'findOrCreateByName', ['Bob'] as Object[]) + + then: + thrown(IllegalStateException) + } + + void "findOrCreateBy throws MissingMethodException on the null-result path when a non-Equal expression reached it"() { + given: + // validate only rejects GT/LT/GTE/LTE - Like/InList/etc slip past it, so this defensive + // check in the null-result construction path is a genuinely separate branch. + Query query = Mock(Query) { + getEntity() >> persistentEntity + singleResult() >> null + } + Session session = Stub(Session) { + createQuery(FinderTestEntity) >> query + } + datastore.hasCurrentSession() >> true + datastore.getCurrentSession() >> session + + when: + SingleResultFinder.findOrCreateBy(datastore).invoke(FinderTestEntity, 'findOrCreateByNameLike', ['Bo%'] as Object[]) + + then: + thrown(MissingMethodException) + } + + void "findOrSaveBy rejects the Or operator, mirroring findOrCreateBy"() { + when: + SingleResultFinder.findOrSaveBy(datastore).invoke(FinderTestEntity, 'findOrSaveByNameOrAge', ['Bob', 42] as Object[]) + + then: + thrown(MissingMethodException) + } + + void "findOrSaveBy constructs a new instance and saves it when the query returns null"() { + given: + Query query = Mock(Query) { + getEntity() >> persistentEntity + singleResult() >> null + } + Session session = Stub(Session) { + createQuery(FinderTestEntity) >> query + } + datastore.hasCurrentSession() >> true + datastore.getCurrentSession() >> session + + when: + Object result = SingleResultFinder.findOrSaveBy(datastore).invoke(FinderTestEntity, 'findOrSaveByName', ['Bob'] as Object[]) + + then: + result instanceof FinderTestEntity + result.name == 'Bob' + result.saved + } + + void "findOrSaveBy invoke throws IllegalStateException when constructed in stateless mode"() { + when: + SingleResultFinder.findOrSaveBy(mappingContext).invoke(FinderTestEntity, 'findOrSaveByName', ['Bob'] as Object[]) + + then: + thrown(IllegalStateException) + } + + void "findOrSaveBy throws MissingMethodException on the null-result path when a non-Equal expression reached it"() { + given: + Query query = Mock(Query) { + getEntity() >> persistentEntity + singleResult() >> null + } + Session session = Stub(Session) { + createQuery(FinderTestEntity) >> query + } + datastore.hasCurrentSession() >> true + datastore.getCurrentSession() >> session + + when: + SingleResultFinder.findOrSaveBy(datastore).invoke(FinderTestEntity, 'findOrSaveByNameLike', ['Bo%'] as Object[]) + + then: + thrown(MissingMethodException) + } +} diff --git a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/api/RxGormStaticApi.groovy b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/api/RxGormStaticApi.groovy index 1278d50e86c..13d50acbe04 100644 --- a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/api/RxGormStaticApi.groovy +++ b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/api/RxGormStaticApi.groovy @@ -40,13 +40,9 @@ import org.grails.datastore.mapping.validation.ValidationException import org.grails.datastore.rx.RxDatastoreClient import org.grails.datastore.rx.query.RxQuery import org.grails.gorm.rx.api.multitenancy.TenantDelegatingRxGormOperations -import org.grails.gorm.rx.finders.CountByFinder -import org.grails.gorm.rx.finders.FindAllByBooleanFinder -import org.grails.gorm.rx.finders.FindAllByFinder -import org.grails.gorm.rx.finders.FindByBooleanFinder -import org.grails.gorm.rx.finders.FindByFinder -import org.grails.gorm.rx.finders.FindOrCreateByFinder -import org.grails.gorm.rx.finders.FindOrSaveByFinder +import org.grails.gorm.rx.finders.RxCountFinder +import org.grails.gorm.rx.finders.RxListResultFinder +import org.grails.gorm.rx.finders.RxSingleResultFinder import org.springframework.beans.PropertyAccessorFactory import rx.Observable import rx.Subscriber @@ -499,13 +495,13 @@ class RxGormStaticApi implements RxGormAllOperations { } protected List createDynamicFinders() { - [new FindOrCreateByFinder(datastoreClient), - new FindOrSaveByFinder(datastoreClient), - new FindByFinder(datastoreClient), - new FindAllByFinder(datastoreClient), - new CountByFinder(datastoreClient), - new FindByBooleanFinder(datastoreClient), - new FindAllByBooleanFinder(datastoreClient)] as List + [RxSingleResultFinder.findOrCreateBy(datastoreClient), + RxSingleResultFinder.findOrSaveBy(datastoreClient), + RxSingleResultFinder.findBy(datastoreClient), + RxListResultFinder.findAllBy(datastoreClient), + RxCountFinder.countBy(datastoreClient), + RxSingleResultFinder.findByBoolean(datastoreClient), + RxListResultFinder.findAllByBoolean(datastoreClient)] as List } @Override diff --git a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/CountByFinder.groovy b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/CountByFinder.groovy deleted file mode 100644 index 1f5b5d95804..00000000000 --- a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/CountByFinder.groovy +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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.grails.gorm.rx.finders - -import groovy.transform.CompileStatic -import org.grails.datastore.gorm.finders.DynamicFinderInvocation -import org.grails.datastore.rx.RxDatastoreClient - -/** - * Implementation of countBy* dynamic finder for RxGORM - * - * @since 6.0 - */ -@CompileStatic -class CountByFinder extends org.grails.datastore.gorm.finders.CountByFinder { - - final RxDatastoreClient datastoreClient - CountByFinder(RxDatastoreClient datastoreClient) { - super(datastoreClient.mappingContext) - this.datastoreClient = datastoreClient - } - - @Override - protected Object doInvokeInternal(DynamicFinderInvocation invocation) { - def javaClass = invocation.getJavaClass() - def query = datastoreClient.createQuery(javaClass) - query = buildQuery(invocation, javaClass, query) - query.singleResult() - } -} diff --git a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindAllByBooleanFinder.groovy b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindAllByBooleanFinder.groovy deleted file mode 100644 index 296e9a74ae9..00000000000 --- a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindAllByBooleanFinder.groovy +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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.grails.gorm.rx.finders - -import groovy.transform.CompileStatic -import org.grails.datastore.rx.RxDatastoreClient - -/** - * Implementation of findAllBy* boolean finder for RxGORM - * - * @see org.grails.datastore.gorm.finders.FindAllByBooleanFinder - */ -@CompileStatic -class FindAllByBooleanFinder extends FindAllByFinder { - - FindAllByBooleanFinder(RxDatastoreClient datastoreClient) { - super(datastoreClient) - setPattern(org.grails.datastore.gorm.finders.FindAllByBooleanFinder.METHOD_PATTERN) - } - - @Override - boolean firstExpressionIsRequiredBoolean() { - return true - } -} diff --git a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindAllByFinder.groovy b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindAllByFinder.groovy deleted file mode 100644 index 13ac471813a..00000000000 --- a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindAllByFinder.groovy +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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.grails.gorm.rx.finders - -import groovy.transform.CompileStatic -import org.grails.datastore.gorm.finders.DynamicFinderInvocation -import org.grails.datastore.rx.RxDatastoreClient -import org.grails.datastore.rx.query.RxQuery - -/** - * Implementation of findAllBy* dynamic finder for RxGORM - * - * @since 6.0 - */ -@CompileStatic -class FindAllByFinder extends org.grails.datastore.gorm.finders.FindAllByFinder { - - final RxDatastoreClient datastoreClient - - FindAllByFinder(RxDatastoreClient datastoreClient) { - super(datastoreClient.mappingContext) - this.datastoreClient = datastoreClient - } - - @Override - protected Object doInvokeInternal(DynamicFinderInvocation invocation) { - def javaClass = invocation.getJavaClass() - def query = datastoreClient.createQuery(javaClass) - applyAdditionalCriteria(query, invocation.getCriteria()) - applyDetachedCriteria(query, invocation.getDetachedCriteria()) - configureQueryWithArguments(javaClass, query, invocation.getArguments()) - query.add(getJunction(invocation)) - def arguments = invocation.getArguments() - if (arguments.length > 0 && (arguments[0] instanceof Map)) { - ((RxQuery)query).findAll((Map)arguments[0]) - } - else { - return ((RxQuery)query).findAll() - } - - } -} diff --git a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindByBooleanFinder.groovy b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindByBooleanFinder.groovy deleted file mode 100644 index f0bdf2c0fc0..00000000000 --- a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindByBooleanFinder.groovy +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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.grails.gorm.rx.finders - -import groovy.transform.CompileStatic -import org.grails.datastore.rx.RxDatastoreClient - -/** - * Implementation of findBy* boolean finder for RxGORM - * - * @see org.grails.datastore.gorm.finders.FindByBooleanFinder - */ -@CompileStatic -class FindByBooleanFinder extends FindByFinder { - - FindByBooleanFinder(RxDatastoreClient datastoreClient) { - super(datastoreClient) - setPattern(org.grails.datastore.gorm.finders.FindByBooleanFinder.METHOD_PATTERN) - } - - @Override - boolean firstExpressionIsRequiredBoolean() { - return true - } -} diff --git a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindByFinder.groovy b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindByFinder.groovy deleted file mode 100644 index 06ce2a582fe..00000000000 --- a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindByFinder.groovy +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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.grails.gorm.rx.finders - -import groovy.transform.CompileStatic -import org.grails.datastore.gorm.finders.DynamicFinderInvocation -import org.grails.datastore.rx.RxDatastoreClient - -/** - * Implementation of findBy* dynamic finder for RxGORM - * - * @since 6.0 - */ -@CompileStatic -class FindByFinder extends org.grails.datastore.gorm.finders.FindByFinder { - - final RxDatastoreClient datastoreClient - - FindByFinder(RxDatastoreClient datastoreClient) { - super(datastoreClient.mappingContext) - this.datastoreClient = datastoreClient - } - - @Override - protected Object doInvokeInternal(DynamicFinderInvocation invocation) { - def javaClass = invocation.getJavaClass() - def query = datastoreClient.createQuery(javaClass) - applyAdditionalCriteria(query, invocation.getCriteria()) - applyDetachedCriteria(query, invocation.getDetachedCriteria()) - configureQueryWithArguments(javaClass, query, invocation.getArguments()) - query.add(getJunction(invocation)) - invokeQuery(query) - } -} diff --git a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindOrCreateByFinder.groovy b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindOrCreateByFinder.groovy deleted file mode 100644 index 9fb8c114bbc..00000000000 --- a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindOrCreateByFinder.groovy +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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.grails.gorm.rx.finders - -import grails.gorm.rx.RxEntity -import groovy.transform.CompileStatic -import org.grails.datastore.gorm.finders.DynamicFinderInvocation -import org.grails.datastore.gorm.finders.MethodExpression -import org.grails.datastore.rx.RxDatastoreClient -import rx.Observable -import rx.Subscriber - -/** - * Implementation of findOrCreateBy* finder for RxGORM - * - * @since 6.0 - */ -@CompileStatic -class FindOrCreateByFinder extends FindByFinder { - - FindOrCreateByFinder(RxDatastoreClient datastoreClient) { - super(datastoreClient) - setPattern(org.grails.datastore.gorm.finders.FindOrCreateByFinder.METHOD_PATTERN) - } - - @Override - protected Object doInvokeInternal(DynamicFinderInvocation invocation) { - Observable observable = (Observable)super.doInvokeInternal(invocation) - observable.switchIfEmpty(Observable.create({ Subscriber s -> - Thread.start { - Map m = [:] - List expressions = invocation.getExpressions() - for (MethodExpression me in expressions) { - if (!(me instanceof MethodExpression.Equal)) { - throw new MissingMethodException(invocation.methodName, invocation.javaClass, invocation.arguments) - } - String propertyName = me.propertyName - Object[] arguments = me.getArguments() - m.put(propertyName, arguments[0]) - } - - def newInstance = invocation.javaClass.newInstance(m) - if (shouldSaveOnCreate()) { - def saveObservable = ((RxEntity) newInstance).save() - saveObservable.subscribe(new Subscriber() { - @Override - void onCompleted() { - s.onCompleted() - } - - @Override - void onError(Throwable e) { - s.onError(e) - } - - @Override - void onNext(Object o) { - s.onNext o - } - }) - } - else { - s.onNext newInstance - s.onCompleted() - } - } - - } as Observable.OnSubscribe) - ) - } - - protected boolean shouldSaveOnCreate() { - return false - } -} diff --git a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindOrSaveByFinder.groovy b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindOrSaveByFinder.groovy deleted file mode 100644 index 77df23793f5..00000000000 --- a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/FindOrSaveByFinder.groovy +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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.grails.gorm.rx.finders - -import groovy.transform.CompileStatic -import org.grails.datastore.rx.RxDatastoreClient - -/** - * Created by graemerocher on 06/05/16. - */ -@CompileStatic -class FindOrSaveByFinder extends FindOrCreateByFinder { - - FindOrSaveByFinder(RxDatastoreClient datastoreClient) { - super(datastoreClient) - setPattern(org.grails.datastore.gorm.finders.FindOrSaveByFinder.METHOD_PATTERN) - } - - @Override - protected boolean shouldSaveOnCreate() { - return true - } -} diff --git a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/RxCountFinder.groovy b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/RxCountFinder.groovy new file mode 100644 index 00000000000..31b965664a4 --- /dev/null +++ b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/RxCountFinder.groovy @@ -0,0 +1,98 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.gorm.rx.finders + +import java.util.regex.Pattern + +import groovy.transform.CompileStatic + +import grails.gorm.DetachedCriteria +import org.grails.datastore.gorm.finders.CountFinder +import org.grails.datastore.gorm.finders.DynamicFinder +import org.grails.datastore.gorm.finders.DynamicFinderInvocation +import org.grails.datastore.gorm.finders.FinderMethod +import org.grails.datastore.mapping.query.Query +import org.grails.datastore.rx.RxDatastoreClient + +/** + * Implements {@code countBy*} for RxGORM - the rx-module mirror of {@link + * org.grails.datastore.gorm.finders.CountFinder}, reusing that class's {@link + * CountFinder#applyCriteriaAndCount} helper for the same independent (non-{@code getJunction}) + * And/Or criteria handling rather than duplicating it. + */ +@CompileStatic +class RxCountFinder implements FinderMethod { + + private static final Pattern METHOD_PATTERN = Pattern.compile('(countBy)(\\w+)') + private static final String[] OPERATORS = ['And', 'Or'] as String[] + + private final RxDatastoreClient datastoreClient + private final DynamicFinder grammar + + private RxCountFinder(RxDatastoreClient datastoreClient, DynamicFinder grammar) { + this.datastoreClient = datastoreClient + this.grammar = grammar + } + + static RxCountFinder countBy(RxDatastoreClient datastoreClient) { + new RxCountFinder(datastoreClient, new DynamicFinder(METHOD_PATTERN, OPERATORS, datastoreClient.mappingContext, false)) + } + + @Override + void setPattern(String pattern) { + grammar.setPattern(pattern) + } + + @Override + boolean isMethodMatch(String methodName) { + grammar.isMethodMatch(methodName) + } + + @Override + Object invoke(Class clazz, String methodName, Object[] arguments) { + invoke(clazz, methodName, (Closure) null, arguments) + } + + @Override + Object invoke(Class clazz, String methodName, Closure additionalCriteria, Object[] arguments) { + DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, additionalCriteria, arguments) + doInvoke(clazz, invocation) + } + + /** + * Not part of {@link FinderMethod} - called reflectively (dynamic Groovy dispatch) by {@link + * org.grails.datastore.gorm.query.criteria.AbstractDetachedCriteria#methodMissing}. See {@link + * RxSingleResultFinder#invoke(Class, String, DetachedCriteria, Object[])} for the full + * rationale, including why this is typed against core's {@link DetachedCriteria}. + */ + Object invoke(Class clazz, String methodName, DetachedCriteria detachedCriteria, Object[] arguments) { + DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, null, arguments) + if (detachedCriteria != null) { + invocation.setDetachedCriteria(detachedCriteria) + } + doInvoke(clazz, invocation) + } + + private Object doInvoke(Class clazz, DynamicFinderInvocation invocation) { + Query query = datastoreClient.createQuery(clazz) + query = CountFinder.applyCriteriaAndCount(invocation, clazz, query) + query.singleResult() + } +} diff --git a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/RxListResultFinder.groovy b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/RxListResultFinder.groovy new file mode 100644 index 00000000000..58e515ec970 --- /dev/null +++ b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/RxListResultFinder.groovy @@ -0,0 +1,120 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.gorm.rx.finders + +import java.util.regex.Pattern + +import groovy.transform.CompileStatic + +import grails.gorm.DetachedCriteria +import org.grails.datastore.gorm.finders.DynamicFinder +import org.grails.datastore.gorm.finders.DynamicFinderInvocation +import org.grails.datastore.gorm.finders.FinderMethod +import org.grails.datastore.mapping.query.Query +import org.grails.datastore.rx.RxDatastoreClient +import org.grails.datastore.rx.query.RxQuery + +/** + * Implements {@code findAllBy*} and the {@code findAllBy*} boolean-clause form + * for RxGORM - the rx-module mirror of {@link org.grails.datastore.gorm.finders.ListResultFinder}. + * + *

Preserved exactly as it existed in the previous per-type rx finder classes: unlike the + * synchronous {@code ListResultFinder}, this does NOT apply a {@code distinct()} projection - that + * was already a pre-existing difference between the sync and rx implementations, not something + * introduced or fixed by this refactor. + */ +@CompileStatic +class RxListResultFinder implements FinderMethod { + + private static final String FIND_ALL_BY_PATTERN = '(findAllBy)([A-Z]\\w*)' + private static final String FIND_ALL_BY_BOOLEAN_PATTERN = '(findAll)((\\w+)(By)([A-Z]\\w*)|(\\w+))' + private static final String[] OPERATORS = ['And', 'Or'] as String[] + + private final RxDatastoreClient datastoreClient + private final DynamicFinder grammar + + private RxListResultFinder(RxDatastoreClient datastoreClient, DynamicFinder grammar) { + this.datastoreClient = datastoreClient + this.grammar = grammar + } + + static RxListResultFinder findAllBy(RxDatastoreClient datastoreClient) { + new RxListResultFinder(datastoreClient, grammar(FIND_ALL_BY_PATTERN, datastoreClient, false)) + } + + static RxListResultFinder findAllByBoolean(RxDatastoreClient datastoreClient) { + new RxListResultFinder(datastoreClient, grammar(FIND_ALL_BY_BOOLEAN_PATTERN, datastoreClient, true)) + } + + private static DynamicFinder grammar(String pattern, RxDatastoreClient datastoreClient, boolean booleanClause) { + new DynamicFinder(Pattern.compile(pattern), OPERATORS, datastoreClient.mappingContext, booleanClause) + } + + @Override + void setPattern(String pattern) { + grammar.setPattern(pattern) + } + + @Override + boolean isMethodMatch(String methodName) { + grammar.isMethodMatch(methodName) + } + + @Override + Object invoke(Class clazz, String methodName, Object[] arguments) { + invoke(clazz, methodName, (Closure) null, arguments) + } + + @Override + Object invoke(Class clazz, String methodName, Closure additionalCriteria, Object[] arguments) { + DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, additionalCriteria, arguments) + doInvoke(clazz, invocation) + } + + /** + * Not part of {@link FinderMethod} - called reflectively (dynamic Groovy dispatch) by {@link + * org.grails.datastore.gorm.query.criteria.AbstractDetachedCriteria#methodMissing}. See {@link + * RxSingleResultFinder#invoke(Class, String, DetachedCriteria, Object[])} for the full + * rationale, including why this is typed against core's {@link DetachedCriteria}. + */ + Object invoke(Class clazz, String methodName, DetachedCriteria detachedCriteria, Object[] arguments) { + DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, null, arguments) + if (detachedCriteria != null) { + invocation.setDetachedCriteria(detachedCriteria) + } + doInvoke(clazz, invocation) + } + + private Object doInvoke(Class clazz, DynamicFinderInvocation invocation) { + Query query = datastoreClient.createQuery(clazz) + DynamicFinder.applyAdditionalCriteria(query, invocation.criteria) + DynamicFinder.applyDetachedCriteria(query, invocation.detachedCriteria) + DynamicFinder.configureQueryWithArguments(clazz, query, invocation.arguments) + query.add(grammar.getJunction(invocation)) + + Object[] remainingArguments = invocation.arguments + if (remainingArguments.length > 0 && (remainingArguments[0] instanceof Map)) { + ((RxQuery) query).findAll((Map) remainingArguments[0]) + } + else { + ((RxQuery) query).findAll() + } + } +} diff --git a/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/RxSingleResultFinder.groovy b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/RxSingleResultFinder.groovy new file mode 100644 index 00000000000..081abbaa71c --- /dev/null +++ b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/RxSingleResultFinder.groovy @@ -0,0 +1,195 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.gorm.rx.finders + +import java.util.regex.Pattern + +import groovy.transform.CompileStatic + +import grails.gorm.DetachedCriteria +import grails.gorm.rx.RxEntity +import org.grails.datastore.gorm.finders.DynamicFinder +import org.grails.datastore.gorm.finders.DynamicFinderInvocation +import org.grails.datastore.gorm.finders.FinderMethod +import org.grails.datastore.gorm.finders.MethodExpression +import org.grails.datastore.mapping.query.Query +import org.grails.datastore.rx.RxDatastoreClient +import rx.Observable +import rx.Subscriber + +/** + * Implements every RxGORM dynamic finder that returns a single result: {@code findBy*}, the + * {@code findBy*} boolean-clause form, {@code findOrCreateBy*} and + * {@code findOrSaveBy*} - the rx-module mirror of {@link org.grails.datastore.gorm.finders.SingleResultFinder}, + * composing the same {@link DynamicFinder} grammar class rather than extending core's finder + * classes as the previous per-type rx finder classes did. + * + *

Unlike the sync side, there is no {@code Session}/{@code Datastore.execute} concept here - + * queries are built directly via {@link RxDatastoreClient#createQuery}, and results are + * {@link Observable}s. The {@code findOrCreateBy*}/{@code findOrSaveBy*} empty-result handling + * (construct-and-maybe-save via {@code Observable.switchIfEmpty}) is preserved exactly as it + * existed in the previous per-type classes, including that it does NOT perform the + * Or-operator/comparison-expression rejection the synchronous {@code SingleResultFinder} does - + * that was already a pre-existing inconsistency between the sync and rx implementations, not + * something introduced or fixed by this refactor. + */ +@CompileStatic +class RxSingleResultFinder implements FinderMethod { + + private static final String FIND_BY_PATTERN = '(findBy)([A-Z]\\w*)' + private static final String FIND_BY_BOOLEAN_PATTERN = '(find)((\\w+)(By)([A-Z]\\w*)|(\\w++))' + private static final String FIND_OR_CREATE_BY_PATTERN = '(findOrCreateBy)([A-Z]\\w*)' + private static final String FIND_OR_SAVE_BY_PATTERN = '(findOrSaveBy)([A-Z]\\w*)' + private static final String[] OPERATORS = ['And', 'Or'] as String[] + + private final RxDatastoreClient datastoreClient + private final DynamicFinder grammar + private final boolean handlesEmptyResult + private final boolean save + + private RxSingleResultFinder(RxDatastoreClient datastoreClient, DynamicFinder grammar, boolean handlesEmptyResult, boolean save) { + this.datastoreClient = datastoreClient + this.grammar = grammar + this.handlesEmptyResult = handlesEmptyResult + this.save = save + } + + static RxSingleResultFinder findBy(RxDatastoreClient datastoreClient) { + new RxSingleResultFinder(datastoreClient, grammar(FIND_BY_PATTERN, datastoreClient, false), false, false) + } + + static RxSingleResultFinder findByBoolean(RxDatastoreClient datastoreClient) { + new RxSingleResultFinder(datastoreClient, grammar(FIND_BY_BOOLEAN_PATTERN, datastoreClient, true), false, false) + } + + static RxSingleResultFinder findOrCreateBy(RxDatastoreClient datastoreClient) { + new RxSingleResultFinder(datastoreClient, grammar(FIND_OR_CREATE_BY_PATTERN, datastoreClient, false), true, false) + } + + static RxSingleResultFinder findOrSaveBy(RxDatastoreClient datastoreClient) { + new RxSingleResultFinder(datastoreClient, grammar(FIND_OR_SAVE_BY_PATTERN, datastoreClient, false), true, true) + } + + private static DynamicFinder grammar(String pattern, RxDatastoreClient datastoreClient, boolean booleanClause) { + new DynamicFinder(Pattern.compile(pattern), OPERATORS, datastoreClient.mappingContext, booleanClause) + } + + @Override + void setPattern(String pattern) { + grammar.setPattern(pattern) + } + + @Override + boolean isMethodMatch(String methodName) { + grammar.isMethodMatch(methodName) + } + + @Override + Object invoke(Class clazz, String methodName, Object[] arguments) { + invoke(clazz, methodName, (Closure) null, arguments) + } + + @Override + Object invoke(Class clazz, String methodName, Closure additionalCriteria, Object[] arguments) { + DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, additionalCriteria, arguments) + doInvoke(clazz, invocation) + } + + /** + * Not part of {@link FinderMethod} - called reflectively (dynamic Groovy dispatch) by {@link + * org.grails.datastore.gorm.query.criteria.AbstractDetachedCriteria#methodMissing}. Typed + * against core's {@link DetachedCriteria} - not rx's own {@code grails.gorm.rx.DetachedCriteria} + * - because {@code DynamicFinderInvocation.setDetachedCriteria} itself only accepts that type, + * exactly matching what the previous per-type rx finder classes had available via inheriting + * this same overload from core's {@code DynamicFinder}. Preserved as-is, not "fixed", since + * changing this narrow typing is outside the scope of this refactor. + */ + Object invoke(Class clazz, String methodName, DetachedCriteria detachedCriteria, Object[] arguments) { + DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, null, arguments) + if (detachedCriteria != null) { + invocation.setDetachedCriteria(detachedCriteria) + } + doInvoke(clazz, invocation) + } + + private Object doInvoke(Class clazz, DynamicFinderInvocation invocation) { + Query query = datastoreClient.createQuery(clazz) + DynamicFinder.applyAdditionalCriteria(query, invocation.criteria) + DynamicFinder.applyDetachedCriteria(query, invocation.detachedCriteria) + DynamicFinder.configureQueryWithArguments(clazz, query, invocation.arguments) + query.add(grammar.getJunction(invocation)) + Object result = query.singleResult() + if (!handlesEmptyResult) { + // Matches the previous implementation exactly: plain findBy/findByBoolean never + // assumed/cast the query's result to Observable, it just returned whatever + // singleResult() produced as Object and let the caller handle it. + return result + } + Observable observable = (Observable) result + return observable.switchIfEmpty(Observable.create({ Subscriber s -> + Thread.start { + try { + Map m = [:] + List expressions = invocation.expressions + for (MethodExpression me in expressions) { + if (!(me instanceof MethodExpression.Equal)) { + throw new MissingMethodException(invocation.methodName, invocation.javaClass, invocation.arguments) + } + String propertyName = me.propertyName + Object[] meArguments = me.getArguments() + m.put(propertyName, meArguments[0]) + } + + def newInstance = invocation.javaClass.newInstance(m) + if (save) { + def saveObservable = ((RxEntity) newInstance).save() + saveObservable.subscribe(new Subscriber() { + @Override + void onCompleted() { + s.onCompleted() + } + + @Override + void onError(Throwable e) { + s.onError(e) + } + + @Override + void onNext(Object o) { + s.onNext o + } + }) + } + else { + s.onNext newInstance + s.onCompleted() + } + } + catch (Throwable e) { + // Without this, a thrown exception (e.g. MissingMethodException from the + // non-Equal-expression check above, or a failure constructing newInstance) + // kills this spawned Thread silently: it never reaches the Observable's error + // channel, so any blocking call on the returned Observable hangs forever. + s.onError(e) + } + } + } as Observable.OnSubscribe)) + } +} diff --git a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/CountByFinderSpec.groovy b/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/CountByFinderSpec.groovy deleted file mode 100644 index 8b1f39e088f..00000000000 --- a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/CountByFinderSpec.groovy +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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.grails.gorm.rx.finders - -import org.grails.datastore.gorm.finders.DynamicFinderInvocation -import org.grails.datastore.gorm.finders.MethodExpression -import org.grails.datastore.mapping.core.Session -import org.grails.datastore.mapping.model.MappingContext -import org.grails.datastore.mapping.model.PersistentEntity -import org.grails.datastore.mapping.query.Query -import org.grails.datastore.mapping.query.Restrictions -import org.grails.datastore.rx.RxDatastoreClient -import org.springframework.core.convert.support.DefaultConversionService -import spock.lang.Specification - -class CountByFinderSpec extends Specification { - - RxDatastoreClient datastoreClient = Mock() - MappingContext mappingContext = Mock() - PersistentEntity entity = Mock() - Query query = Mock() - - def setup() { - entity.getMappingContext() >> mappingContext - entity.getJavaClass() >> Person - mappingContext.getConversionService() >> new DefaultConversionService() - query.getEntity() >> entity - query.projections() >> new Query.ProjectionList() - } - - def "obtains its mapping context from the datastore client when constructed"() { - when: - def finder = new CountByFinder(datastoreClient) - - then: - 1 * datastoreClient.getMappingContext() >> mappingContext - finder.datastoreClient.is(datastoreClient) - } - - def "counts by building and executing a query via the RX datastore client instead of a session"() { - given: - def finder = new CountByFinder(datastoreClient) - def nameExpression = new MethodExpression.Equal(Person, 'name') - nameExpression.setArguments(['Fred'] as Object[]) - def invocation = new DynamicFinderInvocation(Person, 'countByName', [] as Object[], [nameExpression], null, null) - - when: - def result = finder.doInvokeInternal(invocation) - - then: - 1 * datastoreClient.createQuery(Person) >> query - 1 * query.add({ Query.Criterion it -> it instanceof Query.PropertyCriterion }) - 1 * query.singleResult() >> 4L - result == 4L - } - - def "applies detached criteria to the query when present"() { - given: - def finder = new CountByFinder(datastoreClient) - def invocation = new DynamicFinderInvocation(Person, 'countByName', [] as Object[], [], null, null) - def detachedCriteria = Stub(grails.gorm.DetachedCriteria) { - getFetchStrategies() >> [:] - getCriteria() >> [Restrictions.eq('active', true)] - getProjections() >> [] - getOrders() >> [] - } - invocation.setDetachedCriteria(detachedCriteria) - - when: - finder.doInvokeInternal(invocation) - - then: - 1 * datastoreClient.createQuery(Person) >> query - 1 * query.add({ Query.Criterion it -> it instanceof Query.PropertyCriterion }) - 1 * query.singleResult() - } - - def "applies additional criteria to the query when present"() { - given: - def finder = new CountByFinder(datastoreClient) - def invocation = new DynamicFinderInvocation(Person, 'countByName', [] as Object[], [], { -> }, null) - def session = Stub(Session) { - getMappingContext() >> mappingContext - } - mappingContext.getPersistentEntity(_) >> entity - query.getSession() >> session - - when: - finder.doInvokeInternal(invocation) - - then: - 1 * datastoreClient.createQuery(Person) >> query - 1 * query.getSession() >> session - 1 * query.singleResult() - noExceptionThrown() - } - - def "applies query arguments to the query when present"() { - given: - def finder = new CountByFinder(datastoreClient) - def invocation = new DynamicFinderInvocation(Person, 'countByName', [[max: 5]] as Object[], [], null, null) - - when: - finder.doInvokeInternal(invocation) - - then: - 1 * datastoreClient.createQuery(Person) >> query - 1 * query.max(5) - 1 * query.singleResult() - } - - private static class Person { - String name - boolean active - } -} diff --git a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindAllByBooleanFinderSpec.groovy b/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindAllByBooleanFinderSpec.groovy deleted file mode 100644 index 87dc1f823c8..00000000000 --- a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindAllByBooleanFinderSpec.groovy +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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.grails.gorm.rx.finders - -import org.grails.datastore.gorm.finders.DynamicFinderInvocation -import org.grails.datastore.gorm.finders.MethodExpression -import org.grails.datastore.mapping.model.MappingContext -import org.grails.datastore.mapping.model.PersistentEntity -import org.grails.datastore.mapping.query.Query -import org.grails.datastore.rx.RxDatastoreClient -import org.grails.datastore.rx.query.RxQuery -import org.springframework.core.convert.support.DefaultConversionService -import rx.Observable -import spock.lang.Specification - -class FindAllByBooleanFinderSpec extends Specification { - - RxDatastoreClient datastoreClient = Mock() - MappingContext mappingContext = Mock() - PersistentEntity entity = Mock() - RxCapableQuery query = Mock() - - def setup() { - entity.getMappingContext() >> mappingContext - entity.getJavaClass() >> Person - mappingContext.getConversionService() >> new DefaultConversionService() - query.getEntity() >> entity - } - - def "overrides the inherited pattern to match boolean style method names"() { - when: - def finder = new FindAllByBooleanFinder(datastoreClient) - - then: - 1 * datastoreClient.getMappingContext() >> mappingContext - finder.isMethodMatch('findAllActive') - } - - def "firstExpressionIsRequiredBoolean always reports true"() { - given: - def finder = new FindAllByBooleanFinder(datastoreClient) - - expect: - finder.firstExpressionIsRequiredBoolean() - } - - def "queries all results by the boolean property, combining it as a required expression with the rest via Or"() { - given: - def finder = new FindAllByBooleanFinder(datastoreClient) - def activeExpression = new MethodExpression.Equal(Person, 'active') - activeExpression.setArguments([true] as Object[]) - def nameExpression = new MethodExpression.Equal(Person, 'name') - nameExpression.setArguments(['Fred'] as Object[]) - def invocation = new DynamicFinderInvocation(Person, 'findAllActiveByNameOrCity', [] as Object[], - [activeExpression, nameExpression], null, 'Or') - def observable = Observable.just(new Person(name: 'Fred', active: true)) - - when: - def result = finder.doInvokeInternal(invocation) - - then: - 1 * datastoreClient.createQuery(Person) >> query - 1 * query.add({ Query.Criterion it -> it instanceof Query.Conjunction && ((Query.Junction) it).criteria.size() == 2 }) - 1 * query.findAll() >> observable - 0 * query.findAll(_) - result.is(observable) - } - - private static class Person { - String name - boolean active - } - - private static abstract class RxCapableQuery extends Query implements RxQuery { - protected RxCapableQuery() { - super(null, null) - } - } -} diff --git a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindAllByFinderSpec.groovy b/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindAllByFinderSpec.groovy deleted file mode 100644 index ab1bdc244f1..00000000000 --- a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindAllByFinderSpec.groovy +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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.grails.gorm.rx.finders - -import org.grails.datastore.gorm.finders.DynamicFinderInvocation -import org.grails.datastore.gorm.finders.MethodExpression -import org.grails.datastore.mapping.core.Session -import org.grails.datastore.mapping.model.MappingContext -import org.grails.datastore.mapping.model.PersistentEntity -import org.grails.datastore.mapping.query.Query -import org.grails.datastore.mapping.query.Restrictions -import org.grails.datastore.rx.RxDatastoreClient -import org.grails.datastore.rx.query.RxQuery -import org.springframework.core.convert.support.DefaultConversionService -import rx.Observable -import spock.lang.Specification - -class FindAllByFinderSpec extends Specification { - - RxDatastoreClient datastoreClient = Mock() - MappingContext mappingContext = Mock() - PersistentEntity entity = Mock() - RxCapableQuery query = Mock() - - def setup() { - entity.getMappingContext() >> mappingContext - entity.getJavaClass() >> Book - mappingContext.getConversionService() >> new DefaultConversionService() - query.getEntity() >> entity - } - - def "obtains its mapping context from the datastore client when constructed"() { - when: - def finder = new FindAllByFinder(datastoreClient) - - then: - 1 * datastoreClient.getMappingContext() >> mappingContext - finder.datastoreClient.is(datastoreClient) - } - - def "finds all without arguments by invoking findAll on the RX query"() { - given: - def finder = new FindAllByFinder(datastoreClient) - def titleExpression = new MethodExpression.Equal(Book, 'title') - titleExpression.setArguments(['Shogun'] as Object[]) - def invocation = new DynamicFinderInvocation(Book, 'findAllByTitle', [] as Object[], [titleExpression], null, null) - def observable = Observable.just(new Book(title: 'Shogun')) - - when: - def result = finder.doInvokeInternal(invocation) - - then: - 1 * datastoreClient.createQuery(Book) >> query - 1 * query.add({ Query.Criterion it -> it instanceof Query.Junction }) - 1 * query.findAll() >> observable - 0 * query.findAll(_) - result.is(observable) - } - - def "finds all with map arguments by invoking findAll(Map) on the RX query and applying the arguments"() { - given: - def finder = new FindAllByFinder(datastoreClient) - def invocation = new DynamicFinderInvocation(Book, 'findAllByTitle', [[max: 5]] as Object[], [], null, null) - def observable = Observable.just(new Book(title: 'Shogun')) - - when: - def result = finder.doInvokeInternal(invocation) - - then: - 1 * datastoreClient.createQuery(Book) >> query - 1 * query.max(5) - 1 * query.findAll([max: 5]) >> observable - 0 * query.findAll() - result.is(observable) - } - - def "applies detached criteria to the query when present"() { - given: - def finder = new FindAllByFinder(datastoreClient) - def invocation = new DynamicFinderInvocation(Book, 'findAllByTitle', [] as Object[], [], null, null) - def detachedCriteria = Stub(grails.gorm.DetachedCriteria) { - getFetchStrategies() >> [:] - getCriteria() >> [Restrictions.eq('author', 'Clavell')] - getProjections() >> [] - getOrders() >> [] - } - invocation.setDetachedCriteria(detachedCriteria) - - when: - finder.doInvokeInternal(invocation) - - then: - 1 * datastoreClient.createQuery(Book) >> query - 1 * query.add({ Query.Criterion it -> it instanceof Query.PropertyCriterion }) - 1 * query.add({ Query.Criterion it -> it instanceof Query.Junction }) - 1 * query.findAll() >> Observable.empty() - } - - def "applies additional criteria to the query when present"() { - given: - def finder = new FindAllByFinder(datastoreClient) - def invocation = new DynamicFinderInvocation(Book, 'findAllByTitle', [] as Object[], [], { -> }, null) - def session = Stub(Session) { - getMappingContext() >> mappingContext - } - mappingContext.getPersistentEntity(_) >> entity - query.getSession() >> session - - when: - finder.doInvokeInternal(invocation) - - then: - 1 * datastoreClient.createQuery(Book) >> query - 1 * query.getSession() >> session - 1 * query.findAll() >> Observable.empty() - noExceptionThrown() - } - - private static class Book { - String title - String author - } - - private static abstract class RxCapableQuery extends Query implements RxQuery { - protected RxCapableQuery() { - super(null, null) - } - } -} diff --git a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindByBooleanFinderSpec.groovy b/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindByBooleanFinderSpec.groovy deleted file mode 100644 index 34ea85fdf23..00000000000 --- a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindByBooleanFinderSpec.groovy +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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.grails.gorm.rx.finders - -import org.grails.datastore.gorm.finders.DynamicFinderInvocation -import org.grails.datastore.gorm.finders.MethodExpression -import org.grails.datastore.mapping.model.MappingContext -import org.grails.datastore.mapping.model.PersistentEntity -import org.grails.datastore.mapping.query.Query -import org.grails.datastore.rx.RxDatastoreClient -import org.springframework.core.convert.support.DefaultConversionService -import spock.lang.Specification - -class FindByBooleanFinderSpec extends Specification { - - RxDatastoreClient datastoreClient = Mock() - MappingContext mappingContext = Mock() - PersistentEntity entity = Mock() - Query query = Mock() - - def setup() { - entity.getMappingContext() >> mappingContext - entity.getJavaClass() >> Person - mappingContext.getConversionService() >> new DefaultConversionService() - query.getEntity() >> entity - } - - def "overrides the inherited pattern to match boolean style method names"() { - when: - def finder = new FindByBooleanFinder(datastoreClient) - - then: - 1 * datastoreClient.getMappingContext() >> mappingContext - finder.isMethodMatch('findActive') - } - - def "firstExpressionIsRequiredBoolean always reports true"() { - given: - def finder = new FindByBooleanFinder(datastoreClient) - - expect: - finder.firstExpressionIsRequiredBoolean() - } - - def "queries by the boolean property, combining it as a required expression with the rest via Or"() { - given: - def finder = new FindByBooleanFinder(datastoreClient) - def activeExpression = new MethodExpression.Equal(Person, 'active') - activeExpression.setArguments([true] as Object[]) - def nameExpression = new MethodExpression.Equal(Person, 'name') - nameExpression.setArguments(['Fred'] as Object[]) - def invocation = new DynamicFinderInvocation(Person, 'findActiveByNameOrCity', [] as Object[], - [activeExpression, nameExpression], null, 'Or') - - when: - def result = finder.doInvokeInternal(invocation) - - then: - 1 * datastoreClient.createQuery(Person) >> query - 1 * query.add({ Query.Criterion it -> it instanceof Query.Conjunction && ((Query.Junction) it).criteria.size() == 2 }) - 1 * query.singleResult() >> new Person(name: 'Fred', active: true) - result.name == 'Fred' - } - - private static class Person { - String name - boolean active - } -} diff --git a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindByFinderSpec.groovy b/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindByFinderSpec.groovy deleted file mode 100644 index b4d95cd8867..00000000000 --- a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindByFinderSpec.groovy +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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.grails.gorm.rx.finders - -import org.grails.datastore.gorm.finders.DynamicFinderInvocation -import org.grails.datastore.gorm.finders.MethodExpression -import org.grails.datastore.mapping.core.Session -import org.grails.datastore.mapping.model.MappingContext -import org.grails.datastore.mapping.model.PersistentEntity -import org.grails.datastore.mapping.query.Query -import org.grails.datastore.mapping.query.Restrictions -import org.grails.datastore.rx.RxDatastoreClient -import org.springframework.core.convert.support.DefaultConversionService -import spock.lang.Specification - -class FindByFinderSpec extends Specification { - - RxDatastoreClient datastoreClient = Mock() - MappingContext mappingContext = Mock() - PersistentEntity entity = Mock() - Query query = Mock() - - def setup() { - entity.getMappingContext() >> mappingContext - entity.getJavaClass() >> Book - mappingContext.getConversionService() >> new DefaultConversionService() - query.getEntity() >> entity - } - - def "obtains its mapping context from the datastore client when constructed"() { - when: - def finder = new FindByFinder(datastoreClient) - - then: - 1 * datastoreClient.getMappingContext() >> mappingContext - finder.datastoreClient.is(datastoreClient) - } - - def "finds by building and executing a query via the RX datastore client instead of a session"() { - given: - def finder = new FindByFinder(datastoreClient) - def titleExpression = new MethodExpression.Equal(Book, 'title') - titleExpression.setArguments(['Shogun'] as Object[]) - def invocation = new DynamicFinderInvocation(Book, 'findByTitle', [] as Object[], [titleExpression], null, null) - - when: - def result = finder.doInvokeInternal(invocation) - - then: - 1 * datastoreClient.createQuery(Book) >> query - 1 * query.add({ Query.Criterion it -> it instanceof Query.Junction }) - 1 * query.singleResult() >> new Book(title: 'Shogun') - result.title == 'Shogun' - } - - def "applies detached criteria to the query when present"() { - given: - def finder = new FindByFinder(datastoreClient) - def invocation = new DynamicFinderInvocation(Book, 'findByTitle', [] as Object[], [], null, null) - def detachedCriteria = Stub(grails.gorm.DetachedCriteria) { - getFetchStrategies() >> [:] - getCriteria() >> [Restrictions.eq('author', 'Clavell')] - getProjections() >> [] - getOrders() >> [] - } - invocation.setDetachedCriteria(detachedCriteria) - - when: - finder.doInvokeInternal(invocation) - - then: - 1 * datastoreClient.createQuery(Book) >> query - 1 * query.add({ Query.Criterion it -> it instanceof Query.PropertyCriterion }) - 1 * query.add({ Query.Criterion it -> it instanceof Query.Junction }) - 1 * query.singleResult() - } - - def "applies additional criteria to the query when present"() { - given: - def finder = new FindByFinder(datastoreClient) - def invocation = new DynamicFinderInvocation(Book, 'findByTitle', [] as Object[], [], { -> }, null) - def session = Stub(Session) { - getMappingContext() >> mappingContext - } - mappingContext.getPersistentEntity(_) >> entity - query.getSession() >> session - - when: - finder.doInvokeInternal(invocation) - - then: - 1 * datastoreClient.createQuery(Book) >> query - 1 * query.getSession() >> session - 1 * query.singleResult() - noExceptionThrown() - } - - def "applies query arguments to the query when present"() { - given: - def finder = new FindByFinder(datastoreClient) - def invocation = new DynamicFinderInvocation(Book, 'findByTitle', [[max: 5]] as Object[], [], null, null) - - when: - finder.doInvokeInternal(invocation) - - then: - 1 * datastoreClient.createQuery(Book) >> query - 1 * query.max(5) - 1 * query.singleResult() - } - - private static class Book { - String title - String author - } -} diff --git a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindOrCreateByFinderSpec.groovy b/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindOrCreateByFinderSpec.groovy deleted file mode 100644 index dc1c4acfa29..00000000000 --- a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindOrCreateByFinderSpec.groovy +++ /dev/null @@ -1,185 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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.grails.gorm.rx.finders - -import grails.gorm.rx.RxEntity -import org.grails.datastore.gorm.finders.DynamicFinderInvocation -import org.grails.datastore.gorm.finders.MethodExpression -import org.grails.datastore.mapping.model.MappingContext -import org.grails.datastore.mapping.model.PersistentEntity -import org.grails.datastore.mapping.query.Query -import org.grails.datastore.rx.RxDatastoreClient -import org.springframework.core.convert.support.DefaultConversionService -import rx.Observable -import spock.lang.Specification - -class FindOrCreateByFinderSpec extends Specification { - - RxDatastoreClient datastoreClient = Mock() - MappingContext mappingContext = Mock() - PersistentEntity entity = Mock() - Query query = Mock() - - def setup() { - entity.getMappingContext() >> mappingContext - entity.getJavaClass() >> Person - mappingContext.getConversionService() >> new DefaultConversionService() - query.getEntity() >> entity - } - - def "overrides the inherited pattern to match findOrCreateBy method names"() { - when: - def finder = new FindOrCreateByFinder(datastoreClient) - - then: - 1 * datastoreClient.getMappingContext() >> mappingContext - finder.isMethodMatch('findOrCreateByName') - !finder.isMethodMatch('findByName') - } - - def "shouldSaveOnCreate defaults to false"() { - given: - def finder = new FindOrCreateByFinder(datastoreClient) - - expect: - !finder.shouldSaveOnCreate() - } - - def "returns the existing entity when the underlying query already finds a match"() { - given: - def finder = new FindOrCreateByFinder(datastoreClient) - def invocation = nameEqualsFredInvocation(Person) - def existing = new Person(name: 'Fred') - - when: - Observable observable = finder.doInvokeInternal(invocation) as Observable - Person result = observable.toBlocking().first() as Person - - then: - 1 * datastoreClient.createQuery(Person) >> query - 1 * query.singleResult() >> Observable.just(existing) - result.is(existing) - } - - def "creates a new instance from the query arguments when no match exists and saving is not required"() { - given: - def finder = new FindOrCreateByFinder(datastoreClient) - def invocation = nameEqualsFredInvocation(Person) - - when: - Observable observable = finder.doInvokeInternal(invocation) as Observable - Person result = observable.toBlocking().first() as Person - - then: - 1 * datastoreClient.createQuery(Person) >> query - 1 * query.singleResult() >> Observable.empty() - result.name == 'Fred' - } - - def "signals completion after emitting the new instance when saving is not required"() { - given: - def finder = new FindOrCreateByFinder(datastoreClient) - def invocation = nameEqualsFredInvocation(Person) - - when: - Observable observable = finder.doInvokeInternal(invocation) as Observable - def subscriber = new rx.observers.TestSubscriber() - observable.subscribe(subscriber) - subscriber.awaitTerminalEvent(5, java.util.concurrent.TimeUnit.SECONDS) - - then: - 1 * datastoreClient.createQuery(Person) >> query - 1 * query.singleResult() >> Observable.empty() - subscriber.assertCompleted() - subscriber.assertNoErrors() - subscriber.onNextEvents.size() == 1 - (subscriber.onNextEvents[0] as Person).name == 'Fred' - } - - def "creates, saves and returns a new instance when shouldSaveOnCreate is overridden to return true"() { - given: - def finder = new SavingFindOrCreateByFinder(datastoreClient) - def invocation = nameEqualsFredInvocation(PersonThatSavesSuccessfully) - - when: - Observable observable = finder.doInvokeInternal(invocation) as Observable - PersonThatSavesSuccessfully result = observable.toBlocking().first() as PersonThatSavesSuccessfully - - then: - 1 * datastoreClient.createQuery(PersonThatSavesSuccessfully) >> query - 1 * query.singleResult() >> Observable.empty() - result.name == 'Fred' - } - - def "propagates the error when saving the newly created instance fails"() { - given: - def finder = new SavingFindOrCreateByFinder(datastoreClient) - def invocation = nameEqualsFredInvocation(PersonThatFailsToSave) - - when: - Observable observable = finder.doInvokeInternal(invocation) as Observable - observable.toBlocking().first() - - then: - 1 * datastoreClient.createQuery(PersonThatFailsToSave) >> query - 1 * query.singleResult() >> Observable.empty() - def ex = thrown(IllegalStateException) - ex.message == 'save failed' - } - - private static DynamicFinderInvocation nameEqualsFredInvocation(Class type) { - def nameExpression = new MethodExpression.Equal(type, 'name') - nameExpression.setArguments(['Fred'] as Object[]) - new DynamicFinderInvocation(type, 'findOrCreateByName', [] as Object[], [nameExpression], null, null) - } - - private static class Person { - String name - } - - private static class PersonThatSavesSuccessfully implements RxEntity { - String name - - @Override - Observable save(Map arguments) { - Observable.just(this) - } - } - - private static class PersonThatFailsToSave implements RxEntity { - String name - - @Override - Observable save(Map arguments) { - Observable.error(new IllegalStateException('save failed')) - } - } - - private static class SavingFindOrCreateByFinder extends FindOrCreateByFinder { - SavingFindOrCreateByFinder(RxDatastoreClient datastoreClient) { - super(datastoreClient) - } - - @Override - protected boolean shouldSaveOnCreate() { - true - } - } -} diff --git a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindOrSaveByFinderSpec.groovy b/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindOrSaveByFinderSpec.groovy deleted file mode 100644 index 6b6861d3b03..00000000000 --- a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/FindOrSaveByFinderSpec.groovy +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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.grails.gorm.rx.finders - -import grails.gorm.rx.RxEntity -import org.grails.datastore.gorm.finders.DynamicFinderInvocation -import org.grails.datastore.gorm.finders.MethodExpression -import org.grails.datastore.mapping.model.MappingContext -import org.grails.datastore.mapping.model.PersistentEntity -import org.grails.datastore.mapping.query.Query -import org.grails.datastore.rx.RxDatastoreClient -import org.springframework.core.convert.support.DefaultConversionService -import rx.Observable -import spock.lang.Specification - -class FindOrSaveByFinderSpec extends Specification { - - RxDatastoreClient datastoreClient = Mock() - MappingContext mappingContext = Mock() - PersistentEntity entity = Mock() - Query query = Mock() - - def setup() { - entity.getMappingContext() >> mappingContext - entity.getJavaClass() >> Person - mappingContext.getConversionService() >> new DefaultConversionService() - query.getEntity() >> entity - } - - def "overrides the inherited pattern to match findOrSaveBy method names"() { - when: - def finder = new FindOrSaveByFinder(datastoreClient) - - then: - 1 * datastoreClient.getMappingContext() >> mappingContext - finder.isMethodMatch('findOrSaveByName') - !finder.isMethodMatch('findOrCreateByName') - } - - def "shouldSaveOnCreate is overridden to return true"() { - given: - def finder = new FindOrSaveByFinder(datastoreClient) - - expect: - finder.shouldSaveOnCreate() - } - - def "returns the existing entity when the underlying query already finds a match"() { - given: - def finder = new FindOrSaveByFinder(datastoreClient) - def invocation = nameEqualsFredInvocation(Person) - def existing = new Person(name: 'Fred') - - when: - Observable observable = finder.doInvokeInternal(invocation) as Observable - Person result = observable.toBlocking().first() as Person - - then: - 1 * datastoreClient.createQuery(Person) >> query - 1 * query.singleResult() >> Observable.just(existing) - result.is(existing) - } - - def "creates, saves and returns a new instance when no match exists"() { - given: - def finder = new FindOrSaveByFinder(datastoreClient) - def invocation = nameEqualsFredInvocation(PersonThatSavesSuccessfully) - - when: - Observable observable = finder.doInvokeInternal(invocation) as Observable - PersonThatSavesSuccessfully result = observable.toBlocking().first() as PersonThatSavesSuccessfully - - then: - 1 * datastoreClient.createQuery(PersonThatSavesSuccessfully) >> query - 1 * query.singleResult() >> Observable.empty() - result.name == 'Fred' - } - - private static DynamicFinderInvocation nameEqualsFredInvocation(Class type) { - def nameExpression = new MethodExpression.Equal(type, 'name') - nameExpression.setArguments(['Fred'] as Object[]) - new DynamicFinderInvocation(type, 'findOrSaveByName', [] as Object[], [nameExpression], null, null) - } - - private static class Person { - String name - } - - private static class PersonThatSavesSuccessfully implements RxEntity { - String name - - @Override - Observable save(Map arguments) { - Observable.just(this) - } - } -} diff --git a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/RxCountFinderSpec.groovy b/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/RxCountFinderSpec.groovy new file mode 100644 index 00000000000..76d3f50c352 --- /dev/null +++ b/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/RxCountFinderSpec.groovy @@ -0,0 +1,172 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.gorm.rx.finders + +import org.grails.datastore.mapping.core.Session +import org.grails.datastore.mapping.model.MappingContext +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.datastore.mapping.model.PersistentProperty +import org.grails.datastore.mapping.query.Query +import org.grails.datastore.rx.RxDatastoreClient +import org.springframework.core.convert.support.DefaultConversionService +import rx.Observable +import spock.lang.Specification + +/** + * Exercises {@link RxCountFinder} - the rx-module mirror of {@link + * org.grails.datastore.gorm.finders.CountFinder}, reusing that class's {@code + * applyCriteriaAndCount} helper rather than duplicating its independent And/Or criteria handling. + */ +class RxCountFinderSpec extends Specification { + + RxDatastoreClient datastoreClient = Mock() + MappingContext mappingContext = Mock() + PersistentEntity entity = Mock() + Query query = Mock() + + PersistentProperty nameProperty = Stub(PersistentProperty) { + getName() >> 'name' + getType() >> String + } + + def setup() { + entity.getMappingContext() >> mappingContext + mappingContext.getConversionService() >> new DefaultConversionService() + mappingContext.getPersistentEntity(_) >> entity + entity.getPropertyByName('name') >> nameProperty + query.getEntity() >> entity + query.projections() >> new Query.ProjectionList() + datastoreClient.getMappingContext() >> mappingContext + } + + void "isMethodMatch matches countBy* method names"() { + expect: + RxCountFinder.countBy(datastoreClient).isMethodMatch('countByName') + !RxCountFinder.countBy(datastoreClient).isMethodMatch('somethingElse') + } + + void "setPattern delegates to the grammar"() { + given: + def finder = RxCountFinder.countBy(datastoreClient) + + expect: + finder.isMethodMatch('countByName') + !finder.isMethodMatch('customPrefixName') + + when: + finder.setPattern('(customPrefix)(\\w+)') + + then: + finder.isMethodMatch('customPrefixName') + !finder.isMethodMatch('countByName') + } + + void "counts by building and executing a query via the RX datastore client instead of a session"() { + given: + def finder = RxCountFinder.countBy(datastoreClient) + + when: + // query.singleResult() returns an Observable in production (RxQuery#singleResult) - countBy + // passes it through unchanged rather than unwrapping it. + Observable observable = finder.invoke(Person, 'countByName', ['Fred'] as Object[]) as Observable + def result = observable.toBlocking().first() + + then: + 1 * datastoreClient.createQuery(Person) >> query + 1 * query.add({ Query.Criterion it -> it instanceof Query.PropertyCriterion }) + 1 * query.singleResult() >> Observable.just(4L) + result == 4L + } + + void "invoke(Class, methodName, DetachedCriteria, Object[]) merges the detached criteria onto the built query"() { + given: + // Called reflectively (dynamic Groovy dispatch, not part of FinderMethod) by + // AbstractDetachedCriteria#methodMissing. + def finder = RxCountFinder.countBy(datastoreClient) + def detachedCriteria = Stub(grails.gorm.DetachedCriteria) { + getFetchStrategies() >> [:] + getCriteria() >> [org.grails.datastore.mapping.query.Restrictions.eq('active', true)] + getProjections() >> [] + getOrders() >> [] + } + + when: + Observable observable = finder.invoke(Person, 'countByName', detachedCriteria, ['Fred'] as Object[]) as Observable + def result = observable.toBlocking().first() + + then: + 1 * datastoreClient.createQuery(Person) >> query + 2 * query.add({ Query.Criterion it -> it instanceof Query.PropertyCriterion }) + 1 * query.singleResult() >> Observable.just(4L) + result == 4L + } + + void "invoke(Class, methodName, DetachedCriteria, Object[]) with a null detachedCriteria never merges anything onto the built query"() { + given: + def finder = RxCountFinder.countBy(datastoreClient) + + when: + Observable observable = finder.invoke(Person, 'countByName', (grails.gorm.DetachedCriteria) null, ['Fred'] as Object[]) as Observable + def result = observable.toBlocking().first() + + then: + 1 * datastoreClient.createQuery(Person) >> query + 1 * query.add({ Query.Criterion it -> it instanceof Query.PropertyCriterion }) + 1 * query.singleResult() >> Observable.just(4L) + result == 4L + } + + void "applies additional criteria to the query when present"() { + given: + def finder = RxCountFinder.countBy(datastoreClient) + def session = Stub(Session) { + getMappingContext() >> mappingContext + } + query.getSession() >> session + entity.getJavaClass() >> Person + + when: + finder.invoke(Person, 'countByName', { -> }, ['Fred'] as Object[]) + + then: + 1 * datastoreClient.createQuery(Person) >> query + 1 * query.getSession() >> session + 1 * query.singleResult() + noExceptionThrown() + } + + void "applies query arguments to the query when present"() { + given: + def finder = RxCountFinder.countBy(datastoreClient) + + when: + finder.invoke(Person, 'countByName', ['Fred', [max: 5]] as Object[]) + + then: + 1 * datastoreClient.createQuery(Person) >> query + 1 * query.max(5) + 1 * query.singleResult() + } + + private static class Person { + String name + boolean active + } +} diff --git a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/RxListResultFinderSpec.groovy b/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/RxListResultFinderSpec.groovy new file mode 100644 index 00000000000..57bb4fb5d38 --- /dev/null +++ b/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/RxListResultFinderSpec.groovy @@ -0,0 +1,248 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.gorm.rx.finders + +import org.grails.datastore.mapping.core.Session +import org.grails.datastore.mapping.model.MappingContext +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.datastore.mapping.model.PersistentProperty +import org.grails.datastore.mapping.query.Query +import org.grails.datastore.rx.RxDatastoreClient +import org.grails.datastore.rx.query.RxQuery +import org.springframework.core.convert.support.DefaultConversionService +import rx.Observable +import spock.lang.Specification + +/** + * Exercises {@link RxListResultFinder} - the rx-module mirror of {@link + * org.grails.datastore.gorm.finders.ListResultFinder}, composing {@link + * org.grails.datastore.gorm.finders.DynamicFinder} rather than extending core's finder classes as + * the previous per-type rx finder classes (FindAllByFinder/FindAllByBooleanFinder) did. Tests go + * through the real public invoke() entry point with real method names. + */ +class RxListResultFinderSpec extends Specification { + + RxDatastoreClient datastoreClient = Mock() + MappingContext mappingContext = Mock() + PersistentEntity entity = Mock() + RxCapableQuery query = Mock() + + PersistentProperty titleProperty = Stub(PersistentProperty) { + getName() >> 'title' + getType() >> String + } + PersistentProperty nameProperty = Stub(PersistentProperty) { + getName() >> 'name' + getType() >> String + } + PersistentProperty activeProperty = Stub(PersistentProperty) { + getName() >> 'active' + getType() >> Boolean + } + PersistentProperty cityProperty = Stub(PersistentProperty) { + getName() >> 'city' + getType() >> String + } + + def setup() { + entity.getMappingContext() >> mappingContext + mappingContext.getConversionService() >> new DefaultConversionService() + mappingContext.getPersistentEntity(_) >> entity + entity.getPropertyByName('title') >> titleProperty + entity.getPropertyByName('name') >> nameProperty + entity.getPropertyByName('active') >> activeProperty + entity.getPropertyByName('city') >> cityProperty + query.getEntity() >> entity + datastoreClient.getMappingContext() >> mappingContext + } + + void "findAllBy isMethodMatch matches findAllBy* method names"() { + expect: + RxListResultFinder.findAllBy(datastoreClient).isMethodMatch('findAllByTitle') + !RxListResultFinder.findAllBy(datastoreClient).isMethodMatch('somethingElse') + } + + void "setPattern delegates to the grammar"() { + given: + def finder = RxListResultFinder.findAllBy(datastoreClient) + + expect: + finder.isMethodMatch('findAllByTitle') + !finder.isMethodMatch('customPrefixTitle') + + when: + finder.setPattern('(customPrefix)([A-Z]\\w*)') + + then: + finder.isMethodMatch('customPrefixTitle') + !finder.isMethodMatch('findAllByTitle') + } + + void "findAllBy finds all without remaining arguments by invoking findAll on the RX query"() { + given: + def finder = RxListResultFinder.findAllBy(datastoreClient) + def observable = Observable.just(new Book(title: 'Shogun')) + + when: + def result = finder.invoke(Book, 'findAllByTitle', ['Shogun'] as Object[]) + + then: + 1 * datastoreClient.createQuery(Book) >> query + 1 * query.add({ Query.Criterion it -> it instanceof Query.Junction }) + 1 * query.findAll() >> observable + 0 * query.findAll(_) + // Unlike the synchronous ListResultFinder, RxListResultFinder never applies a distinct() + // projection - a pre-existing sync/rx inconsistency this refactor preserves, not fixes. + 0 * query.projections() + result.is(observable) + } + + void "findAllBy invoke(Class, methodName, DetachedCriteria, Object[]) merges the detached criteria onto the built query"() { + given: + // Called reflectively (dynamic Groovy dispatch, not part of FinderMethod) by + // AbstractDetachedCriteria#methodMissing. + def finder = RxListResultFinder.findAllBy(datastoreClient) + def observable = Observable.just(new Book(title: 'Shogun')) + def detachedCriteria = Stub(grails.gorm.DetachedCriteria) { + getFetchStrategies() >> [:] + getCriteria() >> [org.grails.datastore.mapping.query.Restrictions.eq('author', 'Clavell')] + getProjections() >> [] + getOrders() >> [] + } + + when: + def result = finder.invoke(Book, 'findAllByTitle', detachedCriteria, ['Shogun'] as Object[]) + + then: + 1 * datastoreClient.createQuery(Book) >> query + 1 * query.add({ Query.Criterion it -> it instanceof Query.PropertyCriterion }) + 1 * query.add({ Query.Criterion it -> it instanceof Query.Junction }) + 1 * query.findAll() >> observable + result.is(observable) + } + + void "findAllBy invoke(Class, methodName, DetachedCriteria, Object[]) with a null detachedCriteria never merges anything onto the built query"() { + given: + def finder = RxListResultFinder.findAllBy(datastoreClient) + def observable = Observable.just(new Book(title: 'Shogun')) + + when: + def result = finder.invoke(Book, 'findAllByTitle', (grails.gorm.DetachedCriteria) null, ['Shogun'] as Object[]) + + then: + 1 * datastoreClient.createQuery(Book) >> query + 0 * query.add({ Query.Criterion it -> it instanceof Query.PropertyCriterion }) + 1 * query.add({ Query.Criterion it -> it instanceof Query.Junction }) + 1 * query.findAll() >> observable + result.is(observable) + } + + void "findAllBy finds all with a non-Map remaining argument by invoking the no-arg findAll on the RX query"() { + given: + // Mirrors the Map-argument test below, but proves a non-Map remaining argument is treated + // exactly like having no remaining arguments at all, rather than being passed through. + def finder = RxListResultFinder.findAllBy(datastoreClient) + def observable = Observable.just(new Book(title: 'Shogun')) + + when: + def result = finder.invoke(Book, 'findAllByTitle', ['Shogun', 'unexpectedExtra'] as Object[]) + + then: + 1 * datastoreClient.createQuery(Book) >> query + 1 * query.findAll() >> observable + 0 * query.findAll(_) + result.is(observable) + } + + void "findAllBy finds all with remaining map arguments by invoking findAll(Map) on the RX query"() { + given: + def finder = RxListResultFinder.findAllBy(datastoreClient) + def observable = Observable.just(new Book(title: 'Shogun')) + + when: + def result = finder.invoke(Book, 'findAllByTitle', ['Shogun', [max: 5]] as Object[]) + + then: + 1 * datastoreClient.createQuery(Book) >> query + 1 * query.max(5) + 1 * query.findAll([max: 5]) >> observable + 0 * query.findAll() + result.is(observable) + } + + void "findAllBy applies additional criteria to the query when present"() { + given: + def finder = RxListResultFinder.findAllBy(datastoreClient) + def session = Stub(Session) { + getMappingContext() >> mappingContext + } + query.getSession() >> session + entity.getJavaClass() >> Book + + when: + finder.invoke(Book, 'findAllByTitle', { -> }, ['Shogun'] as Object[]) + + then: + 1 * datastoreClient.createQuery(Book) >> query + 1 * query.getSession() >> session + 1 * query.findAll() >> Observable.empty() + noExceptionThrown() + } + + void "findAllByBoolean isMethodMatch matches boolean style method names"() { + expect: + RxListResultFinder.findAllByBoolean(datastoreClient).isMethodMatch('findAllActive') + } + + void "findAllByBoolean queries all results by the boolean property, combining it as a required expression with the rest via Or"() { + given: + def finder = RxListResultFinder.findAllByBoolean(datastoreClient) + def observable = Observable.just(new Person(name: 'Fred', active: true)) + + when: + // The boolean clause's own argument is not consumed from the array - it's hardcoded + // TRUE/FALSE by the grammar based on a Not-prefix, so only "Name"/"City" (Or'd, 1 arg each) + // need real arguments here. + def result = finder.invoke(Person, 'findAllActiveByNameOrCity', ['Fred', 'London'] as Object[]) + + then: + 1 * datastoreClient.createQuery(Person) >> query + 1 * query.add({ Query.Criterion it -> it instanceof Query.Conjunction && ((Query.Junction) it).criteria.size() == 2 }) + 1 * query.findAll() >> observable + 0 * query.findAll(_) + result.is(observable) + } + + private static class Book { + String title + String author + } + + private static class Person { + String name + boolean active + } + + private static abstract class RxCapableQuery extends Query implements RxQuery { + protected RxCapableQuery() { + super(null, null) + } + } +} diff --git a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/RxSingleResultFinderSpec.groovy b/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/RxSingleResultFinderSpec.groovy new file mode 100644 index 00000000000..7370b086da4 --- /dev/null +++ b/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/RxSingleResultFinderSpec.groovy @@ -0,0 +1,374 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.grails.gorm.rx.finders + +import grails.gorm.rx.RxEntity +import org.grails.datastore.mapping.core.Session +import org.grails.datastore.mapping.model.MappingContext +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.datastore.mapping.model.PersistentProperty +import org.grails.datastore.mapping.query.Query +import org.grails.datastore.rx.RxDatastoreClient +import org.springframework.core.convert.support.DefaultConversionService +import rx.Observable +import spock.lang.Specification + +/** + * Exercises {@link RxSingleResultFinder} - the rx-module mirror of {@link + * org.grails.datastore.gorm.finders.SingleResultFinder}, composing {@link + * org.grails.datastore.gorm.finders.DynamicFinder} rather than extending core's finder classes as + * the previous per-type rx finder classes (FindByFinder/FindByBooleanFinder/FindOrCreateByFinder/ + * FindOrSaveByFinder) did. Unlike those classes' own specs, which hand-built a + * DynamicFinderInvocation and called the package-private doInvokeInternal directly, these tests go + * through the real public invoke() entry point with real method names - doInvokeInternal no longer + * exists as a separate seam, and this also means the method-name parsing itself is now exercised, + * not just the query-building/execution half. + */ +class RxSingleResultFinderSpec extends Specification { + + RxDatastoreClient datastoreClient = Mock() + MappingContext mappingContext = Mock() + PersistentEntity entity = Mock() + Query query = Mock() + + PersistentProperty titleProperty = Stub(PersistentProperty) { + getName() >> 'title' + getType() >> String + } + PersistentProperty nameProperty = Stub(PersistentProperty) { + getName() >> 'name' + getType() >> String + } + PersistentProperty activeProperty = Stub(PersistentProperty) { + getName() >> 'active' + getType() >> Boolean + } + PersistentProperty cityProperty = Stub(PersistentProperty) { + getName() >> 'city' + getType() >> String + } + + def setup() { + entity.getMappingContext() >> mappingContext + mappingContext.getConversionService() >> new DefaultConversionService() + mappingContext.getPersistentEntity(_) >> entity + entity.getPropertyByName('title') >> titleProperty + entity.getPropertyByName('name') >> nameProperty + entity.getPropertyByName('city') >> cityProperty + entity.getPropertyByName('active') >> activeProperty + query.getEntity() >> entity + datastoreClient.getMappingContext() >> mappingContext + } + + void "findBy obtains its mapping context from the datastore client when constructed"() { + when: + RxSingleResultFinder.findBy(datastoreClient) + + then: + 1 * datastoreClient.getMappingContext() >> mappingContext + } + + void "findBy isMethodMatch matches findBy* method names"() { + expect: + RxSingleResultFinder.findBy(datastoreClient).isMethodMatch('findByTitle') + !RxSingleResultFinder.findBy(datastoreClient).isMethodMatch('somethingElse') + } + + void "setPattern delegates to the grammar"() { + given: + def finder = RxSingleResultFinder.findBy(datastoreClient) + + expect: + finder.isMethodMatch('findByTitle') + !finder.isMethodMatch('customPrefixTitle') + + when: + finder.setPattern('(customPrefix)([A-Z]\\w*)') + + then: + finder.isMethodMatch('customPrefixTitle') + !finder.isMethodMatch('findByTitle') + } + + void "findBy finds by building and executing a query via the RX datastore client instead of a session"() { + given: + def finder = RxSingleResultFinder.findBy(datastoreClient) + + when: + // query.singleResult() returns an Observable in production (RxQuery#singleResult) - findBy + // passes it through unchanged rather than unwrapping it, so the caller (e.g. + // RxGormStaticApi.methodMissing) receives an Observable, not the raw entity. + Observable observable = finder.invoke(Book, 'findByTitle', ['Shogun'] as Object[]) as Observable + Book result = observable.toBlocking().first() as Book + + then: + 1 * datastoreClient.createQuery(Book) >> query + 1 * query.add({ Query.Criterion it -> it instanceof Query.Junction }) + 1 * query.singleResult() >> Observable.just(new Book(title: 'Shogun')) + result.title == 'Shogun' + } + + void "findBy invoke(Class, methodName, DetachedCriteria, Object[]) merges the detached criteria onto the built query"() { + given: + // Called reflectively (dynamic Groovy dispatch, not part of FinderMethod) by + // AbstractDetachedCriteria#methodMissing. + def finder = RxSingleResultFinder.findBy(datastoreClient) + def detachedCriteria = Stub(grails.gorm.DetachedCriteria) { + getFetchStrategies() >> [:] + getCriteria() >> [org.grails.datastore.mapping.query.Restrictions.eq('author', 'Clavell')] + getProjections() >> [] + getOrders() >> [] + } + + when: + Observable observable = finder.invoke(Book, 'findByTitle', detachedCriteria, ['Shogun'] as Object[]) as Observable + Book result = observable.toBlocking().first() as Book + + then: + 1 * datastoreClient.createQuery(Book) >> query + 1 * query.add({ Query.Criterion it -> it instanceof Query.PropertyCriterion }) + 1 * query.add({ Query.Criterion it -> it instanceof Query.Junction }) + 1 * query.singleResult() >> Observable.just(new Book(title: 'Shogun')) + result.title == 'Shogun' + } + + void "findBy invoke(Class, methodName, DetachedCriteria, Object[]) with a null detachedCriteria never merges anything onto the built query"() { + given: + def finder = RxSingleResultFinder.findBy(datastoreClient) + + when: + Observable observable = finder.invoke(Book, 'findByTitle', (grails.gorm.DetachedCriteria) null, ['Shogun'] as Object[]) as Observable + Book result = observable.toBlocking().first() as Book + + then: + 1 * datastoreClient.createQuery(Book) >> query + 0 * query.add({ Query.Criterion it -> it instanceof Query.PropertyCriterion }) + 1 * query.add({ Query.Criterion it -> it instanceof Query.Junction }) + 1 * query.singleResult() >> Observable.just(new Book(title: 'Shogun')) + result.title == 'Shogun' + } + + void "findBy applies additional criteria to the query when present"() { + given: + def finder = RxSingleResultFinder.findBy(datastoreClient) + def session = Stub(Session) { + getMappingContext() >> mappingContext + } + query.getSession() >> session + entity.getJavaClass() >> Book + + when: + finder.invoke(Book, 'findByTitle', { -> }, ['Shogun'] as Object[]) + + then: + 1 * datastoreClient.createQuery(Book) >> query + 1 * query.getSession() >> session + 1 * query.singleResult() + noExceptionThrown() + } + + void "findBy applies query arguments to the query when present"() { + given: + def finder = RxSingleResultFinder.findBy(datastoreClient) + + when: + finder.invoke(Book, 'findByTitle', ['Shogun', [max: 5]] as Object[]) + + then: + 1 * datastoreClient.createQuery(Book) >> query + 1 * query.max(5) + 1 * query.singleResult() + } + + void "findByBoolean isMethodMatch matches boolean style method names"() { + expect: + RxSingleResultFinder.findByBoolean(datastoreClient).isMethodMatch('findActive') + } + + void "findByBoolean queries by the boolean property, combining it as a required expression with the rest via Or"() { + given: + def finder = RxSingleResultFinder.findByBoolean(datastoreClient) + + when: + Observable observable = finder.invoke(Person, 'findActiveByNameOrCity', ['Fred', 'London'] as Object[]) as Observable + Person result = observable.toBlocking().first() as Person + + then: + 1 * datastoreClient.createQuery(Person) >> query + 1 * query.add({ Query.Criterion it -> it instanceof Query.Conjunction && ((Query.Junction) it).criteria.size() == 2 }) + 1 * query.singleResult() >> Observable.just(new Person(name: 'Fred', active: true)) + result.name == 'Fred' + } + + void "findOrCreateBy isMethodMatch matches findOrCreateBy method names only"() { + expect: + RxSingleResultFinder.findOrCreateBy(datastoreClient).isMethodMatch('findOrCreateByName') + !RxSingleResultFinder.findOrCreateBy(datastoreClient).isMethodMatch('findByName') + } + + void "findOrCreateBy returns the existing entity when the underlying query already finds a match"() { + given: + def finder = RxSingleResultFinder.findOrCreateBy(datastoreClient) + def existing = new Person(name: 'Fred') + + when: + Observable observable = finder.invoke(Person, 'findOrCreateByName', ['Fred'] as Object[]) as Observable + Person result = observable.toBlocking().first() as Person + + then: + 1 * datastoreClient.createQuery(Person) >> query + 1 * query.singleResult() >> Observable.just(existing) + result.is(existing) + } + + void "findOrCreateBy creates a new instance from the query arguments when no match exists and saving is not required"() { + given: + def finder = RxSingleResultFinder.findOrCreateBy(datastoreClient) + + when: + Observable observable = finder.invoke(Person, 'findOrCreateByName', ['Fred'] as Object[]) as Observable + Person result = observable.toBlocking().first() as Person + + then: + 1 * datastoreClient.createQuery(Person) >> query + 1 * query.singleResult() >> Observable.empty() + result.name == 'Fred' + } + + void "findOrCreateBy signals completion after emitting the new instance when saving is not required"() { + given: + def finder = RxSingleResultFinder.findOrCreateBy(datastoreClient) + + when: + Observable observable = finder.invoke(Person, 'findOrCreateByName', ['Fred'] as Object[]) as Observable + def subscriber = new rx.observers.TestSubscriber() + observable.subscribe(subscriber) + subscriber.awaitTerminalEvent(5, java.util.concurrent.TimeUnit.SECONDS) + + then: + 1 * datastoreClient.createQuery(Person) >> query + 1 * query.singleResult() >> Observable.empty() + subscriber.assertCompleted() + subscriber.assertNoErrors() + subscriber.onNextEvents.size() == 1 + (subscriber.onNextEvents[0] as Person).name == 'Fred' + } + + void "findOrCreateBy signals an error rather than hanging when a non-Equal expression reaches the empty-result construction path"() { + given: + // The empty-result construction path runs inside a spawned Thread - unlike the + // synchronous SingleResultFinder, a thrown exception there does not propagate as a normal + // method-call exception, so it must be forwarded to the Observable's error channel + // explicitly (via Subscriber#onError) or it would kill the thread silently and hang any + // blocking call on the returned Observable forever. + def finder = RxSingleResultFinder.findOrCreateBy(datastoreClient) + + when: + Observable observable = finder.invoke(Person, 'findOrCreateByNameLike', ['Fre%'] as Object[]) as Observable + def subscriber = new rx.observers.TestSubscriber() + observable.subscribe(subscriber) + subscriber.awaitTerminalEvent(5, java.util.concurrent.TimeUnit.SECONDS) + + then: + 1 * datastoreClient.createQuery(Person) >> query + 1 * query.singleResult() >> Observable.empty() + subscriber.assertError(groovy.lang.MissingMethodException) + subscriber.onNextEvents.isEmpty() + } + + void "findOrSaveBy isMethodMatch matches findOrSaveBy method names only"() { + expect: + RxSingleResultFinder.findOrSaveBy(datastoreClient).isMethodMatch('findOrSaveByName') + !RxSingleResultFinder.findOrSaveBy(datastoreClient).isMethodMatch('findOrCreateByName') + } + + void "findOrSaveBy returns the existing entity when the underlying query already finds a match"() { + given: + def finder = RxSingleResultFinder.findOrSaveBy(datastoreClient) + def existing = new Person(name: 'Fred') + + when: + Observable observable = finder.invoke(Person, 'findOrSaveByName', ['Fred'] as Object[]) as Observable + Person result = observable.toBlocking().first() as Person + + then: + 1 * datastoreClient.createQuery(Person) >> query + 1 * query.singleResult() >> Observable.just(existing) + result.is(existing) + } + + void "findOrSaveBy creates, saves and returns a new instance when no match exists"() { + given: + def finder = RxSingleResultFinder.findOrSaveBy(datastoreClient) + entity.getPropertyByName('name') >> Stub(PersistentProperty) { getName() >> 'name'; getType() >> String } + + when: + Observable observable = finder.invoke(PersonThatSavesSuccessfully, 'findOrSaveByName', ['Fred'] as Object[]) as Observable + PersonThatSavesSuccessfully result = observable.toBlocking().first() as PersonThatSavesSuccessfully + + then: + 1 * datastoreClient.createQuery(PersonThatSavesSuccessfully) >> query + 1 * query.singleResult() >> Observable.empty() + result.name == 'Fred' + } + + void "findOrSaveBy propagates the error when saving the newly created instance fails"() { + given: + def finder = RxSingleResultFinder.findOrSaveBy(datastoreClient) + + when: + Observable observable = finder.invoke(PersonThatFailsToSave, 'findOrSaveByName', ['Fred'] as Object[]) as Observable + observable.toBlocking().first() + + then: + 1 * datastoreClient.createQuery(PersonThatFailsToSave) >> query + 1 * query.singleResult() >> Observable.empty() + def ex = thrown(IllegalStateException) + ex.message == 'save failed' + } + + private static class Book { + String title + String author + } + + private static class Person { + String name + boolean active + } + + private static class PersonThatSavesSuccessfully implements RxEntity { + String name + + @Override + Observable save(Map arguments) { + Observable.just(this) + } + } + + private static class PersonThatFailsToSave implements RxEntity { + String name + + @Override + Observable save(Map arguments) { + Observable.error(new IllegalStateException('save failed')) + } + } +}