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/09 02:39:15 UTC

(grails-core) 05/18: feat: give every owner of a shared embedded collection a table of its own in native domain binding

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 098aa45866797c69cb762b48130817b8313b46b5
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Thu Oct 8 15:09:17 2026 -0500

    feat: give every owner of a shared embedded collection a table of its own in native domain binding
    
    The generated mode refused a hasMany inside an embedded class that more than one embedded property reaches. The domain binder names the table and the key column after the embedded class, so all the owners share one table whose foreign key points at only one of them: the collection of any other owner cannot be stored, the elements of two properties of one owner are mixed up, and the properties cannot be named alike. The generator now accepts it. The embeddable names no table, and each  [...]
    
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 .../jpa/GeneratedDomainClassBinder.groovy          |  62 +++--
 .../domainbinding/jpa/GrailsDomainGenerator.groovy | 227 +++++++++++++---
 ...ratedDomainClassesEmbeddedCollectionSpec.groovy |  60 +----
 ...omainClassesSharedEmbeddedCollectionSpec.groovy | 289 +++++++++++++++++++++
 .../unbootable/EmbeddedCollectionDomains.groovy    |  62 ++++-
 .../jpa/GrailsDomainGeneratorSpec.groovy           |  40 ++-
 .../advancedGORMFeatures/nativeDomainBinding.adoc  |  13 +-
 .../src/en/guide/upgrading/upgrading90x.adoc       |   3 +
 8 files changed, 622 insertions(+), 134 deletions(-)

diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy
index d79cac710b..a7ea5399c1 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GeneratedDomainClassBinder.groovy
@@ -353,7 +353,7 @@ class GeneratedDomainClassBinder implements SessionFactoryBuilderFactory {
             alignIdentifier((RootClass) persistentClass, generated, metadata)
         }
         for (Property property : persistentClass.declaredProperties) {
-            alignProperty(property, entity, real)
+            alignProperty(property, entity, real, null, '')
         }
         alignUniqueKeys(persistentClass, entity)
     }
@@ -538,7 +538,7 @@ class GeneratedDomainClassBinder implements SessionFactoryBuilderFactory {
             alignCompositeIdentifier(root, entity, metadata)
             return
         }
-        alignProperty(root.identifierProperty, entity, entity.javaClass)
+        alignProperty(root.identifierProperty, entity, entity.javaClass, null, '')
         String name = entity.identity.name
         Field field = generated.generatedClass().getDeclaredField(name)
         GrailsIdGenerator marker = field.getAnnotation(GrailsIdGenerator)
@@ -623,7 +623,12 @@ class GeneratedDomainClassBinder implements SessionFactoryBuilderFactory {
         })
     }
 
