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/10/08 00:00:49 UTC
(grails-core) 02/09: fix: do not refuse a many-to-many that has a Map side
This is an automated email from the ASF dual-hosted git repository.
borinquenkid pushed a commit to branch 9.0.x-h7-improvement-phase3
in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit d5fa506cbbd8904e166ac4f60c6344ad7a304b98
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Wed Oct 7 17:42:27 2026 -0500
fix: do not refuse a many-to-many that has a Map side
A Map-valued side is made non-inverse by MapSecondPassBinder, so it writes
the join table and the relationship is stored even without belongsTo, as the
8.0.x review showed. The owner validator threw for it at startup anyway; it now
skips a relationship with a Map on either side. The generated mode keeps its
own refusal of a map on a many-to-many, which is no longer pre-empted by the
missing belongsTo error.
Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
.../binder/ManyToManyOwnerValidator.groovy | 8 +-
.../binder/ManyToManyWithoutOwnerSpec.groovy | 93 ++++++++++++++++++++++
...eneratedDomainClassesDdlDifferentialSpec.groovy | 1 +
3 files changed, 101 insertions(+), 1 deletion(-)
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/ManyToManyOwnerValidator.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/ManyToManyOwnerValidator.groovy
index a7bab181dc..68b15f148d 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/ManyToManyOwnerValidator.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/ManyToManyOwnerValidator.groovy
@@ -32,7 +32,8 @@ import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernatePersistentP
*
* <p>The rule covers a self-referencing many-to-many too: its sides are owning only when the class declares itself in
* {@code belongsTo}, and without that both are inverse as well. A unidirectional many-to-many, and one where either side
- * owns the relationship, are accepted.</p>
+ * owns the relationship, are accepted. So is one with a {@code Map} on either side, which is stored regardless
+ * because that side writes the join table.</p>
*
* @since 9.0
*/
@@ -55,6 +56,11 @@ class ManyToManyOwnerValidator {
if (!(otherSide instanceof HibernateManyToManyProperty) || otherSide.isOwningSide()) {
return
}
+ // a Map-valued side is never inverse once its second pass has run: MapSecondPassBinder makes it write the
+ // join table, so a relationship with a Map on either side is stored even without belongsTo
+ if (Map.isAssignableFrom(manyToMany.type) || Map.isAssignableFrom(otherSide.type)) {
+ return
+ }
// the same message whichever side is bound first: the sides are named in alphabetical order
HibernateAssociation first = manyToMany
HibernateAssociation second = otherSide
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/ManyToManyWithoutOwnerSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/ManyToManyWithoutOwnerSpec.groovy
index 327af3ce89..901ec72f7c 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/ManyToManyWithoutOwnerSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/ManyToManyWithoutOwnerSpec.groovy
@@ -67,6 +67,20 @@ class ManyToManyWithoutOwnerSpec extends Specification {
return null
}
+ private static UnsupportedOperationException refusalFailure(Closure<?> boot) {
+ try {
+ boot()
+ } catch (Throwable e) {
+ for (Throwable cause = e; cause != null; cause = cause.cause == cause ? null : cause.cause) {
+ if (cause instanceof UnsupportedOperationException) {
+ return (UnsupportedOperationException) cause
+ }
+ }
+ throw e
+ }
+ return null
+ }
+
@Unroll
def "the application does not start when neither side of a many-to-many declares belongsTo (generated mapping: #generated)"() {
when:
@@ -142,6 +156,49 @@ class ManyToManyWithoutOwnerSpec extends Specification {
where:
generated << [false, true]
}
+
+ @Unroll
+ def "a many-to-many with a Map on #shape starts without belongsTo and stores its rows through the Map side"() {
+ when:
+ boot(false, left, right)
+ left.withTransaction {
+ def rightRow = right.newInstance(name: 'right').save(failOnError: true)
+ left.newInstance(name: 'left', (leftProperty): [first: rightRow]).save(flush: true, failOnError: true)
+ }
+
+ then:
+ left.withNewSession { left.findByName('left')."$leftProperty".keySet() } == ['first'] as Set
+ !readsBack || right.withNewSession { right.findByName('right')."$rightProperty"*.name } == ['left']
+
+ where:
+ shape | left | right | leftProperty | rightProperty | readsBack
+ 'one side' | MapSetLeft | MapSetRight | 'rights' | 'lefts' | true
+ 'both sides' | MapMapLeft | MapMapRight | 'rights' | 'lefts' | false
+ }
+
+ @Unroll
+ def "the generated mapping refuses a many-to-many with a Map on #shape as a map on a many-to-many, not as a missing belongsTo"() {
+ when:
+ UnsupportedOperationException refusal = refusalFailure { boot(true, left, right) }
+
+ then:
+ refusal != null
+ refusal.message.contains('a map on a many-to-many, which the generator does not support yet')
+
+ where:
+ shape | left | right
+ 'one side' | MapSetLeft | MapSetRight
+ 'both sides' | MapMapLeft | MapMapRight
+ }
+
+ def "a Set on both sides without belongsTo still does not start"() {
+ when:
+ MappingException failure = mappingFailure { boot(false, NoOwnerLeft, NoOwnerRight) }
+
+ then:
+ failure != null
+ failure.message.startsWith('Neither side of the many-to-many')
+ }
}
@Entity
@@ -189,3 +246,39 @@ class OwnedSelf {
static mappedBy = [followers: 'following', following: 'followers']
static belongsTo = [OwnedSelf]
}
+
+@Entity
+class MapSetLeft {
+
+ String name
+ Map<String, MapSetRight> rights
+
+ static hasMany = [rights: MapSetRight]
+}
+
+@Entity
+class MapSetRight {
+
+ String name
+ Set<MapSetLeft> lefts
+
+ static hasMany = [lefts: MapSetLeft]
+}
+
+@Entity
+class MapMapLeft {
+
+ String name
+ Map<String, MapMapRight> rights
+
+ static hasMany = [rights: MapMapRight]
+}
+
+@Entity
+class MapMapRight {
+
+ String name
+ Map<String, MapMapLeft> lefts
+
+ static hasMany = [lefts: MapMapLeft]
+}
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
index 680b58cd77..5db18f52d3 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassesDdlDifferentialSpec.groovy
@@ -89,6 +89,7 @@ class GeneratedDomainClassesDdlDifferentialSpec extends Specification {
'which is not a persistent property of the entity',
'is a registered type with type parameters',
'the index column of the map is mapped with the type',
+ 'a map on a many-to-many',
]
private static final AtomicInteger BOOTS = new AtomicInteger()