This is an automated email from the ASF dual-hosted git repository. borinquenkid pushed a commit to branch feat/gorm-registry-consolidated in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit 494af9f163658c5d89b6aa98ef9f261c05ee8a38 Author: Walter Duque de Estrada <[email protected]> AuthorDate: Sat Aug 15 11:06:18 2026 -0500 Address remaining #16066 review feedback: document Tenants.withId erasure, add junction-factory tests Tenants.withId(Class<? extends Datastore>, ..) was removed but its replacement, withId(Class domainClass, ..), shares the same erased signature, so old call sites still compile and silently resolve against the DEFAULT datastore instead of throwing. Documents the change and migration path in upgrading80x.adoc #45. Adds H7-only tests for HibernateQuery.disjunction()/conjunction()/negation() that assert actual result sets rather than criteria shape, covering the same junction-drop root cause as the countByXOrY fix but for the conjunction/negation paths that no built-in dynamic finder exercises. H5's HibernateQuery hierarchy never had this bug (AbstractHibernateQuery doesn't share H7's junction-factory implementation), so there is no H5-side equivalent to add. Co-Authored-By: Claude Sonnet 5 <[email protected]> --- .../tests/hibernatequery/HibernateQuerySpec.groovy | 41 +++++++++++++++++++++ .../src/en/guide/upgrading/upgrading80x.adoc | 43 ++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/hibernatequery/HibernateQuerySpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/hibernatequery/HibernateQuerySpec.groovy index 218af9917d..54c6d215e7 100644 --- a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/hibernatequery/HibernateQuerySpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/hibernatequery/HibernateQuerySpec.groovy @@ -963,6 +963,47 @@ class HibernateQuerySpec extends HibernateGormDatastoreSpec { bob == oldBob } + def "countByXOrY exercises disjunction() through a dynamic finder"() { + given: "one person matching only the age side of the Or, one matching only the name side, and one matching neither" + new Person(firstName: "Fred", lastName: "Rogers", age: 51).save(flush: true) + new Person(firstName: "Walt", lastName: "Disney", age: 52).save(flush: true) + + when: "counting by a condition oldBob satisfies through age and Fred satisfies through firstName" + def count = Person.countByFirstNameOrAge("Fred", 50) + + then: "only the two matching rows are counted; a disjunction dropped onto the unused base criteria field would count every row" + count == 2 + } + + def "conjunction() combines added criteria so only rows matching all of them are returned"() { + given: "a second person who satisfies only one of the two criteria added to the conjunction" + new Person(firstName: "Fred", lastName: "Rogers", age: 50).save(flush: true) + + when: "adding two criteria directly to the Junction returned by conjunction()" + def conjunction = hibernateQuery.conjunction() + conjunction.add(new Query.Equals("firstName", "Bob")) + conjunction.add(new Query.Equals("age", 50)) + def results = hibernateQuery.list() + + then: "only the row matching both criteria is returned; a conjunction dropped onto the unused base criteria field would return every row" + results.size() == 1 + results[0] == oldBob + } + + def "negation() excludes rows matching the added criterion"() { + given: "a second person who would incorrectly remain excluded if the negation were silently dropped" + def fred = new Person(firstName: "Fred", lastName: "Rogers", age: 51).save(flush: true) + + when: "negating a criterion that matches oldBob" + def negation = hibernateQuery.negation() + negation.add(new Query.Equals("firstName", "Bob")) + def results = hibernateQuery.list() + + then: "oldBob is excluded and only the non-matching row remains; a negation dropped onto the unused base criteria field would return every row" + results.size() == 1 + results[0] == fred + } + def andList() { given: hibernateQuery.and([new Query.Equals("firstName", "Bob"), new Query.Equals("age", 50)]) diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index 0c354b2945..5711e47408 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -2327,3 +2327,46 @@ CurrentTenantHolder.get(datastore) ---- See <<multiTenancy,Multi-Tenancy>> for the full multi-tenancy API. + +===== Tenants.withId(Class, Serializable, Closure) + +`grails.gorm.multitenancy.Tenants` had two distinct 3-argument `withId` overloads that both erased to +`withId(Class, Serializable, Closure)` at the bytecode level: + +[source,groovy] +---- +// removed - resolved a datastore by its own type +static <T> T withId(Class<? extends Datastore> datastoreClass, Serializable tenantId, Closure<T> callable) + +// kept - resolves the datastore that maps a domain class +static <T> T withId(Class domainClass, Serializable tenantId, Closure<T> callable) +---- + +The datastore-class overload relied on `GormEnhancer.findDatastoreByType(Class)`, which was one of the internal +per-method-dispatch helpers removed as part of the registry migration (see <<_gormenhancer,GormEnhancer>> +above), so it could not be carried forward under its own name. Only the domain-class overload remains. + +Because both overloads share one erased signature, a call site that used to pass a datastore class - +`Tenants.withId(SomeDatastore, tenantId) { ... }` - still compiles unchanged against the domain-class overload. +It no longer resolves the datastore you named: `getDatastoreForDomain(SomeDatastore)` looks `SomeDatastore` up as +if it were a *domain class*, finds no entity registered under that name, and falls through to the application's +`DEFAULT` datastore. On a single-datastore application this is indistinguishable from the old behaviour. On a +multi-datastore application it silently binds the tenant against the wrong datastore - no exception, no log - +which can surface much later as a cross-database read or write. + +If you have a call site that passes a `Datastore` subclass to this method, migrate it to look the datastore up +explicitly and call the datastore-instance overload instead. `Tenants.datastoreLocator` still resolves a +datastore by its type - only the `withId(Class, ..)` shortcut for it is gone: + +[source,groovy] +---- +MultiTenantCapableDatastore datastore = Tenants.datastoreLocator.getDatastore(SomeDatastore) as MultiTenantCapableDatastore +Tenants.withId(datastore, tenantId) { ... } +---- + +or pass an actual domain class if that is what you meant: + +[source,groovy] +---- +Tenants.withId(SomeDomainClass, tenantId) { ... } +----
