You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by bo...@apache.org on 2026/08/12 20:44:02 UTC
(grails-core) 01/01: Add test coverage for AbstractDetachedCriteria and rx DetachedCriteria
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 <wb...@mac.com>
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 <no...@anthropic.com>
---
.../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
+}