You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by bo...@apache.org on 2026/08/15 21:12:38 UTC
(grails-core) 01/14: Replace finders inheritance hierarchy with composition
This is an automated email from the ASF dual-hosted git repository.
borinquenkid pushed a commit to branch test/document-datamapping-core-finders
in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit 9ed990a5d1574143190f648de6a2d6e9aa1b0f39
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Sat Aug 15 13:59:03 2026 -0500
Replace finders inheritance hierarchy with composition
The 4-level DynamicFinder inheritance chain (AbstractFinder ->
DynamicFinder -> AbstractFindByFinder -> concrete finders) made
FindOrSaveByFinder carry dead code: its own copy of the
construct-from-Equal-expressions logic could never run because
FindOrCreateByFinder's superclass method already handled it. The
FindByBooleanFinder/FindAllByBooleanFinder pair duplicated the same
pattern-swap override for the same structural reason.
Replace the hierarchy with flat classes (SingleResultFinder,
ListResultFinder, CountFinder, ListOrderByFinder) that compose a
DynamicFinder grammar instance and are configured via static
factories, in both grails-datamapping-core and its rx mirror. The
construct-from-Equal-expressions logic and the boolean-clause
handling now exist in exactly one place each.
Co-Authored-By: Claude Sonnet 5 <no...@anthropic.com>
---
.../org/grails/datastore/gorm/GormEnhancer.groovy | 24 +-
.../gorm/finders/AbstractFindByFinder.java | 65 --
.../datastore/gorm/finders/CountByFinder.java | 90 ---
.../grails/datastore/gorm/finders/CountFinder.java | 154 +++++
.../datastore/gorm/finders/DynamicFinder.java | 87 ++-
.../gorm/finders/FindAllByBooleanFinder.java | 56 --
.../datastore/gorm/finders/FindAllByFinder.java | 66 --
.../gorm/finders/FindByBooleanFinder.java | 60 --
.../datastore/gorm/finders/FindByFinder.java | 40 --
.../gorm/finders/FindOrCreateByFinder.java | 108 ---
.../datastore/gorm/finders/FindOrSaveByFinder.java | 73 --
.../datastore/gorm/finders/FinderGrammar.java | 64 ++
.../{AbstractFinder.java => FinderSupport.java} | 52 +-
.../datastore/gorm/finders/ListOrderByFinder.java | 25 +-
.../datastore/gorm/finders/ListResultFinder.java | 126 ++++
.../datastore/gorm/finders/SingleResultFinder.java | 229 +++++++
.../datastore/gorm/finders/CountFinderSpec.groovy | 184 +++++
.../finders/DynamicFinderInvocationSpec.groovy | 67 ++
.../gorm/finders/DynamicFinderSpec.groovy | 761 ++++++++++++++++++++-
.../gorm/finders/FinderSupportSpec.groovy | 56 ++
.../datastore/gorm/finders/FinderTestEntity.groovy | 49 ++
.../gorm/finders/ListOrderByFinderSpec.groovy | 107 +++
.../gorm/finders/ListResultFinderSpec.groovy | 139 ++++
.../gorm/finders/MethodExpressionSpec.groovy | 574 ++++++++++++++++
.../gorm/finders/SingleResultFinderSpec.groovy | 282 ++++++++
.../org/grails/gorm/rx/api/RxGormStaticApi.groovy | 24 +-
.../grails/gorm/rx/finders/CountByFinder.groovy | 47 --
.../gorm/rx/finders/FindAllByBooleanFinder.groovy | 42 --
.../grails/gorm/rx/finders/FindAllByFinder.groovy | 59 --
.../gorm/rx/finders/FindByBooleanFinder.groovy | 42 --
.../org/grails/gorm/rx/finders/FindByFinder.groovy | 51 --
.../gorm/rx/finders/FindOrCreateByFinder.groovy | 92 ---
.../gorm/rx/finders/FindOrSaveByFinder.groovy | 40 --
.../grails/gorm/rx/finders/RxCountFinder.groovy | 98 +++
.../gorm/rx/finders/RxListResultFinder.groovy | 120 ++++
.../gorm/rx/finders/RxSingleResultFinder.groovy | 186 +++++
.../rx/finders/FindAllByBooleanFinderSpec.groovy | 96 ---
.../gorm/rx/finders/FindAllByFinderSpec.groovy | 146 ----
.../gorm/rx/finders/FindByBooleanFinderSpec.groovy | 86 ---
.../grails/gorm/rx/finders/FindByFinderSpec.groovy | 133 ----
.../rx/finders/FindOrCreateByFinderSpec.groovy | 185 -----
.../gorm/rx/finders/FindOrSaveByFinderSpec.groovy | 114 ---
...yFinderSpec.groovy => RxCountFinderSpec.groovy} | 73 +-
.../gorm/rx/finders/RxListResultFinderSpec.groovy | 196 ++++++
.../rx/finders/RxSingleResultFinderSpec.groovy | 325 +++++++++
45 files changed, 3859 insertions(+), 1834 deletions(-)
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 efadd5c1cc..0d0650f27e 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<FinderMethod> 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<FinderMethod>
}
}
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 e2b03e51e0..0000000000
--- 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 499c07cd45..0000000000
--- 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<Object>() {
- 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 0000000000..3057c50fa8
--- /dev/null
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/CountFinder.java
@@ -0,0 +1,154 @@
+/*
+ * 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 {@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
+ public Object invoke(Class clazz, String methodName, Object[] arguments) {
+ return invoke(clazz, methodName, (Closure) null, arguments);
+ }
+
+ @Override
+ 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
+ */
+ 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, new SessionCallback<Object>() {
+ @Override
+ public Object doInSession(Session 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
+ */
+ @SuppressWarnings("unchecked")
+ 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 8792a485d8..f93a7a4d8e 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
@@ -35,7 +35,7 @@ import org.springframework.core.convert.ConversionException;
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 +54,6 @@ import org.grails.datastore.gorm.finders.MethodExpression.NotEqual;
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 +65,16 @@ import org.grails.datastore.mapping.reflect.ClassUtils;
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 {
+public class DynamicFinder implements FinderGrammar {
public static final String ARGUMENT_FETCH_SIZE = "fetchSize";
public static final String ARGUMENT_TIMEOUT = "timeout";
@@ -104,6 +106,7 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
private static final String NOT = "Not";
private static final Map<String, Constructor> methodExpressions = new LinkedHashMap<String, Constructor>();
protected final MappingContext mappingContext;
+ private final boolean firstExpressionIsRequiredBoolean;
static {
defaultOperationPatterns = new Pattern[2];
@@ -132,21 +135,20 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
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);
}
@@ -250,23 +252,12 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
* @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) {
@@ -393,10 +384,6 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
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
@@ -613,7 +600,21 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
}
}
- 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) {
@@ -732,7 +733,7 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
}
}
- protected void configureQueryWithArguments(Class clazz, Query query, Object[] arguments) {
+ public static void configureQueryWithArguments(Class clazz, Query query, Object[] arguments) {
if (arguments.length == 0 || !(arguments[0] instanceof Map)) {
populateArgumentsForCriteria(clazz, query, Collections.emptyMap());
return;
@@ -773,14 +774,16 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
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));
@@ -800,6 +803,16 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
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/FindAllByBooleanFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindAllByBooleanFinder.java
deleted file mode 100644
index ce3e03aa1d..0000000000
--- 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 ed1eaede5e..0000000000
--- 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<Object>() {
- 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 c1591f9398..0000000000
--- 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;
-
-/**
- *
- * <p>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.</p>
- *
- * <pre><code>
- * 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"
- * </code></pre>
- *
- * <p>
- * In both of those queries, the query will only select Account objects where active=true.
- * </p>
- *
- * @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 5e8ee5cf61..0000000000
--- 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 8205dec00b..0000000000
--- 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<MethodExpression> 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 3552214b8f..0000000000
--- 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<MethodExpression> 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 0000000000..61b3eee3c0
--- /dev/null
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FinderGrammar.java
@@ -0,0 +1,64 @@
+/*
+ * 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.
+ */
+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 fc23f59795..fd8530d32d 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> T execute(final SessionCallback<T> 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 <T> The callback's result type
+ * @return The callback's result
+ * @throws IllegalStateException if datastore is null (stateless mode)
+ */
+ public static <T> T execute(final Datastore datastore, final SessionCallback<T> 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 1f522b9554..2816249ddd 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
@@ -38,25 +38,34 @@ import org.grails.datastore.mapping.reflect.NameUtils;
* Account.listOrderByHolder();
* Account.listOrderByHolder(max); // max results
*
+ * <p>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);
}
+ @Override
@SuppressWarnings("rawtypes")
public Object invoke(final Class clazz, final String methodName, final Closure additionalCriteria, final Object[] arguments) {
@@ -66,10 +75,10 @@ public class ListOrderByFinder extends AbstractFinder {
String nameInSignature = match.group(2);
final String propertyName = NameUtils.decapitalizeFirstChar(nameInSignature);
- return execute(new SessionCallback<>() {
+ return FinderSupport.execute(datastore, new SessionCallback<>() {
public Object doInSession(final Session session) {
Query q = session.createQuery(clazz);
- applyAdditionalCriteria(q, additionalCriteria);
+ DynamicFinder.applyAdditionalCriteria(q, additionalCriteria);
boolean ascending = true;
if (arguments.length > 0 && (arguments[0] instanceof Map)) {
@@ -83,17 +92,13 @@ public class ListOrderByFinder extends AbstractFinder {
q.order(ascending ? Query.Order.asc(propertyName) : Query.Order.desc(propertyName));
q.projections().distinct();
- return invokeQuery(q);
+ return q.list();
}
});
}
- protected Object invokeQuery(Query q) {
- 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 0000000000..1b45256d17
--- /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 findAll<booleanProperty>By*} 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
+ public Object invoke(Class clazz, String methodName, Object[] arguments) {
+ return invoke(clazz, methodName, (Closure) null, arguments);
+ }
+
+ @Override
+ 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
+ */
+ 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, new SessionCallback<Object>() {
+ @Override
+ public Object doInSession(Session 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/SingleResultFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/SingleResultFinder.java
new file mode 100644
index 0000000000..a05fce66a8
--- /dev/null
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/SingleResultFinder.java
@@ -0,0 +1,229 @@
+/*
+ * 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.SessionCallback;
+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 find<booleanProperty>By*} 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<DynamicFinderInvocation> validate;
+ private final Function<DynamicFinderInvocation, Object> onNullResult;
+
+ private SingleResultFinder(Datastore datastore, DynamicFinder grammar,
+ Consumer<DynamicFinderInvocation> validate, Function<DynamicFinderInvocation, Object> 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")
+ private static Object constructFromEqualExpressions(DynamicFinderInvocation invocation, boolean save) {
+ Map m = new HashMap();
+ List<MethodExpression> 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
+ public Object invoke(Class clazz, String methodName, Object[] arguments) {
+ return invoke(clazz, methodName, (Closure) null, arguments);
+ }
+
+ @Override
+ 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
+ */
+ 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, new SessionCallback<Object>() {
+ @Override
+ public Object doInSession(Session 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 0000000000..b6011f5806
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/CountFinderSpec.groovy
@@ -0,0 +1,184 @@
+/*
+ * 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 "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 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 0000000000..d14dd289da
--- /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 23e0bdeceb..71d4738907 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,734 @@ 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 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 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 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)
}
}
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 0000000000..b7adf3c57b
--- /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 0000000000..7c35f3e699
--- /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 0000000000..e4827a29e2
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/ListOrderByFinderSpec.groovy
@@ -0,0 +1,107 @@
+/*
+ * 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 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 0000000000..6b0164203f
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/ListResultFinderSpec.groovy
@@ -0,0 +1,139 @@
+/*
+ * 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 "findAllBy runs the full round trip, applies distinct and returns the full list"() {
+ given:
+ List<FinderTestEntity> 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<FinderTestEntity> 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 throws IllegalStateException when constructed in stateless mode"() {
+ when:
+ ListResultFinder.findAllBy(mappingContext).invoke(FinderTestEntity, 'findAllByName', ['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 0000000000..1343e5a6ec
--- /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 0000000000..d62ec34abe
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/SingleResultFinderSpec.groovy
@@ -0,0 +1,282 @@
+/*
+ * 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 - see {@code DynamicFinderSpec}'s field-ordering note for why that
+ * matters), 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 "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) throws IllegalStateException when constructed in stateless mode"() {
+ when:
+ SingleResultFinder.findBy(mappingContext).invoke(FinderTestEntity, 'findByName', ['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 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 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 1278d50e86..13d50acbe0 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<D> implements RxGormAllOperations<D> {
}
protected List<FinderMethod> createDynamicFinders() {
- [new FindOrCreateByFinder(datastoreClient),
- new FindOrSaveByFinder(datastoreClient),
- new FindByFinder(datastoreClient),
- new FindAllByFinder(datastoreClient),
- new CountByFinder(datastoreClient),
- new FindByBooleanFinder(datastoreClient),
- new FindAllByBooleanFinder(datastoreClient)] as List<FinderMethod>
+ [RxSingleResultFinder.findOrCreateBy(datastoreClient),
+ RxSingleResultFinder.findOrSaveBy(datastoreClient),
+ RxSingleResultFinder.findBy(datastoreClient),
+ RxListResultFinder.findAllBy(datastoreClient),
+ RxCountFinder.countBy(datastoreClient),
+ RxSingleResultFinder.findByBoolean(datastoreClient),
+ RxListResultFinder.findAllByBoolean(datastoreClient)] as List<FinderMethod>
}
@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 1f5b5d9580..0000000000
--- 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 296e9a74ae..0000000000
--- 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 13ac471813..0000000000
--- 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 f0bdf2c0fc..0000000000
--- 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 06ce2a582f..0000000000
--- 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 9fb8c114bb..0000000000
--- 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<MethodExpression> 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 77df23793f..0000000000
--- 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 0000000000..31b965664a
--- /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 0000000000..58e515ec97
--- /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 findAll<booleanProperty>By*} boolean-clause form
+ * for RxGORM - the rx-module mirror of {@link org.grails.datastore.gorm.finders.ListResultFinder}.
+ *
+ * <p>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 0000000000..d1235beed5
--- /dev/null
+++ b/grails-datamapping-rx/src/main/groovy/org/grails/gorm/rx/finders/RxSingleResultFinder.groovy
@@ -0,0 +1,186 @@
+/*
+ * 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 find<booleanProperty>By*} 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.
+ *
+ * <p>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 {
+ Map m = [:]
+ List<MethodExpression> 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()
+ }
+ }
+ } as Observable.OnSubscribe))
+ }
+}
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 87dc1f823c..0000000000
--- 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 ab1bdc244f..0000000000
--- 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 34ea85fdf2..0000000000
--- 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 b4d95cd886..0000000000
--- 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 dc1c4acfa2..0000000000
--- 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<PersonThatSavesSuccessfully> {
- String name
-
- @Override
- Observable<PersonThatSavesSuccessfully> save(Map<String, Object> arguments) {
- Observable.just(this)
- }
- }
-
- private static class PersonThatFailsToSave implements RxEntity<PersonThatFailsToSave> {
- String name
-
- @Override
- Observable<PersonThatFailsToSave> save(Map<String, Object> 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 6b6861d3b0..0000000000
--- 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<PersonThatSavesSuccessfully> {
- String name
-
- @Override
- Observable<PersonThatSavesSuccessfully> save(Map<String, Object> arguments) {
- Observable.just(this)
- }
- }
-}
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/RxCountFinderSpec.groovy
similarity index 57%
rename from grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/CountByFinderSpec.groovy
rename to grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/RxCountFinderSpec.groovy
index 8b1f39e088..8ec6856b91 100644
--- 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/RxCountFinderSpec.groovy
@@ -19,50 +19,54 @@
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.model.PersistentProperty
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 {
+/**
+ * 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
- entity.getJavaClass() >> Person
mappingContext.getConversionService() >> new DefaultConversionService()
+ mappingContext.getPersistentEntity(_) >> entity
+ entity.getPropertyByName('name') >> nameProperty
query.getEntity() >> entity
query.projections() >> new Query.ProjectionList()
+ datastoreClient.getMappingContext() >> mappingContext
}
- 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)
+ void "isMethodMatch matches countBy* method names"() {
+ expect:
+ RxCountFinder.countBy(datastoreClient).isMethodMatch('countByName')
+ !RxCountFinder.countBy(datastoreClient).isMethodMatch('somethingElse')
}
- def "counts by building and executing a query via the RX datastore client instead of a session"() {
+ void "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)
+ def finder = RxCountFinder.countBy(datastoreClient)
when:
- def result = finder.doInvokeInternal(invocation)
+ def result = finder.invoke(Person, 'countByName', ['Fred'] as Object[])
then:
1 * datastoreClient.createQuery(Person) >> query
@@ -71,39 +75,39 @@ class CountByFinderSpec extends Specification {
result == 4L
}
- def "applies detached criteria to the query when present"() {
+ void "invoke(Class, methodName, DetachedCriteria, Object[]) merges the detached criteria onto the built query"() {
given:
- def finder = new CountByFinder(datastoreClient)
- def invocation = new DynamicFinderInvocation(Person, 'countByName', [] as Object[], [], null, null)
+ // 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() >> [Restrictions.eq('active', true)]
+ getCriteria() >> [org.grails.datastore.mapping.query.Restrictions.eq('active', true)]
getProjections() >> []
getOrders() >> []
}
- invocation.setDetachedCriteria(detachedCriteria)
when:
- finder.doInvokeInternal(invocation)
+ def result = finder.invoke(Person, 'countByName', detachedCriteria, ['Fred'] as Object[])
then:
1 * datastoreClient.createQuery(Person) >> query
- 1 * query.add({ Query.Criterion it -> it instanceof Query.PropertyCriterion })
- 1 * query.singleResult()
+ 2 * query.add({ Query.Criterion it -> it instanceof Query.PropertyCriterion })
+ 1 * query.singleResult() >> 4L
+ result == 4L
}
- def "applies additional criteria to the query when present"() {
+ void "applies additional criteria to the query when present"() {
given:
- def finder = new CountByFinder(datastoreClient)
- def invocation = new DynamicFinderInvocation(Person, 'countByName', [] as Object[], [], { -> }, null)
+ def finder = RxCountFinder.countBy(datastoreClient)
def session = Stub(Session) {
getMappingContext() >> mappingContext
}
- mappingContext.getPersistentEntity(_) >> entity
query.getSession() >> session
+ entity.getJavaClass() >> Person
when:
- finder.doInvokeInternal(invocation)
+ finder.invoke(Person, 'countByName', { -> }, ['Fred'] as Object[])
then:
1 * datastoreClient.createQuery(Person) >> query
@@ -112,13 +116,12 @@ class CountByFinderSpec extends Specification {
noExceptionThrown()
}
- def "applies query arguments to the query when present"() {
+ void "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)
+ def finder = RxCountFinder.countBy(datastoreClient)
when:
- finder.doInvokeInternal(invocation)
+ finder.invoke(Person, 'countByName', ['Fred', [max: 5]] as Object[])
then:
1 * datastoreClient.createQuery(Person) >> query
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 0000000000..bd11405c3b
--- /dev/null
+++ b/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/RxListResultFinderSpec.groovy
@@ -0,0 +1,196 @@
+/*
+ * 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 "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(_)
+ 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 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 0000000000..11e856392b
--- /dev/null
+++ b/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/finders/RxSingleResultFinderSpec.groovy
@@ -0,0 +1,325 @@
+/*
+ * 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 "findBy finds by building and executing a query via the RX datastore client instead of a session"() {
+ given:
+ def finder = RxSingleResultFinder.findBy(datastoreClient)
+
+ when:
+ def result = finder.invoke(Book, 'findByTitle', ['Shogun'] as Object[])
+
+ 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'
+ }
+
+ 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:
+ def result = finder.invoke(Book, 'findByTitle', 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.singleResult() >> 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:
+ def result = finder.invoke(Person, 'findActiveByNameOrCity', ['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.singleResult() >> 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'
+ }
+
+ // NOTE: unlike SingleResultFinderSpec (core), there is deliberately no test here for a
+ // non-Equal expression reaching the empty-result construction path. In this async
+ // implementation that path runs inside a spawned Thread with no try/catch (preserved exactly
+ // from the original rx FindOrCreateByFinder - see the class Javadoc), so a thrown
+ // MissingMethodException never reaches the Observable's error channel at all: the thread dies
+ // silently and any blocking call on the Observable hangs forever. This is a genuine latent bug
+ // in the pre-existing rx implementation, not introduced by this refactor, and was never covered
+ // by the original test suite either - it is not safely testable without either fixing the
+ // production code (out of scope for this pass) or adding an artificial timeout that would not
+ // reflect real behavior.
+
+ 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<PersonThatSavesSuccessfully> {
+ String name
+
+ @Override
+ Observable<PersonThatSavesSuccessfully> save(Map<String, Object> arguments) {
+ Observable.just(this)
+ }
+ }
+
+ private static class PersonThatFailsToSave implements RxEntity<PersonThatFailsToSave> {
+ String name
+
+ @Override
+ Observable<PersonThatFailsToSave> save(Map<String, Object> arguments) {
+ Observable.error(new IllegalStateException('save failed'))
+ }
+ }
+}