This is an automated email from the ASF dual-hosted git repository.

borinquenkid pushed a commit to branch chore/cleanup-AbstractDetachedCriteria
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit cd40c6c164c09e74310efb08dc353a08577b74c5
Author: Walter Duque de Estrada <[email protected]>
AuthorDate: Wed Aug 12 15:41:36 2026 -0500

    Add test coverage for AbstractDetachedCriteria and rx DetachedCriteria
    
    Adds mock-based unit specs for AbstractDetachedCriteria (via 
grails.gorm.DetachedCriteria)
    and for the reactive grails.gorm.rx.DetachedCriteria, taking both from ~0% 
to full
    line/method coverage without needing a real datastore.
    
    Writing the rx specs surfaced two real bugs, both fixed here:
    - buildQueryableCriteria() cast the built DetachedCriteria to 
QueryableCriteria, but the rx
      class never implemented that interface, so every closure-based subquery 
(in, inList, notIn,
      eqAll/gtAll/ltAll/geAll/leAll, gtSome/geSome/ltSome/leSome) threw a 
ClassCastException at
      runtime. Fixed with a small SubqueryAdapter that extends the shared 
AbstractDetachedCriteria
      base directly, since the reactive class's own find()/list() return 
Observable and can't
      coexist with QueryableCriteria's T/List<T> signatures on the same type.
    - prepareQuery() applied fetch strategies (join/select) twice: once via
      DynamicFinder.applyDetachedCriteria(), then again via a redundant 
hand-rolled loop that also
      ignored custom JoinTypes. Removed the dead duplicate.
    
    Also fixes a handful of definite-assignment/shadowing/raw-getAt warnings in
    AbstractDetachedCriteria (uninitialized `prop` in createAlias, a local 
variable named
    `criteria` shadowing the instance field of the same name in clone(), and 
negative-index
    List access replaced with getLast()).
    
    Co-Authored-By: Claude Sonnet 5 <[email protected]>