-    private void alignProperty(Property property, GrailsHibernatePersistentEntity owner, Class<?> ownerClass) {
+    /**
+     * @param holder the entity that declares the embedded property the property is inside, or {@code null} for a property of the entity
+     * @param path the dotted names of the embedded properties from {@code holder} down to the owner of the property
+     */
+    private void alignProperty(
+            Property property, GrailsHibernatePersistentEntity owner, Class<?> ownerClass, GrailsHibernatePersistentEntity holder, String path) {
         PersistentProperty<?> persistentProperty = owner.identity?.name == property.name ?
                 owner.identity : owner.getPropertyByName(property.name)
         if (persistentProperty instanceof HibernatePersistentProperty) {
@@ -633,11 +638,13 @@ class GeneratedDomainClassBinder implements SessionFactoryBuilderFactory {
             alignJoinColumns(property.value, generator.toOneFacets((HibernateToOneProperty) persistentProperty).joinColumns())
         }
         if (property.value instanceof Collection && persistentProperty instanceof HibernatePersistentProperty) {
-            alignJoinColumns((Collection) property.value, (HibernatePersistentProperty) persistentProperty)
-            alignExtraLazy((Collection) property.value, (HibernatePersistentProperty) persistentProperty)
-            alignCollectionTable((Collection) property.value, (HibernatePersistentProperty) persistentProperty)
-            alignListIndexLength((Collection) property.value, (HibernatePersistentProperty) persistentProperty)
-            alignCollectionIndexes((Collection) property.value, (HibernatePersistentProperty) persistentProperty)
+            // a collection of an embedded type that several embedded properties reach has a table of its own, named after its owner
+            String qualifier = holder == null ? null : generator.embeddedCollectionQualifier(holder, path, persistentProperty.name)
+            alignJoinColumns((Collection) property.value, (HibernatePersistentProperty) persistentProperty, qualifier)
+            alignExtraLazy((Collection) property.value, (HibernatePersistentProperty) persistentProperty, qualifier)
+            alignCollectionTable((Collection) property.value, (HibernatePersistentProperty) persistentProperty, qualifier)
+            alignListIndexLength((Collection) property.value, (HibernatePersistentProperty) persistentProperty, qualifier)
+            alignCollectionIndexes((Collection) property.value, (HibernatePersistentProperty) persistentProperty, qualifier)
         }
         if (property.value instanceof Component && persistentProperty instanceof Embedded) {
             // PropertyBinder marks the property lazy when the mapping says lazy: true; @Basic(fetch = LAZY) on an @Embedded is ignored
@@ -650,7 +657,9 @@ class GeneratedDomainClassBinder implements SessionFactoryBuilderFactory {
                 alignComponent(
                         (Component) property.value,
                         (GrailsHibernatePersistentEntity) embedded,
-                        BeanUtils.findPropertyType(property.name, ownerClass))
+                        BeanUtils.findPropertyType(property.name, ownerClass),
+                        holder == null ? owner : holder,
+                        path.isEmpty() ? property.name : "${path}.${property.name}".toString())
             }
         }
     }
@@ -661,15 +670,15 @@ class GeneratedDomainClassBinder implements SessionFactoryBuilderFactory {
      * {@code precision} and {@code scale} change nothing, as the type of a foreign key column is the one of the column it references). A
      * {@code @JoinColumn} states none of them, so they are set on the columns of the bound model.
      */
-    private void alignJoinColumns(Collection collection, HibernatePersistentProperty property) {
+    private void alignJoinColumns(Collection collection, HibernatePersistentProperty property, String qualifier) {
         if (property instanceof HibernateToManyEntityProperty) {
-            ToManyFacets facets = generator.toManyFacets((HibernateToManyEntityProperty) property)
+            ToManyFacets facets = generator.toManyFacets((HibernateToManyEntityProperty) property, qualifier)
             alignJoinColumns(collection.key, facets.keys())
             if (facets.manyToMany()) {
                 alignJoinColumns(collection.element, [facets.element()])
             }
         } else if (property instanceof HibernateBasicProperty) {
-            alignJoinColumns(collection.key, generator.collectionFacets((HibernateBasicProperty) property).keys())
+            alignJoinColumns(collection.key, generator.collectionFacets((HibernateBasicProperty) property, qualifier).keys())
         }
     }
 
@@ -702,12 +711,12 @@ class GeneratedDomainClassBinder implements SessionFactoryBuilderFactory {
      * {@code contains()} and {@code isEmpty()} do not initialize it). Hibernate 7's annotation binder always binds an ordinary
      * lazy collection and has no annotation for the extra-lazy kind, so the flag is set on the bound collection.
      */
-    private void alignExtraLazy(Collection collection, HibernatePersistentProperty property) {
+    private void alignExtraLazy(Collection collection, HibernatePersistentProperty property, String qualifier) {
         boolean extraLazy
         if (property instanceof HibernateToManyEntityProperty) {
-            extraLazy = generator.toManyFacets((HibernateToManyEntityProperty) property).extraLazy()
+            extraLazy = generator.toManyFacets((HibernateToManyEntityProperty) property, qualifier).extraLazy()
         } else if (property instanceof HibernateBasicProperty) {
-            extraLazy = generator.collectionFacets((HibernateBasicProperty) property).extraLazy()
+            extraLazy = generator.collectionFacets((HibernateBasicProperty) property, qualifier).extraLazy()
         } else {
             return
         }
@@ -723,12 +732,12 @@ class GeneratedDomainClassBinder implements SessionFactoryBuilderFactory {
      * join table. The indexes the generator decided are created here on the table Hibernate bound for the collection, with the names the
      * mapping gives them.
      */
-    private void alignCollectionIndexes(Collection collection, HibernatePersistentProperty property) {
+    private void alignCollectionIndexes(Collection collection, HibernatePersistentProperty property, String qualifier) {
         List<IndexFacets> indexes
         if (property instanceof HibernateToManyEntityProperty) {
-            indexes = generator.toManyFacets((HibernateToManyEntityProperty) property).indexes()
+            indexes = generator.toManyFacets((HibernateToManyEntityProperty) property, qualifier).indexes()
         } else if (property instanceof HibernateBasicProperty) {
-            indexes = generator.collectionFacets((HibernateBasicProperty) property).indexes()
+            indexes = generator.collectionFacets((HibernateBasicProperty) property, qualifier).indexes()
         } else {
             return
         }
@@ -751,12 +760,12 @@ class GeneratedDomainClassBinder implements SessionFactoryBuilderFactory {
      * Hibernate's annotation binder gives the index column of a list no length, which it sizes as a long string when the mapping types
      * the index as a string (a CLOB, which cannot be a key). The domain binder's column has the default length of every column it creates.
      */
-    private void alignListIndexLength(Collection collection, HibernatePersistentProperty property) {
+    private void alignListIndexLength(Collection collection, HibernatePersistentProperty property, String qualifier) {
         TypeFacets indexType
         if (property instanceof HibernateToManyEntityProperty) {
-            indexType = generator.toManyFacets((HibernateToManyEntityProperty) property).indexType()
+            indexType = generator.toManyFacets((HibernateToManyEntityProperty) property, qualifier).indexType()
         } else if (property instanceof HibernateBasicProperty) {
-            indexType = generator.collectionFacets((HibernateBasicProperty) property).indexType()
+            indexType = generator.collectionFacets((HibernateBasicProperty) property, qualifier).indexType()
         } else {
             return
         }
@@ -775,18 +784,18 @@ class GeneratedDomainClassBinder implements SessionFactoryBuilderFactory {
      * column is nullable gets a unique key over its columns instead of a primary key. The nullability the generator decided
      * (the binder's) is restored on the columns, and for a set the key Hibernate derived from them is derived again.
      */
-    private void alignCollectionTable(Collection collection, HibernatePersistentProperty property) {
+    private void alignCollectionTable(Collection collection, HibernatePersistentProperty property, String qualifier) {
         if (collection.inverse || collection.oneToMany) {
             return
         }
         List<ColumnFacets> keys
         ColumnFacets element
         if (property instanceof HibernateToManyEntityProperty) {
-            ToManyFacets facets = generator.toManyFacets((HibernateToManyEntityProperty) property)
+            ToManyFacets facets = generator.toManyFacets((HibernateToManyEntityProperty) property, qualifier)
             keys = facets.keys()
             element = facets.element()
         } else if (property instanceof HibernateBasicProperty) {
-            CollectionFacets facets = generator.collectionFacets((HibernateBasicProperty) property)
+            CollectionFacets facets = generator.collectionFacets((HibernateBasicProperty) property, qualifier)
             keys = facets.keys()
             element = facets.element()
         } else {
@@ -814,10 +823,11 @@ class GeneratedDomainClassBinder implements SessionFactoryBuilderFactory {
         }
     }
 
-    private void alignComponent(Component component, GrailsHibernatePersistentEntity embedded, Class<?> type) {
+    private void alignComponent(
+            Component component, GrailsHibernatePersistentEntity embedded, Class<?> type, GrailsHibernatePersistentEntity holder, String path) {
         component.componentClassName = type.name
         for (Property property : component.getProperties()) {
-            alignProperty(property, embedded, type)
+            alignProperty(property, embedded, type, holder, path)
         }
     }
 
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
index 127443eca1..ec7dd95929 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
@@ -209,6 +209,12 @@ class GrailsDomainGenerator {
     private final CreateKeyForProps keyForProps
     private final ForeignKeyColumnCountCalculator foreignKeyColumnCount = new ForeignKeyColumnCountCalculator()
 
+    /** The collections of embedded types that more than one embedded property reaches, as {@code <embedded class>#<collection>}. */
+    private Set<String> sharedEmbeddedCollections = new HashSet<String>()
+
+    /** For each such collection, the qualifier of the embedded properties that do not keep the binder's names, by {@link #embeddedUseKey}. */
+    private Map<String, String> embeddedCollectionQualifiers = new HashMap<String, String>()
+
     GrailsDomainGenerator(
             PersistentEntityNamingStrategy namingStrategy,
             ColumnNameForPropertyAndPathFetcher columnNames,
@@ -282,10 +288,7 @@ class GrailsDomainGenerator {
         for (GrailsHibernatePersistentEntity entity : given) {
             requireReferencedEntities(entity, given)
         }
-        String collision = embeddedCollectionCollision(given)
-        if (collision != null) {
-            throw new UnsupportedOperationException(collision)
-        }
+        assignEmbeddedCollectionQualifiers(given)
         List<GrailsHibernatePersistentEntity> ordered = new ArrayList<GrailsHibernatePersistentEntity>(given)
         ordered.sort { GrailsHibernatePersistentEntity a, GrailsHibernatePersistentEntity b -> depth(a) <=> depth(b) }
 
@@ -317,48 +320,82 @@ class GrailsDomainGenerator {
      * the owner, and gives it no owner-specific identity, so two embedded properties that share a type with a collection share one
      * table whose key is a foreign key to the owner that was bound first (probed: the rows of the other owner violate it unless the
      * identifiers happen to coincide), and Hibernate refuses the duplicate collection at boot when the two properties have the same name
-     * (pinned in {@code GrailsDomainBinderEmbeddedCollectionDefectSpec}). One embedded property per type is described exactly as the
-     * binder binds it; a second one is rejected by name rather than described with a table the generator would have to invent.
+     * (pinned in {@code GrailsDomainBinderEmbeddedCollectionDefectSpec}).
      *
-     * @return why the embedded properties of the entities cannot be described together, or {@code null} when no collection of an
-     *     embedded type is reachable through more than one embedded property
+     * <p>Every embedded property gets a collection table of its own. The first one, in the order of the entity name and then the
+     * property path, keeps the names the binder gives the shared table, so an existing database is undisturbed; the others get names
+     * qualified with the table of their owner and the path of their embedded property ({@link #embeddedCollectionQualifier}).</p>
      */
-    private static String embeddedCollectionCollision(Collection<GrailsHibernatePersistentEntity> entities) {
-        Map<String, List<String>> uses = new LinkedHashMap<String, List<String>>()
+    private void assignEmbeddedCollectionQualifiers(Collection<GrailsHibernatePersistentEntity> entities) {
+        Map<String, List<EmbeddedUse>> uses = new LinkedHashMap<String, List<EmbeddedUse>>()
         for (GrailsHibernatePersistentEntity entity : entities) {
             for (HibernatePersistentProperty property : entity.persistentPropertiesToBind) {
                 if (property instanceof HibernateEmbeddedProperty) {
-                    collectCollectionUses((HibernateEmbeddedProperty) property, "${entity.name}.${property.name}".toString(), uses, [])
+                    collectCollectionUses(entity, (HibernateEmbeddedProperty) property, property.name, uses, [])
                 }
             }
         }
-        Map.Entry<String, List<String>> shared = uses.entrySet().find { Map.Entry<String, List<String>> use -> use.value.size() > 1 }
-        if (shared == null) {
-            return null
+        Set<String> shared = new HashSet<String>()
+        Map<String, String> qualifiers = new HashMap<String, String>()
+        for (Map.Entry<String, List<EmbeddedUse>> entry : uses.entrySet()) {
+            if (entry.value.size() < 2) {
+                continue
+            }
+            shared << entry.key
+            List<EmbeddedUse> ordered = entry.value.sort(false) { EmbeddedUse a, EmbeddedUse b ->
+                a.owner.name <=> b.owner.name ?: a.path <=> b.path
+            }
+            for (EmbeddedUse use : ordered.drop(1)) {
+                qualifiers.put(embeddedUseKey(use.owner, use.path, use.collection), embeddedQualifier(use))
+            }
         }
-        String[] parts = shared.key.split('#')
-        return "The collection [${parts[1]}] of the embedded type [${parts[0]}] is reachable through ${shared.value.size()} embedded " +
-                "properties (${shared.value.join(', ')}): the binder names its table and its key column after the embedded type and not " +
-                'after the owner, so the owners share one table whose key is a foreign key to only one of them, and Hibernate refuses ' +
-                'the duplicate collection at boot when the properties have the same name (pinned in ' +
-                'GrailsDomainBinderEmbeddedCollectionDefectSpec), which the generator does not copy and annotations cannot say'
+        sharedEmbeddedCollections = shared
+        embeddedCollectionQualifiers = qualifiers
+    }
+
+    private String embeddedQualifier(EmbeddedUse use) {
+        List<String> segments = [use.owner.getTableName(namingStrategy).replace('`', '')]
+        segments.addAll(use.path.split(/\./).collect { String name -> namingStrategy.resolveColumnName(name).replace('`', '') })
+        return segments.join('_')
+    }
+
+    private static String embeddedUseKey(GrailsHibernatePersistentEntity owner, String path, String collection) {
+        return "${owner.name}|${path}|${collection}".toString()
+    }
+
+    /**
+     * @return the qualifier that the collection of an embedded type gets in the embedded property {@code path} of {@code owner} (the
+     *     dotted names of the embedded properties from the owner down to the embedded type that holds the collection), or {@code null}
+     *     when it keeps the names the binder gives it: it is reached through that one embedded property only, or that property is the
+     *     first of those that reach it. Known after {@link #generateAll} has run.
+     */
+    String embeddedCollectionQualifier(GrailsHibernatePersistentEntity owner, String path, String collection) {
+        return embeddedCollectionQualifiers.get(embeddedUseKey(owner, path, collection))
     }
 
     private static void collectCollectionUses(
-            HibernateEmbeddedProperty property, String path, Map<String, List<String>> uses, List<Class<?>> visiting) {
+            GrailsHibernatePersistentEntity owner, HibernateEmbeddedProperty property, String path,
+            Map<String, List<EmbeddedUse>> uses, List<Class<?>> visiting) {
         GrailsHibernatePersistentEntity type = (GrailsHibernatePersistentEntity) property.associatedEntity
         if (type == null || visiting.contains(type.javaClass)) {
             return
         }
         for (HibernatePersistentProperty peer : embeddedPeers(property)) {
             if (peer instanceof HibernateEmbeddedProperty) {
-                collectCollectionUses((HibernateEmbeddedProperty) peer, "${path}.${peer.name}".toString(), uses, visiting + [type.javaClass])
+                collectCollectionUses(owner, (HibernateEmbeddedProperty) peer, "${path}.${peer.name}".toString(), uses, visiting + [type.javaClass])
             } else if (peer instanceof HibernateToManyProperty) {
-                uses.computeIfAbsent("${type.javaClass.name}#${peer.name}".toString()) { String key -> new ArrayList<String>() }.add(path)
+                uses.computeIfAbsent(sharedKey(type, peer.name)) { String key -> new ArrayList<EmbeddedUse>() }
+                        .add(new EmbeddedUse(owner, path, peer.name))
             }
         }
     }
 
+    private static String sharedKey(GrailsHibernatePersistentEntity type, String collection) {
+        return "${type.javaClass.name}#${collection}".toString()
+    }
+
+    private static record EmbeddedUse(GrailsHibernatePersistentEntity owner, String path, String collection) { }
+
     private static void requireWholeHierarchy(GrailsHibernatePersistentEntity entity, Set<GrailsHibernatePersistentEntity> given) {
         if (!entity.isRoot() && superEntity(entity, given) == null) {
             throw new IllegalArgumentException(
@@ -1447,6 +1484,14 @@ class GrailsDomainGenerator {
      * @throws UnsupportedOperationException when something about the collection cannot be stated yet
      */
     CollectionFacets collectionFacets(HibernateBasicProperty property) {
+        return collectionFacets(property, null)
+    }
+
+    /**
+     * @param qualifier the qualifier of a collection inside an embedded type that another embedded property reaches too
+     *     ({@link #embeddedCollectionQualifier}), or {@code null}: the collection then has a table and a key column of its own
+     */
+    CollectionFacets collectionFacets(HibernateBasicProperty property, String qualifier) {
         String problem = collectionProblem(property)
         if (problem != null) {
             throw new UnsupportedOperationException(unsupportedReason(property.hibernateOwner, property))
@@ -1456,23 +1501,51 @@ class GrailsDomainGenerator {
         JoinTable joinTable = mapped.joinTable
         // TableForManyCalculator: the join table's own schema, else the owner's table schema; the catalog is never the owner's
         String schema = joinTable?.schema != null ? joinTable.schema : entityFacets(property.hibernateOwner).schema()
+        String table = qualifier == null ? tableForMany.getTableName(property) : qualifiedTableName(property, qualifier)
+        List<ColumnFacets> keys = qualifiedKeys(collectionKeyColumns(property), collectionKeyReferencedColumns(property), qualifier)
+        ColumnFacets element = collectionElementFacets(property, kind)
         return new CollectionFacets(
                 kind,
-                tableForMany.getTableName(property),
+                table,
                 schema,
                 joinTable?.catalog,
-                collectionKeyFacets(property),
-                collectionElementFacets(property, kind),
+                qualifier == null ? collectionKeyFacets(property) : keys[0],
+                element,
                 collectionIndexFacets(property, kind),
                 property.isLazy(),
                 property.getLazy() == Boolean.TRUE,
                 FetchMode.JOIN == mapped.fetchMode ? FetchMode.JOIN : FetchMode.SELECT,
                 Math.max(property.batchSize, 0),
                 property.cacheUsage,
-                collectionKeyColumns(property),
+                keys,
                 collectionKeyReferencedColumns(property),
                 collectionIndexType(property, kind),
-                collectionTableIndexes(property, tableForMany.getTableName(property), collectionKeyColumns(property), collectionElementFacets(property, kind)))
+                collectionTableIndexes(property, table, keys, element))
+    }
+
+    /**
+     * The table of a collection inside an embedded type that has a table of its own: the qualifier (the table of the owner and the path of
+     * the embedded property) and the name the mapping gives the join table, else the name of the collection.
+     */
+    private String qualifiedTableName(HibernateToManyProperty property, String qualifier) {
+        String named = property.hibernateMappedForm.joinTable?.name
+        return "${qualifier}_${named != null ? named : namingStrategy.resolveColumnName(property.name).replace('`', '')}".toString()
+    }
+
+    /** The key columns of a collection that has a table of its own: named after the qualifier, one column for each key of the owner. */
+    private static List<ColumnFacets> qualifiedKeys(List<ColumnFacets> keys, List<String> referenced, String qualifier) {
+        if (qualifier == null) {
+            return keys
+        }
+        List<ColumnFacets> result = []
+        for (int i = 0; i < keys.size(); i++) {
+            ColumnFacets key = keys[i]
+            String name = keys.size() == 1 ? "${qualifier}_id".toString() : "${qualifier}_${referenced[i]}".toString()
+            result << new ColumnFacets(
+                    name, key.nullable(), key.unique(), key.insertable(), key.updatable(), key.length(), key.precision(), key.scale(),
+                    key.sqlType(), key.defaultValue(), key.read(), key.write(), key.comment())
+        }
+        return result
     }
 
     /**
@@ -1757,6 +1830,15 @@ class GrailsDomainGenerator {
      * @throws UnsupportedOperationException when something about the collection cannot be stated yet
      */
     ToManyFacets toManyFacets(HibernateToManyEntityProperty property) {
+        return toManyFacets(property, null)
+    }
+
+    /**
+     * @param qualifier the qualifier of a collection inside an embedded type that another embedded property reaches too
+     *     ({@link #embeddedCollectionQualifier}), or {@code null}: a collection that has a join table then has a table and a key
+     *     column of its own
+     */
+    ToManyFacets toManyFacets(HibernateToManyEntityProperty property, String qualifier) {
         if (toManyProblem(property) != null) {
             throw new UnsupportedOperationException(unsupportedReason(property.hibernateOwner, property))
         }
@@ -1821,6 +1903,11 @@ class GrailsDomainGenerator {
         }
         String condition = property instanceof HibernateOneToManyProperty && target.isMultiTenant() ?
                 target.getMultiTenantFilterCondition(defaultColumnNames) : null
+        if (qualifier != null && table != null && mappedBy == null) {
+            table = qualifiedTableName(property, qualifier)
+            keys = qualifiedKeys(keys != null ? keys : [key], keys != null ? collectionKeyReferencedColumns(property) : [], qualifier)
+            key = keys[0]
+        }
         return new ToManyFacets(
                 kind,
                 target.name,
@@ -2686,7 +2773,9 @@ class GrailsDomainGenerator {
                 if (facets.catalog()) {
                     joinTable = joinTable.define('catalog', facets.catalog())
                 }
-                annotations << joinTable.build()
+                if (!sharedEmbeddedCollection(property)) {
+                    annotations << joinTable.build()
+                }
             }
             annotations << manyToMany.build()
             if (facets.cascade().orphanRemoval()) {
@@ -2824,7 +2913,9 @@ class GrailsDomainGenerator {
         if (facets.catalog()) {
             table = table.define('catalog', facets.catalog())
         }
-        annotations << table.build()
+        if (!sharedEmbeddedCollection(property)) {
+            annotations << table.build()
+        }
         annotations << columnAnnotation(facets.element(), property.componentType)
         if (facets.kind() == CollectionKind.LIST) {
             annotations << AnnotationDescription.Builder.ofType(OrderColumn)
@@ -2939,6 +3030,81 @@ class GrailsDomainGenerator {
         return annotation.build()
     }
 
+    /**
+     * Whether the collection is inside an embedded type that more than one embedded property reaches. Its table and key are then stated by
+     * each owner with an {@code @AssociationOverride} ({@code @CollectionTable} on the field of the shared embeddable would win over it).
+     */
+    private boolean sharedEmbeddedCollection(HibernatePersistentProperty property) {
+        return sharedEmbeddedCollections.contains(sharedKey(property.hibernateOwner, property.name))
+    }
+
+    /**
+     * The collections of the embedded type of an embedded property, and of the embedded types nested in it, that more than one embedded
+     * property reaches, each with its path relative to the embedded property and the dotted path of the embedded property that holds it.
+     */
+    private void sharedCollectionPeers(
+            HibernateEmbeddedProperty property, String relative, String binderPath, List<Class<?>> visiting, List<SharedCollection> into) {
+        GrailsHibernatePersistentEntity type = (GrailsHibernatePersistentEntity) property.associatedEntity
+        if (type == null || visiting.contains(type.javaClass)) {
+            return
+        }
+        for (HibernatePersistentProperty peer : embeddedPeers(property)) {
+            if (peer instanceof HibernateEmbeddedProperty) {
+                sharedCollectionPeers((HibernateEmbeddedProperty) peer, relative.isEmpty() ? peer.name : "${relative}.${peer.name}".toString(),
+                        "${binderPath}.${peer.name}".toString(), visiting + [type.javaClass], into)
+            } else if (peer instanceof HibernateToManyProperty && sharedEmbeddedCollection(peer)) {
+                into << new SharedCollection(relative.isEmpty() ? peer.name : "${relative}.${peer.name}".toString(), binderPath, peer)
+            }
+        }
+    }
+
+    private static record SharedCollection(String relativePath, String binderPath, HibernatePersistentProperty property) { }
+
+    /**
+     * The {@code @AssociationOverride}s that state the table and the key of each shared collection of an embedded property for its
+     * owner: the table and the key column of the binder for the first embedded property that reaches the collection, names qualified with
+     * the owner and the path for the others.
+     */
+    private List<AnnotationDescription> sharedCollectionOverrides(HibernateEmbeddedProperty property) {
+        List<SharedCollection> shared = []
+        sharedCollectionPeers(property, '', property.name, [], shared)
+        return shared.collect { SharedCollection collection ->
+            String qualifier = embeddedCollectionQualifier(property.hibernateOwner, collection.binderPath(), collection.property().name)
+            AnnotationDescription.Builder joinTable
+            if (collection.property() instanceof HibernateToManyEntityProperty) {
+                ToManyFacets facets = toManyFacets((HibernateToManyEntityProperty) collection.property(), qualifier)
+                if (facets.mappedBy() != null || !facets.manyToMany()) {
+                    return null
+                }
+                joinTable = AnnotationDescription.Builder.ofType(JpaJoinTable)
+                        .define('name', facets.tableName())
+                        .defineAnnotationArray('joinColumns', TypeDescription.ForLoadedType.of(JoinColumn), keyJoinColumns(facets.keys(), facets.referencedKeys()))
+                        .defineAnnotationArray('inverseJoinColumns', TypeDescription.ForLoadedType.of(JoinColumn), joinColumnAnnotation(facets.element()))
+                if (facets.schema()) {
+                    joinTable = joinTable.define('schema', facets.schema())
+                }
+                if (facets.catalog()) {
+                    joinTable = joinTable.define('catalog', facets.catalog())
+                }
+            } else {
+                CollectionFacets facets = collectionFacets((HibernateBasicProperty) collection.property(), qualifier)
+                joinTable = AnnotationDescription.Builder.ofType(JpaJoinTable)
+                        .define('name', facets.tableName())
+                        .defineAnnotationArray('joinColumns', TypeDescription.ForLoadedType.of(JoinColumn), keyJoinColumns(facets.keys(), facets.referencedKeys()))
+                if (facets.schema()) {
+                    joinTable = joinTable.define('schema', facets.schema())
+                }
+                if (facets.catalog()) {
+                    joinTable = joinTable.define('catalog', facets.catalog())
+                }
+            }
+            return AnnotationDescription.Builder.ofType(AssociationOverride)
+                    .define('name', collection.relativePath())
+                    .define('joinTable', joinTable.build())
+                    .build()
+        }.findAll { AnnotationDescription override -> override != null }
+    }
+
     /**
      * An embedded property is an {@code @Embedded} field of the generated embeddable type. The owner states every
      * column of the embedded type with {@code @AttributeOverride}, because the names and the nullability depend on the
@@ -2963,6 +3129,7 @@ class GrailsDomainGenerator {
                                                 [joinColumnAnnotation(leaf.column())] as AnnotationDescription[])
                                 .build()
                     }
+            associationOverrides.addAll(sharedCollectionOverrides(property))
             if (!associationOverrides.isEmpty()) {
                 annotations << AnnotationDescription.Builder.ofType(AssociationOverrides)
                         .defineAnnotationArray('value', TypeDescription.ForLoadedType.of(AssociationOverride),
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesEmbeddedCollectionSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesEmbeddedCollectionSpec.groovy
index f28322dd1a..15c8fabdce 100644
--- a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesEmbeddedCollectionSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesEmbeddedCollectionSpec.groovy
@@ -21,10 +21,6 @@ package grails.gorm.tests.generated
 import java.util.concurrent.atomic.AtomicInteger
 
 import grails.gorm.annotation.Entity
-import grails.unbootable.EmbeddedCollectionOwnerA
-import grails.unbootable.EmbeddedCollectionOwnerB
-import grails.unbootable.EmbeddedCollectionOwnerC
-import grails.unbootable.EmbeddedCollectionOwnerD
 import org.hibernate.mapping.Table
 import spock.lang.AutoCleanup
 import spock.lang.Specification
@@ -36,8 +32,8 @@ import org.grails.orm.hibernate.HibernateDatastore
  * A collection inside an embedded type, bound by the domain binder and by the generated classes. The domain binder names the table
  * of such a collection after the embedded type and not after the owner ({@code ecs_words_words}), and its key column after the
  * embedded type too ({@code ecs_words_id}), whatever the embedded property is called; the key points at the table of the owner. The
- * generated classes state the same names. Two embedded properties that share a type with a collection would share that one table,
- * which the binder cannot make sense of, so the generated mode rejects them by name.
+ * generated classes state the same names. Two embedded properties that share a type with a collection get a table each in the generated
+ * mode, see {@link GeneratedDomainClassesSharedEmbeddedCollectionSpec}.
  */
 class GeneratedDomainClassesEmbeddedCollectionSpec extends Specification {
 
@@ -255,58 +251,6 @@ class GeneratedDomainClassesEmbeddedCollectionSpec extends Specification {
         results[1] == results[0]
         results[1] == [2, ['blue', 'green', 'red'], 2]
     }
-
-    void "a type with a collection that two embedded properties share is rejected by the generated mode and says why"() {
-        when: "the domain binder cannot boot owners that embed the type under the same name"
-        boot([EmbeddedCollectionOwnerA, EmbeddedCollectionOwnerB], false)
-
-        then:
-        thrown(Exception)
-
-        when:
-        boot([EmbeddedCollectionOwnerA, EmbeddedCollectionOwnerB], true)
-
-        then:
-        Exception e = thrown()
-        rootOf(e) instanceof UnsupportedOperationException
-        rootOf(e).message.contains('is reachable through 2 embedded properties')
-        rootOf(e).message.contains('EmbeddedCollectionOwnerA.inner')
-        rootOf(e).message.contains('EmbeddedCollectionOwnerB.inner')
-    }
-
-    void "under different property names the domain binder boots but keeps one table with a key to the first owner, which the generated mode does not copy"() {
-        when:
-        Map<String, Map> binder = schema([EmbeddedCollectionOwnerC, EmbeddedCollectionOwnerD], false)
-
-        then: "one collection table, a foreign key to one of the owners"
-        binder.keySet().count { it.contains('words') } == 1
-        binder.values().find { it.columns.containsKey('embedded_collection_holder_id') }.foreignKeys.size() == 1
-
-        when: "the second owner stores a collection"
-        EmbeddedCollectionOwnerD.withTransaction {
-            new EmbeddedCollectionOwnerD(second: new grails.unbootable.EmbeddedCollectionHolder(words: ['w'] as Set)).save(failOnError: true, flush: true)
-        }
-
-        then: "its row violates the foreign key to the other owner"
-        thrown(Exception)
-
-        when:
-        boot([EmbeddedCollectionOwnerC, EmbeddedCollectionOwnerD], true)
-
-        then:
-        Exception e = thrown()
-        rootOf(e) instanceof UnsupportedOperationException
-        rootOf(e).message.contains('EmbeddedCollectionOwnerC.first')
-        rootOf(e).message.contains('EmbeddedCollectionOwnerD.second')
-    }
-
-    private static Throwable rootOf(Throwable e) {
-        Throwable root = e
-        while (root.cause != null && root.cause != root) {
-            root = root.cause
-        }
-        return root
-    }
 }
 
 enum EcsColor { RED, GREEN }
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesSharedEmbeddedCollectionSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesSharedEmbeddedCollectionSpec.groovy
new file mode 100644
index 0000000000..40fb4941d2
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesSharedEmbeddedCollectionSpec.groovy
@@ -0,0 +1,289 @@
+/*
+ * 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
+ *
+ *   http://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.tests.generated
+
+import java.util.concurrent.atomic.AtomicInteger
+
+import grails.unbootable.EmbeddedCollectionHolder
+import grails.unbootable.EmbeddedCollectionItem
+import grails.unbootable.EmbeddedCollectionOwnerA
+import grails.unbootable.EmbeddedCollectionOwnerB
+import grails.unbootable.EmbeddedCollectionOwnerC
+import grails.unbootable.EmbeddedCollectionOwnerD
+import grails.unbootable.EmbeddedCollectionOwnerTwice
+import grails.unbootable.EmbeddedItemsHolder
+import grails.unbootable.EmbeddedItemsOwnerA
+import grails.unbootable.EmbeddedItemsOwnerB
+import grails.unbootable.EmbeddedMiddle
+import grails.unbootable.EmbeddedNestedOwnerA
+import grails.unbootable.EmbeddedNestedOwnerB
+import org.hibernate.mapping.Table
+import spock.lang.AutoCleanup
+import spock.lang.Specification
+
+import org.grails.orm.hibernate.HibernateDatastore
+
+/**
+ * A collection inside an embedded type that more than one embedded property reaches. The domain binder names its table and its key
+ * column after the embedded type, so all the owners share one table whose key is a foreign key to the owner that was bound first (the
+ * rows of any other owner violate it), and it fails to boot when the properties are named alike. The generated mode gives every owner a
+ * table of its own: the first owner, in the order of the entity name and then the property path, keeps the names of the binder, the
+ * others get names qualified with their owner and the path of their embedded property.
+ */
+class GeneratedDomainClassesSharedEmbeddedCollectionSpec extends Specification {
+
+    static final AtomicInteger BOOTS = new AtomicInteger()
+
+    @AutoCleanup
+    HibernateDatastore datastore
+
+    private HibernateDatastore boot(List<Class> group, boolean generated) {
+        datastore?.close()
+        datastore = new HibernateDatastore([
+                'dataSource.url'                  : "jdbc:h2:mem:gse${BOOTS.incrementAndGet()};LOCK_TIMEOUT=10000".toString(),
+                'dataSource.dbCreate'             : 'create-drop',
+                'hibernate.generatedDomainClasses': generated,
+        ], group as Class[])
+        return datastore
+    }
+
+    /** The tables of the boot model with their columns and the table each foreign key points at. */
+    private Map<String, Map> schema(List<Class> group, boolean generated) {
+        HibernateDatastore booted = boot(group, generated)
+        Map<String, Map> result = new TreeMap<String, Map>()
+        for (Table table : booted.metadata.collectTableMappings()) {
+            if (table.physicalTable) {
+                result[table.name] = [
+                        columns    : table.columns*.name.sort(),
+                        foreignKeys: table.foreignKeys.values().collect { "${it.columns*.name} -> ${it.referencedTable.name}".toString() }.sort(),
+                ]
+            }
+        }
+        return result
+    }
+
+    private int countRows(String table) {
+        datastore.connectionSources.defaultConnectionSource.dataSource.connection.withCloseable { java.sql.Connection connection ->
+            connection.createStatement().executeQuery("select count(*) from ${table}".toString()).with { java.sql.ResultSet rows -> rows.next(); rows.getInt(1) }
+        }
+    }
+
+    void "the domain binder binds one table whose key points at the first owner only, so the rows of the other owner are refused"() {
+        when:
+        Map<String, Map> binder = schema([EmbeddedCollectionOwnerC, EmbeddedCollectionOwnerD], false)
+
+        then: "one collection table, a foreign key to one of the owners"
+        binder.keySet().count { it.contains('words') } == 1
+        binder.values().find { it.columns.contains('embedded_collection_holder_id') }.foreignKeys.size() == 1
+
+        when: "the second owner stores a collection"
+        EmbeddedCollectionOwnerD.withTransaction {
+            new EmbeddedCollectionOwnerD(second: new EmbeddedCollectionHolder(words: ['w'] as Set)).save(failOnError: true, flush: true)
+        }
+
+        then: "its row violates the foreign key to the other owner"
+        thrown(Exception)
+    }
+
+    void "the domain binder cannot boot owners that embed the type under the same name"() {
+        when:
+        boot([EmbeddedCollectionOwnerA, EmbeddedCollectionOwnerB], false)
+
+        then:
+        Exception e = thrown()
+        rootOf(e).getClass().simpleName == 'DuplicateMappingException'
+    }
+
+    void "the first owner keeps the table and the key column of the binder, the other one gets names qualified with its owner and its property"() {
+        when:
+        Map<String, Map> generated = schema([EmbeddedCollectionOwnerC, EmbeddedCollectionOwnerD], true)
+
+        then:
+        generated.keySet() == ['embedded_collection_holder_words', 'embedded_collection_ownerc', 'embedded_collection_ownerd',
+                               'embedded_collection_ownerd_second_words'] as Set
+        generated['embedded_collection_holder_words'].columns == ['embedded_collection_holder_id', 'words_java_lang_string']
+        generated['embedded_collection_holder_words'].foreignKeys == ['[embedded_collection_holder_id] -> embedded_collection_ownerc']
+        generated['embedded_collection_ownerd_second_words'].columns == ['embedded_collection_ownerd_second_id', 'words_java_lang_string']
+        generated['embedded_collection_ownerd_second_words'].foreignKeys == ['[embedded_collection_ownerd_second_id] -> embedded_collection_ownerd']
+    }
+
+    void "the owners are told apart by the name of the entity whatever the order they are given in"() {
+        expect:
+        schema([EmbeddedCollectionOwnerD, EmbeddedCollectionOwnerC], true) == schema([EmbeddedCollectionOwnerC, EmbeddedCollectionOwnerD], true)
+    }
+
+    void "a type that only one owner embeds keeps exactly the schema of the domain binder"() {
+        expect:
+        schema([EmbeddedCollectionOwnerC], true) == schema([EmbeddedCollectionOwnerC], false)
+    }
+
+    void "each owner stores, reads back and deletes its own collection"() {
+        given:
+        boot([EmbeddedCollectionOwnerC, EmbeddedCollectionOwnerD], true)
+
+        when:
+        Long c = EmbeddedCollectionOwnerC.withTransaction {
+            new EmbeddedCollectionOwnerC(first: new EmbeddedCollectionHolder(words: ['a', 'b'] as Set)).save(failOnError: true, flush: true).id
+        }
+        Long d = EmbeddedCollectionOwnerD.withTransaction {
+            new EmbeddedCollectionOwnerD(second: new EmbeddedCollectionHolder(words: ['x'] as Set)).save(failOnError: true, flush: true).id
+        }
+
+        then:
+        EmbeddedCollectionOwnerC.withNewSession { EmbeddedCollectionOwnerC.get(c).first.words.sort() } == ['a', 'b']
+        EmbeddedCollectionOwnerD.withNewSession { EmbeddedCollectionOwnerD.get(d).second.words.sort() } == ['x']
+        countRows('embedded_collection_holder_words') == 2
+        countRows('embedded_collection_ownerd_second_words') == 1
+
+        when:
+        EmbeddedCollectionOwnerD.withTransaction {
+            EmbeddedCollectionOwnerD owner = EmbeddedCollectionOwnerD.get(d)
+            owner.second.words = ['y', 'z'] as Set
+            owner.save(failOnError: true, flush: true)
+        }
+
+        then:
+        EmbeddedCollectionOwnerD.withNewSession { EmbeddedCollectionOwnerD.get(d).second.words.sort() } == ['y', 'z']
+        EmbeddedCollectionOwnerC.withNewSession { EmbeddedCollectionOwnerC.get(c).first.words.sort() } == ['a', 'b']
+
+        when:
+        EmbeddedCollectionOwnerD.withTransaction { EmbeddedCollectionOwnerD.get(d).delete(flush: true) }
+
+        then:
+        countRows('embedded_collection_ownerd_second_words') == 0
+        countRows('embedded_collection_holder_words') == 2
+    }
+
+    void "owners that embed the type under the same name boot with qualified names for all but the first, and store their own collections"() {
+        when:
+        Map<String, Map> generated = schema([EmbeddedCollectionOwnerA, EmbeddedCollectionOwnerB], true)
+
+        then:
+        generated.keySet() == ['embedded_collection_holder_words', 'embedded_collection_ownera', 'embedded_collection_ownerb',
+                               'embedded_collection_ownerb_inner_words'] as Set
+        generated['embedded_collection_holder_words'].foreignKeys == ['[embedded_collection_holder_id] -> embedded_collection_ownera']
+        generated['embedded_collection_ownerb_inner_words'].foreignKeys == ['[embedded_collection_ownerb_inner_id] -> embedded_collection_ownerb']
+
+        when:
+        Long a = EmbeddedCollectionOwnerA.withTransaction {
+            new EmbeddedCollectionOwnerA(inner: new EmbeddedCollectionHolder(words: ['a1'] as Set)).save(failOnError: true, flush: true).id
+        }
+        Long b = EmbeddedCollectionOwnerB.withTransaction {
+            new EmbeddedCollectionOwnerB(inner: new EmbeddedCollectionHolder(words: ['b1', 'b2'] as Set)).save(failOnError: true, flush: true).id
+        }
+
+        then:
+        EmbeddedCollectionOwnerA.withNewSession { EmbeddedCollectionOwnerA.get(a).inner.words.sort() } == ['a1']
+        EmbeddedCollectionOwnerB.withNewSession { EmbeddedCollectionOwnerB.get(b).inner.words.sort() } == ['b1', 'b2']
+    }
+
+    void "one owner that embeds the type twice has a table for each property, where the domain binder mixes the elements of both"() {
+        when: "the domain binder stores the elements of both properties in one table"
+        boot([EmbeddedCollectionOwnerTwice], false)
+        Long classic = EmbeddedCollectionOwnerTwice.withTransaction {
+            new EmbeddedCollectionOwnerTwice(
+                    home: new EmbeddedCollectionHolder(words: ['h'] as Set),
+                    work: new EmbeddedCollectionHolder(words: ['w'] as Set)).save(failOnError: true, flush: true).id
+        }
+        List classicRead = EmbeddedCollectionOwnerTwice.withNewSession {
+            EmbeddedCollectionOwnerTwice owner = EmbeddedCollectionOwnerTwice.get(classic)
+            [owner.home.words.sort(), owner.work.words.sort()]
+        }
+
+        then: "each property sees the elements of the other"
+        classicRead == [['h', 'w'], ['h', 'w']]
+
+        when:
+        Map<String, Map> generated = schema([EmbeddedCollectionOwnerTwice], true)
+        Long id = EmbeddedCollectionOwnerTwice.withTransaction {
+            new EmbeddedCollectionOwnerTwice(
+                    home: new EmbeddedCollectionHolder(words: ['h'] as Set),
+                    work: new EmbeddedCollectionHolder(words: ['w'] as Set)).save(failOnError: true, flush: true).id
+        }
+
+        then:
+        generated.keySet() == ['embedded_collection_holder_words', 'embedded_collection_owner_twice', 'embedded_collection_owner_twice_work_words'] as Set
+        EmbeddedCollectionOwnerTwice.withNewSession {
+            EmbeddedCollectionOwnerTwice owner = EmbeddedCollectionOwnerTwice.get(id)
+            [owner.home.words.sort(), owner.work.words.sort()]
+        } == [['h'], ['w']]
+    }
+
+    void "a collection of entities and a list inside an embedded type get a table of their own for each owner"() {
+        when:
+        Map<String, Map> generated = schema([EmbeddedItemsOwnerA, EmbeddedItemsOwnerB, EmbeddedCollectionItem], true)
+
+        then:
+        generated.keySet() == [
+                'embedded_collection_item', 'embedded_items_holder_embedded_collection_item', 'embedded_items_holder_order',
+                'embedded_items_ownera', 'embedded_items_ownerb', 'embedded_items_ownerb_theirs_items',
+                'embedded_items_ownerb_theirs_order'] as Set
+        generated['embedded_items_ownerb_theirs_items'].foreignKeys.size() == 2
+        generated['embedded_items_ownerb_theirs_items'].foreignKeys.contains('[embedded_items_ownerb_theirs_id] -> embedded_items_ownerb')
+
+        when:
+        Long a = EmbeddedItemsOwnerA.withTransaction {
+            EmbeddedCollectionItem one = new EmbeddedCollectionItem(name: 'one').save(failOnError: true)
+            new EmbeddedItemsOwnerA(mine: new EmbeddedItemsHolder(items: [one] as Set, order: ['x', 'y'])).save(failOnError: true, flush: true).id
+        }
+        Long b = EmbeddedItemsOwnerB.withTransaction {
+            EmbeddedCollectionItem two = new EmbeddedCollectionItem(name: 'two').save(failOnError: true)
+            new EmbeddedItemsOwnerB(theirs: new EmbeddedItemsHolder(items: [two] as Set, order: ['z'])).save(failOnError: true, flush: true).id
+        }
+
+        then:
+        EmbeddedItemsOwnerA.withNewSession { EmbeddedItemsHolder h = EmbeddedItemsOwnerA.get(a).mine; [h.items*.name, new ArrayList(h.order)] } == [['one'], ['x', 'y']]
+        EmbeddedItemsOwnerB.withNewSession { EmbeddedItemsHolder h = EmbeddedItemsOwnerB.get(b).theirs; [h.items*.name, new ArrayList(h.order)] } == [['two'], ['z']]
+    }
+
+    void "a collection in a nested embedded type and one in a direct embedded property each get a table of their own"() {
+        when:
+        Map<String, Map> generated = schema([EmbeddedNestedOwnerA, EmbeddedNestedOwnerB], true)
+
+        then:
+        generated.keySet() == [
+                'embedded_collection_holder_words', 'embedded_nested_ownera', 'embedded_nested_ownerb',
+                'embedded_nested_ownerb_direct_words', 'embedded_nested_ownerb_mid_deep_words'] as Set
+
+        when:
+        Long a = EmbeddedNestedOwnerA.withTransaction {
+            new EmbeddedNestedOwnerA(mid: new EmbeddedMiddle(label: 'a', deep: new EmbeddedCollectionHolder(words: ['a'] as Set))).save(failOnError: true, flush: true).id
+        }
+        Long b = EmbeddedNestedOwnerB.withTransaction {
+            new EmbeddedNestedOwnerB(
+                    mid: new EmbeddedMiddle(label: 'b', deep: new EmbeddedCollectionHolder(words: ['b-deep'] as Set)),
+                    direct: new EmbeddedCollectionHolder(words: ['b-direct'] as Set)).save(failOnError: true, flush: true).id
+        }
+
+        then:
+        EmbeddedNestedOwnerA.withNewSession { EmbeddedNestedOwnerA.get(a).mid.deep.words.sort() } == ['a']
+        EmbeddedNestedOwnerB.withNewSession {
+            EmbeddedNestedOwnerB owner = EmbeddedNestedOwnerB.get(b)
+            [owner.mid.deep.words.sort(), owner.direct.words.sort()]
+        } == [['b-deep'], ['b-direct']]
+    }
+
+    private static Throwable rootOf(Throwable e) {
+        Throwable root = e
+        while (root.cause != null && root.cause != root) {
+            root = root.cause
+        }
+        return root
+    }
+}
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/unbootable/EmbeddedCollectionDomains.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/unbootable/EmbeddedCollectionDomains.groovy
index 5ef6dc4cb7..d4b08a5e0b 100644
--- a/grails-data-hibernate7/core/src/test/groovy/grails/unbootable/EmbeddedCollectionDomains.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/unbootable/EmbeddedCollectionDomains.groovy
@@ -22,7 +22,8 @@ import grails.gorm.annotation.Entity
 
 /**
  * Domain classes that embed a type with a collection, kept outside the packages the differential spec scans: the binder names the
- * table and the key column of the collection after the embedded type, so two owners of the type share one collection table.
+ * table and the key column of the collection after the embedded type, so two owners of the type share one collection table (the
+ * generated mode gives every owner a table of its own).
  */
 @Entity
 class EmbeddedCollectionOwnerA {
@@ -78,3 +79,62 @@ class EmbeddedCollectionOwnerTwice {
 
     static embedded = ['home', 'work']
 }
+
+/**
+ * Two owners that embed a type with a collection of entities and a list, under different names, and one owner that embeds the type
+ * twice, with a type nested in another.
+ */
+@Entity
+class EmbeddedCollectionItem {
+
+    String name
+}
+
+class EmbeddedItemsHolder {
+
+    Set<EmbeddedCollectionItem> items
+    List<String> order
+
+    static hasMany = [items: EmbeddedCollectionItem, order: String]
+}
+
+@Entity
+class EmbeddedItemsOwnerA {
+
+    EmbeddedItemsHolder mine
+
+    static embedded = ['mine']
+}
+
+@Entity
+class EmbeddedItemsOwnerB {
+
+    EmbeddedItemsHolder theirs
+
+    static embedded = ['theirs']
+}
+
+class EmbeddedMiddle {
+
+    String label
+    EmbeddedCollectionHolder deep
+
+    static embedded = ['deep']
+}
+
+@Entity
+class EmbeddedNestedOwnerA {
+
+    EmbeddedMiddle mid
+
+    static embedded = ['mid']
+}
+
+@Entity
+class EmbeddedNestedOwnerB {
+
+    EmbeddedMiddle mid
+    EmbeddedCollectionHolder direct
+
+    static embedded = ['mid', 'direct']
+}
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
index faa5b295c8..08968c05fc 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorSpec.groovy
@@ -1407,27 +1407,39 @@ class GrailsDomainGeneratorSpec extends HibernateGormDatastoreSpec {
         items.getAnnotation(JoinTable).joinColumns()*.name() == ['gen_coll_embedded_entities_items_id']
     }
 
-    void "an embedded type that has a collection is rejected when a second embedded property shares it"() {
+    void "an embedded type that has a collection and is shared by two embedded properties has the table of each owner stated by the owner"() {
         when:
-        newGenerator().generateAll([unbound(EmbeddedCollectionOwnerA), unbound(EmbeddedCollectionOwnerB)], getClass().classLoader)
+        Map<GrailsHibernatePersistentEntity, Class<?>> classes = newGenerator().generateAll(
+                [unbound(EmbeddedCollectionOwnerB), unbound(EmbeddedCollectionOwnerA)], getClass().classLoader)
+        Class<?> ownerA = classes.find { it.key.name == EmbeddedCollectionOwnerA.name }.value
+        Class<?> ownerB = classes.find { it.key.name == EmbeddedCollectionOwnerB.name }.value
+        AssociationOverride first = ownerA.getDeclaredField('inner').getAnnotation(AssociationOverrides).value().find { it.name() == 'words' }
+        AssociationOverride second = ownerB.getDeclaredField('inner').getAnnotation(AssociationOverrides).value().find { it.name() == 'words' }
 
-        then:
-        UnsupportedOperationException e = thrown()
-        e.message.contains('The collection [words] of the embedded type [' + EmbeddedCollectionHolder.name + ']')
-        e.message.contains('is reachable through 2 embedded properties')
-        e.message.contains(EmbeddedCollectionOwnerA.name + '.inner')
-        e.message.contains(EmbeddedCollectionOwnerB.name + '.inner')
+        then: "the shared embeddable names no table, which a field annotation would fix for every owner"
+        !ownerA.getDeclaredField('inner').type.getDeclaredField('words').isAnnotationPresent(CollectionTable)
+        ownerA.getDeclaredField('inner').type == ownerB.getDeclaredField('inner').type
+
+        and: "the first owner by entity name keeps the table and the key column of the binder, the other one is qualified"
+        first.joinTable().name() == 'embedded_collection_holder_words'
+        first.joinTable().joinColumns()*.name() == ['embedded_collection_holder_id']
+        second.joinTable().name() == 'embedded_collection_ownerb_inner_words'
+        second.joinTable().joinColumns()*.name() == ['embedded_collection_ownerb_inner_id']
     }
 
-    void "an owner that embeds the same type with a collection twice is rejected"() {
+    void "an owner that embeds the same type with a collection twice has a table for each property"() {
         when:
-        newGenerator().generateAll([unbound(EmbeddedCollectionOwnerTwice)], getClass().classLoader)
+        Class<?> owner = newGenerator().generateAll([unbound(EmbeddedCollectionOwnerTwice)], getClass().classLoader).values().first()
+        Map<String, AssociationOverride> overrides = owner.getDeclaredField(field).getAnnotation(AssociationOverrides).value().collectEntries { [(it.name()): it] }
 
         then:
-        UnsupportedOperationException e = thrown()
-        e.message.contains('is reachable through 2 embedded properties')
-        e.message.contains(EmbeddedCollectionOwnerTwice.name + '.home')
-        e.message.contains(EmbeddedCollectionOwnerTwice.name + '.work')
+        overrides['words'].joinTable().name() == table
+        overrides['words'].joinTable().joinColumns()*.name() == [key]
+
+        where:
+        field  | table                                   | key
+        'home' | 'embedded_collection_holder_words'      | 'embedded_collection_holder_id'
+        'work' | 'embedded_collection_owner_twice_work_words' | 'embedded_collection_owner_twice_work_id'
     }
 
     void "owners that embed different types with collections are generated together"() {
diff --git a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
index 02fbc86614..32ca4c4d1e 100644
--- a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
+++ b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
@@ -60,7 +60,7 @@ For the domain classes in GORM's own test suites, both bindings produce the same
 * Properties mapped with a `UserType` class, and with the registered converter types `yes_no`, `true_false` and `numeric_boolean`, are stored as before.
 * A `hasMany` of basic values whose `type` is `'serializable'` or a `UserType` class is stored in one column of the owner's table, as before, with the `index`, `unique` group and column settings of the property. A `type` on a `hasMany` of domain classes changes nothing in both bindings.
 * An `index` on the property of a `hasMany` is created over the key column of the collection table (and over the element column of a collection of enums), and `indexColumn` may give the index of a list or the key of a map a `type` other than integer or string.
-* A `hasMany` inside a class listed in `static embedded` (basic values, enums or domain classes, also in a nested embedded class) is stored in a collection table named after the embedded class, not after the owner, with a key column named after the embedded class too, as with classic binding.
+* A `hasMany` inside a class listed in `static embedded` (basic values, enums or domain classes, also in a nested embedded class) is stored in a collection table named after the embedded class, not after the owner, with a key column named after the embedded class too, as with classic binding. When more than one embedded property embeds the class, every owner gets a table of its own, see <<nativeDomainBinding-behavior,Differences in Behavior>>.
 * A `Map` on a many-to-many (`Map<String, Book> books` with a `Set` or another `Map` on the other side) writes its own join table, with the key column of the map as part of the primary key, as with classic binding. A `Set` on the other side reads that table and does not write it, and two maps are two separate tables: rows stored through one map are not visible through the other, in both bindings.
 * `type: 'serializable'` on a property that is not a collection (a `String`, a `byte[]` or any `Serializable`) is a binary column holding the Java serialization of the value, as with classic binding.
 * `params` on a registered type name such as `type: 'string'` are ignored, as with classic binding. They reach the type when it is a `UserType` that implements `ParameterizedType`.
@@ -87,10 +87,6 @@ Nothing is ignored silently. A mapping that boots is bound the way it is written
 | Message of the root cause
 | What to do
 
-| A collection inside an embedded class that two embedded properties share: a `hasMany` in a class listed in `static embedded`, where more than one property of the application (of one domain class or of several) embeds that class.
-| `The collection [...] of the embedded type [...] is reachable through N embedded properties (...) ...`
-| Embed the class from one property only, or make it a domain class and map an association. Classic binding names the table and the key column of the collection after the embedded class, so all the owners share one table whose foreign key points at only one of them, and it fails to boot when the owners name the property alike.
-
 | A natural id that names a collection or a formula.
 | `The natural id of [...] names [...], which is ... only a simple property, an enum, a foreign key or an embedded type can be part of a natural id ...`
 | Name only simple properties, enums, many-to-one associations and embedded objects in `natural`. Classic binding does not boot either: it fails with a `ClassCastException` for a collection and with `constraint involves a formula` for a formula.
@@ -135,6 +131,9 @@ Some bugs of classic binding are not copied by native binding. Native binding do
 | A `unique` group on the property of a collection (`hasMany`).
 | The group names columns of the owner's table, which the table of the collection does not have. Classic binding tries to create a unique key over them and the statement fails when the schema is created, so the database has no such key. Native binding creates no key. The `index` option on the same property works in both bindings: the index is created over the key column of the collection table (and over the element column of a collection of enums), with the name the mapping gives it.
 
+| A `hasMany` inside a class listed in `static embedded` (basic values, enums or domain classes) when more than one embedded property of the application embeds that class: two domain classes, or one domain class with two properties of that type.
+| Classic binding names the table and the key column after the embedded class, so all the owners share one table. Its foreign key points at the owner that was bound first, so the collection of any other owner cannot be stored (the insert violates the key), the elements of two properties of one owner are mixed up, and the application fails to start when the properties have the same name. Native binding gives every owner a collection table of its own. The first owner, in the order of the e [...]
+
 | An `index` given as a closure on the property of a collection, for example `index: { column name: 'position' }`.
 | The closure configures the index column of a list or a map in both bindings. Classic binding also creates an index of the collection table that is named after the closure, a name that changes on every start. Native binding creates the index column and no such index.
 |===
@@ -190,6 +189,10 @@ The schema of 734 groups of related domain classes was created with both binding
 | See <<nativeDomainBinding-behavior,Differences in Behavior>>.
 | The key never existed in a database created by classic binding: the statement that creates it fails.
 
+| An extra collection table, with a key column named after the owner and the embedded property, for every owner after the first of an embedded class that has a `hasMany`.
+| See <<nativeDomainBinding-behavior,Differences in Behavior>>. Classic binding shares one table between the owners.
+| The first owner keeps its table and column names. The tables of the other owners are new: `update` creates them, and the elements that classic binding could not store for those owners were never in the database.
+
 | No index named after a closure on the table of a collection whose `index` is a closure.
 | See <<nativeDomainBinding-behavior,Differences in Behavior>>.
 | A database created by classic binding has an index with a name that depends on the start it was created in. Drop it if you want to be rid of it.
diff --git a/grails-doc/src/en/guide/upgrading/upgrading90x.adoc b/grails-doc/src/en/guide/upgrading/upgrading90x.adoc
index 9f3d83ff6d..6bf7c2ca78 100644
--- a/grails-doc/src/en/guide/upgrading/upgrading90x.adoc
+++ b/grails-doc/src/en/guide/upgrading/upgrading90x.adoc
@@ -103,6 +103,9 @@ The mapping can, though:
   The manual chapter lists them.
 * The generated schema differs from the one of classic binding in a few documented cases, for example a `check (idx >= 0)` constraint on the index column of every list, and the column types of foreign keys to nested composite identifiers.
 * A few defects of classic binding are not copied, such as a `unique` group that includes an enum property being dropped.
+* A `hasMany` inside a class listed in `static embedded` that more than one embedded property embeds gets a collection table of its own for every owner.
+  Classic binding shares one table whose foreign key points at only one owner, so the collection of any other owner cannot be stored, and it does not start when the properties have the same name.
+  The first owner (by entity name, then property path) keeps the table and the key column it has today, the others get names that start with their table and the path of the embedded property.
 * A custom collection type on a `hasMany` now takes effect, see <<native-binding-custom-collection-type,1.4>>.
 
 Before you switch an existing database, run `dbm-gorm-diff` against a copy of it with the setting on.