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 +}