---
 .../query/criteria/AbstractDetachedCriteria.groovy |   39 +-
 .../criteria/AbstractDetachedCriteriaSpec.groovy   | 1167 ++++++++++++++++++++
 .../groovy/grails/gorm/rx/DetachedCriteria.groovy  |   72 +-
 .../grails/gorm/rx/DetachedCriteriaSpec.groovy     |  608 ++++++++++
 .../gorm/rx/api/DetachedCriteriaQuerySpec.groovy   |  281 +++++
 5 files changed, 2125 insertions(+), 42 deletions(-)

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 37326ffb02..fc1352b664 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
@@ -116,7 +116,7 @@ abstract class AbstractDetachedCriteria<T> implements 
Criteria, Cloneable {
      */
     Criteria createAlias(String associationPath, String alias) {
         initialiseIfNecessary(targetClass)
-        PersistentProperty prop
+        PersistentProperty prop = null
         if (associationPath.contains('.')) {
             def tokens = associationPath.split(/\./)
             def entity = this.persistentEntity
@@ -203,7 +203,7 @@ abstract class AbstractDetachedCriteria<T> implements 
Criteria, Cloneable {
         try {
             dynamicFinders = targetClass.gormDynamicFinders
             persistentEntity = targetClass.gormPersistentEntity
-        } catch (MissingPropertyException mpe) {
+        } catch (MissingPropertyException ignored) {
             throw new IllegalArgumentException("Class [$targetClass.name] is 
not a domain class")
         }
     }
@@ -216,7 +216,7 @@ abstract class AbstractDetachedCriteria<T> implements 
Criteria, Cloneable {
             }
         }
         if (junctions)  {
-            junctions[-1].add(criterion)
+            junctions.getLast().add(criterion)
         }
         else {
             criteria << criterion
@@ -871,21 +871,21 @@ abstract class AbstractDetachedCriteria<T> implements 
Criteria, Cloneable {
     @Override
     @CompileStatic
     AbstractDetachedCriteria<T> clone() {
-        AbstractDetachedCriteria criteria = newInstance()
-        criteria.@criteria = new ArrayList(this.criteria)
+        AbstractDetachedCriteria cloned = newInstance()
+        cloned.@criteria = new ArrayList(this.criteria)
         final projections = new ArrayList(this.projections)
-        criteria.@projections = projections
-        criteria.projectionList = new DetachedProjections(projections)
-        criteria.@orders = new ArrayList(this.orders)
-        criteria.defaultMax = defaultMax
-        criteria.defaultOffset = defaultOffset
-        criteria.@fetchStrategies = new HashMap<>(this.fetchStrategies)
-        criteria.@joinTypes = new HashMap<>(this.joinTypes)
-        criteria.@junctions = new ArrayList(this.junctions)
-        criteria.@connectionName = this.connectionName
-        criteria.@lazyQuery = this.lazyQuery
-        criteria.@associationCriteriaMap = new 
LinkedHashMap<>(this.associationCriteriaMap)
-        return criteria
+        cloned.@projections = projections
+        cloned.projectionList = new DetachedProjections(projections)
+        cloned.@orders = new ArrayList(this.orders)
+        cloned.defaultMax = defaultMax
+        cloned.defaultOffset = defaultOffset
+        cloned.@fetchStrategies = new HashMap<>(this.fetchStrategies)
+        cloned.@joinTypes = new HashMap<>(this.joinTypes)
+        cloned.@junctions = new ArrayList(this.junctions)
+        cloned.@connectionName = this.connectionName
+        cloned.@lazyQuery = this.lazyQuery
+        cloned.@associationCriteriaMap = new 
LinkedHashMap<>(this.associationCriteriaMap)
+        return cloned
     }
 
     protected abstract AbstractDetachedCriteria newInstance()
@@ -1057,7 +1057,8 @@ abstract class AbstractDetachedCriteria<T> implements 
Criteria, Cloneable {
             throw new MissingMethodException(methodName, 
AbstractDetachedCriteria, args)
         }
 
-        def alias = args[0] instanceof CharSequence ? args[0].toString() : null
+        Object[] argsArray = (Object[]) args
+        def alias = argsArray[0] instanceof CharSequence ? 
argsArray[0].toString() : null
 
         // Explicit null checks: Groovy truth on a DetachedCriteria invokes 
asBoolean(),
         // which executes the criteria as a query - a spurious query for a 
repeated
@@ -1073,7 +1074,7 @@ abstract class AbstractDetachedCriteria<T> implements 
Criteria, Cloneable {
         associationCriteriaMap[methodName] = associationCriteria
         add(associationCriteria)
 
-        def lastArg = args[-1]
+        def lastArg = argsArray[-1]
         if (lastArg instanceof Closure) {
             Closure callable = lastArg
             callable.resolveStrategy = Closure.DELEGATE_FIRST
diff --git 
a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/criteria/AbstractDetachedCriteriaSpec.groovy
 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/criteria/AbstractDetachedCriteriaSpec.groovy
new file mode 100644
index 0000000000..d5ebc8185b
--- /dev/null
+++ 
b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/query/criteria/AbstractDetachedCriteriaSpec.groovy
@@ -0,0 +1,1167 @@
+/*
+ *  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.query.criteria
+
+import grails.gorm.DetachedCriteria
+
+import jakarta.persistence.FetchType
+import jakarta.persistence.criteria.JoinType
+
+import org.grails.datastore.gorm.finders.FinderMethod
+import org.grails.datastore.mapping.model.PersistentEntity
+import org.grails.datastore.mapping.model.PersistentProperty
+import org.grails.datastore.mapping.model.types.Association
+import org.grails.datastore.mapping.query.Query
+import org.grails.datastore.mapping.query.api.QueryableCriteria
+import spock.lang.Specification
+
+/**
+ * Exercises {@link AbstractDetachedCriteria} through its concrete subclass 
{@link DetachedCriteria}.
+ */
+class AbstractDetachedCriteriaSpec extends Specification {
+
+    void "eq adds an Equals criterion"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.eq('name', 'Bob')
+
+        then:
+        criteria.criteria.size() == 1
+        Query.Equals c = criteria.criteria[0]
+        c.property == 'name'
+        c.value == 'Bob'
+    }
+
+    void "add builds a QueryableCriteria when a PropertyCriterion's value is a 
bare closure"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.eq('publisher', { eq('name', 'Apache') })
+
+        then:
+        Query.Equals c = criteria.criteria[0]
+        c.property == 'publisher'
+        c.value instanceof DetachedCriteria
+        ((DetachedCriteria) c.value).criteria.size() == 1
+    }
+
+    void "ne adds a NotEquals criterion"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.ne('name', 'Bob')
+
+        then:
+        Query.NotEquals c = criteria.criteria[0]
+        c.property == 'name'
+        c.value == 'Bob'
+    }
+
+    void "gt adds a GreaterThan criterion"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.gt('age', 18)
+
+        then:
+        Query.GreaterThan c = criteria.criteria[0]
+        c.property == 'age'
+        c.value == 18
+    }
+
+    void "lt adds a LessThan criterion"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.lt('age', 65)
+
+        then:
+        Query.LessThan c = criteria.criteria[0]
+        c.property == 'age'
+        c.value == 65
+    }
+
+    void "gte adds a GreaterThanEquals criterion"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.gte('age', 18)
+
+        then:
+        Query.GreaterThanEquals c = criteria.criteria[0]
+        c.property == 'age'
+        c.value == 18
+    }
+
+    void "ge is an alias for gte"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.ge('age', 18)
+
+        then:
+        Query.GreaterThanEquals c = criteria.criteria[0]
+        c.property == 'age'
+        c.value == 18
+    }
+
+    void "lte adds a LessThanEquals criterion"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.lte('age', 65)
+
+        then:
+        Query.LessThanEquals c = criteria.criteria[0]
+        c.property == 'age'
+        c.value == 65
+    }
+
+    void "le is an alias for lte"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.le('age', 65)
+
+        then:
+        Query.LessThanEquals c = criteria.criteria[0]
+        c.property == 'age'
+        c.value == 65
+    }
+
+    void "between adds a Between criterion"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.between('age', 18, 65)
+
+        then:
+        Query.Between c = criteria.criteria[0]
+        c.property == 'age'
+        c.from == 18
+        c.to == 65
+    }
+
+    void "like adds a Like criterion with a stringified value"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.like('name', "B${'ob'}")
+
+        then:
+        Query.Like c = criteria.criteria[0]
+        c.property == 'name'
+        c.pattern == 'Bob'
+    }
+
+    void "ilike adds an ILike criterion"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.ilike('name', 'bob')
+
+        then:
+        Query.ILike c = criteria.criteria[0]
+        c.property == 'name'
+        c.pattern == 'bob'
+    }
+
+    void "rlike adds an RLike criterion"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.rlike('name', '^B.*')
+
+        then:
+        Query.RLike c = criteria.criteria[0]
+        c.property == 'name'
+        c.pattern == '^B.*'
+    }
+
+    void "isNull/isNotNull/isEmpty/isNotEmpty add the matching property-name 
criterion"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.isNull('name')
+        criteria.isNotNull('name')
+        criteria.isEmpty('name')
+        criteria.isNotEmpty('name')
+
+        then:
+        criteria.criteria.size() == 4
+        criteria.criteria[0] instanceof Query.IsNull
+        criteria.criteria[1] instanceof Query.IsNotNull
+        criteria.criteria[2] instanceof Query.IsEmpty
+        criteria.criteria[3] instanceof Query.IsNotEmpty
+        criteria.criteria.every { it.property == 'name' }
+    }
+
+    void "idEq adds an IdEquals criterion"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.idEq(42)
+
+        then:
+        Query.IdEquals c = criteria.criteria[0]
+        c.property == 'id'
+        c.value == 42
+    }
+
+    void "idEquals is an alias for idEq"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.idEquals(42)
+
+        then:
+        Query.IdEquals c = criteria.criteria[0]
+        c.value == 42
+    }
+
+    void "eqProperty/neProperty/gtProperty/geProperty/ltProperty/leProperty 
compare two properties"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.eqProperty('name', 'nickName')
+        criteria.neProperty('name', 'nickName')
+        criteria.gtProperty('age', 'maxAge')
+        criteria.geProperty('age', 'maxAge')
+        criteria.ltProperty('age', 'minAge')
+        criteria.leProperty('age', 'minAge')
+
+        then:
+        criteria.criteria.size() == 6
+        criteria.criteria[0] instanceof Query.EqualsProperty
+        criteria.criteria[1] instanceof Query.NotEqualsProperty
+        criteria.criteria[2] instanceof Query.GreaterThanProperty
+        criteria.criteria[3] instanceof Query.GreaterThanEqualsProperty
+        criteria.criteria[4] instanceof Query.LessThanProperty
+        criteria.criteria[5] instanceof Query.LessThanEqualsProperty
+    }
+
+    void "allEq adds a Conjunction of Equals criteria, one per map entry"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.allEq(name: 'Bob', age: 42)
+
+        then:
+        criteria.criteria.size() == 1
+        Query.Conjunction conjunction = criteria.criteria[0]
+        conjunction.criteria.size() == 2
+        conjunction.criteria.every { it instanceof Query.Equals }
+        conjunction.criteria*.property.sort() == ['age', 'name']
+    }
+
+    void "sizeEq/sizeGt/sizeGe/sizeLe/sizeLt/sizeNe add size-comparison 
criteria"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.sizeEq('books', 1)
+        criteria.sizeGt('books', 1)
+        criteria.sizeGe('books', 1)
+        criteria.sizeLe('books', 1)
+        criteria.sizeLt('books', 1)
+        criteria.sizeNe('books', 1)
+
+        then:
+        criteria.criteria.size() == 6
+        criteria.criteria[0] instanceof Query.SizeEquals
+        criteria.criteria[1] instanceof Query.SizeGreaterThan
+        criteria.criteria[2] instanceof Query.SizeGreaterThanEquals
+        criteria.criteria[3] instanceof Query.SizeLessThanEquals
+        criteria.criteria[4] instanceof Query.SizeLessThan
+        criteria.criteria[5] instanceof Query.SizeNotEquals
+        criteria.criteria.every { it.property == 'books' }
+    }
+
+    void "inList(Collection) converts CharSequence values to String"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def gstring = "${'Bob'}"
+
+        when:
+        criteria.inList('name', [gstring, 'Alice'])
+
+        then:
+        Query.In c = criteria.criteria[0]
+        c.property == 'name'
+        new ArrayList(c.values) == ['Bob', 'Alice']
+        c.values.every { it instanceof String }
+    }
+
+    void "inList(Object array) delegates to inList(Collection)"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.inList('name', ['Bob', 'Alice'] as Object[])
+
+        then:
+        Query.In c = criteria.criteria[0]
+        new ArrayList(c.values) == ['Bob', 'Alice']
+    }
+
+    void "'in'(Collection) delegates to inList"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria."in"('name', ['Bob'])
+
+        then:
+        criteria.criteria[0] instanceof Query.In
+    }
+
+    void "'in'(Object array) delegates to inList"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria."in"('name', ['Bob'] as Object[])
+
+        then:
+        criteria.criteria[0] instanceof Query.In
+    }
+
+    void "inList(QueryableCriteria) adds an In criterion wrapping the 
subquery"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def subquery = Mock(QueryableCriteria)
+
+        when:
+        criteria.inList('name', subquery)
+
+        then:
+        Query.In c = criteria.criteria[0]
+        c.property == 'name'
+        c.subquery.is(subquery)
+    }
+
+    void "'in'(QueryableCriteria) delegates to inList(QueryableCriteria)"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def subquery = Mock(QueryableCriteria)
+
+        when:
+        criteria."in"('name', subquery)
+
+        then:
+        Query.In c = criteria.criteria[0]
+        c.subquery.is(subquery)
+    }
+
+    void "'in'(Closure) builds a QueryableCriteria from the closure and 
delegates to inList"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria."in"('name') { eq('name', 'Bob') }
+
+        then:
+        Query.In c = criteria.criteria[0]
+        c.property == 'name'
+        c.subquery instanceof DetachedCriteria
+        ((DetachedCriteria) c.subquery).criteria.size() == 1
+    }
+
+    void "inList(Closure) builds a QueryableCriteria from the closure"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.inList('name') { eq('name', 'Bob') }
+
+        then:
+        Query.In c = criteria.criteria[0]
+        c.subquery instanceof DetachedCriteria
+    }
+
+    void "notIn(QueryableCriteria) adds a NotIn criterion"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def subquery = Mock(QueryableCriteria)
+
+        when:
+        criteria.notIn('name', subquery)
+
+        then:
+        Query.NotIn c = criteria.criteria[0]
+        c.property == 'name'
+        c.value.is(subquery)
+    }
+
+    void "notIn(Closure) builds a QueryableCriteria and delegates"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.notIn('name') { eq('name', 'Bob') }
+
+        then:
+        criteria.criteria[0] instanceof Query.NotIn
+    }
+
+    void "exists/notExists wrap a subquery"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def subquery = Mock(QueryableCriteria)
+
+        when:
+        criteria.exists(subquery)
+        criteria.notExists(subquery)
+
+        then:
+        criteria.criteria.size() == 2
+        criteria.criteria[0] instanceof Query.Exists
+        criteria.criteria[1] instanceof Query.NotExists
+    }
+
+    void "eqAll/gtAll/ltAll/geAll/leAll wrap a subquery"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def subquery = Mock(QueryableCriteria)
+
+        when:
+        criteria.eqAll('age', subquery)
+        criteria.gtAll('age', subquery)
+        criteria.ltAll('age', subquery)
+        criteria.geAll('age', subquery)
+        criteria.leAll('age', subquery)
+
+        then:
+        criteria.criteria.size() == 5
+        criteria.criteria[0] instanceof Query.EqualsAll
+        criteria.criteria[1] instanceof Query.GreaterThanAll
+        criteria.criteria[2] instanceof Query.LessThanAll
+        criteria.criteria[3] instanceof Query.GreaterThanEqualsAll
+        criteria.criteria[4] instanceof Query.LessThanEqualsAll
+    }
+
+    void "gtSome/geSome/ltSome/leSome wrap a subquery"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def subquery = Mock(QueryableCriteria)
+
+        when:
+        criteria.gtSome('age', subquery)
+        criteria.geSome('age', subquery)
+        criteria.ltSome('age', subquery)
+        criteria.leSome('age', subquery)
+
+        then:
+        criteria.criteria.size() == 4
+        criteria.criteria[0] instanceof Query.GreaterThanSome
+        criteria.criteria[1] instanceof Query.GreaterThanEqualsSome
+        criteria.criteria[2] instanceof Query.LessThanSome
+        criteria.criteria[3] instanceof Query.LessThanEqualsSome
+    }
+
+    void "eqAll/gtAll/ltAll/geAll/leAll/gtSome/geSome/ltSome/leSome accept a 
closure and build the subquery from it"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.eqAll('age') { eq('age', 42) }
+        criteria.gtAll('age') { eq('age', 42) }
+        criteria.ltAll('age') { eq('age', 42) }
+        criteria.geAll('age') { eq('age', 42) }
+        criteria.leAll('age') { eq('age', 42) }
+        criteria.gtSome('age') { eq('age', 42) }
+        criteria.geSome('age') { eq('age', 42) }
+        criteria.ltSome('age') { eq('age', 42) }
+        criteria.leSome('age') { eq('age', 42) }
+
+        then:
+        criteria.criteria.size() == 9
+        criteria.criteria[0] instanceof Query.EqualsAll
+        criteria.criteria[1] instanceof Query.GreaterThanAll
+        criteria.criteria[2] instanceof Query.LessThanAll
+        criteria.criteria[3] instanceof Query.GreaterThanEqualsAll
+        criteria.criteria[4] instanceof Query.LessThanEqualsAll
+        criteria.criteria[5] instanceof Query.GreaterThanSome
+        criteria.criteria[6] instanceof Query.GreaterThanEqualsSome
+        criteria.criteria[7] instanceof Query.LessThanSome
+        criteria.criteria[8] instanceof Query.LessThanEqualsSome
+        criteria.criteria.every { (it as Query.SubqueryCriterion).value 
instanceof DetachedCriteria }
+    }
+
+    void "order(String) adds an ascending order"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.order('name')
+
+        then:
+        criteria.orders.size() == 1
+        criteria.orders[0].property == 'name'
+        criteria.orders[0].direction == Query.Order.Direction.ASC
+    }
+
+    void "order(String, String) sets the given direction"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.order('name', 'desc')
+
+        then:
+        criteria.orders[0].direction == Query.Order.Direction.DESC
+    }
+
+    void "order(Query.Order) appends the given order instance"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def order = Query.Order.desc('name')
+
+        when:
+        criteria.order(order)
+
+        then:
+        criteria.orders[0].is(order)
+    }
+
+    void "and wraps nested criteria in a Conjunction"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.and {
+            eq('name', 'Bob')
+            eq('age', 42)
+        }
+
+        then:
+        criteria.criteria.size() == 1
+        Query.Conjunction conjunction = criteria.criteria[0]
+        conjunction.criteria.size() == 2
+    }
+
+    void "or wraps nested criteria in a Disjunction"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.or {
+            eq('name', 'Bob')
+            eq('name', 'Alice')
+        }
+
+        then:
+        criteria.criteria.size() == 1
+        criteria.criteria[0] instanceof Query.Disjunction
+        (criteria.criteria[0] as Query.Disjunction).criteria.size() == 2
+    }
+
+    void "not wraps nested criteria in a Negation"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.not {
+            eq('name', 'Bob')
+        }
+
+        then:
+        criteria.criteria.size() == 1
+        criteria.criteria[0] instanceof Query.Negation
+        (criteria.criteria[0] as Query.Negation).criteria.size() == 1
+    }
+
+    void "junction is closed even when the closure throws"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.and {
+            eq('name', 'Bob')
+            throw new IllegalStateException('boom')
+        }
+
+        then:
+        thrown(IllegalStateException)
+        criteria.criteria.size() == 1
+        (criteria.criteria[0] as Query.Conjunction).criteria.size() == 1
+    }
+
+    void "projections closure populates the projection list"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.projections {
+            avg('age')
+            max('age')
+            min('age')
+            sum('age')
+            property('name')
+            rowCount()
+            count()
+            id()
+            distinct('name')
+            distinct()
+            countDistinct('name')
+            groupProperty('name')
+        }
+
+        then:
+        criteria.projections.size() == 12
+    }
+
+    void "join(property) marks the property for eager fetching"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.join('books')
+
+        then:
+        criteria.fetchStrategies['books'] == FetchType.EAGER
+        criteria.getFetchStrategies() == [books: FetchType.EAGER]
+    }
+
+    void "join(property, joinType) records the join type as well"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.join('books', JoinType.LEFT)
+
+        then:
+        criteria.fetchStrategies['books'] == FetchType.EAGER
+        criteria.joinTypes['books'] == JoinType.LEFT
+        criteria.getJoinTypes() == [books: JoinType.LEFT]
+    }
+
+    void "select(property) marks the property for lazy fetching"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.select('books')
+
+        then:
+        criteria.fetchStrategies['books'] == FetchType.LAZY
+    }
+
+    void "getFetchStrategies and getJoinTypes return unmodifiable views"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        criteria.join('books')
+
+        when:
+        criteria.getFetchStrategies()['other'] = FetchType.LAZY
+
+        then:
+        thrown(UnsupportedOperationException)
+    }
+
+    void "cache and readOnly are no-ops that return this"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        expect:
+        criteria.cache(true).is(criteria)
+        criteria.readOnly(true).is(criteria)
+    }
+
+    void "setAlias/getAlias round-trip"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.setAlias('t')
+
+        then:
+        criteria.getAlias() == 't'
+    }
+
+    void "where derives a new criteria instance without mutating the 
original"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        criteria.eq('name', 'Bob')
+
+        when:
+        def derived = criteria.where { eq('age', 42) }
+
+        then:
+        !derived.is(criteria)
+        criteria.criteria.size() == 1
+        derived.criteria.size() == 2
+    }
+
+    void "build derives a new criteria instance without mutating the 
original"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        criteria.eq('name', 'Bob')
+
+        when:
+        def derived = criteria.build { eq('age', 42) }
+
+        then:
+        !derived.is(criteria)
+        criteria.criteria.size() == 1
+        derived.criteria.size() == 2
+    }
+
+    void "buildLazy stashes the closure for later application"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        def derived = criteria.buildLazy { eq('age', 42) }
+
+        then:
+        derived.criteria.isEmpty()
+        derived.@lazyQuery != null
+
+        when: "a criterion is added, triggering applyLazyCriteria"
+        derived.eq('name', 'Bob')
+
+        then:
+        derived.criteria.size() == 2
+        derived.@lazyQuery == null
+    }
+
+    void "whereLazy applies the closure eagerly like where"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        def derived = criteria.whereLazy { eq('age', 42) }
+
+        then:
+        derived.criteria.size() == 1
+    }
+
+    void "withConnection derives a new instance with the given connection 
name"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        def derived = criteria.withConnection('secondary')
+
+        then:
+        !derived.is(criteria)
+        derived.@connectionName == 'secondary'
+    }
+
+    void "max(int)/offset(int) derive a new instance without mutating the 
original"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        def maxed = criteria.max(10)
+        def offset = criteria.offset(5)
+
+        then:
+        maxed.defaultMax == 10
+        criteria.defaultMax == null
+        offset.defaultOffset == 5
+        criteria.defaultOffset == null
+    }
+
+    void "sort(property) and sort(property, direction) derive a new instance 
with an added order"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        def ascSort = criteria.sort('name')
+        def descSort = criteria.sort('name', 'desc')
+
+        then:
+        ascSort.orders.size() == 1
+        ascSort.orders[0].direction == Query.Order.Direction.ASC
+        descSort.orders[0].direction == Query.Order.Direction.DESC
+        criteria.orders.isEmpty()
+    }
+
+    void "property/id/avg/sum/min/max(String)/distinct derive a new instance 
with the projection added"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        def prop = criteria.property('name')
+        def id = criteria.id()
+        def avg = criteria.avg('age')
+        def sum = criteria.sum('age')
+        def min = criteria.min('age')
+        def max = criteria.max('age')
+        def distinct = criteria.distinct('name')
+
+        then:
+        [prop, id, avg, sum, min, max, distinct].every { it.projections.size() 
== 1 }
+        criteria.projections.isEmpty()
+    }
+
+    void "clone produces a distinct DetachedCriteria with copied 
collections"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        criteria.eq('name', 'Bob')
+        criteria.order('name')
+        criteria.projectionList.property('name')
+
+        when:
+        def cloned = criteria.clone()
+        cloned.eq('age', 42)
+        cloned.order('age')
+        cloned.projectionList.property('age')
+
+        then:
+        cloned instanceof DetachedCriteria
+        !cloned.is(criteria)
+        criteria.criteria.size() == 1
+        cloned.criteria.size() == 2
+        criteria.orders.size() == 1
+        cloned.orders.size() == 2
+        criteria.projections.size() == 1
+        cloned.projections.size() == 2
+    }
+
+    void "getPersistentEntity throws IllegalArgumentException for a class that 
is not GORM-enhanced"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        criteria.getPersistentEntity()
+
+        then:
+        IllegalArgumentException e = thrown()
+        e.message.contains('is not a domain class')
+    }
+
+    void "getPersistentClass returns the java class of the persistent 
entity"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def entity = Mock(PersistentEntity) {
+            getJavaClass() >> TestEntity
+        }
+        criteria.@persistentEntity = entity
+        criteria.@dynamicFinders = []
+
+        expect:
+        criteria.getPersistentClass() == TestEntity
+        criteria.getPersistentEntity().is(entity)
+    }
+
+    void "createAlias creates a DetachedAssociationCriteria for a top-level 
association"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def associatedEntity = Mock(PersistentEntity) {
+            getJavaClass() >> TestEntity
+        }
+        def association = Mock(Association) {
+            getAssociatedEntity() >> associatedEntity
+        }
+        def entity = Mock(PersistentEntity) {
+            getPropertyByName('books') >> association
+        }
+        criteria.@persistentEntity = entity
+        criteria.@dynamicFinders = []
+
+        when:
+        def result = criteria.createAlias('books', 'b')
+
+        then:
+        result instanceof DetachedAssociationCriteria
+        result.alias == 'b'
+        criteria.criteria.contains(result)
+        criteria.@associationCriteriaMap['books'].is(result)
+    }
+
+    void "createAlias reuses an existing association criteria and updates its 
alias"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def associatedEntity = Mock(PersistentEntity) {
+            getJavaClass() >> TestEntity
+        }
+        def association = Mock(Association) {
+            getAssociatedEntity() >> associatedEntity
+        }
+        def entity = Mock(PersistentEntity) {
+            getPropertyByName('books') >> association
+        }
+        criteria.@persistentEntity = entity
+        criteria.@dynamicFinders = []
+
+        when:
+        def first = criteria.createAlias('books', 'b1')
+        def second = criteria.createAlias('books', 'b2')
+
+        then:
+        first.is(second)
+        second.alias == 'b2'
+        criteria.criteria.size() == 1
+    }
+
+    void "createAlias resolves a dotted association path"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def leafEntity = Mock(PersistentEntity) {
+            getJavaClass() >> TestEntity
+        }
+        def leafAssociation = Mock(Association) {
+            getAssociatedEntity() >> leafEntity
+        }
+        def midEntity = Mock(PersistentEntity) {
+            getPropertyByName('author') >> leafAssociation
+        }
+        def rootAssociation = Mock(Association) {
+            getAssociatedEntity() >> midEntity
+        }
+        def rootEntity = Mock(PersistentEntity) {
+            getPropertyByName('books') >> rootAssociation
+        }
+        criteria.@persistentEntity = rootEntity
+        criteria.@dynamicFinders = []
+
+        when:
+        def result = criteria.createAlias('books.author', 'a')
+
+        then:
+        result instanceof DetachedAssociationCriteria
+        result.alias == 'a'
+    }
+
+    void "createAlias throws IllegalArgumentException when the property is not 
an association"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def property = Mock(PersistentProperty)
+        def entity = Mock(PersistentEntity) {
+            getPropertyByName('name') >> property
+        }
+        criteria.@persistentEntity = entity
+        criteria.@dynamicFinders = []
+
+        when:
+        criteria.createAlias('name', 'n')
+
+        then:
+        thrown(IllegalArgumentException)
+    }
+
+    void "createAlias throws IllegalArgumentException for a dotted path 
segment that is not an association"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def property = Mock(PersistentProperty)
+        def entity = Mock(PersistentEntity) {
+            getPropertyByName('name') >> property
+        }
+        criteria.@persistentEntity = entity
+        criteria.@dynamicFinders = []
+
+        when:
+        criteria.createAlias('name.other', 'n')
+
+        then:
+        thrown(IllegalArgumentException)
+    }
+
+    void "propertyMissing returns a property projection for a known 
property"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def property = Mock(PersistentProperty)
+        def entity = Mock(PersistentEntity) {
+            getPropertyByName('name') >> property
+        }
+        criteria.@persistentEntity = entity
+        criteria.@dynamicFinders = []
+
+        when:
+        def result = criteria.propertyMissing('name')
+
+        then:
+        result instanceof DetachedCriteria
+        result.projections.size() == 1
+    }
+
+    void "propertyMissing throws MissingPropertyException for an unknown 
property"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def entity = Mock(PersistentEntity) {
+            getPropertyByName('nope') >> null
+        }
+        criteria.@persistentEntity = entity
+        criteria.@dynamicFinders = []
+
+        when:
+        criteria.propertyMissing('nope')
+
+        then:
+        thrown(MissingPropertyException)
+    }
+
+    void "methodMissing delegates to a matching dynamic finder"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def finder = Mock(FinderMethod) {
+            isMethodMatch('findByName') >> true
+        }
+        criteria.@dynamicFinders = [finder]
+
+        when:
+        def result = criteria.findByName('Bob')
+
+        then:
+        1 * finder.invoke(TestEntity, 'findByName', criteria, ['Bob'] as 
Object[]) >> 'found'
+        result == 'found'
+    }
+
+    void "methodMissing throws MissingMethodException when no finder matches 
and no args are given"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def finder = Mock(FinderMethod) {
+            isMethodMatch(_) >> false
+        }
+        criteria.@dynamicFinders = [finder]
+
+        when:
+        criteria.notAMethod()
+
+        then:
+        thrown(MissingMethodException)
+    }
+
+    void "methodMissing throws MissingMethodException when the property is not 
an association"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def finder = Mock(FinderMethod) {
+            isMethodMatch(_) >> false
+        }
+        def property = Mock(PersistentProperty)
+        def entity = Mock(PersistentEntity) {
+            getPropertyByName('name') >> property
+        }
+        criteria.@persistentEntity = entity
+        criteria.@dynamicFinders = [finder]
+
+        when:
+        criteria.name('Bob')
+
+        then:
+        thrown(MissingMethodException)
+    }
+
+    void "methodMissing adds an association criteria without a closure 
argument"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def finder = Mock(FinderMethod) {
+            isMethodMatch(_) >> false
+        }
+        def associatedEntity = Mock(PersistentEntity) {
+            getJavaClass() >> TestEntity
+        }
+        def association = Mock(Association) {
+            getAssociatedEntity() >> associatedEntity
+        }
+        def entity = Mock(PersistentEntity) {
+            getPropertyByName('books') >> association
+        }
+        criteria.@persistentEntity = entity
+        criteria.@dynamicFinders = [finder]
+
+        when:
+        criteria.books('b')
+
+        then:
+        criteria.criteria.size() == 1
+        (criteria.criteria[0] as DetachedAssociationCriteria).alias == 'b'
+    }
+
+    void "methodMissing reuses the existing association's alias when none is 
given"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def finder = Mock(FinderMethod) {
+            isMethodMatch(_) >> false
+        }
+        def associatedEntity = Mock(PersistentEntity) {
+            getJavaClass() >> TestEntity
+        }
+        def association = Mock(Association) {
+            getAssociatedEntity() >> associatedEntity
+        }
+        def entity = Mock(PersistentEntity) {
+            getPropertyByName('books') >> association
+        }
+        criteria.@persistentEntity = entity
+        criteria.@dynamicFinders = [finder]
+
+        when:
+        criteria.books('b1') { eq('title', 'first') }
+        criteria.books { eq('title', 'second') }
+
+        then:
+        criteria.criteria.size() == 2
+        (criteria.criteria[1] as DetachedAssociationCriteria).alias == 'b1'
+    }
+
+    void "methodMissing builds an association criteria and delegates the 
closure to it"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def finder = Mock(FinderMethod) {
+            isMethodMatch(_) >> false
+        }
+        def associatedEntity = Mock(PersistentEntity) {
+            getJavaClass() >> TestEntity
+        }
+        def association = Mock(Association) {
+            getAssociatedEntity() >> associatedEntity
+        }
+        def entity = Mock(PersistentEntity) {
+            getPropertyByName('books') >> association
+        }
+        criteria.@persistentEntity = entity
+        criteria.@dynamicFinders = [finder]
+        boolean delegateWasAssociationCriteria = false
+
+        when:
+        criteria.books {
+            delegateWasAssociationCriteria = delegate instanceof 
DetachedAssociationCriteria
+            eq('title', 'Groovy in Action')
+        }
+
+        then:
+        criteria.criteria.size() == 1
+        criteria.criteria[0] instanceof DetachedAssociationCriteria
+        delegateWasAssociationCriteria
+        (criteria.criteria[0] as DetachedAssociationCriteria).criteria.size() 
== 1
+    }
+}
diff --git 
a/grails-datamapping-rx/src/main/groovy/grails/gorm/rx/DetachedCriteria.groovy 
b/grails-datamapping-rx/src/main/groovy/grails/gorm/rx/DetachedCriteria.groovy
index 9711b13955..b807a2efd9 100644
--- 
a/grails-datamapping-rx/src/main/groovy/grails/gorm/rx/DetachedCriteria.groovy
+++ 
b/grails-datamapping-rx/src/main/groovy/grails/gorm/rx/DetachedCriteria.groovy
@@ -32,8 +32,6 @@ import rx.Observable
 import rx.Subscriber
 import rx.Subscription
 
