You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by jd...@apache.org on 2026/10/02 13:31:47 UTC
(grails-core) 01/01: Replace finders inheritance hierarchy with composition
This is an automated email from the ASF dual-hosted git repository.
jdaugherty pushed a commit to branch refactor/finder-composition-8.0.x
in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit 7bb6de9e568d15ea780f9b14d84d036dc4ae726e
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Fri Oct 2 09:31:22 2026 -0400
Replace finders inheritance hierarchy with composition
Backport of #16154 to 8.0.x.
The dynamic finders in org.grails.datastore.gorm.finders no longer form an
AbstractFinder -> DynamicFinder -> AbstractFindByFinder -> concrete finder
chain. SingleResultFinder, ListResultFinder and CountFinder are flat
classes, created through static factories, that compose a DynamicFinder.
DynamicFinder is now only the method-name grammar and criteria builder.
- findOrCreateBy* and findOrSaveBy* share one implementation of building
an instance from the Equal expressions. The old FindOrSaveByFinder copy
of that branch could never run
- countBy*Or* resolves association arguments to their identifiers, as
countBy*And* does, which fixes the MongoDB under-count. The
@PendingFeature on the Mongo spec is removed
- AbstractDetachedCriteria#dynamicFinders is typed List<FinderMethod>
- The removed public classes and the factories that replace them are
listed in the upgrade guide
The RxGORM half of #16154 is not included, because grails-datamapping-rx
is not built on 8.0.x. ListOrderByFinder keeps, and the new FinderSupport
gets, the ASF license header.
Fixes #15789
Co-authored-by: James Daugherty <jd...@jdresources.net>
---
.../gorm/mongo/DisjunctionQuerySpec.groovy | 2 -
.../datastore/gorm/DefaultGormApiFactory.groovy | 24 +-
.../org/grails/datastore/gorm/GormEnhancer.groovy | 24 +-
.../gorm/finders/AbstractFindByFinder.java | 69 --
.../datastore/gorm/finders/AbstractFinder.java | 86 --
.../datastore/gorm/finders/CountByFinder.java | 91 ---
.../grails/datastore/gorm/finders/CountFinder.java | 157 ++++
.../datastore/gorm/finders/DynamicFinder.java | 359 ++++-----
.../gorm/finders/DynamicFinderInvocation.java | 12 +-
.../gorm/finders/FindAllByBooleanFinder.java | 56 --
.../datastore/gorm/finders/FindAllByFinder.java | 71 --
.../gorm/finders/FindByBooleanFinder.java | 51 --
.../datastore/gorm/finders/FindByFinder.java | 45 --
.../gorm/finders/FindOrCreateByFinder.java | 117 ---
.../datastore/gorm/finders/FindOrSaveByFinder.java | 53 --
.../datastore/gorm/finders/FinderGrammar.java | 65 ++
.../datastore/gorm/finders/FinderSupport.java | 82 ++
.../datastore/gorm/finders/ListOrderByFinder.java | 107 +--
.../datastore/gorm/finders/ListResultFinder.java | 134 ++++
.../datastore/gorm/finders/MethodExpression.java | 64 +-
.../datastore/gorm/finders/SingleResultFinder.java | 245 ++++++
.../query/criteria/AbstractDetachedCriteria.groovy | 3 +-
.../datastore/gorm/finders/CountFinderSpec.groovy | 261 +++++++
...c.groovy => DatastoreResolverFinderSpec.groovy} | 66 +-
.../gorm/finders/DynamicFinderCoverageSpec.groovy | 10 +-
.../finders/DynamicFinderInvocationSpec.groovy | 67 ++
.../gorm/finders/DynamicFinderSpec.groovy | 869 ++++++++++++++++++++-
.../finders/FindOrSaveByDynamicFinderSpec.groovy | 63 ++
.../gorm/finders/FindOrSaveByFinderSpec.groovy | 88 ---
.../gorm/finders/FinderSupportSpec.groovy | 123 +++
.../datastore/gorm/finders/FinderTestEntity.groovy | 49 ++
.../gorm/finders/ListOrderByFinderSpec.groovy | 210 +++++
.../gorm/finders/ListResultFinderSpec.groovy | 222 ++++++
.../gorm/finders/MethodExpressionSpec.groovy | 574 ++++++++++++++
.../gorm/finders/SingleResultFinderSpec.groovy | 412 ++++++++++
.../src/en/guide/upgrading/upgrading80x.adoc | 35 +
36 files changed, 3898 insertions(+), 1068 deletions(-)
diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/DisjunctionQuerySpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/DisjunctionQuerySpec.groovy
index 38569b5efc..68ecda3680 100644
--- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/DisjunctionQuerySpec.groovy
+++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/DisjunctionQuerySpec.groovy
@@ -24,7 +24,6 @@ import org.apache.grails.data.testing.tck.domains.PetType
import org.apache.grails.data.mongo.core.GrailsDataMongoTckManager
import org.apache.grails.data.testing.tck.base.GrailsDataTckSpec
import spock.lang.Issue
-import spock.lang.PendingFeature
class DisjunctionQuerySpec extends GrailsDataTckSpec<GrailsDataMongoTckManager> {
def dogType
@@ -65,7 +64,6 @@ class DisjunctionQuerySpec extends GrailsDataTckSpec<GrailsDataMongoTckManager>
pet.name == "Big Bird"
}
- @PendingFeature(reason = "Pre-existing: count over an OR disjunction (countByXOrY) under-counts on MongoDB — the count aggregation does not correctly count distinct matches of the disjunction. Verified pre-existing (MongoQuery count path is unchanged from before the GormRegistry rewrite; find-over-OR works). Tracked for an independent fix in https://github.com/apache/grails-core/issues/15789.")
@Issue('GPMONGODB-380')
void "Count all dogs or pets with the name Jack"() {
given: "Some data"
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/DefaultGormApiFactory.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/DefaultGormApiFactory.groovy
index 331f887e09..d1e8761f03 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/DefaultGormApiFactory.groovy
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/DefaultGormApiFactory.groovy
@@ -19,15 +19,11 @@
package org.grails.datastore.gorm
import groovy.transform.CompileStatic
-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.CountFinder
import org.grails.datastore.gorm.finders.FinderMethod
-import org.grails.datastore.gorm.finders.FindOrCreateByFinder
-import org.grails.datastore.gorm.finders.FindOrSaveByFinder
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.mapping.model.MappingContext
/**
@@ -71,13 +67,13 @@ class DefaultGormApiFactory implements GormApiFactory {
@Override
List<FinderMethod> createDynamicFinders(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
- [new FindOrCreateByFinder(datastoreResolver, mappingContext),
- new FindOrSaveByFinder(datastoreResolver, mappingContext),
- new FindByFinder(datastoreResolver, mappingContext),
- new FindAllByFinder(datastoreResolver, mappingContext),
- new FindAllByBooleanFinder(datastoreResolver, mappingContext),
- new FindByBooleanFinder(datastoreResolver, mappingContext),
- new CountByFinder(datastoreResolver, mappingContext),
+ [SingleResultFinder.findOrCreateBy(datastoreResolver, mappingContext),
+ SingleResultFinder.findOrSaveBy(datastoreResolver, mappingContext),
+ SingleResultFinder.findBy(datastoreResolver, mappingContext),
+ ListResultFinder.findAllBy(datastoreResolver, mappingContext),
+ ListResultFinder.findAllByBoolean(datastoreResolver, mappingContext),
+ SingleResultFinder.findByBoolean(datastoreResolver, mappingContext),
+ CountFinder.countBy(datastoreResolver, mappingContext),
new ListOrderByFinder(datastoreResolver, mappingContext)] as List<FinderMethod>
}
}
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 b30438a4a4..68bff58c54 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
@@ -28,15 +28,11 @@ import org.springframework.transaction.PlatformTransactionManager
import org.springframework.util.ClassUtils
import grails.gorm.MultiTenant
-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.CountFinder
import org.grails.datastore.gorm.finders.FinderMethod
-import org.grails.datastore.gorm.finders.FindOrCreateByFinder
-import org.grails.datastore.gorm.finders.FindOrSaveByFinder
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.mapping.core.Datastore
import org.grails.datastore.mapping.core.connections.ConnectionSource
import org.grails.datastore.mapping.core.connections.ConnectionSourceSettings
@@ -423,13 +419,13 @@ class GormEnhancer implements Closeable {
@Deprecated
@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 8f9e42a4ff..0000000000
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/AbstractFindByFinder.java
+++ /dev/null
@@ -1,69 +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.gorm.DatastoreResolver;
-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, String[] operators, DatastoreResolver datastoreResolver, MappingContext mappingContext) {
- super(pattern, operators, datastoreResolver, mappingContext);
- }
-
- protected AbstractFindByFinder(Pattern pattern, MappingContext mappingContext) {
- super(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.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/AbstractFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/AbstractFinder.java
deleted file mode 100644
index 3c44da7286..0000000000
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/AbstractFinder.java
+++ /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.datastore.gorm.finders;
-
-import groovy.lang.Closure;
-
-import grails.gorm.CriteriaBuilder;
-import org.grails.datastore.gorm.DatastoreResolver;
-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
- */
-@SuppressWarnings("rawtypes")
-public abstract class AbstractFinder implements FinderMethod {
-
- protected DatastoreResolver datastoreResolver;
- protected Datastore datastore;
-
- public AbstractFinder(final Datastore datastore) {
- this.datastore = datastore;
- }
-
- public AbstractFinder(final DatastoreResolver datastoreResolver) {
- this.datastoreResolver = datastoreResolver;
- }
-
- protected Datastore getDatastore() {
- if (datastoreResolver != null) {
- return datastoreResolver.resolve();
- }
- return datastore;
- }
-
- protected <T> T execute(final SessionCallback<T> callback) {
- Datastore ds = getDatastore();
- if (ds != null) {
- return DatastoreUtils.execute(ds, callback);
- }
- else {
- throw new IllegalStateException("Cannot execute session query with null datastore");
- }
- }
-
- protected void execute(final VoidSessionCallback callback) {
- Datastore ds = getDatastore();
- if (ds != null) {
- DatastoreUtils.execute(ds, callback);
- }
- else {
- throw new IllegalStateException("Cannot execute session query with null datastore");
- }
-
- }
-
- 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/CountByFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/CountByFinder.java
deleted file mode 100644
index 3b835b1042..0000000000
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/CountByFinder.java
+++ /dev/null
@@ -1,91 +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 grails.gorm.DetachedCriteria;
-import org.grails.datastore.gorm.DatastoreResolver;
-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 countBy* queries.
- *
- * @author Graeme Rocher
- */
-public class CountByFinder extends DynamicFinder implements QueryBuildingFinder {
-
- private static final String METHOD_PATTERN = "(countBy)([A-Z]\\w*)";
- protected static final String[] OPERATORS = { "And", "Or" };
-
- public CountByFinder(final Datastore datastore) {
- super(Pattern.compile(METHOD_PATTERN), OPERATORS, datastore);
- }
-
- public CountByFinder(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
- super(Pattern.compile(METHOD_PATTERN), OPERATORS, datastoreResolver, mappingContext);
- }
-
- public CountByFinder(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 q = buildQuery(invocation, session);
- q.projections().count();
- return q.singleResult();
- }
- });
- }
-
- @Override
- public Query buildQuery(DynamicFinderInvocation invocation, Session session) {
- final Class clazz = invocation.getJavaClass();
- Query q = session.createQuery(clazz);
- applyDetachedCriteria(q, invocation.getDetachedCriteria());
-
- final String operator = invocation.getOperator();
- if (operator != null && operator.equals("Or")) {
- Query.Junction disjunction = q.disjunction();
- for (MethodExpression expression : invocation.getExpressions()) {
- disjunction.add(expression.createCriterion());
- }
- }
- else {
- for (MethodExpression expression : invocation.getExpressions()) {
- q.add(expression.createCriterion());
- }
- }
- return q;
- }
-
- protected void applyDetachedCriteria(Query q, DetachedCriteria detachedCriteria) {
- if (detachedCriteria != null) {
- DynamicFinder.applyDetachedCriteria(q, detachedCriteria);
- }
- }
-
-}
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..f376286717
--- /dev/null
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/CountFinder.java
@@ -0,0 +1,157 @@
+/*
+ * 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.gorm.DatastoreResolver;
+import org.grails.datastore.mapping.core.Datastore;
+import org.grails.datastore.mapping.core.Session;
+import org.grails.datastore.mapping.model.MappingContext;
+import org.grails.datastore.mapping.query.Query;
+
+/**
+ * Implements {@code countBy*}. Kept as its own dedicated class rather than a variant of {@link
+ * SingleResultFinder}: its {@link #buildQuery} does not reuse {@link DynamicFinder#getJunction} at
+ * all - for the {@code Or} operator it extends the query's own top-level junction directly via
+ * {@code Query.disjunction()}/{@code Query.add(Junction, Criterion)}, rather than building a
+ * standalone {@code Disjunction} added as one nested criterion the way every other finder's
+ * shared {@code buildQuery} does. This divergence is preserved exactly as it existed before this
+ * class existed, not unified with the shared grammar's junction-building.
+ */
+public class CountFinder implements FinderMethod, QueryBuildingFinder {
+
+ private static final Pattern METHOD_PATTERN = Pattern.compile("(countBy)(\\w+)");
+ private static final String[] OPERATORS = {"And", "Or"};
+ private static final String OPERATOR_OR = "Or";
+
+ private final DatastoreResolver datastoreResolver;
+ private final DynamicFinder grammar;
+
+ private CountFinder(DatastoreResolver datastoreResolver, DynamicFinder grammar) {
+ this.datastoreResolver = datastoreResolver;
+ this.grammar = grammar;
+ }
+
+ public static CountFinder countBy(Datastore datastore) {
+ return countBy(FinderSupport.resolverFor(datastore), datastore.getMappingContext());
+ }
+
+ public static CountFinder countBy(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
+ return new CountFinder(datastoreResolver, new DynamicFinder(METHOD_PATTERN, OPERATORS, mappingContext, false));
+ }
+
+ public static CountFinder countBy(MappingContext mappingContext) {
+ return countBy(null, mappingContext);
+ }
+
+ @Override
+ public void setPattern(String pattern) {
+ grammar.setPattern(pattern);
+ }
+
+ @Override
+ public boolean isMethodMatch(String methodName) {
+ return grammar.isMethodMatch(methodName);
+ }
+
+ @Override
+ @SuppressWarnings("rawtypes")
+ public Object invoke(Class clazz, String methodName, Object[] arguments) {
+ return invoke(clazz, methodName, (Closure) null, arguments);
+ }
+
+ @Override
+ @SuppressWarnings("rawtypes")
+ public Object invoke(Class clazz, String methodName, Closure additionalCriteria, Object[] arguments) {
+ DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, additionalCriteria, arguments);
+ return doInvoke(invocation);
+ }
+
+ /**
+ * Not part of {@link FinderMethod} - called reflectively (dynamic Groovy dispatch) by {@link
+ * org.grails.datastore.gorm.query.criteria.AbstractDetachedCriteria#methodMissing}. See {@link
+ * SingleResultFinder#invoke(Class, String, DetachedCriteria, Object[])} for the full rationale.
+ *
+ * @param clazz The persistent class
+ * @param methodName The method name
+ * @param detachedCriteria The detached criteria to merge into the built query
+ * @param arguments The method call arguments
+ * @return The result of the method call
+ */
+ @SuppressWarnings("rawtypes")
+ public Object invoke(Class clazz, String methodName, DetachedCriteria detachedCriteria, Object[] arguments) {
+ DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, null, arguments);
+ if (detachedCriteria != null) {
+ invocation.setDetachedCriteria(detachedCriteria);
+ }
+ return doInvoke(invocation);
+ }
+
+ private Object doInvoke(final DynamicFinderInvocation invocation) {
+ return FinderSupport.execute(datastoreResolver, session -> {
+ Query query = buildQuery(invocation, session);
+ return query.singleResult();
+ });
+ }
+
+ @Override
+ public Query buildQuery(DynamicFinderInvocation invocation, Session session) {
+ final Class<?> clazz = invocation.getJavaClass();
+ Query query = session.createQuery(clazz);
+ return applyCriteriaAndCount(invocation, clazz, query);
+ }
+
+ /**
+ * Applies this finder's independent (non-{@link DynamicFinder#getJunction}) And/Or criteria
+ * handling and the {@code count()} projection to an already-created query. Exposed as a public
+ * static helper so other query-building strategies (e.g. {@code grails-datamapping-rx}'s
+ * count finder, which builds its query outside a {@link Session}) can reuse this logic rather
+ * than duplicating it.
+ *
+ * @param invocation The invocation
+ * @param clazz The persistent class
+ * @param query The already-created query to apply criteria to
+ * @return The same query, for chaining
+ */
+ public static Query applyCriteriaAndCount(DynamicFinderInvocation invocation, Class<?> clazz, Query query) {
+ DynamicFinder.applyAdditionalCriteria(query, invocation.getCriteria());
+ DynamicFinder.applyDetachedCriteria(query, invocation.getDetachedCriteria());
+ DynamicFinder.configureQueryWithArguments(clazz, query, invocation.getArguments());
+
+ String operatorInUse = invocation.getOperator();
+ if (OPERATOR_OR.equals(operatorInUse)) {
+ Query.Junction disjunction = query.disjunction();
+ for (MethodExpression expression : invocation.getExpressions()) {
+ query.add(disjunction, expression.createCriterion());
+ }
+ }
+ else {
+ for (MethodExpression expression : invocation.getExpressions()) {
+ query.add(expression.createCriterion());
+ }
+ }
+
+ query.projections().count();
+ return query;
+ }
+}
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinder.java
index 1375fb37be..2b48a0cdbf 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinder.java
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinder.java
@@ -20,6 +20,8 @@ import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@@ -35,8 +37,7 @@ import org.springframework.core.convert.ConversionException;
import org.springframework.core.convert.ConversionService;
import org.springframework.util.StringUtils;
-import grails.gorm.DetachedCriteria;
-import org.grails.datastore.gorm.DatastoreResolver;
+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;
@@ -56,7 +57,6 @@ import org.grails.datastore.gorm.finders.MethodExpression.NotInList;
import org.grails.datastore.gorm.finders.MethodExpression.Rlike;
import org.grails.datastore.gorm.query.criteria.AbstractCriteriaBuilder;
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;
@@ -70,13 +70,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 {
+@SuppressWarnings({"rawtypes", "unchecked", "ResultOfMethodCallIgnored"})
+public class DynamicFinder implements FinderGrammar {
public static final String ARGUMENT_FETCH_SIZE = "fetchSize";
public static final String ARGUMENT_TIMEOUT = "timeout";
@@ -97,8 +100,8 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
private static final String OPERATOR_OR = "Or";
private static final String OPERATOR_AND = "And";
private static final String[] DEFAULT_OPERATORS = {OPERATOR_AND, OPERATOR_OR};
- private Pattern[] operatorPatterns;
- private String[] operators;
+ private final Pattern[] operatorPatterns;
+ private final String[] operators;
private static Pattern methodExpressinPattern;
@@ -108,8 +111,9 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
private static final String NOT = "Not";
private static final String INVALID_SORT_PROPERTY = "Invalid sort property";
private static final String INVALID_SORT_DIRECTION = "Invalid sort direction";
- private static final Map<String, Constructor> methodExpressions = new LinkedHashMap<String, Constructor>();
+ private static final Map<String, Constructor> methodExpressions = new LinkedHashMap<>();
protected final MappingContext mappingContext;
+ private final boolean firstExpressionIsRequiredBoolean;
static {
defaultOperationPatterns = new Pattern[2];
@@ -129,39 +133,27 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
for (Class c : classes) {
methodExpressions.put(c.getSimpleName(), c.getConstructor(constructorParamTypes));
}
- } catch (SecurityException e) {
- // ignore
- } catch (NoSuchMethodException e) {
+ } catch (SecurityException | NoSuchMethodException e) {
// ignore
}
resetMethodExpressionPattern();
}
- protected DynamicFinder(final Pattern pattern, final String[] operators, final DatastoreResolver datastoreResolver, MappingContext mappingContext) {
- super(datastoreResolver);
- this.mappingContext = mappingContext;
- this.pattern = pattern;
- this.operators = operators;
- this.operatorPatterns = new Pattern[operators.length];
- populateOperators(operators);
- }
-
- 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((Datastore) 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);
}
@@ -176,11 +168,7 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
methodExpressions.put(methodExpression.getSimpleName(), methodExpression.getConstructor(
Class.class, String.class));
resetMethodExpressionPattern();
- } catch (SecurityException e) {
- throw new IllegalArgumentException("Class [" + methodExpression +
- "] does not provide a constructor that takes parameters of type Class and String: " +
- e.getMessage(), e);
- } catch (NoSuchMethodException e) {
+ } catch (SecurityException | NoSuchMethodException e) {
throw new IllegalArgumentException("Class [" + methodExpression +
"] does not provide a constructor that takes parameters of type Class and String: " +
e.getMessage(), e);
@@ -198,54 +186,52 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
public static MatchSpec buildMatchSpec(String prefix, String methodName, int parameterCount) {
String methodPattern = "(" + prefix + ")([A-Z]\\w*)";
Matcher matcher = Pattern.compile(methodPattern).matcher(methodName);
- if (matcher.find()) {
+ if (matcher.find() && matcher.groupCount() == 2) {
int totalRequiredArguments = 0;
List<MethodExpression> expressions = new ArrayList<>();
- if (matcher.groupCount() == 2) {
- String querySequence = matcher.group(2);
- String operatorInUse;
- boolean containsOperator = false;
- String[] queryParameters;
- for (int i = 0; i < DEFAULT_OPERATORS.length; i++) {
- Matcher currentMatcher = defaultOperationPatterns[i].matcher(querySequence);
- if (currentMatcher.find()) {
- containsOperator = true;
- operatorInUse = DEFAULT_OPERATORS[i];
-
- queryParameters = querySequence.split(operatorInUse);
- // loop through query parameters and create expressions
- // calculating the number of arguments required for the expression
- for (String queryParameter : queryParameters) {
- MethodExpression currentExpression = findMethodExpression(queryParameter);
- // add to list of expressions
- totalRequiredArguments += currentExpression.argumentsRequired;
- expressions.add(currentExpression);
- }
- break;
- }
- }
-
- // otherwise there is only one expression
- if (!containsOperator && querySequence != null) {
- MethodExpression solo = findMethodExpression(querySequence);
+ String querySequence = matcher.group(2);
+ String operatorInUse;
+ boolean containsOperator = false;
+ String[] queryParameters;
+ for (int i = 0; i < DEFAULT_OPERATORS.length; i++) {
+ Matcher currentMatcher = defaultOperationPatterns[i].matcher(querySequence);
+ if (currentMatcher.find()) {
+ containsOperator = true;
+ operatorInUse = DEFAULT_OPERATORS[i];
- final int requiredArguments = solo.getArgumentsRequired();
- if (requiredArguments > parameterCount) {
- return null;
+ queryParameters = querySequence.split(operatorInUse);
+ // loop through query parameters and create expressions
+ // calculating the number of arguments required for the expression
+ for (String queryParameter : queryParameters) {
+ MethodExpression currentExpression = findMethodExpression(queryParameter);
+ // add to list of expressions
+ totalRequiredArguments += currentExpression.argumentsRequired;
+ expressions.add(currentExpression);
}
-
- totalRequiredArguments += requiredArguments;
- expressions.add(solo);
+ break;
}
+ }
+
+ // otherwise there is only one expression
+ if (!containsOperator) {
+ MethodExpression solo = findMethodExpression(querySequence);
- // if the total of all the arguments necessary does not equal the number of arguments
- // return null
- if (totalRequiredArguments > parameterCount) {
+ final int requiredArguments = solo.getArgumentsRequired();
+ if (requiredArguments > parameterCount) {
return null;
}
- else {
- return new MatchSpec(methodName, prefix, querySequence, totalRequiredArguments, expressions);
- }
+
+ totalRequiredArguments += requiredArguments;
+ expressions.add(solo);
+ }
+
+ // if the total of all the arguments necessary does not equal the number of arguments
+ // return null
+ if (totalRequiredArguments > parameterCount) {
+ return null;
+ }
+ else {
+ return new MatchSpec(methodName, prefix, querySequence, totalRequiredArguments, expressions);
}
}
return null;
@@ -265,23 +251,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) {
@@ -349,7 +324,7 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
for (int k = 0; k < requiredArgs; k++, argumentCursor++) {
currentArguments[k] = arguments[argumentCursor];
}
- currentExpression = getInitializedExpression(currentExpression, currentArguments);
+ getInitializedExpression(currentExpression, currentArguments);
PersistentEntity persistentEntity = mappingContext.getPersistentEntity(clazz.getName());
try {
@@ -376,9 +351,7 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
}
totalRequiredArguments += requiredArguments;
- Object[] soloArgs = new Object[requiredArguments];
- System.arraycopy(arguments, 0, soloArgs, 0, requiredArguments);
- solo = getInitializedExpression(solo, arguments);
+ getInitializedExpression(solo, arguments);
PersistentEntity persistentEntity = mappingContext.getPersistentEntity(clazz.getName());
try {
solo.convertArguments(persistentEntity);
@@ -408,10 +381,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
@@ -423,29 +392,10 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
}
String orderParam = (String) argMap.get(ARGUMENT_ORDER);
- Object fetchObj = argMap.get(ARGUMENT_FETCH);
- if (fetchObj instanceof Map) {
- Map fetch = (Map) fetchObj;
- for (Object o : fetch.keySet()) {
- String associationName = (String) o;
- Object fetchValue = fetch.get(associationName);
- if (fetchValue instanceof FetchType) {
- FetchType fetchType = (FetchType) fetchValue;
- handleFetchType(query, associationName, fetchType);
- }
- else if (fetchValue instanceof JoinType) {
- JoinType joinType = (JoinType) fetchValue;
- query.join(associationName, joinType);
- } else {
- FetchType fetchType = getFetchMode(fetchValue);
- handleFetchType(query, associationName, fetchType);
- }
- }
- }
-
- if (argMap.containsKey(ARGUMENT_CACHE)) {
- query.cache(ClassUtils.getBooleanFromMap(ARGUMENT_CACHE, argMap));
- }
+ applyFetchAndCacheArguments(argMap,
+ (associationName, fetchType) -> handleFetchType(query, associationName, fetchType),
+ query::join,
+ query::cache);
Object sortObject = argMap.get(ARGUMENT_SORT);
PersistentEntity entity = resolvePersistentEntity(query);
@@ -466,22 +416,19 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
}
boolean ignoreCase = !argMap.containsKey(ARGUMENT_IGNORE_CASE) || ClassUtils.getBooleanFromMap(ARGUMENT_IGNORE_CASE, argMap);
- if (sortObject != null) {
- if (sortObject instanceof CharSequence) {
- final String sort = sortObject.toString();
+ if (sortObject instanceof CharSequence) {
+ final String sort = sortObject.toString();
+ validateSortProperty(entity, sort);
+ query.order(buildOrder(sort, orderParam, ignoreCase));
+ }
+ else if (sortObject instanceof Map sortMap) {
+ // each entry carries its own direction, as in applySortForMap for the Query overload
+ for (Object key : sortMap.keySet()) {
+ Object value = sortMap.get(key);
+ String sort = key.toString();
+ String direction = value != null ? value.toString() : ORDER_ASC;
validateSortProperty(entity, sort);
- query.order(buildOrder(sort, orderParam, ignoreCase));
- }
- else if (sortObject instanceof Map) {
- // each entry carries its own direction, as in applySortForMap for the Query overload
- Map sortMap = (Map) sortObject;
- for (Object key : sortMap.keySet()) {
- Object value = sortMap.get(key);
- String sort = key.toString();
- String direction = value != null ? value.toString() : ORDER_ASC;
- validateSortProperty(entity, sort);
- query.order(buildOrder(sort, direction, ignoreCase));
- }
+ query.order(buildOrder(sort, direction, ignoreCase));
}
}
@@ -492,11 +439,12 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
/**
* Populates arguments for the given query form the given map
+ * @param targetClass Unused - kept for call-site/API compatibility with existing callers in
+ * grails-data-hibernate5/7 and grails-data-mongodb
* @param query The query
* @param argMap The query arguments
*/
- //TODO: Change {code}Class<? extends Object>{code} to {class<?>} once GROOVY-9460 is fixed.
- public static void populateArgumentsForCriteria(Class<? extends Object> targetClass, Query query, Map argMap) {
+ public static void populateArgumentsForCriteria(@SuppressWarnings("unused") Class<?> targetClass, Query query, Map argMap) {
if (argMap == null) {
return;
}
@@ -512,29 +460,10 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
}
String orderParam = (String) argMap.get(ARGUMENT_ORDER);
- Object fetchObj = argMap.get(ARGUMENT_FETCH);
- if (fetchObj instanceof Map) {
- Map fetch = (Map) fetchObj;
- for (Object o : fetch.keySet()) {
- String associationName = (String) o;
- Object fetchValue = fetch.get(associationName);
- if (fetchValue instanceof FetchType) {
- FetchType fetchType = (FetchType) fetchValue;
- handleFetchType(query, associationName, fetchType);
- }
- else if (fetchValue instanceof JoinType) {
- JoinType joinType = (JoinType) fetchValue;
- query.join(associationName, joinType);
- } else {
- FetchType fetchType = getFetchMode(fetchValue);
- handleFetchType(query, associationName, fetchType);
- }
- }
- }
-
- if (argMap.containsKey(ARGUMENT_CACHE)) {
- query.cache(ClassUtils.getBooleanFromMap(ARGUMENT_CACHE, argMap));
- }
+ applyFetchAndCacheArguments(argMap,
+ (associationName, fetchType) -> handleFetchType(query, associationName, fetchType),
+ query::join,
+ query::cache);
if (argMap.containsKey(ARGUMENT_LOCK)) {
query.lock(ClassUtils.getBooleanFromMap(ARGUMENT_LOCK, argMap));
}
@@ -568,15 +497,12 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
}
boolean ignoreCase = !argMap.containsKey(ARGUMENT_IGNORE_CASE) || ClassUtils.getBooleanFromMap(ARGUMENT_IGNORE_CASE, argMap);
- if (sortObject != null) {
- if (sortObject instanceof CharSequence) {
- final String sort = sortObject.toString();
- addSimpleSort(query, sort, orderParam, ignoreCase);
- }
- else if (sortObject instanceof Map) {
- Map sortMap = (Map) sortObject;
- applySortForMap(query, sortMap, ignoreCase);
- }
+ if (sortObject instanceof CharSequence) {
+ final String sort = sortObject.toString();
+ addSimpleSort(query, sort, orderParam, ignoreCase);
+ }
+ else if (sortObject instanceof Map sortMap) {
+ applySortForMap(query, sortMap, ignoreCase);
}
if (query instanceof QueryArgumentsAware) {
@@ -584,6 +510,41 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
}
}
+ /**
+ * Shared by both {@code populateArgumentsForCriteria} overloads: applies the {@code fetch}/
+ * {@code cache} argument-map entries via the caller-supplied handlers, since {@link Query} and
+ * {@link BuildableCriteria} don't share a common supertype exposing {@code join}/{@code select}/
+ * {@code cache}.
+ *
+ * @param argMap The query arguments
+ * @param fetchHandler Applies a resolved {@link FetchType} to an association
+ * @param joinHandler Applies an explicit {@link JoinType} to an association
+ * @param cacheSetter Applies the {@code cache} argument, when present
+ */
+ private static void applyFetchAndCacheArguments(Map argMap, BiConsumer<String, FetchType> fetchHandler,
+ BiConsumer<String, JoinType> joinHandler, Consumer<Boolean> cacheSetter) {
+ Object fetchObj = argMap.get(ARGUMENT_FETCH);
+ if (fetchObj instanceof Map fetch) {
+ for (Object o : fetch.keySet()) {
+ String associationName = (String) o;
+ Object fetchValue = fetch.get(associationName);
+ if (fetchValue instanceof FetchType fetchType) {
+ fetchHandler.accept(associationName, fetchType);
+ }
+ else if (fetchValue instanceof JoinType joinType) {
+ joinHandler.accept(associationName, joinType);
+ }
+ else {
+ fetchHandler.accept(associationName, getFetchMode(fetchValue));
+ }
+ }
+ }
+
+ if (argMap.containsKey(ARGUMENT_CACHE)) {
+ cacheSetter.accept(ClassUtils.getBooleanFromMap(ARGUMENT_CACHE, argMap));
+ }
+ }
+
/**
* Applies sorting logic to the given query from the given map
* @param query The query
@@ -611,9 +572,6 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
if (name.equalsIgnoreCase(FetchType.EAGER.toString()) || name.equalsIgnoreCase("join")) {
return FetchType.EAGER;
}
- if (name.equalsIgnoreCase(FetchType.LAZY.toString()) || name.equalsIgnoreCase("select")) {
- return FetchType.LAZY;
- }
return FetchType.LAZY;
}
@@ -657,7 +615,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) {
@@ -752,7 +724,7 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
}
private static void resetMethodExpressionPattern() {
- String expressionPattern = DefaultGroovyMethods.join((Iterable) methodExpressions.keySet(), "|");
+ String expressionPattern = DefaultGroovyMethods.join(methodExpressions.keySet(), "|");
methodExpressinPattern = Pattern.compile("\\p{Upper}[\\p{Lower}\\d]+(" + expressionPattern + ")");
}
@@ -881,14 +853,13 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
}
}
- protected void configureQueryWithArguments(Class clazz, Query query, Object[] arguments) {
- if (arguments.length == 0 || !(arguments[0] instanceof Map)) {
- populateArgumentsForCriteria(clazz, query, Collections.emptyMap());
+ public static void configureQueryWithArguments(Class clazz, Query query, Object[] arguments) {
+ if (arguments.length > 0 && arguments[0] instanceof Map<?, ?> argMap) {
+ populateArgumentsForCriteria(clazz, query, argMap);
return;
}
- Map<?, ?> argMap = (Map<?, ?>) arguments[0];
- populateArgumentsForCriteria(clazz, query, argMap);
+ populateArgumentsForCriteria(clazz, query, Collections.emptyMap());
}
private static String calcPropertyName(String queryParameter, String clause) {
@@ -905,34 +876,32 @@ public abstract class DynamicFinder extends AbstractFinder implements QueryBuild
}
/**
- * Initializes the arguments of the specified expression with the specified arguments. If the
- * expression is an Equal expression and the argument is null then a new expression is created
- * and returned of type IsNull.
+ * Initializes the arguments of the specified expression with the specified arguments.
*
* @param expression expression to initialize
* @param arguments arguments to the expression
- * @return the initialized expression
*/
- private MethodExpression getInitializedExpression(MethodExpression expression, Object[] arguments) {
+ private void getInitializedExpression(MethodExpression expression, Object[] arguments) {
// if (expression instanceof Equal && arguments.length == 1 && arguments[0] == null) { // logic moved directly to Equal.createCriterion
// expression = new IsNull(expression.propertyName);
// } else {
expression.setArguments(arguments);
// }
- return expression;
}
+ @Override
public boolean firstExpressionIsRequiredBoolean() {
- return false;
+ return firstExpressionIsRequiredBoolean;
}
- protected Query.Junction getJunction(DynamicFinderInvocation invocation) {
+ @Override
+ public Query.Junction getJunction(DynamicFinderInvocation invocation) {
var criteria = invocation.getExpressions().stream().map(MethodExpression::createCriterion).collect(Collectors.toList());
Query.Junction junction;
- if (FindAllByFinder.OPERATOR_OR.equals(invocation.getOperator())) {
+ if (OPERATOR_OR.equals(invocation.getOperator())) {
if (firstExpressionIsRequiredBoolean()) {
junction = new Query.Conjunction();
- junction.add(criteria.remove(0));
+ junction.add(criteria.removeFirst());
var disjunction = new Query.Disjunction();
criteria.forEach(disjunction::add);
junction.add(disjunction);
@@ -949,6 +918,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/DynamicFinderInvocation.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinderInvocation.java
index f086faf718..daf4a34055 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinderInvocation.java
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/DynamicFinderInvocation.java
@@ -33,12 +33,12 @@ import grails.gorm.DetachedCriteria;
@SuppressWarnings("rawtypes")
public class DynamicFinderInvocation {
- private Class javaClass;
- private String methodName;
- private Object[] arguments;
- private List<MethodExpression> expressions;
- private Closure criteria;
- private String operator;
+ private final Class javaClass;
+ private final String methodName;
+ private final Object[] arguments;
+ private final List<MethodExpression> expressions;
+ private final Closure criteria;
+ private final String operator;
private DetachedCriteria detachedCriteria;
public DynamicFinderInvocation(Class javaClass, String methodName, Object[] arguments,
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindAllByBooleanFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindAllByBooleanFinder.java
deleted file mode 100644
index 3bf787abd0..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.gorm.DatastoreResolver;
-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 initial boolean property and additional criteria on other properties.
- *
- * eg.
- * Book.findAllActiveByTitle("The Stand")
- *
- * @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(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
- super(datastoreResolver, mappingContext);
- 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 8027851bc3..0000000000
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindAllByFinder.java
+++ /dev/null
@@ -1,71 +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.gorm.DatastoreResolver;
-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(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
- super(Pattern.compile(METHOD_PATTERN), OPERATORS, datastoreResolver, mappingContext);
- }
-
- public FindAllByFinder(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 query) {
- return query.list();
- }
-
- protected void adjustQuery(Query query) {
- // do nothing
- }
-
-}
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 c4e24a7178..0000000000
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindByBooleanFinder.java
+++ /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.datastore.gorm.finders;
-
-import org.grails.datastore.gorm.DatastoreResolver;
-import org.grails.datastore.mapping.core.Datastore;
-import org.grails.datastore.mapping.model.MappingContext;
-
-/**
- * @author Graeme Rocher
- * @since 1.0
- */
-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(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
- super(datastoreResolver, mappingContext);
- 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 28f7815b39..0000000000
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindByFinder.java
+++ /dev/null
@@ -1,45 +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.gorm.DatastoreResolver;
-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(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
- super(Pattern.compile(METHOD_PATTERN), OPERATORS, datastoreResolver, mappingContext);
- }
-
- 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 3fda6c9e9d..0000000000
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindOrCreateByFinder.java
+++ /dev/null
@@ -1,117 +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.gorm.DatastoreResolver;
-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 String methodPattern, DatastoreResolver datastoreResolver, MappingContext mappingContext) {
- super(Pattern.compile(methodPattern), OPERATORS, datastoreResolver, mappingContext);
- }
-
- public FindOrCreateByFinder(final Datastore datastore) {
- this(METHOD_PATTERN, datastore);
- }
-
- public FindOrCreateByFinder(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
- this(METHOD_PATTERN, datastoreResolver, mappingContext);
- }
-
- 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 d31ce7140f..0000000000
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FindOrSaveByFinder.java
+++ /dev/null
@@ -1,53 +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.gorm.DatastoreResolver;
-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 String methodPattern, final Datastore datastore) {
- super(methodPattern, datastore);
- }
-
- public FindOrSaveByFinder(final String methodPattern, DatastoreResolver datastoreResolver, MappingContext mappingContext) {
- super(methodPattern, datastoreResolver, mappingContext);
- }
-
- public FindOrSaveByFinder(Datastore datastore) {
- super(METHOD_PATTERN, datastore);
- }
-
- public FindOrSaveByFinder(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
- super(METHOD_PATTERN, datastoreResolver, mappingContext);
- }
-
- public FindOrSaveByFinder(MappingContext mappingContext) {
- super(METHOD_PATTERN, mappingContext);
- }
-
- @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..ccec871bdc
--- /dev/null
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FinderGrammar.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.grails.datastore.gorm.finders;
+
+import groovy.lang.Closure;
+
+import org.grails.datastore.mapping.query.Query;
+
+/**
+ * The composition seam finder implementations use to parse a dynamic finder method name into a
+ * {@link DynamicFinderInvocation} and build the resulting {@link Query.Junction}, without
+ * subclassing {@link DynamicFinder}. Implemented by {@link DynamicFinder} itself; finder
+ * implementations (in this package and in {@code grails-datamapping-rx}) hold a {@code DynamicFinder}
+ * instance as a composed collaborator rather than extending it.
+ */
+@SuppressWarnings("rawtypes")
+public interface FinderGrammar {
+
+ /**
+ * @param methodName The method name
+ * @return True if the given method name matches this grammar's pattern
+ */
+ boolean isMethodMatch(String methodName);
+
+ /**
+ * Parses a dynamic finder method invocation into its constituent {@link MethodExpression}s.
+ *
+ * @param clazz The persistent class the finder targets
+ * @param methodName The full method name
+ * @param additionalCriteria An optional additional-criteria closure
+ * @param arguments The method call arguments
+ * @return The parsed invocation
+ */
+ DynamicFinderInvocation createFinderInvocation(Class clazz, String methodName, Closure additionalCriteria, Object[] arguments);
+
+ /**
+ * Builds the AND/OR junction of criteria for the given invocation.
+ *
+ * @param invocation The invocation
+ * @return The junction
+ */
+ Query.Junction getJunction(DynamicFinderInvocation invocation);
+
+ /**
+ * @return Whether the first parsed expression is a required boolean clause (the
+ * "find<booleanProperty>By*"/"findAll<booleanProperty>By*" finder forms)
+ */
+ boolean firstExpressionIsRequiredBoolean();
+}
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FinderSupport.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FinderSupport.java
new file mode 100644
index 0000000000..dbbaa1989e
--- /dev/null
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/FinderSupport.java
@@ -0,0 +1,82 @@
+/*
+ * 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.gorm.DatastoreResolver;
+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;
+
+/**
+ * Shared session-execution helper for the synchronous finder implementations in this package.
+ * Not a base class - each finder holds a {@link DatastoreResolver} field and calls these
+ * statically, rather than inheriting an {@code execute} method. The datastore is resolved on
+ * every call, so a finder built from a lazy resolver follows whatever datastore the resolver
+ * returns at invocation time.
+ */
+public final class FinderSupport {
+
+ private static final String NULL_DATASTORE_MESSAGE = "Cannot execute session query with null datastore";
+
+ private FinderSupport() {
+ }
+
+ /**
+ * Wraps a fixed datastore as a resolver.
+ *
+ * @param datastore The datastore, or null for stateless mode
+ * @return A resolver that always returns the given datastore, or null when the datastore is null
+ */
+ public static DatastoreResolver resolverFor(final Datastore datastore) {
+ return datastore == null ? null : () -> datastore;
+ }
+
+ /**
+ * Executes the given callback within a session bound to the datastore the resolver returns.
+ *
+ * @param datastoreResolver The datastore resolver, or null for stateless mode
+ * @param callback The callback
+ * @param <T> The callback's result type
+ * @return The callback's result
+ * @throws IllegalStateException if no datastore can be resolved (stateless mode)
+ */
+ public static <T> T execute(final DatastoreResolver datastoreResolver, final SessionCallback<T> callback) {
+ return DatastoreUtils.execute(resolve(datastoreResolver), callback);
+ }
+
+ /**
+ * Executes the given void callback within a session bound to the datastore the resolver returns.
+ *
+ * @param datastoreResolver The datastore resolver, or null for stateless mode
+ * @param callback The callback
+ * @throws IllegalStateException if no datastore can be resolved (stateless mode)
+ */
+ public static void execute(final DatastoreResolver datastoreResolver, final VoidSessionCallback callback) {
+ DatastoreUtils.execute(resolve(datastoreResolver), callback);
+ }
+
+ private static Datastore resolve(final DatastoreResolver datastoreResolver) {
+ Datastore datastore = datastoreResolver == null ? null : datastoreResolver.resolve();
+ if (datastore == null) {
+ throw new IllegalStateException(NULL_DATASTORE_MESSAGE);
+ }
+ return datastore;
+ }
+}
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 47d937953b..53fb0301f0 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
@@ -27,7 +27,6 @@ import groovy.lang.Closure;
import org.grails.datastore.gorm.DatastoreResolver;
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;
@@ -41,74 +40,86 @@ import org.grails.datastore.mapping.reflect.NameUtils;
* Book.listOrderByTitle(max:10)
* Book.listOrderByTitleAndAuthor(max:10)
*
+ * <p>Never shared {@link DynamicFinder}'s grammar (no operator-suffix parsing - just trailing
+ * property names joined by {@code And}), 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 static final String PROPERTY_SEPARATOR = "And";
+ private final DatastoreResolver datastoreResolver;
private Pattern pattern = METHOD_PATTERN;
- public ListOrderByFinder(final Datastore datastore) {
- super(datastore);
+ public ListOrderByFinder(Datastore datastore) {
+ this(FinderSupport.resolverFor(datastore), null);
}
- public ListOrderByFinder(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
- super(datastoreResolver);
+ /**
+ * @param datastoreResolver Resolves the datastore at invocation time
+ * @param mappingContext Unused - kept so this finder is registered the same way as the
+ * grammar-based finders, which need the mapping context to convert arguments
+ */
+ public ListOrderByFinder(DatastoreResolver datastoreResolver, @SuppressWarnings("unused") MappingContext mappingContext) {
+ this.datastoreResolver = datastoreResolver;
}
+ @Override
public void setPattern(String pattern) {
this.pattern = Pattern.compile(pattern);
}
- public boolean isMethodMatch(String methodName) {
- return pattern.matcher(methodName).find();
- }
-
@Override
+ @SuppressWarnings("rawtypes")
public Object invoke(final Class clazz, final String methodName, final Object[] arguments) {
return invoke(clazz, methodName, null, arguments);
}
@Override
+ @SuppressWarnings({"rawtypes", "unchecked", "ResultOfMethodCallIgnored"})
public Object invoke(final Class clazz, final String methodName, final Closure additionalCriteria, final Object[] arguments) {
- return execute(new SessionCallback<Object>() {
- @Override
- public Object doInSession(final Session session) {
- final Matcher matcher = pattern.matcher(methodName);
- matcher.find();
- String parts = matcher.group(2);
-
- final Query q = session.createQuery(clazz);
- String[] propertyNames = parts.split("And");
-
- // Resolve the sort direction BEFORE applying any order. Applying asc first and then
- // trying to clear/replace it leaves the eagerly-applied asc order in the underlying
- // criteria, so an explicit order:'desc' argument was silently ignored. The direction
- // goes through the same normalization as every other entry point, so a value other
- // than asc or desc is rejected here too instead of quietly sorting ascending.
- boolean ascending = true;
- if (arguments.length > 0 && (arguments[0] instanceof Map)) {
- Map args = new LinkedHashMap((Map) arguments[0]);
- final Object order = args.remove(DynamicFinder.ARGUMENT_ORDER);
- final String direction = DynamicFinder.normalizeDirection(order != null ? order.toString() : null);
- ascending = !DynamicFinder.ORDER_DESC.equals(direction);
- DynamicFinder.populateArgumentsForCriteria(clazz, q, args);
- }
-
- for (String propertyName : propertyNames) {
- // Lower-case only the first character: a GORM property's name is the method-name
- // segment with its first letter de-capitalised. JavaBeans-style decapitalize()
- // leaves a name whose first two letters are upper-case unchanged (e.g. "ISize"),
- // which would not match a Hungarian-notation property such as "iSize".
- String property = NameUtils.decapitalizeFirstChar(propertyName);
- q.order(ascending ? Query.Order.asc(property) : Query.Order.desc(property));
- }
-
- if (additionalCriteria != null) {
- applyAdditionalCriteria(q, additionalCriteria);
- }
-
- return q.list();
+
+ Matcher match = pattern.matcher(methodName);
+ match.find();
+
+ final String[] propertyNames = match.group(2).split(PROPERTY_SEPARATOR);
+
+ return FinderSupport.execute(datastoreResolver, (SessionCallback<Object>) session -> {
+ Query q = session.createQuery(clazz);
+
+ // Resolve the sort direction BEFORE applying any order. Applying asc first and then
+ // trying to clear/replace it leaves the eagerly-applied asc order in the underlying
+ // criteria, so an explicit order:'desc' argument was silently ignored. The direction
+ // goes through the same normalization as every other entry point, so a value other
+ // than asc or desc is rejected here too instead of quietly sorting ascending.
+ boolean ascending = true;
+ if (arguments.length > 0 && (arguments[0] instanceof Map)) {
+ final Map args = new LinkedHashMap((Map) arguments[0]);
+ final Object order = args.remove(DynamicFinder.ARGUMENT_ORDER);
+ final String direction = DynamicFinder.normalizeDirection(order != null ? order.toString() : null);
+ ascending = !DynamicFinder.ORDER_DESC.equals(direction);
+ DynamicFinder.populateArgumentsForCriteria(clazz, q, args);
+ }
+
+ for (String propertyName : propertyNames) {
+ // Lower-case only the first character: a GORM property's name is the method-name
+ // segment with its first letter de-capitalised. JavaBeans-style decapitalize()
+ // leaves a name whose first two letters are upper-case unchanged (e.g. "ISize"),
+ // which would not match a Hungarian-notation property such as "iSize".
+ String property = NameUtils.decapitalizeFirstChar(propertyName);
+ q.order(ascending ? Query.Order.asc(property) : Query.Order.desc(property));
}
+
+ DynamicFinder.applyAdditionalCriteria(q, additionalCriteria);
+
+ return q.list();
});
}
+
+ @Override
+ public boolean isMethodMatch(String methodName) {
+ return pattern.matcher(methodName).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..3b045f3491
--- /dev/null
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/ListResultFinder.java
@@ -0,0 +1,134 @@
+/*
+ * 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.gorm.DatastoreResolver;
+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 DatastoreResolver datastoreResolver;
+ private final DynamicFinder grammar;
+
+ private ListResultFinder(DatastoreResolver datastoreResolver, DynamicFinder grammar) {
+ this.datastoreResolver = datastoreResolver;
+ this.grammar = grammar;
+ }
+
+ public static ListResultFinder findAllBy(Datastore datastore) {
+ return findAllBy(FinderSupport.resolverFor(datastore), datastore.getMappingContext());
+ }
+
+ public static ListResultFinder findAllBy(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
+ return new ListResultFinder(datastoreResolver, grammar(FIND_ALL_BY_PATTERN, mappingContext, false));
+ }
+
+ public static ListResultFinder findAllBy(MappingContext mappingContext) {
+ return findAllBy(null, mappingContext);
+ }
+
+ public static ListResultFinder findAllByBoolean(Datastore datastore) {
+ return findAllByBoolean(FinderSupport.resolverFor(datastore), datastore.getMappingContext());
+ }
+
+ public static ListResultFinder findAllByBoolean(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
+ return new ListResultFinder(datastoreResolver, grammar(FIND_ALL_BY_BOOLEAN_PATTERN, mappingContext, true));
+ }
+
+ public static ListResultFinder findAllByBoolean(MappingContext mappingContext) {
+ return findAllByBoolean(null, mappingContext);
+ }
+
+ private static DynamicFinder grammar(String pattern, MappingContext mappingContext, boolean booleanClause) {
+ return new DynamicFinder(Pattern.compile(pattern), OPERATORS, mappingContext, booleanClause);
+ }
+
+ @Override
+ public void setPattern(String pattern) {
+ grammar.setPattern(pattern);
+ }
+
+ @Override
+ public boolean isMethodMatch(String methodName) {
+ return grammar.isMethodMatch(methodName);
+ }
+
+ @Override
+ @SuppressWarnings("rawtypes")
+ public Object invoke(Class clazz, String methodName, Object[] arguments) {
+ return invoke(clazz, methodName, (Closure) null, arguments);
+ }
+
+ @Override
+ @SuppressWarnings("rawtypes")
+ public Object invoke(Class clazz, String methodName, Closure additionalCriteria, Object[] arguments) {
+ DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, additionalCriteria, arguments);
+ return doInvoke(invocation);
+ }
+
+ /**
+ * Not part of {@link FinderMethod} - called reflectively (dynamic Groovy dispatch) by {@link
+ * org.grails.datastore.gorm.query.criteria.AbstractDetachedCriteria#methodMissing}. See {@link
+ * SingleResultFinder#invoke(Class, String, DetachedCriteria, Object[])} for the full rationale.
+ *
+ * @param clazz The persistent class
+ * @param methodName The method name
+ * @param detachedCriteria The detached criteria to merge into the built query
+ * @param arguments The method call arguments
+ * @return The result of the method call
+ */
+ @SuppressWarnings("rawtypes")
+ public Object invoke(Class clazz, String methodName, DetachedCriteria detachedCriteria, Object[] arguments) {
+ DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, null, arguments);
+ if (detachedCriteria != null) {
+ invocation.setDetachedCriteria(detachedCriteria);
+ }
+ return doInvoke(invocation);
+ }
+
+ private Object doInvoke(final DynamicFinderInvocation invocation) {
+ return FinderSupport.execute(datastoreResolver, (SessionCallback<Object>) session -> {
+ Query query = buildQuery(invocation, session);
+ return query.list();
+ });
+ }
+
+ @Override
+ public Query buildQuery(DynamicFinderInvocation invocation, Session session) {
+ return grammar.buildQuery(invocation, session);
+ }
+}
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/MethodExpression.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/MethodExpression.java
index ea1c597f28..c580720aea 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/MethodExpression.java
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/MethodExpression.java
@@ -38,6 +38,13 @@ import org.grails.datastore.mapping.query.Restrictions;
/**
* Method expression used to evaluate a dynamic finder.
+ *
+ * <p>Every concrete subclass below provides both a {@code (Class, String)} and a {@code (String)}
+ * constructor. Neither shape is ever called as a literal {@code new Xxx(...)} expression for every
+ * subclass - {@link DynamicFinder}'s registry invokes the {@code (Class, String)} constructor
+ * reflectively via {@code Constructor.newInstance}, and {@code MethodExpressionSpec} exercises both
+ * shapes reflectively via {@code Class.getConstructor(...).newInstance(...)} - so static usage
+ * analysis can't see either call site even though both are genuinely exercised.
*/
public abstract class MethodExpression {
@@ -52,7 +59,7 @@ public abstract class MethodExpression {
public abstract Query.Criterion createCriterion();
- protected MethodExpression(Class<?> targetClass, String propertyName) {
+ protected MethodExpression(@SuppressWarnings("unused") Class<?> targetClass, String propertyName) {
this.propertyName = propertyName;
}
@@ -67,13 +74,7 @@ public abstract class MethodExpression {
public void convertArguments(PersistentEntity persistentEntity) {
ConversionService conversionService = persistentEntity
.getMappingContext().getConversionService();
- PersistentProperty<?> prop = persistentEntity
- .getPropertyByName(propertyName);
- if (prop == null) {
- if (propertyName.equals(persistentEntity.getIdentity().getName())) {
- prop = persistentEntity.getIdentity();
- }
- }
+ PersistentProperty<?> prop = resolveProperty(persistentEntity, propertyName);
if (prop != null && arguments != null && argumentsRequired > 0) {
Class<?> type = prop.getType();
for (int i = 0; i < argumentsRequired; i++) {
@@ -188,10 +189,12 @@ public abstract class MethodExpression {
}
public static class Ilike extends MethodExpression {
+ @SuppressWarnings("unused")
public Ilike(Class<?> targetClass, String propertyName) {
super(targetClass, propertyName);
}
+ @SuppressWarnings("unused")
public Ilike(String propertyName) {
super(propertyName);
}
@@ -203,10 +206,12 @@ public abstract class MethodExpression {
}
public static class Rlike extends MethodExpression {
+ @SuppressWarnings("unused")
public Rlike(Class<?> targetClass, String propertyName) {
super(targetClass, propertyName);
}
+ @SuppressWarnings("unused")
public Rlike(String propertyName) {
super(propertyName);
}
@@ -218,6 +223,7 @@ public abstract class MethodExpression {
}
public static class NotInList extends MethodExpression {
+ @SuppressWarnings("unused")
public NotInList(Class<?> targetClass, String propertyName) {
super(targetClass, propertyName);
}
@@ -248,16 +254,14 @@ public abstract class MethodExpression {
public void convertArguments(PersistentEntity persistentEntity) {
ConversionService conversionService = persistentEntity
.getMappingContext().getConversionService();
- String propertyName = this.propertyName;
- PersistentProperty<?> prop = persistentEntity
- .getPropertyByName(propertyName);
- Object[] arguments = this.arguments;
- convertArgumentsForProp(persistentEntity, prop, propertyName, arguments, conversionService);
+ PersistentProperty<?> prop = resolveProperty(persistentEntity, propertyName);
+ convertArgumentsForProp(prop, arguments, conversionService);
}
}
public static class InList extends MethodExpression {
+ @SuppressWarnings("unused")
public InList(Class<?> targetClass, String propertyName) {
super(targetClass, propertyName);
}
@@ -286,9 +290,8 @@ public abstract class MethodExpression {
public void convertArguments(PersistentEntity persistentEntity) {
ConversionService conversionService = persistentEntity
.getMappingContext().getConversionService();
- PersistentProperty<?> prop = persistentEntity
- .getPropertyByName(propertyName);
- convertArgumentsForProp(persistentEntity, prop, propertyName, arguments, conversionService);
+ PersistentProperty<?> prop = resolveProperty(persistentEntity, propertyName);
+ convertArgumentsForProp(prop, arguments, conversionService);
}
}
@@ -323,6 +326,7 @@ public abstract class MethodExpression {
public static class InRange extends MethodExpression {
+ @SuppressWarnings("unused")
public InRange(Class<?> targetClass, String propertyName) {
super(targetClass, propertyName);
argumentsRequired = 1;
@@ -357,11 +361,13 @@ public abstract class MethodExpression {
public static class IsNull extends MethodExpression {
+ @SuppressWarnings("unused")
public IsNull(Class<?> targetClass, String propertyName) {
super(targetClass, propertyName);
argumentsRequired = 0;
}
+ @SuppressWarnings("unused")
public IsNull(String propertyName) {
super(propertyName);
argumentsRequired = 0;
@@ -376,11 +382,13 @@ public abstract class MethodExpression {
public static class IsNotNull extends MethodExpression {
+ @SuppressWarnings("unused")
public IsNotNull(Class<?> targetClass, String propertyName) {
super(targetClass, propertyName);
argumentsRequired = 0;
}
+ @SuppressWarnings("unused")
public IsNotNull(String propertyName) {
super(propertyName);
argumentsRequired = 0;
@@ -395,11 +403,13 @@ public abstract class MethodExpression {
public static class IsEmpty extends MethodExpression {
+ @SuppressWarnings("unused")
public IsEmpty(Class<?> targetClass, String propertyName) {
super(targetClass, propertyName);
argumentsRequired = 0;
}
+ @SuppressWarnings("unused")
public IsEmpty(String propertyName) {
super(propertyName);
argumentsRequired = 0;
@@ -414,11 +424,13 @@ public abstract class MethodExpression {
public static class IsNotEmpty extends MethodExpression {
+ @SuppressWarnings("unused")
public IsNotEmpty(Class<?> targetClass, String propertyName) {
super(targetClass, propertyName);
argumentsRequired = 0;
}
+ @SuppressWarnings("unused")
public IsNotEmpty(String propertyName) {
super(propertyName);
argumentsRequired = 0;
@@ -455,6 +467,7 @@ public abstract class MethodExpression {
public static class NotEqual extends MethodExpression {
+ @SuppressWarnings("unused")
public NotEqual(Class<?> targetClass, String propertyName) {
super(targetClass, propertyName);
}
@@ -475,12 +488,21 @@ public abstract class MethodExpression {
}
- private static void convertArgumentsForProp(PersistentEntity persistentEntity, PersistentProperty<?> prop, String propertyName, Object[] arguments, ConversionService conversionService) {
- if (prop == null) {
- if (propertyName.equals(persistentEntity.getIdentity().getName())) {
- prop = persistentEntity.getIdentity();
- }
+ /**
+ * Resolves the given property name against the entity, falling back to the identity property
+ * when there is no regular property by that name (e.g. {@code findByIdInList}). Shared by the
+ * base {@link #convertArguments} and {@link #convertArgumentsForProp} so the fallback exists in
+ * exactly one place.
+ */
+ private static PersistentProperty<?> resolveProperty(PersistentEntity persistentEntity, String propertyName) {
+ PersistentProperty<?> prop = persistentEntity.getPropertyByName(propertyName);
+ if (prop == null && propertyName.equals(persistentEntity.getIdentity().getName())) {
+ return persistentEntity.getIdentity();
}
+ return prop;
+ }
+
+ private static void convertArgumentsForProp(PersistentProperty<?> prop, Object[] arguments, ConversionService conversionService) {
if (prop != null) {
Class<?> type = prop.getType();
Collection<?> collection = (Collection<?>) arguments[0];
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/SingleResultFinder.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/SingleResultFinder.java
new file mode 100644
index 0000000000..11f414f31f
--- /dev/null
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/finders/SingleResultFinder.java
@@ -0,0 +1,245 @@
+/*
+ * 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.gorm.DatastoreResolver;
+import org.grails.datastore.mapping.core.Datastore;
+import org.grails.datastore.mapping.core.Session;
+import org.grails.datastore.mapping.core.exceptions.ConfigurationException;
+import org.grails.datastore.mapping.model.MappingContext;
+import org.grails.datastore.mapping.query.Query;
+
+/**
+ * Implements every dynamic finder that returns a single result: {@code findBy*}, the
+ * {@code 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 DatastoreResolver datastoreResolver;
+ private final DynamicFinder grammar;
+ private final Consumer<DynamicFinderInvocation> validate;
+ private final Function<DynamicFinderInvocation, Object> onNullResult;
+
+ private SingleResultFinder(DatastoreResolver datastoreResolver, DynamicFinder grammar,
+ Consumer<DynamicFinderInvocation> validate, Function<DynamicFinderInvocation, Object> onNullResult) {
+ this.datastoreResolver = datastoreResolver;
+ this.grammar = grammar;
+ this.validate = validate;
+ this.onNullResult = onNullResult;
+ }
+
+ public static SingleResultFinder findBy(Datastore datastore) {
+ return findBy(FinderSupport.resolverFor(datastore), datastore.getMappingContext());
+ }
+
+ public static SingleResultFinder findBy(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
+ return new SingleResultFinder(datastoreResolver, grammar(FIND_BY_PATTERN, mappingContext, false), null, null);
+ }
+
+ public static SingleResultFinder findBy(MappingContext mappingContext) {
+ return findBy(null, mappingContext);
+ }
+
+ public static SingleResultFinder findByBoolean(Datastore datastore) {
+ return findByBoolean(FinderSupport.resolverFor(datastore), datastore.getMappingContext());
+ }
+
+ public static SingleResultFinder findByBoolean(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
+ return new SingleResultFinder(datastoreResolver, grammar(FIND_BY_BOOLEAN_PATTERN, mappingContext, true), null, null);
+ }
+
+ public static SingleResultFinder findByBoolean(MappingContext mappingContext) {
+ return findByBoolean(null, mappingContext);
+ }
+
+ public static SingleResultFinder findOrCreateBy(Datastore datastore) {
+ return findOrCreateBy(FinderSupport.resolverFor(datastore), datastore.getMappingContext());
+ }
+
+ public static SingleResultFinder findOrCreateBy(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
+ return findOrCreateOrSave(datastoreResolver, mappingContext, false);
+ }
+
+ public static SingleResultFinder findOrCreateBy(MappingContext mappingContext) {
+ return findOrCreateBy(null, mappingContext);
+ }
+
+ public static SingleResultFinder findOrSaveBy(Datastore datastore) {
+ return findOrSaveBy(FinderSupport.resolverFor(datastore), datastore.getMappingContext());
+ }
+
+ public static SingleResultFinder findOrSaveBy(DatastoreResolver datastoreResolver, MappingContext mappingContext) {
+ return findOrCreateOrSave(datastoreResolver, mappingContext, true);
+ }
+
+ public static SingleResultFinder findOrSaveBy(MappingContext mappingContext) {
+ return findOrSaveBy(null, mappingContext);
+ }
+
+ private static SingleResultFinder findOrCreateOrSave(DatastoreResolver datastoreResolver, MappingContext mappingContext, boolean save) {
+ String pattern = save ? FIND_OR_SAVE_BY_PATTERN : FIND_OR_CREATE_BY_PATTERN;
+ return new SingleResultFinder(datastoreResolver, grammar(pattern, mappingContext, false),
+ SingleResultFinder::rejectOrAndComparisonOperators,
+ invocation -> constructFromEqualExpressions(invocation, save));
+ }
+
+ private static DynamicFinder grammar(String pattern, MappingContext mappingContext, boolean booleanClause) {
+ return new DynamicFinder(Pattern.compile(pattern), OPERATORS, mappingContext, booleanClause);
+ }
+
+ /**
+ * Shared by {@code findOrCreateBy}/{@code findOrSaveBy}: rejects the {@code Or} operator and
+ * any comparison-based expression (only equality-based finds can be created/saved).
+ */
+ private static void rejectOrAndComparisonOperators(DynamicFinderInvocation invocation) {
+ if (OPERATOR_OR.equals(invocation.getOperator())) {
+ throw new MissingMethodException(invocation.getMethodName(), invocation.getJavaClass(), invocation.getArguments());
+ }
+ for (MethodExpression methodExpression : invocation.getExpressions()) {
+ if (methodExpression instanceof MethodExpression.GreaterThan ||
+ methodExpression instanceof MethodExpression.LessThan ||
+ methodExpression instanceof MethodExpression.GreaterThanEquals ||
+ methodExpression instanceof MethodExpression.LessThanEquals) {
+ throw new ConfigurationException("Only equality-based expressions are supported for " + invocation.getMethodName());
+ }
+ }
+ }
+
+ /**
+ * Shared by {@code findOrCreateBy}/{@code findOrSaveBy}: constructs a new instance from the
+ * invocation's {@code Equal}-typed expressions when the underlying query returns null, saving
+ * it too when {@code save} is true. The single implementation this helper provides is what
+ * prevents the two finder kinds from ever diverging into duplicate, driftable copies.
+ */
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ private static Object constructFromEqualExpressions(DynamicFinderInvocation invocation, boolean save) {
+ Map m = new HashMap();
+ List<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
+ @SuppressWarnings("rawtypes")
+ public Object invoke(Class clazz, String methodName, Object[] arguments) {
+ return invoke(clazz, methodName, (Closure) null, arguments);
+ }
+
+ @Override
+ @SuppressWarnings("rawtypes")
+ public Object invoke(Class clazz, String methodName, Closure additionalCriteria, Object[] arguments) {
+ DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, additionalCriteria, arguments);
+ return doInvoke(invocation);
+ }
+
+ /**
+ * Not part of {@link FinderMethod} - called reflectively (dynamic Groovy dispatch) by {@link
+ * org.grails.datastore.gorm.query.criteria.AbstractDetachedCriteria#methodMissing}, which
+ * invokes a matched dynamic finder with the detached criteria instance itself as the "extra"
+ * argument so its accumulated criteria/projections/orders get merged into the built query.
+ *
+ * @param clazz The persistent class
+ * @param methodName The method name
+ * @param detachedCriteria The detached criteria to merge into the built query
+ * @param arguments The method call arguments
+ * @return The result of the method call
+ */
+ @SuppressWarnings("rawtypes")
+ public Object invoke(Class clazz, String methodName, DetachedCriteria detachedCriteria, Object[] arguments) {
+ DynamicFinderInvocation invocation = grammar.createFinderInvocation(clazz, methodName, null, arguments);
+ if (detachedCriteria != null) {
+ invocation.setDetachedCriteria(detachedCriteria);
+ }
+ return doInvoke(invocation);
+ }
+
+ private Object doInvoke(final DynamicFinderInvocation invocation) {
+ if (validate != null) {
+ validate.accept(invocation);
+ }
+ return FinderSupport.execute(datastoreResolver, 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/main/groovy/org/grails/datastore/gorm/query/criteria/AbstractDetachedCriteria.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/query/criteria/AbstractDetachedCriteria.groovy
index f0d1115dfa..afafc12ef4 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/query/criteria/AbstractDetachedCriteria.groovy
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/query/criteria/AbstractDetachedCriteria.groovy
@@ -26,7 +26,6 @@ import groovy.transform.TypeCheckingMode
import jakarta.persistence.FetchType
import jakarta.persistence.criteria.JoinType
-import org.grails.datastore.gorm.finders.DynamicFinder
import org.grails.datastore.gorm.finders.FinderMethod
import org.grails.datastore.mapping.core.connections.ConnectionSource
import org.grails.datastore.mapping.model.PersistentEntity
@@ -52,7 +51,7 @@ abstract class AbstractDetachedCriteria<T> implements Criteria, Cloneable {
protected List<Query.Order> orders = []
protected List<Query.Projection> projections = []
protected Class targetClass
- protected List<DynamicFinder> dynamicFinders
+ protected List<FinderMethod> dynamicFinders
protected Integer defaultOffset
protected Integer defaultMax
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..adcb565cd5
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/CountFinderSpec.groovy
@@ -0,0 +1,261 @@
+/*
+ * 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.gorm.DatastoreResolver
+import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.core.Session
+import org.grails.datastore.mapping.model.MappingContext
+import org.grails.datastore.mapping.model.PersistentEntity
+import org.grails.datastore.mapping.model.PersistentProperty
+import org.grails.datastore.mapping.query.Query
+import org.springframework.core.convert.support.DefaultConversionService
+import spock.lang.Specification
+import spock.lang.Unroll
+
+/**
+ * Exercises {@link CountFinder}'s own independent {@code buildQuery} - unlike every other finder
+ * in this package, it does NOT reuse {@code DynamicFinder.getJunction()} at all: for the And
+ * operator (or no operator) each expression's criterion is added directly to the query, while for
+ * Or each criterion is added to an explicit {@code disjunction()} via the two-arg
+ * {@code Query.add(Junction, Criterion)} overload - and a {@code count()} projection is always
+ * applied regardless of operator.
+ */
+class CountFinderSpec extends Specification {
+
+ PersistentProperty nameProperty = Stub(PersistentProperty) {
+ getName() >> 'name'
+ getType() >> String
+ }
+ MappingContext mappingContext = Stub(MappingContext)
+ PersistentEntity persistentEntity = Stub(PersistentEntity)
+ Datastore datastore = Stub(Datastore) {
+ getMappingContext() >> mappingContext
+ }
+ CountFinder countFinder = CountFinder.countBy(datastore)
+
+ // See DynamicFinderSpec's setup() for why this circular wiring can't be done via inline field
+ // initializers.
+ void setup() {
+ mappingContext.getConversionService() >> new DefaultConversionService()
+ mappingContext.getPersistentEntity(FinderTestEntity.name) >> persistentEntity
+ persistentEntity.getMappingContext() >> mappingContext
+ persistentEntity.getPropertyByName('name') >> nameProperty
+ }
+
+ private static DynamicFinderInvocation twoEqualsInvocation(String operator) {
+ MethodExpression.Equal nameExpression = new MethodExpression.Equal('name')
+ nameExpression.setArguments(['Bob'] as Object[])
+ MethodExpression.Equal ageExpression = new MethodExpression.Equal('age')
+ ageExpression.setArguments([42] as Object[])
+ new DynamicFinderInvocation(FinderTestEntity, 'countByNameAndAge', [] as Object[],
+ [nameExpression, ageExpression], null, operator)
+ }
+
+ @Unroll
+ void "buildQuery adds each expression's criterion directly to the query, without a disjunction, for operator #operator"() {
+ given:
+ Query.ProjectionList projectionList = Mock(Query.ProjectionList)
+ Query query = Mock(Query) {
+ getEntity() >> persistentEntity
+ projections() >> projectionList
+ }
+
+ when:
+ countFinder.buildQuery(twoEqualsInvocation(operator), Stub(Session) { createQuery(FinderTestEntity) >> query })
+
+ then:
+ 2 * query.add(_)
+ 0 * query.disjunction()
+ 1 * projectionList.count()
+
+ where:
+ operator << [null, 'And']
+ }
+
+ void "buildQuery adds each expression's criterion to an explicit disjunction for the Or operator"() {
+ given:
+ Query.Junction disjunctionMock = Mock(Query.Junction)
+ Query.ProjectionList projectionList = Mock(Query.ProjectionList)
+ Query query = Mock(Query) {
+ getEntity() >> persistentEntity
+ projections() >> projectionList
+ }
+ Session session = Stub(Session) {
+ createQuery(FinderTestEntity) >> query
+ }
+
+ when:
+ countFinder.buildQuery(twoEqualsInvocation('Or'), session)
+
+ then:
+ // Return value is specified here, not via a separate stub, since a bare cardinality
+ // interaction for the same method declared later would shadow an earlier stub's return
+ // value and silently default to returning null.
+ 1 * query.disjunction() >> disjunctionMock
+ 2 * query.add(disjunctionMock, _)
+ 0 * query.add(_)
+ 1 * projectionList.count()
+ }
+
+ void "isMethodMatch matches countBy* method names"() {
+ expect:
+ countFinder.isMethodMatch('countByName')
+ countFinder.isMethodMatch('countByNameAndAge')
+ !countFinder.isMethodMatch('somethingElse')
+ }
+
+ void "setPattern delegates to the grammar"() {
+ expect:
+ countFinder.isMethodMatch('countByName')
+ !countFinder.isMethodMatch('customPrefixName')
+
+ when:
+ countFinder.setPattern('(customPrefix)(\\w+)')
+
+ then:
+ countFinder.isMethodMatch('customPrefixName')
+ !countFinder.isMethodMatch('countByName')
+ }
+
+ void "invoke runs the full round trip through the real DatastoreUtils.execute seam and returns the count"() {
+ given:
+ Query.ProjectionList projectionList = Mock(Query.ProjectionList)
+ Query query = Mock(Query) {
+ getEntity() >> persistentEntity
+ projections() >> projectionList
+ singleResult() >> 2L
+ }
+ Session session = Stub(Session) {
+ createQuery(FinderTestEntity) >> query
+ }
+ datastore.hasCurrentSession() >> true
+ datastore.getCurrentSession() >> session
+
+ when:
+ Object result = countFinder.invoke(FinderTestEntity, 'countByName', ['Bob'] as Object[])
+
+ then:
+ result == 2L
+ 1 * projectionList.count()
+ }
+
+ void "invoke(Class, methodName, DetachedCriteria, Object[]) merges the detached criteria onto the built query"() {
+ given:
+ // Called reflectively (dynamic Groovy dispatch, not part of FinderMethod) by
+ // AbstractDetachedCriteria#methodMissing.
+ Query.ProjectionList projectionList = Mock(Query.ProjectionList)
+ Query query = Mock(Query) {
+ getEntity() >> persistentEntity
+ projections() >> projectionList
+ singleResult() >> 2L
+ }
+ Session session = Stub(Session) {
+ createQuery(FinderTestEntity) >> query
+ }
+ datastore.hasCurrentSession() >> true
+ datastore.getCurrentSession() >> session
+ grails.gorm.DetachedCriteria detachedCriteria = Stub(grails.gorm.DetachedCriteria) {
+ getFetchStrategies() >> [:]
+ getCriteria() >> [org.grails.datastore.mapping.query.Restrictions.eq('age', 42)]
+ getProjections() >> []
+ getOrders() >> []
+ }
+
+ when:
+ Object result = countFinder.invoke(FinderTestEntity, 'countByName', detachedCriteria, ['Bob'] as Object[])
+
+ then:
+ result == 2L
+ // One from the detached criteria's merged criterion, one from the real "name" expression.
+ 2 * query.add({ it instanceof Query.PropertyCriterion })
+ 1 * projectionList.count()
+ }
+
+ void "invoke(Class, methodName, DetachedCriteria, Object[]) with a null detachedCriteria never merges anything onto the built query"() {
+ given:
+ Query.ProjectionList projectionList = Mock(Query.ProjectionList)
+ Query query = Mock(Query) {
+ getEntity() >> persistentEntity
+ projections() >> projectionList
+ singleResult() >> 2L
+ }
+ Session session = Stub(Session) {
+ createQuery(FinderTestEntity) >> query
+ }
+ datastore.hasCurrentSession() >> true
+ datastore.getCurrentSession() >> session
+
+ when:
+ Object result = countFinder.invoke(FinderTestEntity, 'countByName', (grails.gorm.DetachedCriteria) null, ['Bob'] as Object[])
+
+ then:
+ result == 2L
+ 1 * query.add({ it instanceof Query.PropertyCriterion })
+ 1 * projectionList.count()
+ }
+
+ void "invoke throws IllegalStateException when constructed in stateless mode"() {
+ when:
+ CountFinder.countBy(mappingContext).invoke(FinderTestEntity, 'countByName', ['Bob'] as Object[])
+
+ then:
+ thrown(IllegalStateException)
+ }
+ void "countBy resolves its datastore through the DatastoreResolver on every invocation, not at construction"() {
+ given:
+ Query query = Mock(Query) {
+ getEntity() >> persistentEntity
+ projections() >> Mock(Query.ProjectionList)
+ }
+ Session session = Stub(Session) {
+ createQuery(FinderTestEntity) >> query
+ }
+ Datastore lateBoundDatastore = Stub(Datastore) {
+ hasCurrentSession() >> true
+ getCurrentSession() >> session
+ }
+ DatastoreResolver resolver = Mock(DatastoreResolver)
+
+ when:
+ def finder = CountFinder.countBy(resolver, mappingContext)
+
+ then:
+ 0 * resolver.resolve()
+
+ when:
+ finder.invoke(FinderTestEntity, 'countByName', ['Bob'] as Object[])
+
+ then:
+ 1 * resolver.resolve() >> lateBoundDatastore
+ 1 * query.singleResult() >> 0L
+ }
+
+ void "countBy throws IllegalStateException when the DatastoreResolver resolves no datastore"() {
+ given:
+ DatastoreResolver resolver = Mock(DatastoreResolver)
+
+ when:
+ CountFinder.countBy(resolver, mappingContext).invoke(FinderTestEntity, 'countByName', ['Bob'] as Object[])
+
+ then:
+ IllegalStateException e = thrown()
+ e.message == 'Cannot execute session query with null datastore'
+ }
+}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/AbstractFinderSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DatastoreResolverFinderSpec.groovy
similarity index 56%
rename from grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/AbstractFinderSpec.groovy
rename to grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DatastoreResolverFinderSpec.groovy
index c266384f1f..7073d4fd9e 100644
--- a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/AbstractFinderSpec.groovy
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DatastoreResolverFinderSpec.groovy
@@ -25,60 +25,59 @@ import spock.lang.Specification
import spock.lang.Unroll
/**
- * {@code AbstractFinder} is the common base for the "named" dynamic finder classes
- * ({@code FindAllByFinder}, {@code CountByFinder}, {@code ListOrderByFinder}, etc.) that
- * {@code DefaultGormApiFactory#createDynamicFinders} registers using the new
- * {@code DatastoreResolver}-based constructor this PR added. Drives real dynamic finder calls
- * through a {@code SimpleMapDatastore}-backed entity to exercise the lazy resolver path and the
- * additional-criteria closure support.
+ * {@code DefaultGormApiFactory#createDynamicFinders} registers every finder ({@code ListResultFinder},
+ * {@code CountFinder}, {@code ListOrderByFinder}, etc.) through its {@code DatastoreResolver}-based
+ * factory, and {@link FinderSupport} resolves the datastore on every invocation. Drives real
+ * dynamic finder calls through a {@code SimpleMapDatastore}-backed entity to exercise that lazy
+ * resolver path and the additional-criteria closure support.
*/
-class AbstractFinderSpec extends Specification {
+class DatastoreResolverFinderSpec extends Specification {
@AutoCleanup
- SimpleMapDatastore datastore = new SimpleMapDatastore(AbstractFinderThing)
+ SimpleMapDatastore datastore = new SimpleMapDatastore(ResolverFinderThing)
void "findAllBy resolves its datastore via the lazy DatastoreResolver and executes a real query"() {
given:
- AbstractFinderThing.newInstance(title: 'Alpha').save(flush: true)
- AbstractFinderThing.newInstance(title: 'Beta').save(flush: true)
+ ResolverFinderThing.newInstance(title: 'Alpha').save(flush: true)
+ ResolverFinderThing.newInstance(title: 'Beta').save(flush: true)
expect:
- AbstractFinderThing.findAllByTitle('Alpha').size() == 1
+ ResolverFinderThing.findAllByTitle('Alpha').size() == 1
}
void "countBy resolves its datastore via the lazy DatastoreResolver and executes a real query"() {
given:
- AbstractFinderThing.newInstance(title: 'Same').save(flush: true)
- AbstractFinderThing.newInstance(title: 'Same').save(flush: true)
+ ResolverFinderThing.newInstance(title: 'Same').save(flush: true)
+ ResolverFinderThing.newInstance(title: 'Same').save(flush: true)
expect:
- AbstractFinderThing.countByTitle('Same') == 2
+ ResolverFinderThing.countByTitle('Same') == 2
}
void "listOrderBy resolves its datastore via the lazy DatastoreResolver and executes a real query"() {
given:
- AbstractFinderThing.newInstance(title: 'B').save(flush: true)
- AbstractFinderThing.newInstance(title: 'A').save(flush: true)
+ ResolverFinderThing.newInstance(title: 'B').save(flush: true)
+ ResolverFinderThing.newInstance(title: 'A').save(flush: true)
expect:
- AbstractFinderThing.listOrderByTitle()*.title == ['A', 'B']
+ ResolverFinderThing.listOrderByTitle()*.title == ['A', 'B']
}
void "listOrderBy normalizes the order direction regardless of case and surrounding whitespace"() {
given:
- AbstractFinderThing.newInstance(title: 'B').save(flush: true)
- AbstractFinderThing.newInstance(title: 'A').save(flush: true)
+ ResolverFinderThing.newInstance(title: 'B').save(flush: true)
+ ResolverFinderThing.newInstance(title: 'A').save(flush: true)
expect:
- AbstractFinderThing.listOrderByTitle(order: ' DESC ')*.title == ['B', 'A']
- AbstractFinderThing.listOrderByTitle(order: 'Asc')*.title == ['A', 'B']
- AbstractFinderThing.listOrderByTitle(order: '')*.title == ['A', 'B']
+ ResolverFinderThing.listOrderByTitle(order: ' DESC ')*.title == ['B', 'A']
+ ResolverFinderThing.listOrderByTitle(order: 'Asc')*.title == ['A', 'B']
+ ResolverFinderThing.listOrderByTitle(order: '')*.title == ['A', 'B']
}
@Unroll
void "listOrderBy rejects order direction #description without echoing it"() {
when:
- AbstractFinderThing.listOrderByTitle(order: order)
+ ResolverFinderThing.listOrderByTitle(order: order)
then:
def e = thrown(IllegalArgumentException)
@@ -90,29 +89,28 @@ class AbstractFinderSpec extends Specification {
'desc extra' | 'carrying extra tokens'
}
- void "listOrderBy with an additional criteria closure applies it via applyAdditionalCriteria"() {
- given: "ListOrderByFinder.invoke(Class, methodName, Closure, Object[]) is the one caller of\n" +
- "applyAdditionalCriteria in this class hierarchy - called directly since routing a\n" +
- "trailing closure through GormStaticApi's own dynamic dispatch into this specific\n" +
- "4-arg overload isn't part of what this class itself needs proving"
- AbstractFinderThing.newInstance(title: 'Matched', age: 30).save(flush: true)
- AbstractFinderThing.newInstance(title: 'Matched', age: 99).save(flush: true)
+ void "listOrderBy with an additional criteria closure applies it via DynamicFinder.applyAdditionalCriteria"() {
+ given: "ListOrderByFinder.invoke(Class, methodName, Closure, Object[]) is called directly since\n" +
+ "routing a trailing closure through GormStaticApi's own dynamic dispatch into this\n" +
+ "specific 4-arg overload isn't part of what this class itself needs proving"
+ ResolverFinderThing.newInstance(title: 'Matched', age: 30).save(flush: true)
+ ResolverFinderThing.newInstance(title: 'Matched', age: 99).save(flush: true)
def finder = new ListOrderByFinder(datastore)
when:
- def results = finder.invoke(AbstractFinderThing, 'listOrderByTitle', { lt('age', 50) }, [] as Object[])
+ def results = finder.invoke(ResolverFinderThing, 'listOrderByTitle', { lt('age', 50) }, [] as Object[])
then:
results.size() == 1
results[0].age == 30
}
- void "execute(SessionCallback) throws IllegalStateException when no datastore can be resolved"() {
+ void "invoke throws IllegalStateException when no datastore can be resolved"() {
given:
def finder = new ListOrderByFinder((org.grails.datastore.mapping.core.Datastore) null)
when:
- finder.invoke(AbstractFinderThing, 'listOrderByTitle', [] as Object[])
+ finder.invoke(ResolverFinderThing, 'listOrderByTitle', [] as Object[])
then:
def e = thrown(IllegalStateException)
@@ -121,7 +119,7 @@ class AbstractFinderSpec extends Specification {
}
@Entity
-class AbstractFinderThing {
+class ResolverFinderThing {
String title
Integer age
}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DynamicFinderCoverageSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DynamicFinderCoverageSpec.groovy
index d4be58eed5..bc2ad2d99a 100644
--- a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DynamicFinderCoverageSpec.groovy
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/DynamicFinderCoverageSpec.groovy
@@ -268,9 +268,9 @@ class DynamicFinderCoverageSpec extends Specification {
notThrown(Exception)
}
- void "the DatastoreResolver+MappingContext constructor wires a finder that is usable without a bound datastore"() {
+ void "the DatastoreResolver+MappingContext factory wires a finder that is usable without a bound datastore"() {
given:
- def finder = new FindByFinder(new org.grails.datastore.gorm.DatastoreResolver() {
+ def finder = SingleResultFinder.findBy(new org.grails.datastore.gorm.DatastoreResolver() {
@Override org.grails.datastore.mapping.core.Datastore resolve() { datastore }
}, datastore.mappingContext)
@@ -278,9 +278,9 @@ class DynamicFinderCoverageSpec extends Specification {
finder.isMethodMatch('findByName')
}
- void "the MappingContext-only constructor wires a finder without any datastore or resolver"() {
+ void "the MappingContext-only factory wires a finder without any datastore or resolver"() {
given:
- def finder = new FindByFinder(datastore.mappingContext)
+ def finder = SingleResultFinder.findBy(datastore.mappingContext)
expect:
finder.isMethodMatch('findByName')
@@ -288,7 +288,7 @@ class DynamicFinderCoverageSpec extends Specification {
void "invoke(Class, methodName, DetachedCriteria, arguments) merges the detached criteria into the finder invocation"() {
given:
- def finder = new FindAllByFinder(datastore)
+ def finder = ListResultFinder.findAllBy(datastore)
def detached = new grails.gorm.DetachedCriteria(DynamicFinderThing).build { gt('age', 20) }
when:
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..b128d915ee 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,842 @@ 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']
+ prefix | methodName | parameters | expressions | queryExpression | propertyNames
+ "findBy" | "findByTitle" | 1 | 1 | "Title" | ['title']
+ "findBy" | "findByTitleBetween" | 2 | 1 | "TitleBetween" | ['title']
+ "findBy" | "findByTitleAndAuthor" | 2 | 2 | "TitleAndAuthor" | ['title', 'author']
+ "findBy" | "findByTitleOrAuthor" | 2 | 2 | "TitleOrAuthor" | ['title', 'author']
+ "findBy" | "findByAgeGreaterThanEquals" | 1 | 1 | "AgeGreaterThanEquals" | ['age']
+ "findBy" | "findByAgeLessThanEquals" | 1 | 1 | "AgeLessThanEquals" | ['age']
+ "findBy" | "findByActiveIsNull" | 0 | 1 | "ActiveIsNull" | ['active']
+ "findBy" | "findByActiveIsNotNull" | 0 | 1 | "ActiveIsNotNull" | ['active']
+ "findBy" | "findByAuthorNot" | 1 | 1 | "AuthorNot" | ['author']
+ }
+
+ void "buildMatchSpec returns null when there are fewer parameters than the expression requires"() {
+ expect:
+ DynamicFinder.buildMatchSpec("findBy", "findByTitleBetween", 1) == null
+ }
+
+ void "buildMatchSpec returns null when a lone expression's arguments exceed the given parameter count"() {
+ expect:
+ DynamicFinder.buildMatchSpec("findBy", "findByTitle", 0) == null
+ }
+
+ void "buildMatchSpec returns null when the method name does not match the prefix pattern at all"() {
+ expect:
+ DynamicFinder.buildMatchSpec("findBy", "getSomethingUnrelated", 1) == null
+ }
+
+ void "buildMatchSpec returns null when an And-combined expression's total arguments exceed the given parameter count"() {
+ expect:
+ // "TitleAndAuthorBetween" splits into Equal(title, 1 arg) + Between(author, 2 args) = 3
+ // required arguments total - only reachable via the post-loop check since neither
+ // individual expression alone exceeds parameterCount.
+ DynamicFinder.buildMatchSpec("findBy", "findByTitleAndAuthorBetween", 2) == null
+ }
+
+ void "buildMatchSpec resolves a GreaterThanEquals suffix without truncating it to GreaterThan"() {
+ given:
+ MatchSpec spec = DynamicFinder.buildMatchSpec("findBy", "findByAgeGreaterThanEquals", 1)
+
+ expect:
+ spec.methodCallExpressions[0] instanceof MethodExpression.GreaterThanEquals
+ }
+
+ void "buildMatchSpec wraps a Not-suffixed clause's criterion in a Negation"() {
+ given:
+ MatchSpec spec = DynamicFinder.buildMatchSpec("findBy", "findByAuthorNot", 1)
+ MethodExpression expression = spec.methodCallExpressions[0]
+ expression.setArguments(['Bob'] as Object[])
+
+ when:
+ Query.Criterion criterion = expression.createCriterion()
+
+ then:
+ criterion instanceof Query.Negation
+ Query.Negation negation = (Query.Negation) criterion
+ negation.criteria.size() == 1
+ negation.criteria[0] instanceof Query.Equals
+ negation.criteria[0].property == 'author'
+ }
+
+ void "buildMatchSpec throws when the derived property name is empty because the property itself shares the operator's name"() {
+ when:
+ // "findByLikeLike" means "property 'Like', operator Like" - but calcPropertyName finds the
+ // FIRST occurrence of "Like" in "LikeLike" (index 0, the property's own name), not the
+ // occurrence the regex actually matched, so the derived property name comes out empty.
+ DynamicFinder.buildMatchSpec("findBy", "findByLikeLike", 1)
+
+ then:
+ thrown(IllegalArgumentException)
+ }
+
+ void "buildMatchSpec's naive literal split on the And/Or operator can split inside an unrelated property name"() {
+ when:
+ // "findByAndroidVersionAndTitle" is meant to be "androidVersion And title", but
+ // querySequence.split("And") splits on every literal occurrence of "And", including the
+ // one inside "Android" itself, producing an empty leading segment - a real parsing bug in
+ // both buildMatchSpec and createFinderInvocation's identical split logic, not a
+ // hypothetical. Documented here as current behavior, not fixed as part of this pass.
+ DynamicFinder.buildMatchSpec("findBy", "findByAndroidVersionAndTitle", 2)
+
+ then:
+ thrown(IllegalArgumentException)
+ }
+
+ void "registerNewMethodExpression rejects a class without a (Class, String) constructor"() {
+ when:
+ DynamicFinder.registerNewMethodExpression(String)
+
+ then:
+ thrown(IllegalArgumentException)
+ }
+
+ void "registerNewMethodExpression registers a custom operator that buildMatchSpec can then resolve"() {
+ given:
+ DynamicFinder.registerNewMethodExpression(ZzzCustomTest)
+
+ when:
+ // the registered alternative is matched against the CLASS'S OWN SIMPLE NAME, not some
+ // separately-declared suffix string - same convention every built-in operator uses
+ // (e.g. MethodExpression.GreaterThan's suffix is "GreaterThan", not a custom string).
+ MatchSpec spec = DynamicFinder.buildMatchSpec("findBy", "findByTitleZzzCustomTest", 1)
+
+ then:
+ spec.methodCallExpressions[0] instanceof ZzzCustomTest
+ spec.propertyNames == ['title']
+ }
+
+ static class ZzzCustomTest extends MethodExpression {
+ ZzzCustomTest(Class targetClass, String propertyName) {
+ super(targetClass, propertyName)
+ }
+
+ @Override
+ Query.Criterion createCriterion() {
+ return org.grails.datastore.mapping.query.Restrictions.eq(propertyName, arguments[0])
+ }
+ }
+
+ PersistentProperty idProperty = Stub(PersistentProperty) {
+ getName() >> 'id'
+ getType() >> Long
+ }
+ PersistentProperty nameProperty = Stub(PersistentProperty) {
+ getName() >> 'name'
+ getType() >> String
+ }
+ PersistentProperty ageProperty = Stub(PersistentProperty) {
+ getName() >> 'age'
+ getType() >> Integer
+ }
+ PersistentProperty authorProperty = Stub(PersistentProperty) {
+ getName() >> 'author'
+ getType() >> String
+ }
+ PersistentProperty activeProperty = Stub(PersistentProperty) {
+ getName() >> 'active'
+ getType() >> Boolean
+ }
+ PersistentProperty tagsProperty = Stub(Basic) {
+ getName() >> 'tags'
+ getType() >> List
+ }
+ MappingContext mappingContext = Stub(MappingContext)
+ PersistentEntity persistentEntity = Stub(PersistentEntity)
+
+ // Wired in setup() rather than inline field initializers: mappingContext.getPersistentEntity()
+ // needs to return `persistentEntity`, and persistentEntity.getMappingContext() needs to return
+ // `mappingContext` right back - a genuine circular reference that inline field initializers
+ // can't express (a Stub(...) { } closure captures the RHS field's value at the moment its own
+ // field initializer runs, so whichever field is declared second would still be null).
+ void setup() {
+ mappingContext.getConversionService() >> new DefaultConversionService()
+ mappingContext.getPersistentEntity(FinderTestEntity.name) >> persistentEntity
+ persistentEntity.getIdentity() >> idProperty
+ persistentEntity.getMappingContext() >> mappingContext
+ persistentEntity.getPropertyByName('name') >> nameProperty
+ persistentEntity.getPropertyByName('age') >> ageProperty
+ persistentEntity.getPropertyByName('author') >> authorProperty
+ persistentEntity.getPropertyByName('active') >> activeProperty
+ persistentEntity.getPropertyByName('tags') >> tagsProperty
+ }
+
+ DynamicFinder findByGrammar = new DynamicFinder(FIND_BY_PATTERN, OPERATORS, mappingContext, false)
+ DynamicFinder findByBooleanGrammar = new DynamicFinder(FIND_BY_BOOLEAN_PATTERN, OPERATORS, mappingContext, true)
+
+ @Unroll
+ void "isMethodMatch('#methodName') == #matches"() {
+ expect:
+ findByGrammar.isMethodMatch(methodName) == matches
+
+ where:
+ methodName | matches
+ 'findByName' | true
+ 'findByNameAndAge' | true
+ 'somethingElse' | false
+ }
+
+ void "firstExpressionIsRequiredBoolean reflects the constructor flag"() {
+ expect:
+ !findByGrammar.firstExpressionIsRequiredBoolean()
+ findByBooleanGrammar.firstExpressionIsRequiredBoolean()
+ }
+
+ void "createFinderInvocation resolves a single equality expression"() {
+ when:
+ DynamicFinderInvocation invocation = findByGrammar.createFinderInvocation(FinderTestEntity, 'findByName', null, ['Bob'] as Object[])
+
+ then:
+ invocation.expressions.size() == 1
+ invocation.expressions[0] instanceof MethodExpression.Equal
+ invocation.expressions[0].propertyName == 'name'
+ invocation.arguments.length == 0
+ }
+
+ void "createFinderInvocation splits an And junction into two expressions and records the operator"() {
+ when:
+ DynamicFinderInvocation invocation = findByGrammar.createFinderInvocation(
+ FinderTestEntity, 'findByNameAndAge', null, ['Bob', 42] as Object[])
+
+ then:
+ invocation.operator == 'And'
+ invocation.expressions.size() == 2
+ invocation.expressions[0].propertyName == 'name'
+ invocation.expressions[1].propertyName == 'age'
+ }
+
+ void "createFinderInvocation splits an Or junction and records the Or operator"() {
+ when:
+ DynamicFinderInvocation invocation = findByGrammar.createFinderInvocation(
+ FinderTestEntity, 'findByNameOrAge', null, ['Bob', 42] as Object[])
+
+ then:
+ invocation.operator == 'Or'
+ invocation.expressions.size() == 2
+ }
+
+ void "createFinderInvocation wraps a Not-suffixed clause's expression so its criterion negates"() {
+ when:
+ DynamicFinderInvocation invocation = findByGrammar.createFinderInvocation(
+ FinderTestEntity, 'findByAuthorNot', null, ['Bob'] as Object[])
+ Query.Criterion criterion = invocation.expressions[0].createCriterion()
+
+ then:
+ invocation.expressions.size() == 1
+ criterion instanceof Query.Negation
+ }
+
+ void "createFinderInvocation throws MissingMethodException when too few arguments are supplied"() {
+ when:
+ findByGrammar.createFinderInvocation(FinderTestEntity, 'findByNameAndAge', null, ['Bob'] as Object[])
+
+ then:
+ thrown(MissingMethodException)
+ }
+
+ void "createFinderInvocation's naive literal split on the And/Or operator can split inside an unrelated property name"() {
+ when:
+ // Mirrors the "buildMatchSpec's naive literal split..." test above, but through the actual
+ // runtime entry point real finders call, proving the split logic really is identical - not
+ // merely asserted to be by comment. "findByAndroidVersionAndTitle" is meant to be
+ // "androidVersion And title", but querySequence.split("And") splits on every literal
+ // occurrence of "And", including the one starting "AndroidVersion" itself, producing an
+ // empty leading segment.
+ findByGrammar.createFinderInvocation(FinderTestEntity, 'findByAndroidVersionAndTitle', null, ['x', 'y'] as Object[])
+
+ then:
+ thrown(IllegalArgumentException)
+ }
+
+ void "createFinderInvocation rethrows a ConversionException as MissingMethodException for a non-Basic property"() {
+ given:
+ // "age" (a plain Integer, non-Basic property) with an argument that genuinely needs
+ // conversion - convertArguments only ever calls the ConversionService when the argument's
+ // type isn't already assignable to the property's type, so an already-matching argument
+ // (e.g. a String for a String property) would never reach the ConversionService at all.
+ mappingContext.getConversionService() >> Stub(org.springframework.core.convert.ConversionService) {
+ canConvert(_, _) >> true
+ convert(_, _) >> { throw new ConverterNotFoundException(TypeDescriptor.valueOf(String), TypeDescriptor.valueOf(Integer)) }
+ }
+
+ when:
+ findByGrammar.createFinderInvocation(FinderTestEntity, 'findByAge', null, ['not-a-number'] as Object[])
+
+ then:
+ thrown(MissingMethodException)
+ }
+
+ void "createFinderInvocation swallows a ConversionException for a Basic property"() {
+ given:
+ // Real Basic-typed properties always model a to-many/collection shape (Basic extends
+ // ToMany), and MethodExpression's own conversion logic skips the ConversionService
+ // entirely for a raw collection-typed property - so a scalar-typed Basic stub is used
+ // here purely to exercise DynamicFinder's own "instanceof Basic" dispatch in isolation,
+ // not to model a realistic Basic property.
+ PersistentProperty basicFlagProperty = Stub(Basic) {
+ getName() >> 'flag'
+ getType() >> Boolean
+ }
+ persistentEntity.getPropertyByName('flag') >> basicFlagProperty
+ mappingContext.getConversionService() >> Stub(org.springframework.core.convert.ConversionService) {
+ canConvert(_, _) >> true
+ convert(_, _) >> { throw new ConverterNotFoundException(TypeDescriptor.valueOf(String), TypeDescriptor.valueOf(Boolean)) }
+ }
+
+ when:
+ DynamicFinderInvocation invocation = findByGrammar.createFinderInvocation(FinderTestEntity, 'findByFlag', null, ['not-a-boolean'] as Object[])
+
+ then:
+ noExceptionThrown()
+ invocation.expressions.size() == 1
+ }
+
+ void "createFinderInvocation resolves the boolean-plus-By form, defaulting the boolean argument to TRUE"() {
+ when:
+ DynamicFinderInvocation invocation = findByBooleanGrammar.createFinderInvocation(
+ FinderTestEntity, 'findActiveByName', null, ['Bob'] as Object[])
+
+ then:
+ invocation.expressions.size() == 2
+ invocation.expressions[0].propertyName == 'active'
+ invocation.expressions[0].getArguments()[0] == Boolean.TRUE
+ invocation.expressions[1].propertyName == 'name'
+ }
+
+ void "createFinderInvocation resolves the lone-boolean form with no By clause and no consumed arguments"() {
+ when:
+ DynamicFinderInvocation invocation = findByBooleanGrammar.createFinderInvocation(
+ FinderTestEntity, 'findActive', null, [] as Object[])
+
+ then:
+ invocation.expressions.size() == 1
+ invocation.expressions[0].propertyName == 'active'
+ invocation.expressions[0].getArguments()[0] == Boolean.TRUE
+ }
+
+ void "createFinderInvocation flips the boolean argument to FALSE for a Not-prefixed boolean clause"() {
+ when:
+ DynamicFinderInvocation invocation = findByBooleanGrammar.createFinderInvocation(
+ FinderTestEntity, 'findNotActiveByName', null, ['Bob'] as Object[])
+
+ then:
+ invocation.expressions[0].propertyName == 'active'
+ invocation.expressions[0].getArguments()[0] == Boolean.FALSE
+ }
+
+ void "getJunction builds a Conjunction for the And operator"() {
+ given:
+ MethodExpression name = new MethodExpression.Equal('name')
+ name.setArguments(['Bob'] as Object[])
+ MethodExpression age = new MethodExpression.Equal('age')
+ age.setArguments([42] as Object[])
+ DynamicFinderInvocation invocation = new DynamicFinderInvocation(FinderTestEntity, 'findByNameAndAge', [] as Object[], [name, age], null, 'And')
+
+ when:
+ Query.Junction junction = findByGrammar.getJunction(invocation)
+
+ then:
+ junction instanceof Query.Conjunction
+ junction.criteria.size() == 2
+ }
+
+ void "getJunction builds a Disjunction for the Or operator when no boolean clause is required"() {
+ given:
+ MethodExpression name = new MethodExpression.Equal('name')
+ name.setArguments(['Bob'] as Object[])
+ MethodExpression age = new MethodExpression.Equal('age')
+ age.setArguments([42] as Object[])
+ DynamicFinderInvocation invocation = new DynamicFinderInvocation(FinderTestEntity, 'findByNameOrAge', [] as Object[], [name, age], null, 'Or')
+
+ when:
+ Query.Junction junction = findByGrammar.getJunction(invocation)
+
+ then:
+ junction instanceof Query.Disjunction
+ junction.criteria.size() == 2
+ }
+
+ void "getJunction keeps a required boolean clause AND'd even when the rest of the clauses use Or"() {
+ given:
+ MethodExpression active = new MethodExpression.Equal('active')
+ active.setArguments([Boolean.TRUE] as Object[])
+ MethodExpression name = new MethodExpression.Equal('name')
+ name.setArguments(['Bob'] as Object[])
+ MethodExpression age = new MethodExpression.Equal('age')
+ age.setArguments([42] as Object[])
+ DynamicFinderInvocation invocation = new DynamicFinderInvocation(
+ FinderTestEntity, 'findActiveByNameOrAge', [] as Object[], [active, name, age], null, 'Or')
+
+ when:
+ Query.Junction junction = findByBooleanGrammar.getJunction(invocation)
+
+ then:
+ junction instanceof Query.Conjunction
+ junction.criteria.size() == 2
+ junction.criteria[1] instanceof Query.Disjunction
+ junction.criteria[1].criteria.size() == 2
+ }
+
+ void "populateArgumentsForCriteria(Query) is a no-op for a null argument map"() {
+ given:
+ Query query = Mock(Query)
+
+ when:
+ DynamicFinder.populateArgumentsForCriteria(FinderTestEntity, query, null)
+
+ then:
+ 0 * query._
+ }
+
+ void "populateArgumentsForCriteria(Query) applies max and offset, converting String values"() {
+ given:
+ Query query = Mock(Query) {
+ getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext }
+ }
+
+ when:
+ DynamicFinder.populateArgumentsForCriteria(FinderTestEntity, query, [max: '10', offset: '5'])
+
+ then:
+ 1 * query.max(10)
+ 1 * query.offset(5)
+ }
+
+ void "populateArgumentsForCriteria(Query) applies a String sort, defaulting to ascending and ignoring case"() {
+ given:
+ Query query = Mock(Query) {
+ getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext }
+ }
+
+ when:
+ DynamicFinder.populateArgumentsForCriteria(FinderTestEntity, query, [sort: 'age'])
+
+ then:
+ 1 * query.order({ Query.Order order ->
+ order.property == 'age' && order.direction == Query.Order.Direction.ASC && order.ignoreCase
+ })
+ }
+
+ void "populateArgumentsForCriteria(Query) applies a descending order and honours ignoreCase: false"() {
+ given:
+ Query query = Mock(Query) {
+ getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext }
+ }
+
+ when:
+ DynamicFinder.populateArgumentsForCriteria(FinderTestEntity, query, [sort: 'age', order: 'desc', ignoreCase: false])
+
+ then:
+ 1 * query.order({ Query.Order order ->
+ order.property == 'age' && order.direction == Query.Order.Direction.DESC && !order.ignoreCase
+ })
+ }
+
+ void "populateArgumentsForCriteria(Query) applies a multi-field sort Map"() {
+ given:
+ Query query = Mock(Query) {
+ getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext }
+ }
+
+ when:
+ DynamicFinder.populateArgumentsForCriteria(FinderTestEntity, query, [sort: [name: 'asc', age: 'desc']])
+
+ then:
+ 1 * query.order({ Query.Order order -> order.property == 'name' && order.direction == Query.Order.Direction.ASC })
+ 1 * query.order({ Query.Order order -> order.property == 'age' && order.direction == Query.Order.Direction.DESC })
+ }
+
+ @Unroll
+ void "populateArgumentsForCriteria(Query) applies fetch entry #fetchValue as #expectedMethod"() {
+ given:
+ Query query = Mock(Query) {
+ getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext }
+ }
+
+ when:
+ DynamicFinder.populateArgumentsForCriteria(FinderTestEntity, query, [fetch: [author: fetchValue]])
+
+ then:
+ 1 * query."$expectedMethod"('author')
+
+ where:
+ fetchValue | expectedMethod
+ FetchType.EAGER | 'join'
+ FetchType.LAZY | 'select'
+ 'eager' | 'join'
+ 'join' | 'join'
+ 'lazy' | 'select'
+ 'select' | 'select'
+ 'unrecognised' | 'select'
+ }
+
+ void "populateArgumentsForCriteria(Query) applies an explicit JoinType fetch entry via the two-arg join"() {
+ given:
+ Query query = Mock(Query) {
+ getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext }
+ }
+
+ when:
+ DynamicFinder.populateArgumentsForCriteria(FinderTestEntity, query, [fetch: [author: JoinType.LEFT]])
+
+ then:
+ 1 * query.join('author', JoinType.LEFT)
+ }
+
+ void "populateArgumentsForCriteria(Query) applies cache and lock flags"() {
+ given:
+ Query query = Mock(Query) {
+ getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext }
+ }
+
+ when:
+ DynamicFinder.populateArgumentsForCriteria(FinderTestEntity, query, [cache: true, lock: true])
+
+ then:
+ 1 * query.cache(true)
+ 1 * query.lock(true)
+ }
+
+ void "populateArgumentsForCriteria(Query) passes the raw argument map to a QueryArgumentsAware query"() {
+ given:
+ Query query = Mock(Query, additionalInterfaces: [QueryArgumentsAware]) {
+ getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext }
+ }
+ Map argMap = [sort: 'name']
+
+ when:
+ DynamicFinder.populateArgumentsForCriteria(FinderTestEntity, query, argMap)
+
+ then:
+ 1 * ((QueryArgumentsAware) query).setArguments(argMap)
+ }
+
+ void "populateArgumentsForCriteria(BuildableCriteria) is a no-op for a null argument map"() {
+ given:
+ BuildableCriteria criteria = Mock(BuildableCriteria)
+
+ when:
+ DynamicFinder.populateArgumentsForCriteria(criteria, null)
+
+ then:
+ 0 * criteria._
+ }
+
+ void "populateArgumentsForCriteria(BuildableCriteria) applies cache and sort, but has no lock/max/offset support"() {
+ given:
+ BuildableCriteria criteria = Mock(BuildableCriteria)
+
+ when:
+ DynamicFinder.populateArgumentsForCriteria(criteria, [cache: true, sort: 'name'])
+
+ then:
+ 1 * criteria.cache(true)
+ 1 * criteria.order({ Query.Order order -> order.property == 'name' })
+ 0 * criteria.lock(_)
+ 0 * criteria.max(_)
+ 0 * criteria.offset(_)
+ }
+
+ void "populateArgumentsForCriteria(BuildableCriteria) applies a descending order for a single-field CharSequence sort"() {
+ given:
+ BuildableCriteria criteria = Mock(BuildableCriteria)
+
+ when:
+ DynamicFinder.populateArgumentsForCriteria(criteria, [sort: 'name', order: 'desc'])
+
+ then:
+ 1 * criteria.order({ Query.Order order -> order.property == 'name' && order.direction == Query.Order.Direction.DESC && order.ignoreCase })
+ }
+
+ void "populateArgumentsForCriteria(BuildableCriteria) honours ignoreCase: false for a single-field CharSequence sort"() {
+ given:
+ BuildableCriteria criteria = Mock(BuildableCriteria)
+
+ when:
+ DynamicFinder.populateArgumentsForCriteria(criteria, [sort: 'name', ignoreCase: false])
+
+ then:
+ 1 * criteria.order({ Query.Order order -> order.property == 'name' && order.direction == Query.Order.Direction.ASC && !order.ignoreCase })
+ }
+
+ void "populateArgumentsForCriteria(BuildableCriteria) applies fetch strategies for EAGER and LAZY associations"() {
+ given:
+ BuildableCriteria criteria = Mock(BuildableCriteria)
+
+ when:
+ DynamicFinder.populateArgumentsForCriteria(criteria, [fetch: [author: FetchType.EAGER, tags: FetchType.LAZY]])
+
+ then:
+ 1 * criteria.join('author')
+ 1 * criteria.select('tags')
+ }
+
+ void "populateArgumentsForCriteria(BuildableCriteria) applies a multi-field sort Map, honouring each entry's own direction"() {
+ given:
+ 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.DESC })
+ }
+
+ void "populateArgumentsForCriteria(BuildableCriteria) ignores the top-level order argument for a sort Map, as the Query overload does"() {
+ 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.ASC })
+ 1 * criteria.order({ Query.Order order -> order.property == 'age' && order.direction == Query.Order.Direction.DESC })
+ }
+
+ void "populateArgumentsForCriteria(BuildableCriteria) defaults a null sort Map entry direction to ascending"() {
+ given:
+ BuildableCriteria criteria = Mock(BuildableCriteria)
+
+ when:
+ DynamicFinder.populateArgumentsForCriteria(criteria, [sort: [name: null]])
+
+ then:
+ 1 * criteria.order({ Query.Order order -> order.property == 'name' && order.direction == Query.Order.Direction.ASC })
+ }
+
+ @Unroll
+ void "populateArgumentsForCriteria(BuildableCriteria) rejects a sort key #description without echoing it"() {
+ given:
+ BuildableCriteria criteria = Mock(BuildableCriteria)
+
+ when:
+ DynamicFinder.populateArgumentsForCriteria(criteria, [sort: sort])
+
+ then:
+ IllegalArgumentException e = thrown()
+ e.message == 'Invalid sort property'
+ 0 * criteria.order(_)
+
+ where:
+ sort | description
+ 'name desc' | 'carrying a direction'
+ 'name, age' | 'carrying a second expression'
+ }
+
+ void "populateArgumentsForCriteria(BuildableCriteria) rejects an order direction that is neither asc nor desc"() {
+ given:
+ BuildableCriteria criteria = Mock(BuildableCriteria)
+
+ when:
+ DynamicFinder.populateArgumentsForCriteria(criteria, [sort: 'name', order: 'sideways'])
+
+ then:
+ IllegalArgumentException e = thrown()
+ e.message == 'Invalid sort direction'
+ 0 * criteria.order(_)
+ }
+
+ 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:
+ input | expected
+ FetchType.EAGER | FetchType.EAGER
+ FetchType.LAZY | FetchType.LAZY
+ 'eager' | FetchType.EAGER
+ 'join' | FetchType.EAGER
+ 'lazy' | FetchType.LAZY
+ 'select' | FetchType.LAZY
+ 'anything-else' | FetchType.LAZY
+ null | FetchType.LAZY
+ }
+
+ void "applySortForMap applies ascending/descending per-entry order values, defaulting to ascending"() {
+ given:
+ Query query = Mock(Query)
+
+ when:
+ DynamicFinder.applySortForMap(query, [name: 'asc', age: 'desc', title: null], true)
+
+ then:
+ 1 * query.order({ Query.Order o -> o.property == 'name' && o.direction == Query.Order.Direction.ASC })
+ 1 * query.order({ Query.Order o -> o.property == 'age' && o.direction == Query.Order.Direction.DESC })
+ 1 * query.order({ Query.Order o -> o.property == 'title' && o.direction == Query.Order.Direction.ASC })
+ }
+
+ void "applyDetachedCriteria is a no-op for a null detached criteria"() {
+ given:
+ Query query = Mock(Query)
+
+ when:
+ DynamicFinder.applyDetachedCriteria(query, null)
+
+ then:
+ 0 * query._
+ }
+
+ void "applyDetachedCriteria merges fetch strategies, criteria, projections and orders onto the live query"() {
+ given:
+ Query query = Mock(Query)
+ MethodExpression.Equal expression = new MethodExpression.Equal('name')
+ expression.setArguments(['Bob'] as Object[])
+ Query.Criterion criterion = expression.createCriterion()
+ Query.Projection projection = Mock(Query.Projection)
+ Query.Order order = Query.Order.asc('age')
+ AbstractDetachedCriteria detachedCriteria = Stub(AbstractDetachedCriteria) {
+ getFetchStrategies() >> [author: FetchType.EAGER, tags: FetchType.LAZY]
+ getJoinTypes() >> [author: JoinType.LEFT]
+ getCriteria() >> [criterion]
+ getProjections() >> [projection]
+ getOrders() >> [order]
+ }
+ Query.ProjectionList projectionList = Mock(Query.ProjectionList)
+ query.projections() >> projectionList
+
+ when:
+ DynamicFinder.applyDetachedCriteria(query, detachedCriteria)
+
+ then:
+ 1 * query.join('author', JoinType.LEFT)
+ 1 * query.select('tags')
+ 1 * query.add(criterion)
+ 1 * projectionList.add(projection)
+ 1 * query.order(order)
+ }
+
+ void "applyDetachedCriteria joins without an explicit JoinType when none is configured for the association"() {
+ given:
+ Query query = Mock(Query)
+ AbstractDetachedCriteria detachedCriteria = Stub(AbstractDetachedCriteria) {
+ getFetchStrategies() >> [author: FetchType.EAGER]
+ getJoinTypes() >> [:]
+ getCriteria() >> []
+ getProjections() >> []
+ getOrders() >> []
+ }
+
+ when:
+ DynamicFinder.applyDetachedCriteria(query, detachedCriteria)
+
+ then:
+ 1 * query.join('author')
+ 0 * query.join('author', _)
+ }
+
+ void "applyAdditionalCriteria is a no-op for a null closure"() {
+ given:
+ Query query = Mock(Query)
+
+ when:
+ DynamicFinder.applyAdditionalCriteria(query, null)
+
+ then:
+ 0 * query._
+ }
+
+ void "applyAdditionalCriteria builds a real CriteriaBuilder from the query and adds the closure's criterion"() {
+ given:
+ PersistentEntity entity = Stub(PersistentEntity) {
+ getJavaClass() >> FinderTestEntity
+ getPropertyByName('name') >> nameProperty
+ }
+ Session session = Stub(Session) {
+ getMappingContext() >> mappingContext
+ }
+ Query query = Mock(Query) {
+ getEntity() >> entity
+ getSession() >> session
+ }
+ mappingContext.getPersistentEntity(FinderTestEntity.name) >> entity
+
+ when:
+ DynamicFinder.applyAdditionalCriteria(query, { eq('name', 'Bob') })
+
+ then:
+ 1 * query.add({ Query.Criterion criterion ->
+ criterion instanceof Query.Equals && criterion.property == 'name' && criterion.value == 'Bob'
+ })
+ }
+
+ void "buildQuery creates a query from the session, applies additional criteria, detached criteria, arguments and the junction"() {
+ given:
+ Query query = Mock(Query) {
+ getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext }
+ }
+ Session session = Stub(Session) {
+ createQuery(FinderTestEntity) >> query
+ getMappingContext() >> mappingContext
+ }
+ DynamicFinderInvocation invocation = findByGrammar.createFinderInvocation(
+ FinderTestEntity, 'findByName', null, ['Bob'] as Object[])
+
+ when:
+ Query result = findByGrammar.buildQuery(invocation, session)
+
+ then:
+ result.is(query)
+ 1 * query.add({ it instanceof Query.Conjunction })
+ }
+
+ void "buildQuery consumes the remaining argument map via configureQueryWithArguments"() {
+ given:
+ Query query = Mock(Query) {
+ getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext }
+ }
+ Session session = Stub(Session) {
+ createQuery(FinderTestEntity) >> query
+ getMappingContext() >> mappingContext
+ }
+ DynamicFinderInvocation invocation = findByGrammar.createFinderInvocation(
+ FinderTestEntity, 'findByName', null, ['Bob', [max: 5]] as Object[])
+
+ when:
+ findByGrammar.buildQuery(invocation, session)
+
+ then:
+ 1 * query.max(5)
+ }
+
+ void "buildQuery ignores a remaining argument that is not a Map"() {
+ given:
+ // configureQueryWithArguments only inspects arguments[0] as a criteria Map when it actually
+ // is one - a non-Map remaining argument must fall through exactly like having no remaining
+ // arguments at all.
+ Query query = Mock(Query) {
+ getEntity() >> Stub(PersistentEntity) { getMappingContext() >> mappingContext }
+ }
+ Session session = Stub(Session) {
+ createQuery(FinderTestEntity) >> query
+ getMappingContext() >> mappingContext
+ }
+ DynamicFinderInvocation invocation = findByGrammar.createFinderInvocation(
+ FinderTestEntity, 'findByName', null, ['Bob', 'unexpectedExtra'] as Object[])
+
+ when:
+ findByGrammar.buildQuery(invocation, session)
+
+ then:
+ 0 * query.max(_)
+ 0 * query.offset(_)
}
}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/FindOrSaveByDynamicFinderSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/FindOrSaveByDynamicFinderSpec.groovy
new file mode 100644
index 0000000000..fefbe2913a
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/FindOrSaveByDynamicFinderSpec.groovy
@@ -0,0 +1,63 @@
+/*
+ * 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.annotation.Entity
+import org.grails.datastore.mapping.simple.SimpleMapDatastore
+import spock.lang.AutoCleanup
+import spock.lang.Specification
+
+/**
+ * Drives {@code findOrSaveBy*} through a {@code SimpleMapDatastore}-backed entity: the composed
+ * {@link SingleResultFinder#findOrSaveBy} shares its construct-on-miss logic with
+ * {@code findOrCreateBy*} and differs only in saving the constructed instance.
+ */
+class FindOrSaveByDynamicFinderSpec extends Specification {
+
+ @AutoCleanup
+ SimpleMapDatastore datastore = new SimpleMapDatastore(FindOrSaveByThing)
+
+ void "findOrSaveBy creates and persists a new instance when no match exists"() {
+ when:
+ def result = FindOrSaveByThing.findOrSaveByTitle('brand new')
+
+ then: "the new instance is actually saved (gets an id), unlike findOrCreateBy which only\n" +
+ "constructs it in memory"
+ result != null
+ result.title == 'brand new'
+ result.id != null
+ }
+
+ void "findOrSaveBy returns the existing match without creating a duplicate"() {
+ given:
+ def existing = FindOrSaveByThing.newInstance(title: 'already here').save(flush: true)
+
+ when:
+ def result = FindOrSaveByThing.findOrSaveByTitle('already here')
+
+ then:
+ result.title == 'already here'
+ result.id == existing.id
+ }
+}
+
+@Entity
+class FindOrSaveByThing {
+ String title
+}
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/FindOrSaveByFinderSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/FindOrSaveByFinderSpec.groovy
deleted file mode 100644
index 731d8ee9ba..0000000000
--- a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/FindOrSaveByFinderSpec.groovy
+++ /dev/null
@@ -1,88 +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 grails.gorm.annotation.Entity
-import org.grails.datastore.gorm.DatastoreResolver
-import org.grails.datastore.mapping.simple.SimpleMapDatastore
-import spock.lang.AutoCleanup
-import spock.lang.Specification
-
-/**
- * The diff simplified this class drastically - dropped a whole bespoke {@code doInvokeInternal}
- * override (~25 lines of manual property-map construction and constructor invocation via
- * {@code MetaClass}) in favour of just overriding {@code shouldSaveOnCreate()} on the shared
- * {@code FindOrCreateByFinder} base, plus added 3 new constructor overloads
- * ({@code (String, Datastore)}, {@code (String, DatastoreResolver, MappingContext)},
- * {@code (MappingContext)}) matching sibling finder classes' shape. The 2-arg
- * {@code (DatastoreResolver, MappingContext)} and single-arg {@code (Datastore)} constructors are
- * the ones `DefaultGormApiFactory`/`GormEnhancer` actually register in production and were already
- * covered incidentally; the 3 new overloads have no callers anywhere in the codebase.
- */
-class FindOrSaveByFinderSpec extends Specification {
-
- @AutoCleanup
- SimpleMapDatastore datastore = new SimpleMapDatastore(FindOrSaveByFinderThing)
-
- void "findOrSaveBy creates and persists a new instance when no match exists"() {
- when:
- def result = FindOrSaveByFinderThing.findOrSaveByTitle('brand new')
-
- then: "shouldSaveOnCreate()==true means the new instance is actually saved (gets an id),\n" +
- "unlike findOrCreateBy which only constructs it in memory"
- result != null
- result.title == 'brand new'
- result.id != null
- }
-
- void "findOrSaveBy returns the existing match without creating a duplicate"() {
- given:
- def existing = FindOrSaveByFinderThing.newInstance(title: 'already here').save(flush: true)
-
- when:
- def result = FindOrSaveByFinderThing.findOrSaveByTitle('already here')
-
- then:
- result.title == 'already here'
- result.id == existing.id
- }
-
- void "the (String, Datastore) constructor is usable directly"() {
- expect:
- new FindOrSaveByFinder(FindOrSaveByFinder.METHOD_PATTERN, datastore) != null
- }
-
- void "the (String, DatastoreResolver, MappingContext) constructor is usable directly"() {
- given:
- def resolver = { datastore } as DatastoreResolver
-
- expect:
- new FindOrSaveByFinder(FindOrSaveByFinder.METHOD_PATTERN, resolver, datastore.mappingContext) != null
- }
-
- void "the (MappingContext) constructor is usable directly"() {
- expect:
- new FindOrSaveByFinder(datastore.mappingContext) != null
- }
-}
-
-@Entity
-class FindOrSaveByFinderThing {
- String title
-}
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..92ef168ee3
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/FinderSupportSpec.groovy
@@ -0,0 +1,123 @@
+/*
+ * 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.gorm.DatastoreResolver
+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.VoidSessionCallback
+import spock.lang.Specification
+
+/**
+ * Exercises {@link FinderSupport} - the shared session-execution helper every synchronous finder
+ * in this package calls with its own {@code DatastoreResolver} field, rather than inheriting an
+ * {@code execute} method. The datastore is resolved on every call, so a lazy resolver is honoured.
+ */
+class FinderSupportSpec extends Specification {
+
+ void "resolverFor wraps a datastore in a resolver that always returns it"() {
+ given:
+ Datastore datastore = Stub(Datastore)
+
+ expect:
+ FinderSupport.resolverFor(datastore).resolve().is(datastore)
+ }
+
+ void "resolverFor returns null for a null datastore, preserving stateless mode"() {
+ expect:
+ FinderSupport.resolverFor(null) == null
+ }
+
+ void "execute(SessionCallback) throws IllegalStateException when the resolver 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 with null datastore'
+ }
+
+ void "execute(SessionCallback) throws IllegalStateException when the resolver resolves no datastore"() {
+ given:
+ DatastoreResolver resolver = Mock(DatastoreResolver)
+ SessionCallback callback = { Session session -> 'result' } as SessionCallback
+
+ when:
+ FinderSupport.execute(resolver, callback)
+
+ then:
+ IllegalStateException e = thrown()
+ e.message == 'Cannot execute session query with null datastore'
+ }
+
+ void "execute(VoidSessionCallback) throws IllegalStateException when the resolver is null"() {
+ given:
+ VoidSessionCallback callback = { Session session -> } as VoidSessionCallback
+
+ when:
+ FinderSupport.execute(null, callback)
+
+ then:
+ IllegalStateException e = thrown()
+ e.message == 'Cannot execute session query with null datastore'
+ }
+
+ void "execute(SessionCallback) resolves the datastore on every call and runs the callback in its session"() {
+ given:
+ Session session = Stub(Session)
+ Datastore datastore = Stub(Datastore) {
+ hasCurrentSession() >> true
+ getCurrentSession() >> session
+ }
+ DatastoreResolver resolver = Mock(DatastoreResolver)
+ SessionCallback callback = { Session s -> s } as SessionCallback
+
+ when:
+ def first = FinderSupport.execute(resolver, callback)
+ def second = FinderSupport.execute(resolver, callback)
+
+ then:
+ 2 * resolver.resolve() >> datastore
+ first.is(session)
+ second.is(session)
+ }
+
+ void "execute(VoidSessionCallback) resolves the datastore and runs the callback in its session"() {
+ given:
+ Session session = Stub(Session)
+ Datastore datastore = Stub(Datastore) {
+ hasCurrentSession() >> true
+ getCurrentSession() >> session
+ }
+ DatastoreResolver resolver = Mock(DatastoreResolver)
+ List<Session> seen = []
+ VoidSessionCallback callback = { Session s -> seen << s } as VoidSessionCallback
+
+ when:
+ FinderSupport.execute(resolver, callback)
+
+ then:
+ 1 * resolver.resolve() >> datastore
+ seen == [session]
+ }
+}
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..7560eb3684
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/ListOrderByFinderSpec.groovy
@@ -0,0 +1,210 @@
+/*
+ * 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.gorm.DatastoreResolver
+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 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()
+ }
+ PersistentEntity persistentEntity = Stub(PersistentEntity) {
+ getMappingContext() >> mappingContext
+ getPropertyByName('age') >> Stub(PersistentProperty) { getName() >> 'age' }
+ }
+ Query query = Mock(Query) {
+ getEntity() >> persistentEntity
+ }
+ Session session = Stub(Session) {
+ createQuery(FinderTestEntity) >> query
+ }
+ Datastore datastore = Stub(Datastore) {
+ hasCurrentSession() >> true
+ getCurrentSession() >> session
+ }
+ ListOrderByFinder finder = new ListOrderByFinder(datastore)
+
+ @Unroll
+ void "isMethodMatch('#methodName') == #matches"() {
+ expect:
+ finder.isMethodMatch(methodName) == matches
+
+ where:
+ methodName | matches
+ 'listOrderByName' | true
+ 'listOrderByNameAndAge' | true
+ 'somethingElse' | false
+ }
+
+ void "setPattern replaces the method-name pattern"() {
+ when:
+ finder.setPattern('(customOrderBy)(\\w+)')
+
+ then:
+ finder.isMethodMatch('customOrderByName')
+ !finder.isMethodMatch('listOrderByName')
+ }
+
+ void "invoke defaults to ascending order when no Map argument is supplied and lists the results without a distinct projection"() {
+ when:
+ finder.invoke(FinderTestEntity, 'listOrderByAge', [] as Object[])
+
+ then:
+ 1 * query.order({ Query.Order order -> order.property == 'age' && order.direction == Query.Order.Direction.ASC })
+ 0 * query.projections()
+ 1 * query.list()
+ 0 * query.singleResult()
+ }
+
+ void "invoke orders by every And-separated property in the method name, in order"() {
+ when:
+ finder.invoke(FinderTestEntity, 'listOrderByNameAndAge', [] as Object[])
+
+ then:
+ 1 * query.order({ Query.Order order -> order.property == 'name' && order.direction == Query.Order.Direction.ASC })
+
+ then:
+ 1 * query.order({ Query.Order order -> order.property == 'age' && order.direction == Query.Order.Direction.ASC })
+ 1 * query.list()
+ }
+
+ void "invoke lower-cases only the first character of each property segment"() {
+ when:
+ finder.invoke(FinderTestEntity, 'listOrderByISize', [] as Object[])
+
+ then:
+ 1 * query.order({ Query.Order order -> order.property == 'iSize' })
+ 1 * query.list()
+ }
+
+ void "invoke ignores a non-Map first argument, defaulting to ascending order without delegating to DynamicFinder.populateArgumentsForCriteria"() {
+ when:
+ finder.invoke(FinderTestEntity, 'listOrderByAge', ['not a map'] as Object[])
+
+ then:
+ 1 * query.order({ Query.Order order -> order.property == 'age' && order.direction == Query.Order.Direction.ASC })
+ 0 * query.max(_)
+ 1 * query.list()
+ }
+
+ @Unroll
+ void "invoke stays ascending when the Map's order entry is '#order'"() {
+ when:
+ finder.invoke(FinderTestEntity, 'listOrderByAge', [order != null ? [order: order] : [:]] as Object[])
+
+ then:
+ 1 * query.order({ Query.Order o -> o.property == 'age' && o.direction == Query.Order.Direction.ASC })
+ 1 * query.list()
+
+ where:
+ order << [null, 'asc', 'ASC', ' Asc ', '']
+ }
+
+ @Unroll
+ void "invoke flips to descending order for '#order' and delegates the remaining Map entries to DynamicFinder.populateArgumentsForCriteria"() {
+ 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: order, max: 5]] as Object[])
+
+ then:
+ 1 * query.order({ Query.Order o -> o.property == 'age' && o.direction == Query.Order.Direction.DESC })
+ 1 * query.max(5)
+ 1 * query.list()
+
+ where:
+ order << ['desc', 'DESC', ' Desc ']
+ }
+
+ @Unroll
+ void "invoke rejects the order direction '#order' without echoing it and before touching the query"() {
+ when:
+ finder.invoke(FinderTestEntity, 'listOrderByAge', [[order: order]] as Object[])
+
+ then:
+ IllegalArgumentException e = thrown()
+ e.message == 'Invalid sort direction'
+ 0 * query.order(_)
+ 0 * query.list()
+
+ where:
+ order << ['sideways', 'desc extra']
+ }
+
+ void "invoke applies an additional criteria closure to the query"() {
+ given:
+ Closure additionalCriteria = { -> }
+ query.getSession() >> session
+ persistentEntity.getJavaClass() >> FinderTestEntity
+ session.getMappingContext() >> mappingContext
+
+ when:
+ finder.invoke(FinderTestEntity, 'listOrderByAge', additionalCriteria, [] as Object[])
+
+ then:
+ 1 * query.getSession() >> session
+ 1 * query.list()
+ }
+
+ void "invoke resolves its datastore through the DatastoreResolver on every invocation, not at construction"() {
+ given:
+ DatastoreResolver resolver = Mock(DatastoreResolver)
+
+ when:
+ ListOrderByFinder resolved = new ListOrderByFinder(resolver, mappingContext)
+
+ then:
+ 0 * resolver.resolve()
+
+ when:
+ resolved.invoke(FinderTestEntity, 'listOrderByAge', [] as Object[])
+
+ then:
+ 1 * resolver.resolve() >> datastore
+ 1 * query.list()
+ }
+
+ void "invoke throws IllegalStateException when constructed with a null datastore"() {
+ when:
+ new ListOrderByFinder((Datastore) null).invoke(FinderTestEntity, 'listOrderByAge', [] as Object[])
+
+ then:
+ IllegalStateException e = thrown()
+ e.message == 'Cannot execute session query with null datastore'
+ }
+}
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..75642b4c28
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/ListResultFinderSpec.groovy
@@ -0,0 +1,222 @@
+/*
+ * 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.gorm.DatastoreResolver
+import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.core.Session
+import org.grails.datastore.mapping.model.MappingContext
+import org.grails.datastore.mapping.model.PersistentEntity
+import org.grails.datastore.mapping.query.Query
+import org.springframework.core.convert.support.DefaultConversionService
+import spock.lang.Specification
+import spock.lang.Unroll
+
+/**
+ * Exercises {@link ListResultFinder} - the composed replacement for the deleted
+ * FindAllByFinder/FindAllByBooleanFinder pair.
+ */
+class ListResultFinderSpec extends Specification {
+
+ MappingContext mappingContext = Stub(MappingContext)
+ PersistentEntity persistentEntity = Stub(PersistentEntity)
+ Datastore datastore = Stub(Datastore) {
+ getMappingContext() >> mappingContext
+ }
+
+ void setup() {
+ mappingContext.getConversionService() >> new DefaultConversionService()
+ mappingContext.getPersistentEntity(FinderTestEntity.name) >> persistentEntity
+ persistentEntity.getMappingContext() >> mappingContext
+ persistentEntity.getPropertyByName('name') >> Stub(org.grails.datastore.mapping.model.PersistentProperty) {
+ getName() >> 'name'
+ getType() >> String
+ }
+ }
+
+ @Unroll
+ void "findAllBy isMethodMatch('#methodName') == #matches"() {
+ expect:
+ ListResultFinder.findAllBy(datastore).isMethodMatch(methodName) == matches
+
+ where:
+ methodName | matches
+ 'findAllByName' | true
+ 'findAllByNameAndAge' | true
+ 'somethingElse' | false
+ }
+
+ @Unroll
+ void "findAllByBoolean isMethodMatch('#methodName') == #matches"() {
+ expect:
+ ListResultFinder.findAllByBoolean(datastore).isMethodMatch(methodName) == matches
+
+ where:
+ methodName | matches
+ 'findAllActiveByName' | true
+ 'findAllActive' | true
+ 'somethingElse' | false
+ }
+
+ void "setPattern delegates to the grammar"() {
+ given:
+ ListResultFinder finder = ListResultFinder.findAllBy(datastore)
+
+ expect:
+ finder.isMethodMatch('findAllByName')
+ !finder.isMethodMatch('customPrefixName')
+
+ when:
+ finder.setPattern('(customPrefix)([A-Z]\\w*)')
+
+ then:
+ finder.isMethodMatch('customPrefixName')
+ !finder.isMethodMatch('findAllByName')
+ }
+
+ void "findAllBy runs the full round trip without a distinct projection and returns the full list"() {
+ given:
+ List<FinderTestEntity> expected = [new FinderTestEntity(name: 'Bob')]
+ Query query = Mock(Query) {
+ getEntity() >> persistentEntity
+ }
+ 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)
+ 0 * query.projections()
+ 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 query = Mock(Query) {
+ getEntity() >> persistentEntity
+ }
+ 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 })
+ 0 * query.projections()
+ 1 * query.list() >> expected
+ }
+
+ void "invoke(Class, methodName, DetachedCriteria, Object[]) with a null detachedCriteria never merges anything onto the built query"() {
+ given:
+ List<FinderTestEntity> expected = [new FinderTestEntity(name: 'Bob')]
+ Query query = Mock(Query) {
+ getEntity() >> persistentEntity
+ }
+ Session session = Stub(Session) {
+ createQuery(FinderTestEntity) >> query
+ }
+ datastore.hasCurrentSession() >> true
+ datastore.getCurrentSession() >> session
+
+ when:
+ Object result = ListResultFinder.findAllBy(datastore).invoke(FinderTestEntity, 'findAllByName', (grails.gorm.DetachedCriteria) null, ['Bob'] as Object[])
+
+ then:
+ result.is(expected)
+ 0 * query.add({ it instanceof Query.PropertyCriterion })
+ 0 * query.projections()
+ 1 * query.list() >> expected
+ }
+
+ void "invoke throws IllegalStateException when constructed in stateless mode"() {
+ when:
+ ListResultFinder.findAllBy(mappingContext).invoke(FinderTestEntity, 'findAllByName', ['Bob'] as Object[])
+
+ then:
+ thrown(IllegalStateException)
+ }
+
+ void "findAllByBoolean invoke throws IllegalStateException when constructed in stateless mode"() {
+ when:
+ ListResultFinder.findAllByBoolean(mappingContext).invoke(FinderTestEntity, 'findAllActiveByName', ['Bob'] as Object[])
+
+ then:
+ thrown(IllegalStateException)
+ }
+ void "findAllBy resolves its datastore through the DatastoreResolver on every invocation, not at construction"() {
+ given:
+ Query query = Mock(Query) {
+ getEntity() >> persistentEntity
+ projections() >> Mock(Query.ProjectionList)
+ }
+ Session session = Stub(Session) {
+ createQuery(FinderTestEntity) >> query
+ }
+ Datastore lateBoundDatastore = Stub(Datastore) {
+ hasCurrentSession() >> true
+ getCurrentSession() >> session
+ }
+ DatastoreResolver resolver = Mock(DatastoreResolver)
+
+ when:
+ def finder = ListResultFinder.findAllBy(resolver, mappingContext)
+
+ then:
+ 0 * resolver.resolve()
+
+ when:
+ finder.invoke(FinderTestEntity, 'findAllByName', ['Bob'] as Object[])
+
+ then:
+ 1 * resolver.resolve() >> lateBoundDatastore
+ 1 * query.list() >> []
+ }
+
+ void "findAllBy throws IllegalStateException when the DatastoreResolver resolves no datastore"() {
+ given:
+ DatastoreResolver resolver = Mock(DatastoreResolver)
+
+ when:
+ ListResultFinder.findAllBy(resolver, mappingContext).invoke(FinderTestEntity, 'findAllByName', ['Bob'] as Object[])
+
+ then:
+ IllegalStateException e = thrown()
+ e.message == 'Cannot execute session query with null datastore'
+ }
+}
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..da39076000
--- /dev/null
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/finders/SingleResultFinderSpec.groovy
@@ -0,0 +1,412 @@
+/*
+ * 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.gorm.DatastoreResolver
+import org.grails.datastore.mapping.core.Datastore
+import org.grails.datastore.mapping.core.Session
+import org.grails.datastore.mapping.core.exceptions.ConfigurationException
+import org.grails.datastore.mapping.model.MappingContext
+import org.grails.datastore.mapping.model.PersistentEntity
+import org.grails.datastore.mapping.model.PersistentProperty
+import org.grails.datastore.mapping.query.Query
+import org.springframework.core.convert.support.DefaultConversionService
+import spock.lang.Specification
+import spock.lang.Unroll
+
+/**
+ * Exercises {@link SingleResultFinder} - the composed replacement for the deleted
+ * AbstractFindByFinder/FindByFinder/FindByBooleanFinder/FindOrCreateByFinder/FindOrSaveByFinder
+ * class hierarchy. Every factory method builds its own {@link DynamicFinder} grammar instance
+ * (never shared across finders, since a shared instance would be a real thread-safety hazard
+ * given finder instances are long-lived and invoked from arbitrary request threads), so each
+ * test constructs the specific factory under test directly rather than reusing a single shared
+ * field.
+ */
+class SingleResultFinderSpec extends Specification {
+
+ PersistentProperty nameProperty = Stub(PersistentProperty) {
+ getName() >> 'name'
+ getType() >> String
+ }
+ PersistentProperty ageProperty = Stub(PersistentProperty) {
+ getName() >> 'age'
+ getType() >> Integer
+ }
+ MappingContext mappingContext = Stub(MappingContext)
+ PersistentEntity persistentEntity = Stub(PersistentEntity)
+ Datastore datastore = Stub(Datastore) {
+ getMappingContext() >> mappingContext
+ }
+
+ // Every property lookup a test's method name touches must be stubbed explicitly: Spock's Stub
+ // "smart null" returns a DUMMY PersistentProperty (not null) for any unstubbed
+ // getPropertyByName(...) call, since PersistentProperty is an interface - that dummy's own
+ // unstubbed getType() then needs a dummy java.lang.Class, which Spock cannot create (final
+ // class), crashing every test that reaches convertArguments for an unstubbed property.
+ void setup() {
+ mappingContext.getConversionService() >> new DefaultConversionService()
+ mappingContext.getPersistentEntity(FinderTestEntity.name) >> persistentEntity
+ persistentEntity.getMappingContext() >> mappingContext
+ persistentEntity.getPropertyByName('name') >> nameProperty
+ persistentEntity.getPropertyByName('age') >> ageProperty
+ }
+
+ @Unroll
+ void "findBy isMethodMatch('#methodName') == #matches"() {
+ expect:
+ SingleResultFinder.findBy(datastore).isMethodMatch(methodName) == matches
+
+ where:
+ methodName | matches
+ 'findByName' | true
+ 'findByNameAndAge' | true
+ 'somethingElse' | false
+ }
+
+ @Unroll
+ void "findByBoolean isMethodMatch('#methodName') == #matches"() {
+ expect:
+ SingleResultFinder.findByBoolean(datastore).isMethodMatch(methodName) == matches
+
+ where:
+ methodName | matches
+ 'findActiveByName' | true
+ 'findActive' | true
+ 'somethingElse' | false
+ }
+
+ void "setPattern delegates to the grammar"() {
+ given:
+ SingleResultFinder finder = SingleResultFinder.findBy(datastore)
+
+ expect:
+ finder.isMethodMatch('findByName')
+ !finder.isMethodMatch('customPrefixName')
+
+ when:
+ finder.setPattern('(customPrefix)([A-Z]\\w*)')
+
+ then:
+ finder.isMethodMatch('customPrefixName')
+ !finder.isMethodMatch('findByName')
+ }
+
+ void "findBy runs the full round trip through the real DatastoreUtils.execute seam and returns singleResult()"() {
+ given:
+ FinderTestEntity expected = new FinderTestEntity(name: 'Bob')
+ Query query = Mock(Query) {
+ getEntity() >> persistentEntity
+ singleResult() >> expected
+ }
+ Session session = Stub(Session) {
+ createQuery(FinderTestEntity) >> query
+ }
+ // DatastoreUtils.execute() takes the "existing bound session" branch when
+ // Datastore.hasCurrentSession() answers true, invoking the callback directly with that
+ // session - the simplest reliable seam for exercising the real round trip without wiring
+ // Spring's transaction synchronization machinery.
+ datastore.hasCurrentSession() >> true
+ datastore.getCurrentSession() >> session
+
+ when:
+ Object result = SingleResultFinder.findBy(datastore).invoke(FinderTestEntity, 'findByName', ['Bob'] as Object[])
+
+ then:
+ result.is(expected)
+ 1 * query.add({ it instanceof Query.Conjunction })
+ }
+
+ void "invoke(Class, methodName, DetachedCriteria, Object[]) merges the detached criteria onto the built query"() {
+ given:
+ // Called reflectively (dynamic Groovy dispatch, not part of FinderMethod) by
+ // AbstractDetachedCriteria#methodMissing - this is the seam a real
+ // `SomeDetachedCriteria.findByName(...)` call goes through.
+ FinderTestEntity expected = new FinderTestEntity(name: 'Bob')
+ Query query = Mock(Query) {
+ getEntity() >> persistentEntity
+ singleResult() >> expected
+ }
+ Session session = Stub(Session) {
+ createQuery(FinderTestEntity) >> query
+ }
+ datastore.hasCurrentSession() >> true
+ datastore.getCurrentSession() >> session
+ grails.gorm.DetachedCriteria detachedCriteria = Stub(grails.gorm.DetachedCriteria) {
+ getFetchStrategies() >> [:]
+ getCriteria() >> [org.grails.datastore.mapping.query.Restrictions.eq('age', 42)]
+ getProjections() >> []
+ getOrders() >> []
+ }
+
+ when:
+ Object result = SingleResultFinder.findBy(datastore).invoke(FinderTestEntity, 'findByName', detachedCriteria, ['Bob'] as Object[])
+
+ then:
+ result.is(expected)
+ 1 * query.add({ it instanceof Query.PropertyCriterion })
+ 1 * query.add({ it instanceof Query.Conjunction })
+ }
+
+ void "invoke(Class, methodName, DetachedCriteria, Object[]) with a null detachedCriteria never merges anything onto the built query"() {
+ given:
+ // Same reflective entry point as above, but for the case AbstractDetachedCriteria's own
+ // detached criteria has nothing to merge - proving the null-check actually guards the merge
+ // rather than it happening to be a no-op some other way.
+ FinderTestEntity expected = new FinderTestEntity(name: 'Bob')
+ Query query = Mock(Query) {
+ getEntity() >> persistentEntity
+ singleResult() >> expected
+ }
+ Session session = Stub(Session) {
+ createQuery(FinderTestEntity) >> query
+ }
+ datastore.hasCurrentSession() >> true
+ datastore.getCurrentSession() >> session
+
+ when:
+ Object result = SingleResultFinder.findBy(datastore).invoke(FinderTestEntity, 'findByName', (grails.gorm.DetachedCriteria) null, ['Bob'] as Object[])
+
+ then:
+ result.is(expected)
+ 0 * query.add({ it instanceof Query.PropertyCriterion })
+ 1 * query.add({ it instanceof Query.Conjunction })
+ }
+
+ void "invoke(Class, methodName) throws IllegalStateException when constructed in stateless mode"() {
+ when:
+ SingleResultFinder.findBy(mappingContext).invoke(FinderTestEntity, 'findByName', ['Bob'] as Object[])
+
+ then:
+ thrown(IllegalStateException)
+ }
+
+ void "findByBoolean invoke throws IllegalStateException when constructed in stateless mode"() {
+ when:
+ SingleResultFinder.findByBoolean(mappingContext).invoke(FinderTestEntity, 'findActiveByName', ['Bob'] as Object[])
+
+ then:
+ thrown(IllegalStateException)
+ }
+
+ void "findOrCreateBy rejects the Or operator without ever touching the datastore"() {
+ given:
+ Datastore datastoreThatMustNotBeUsed = Mock(Datastore) {
+ getMappingContext() >> mappingContext
+ }
+
+ when:
+ SingleResultFinder.findOrCreateBy(datastoreThatMustNotBeUsed).invoke(
+ FinderTestEntity, 'findOrCreateByNameOrAge', ['Bob', 42] as Object[])
+
+ then:
+ thrown(MissingMethodException)
+ 0 * datastoreThatMustNotBeUsed.hasCurrentSession()
+ 0 * datastoreThatMustNotBeUsed.connect()
+ }
+
+ @Unroll
+ void "findOrCreateBy rejects a #expressionType.simpleName expression as not equality-based"() {
+ given:
+ Datastore datastoreThatMustNotBeUsed = Mock(Datastore) {
+ getMappingContext() >> mappingContext
+ }
+
+ when:
+ SingleResultFinder.findOrCreateBy(datastoreThatMustNotBeUsed).invoke(
+ FinderTestEntity, "findOrCreateByAge${expressionType.simpleName}", [18] as Object[])
+
+ then:
+ thrown(ConfigurationException)
+
+ where:
+ expressionType << [MethodExpression.GreaterThan, MethodExpression.LessThan,
+ MethodExpression.GreaterThanEquals, MethodExpression.LessThanEquals]
+ }
+
+ void "findOrCreateBy constructs a new instance via the Map constructor when the query returns null, without saving it"() {
+ given:
+ Query query = Mock(Query) {
+ getEntity() >> persistentEntity
+ singleResult() >> null
+ }
+ Session session = Stub(Session) {
+ createQuery(FinderTestEntity) >> query
+ }
+ datastore.hasCurrentSession() >> true
+ datastore.getCurrentSession() >> session
+
+ when:
+ Object result = SingleResultFinder.findOrCreateBy(datastore).invoke(FinderTestEntity, 'findOrCreateByName', ['Bob'] as Object[])
+
+ then:
+ result instanceof FinderTestEntity
+ result.name == 'Bob'
+ !result.saved
+ }
+
+ void "findOrCreateBy wraps a ConversionException from singleResult() into a MissingMethodException"() {
+ given:
+ // Guards the onNullResult-configured path (findOrCreateBy/findOrSaveBy only): if the query
+ // itself fails to convert an argument rather than simply returning null, that failure must
+ // still surface as a MissingMethodException, not propagate as a raw ConversionException.
+ Query query = Mock(Query) {
+ getEntity() >> persistentEntity
+ singleResult() >> { throw new org.springframework.core.convert.ConversionFailedException(
+ org.springframework.core.convert.TypeDescriptor.valueOf(String), org.springframework.core.convert.TypeDescriptor.valueOf(Integer), 'Bob', new IllegalArgumentException()) }
+ }
+ Session session = Stub(Session) {
+ createQuery(FinderTestEntity) >> query
+ }
+ datastore.hasCurrentSession() >> true
+ datastore.getCurrentSession() >> session
+
+ when:
+ SingleResultFinder.findOrCreateBy(datastore).invoke(FinderTestEntity, 'findOrCreateByName', ['Bob'] as Object[])
+
+ then:
+ thrown(MissingMethodException)
+ }
+
+ void "findOrCreateBy invoke throws IllegalStateException when constructed in stateless mode"() {
+ when:
+ SingleResultFinder.findOrCreateBy(mappingContext).invoke(FinderTestEntity, 'findOrCreateByName', ['Bob'] as Object[])
+
+ then:
+ thrown(IllegalStateException)
+ }
+
+ void "findOrCreateBy throws MissingMethodException on the null-result path when a non-Equal expression reached it"() {
+ given:
+ // validate only rejects GT/LT/GTE/LTE - Like/InList/etc slip past it, so this defensive
+ // check in the null-result construction path is a genuinely separate branch.
+ Query query = Mock(Query) {
+ getEntity() >> persistentEntity
+ singleResult() >> null
+ }
+ Session session = Stub(Session) {
+ createQuery(FinderTestEntity) >> query
+ }
+ datastore.hasCurrentSession() >> true
+ datastore.getCurrentSession() >> session
+
+ when:
+ SingleResultFinder.findOrCreateBy(datastore).invoke(FinderTestEntity, 'findOrCreateByNameLike', ['Bo%'] as Object[])
+
+ then:
+ thrown(MissingMethodException)
+ }
+
+ void "findOrSaveBy rejects the Or operator, mirroring findOrCreateBy"() {
+ when:
+ SingleResultFinder.findOrSaveBy(datastore).invoke(FinderTestEntity, 'findOrSaveByNameOrAge', ['Bob', 42] as Object[])
+
+ then:
+ thrown(MissingMethodException)
+ }
+
+ void "findOrSaveBy constructs a new instance and saves it when the query returns null"() {
+ given:
+ Query query = Mock(Query) {
+ getEntity() >> persistentEntity
+ singleResult() >> null
+ }
+ Session session = Stub(Session) {
+ createQuery(FinderTestEntity) >> query
+ }
+ datastore.hasCurrentSession() >> true
+ datastore.getCurrentSession() >> session
+
+ when:
+ Object result = SingleResultFinder.findOrSaveBy(datastore).invoke(FinderTestEntity, 'findOrSaveByName', ['Bob'] as Object[])
+
+ then:
+ result instanceof FinderTestEntity
+ result.name == 'Bob'
+ result.saved
+ }
+
+ void "findOrSaveBy invoke throws IllegalStateException when constructed in stateless mode"() {
+ when:
+ SingleResultFinder.findOrSaveBy(mappingContext).invoke(FinderTestEntity, 'findOrSaveByName', ['Bob'] as Object[])
+
+ then:
+ thrown(IllegalStateException)
+ }
+
+ void "findOrSaveBy throws MissingMethodException on the null-result path when a non-Equal expression reached it"() {
+ given:
+ Query query = Mock(Query) {
+ getEntity() >> persistentEntity
+ singleResult() >> null
+ }
+ Session session = Stub(Session) {
+ createQuery(FinderTestEntity) >> query
+ }
+ datastore.hasCurrentSession() >> true
+ datastore.getCurrentSession() >> session
+
+ when:
+ SingleResultFinder.findOrSaveBy(datastore).invoke(FinderTestEntity, 'findOrSaveByNameLike', ['Bo%'] as Object[])
+
+ then:
+ thrown(MissingMethodException)
+ }
+ void "findBy resolves its datastore through the DatastoreResolver on every invocation, not at construction"() {
+ given:
+ Query query = Mock(Query) {
+ getEntity() >> persistentEntity
+ projections() >> Mock(Query.ProjectionList)
+ }
+ Session session = Stub(Session) {
+ createQuery(FinderTestEntity) >> query
+ }
+ Datastore lateBoundDatastore = Stub(Datastore) {
+ hasCurrentSession() >> true
+ getCurrentSession() >> session
+ }
+ DatastoreResolver resolver = Mock(DatastoreResolver)
+
+ when:
+ def finder = SingleResultFinder.findBy(resolver, mappingContext)
+
+ then:
+ 0 * resolver.resolve()
+
+ when:
+ finder.invoke(FinderTestEntity, 'findByName', ['Bob'] as Object[])
+
+ then:
+ 1 * resolver.resolve() >> lateBoundDatastore
+ 1 * query.singleResult()
+ }
+
+ void "findBy throws IllegalStateException when the DatastoreResolver resolves no datastore"() {
+ given:
+ DatastoreResolver resolver = Mock(DatastoreResolver)
+
+ when:
+ SingleResultFinder.findBy(resolver, mappingContext).invoke(FinderTestEntity, 'findByName', ['Bob'] as Object[])
+
+ then:
+ IllegalStateException e = thrown()
+ e.message == 'Cannot execute session query with null datastore'
+ }
+}
diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
index eb7f271a6d..988d2293e1 100644
--- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
+++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
@@ -4636,3 +4636,38 @@ grails:
----
See link:security.html#codecs[Encoding and Decoding Objects].
+
+==== 82. GORM Dynamic Finder Classes Replaced
+
+The dynamic finder implementations in the `org.grails.datastore.gorm.finders` package no longer form an inheritance hierarchy. The following public classes have been removed:
+
+* `AbstractFinder`
+* `AbstractFindByFinder`
+* `FindByFinder`
+* `FindByBooleanFinder`
+* `FindAllByFinder`
+* `FindAllByBooleanFinder`
+* `FindOrCreateByFinder`
+* `FindOrSaveByFinder`
+* `CountByFinder`
+
+`DynamicFinder` is now a concrete class that only parses a finder method name and builds its query criteria. It no longer implements `FinderMethod`, has no `invoke` or `doInvokeInternal` methods, and its constructor takes a method pattern, the junction operators, a `MappingContext` and a boolean-clause flag. Finder execution lives in three classes that compose a `DynamicFinder` instead of extending it and are created through static factory methods:
+
+[source,groovy]
+----
+import org.grails.datastore.gorm.finders.CountFinder
+import org.grails.datastore.gorm.finders.ListResultFinder
+import org.grails.datastore.gorm.finders.SingleResultFinder
+
+SingleResultFinder.findBy(datastoreResolver, mappingContext) // findBy*
+SingleResultFinder.findByBoolean(datastoreResolver, mappingContext) // find<Boolean>By*
+SingleResultFinder.findOrCreateBy(datastoreResolver, mappingContext) // findOrCreateBy*
+SingleResultFinder.findOrSaveBy(datastoreResolver, mappingContext) // findOrSaveBy*
+ListResultFinder.findAllBy(datastoreResolver, mappingContext) // findAllBy*
+ListResultFinder.findAllByBoolean(datastoreResolver, mappingContext) // findAll<Boolean>By*
+CountFinder.countBy(datastoreResolver, mappingContext) // countBy*
+----
+
+Each factory also accepts a `Datastore`, or only a `MappingContext` for a finder that parses method names without executing queries. `ListOrderByFinder` keeps its constructors but no longer extends `AbstractFinder`, and the list of finders a `DetachedCriteria` consults is now typed as `List<FinderMethod>`.
+
+Applications are unaffected: dynamic finders such as `Book.findByTitle('The Stand')` behave as before. A GORM datastore implementation or plugin outside the Grails repository that extends one of the removed classes, or that calls `new FindByFinder(...)` and its siblings when building its finder list, must switch to the factory methods above. Code that only holds finders as `FinderMethod` compiles unchanged.