-import jakarta.persistence.FetchType
-
 /**
  * Reactive version of {@link grails.gorm.DetachedCriteria}
  *
@@ -63,7 +61,7 @@ class DetachedCriteria<T> extends 
AbstractDetachedCriteria<Observable<T>> implem
     Observable<T> find(Map args = Collections.emptyMap(), 
@DelegatesTo(DetachedCriteria) Closure additionalCriteria = null) {
         Query query = prepareQuery(args, additionalCriteria)
         query.max(1)
-        return ((RxQuery)query).findAll(args)
+        return ((RxQuery<T>)query).findAll(args)
     }
 
     /**
@@ -77,7 +75,7 @@ class DetachedCriteria<T> extends 
AbstractDetachedCriteria<Observable<T>> implem
      */
     Observable<T> findAll(Map args = Collections.emptyMap(), 
@DelegatesTo(DetachedCriteria) Closure additionalCriteria = null) {
         Query query = prepareQuery(args, additionalCriteria)
-        return ((RxQuery)query).findAll(args)
+        return ((RxQuery<T>)query).findAll(args)
     }
 
     /**
@@ -88,7 +86,7 @@ class DetachedCriteria<T> extends 
AbstractDetachedCriteria<Observable<T>> implem
     Observable<T> get(Map args, @DelegatesTo(DetachedCriteria) Closure 
additionalCriteria = null) {
         Query query = prepareQuery(args, additionalCriteria)
         query.max(1)
-        return ((RxQuery)query).singleResult(args)
+        return ((RxQuery<T>)query).singleResult(args)
     }
 
     /**
@@ -99,7 +97,7 @@ class DetachedCriteria<T> extends 
AbstractDetachedCriteria<Observable<T>> implem
     Observable<T> get(@DelegatesTo(DetachedCriteria) Closure 
additionalCriteria = null) {
         Query query = prepareQuery(Collections.emptyMap(), additionalCriteria)
         query.max(1)
-        return ((RxQuery)query).singleResult()
+        return ((RxQuery<T>)query).singleResult()
     }
 
     /**
@@ -111,7 +109,7 @@ class DetachedCriteria<T> extends 
AbstractDetachedCriteria<Observable<T>> implem
      */
     Observable<List<T>> toList(Map args = Collections.emptyMap(), 
@DelegatesTo(DetachedCriteria) Closure additionalCriteria = null) {
         Query query = prepareQuery(args, additionalCriteria)
-        return ((RxQuery)query).findAll(args).toList()
+        return ((RxQuery<T>)query).findAll(args).toList()
     }
 
     /**
@@ -123,7 +121,7 @@ class DetachedCriteria<T> extends 
AbstractDetachedCriteria<Observable<T>> implem
      */
     Observable<List<T>> list(Map args = Collections.emptyMap(), 
@DelegatesTo(DetachedCriteria) Closure additionalCriteria = null) {
         Query query = prepareQuery(args, additionalCriteria)
-        return ((RxQuery)query).findAll(args).toList()
+        return ((RxQuery<T>)query).findAll(args).toList()
     }
 
     /**
@@ -136,7 +134,7 @@ class DetachedCriteria<T> extends 
AbstractDetachedCriteria<Observable<T>> implem
     Observable<Number> getCount(Map args = Collections.emptyMap(), 
@DelegatesTo(DetachedCriteria) Closure additionalCriteria = null) {
         Query query = prepareQuery(args, additionalCriteria)
         query.projections().count()
-        return ((RxQuery)query).singleResult(args) as Observable<Number>
+        return ((RxQuery<T>)query).singleResult(args) as Observable<Number>
     }
 
     /**
@@ -157,7 +155,7 @@ class DetachedCriteria<T> extends 
AbstractDetachedCriteria<Observable<T>> implem
      */
     Observable<Number> updateAll(Map propertiesMap) {
         Query query = prepareQuery(Collections.emptyMap(), null)
-        return ((RxQuery)query).updateAll(propertiesMap)
+        return ((RxQuery<T>)query).updateAll(propertiesMap)
     }
 
     /**
@@ -167,7 +165,7 @@ class DetachedCriteria<T> extends 
AbstractDetachedCriteria<Observable<T>> implem
      */
     Observable<Number> deleteAll() {
         Query query = prepareQuery(Collections.emptyMap(), null)
-        return ((RxQuery)query).deleteAll()
+        return ((RxQuery<T>)query).deleteAll()
     }
 
     /**
@@ -262,7 +260,45 @@ class DetachedCriteria<T> extends 
AbstractDetachedCriteria<Observable<T>> implem
 
     @Override
     protected QueryableCriteria buildQueryableCriteria(Closure queryClosure) {
-        return (QueryableCriteria)new 
DetachedCriteria(targetClass).build(queryClosure)
+        return (QueryableCriteria) new SubqueryAdapter<T>((Class) 
targetClass).build(queryClosure)
+    }
+
+    /**
+     * A bare structural {@link AbstractDetachedCriteria} implementing {@link 
QueryableCriteria},
+     * used to represent a subquery embedded within another criteria (for 
example within
+     * {@code in}, {@code notIn} or {@code eqAll}). The query engine only ever 
reads the
+     * structural criteria/projections from a subquery - it never executes it 
standalone - so
+     * {@link #find()} and {@link #list()} are deliberately unsupported. This 
can't reuse the
+     * reactive {@link DetachedCriteria} itself: that class's own {@code 
find}/{@code list}
+     * methods already return {@link Observable}, which collides with the 
{@code T}/
+     * {@code List<T>} signatures {@link QueryableCriteria} requires.
+     */
+    @CompileStatic
+    private static class SubqueryAdapter<T> extends 
AbstractDetachedCriteria<T> implements QueryableCriteria<T> {
+
+        SubqueryAdapter(Class targetClass, String alias = null) {
+            super(targetClass, alias)
+        }
+
+        @Override
+        protected SubqueryAdapter<T> newInstance() {
+            new SubqueryAdapter<T>(targetClass, alias)
+        }
+
+        @Override
+        protected QueryableCriteria buildQueryableCriteria(Closure 
queryClosure) {
+            return (QueryableCriteria) new SubqueryAdapter<T>((Class) 
targetClass).build(queryClosure)
+        }
+
+        @Override
+        T find() {
+            throw new UnsupportedOperationException('find() cannot be called 
directly on a subquery criteria')
+        }
+
+        @Override
+        List<T> list() {
+            throw new UnsupportedOperationException('list() cannot be called 
directly on a subquery criteria')
+        }
     }
 
     @Override
@@ -643,22 +679,12 @@ class DetachedCriteria<T> extends 
AbstractDetachedCriteria<Observable<T>> implem
         }
         DynamicFinder.applyDetachedCriteria(query, this)
 
-        for (entry in fetchStrategies) {
-            switch (entry.value) {
-                case FetchType.EAGER:
-                    query.join(entry.key)
-                    break
-                default:
-                    query.select(entry.key)
-            }
-        }
-
         if (query instanceof QueryArgumentsAware) {
             query.arguments = args
         }
 
         if (additionalCriteria != null) {
-            def additionalDetached = new 
DetachedCriteria(targetClass).build(additionalCriteria)
+            def additionalDetached = new 
DetachedCriteria((Class<Observable<T>>) (Class) 
targetClass).build(additionalCriteria)
             DynamicFinder.applyDetachedCriteria(query, additionalDetached)
         }
 
diff --git 
a/grails-datamapping-rx/src/test/groovy/grails/gorm/rx/DetachedCriteriaSpec.groovy
 
b/grails-datamapping-rx/src/test/groovy/grails/gorm/rx/DetachedCriteriaSpec.groovy
new file mode 100644
index 0000000000..400dd68c70
--- /dev/null
+++ 
b/grails-datamapping-rx/src/test/groovy/grails/gorm/rx/DetachedCriteriaSpec.groovy
@@ -0,0 +1,608 @@
+/*
+ *  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 grails.gorm.rx
+
+import org.grails.datastore.mapping.model.PersistentEntity
+import org.grails.datastore.mapping.model.PersistentProperty
+import org.grails.datastore.mapping.query.Query
+import org.grails.datastore.mapping.query.api.QueryableCriteria
+import spock.lang.Specification
+
+/**
+ * Exercises the cast-and-delegate overrides on {@link DetachedCriteria}, the 
reactive
+ * counterpart of {@code grails.gorm.DetachedCriteria}, without touching any 
real datastore.
+ * The underlying restriction-building logic itself is covered by
+ * {@code AbstractDetachedCriteriaSpec} in {@code grails-datamapping-core}; 
these specs verify
+ * that each override here correctly delegates to its {@code super} 
implementation and narrows
+ * the return type to {@link DetachedCriteria}.
+ */
+class DetachedCriteriaSpec extends Specification {
+
+    void "eq/ne/gt/ge/gte/lt/le/lte/between add the expected criteria and 
return a DetachedCriteria"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        def results = [
+                criteria.eq('name', 'Bob'),
+                criteria.idEq(1),
+                criteria.ne('name', 'Bob'),
+                criteria.between('age', 1, 2),
+                criteria.gte('age', 1),
+                criteria.ge('age', 1),
+                criteria.gt('age', 1),
+                criteria.lte('age', 1),
+                criteria.le('age', 1),
+                criteria.lt('age', 1)
+        ]
+
+        then:
+        criteria.criteria.size() == 10
+        results.every { it instanceof DetachedCriteria }
+        criteria.criteria[0] instanceof Query.Equals
+        criteria.criteria[1] instanceof Query.IdEquals
+        criteria.criteria[2] instanceof Query.NotEquals
+        criteria.criteria[3] instanceof Query.Between
+        criteria.criteria[4] instanceof Query.GreaterThanEquals
+        criteria.criteria[5] instanceof Query.GreaterThanEquals
+        criteria.criteria[6] instanceof Query.GreaterThan
+        criteria.criteria[7] instanceof Query.LessThanEquals
+        criteria.criteria[8] instanceof Query.LessThanEquals
+        criteria.criteria[9] instanceof Query.LessThan
+    }
+
+    void "like/ilike/rlike add pattern criteria"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        def results = [
+                criteria.like('name', 'B%'),
+                criteria.ilike('name', 'b%'),
+                criteria.rlike('name', '^B.*')
+        ]
+
+        then:
+        results.every { it instanceof DetachedCriteria }
+        criteria.criteria[0] instanceof Query.Like
+        criteria.criteria[1] instanceof Query.ILike
+        criteria.criteria[2] instanceof Query.RLike
+    }
+
+    void "isNull/isNotNull/isEmpty/isNotEmpty add the matching property-name 
criteria"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        def results = [
+                criteria.isNull('name'),
+                criteria.isNotNull('name'),
+                criteria.isEmpty('name'),
+                criteria.isNotEmpty('name')
+        ]
+
+        then:
+        results.every { it instanceof DetachedCriteria }
+        criteria.criteria[0] instanceof Query.IsNull
+        criteria.criteria[1] instanceof Query.IsNotNull
+        criteria.criteria[2] instanceof Query.IsEmpty
+        criteria.criteria[3] instanceof Query.IsNotEmpty
+    }
+
+    void "idEquals adds an IdEquals criterion"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        def result = criteria.idEquals(42)
+
+        then:
+        result instanceof DetachedCriteria
+        criteria.criteria[0] instanceof Query.IdEquals
+    }
+
+    void "exists/notExists wrap a subquery"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def subquery = Mock(QueryableCriteria)
+
+        when:
+        def results = [
+                criteria.exists(subquery),
+                criteria.notExists(subquery)
+        ]
+
+        then:
+        results.every { it instanceof DetachedCriteria }
+        criteria.criteria[0] instanceof Query.Exists
+        criteria.criteria[1] instanceof Query.NotExists
+    }
+
+    void "eqProperty/neProperty/gtProperty/geProperty/ltProperty/leProperty 
compare two properties"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        def results = [
+                criteria.eqProperty('name', 'nickName'),
+                criteria.neProperty('name', 'nickName'),
+                criteria.gtProperty('age', 'maxAge'),
+                criteria.geProperty('age', 'maxAge'),
+                criteria.ltProperty('age', 'minAge'),
+                criteria.leProperty('age', 'minAge')
+        ]
+
+        then:
+        results.every { it instanceof DetachedCriteria }
+        criteria.criteria[0] instanceof Query.EqualsProperty
+        criteria.criteria[1] instanceof Query.NotEqualsProperty
+        criteria.criteria[2] instanceof Query.GreaterThanProperty
+        criteria.criteria[3] instanceof Query.GreaterThanEqualsProperty
+        criteria.criteria[4] instanceof Query.LessThanProperty
+        criteria.criteria[5] instanceof Query.LessThanEqualsProperty
+    }
+
+    void "allEq adds a Conjunction of Equals criteria"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        def result = criteria.allEq(name: 'Bob', age: 42)
+
+        then:
+        result instanceof DetachedCriteria
+        Query.Conjunction conjunction = criteria.criteria[0]
+        conjunction.criteria.size() == 2
+    }
+
+    void "sizeEq/sizeGt/sizeGe/sizeLe/sizeLt/sizeNe add size-comparison 
criteria"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        def results = [
+                criteria.sizeEq('books', 1),
+                criteria.sizeGt('books', 1),
+                criteria.sizeGe('books', 1),
+                criteria.sizeLe('books', 1),
+                criteria.sizeLt('books', 1),
+                criteria.sizeNe('books', 1)
+        ]
+
+        then:
+        results.every { it instanceof DetachedCriteria }
+        criteria.criteria[0] instanceof Query.SizeEquals
+        criteria.criteria[1] instanceof Query.SizeGreaterThan
+        criteria.criteria[2] instanceof Query.SizeGreaterThanEquals
+        criteria.criteria[3] instanceof Query.SizeLessThanEquals
+        criteria.criteria[4] instanceof Query.SizeLessThan
+        criteria.criteria[5] instanceof Query.SizeNotEquals
+    }
+
+    void "inList(Collection)/inList(Object array)/in(Collection)/in(Object 
array) all add an In criterion"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        def results = [
+                criteria.inList('name', ['Bob', 'Alice']),
+                criteria.inList('name', ['Bob'] as Object[]),
+                criteria."in"('name', ['Bob']),
+                criteria."in"('name', ['Bob'] as Object[])
+        ]
+
+        then:
+        results.every { it instanceof DetachedCriteria }
+        criteria.criteria.every { it instanceof Query.In }
+    }
+
+    void "inList(QueryableCriteria)/in(QueryableCriteria) wrap a subquery in 
an In criterion"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def subquery = Mock(QueryableCriteria)
+
+        when:
+        def results = [
+                criteria.inList('name', subquery),
+                criteria."in"('name', subquery)
+        ]
+
+        then:
+        results.every { it instanceof DetachedCriteria }
+        criteria.criteria.every { it instanceof Query.In }
+    }
+
+    void "inList(Closure)/in(Closure) build a QueryableCriteria from the 
closure"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        def results = [
+                criteria.inList('name') { eq('name', 'Bob') },
+                criteria."in"('name') { eq('name', 'Bob') }
+        ]
+
+        then:
+        results.every { it instanceof DetachedCriteria }
+        criteria.criteria.every {
+            it instanceof Query.In && ((Query.In) it).subquery instanceof 
QueryableCriteria
+        }
+    }
+
+    void "notIn(QueryableCriteria)/notIn(Closure) add a NotIn criterion"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def subquery = Mock(QueryableCriteria)
+
+        when:
+        def results = [
+                criteria.notIn('name', subquery),
+                criteria.notIn('name') { eq('name', 'Bob') }
+        ]
+
+        then:
+        results.every { it instanceof DetachedCriteria }
+        criteria.criteria.every { it instanceof Query.NotIn }
+    }
+
+    void "order(String)/order(String,String)/order(Query.Order) append orders 
and return a DetachedCriteria"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def order = Query.Order.desc('title')
+
+        when:
+        def results = [
+                criteria.order('name'),
+                criteria.order('name', 'desc'),
+                criteria.order(order)
+        ]
+
+        then:
+        results.every { it instanceof DetachedCriteria }
+        criteria.orders.size() == 3
+        criteria.orders[0].direction == Query.Order.Direction.ASC
+        criteria.orders[1].direction == Query.Order.Direction.DESC
+        criteria.orders[2].is(order)
+    }
+
+    void "and/or/not wrap nested criteria in the matching junction"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        def andResult = criteria.and { eq('name', 'Bob') }
+        def orResult = criteria.or { eq('name', 'Alice') }
+        def notResult = criteria.not { eq('name', 'Eve') }
+
+        then:
+        [andResult, orResult, notResult].every { it instanceof 
DetachedCriteria }
+        criteria.criteria.size() == 3
+        criteria.criteria[0] instanceof Query.Conjunction
+        criteria.criteria[1] instanceof Query.Disjunction
+        criteria.criteria[2] instanceof Query.Negation
+    }
+
+    void "eqAll/gtAll/ltAll/geAll/leAll accept a closure and build a 
subquery"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        def results = [
+                criteria.eqAll('age') { eq('age', 42) },
+                criteria.gtAll('age') { eq('age', 42) },
+                criteria.ltAll('age') { eq('age', 42) },
+                criteria.geAll('age') { eq('age', 42) },
+                criteria.leAll('age') { eq('age', 42) }
+        ]
+
+        then:
+        results.every { it instanceof DetachedCriteria }
+        criteria.criteria.size() == 5
+        criteria.criteria[0] instanceof Query.EqualsAll
+        criteria.criteria[1] instanceof Query.GreaterThanAll
+        criteria.criteria[2] instanceof Query.LessThanAll
+        criteria.criteria[3] instanceof Query.GreaterThanEqualsAll
+        criteria.criteria[4] instanceof Query.LessThanEqualsAll
+    }
+
+    void "eqAll/gtAll/ltAll/geAll/leAll accept a QueryableCriteria subquery 
directly"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def subquery = Mock(QueryableCriteria)
+
+        when:
+        def results = [
+                criteria.eqAll('age', subquery),
+                criteria.gtAll('age', subquery),
+                criteria.ltAll('age', subquery),
+                criteria.geAll('age', subquery),
+                criteria.leAll('age', subquery)
+        ]
+
+        then:
+        results.every { it instanceof DetachedCriteria }
+        criteria.criteria.size() == 5
+    }
+
+    void "gtSome/geSome/ltSome/leSome accept either a QueryableCriteria or a 
closure"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def subquery = Mock(QueryableCriteria)
+
+        when:
+        def results = [
+                criteria.gtSome('age', subquery),
+                criteria.gtSome('age') { eq('age', 42) },
+                criteria.geSome('age', subquery),
+                criteria.geSome('age') { eq('age', 42) },
+                criteria.ltSome('age', subquery),
+                criteria.ltSome('age') { eq('age', 42) },
+                criteria.leSome('age', subquery),
+                criteria.leSome('age') { eq('age', 42) }
+        ]
+
+        then:
+        results.every { it instanceof DetachedCriteria }
+        criteria.criteria.size() == 8
+        criteria.criteria[0] instanceof Query.GreaterThanSome
+        criteria.criteria[1] instanceof Query.GreaterThanSome
+        criteria.criteria[2] instanceof Query.GreaterThanEqualsSome
+        criteria.criteria[3] instanceof Query.GreaterThanEqualsSome
+        criteria.criteria[4] instanceof Query.LessThanSome
+        criteria.criteria[5] instanceof Query.LessThanSome
+        criteria.criteria[6] instanceof Query.LessThanEqualsSome
+        criteria.criteria[7] instanceof Query.LessThanEqualsSome
+    }
+
+    void "join(property) and join with select mark fetch strategies"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        def joined = criteria.join('books')
+        def selected = criteria.select('author')
+
+        then:
+        joined instanceof DetachedCriteria
+        selected instanceof DetachedCriteria
+        criteria.fetchStrategies.size() == 2
+    }
+
+    void "projections closure populates the projection list and returns a 
DetachedCriteria"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        def result = criteria.projections {
+            property('name')
+            count()
+        }
+
+        then:
+        result instanceof DetachedCriteria
+        criteria.projections.size() == 2
+    }
+
+    void "where/build derive a new DetachedCriteria without mutating the 
original"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        criteria.eq('name', 'Bob')
+
+        when:
+        def whereResult = criteria.where { eq('age', 42) }
+        def buildResult = criteria.build { eq('age', 43) }
+
+        then:
+        whereResult instanceof DetachedCriteria
+        buildResult instanceof DetachedCriteria
+        !whereResult.is(criteria)
+        !buildResult.is(criteria)
+        criteria.criteria.size() == 1
+        whereResult.criteria.size() == 2
+        buildResult.criteria.size() == 2
+    }
+
+    void "whereLazy/buildLazy derive a new DetachedCriteria"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        def whereLazyResult = criteria.whereLazy { eq('age', 42) }
+        def buildLazyResult = criteria.buildLazy { eq('age', 42) }
+
+        then:
+        whereLazyResult instanceof DetachedCriteria
+        buildLazyResult instanceof DetachedCriteria
+        whereLazyResult.criteria.size() == 1
+        buildLazyResult.criteria.isEmpty()
+        buildLazyResult.@lazyQuery != null
+    }
+
+    void "max(int)/offset(int) derive a new DetachedCriteria without mutating 
the original"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        def maxed = criteria.max(10)
+        def offset = criteria.offset(5)
+
+        then:
+        maxed instanceof DetachedCriteria
+        offset instanceof DetachedCriteria
+        maxed.defaultMax == 10
+        offset.defaultOffset == 5
+        criteria.defaultMax == null
+        criteria.defaultOffset == null
+    }
+
+    void "sort(property)/sort(property,direction) derive a new 
DetachedCriteria with an added order"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        def ascSort = criteria.sort('name')
+        def descSort = criteria.sort('name', 'desc')
+
+        then:
+        ascSort instanceof DetachedCriteria
+        descSort instanceof DetachedCriteria
+        ascSort.orders[0].direction == Query.Order.Direction.ASC
+        descSort.orders[0].direction == Query.Order.Direction.DESC
+        criteria.orders.isEmpty()
+    }
+
+    void "property/id/avg/sum/min/max(String)/distinct derive a new 
DetachedCriteria with the projection added"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        def results = [
+                criteria.property('name'),
+                criteria.id(),
+                criteria.avg('age'),
+                criteria.sum('age'),
+                criteria.min('age'),
+                criteria.max('age'),
+                criteria.distinct('name')
+        ]
+
+        then:
+        results.every { it instanceof DetachedCriteria && 
it.projections.size() == 1 }
+        criteria.projections.isEmpty()
+    }
+
+    void "propertyMissing returns a property projection for a known 
property"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def property = Mock(PersistentProperty)
+        def entity = Mock(PersistentEntity) {
+            getPropertyByName('name') >> property
+        }
+        criteria.@persistentEntity = entity
+        criteria.@dynamicFinders = []
+
+        when:
+        def result = criteria.propertyMissing('name')
+
+        then:
+        result instanceof DetachedCriteria
+        result.projections.size() == 1
+    }
+
+    void "propertyMissing throws MissingPropertyException for an unknown 
property"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def entity = Mock(PersistentEntity) {
+            getPropertyByName('nope') >> null
+        }
+        criteria.@persistentEntity = entity
+        criteria.@dynamicFinders = []
+
+        when:
+        criteria.propertyMissing('nope')
+
+        then:
+        thrown(MissingPropertyException)
+    }
+
+    void "clone produces a distinct DetachedCriteria with copied 
collections"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        criteria.eq('name', 'Bob')
+        criteria.order('name')
+
+        when:
+        def cloned = criteria.clone()
+        cloned.eq('age', 42)
+        cloned.order('age')
+
+        then:
+        cloned instanceof DetachedCriteria
+        !cloned.is(criteria)
+        criteria.criteria.size() == 1
+        cloned.criteria.size() == 2
+        criteria.orders.size() == 1
+        cloned.orders.size() == 2
+    }
+
+    void "buildQueryableCriteria builds a QueryableCriteria subquery from the 
closure"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        def result = criteria.buildQueryableCriteria { eq('name', 'Bob') }
+
+        then:
+        result instanceof QueryableCriteria
+        !result.is(criteria)
+        result.criteria.size() == 1
+        criteria.criteria.isEmpty()
+    }
+
+    void "the QueryableCriteria built for a subquery does not support find() 
or list()"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        def subquery = criteria.buildQueryableCriteria { eq('name', 'Bob') }
+        subquery.find()
+
+        then:
+        thrown(UnsupportedOperationException)
+
+        when:
+        subquery.list()
+
+        then:
+        thrown(UnsupportedOperationException)
+    }
+
+    void "a subquery can itself contain a nested closure-based subquery"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+
+        when:
+        def subquery = criteria.buildQueryableCriteria {
+            inList('name') { eq('name', 'Bob') }
+        }
+
+        then:
+        subquery instanceof QueryableCriteria
+        subquery.criteria.size() == 1
+        (subquery.criteria[0] as Query.In).subquery instanceof 
QueryableCriteria
+    }
+
+    void "convertArgumentList converts CharSequence values to String"() {
+        given:
+        def criteria = new DetachedCriteria(TestEntity)
+        def gstring = "${'Bob'}"
+
+        when:
+        def result = criteria.convertArgumentList([gstring, 'Alice'])
+
+        then:
+        result == ['Bob', 'Alice']
+        result.every { it instanceof String }
+    }
+}
+
+class TestEntity {
+    Long id
+    String name
+}
diff --git 
a/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/api/DetachedCriteriaQuerySpec.groovy
 
b/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/api/DetachedCriteriaQuerySpec.groovy
new file mode 100644
index 0000000000..a5f27f9a81
--- /dev/null
+++ 
b/grails-datamapping-rx/src/test/groovy/org/grails/gorm/rx/api/DetachedCriteriaQuerySpec.groovy
@@ -0,0 +1,281 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.gorm.rx.api
+
+import grails.gorm.rx.DetachedCriteria
+import org.grails.datastore.mapping.core.connections.ConnectionSource
+import org.grails.datastore.mapping.model.MappingContext
+import org.grails.datastore.mapping.model.PersistentEntity
+import org.grails.datastore.mapping.query.Query
+import org.grails.datastore.mapping.query.api.QueryArgumentsAware
+import org.grails.datastore.rx.internal.RxDatastoreClientImplementor
+import org.grails.datastore.rx.query.RxQuery
+import org.springframework.core.convert.ConversionService
+import rx.Observable
+import rx.Subscriber
+import spock.lang.Specification
+
+/**
+ * Exercises the reactive query-execution methods on {@link DetachedCriteria} 
(rx) - the ones
+ * that go through {@code prepareQuery} and {@link 
RxGormEnhancer#findStaticApi}. This requires
+ * a fake entity registered with {@link RxGormEnhancer}, since those static 
lookups can't be
+ * intercepted from outside the {@code CompileStatic} call site with 
metaclass-based mocking.
+ * Package-local so the test can call the {@code protected static}
+ * {@code RxGormEnhancer.registerEntityWithConnectionSource} directly rather 
than driving the
+ * full {@code registerEntity} multi-tenancy/connection-source resolution 
machinery.
+ */
+class DetachedCriteriaQuerySpec extends Specification {
+
+    Query mockQuery
+    RxDatastoreClientImplementor datastoreClient
+
+    void setup() {
+        def queryEntity = Mock(PersistentEntity) {
+            getMappingContext() >> Mock(MappingContext) {
+                getConversionService() >> Mock(ConversionService)
+            }
+        }
+        mockQuery = Mock(Query, additionalInterfaces: [RxQuery, 
QueryArgumentsAware]) {
+            getEntity() >> queryEntity
+        }
+        datastoreClient = Mock(RxDatastoreClientImplementor) {
+            createQuery(QueryTestEntity, _ as Map) >> mockQuery
+        }
+        def persistentEntity = Mock(PersistentEntity) {
+            getJavaClass() >> QueryTestEntity
+            getName() >> QueryTestEntity.name
+        }
+        def staticApi = Mock(RxGormStaticApi) {
+            getDatastoreClient() >> datastoreClient
+        }
+        datastoreClient.createStaticApi(persistentEntity, 
ConnectionSource.DEFAULT) >> staticApi
+        datastoreClient.createInstanceApi(persistentEntity, 
ConnectionSource.DEFAULT) >> Mock(RxGormInstanceApi)
+        datastoreClient.createValidationApi(persistentEntity, 
ConnectionSource.DEFAULT) >> Mock(RxGormValidationApi)
+        RxGormEnhancer.registerEntityWithConnectionSource(persistentEntity, 
ConnectionSource.DEFAULT, ConnectionSource.DEFAULT, datastoreClient)
+    }
+
+    void cleanup() {
+        RxGormEnhancer.close()
+    }
+
+    void "find applies a max of 1 and delegates to RxQuery#findAll"() {
+        given:
+        def criteria = new DetachedCriteria(QueryTestEntity)
+        def observable = Observable.just(new QueryTestEntity())
+        mockQuery.findAll(_ as Map) >> observable
+
+        when:
+        def result = criteria.find()
+
+        then:
+        1 * mockQuery.max(1)
+        result.is(observable)
+    }
+
+    void "findAll delegates to RxQuery#findAll"() {
+        given:
+        def criteria = new DetachedCriteria(QueryTestEntity)
+        def observable = Observable.just(new QueryTestEntity())
+        mockQuery.findAll(_ as Map) >> observable
+
+        when:
+        def result = criteria.findAll()
+
+        then:
+        result.is(observable)
+    }
+
+    void "get(Map) applies a max of 1 and delegates to RxQuery#singleResult"() 
{
+        given:
+        def criteria = new DetachedCriteria(QueryTestEntity)
+        def observable = Observable.just(new QueryTestEntity())
+        mockQuery.singleResult(_ as Map) >> observable
+
+        when:
+        def result = criteria.get([:])
+
+        then:
+        1 * mockQuery.max(1)
+        result.is(observable)
+    }
+
+    void "get(Closure) applies a max of 1 and delegates to the no-arg 
RxQuery#singleResult"() {
+        given:
+        def criteria = new DetachedCriteria(QueryTestEntity)
+        def observable = Observable.just(new QueryTestEntity())
+        mockQuery.singleResult() >> observable
+
+        when:
+        def result = criteria.get()
+
+        then:
+        1 * mockQuery.max(1)
+        result.is(observable)
+    }
+
+    void "toList/list convert the RxQuery#findAll observable into an 
observable list"() {
+        given:
+        def criteria = new DetachedCriteria(QueryTestEntity)
+        def one = new QueryTestEntity(name: 'one')
+        def two = new QueryTestEntity(name: 'two')
+        mockQuery.findAll(_ as Map) >> Observable.just(one, two)
+
+        when:
+        def toListResult = criteria.toList().toBlocking().first()
+        def listResult = criteria.list().toBlocking().first()
+
+        then:
+        toListResult == [one, two]
+        listResult == [one, two]
+    }
+
+    void "getCount/count apply a count projection and delegate to 
RxQuery#singleResult"() {
+        given:
+        def criteria = new DetachedCriteria(QueryTestEntity)
+        def observable = Observable.just(2)
+        mockQuery.singleResult(_ as Map) >> observable
+        mockQuery.projections() >> Mock(Query.ProjectionList)
+
+        when:
+        def getCountResult = criteria.getCount()
+        def countResult = criteria.count()
+
+        then:
+        getCountResult.is(observable)
+        countResult.is(observable)
+    }
+
+    void "updateAll delegates to RxQuery#updateAll"() {
+        given:
+        def criteria = new DetachedCriteria(QueryTestEntity)
+        def observable = Observable.just(1)
+        mockQuery.updateAll(_ as Map) >> observable
+
+        when:
+        def result = criteria.updateAll(name: 'Bob')
+
+        then:
+        result.is(observable)
+    }
+
+    void "deleteAll delegates to RxQuery#deleteAll"() {
+        given:
+        def criteria = new DetachedCriteria(QueryTestEntity)
+        def observable = Observable.just(1)
+        mockQuery.deleteAll() >> observable
+
+        when:
+        def result = criteria.deleteAll()
+
+        then:
+        result.is(observable)
+    }
+
+    void "toQuery returns the prepared Query"() {
+        given:
+        def criteria = new DetachedCriteria(QueryTestEntity)
+
+        when:
+        def result = criteria.toQuery()
+
+        then:
+        result.is(mockQuery)
+    }
+
+    void "toObservable delegates to findAll"() {
+        given:
+        def criteria = new DetachedCriteria(QueryTestEntity)
+        def observable = Observable.just(new QueryTestEntity())
+        mockQuery.findAll(_ as Map) >> observable
+
+        when:
+        def result = criteria.toObservable()
+
+        then:
+        result.is(observable)
+    }
+
+    void "subscribe delegates to findAll().subscribe(subscriber)"() {
+        given:
+        def criteria = new DetachedCriteria(QueryTestEntity)
+        def entity = new QueryTestEntity()
+        mockQuery.findAll(_ as Map) >> Observable.just(entity)
+        def received = null
+        def subscriber = new Subscriber<QueryTestEntity>() {
+            void onCompleted() { }
+            void onError(Throwable e) { }
+            void onNext(QueryTestEntity e) { received = e }
+        }
+
+        when:
+        criteria.subscribe(subscriber)
+
+        then:
+        received.is(entity)
+    }
+
+    void "prepareQuery applies the default max and offset when set"() {
+        given:
+        def criteria = new DetachedCriteria(QueryTestEntity)
+        def withMax = criteria.max(10)
+        def withOffset = criteria.offset(5)
+
+        when:
+        withMax.findAll()
+
+        then:
+        1 * mockQuery.max(10)
+
+        when:
+        withOffset.findAll()
+
+        then:
+        1 * mockQuery.offset(5)
+    }
+
+    void "prepareQuery applies join and select fetch strategies"() {
+        given:
+        def criteria = new DetachedCriteria(QueryTestEntity)
+        criteria.join('books')
+        criteria.select('author')
+
+        when:
+        criteria.findAll()
+
+        then:
+        1 * mockQuery.join('books')
+        1 * mockQuery.select('author')
+    }
+
+    void "prepareQuery merges an additional criteria closure"() {
+        given:
+        def criteria = new DetachedCriteria(QueryTestEntity)
+
+        when:
+        criteria.findAll([:]) { eq('name', 'Bob') }
+
+        then:
+        1 * mockQuery.add({ it.property == 'name' && it.value == 'Bob' })
+    }
+}
+
+class QueryTestEntity {
+    Long id
+    String name
+}

Reply via email to