diff --git a/hibernate-core/src/main/java/org/hibernate/annotations/CollectionTableOverride.java b/hibernate-core/src/main/java/org/hibernate/annotations/CollectionTableOverride.java new file mode 100644 index 000000000000..e6f75297c430 --- /dev/null +++ b/hibernate-core/src/main/java/org/hibernate/annotations/CollectionTableOverride.java @@ -0,0 +1,60 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.annotations; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import jakarta.persistence.CollectionTable; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Used to override the collection table configuration for a collection + * that is nested within an embeddable class. + * + *

This annotation allows overriding the collection table configuration + * for collections that are defined within an embeddable class. + * + *

Example: + *

+ * @Embeddable
+ * public class Address {
+ *     @ElementCollection
+ *     @CollectionTable(name = "default_phones")
+ *     List<Phone> phones;
+ * }
+ *
+ * @Entity
+ * public class Person {
+ *     @Embedded
+ *     @CollectionTableOverride(
+ *         name = "phones",
+ *         collectionTable = @CollectionTable(name = "person_phones")
+ *     )
+ *     Address address;
+ * }
+ * 
+ */ +@Target({ TYPE, METHOD, FIELD }) +@Retention(RUNTIME) +public @interface CollectionTableOverride { + /** + * The path to the collection property within the embeddable. + * For example, if the embeddable has a field "phones", the name would be "phones". + * For nested embeddables, use dot notation like "address.phones". + */ + String name(); + + /** + * The collection table configuration to use instead of the default. + * This allows specifying the full {@link CollectionTable} annotation + * with all its attributes (name, schema, catalog, joinColumns, indexes, etc.). + */ + CollectionTable collectionTable(); +} diff --git a/hibernate-core/src/main/java/org/hibernate/annotations/CollectionTableOverrides.java b/hibernate-core/src/main/java/org/hibernate/annotations/CollectionTableOverrides.java new file mode 100644 index 000000000000..bb2b6a103adf --- /dev/null +++ b/hibernate-core/src/main/java/org/hibernate/annotations/CollectionTableOverrides.java @@ -0,0 +1,22 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.annotations; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Container for multiple {@link CollectionTableOverride} annotations. + */ +@Target({ TYPE, METHOD, FIELD }) +@Retention(RUNTIME) +public @interface CollectionTableOverrides { + CollectionTableOverride[] value(); +} diff --git a/hibernate-core/src/main/java/org/hibernate/boot/model/internal/CollectionBinder.java b/hibernate-core/src/main/java/org/hibernate/boot/model/internal/CollectionBinder.java index f60ab5e44969..50acd1a26648 100644 --- a/hibernate-core/src/main/java/org/hibernate/boot/model/internal/CollectionBinder.java +++ b/hibernate-core/src/main/java/org/hibernate/boot/model/internal/CollectionBinder.java @@ -19,6 +19,8 @@ import org.hibernate.FetchMode; import org.hibernate.MappingException; import org.hibernate.annotations.*; +import org.hibernate.annotations.CollectionTableOverride; +import org.hibernate.annotations.CollectionTableOverrides; import org.hibernate.boot.model.IdentifierGeneratorDefinition; import org.hibernate.boot.models.AnnotationPlacementException; import org.hibernate.boot.models.JpaAnnotations; @@ -103,6 +105,7 @@ import static org.hibernate.boot.model.internal.BinderHelper.extractFromPackage; import static org.hibernate.boot.model.internal.BinderHelper.getFetchMode; import static org.hibernate.boot.model.internal.BinderHelper.getPath; +import static org.hibernate.boot.model.internal.BinderHelper.getRelativePath; import static org.hibernate.boot.model.internal.BinderHelper.isDefault; import static org.hibernate.boot.model.internal.BinderHelper.isPrimitive; import static org.hibernate.boot.model.internal.DialectOverridesAnnotationHelper.getOverridableAnnotation; @@ -220,9 +223,20 @@ public static void bindCollection( final var oneToManyAnn = memberDetails.getAnnotationUsage( OneToMany.class, modelsContext ); final var manyToManyAnn = memberDetails.getAnnotationUsage( ManyToMany.class, modelsContext ); final var elementCollectionAnn = memberDetails.getAnnotationUsage( ElementCollection.class, modelsContext ); - checkAnnotations( propertyHolder, inferredData, memberDetails, oneToManyAnn, manyToManyAnn, elementCollectionAnn ); + checkAnnotations( + propertyHolder, + inferredData, + memberDetails, + oneToManyAnn, + manyToManyAnn, + elementCollectionAnn + ); - final var collectionBinder = getCollectionBinder( memberDetails, hasMapKeyAnnotation( memberDetails ), context ); + final var collectionBinder = getCollectionBinder( + memberDetails, + hasMapKeyAnnotation( memberDetails ), + context + ); collectionBinder.setIndexColumn( getIndexColumn( propertyHolder, inferredData, entityBinder, context ) ); collectionBinder.setMapKey( memberDetails.getAnnotationUsage( MapKey.class, modelsContext ) ); collectionBinder.setPropertyName( inferredData.getPropertyName() ); @@ -231,10 +245,19 @@ public static void bindCollection( collectionBinder.setNaturalSort( memberDetails.getAnnotationUsage( SortNatural.class, modelsContext ) ); collectionBinder.setComparatorSort( memberDetails.getAnnotationUsage( SortComparator.class, modelsContext ) ); collectionBinder.setCache( memberDetails.getAnnotationUsage( Cache.class, modelsContext ) ); - collectionBinder.setQueryCacheLayout( memberDetails.getAnnotationUsage( QueryCacheLayout.class, modelsContext ) ); - collectionBinder.setPropertyHolder(propertyHolder); + collectionBinder.setQueryCacheLayout( memberDetails.getAnnotationUsage( + QueryCacheLayout.class, + modelsContext + ) ); + collectionBinder.setPropertyHolder( propertyHolder ); - collectionBinder.setNotFoundAction( notFoundAction( propertyHolder, inferredData, memberDetails, manyToManyAnn, modelsContext ) ); + collectionBinder.setNotFoundAction( notFoundAction( + propertyHolder, + inferredData, + memberDetails, + manyToManyAnn, + modelsContext + ) ); collectionBinder.setElementType( inferredData.getClassOrElementType() ); collectionBinder.setAccessType( inferredData.getDefaultAccess() ); collectionBinder.setEmbedded( memberDetails.hasAnnotationUsage( Embedded.class, modelsContext ) ); @@ -333,8 +356,8 @@ private static NotFoundAction notFoundAction( final var notFound = property.getAnnotationUsage( NotFound.class, sourceModelContext ); if ( notFound != null ) { if ( manyToManyAnn == null ) { - throw new AnnotationException( "Collection '" + getPath(propertyHolder, inferredData) - + "' annotated '@NotFound' is not a '@ManyToMany' association" ); + throw new AnnotationException( "Collection '" + getPath( propertyHolder, inferredData ) + + "' annotated '@NotFound' is not a '@ManyToMany' association" ); } return notFound.action(); } @@ -369,7 +392,7 @@ private static PropertyData virtualPropertyData(PropertyData inferredData, Membe //do not use "element" if you are a JPA 2 @ElementCollection, only for legacy Hibernate mappings return property.hasDirectAnnotationUsage( ElementCollection.class ) ? inferredData - : new WrappedInferredData(inferredData, "element" ); + : new WrappedInferredData( inferredData, "element" ); } private static void checkAnnotations( @@ -382,8 +405,8 @@ private static void checkAnnotations( if ( ( oneToMany != null || manyToMany != null || elementCollection != null ) && isToManyAssociationWithinEmbeddableCollection( propertyHolder ) ) { throw new AnnotationException( "Property '" + getPath( propertyHolder, inferredData ) + - "' belongs to an '@Embeddable' class that is contained in an '@ElementCollection' and may not be a " - + annotationName( oneToMany, manyToMany, elementCollection )); + "' belongs to an '@Embeddable' class that is contained in an '@ElementCollection' and may not be a " + + annotationName( oneToMany, manyToMany, elementCollection ) ); } if ( oneToMany != null && property.hasDirectAnnotationUsage( SoftDelete.class ) ) { @@ -396,17 +419,21 @@ && isToManyAssociationWithinEmbeddableCollection( propertyHolder ) ) { if ( property.hasDirectAnnotationUsage( OrderColumn.class ) && manyToMany != null && isNotBlank( manyToMany.mappedBy() ) ) { - throw new AnnotationException("Collection '" + getPath( propertyHolder, inferredData ) + - "' is the unowned side of a bidirectional '@ManyToMany' and may not have an '@OrderColumn'"); + throw new AnnotationException( "Collection '" + getPath( propertyHolder, inferredData ) + + "' is the unowned side of a bidirectional '@ManyToMany' and may not have an '@OrderColumn'" ); } if ( manyToMany != null || elementCollection != null ) { if ( property.hasDirectAnnotationUsage( JoinColumn.class ) || property.hasDirectAnnotationUsage( JoinColumns.class ) ) { throw new AnnotationException( "Property '" + getPath( propertyHolder, inferredData ) - + "' is a " + annotationName( oneToMany, manyToMany, elementCollection ) - + " and is directly annotated '@JoinColumn'" - + " (specify '@JoinColumn' inside '@JoinTable' or '@CollectionTable')" ); + + "' is a " + annotationName( + oneToMany, + manyToMany, + elementCollection + ) + + " and is directly annotated '@JoinColumn'" + + " (specify '@JoinColumn' inside '@JoinTable' or '@CollectionTable')" ); } } } @@ -449,26 +476,28 @@ private static String handleTargetEntity( //TODO enhance exception with @ManyToAny and @CollectionOfElements if ( oneToManyAnn != null && manyToManyAnn != null ) { throw new AnnotationException( "Property '" + getPath( propertyHolder, inferredData ) - + "' is annotated both '@OneToMany' and '@ManyToMany'" ); + + "' is annotated both '@OneToMany' and '@ManyToMany'" ); } final String mappedBy; if ( oneToManyAnn != null ) { if ( joinColumns.isSecondary() ) { throw new AnnotationException( "Collection '" + getPath( propertyHolder, inferredData ) - + "' has foreign key in secondary table" ); + + "' has foreign key in secondary table" ); } collectionBinder.setFkJoinColumns( joinColumns ); mappedBy = nullIfEmpty( oneToManyAnn.mappedBy() ); collectionBinder.setTargetEntity( oneToManyAnn.targetEntity() ); collectionBinder.setCascadeStrategy( - aggregateCascadeTypes( oneToManyAnn.cascade(), hibernateCascade, - oneToManyAnn.orphanRemoval(), context ) ); + aggregateCascadeTypes( + oneToManyAnn.cascade(), hibernateCascade, + oneToManyAnn.orphanRemoval(), context + ) ); collectionBinder.setOneToMany( true ); } else if ( elementCollectionAnn != null ) { if ( joinColumns.isSecondary() ) { throw new AnnotationException( "Collection '" + getPath( propertyHolder, inferredData ) - + "' has foreign key in secondary table" ); + + "' has foreign key in secondary table" ); } collectionBinder.setFkJoinColumns( joinColumns ); mappedBy = null; @@ -497,17 +526,17 @@ else if ( property.hasDirectAnnotationUsage( ManyToAny.class ) ) { } private static boolean hasMapKeyAnnotation(MemberDetails property) { - return property.hasDirectAnnotationUsage(MapKeyJavaType.class) - || property.hasDirectAnnotationUsage(MapKeyJdbcType.class) - || property.hasDirectAnnotationUsage(MapKeyJdbcTypeCode.class) - || property.hasDirectAnnotationUsage(MapKeyMutability.class) - || property.hasDirectAnnotationUsage(MapKey.class) - || property.hasDirectAnnotationUsage(MapKeyType.class); + return property.hasDirectAnnotationUsage( MapKeyJavaType.class ) + || property.hasDirectAnnotationUsage( MapKeyJdbcType.class ) + || property.hasDirectAnnotationUsage( MapKeyJdbcTypeCode.class ) + || property.hasDirectAnnotationUsage( MapKeyMutability.class ) + || property.hasDirectAnnotationUsage( MapKey.class ) + || property.hasDirectAnnotationUsage( MapKeyType.class ); } private static boolean isToManyAssociationWithinEmbeddableCollection(PropertyHolder propertyHolder) { return propertyHolder instanceof ComponentPropertyHolder componentPropertyHolder - && componentPropertyHolder.isWithinElementCollection(); + && componentPropertyHolder.isWithinElementCollection(); } private static AnnotatedColumns elementColumns( @@ -532,7 +561,7 @@ private static AnnotatedColumns elementColumns( } else if ( property.hasDirectAnnotationUsage( Formula.class ) ) { return buildFormulaFromAnnotation( - getOverridableAnnotation(property, Formula.class, context), + getOverridableAnnotation( property, Formula.class, context ), // comment, nullability, propertyHolder, @@ -594,9 +623,9 @@ private static AnnotatedColumns mapKeyColumns( return buildColumnFromAnnotations( property.hasDirectAnnotationUsage( MapKeyColumn.class ) ? MapKeyColumnJpaAnnotation.toColumnAnnotation( - property.getDirectAnnotationUsage( MapKeyColumn.class ), - context.getBootstrapContext().getModelsContext() - ) + property.getDirectAnnotationUsage( MapKeyColumn.class ), + context.getBootstrapContext().getModelsContext() + ) : null, // comment, Nullability.FORCED_NOT_NULL, @@ -620,9 +649,22 @@ private static void bindJoinedTableAssociation( final var assocTable = propertyHolder.getJoinTable( property ); final var collectionTable = property.getDirectAnnotationUsage( CollectionTable.class ); + // Check for @CollectionTableOverride annotation + final var collectionTableOverride = findCollectionTableOverride( + propertyHolder, + property, + inferredData, + buildingContext + ); + + // If @CollectionTableOverride is present, use its collectionTable annotation + final CollectionTable effectiveCollectionTable = collectionTableOverride != null + ? collectionTableOverride.collectionTable() + : collectionTable; + final JoinColumn[] annJoins; final JoinColumn[] annInverseJoins; - if ( assocTable != null || collectionTable != null ) { + if ( assocTable != null || effectiveCollectionTable != null ) { final String catalog; final String schema; final String tableName; @@ -633,15 +675,15 @@ private static void bindJoinedTableAssociation( final String options; //JPA 2 has priority - if ( collectionTable != null ) { - catalog = collectionTable.catalog(); - schema = collectionTable.schema(); - tableName = collectionTable.name(); - uniqueConstraints = collectionTable.uniqueConstraints(); - joins = collectionTable.joinColumns(); + if ( effectiveCollectionTable != null ) { + catalog = effectiveCollectionTable.catalog(); + schema = effectiveCollectionTable.schema(); + tableName = effectiveCollectionTable.name(); + uniqueConstraints = effectiveCollectionTable.uniqueConstraints(); + joins = effectiveCollectionTable.joinColumns(); inverseJoins = null; - jpaIndexes = collectionTable.indexes(); - options = collectionTable.options(); + jpaIndexes = effectiveCollectionTable.indexes(); + options = effectiveCollectionTable.options(); } else { catalog = assocTable.catalog(); @@ -771,8 +813,10 @@ private static CollectionBinder getCollectionBinder( boolean isHibernateExtensionMapping, MetadataBuildingContext buildingContext) { final var typeAnnotation = - property.getAnnotationUsage( CollectionType.class, - buildingContext.getBootstrapContext().getModelsContext() ); + property.getAnnotationUsage( + CollectionType.class, + buildingContext.getBootstrapContext().getModelsContext() + ); final var binder = typeAnnotation != null ? createBinderFromCustomTypeAnnotation( property, typeAnnotation, buildingContext ) : createBinderAutomatically( property, buildingContext ); @@ -877,19 +921,29 @@ private static CollectionClassification determineCollectionClassification( final var modelsContext = buildingContext.getBootstrapContext().getModelsContext(); if ( !property.hasAnnotationUsage( Bag.class, modelsContext ) ) { - return determineCollectionClassification( determineSemanticJavaType( property ), property, buildingContext ); + return determineCollectionClassification( + determineSemanticJavaType( property ), + property, + buildingContext + ); } if ( property.hasAnnotationUsage( OrderColumn.class, modelsContext ) ) { throw new AnnotationException( "Attribute '" - + qualify( property.getDeclaringType().getName(), property.getName() ) - + "' is annotated '@Bag' and may not also be annotated '@OrderColumn'" ); + + qualify( + property.getDeclaringType().getName(), + property.getName() + ) + + "' is annotated '@Bag' and may not also be annotated '@OrderColumn'" ); } if ( property.hasAnnotationUsage( ListIndexBase.class, modelsContext ) ) { throw new AnnotationException( "Attribute '" - + qualify( property.getDeclaringType().getName(), property.getName() ) - + "' is annotated '@Bag' and may not also be annotated '@ListIndexBase'" ); + + qualify( + property.getDeclaringType().getName(), + property.getName() + ) + + "' is annotated '@Bag' and may not also be annotated '@ListIndexBase'" ); } final var collectionJavaType = property.getType().determineRawClass().toJavaClass(); @@ -1026,7 +1080,7 @@ private void setElementType(TypeDetails collectionElementType) { private void setTargetEntity(Class targetEntity) { setTargetEntity( modelsContext().getClassDetailsRegistry() - .resolveClassDetails( targetEntity.getName() ) ); + .resolveClassDetails( targetEntity.getName() ) ); } private void setTargetEntity(ClassDetails targetEntity) { @@ -1057,7 +1111,8 @@ private void bind() { final EmbeddedTable misplaced = property.getDirectAnnotationUsage( EmbeddedTable.class ); if ( misplaced != null ) { // not allowed - throw new AnnotationPlacementException( "@EmbeddedTable only supported for use on entity or mapped-superclass" ); + throw new AnnotationPlacementException( + "@EmbeddedTable only supported for use on entity or mapped-superclass" ); } } collection = createCollection( propertyHolder.getPersistentClass() ); @@ -1098,7 +1153,7 @@ private boolean isMutable() { private void checkMapKeyColumn() { if ( property.hasDirectAnnotationUsage( MapKeyColumn.class ) && hasMapKeyProperty ) { throw new AnnotationException( "Collection '" + qualify( propertyHolder.getPath(), propertyName ) - + "' is annotated both '@MapKey' and '@MapKeyColumn'" ); + + "' is annotated both '@MapKey' and '@MapKeyColumn'" ); } } @@ -1109,7 +1164,7 @@ private void scheduleSecondPass(boolean isMappedBy) { metadataCollector.addMappedBy( getElementType().getName(), mappedBy, propertyName ); } - if ( inheritanceStatePerClass == null) { + if ( inheritanceStatePerClass == null ) { throw new AssertionFailure( "inheritanceStatePerClass not set" ); } metadataCollector.addSecondPass( getSecondPass(), !isMappedBy ); @@ -1135,40 +1190,40 @@ private void detectMappedByProblem(boolean isMappedBy) { if ( property.hasDirectAnnotationUsage( JoinColumn.class ) || property.hasDirectAnnotationUsage( JoinColumns.class ) ) { throw new AnnotationException( "Association '" - + qualify( propertyHolder.getPath(), propertyName ) - + "' is 'mappedBy' another entity and may not specify the '@JoinColumn'" ); + + qualify( propertyHolder.getPath(), propertyName ) + + "' is 'mappedBy' another entity and may not specify the '@JoinColumn'" ); } if ( propertyHolder.getJoinTable( property ) != null ) { throw new AnnotationException( "Association '" - + qualify( propertyHolder.getPath(), propertyName ) - + "' is 'mappedBy' another entity and may not specify the '@JoinTable'" ); + + qualify( propertyHolder.getPath(), propertyName ) + + "' is 'mappedBy' another entity and may not specify the '@JoinTable'" ); } if ( oneToMany ) { if ( property.hasDirectAnnotationUsage( MapKeyColumn.class ) ) { BOOT_LOGGER.warn( "Association '" - + qualify( propertyHolder.getPath(), propertyName ) - + "' is 'mappedBy' another entity and should not specify a '@MapKeyColumn'" - + " (use '@MapKey' instead)" ); + + qualify( propertyHolder.getPath(), propertyName ) + + "' is 'mappedBy' another entity and should not specify a '@MapKeyColumn'" + + " (use '@MapKey' instead)" ); } if ( property.hasDirectAnnotationUsage( OrderColumn.class ) ) { BOOT_LOGGER.warn( "Association '" - + qualify( propertyHolder.getPath(), propertyName ) - + "' is 'mappedBy' another entity and should not specify an '@OrderColumn'" - + " (use '@OrderBy' instead)" ); + + qualify( propertyHolder.getPath(), propertyName ) + + "' is 'mappedBy' another entity and should not specify an '@OrderColumn'" + + " (use '@OrderBy' instead)" ); } } else { if ( property.hasDirectAnnotationUsage( MapKeyColumn.class ) ) { throw new AnnotationException( "Association '" - + qualify( propertyHolder.getPath(), propertyName ) - + "' is 'mappedBy' another entity and may not specify a '@MapKeyColumn'" - + " (use '@MapKey' instead)" ); + + qualify( propertyHolder.getPath(), propertyName ) + + "' is 'mappedBy' another entity and may not specify a '@MapKeyColumn'" + + " (use '@MapKey' instead)" ); } if ( property.hasDirectAnnotationUsage( OrderColumn.class ) ) { throw new AnnotationException( "Association '" - + qualify( propertyHolder.getPath(), propertyName ) - + "' is 'mappedBy' another entity and may not specify an '@OrderColumn'" - + " (use '@OrderBy' instead)" ); + + qualify( propertyHolder.getPath(), propertyName ) + + "' is 'mappedBy' another entity and may not specify an '@OrderColumn'" + + " (use '@OrderBy' instead)" ); } } } @@ -1176,17 +1231,17 @@ else if ( oneToMany && property.hasDirectAnnotationUsage( OnDelete.class ) && !hasExplicitJoinColumn() ) { throw new AnnotationException( "Unidirectional '@OneToMany' association '" - + qualify( propertyHolder.getPath(), propertyName ) - + "' is annotated '@OnDelete' and must explicitly specify a '@JoinColumn'" ); + + qualify( propertyHolder.getPath(), propertyName ) + + "' is annotated '@OnDelete' and must explicitly specify a '@JoinColumn'" ); } } private boolean hasExplicitJoinColumn() { return property.hasDirectAnnotationUsage( JoinColumn.class ) - || property.hasDirectAnnotationUsage( JoinColumns.class ) - || property.hasDirectAnnotationUsage( JoinTable.class ) - && property.getDirectAnnotationUsage( JoinTable.class ) - .joinColumns().length > 0; + || property.hasDirectAnnotationUsage( JoinColumns.class ) + || property.hasDirectAnnotationUsage( JoinTable.class ) + && property.getDirectAnnotationUsage( JoinTable.class ) + .joinColumns().length > 0; } private void bindProperty() { @@ -1309,7 +1364,7 @@ else if ( comparatorSort != null ) { if ( jpaOrderBy != null && sqlOrder != null ) { throw buildIllegalOrderCombination(); } - final boolean ordered = jpaOrderBy != null || sqlOrder != null ; + final boolean ordered = jpaOrderBy != null || sqlOrder != null; if ( ordered ) { // we can only apply the sql-based order by up front. The jpa order by has to wait for second pass if ( sqlOrder != null ) { @@ -1391,14 +1446,16 @@ private ModelsContext modelsContext() { } private void handleFetchProfileOverrides() { - property.forEachAnnotationUsage( FetchProfileOverride.class, modelsContext(), (usage) -> { - getMetadataCollector().addSecondPass( new FetchSecondPass( - usage, - propertyHolder, - propertyName, - buildingContext - ) ); - } ); + property.forEachAnnotationUsage( + FetchProfileOverride.class, modelsContext(), (usage) -> { + getMetadataCollector().addSecondPass( new FetchSecondPass( + usage, + propertyHolder, + propertyName, + buildingContext + ) ); + } + ); } private void handleFetch() { @@ -1414,7 +1471,7 @@ private void handleFetch() { private void setHibernateFetchMode(org.hibernate.annotations.FetchMode fetchMode) { switch ( fetchMode ) { - case JOIN : + case JOIN: collection.setFetchMode( FetchMode.JOIN ); collection.setLazy( false ); break; @@ -1469,7 +1526,7 @@ TypeDetails getElementType() { } else { throw new AnnotationException( "Collection '" + safeCollectionRole() - + "' is declared with a raw type and has an explicit 'targetEntity'" ); + + "' is declared with a raw type and has an explicit 'targetEntity'" ); } } else { @@ -1523,8 +1580,8 @@ private Property getMappedByProperty(TypeDetails elementType, PersistentClass pe catch (MappingException e) { throw new AnnotationException( "Collection '" + safeCollectionRole() - + "' is 'mappedBy' a property named '" + mappedBy - + "' which does not exist in the target entity '" + elementType.getName() + "'" + + "' is 'mappedBy' a property named '" + mappedBy + + "' which does not exist in the target entity '" + elementType.getName() + "'" ); } } @@ -1532,19 +1589,19 @@ private Property getMappedByProperty(TypeDetails elementType, PersistentClass pe private boolean noAssociationTable(Map persistentClasses) { final var persistentClass = persistentClasses.get( getElementType().getName() ); return persistentClass != null - && !isReversePropertyInJoin( getElementType(), persistentClass, persistentClasses ) - && oneToMany - && !isExplicitAssociationTable - && ( implicitJoinColumn() || explicitForeignJoinColumn() ); + && !isReversePropertyInJoin( getElementType(), persistentClass, persistentClasses ) + && oneToMany + && !isExplicitAssociationTable + && ( implicitJoinColumn() || explicitForeignJoinColumn() ); } private boolean implicitJoinColumn() { - return joinColumns.getJoinColumns().get(0).isImplicit() - && isUnownedCollection(); //implicit @JoinColumn + return joinColumns.getJoinColumns().get( 0 ).isImplicit() + && isUnownedCollection(); //implicit @JoinColumn } private boolean explicitForeignJoinColumn() { - return !foreignJoinColumns.getJoinColumns().get(0).isImplicit(); //this is an explicit @JoinColumn + return !foreignJoinColumns.getJoinColumns().get( 0 ).isImplicit(); //this is an explicit @JoinColumn } /** @@ -1568,8 +1625,10 @@ protected void bindOneToManySecondPass(Map persistentCl handleJpaOrderBy( collection, associatedClass ); if ( associatedClass == null ) { throw new MappingException( - String.format( "Association [%s] for entity [%s] references unmapped class [%s]", - propertyName, propertyHolder.getClassName(), referencedEntityName ) + String.format( + "Association [%s] for entity [%s] references unmapped class [%s]", + propertyName, propertyHolder.getClassName(), referencedEntityName + ) ); } oneToMany.setAssociatedClass( associatedClass ); @@ -1585,7 +1644,7 @@ protected void bindOneToManySecondPass(Map persistentCl final var collectionTable = foreignJoinColumns.hasMappedBy() ? associatedClass.getRecursiveProperty( foreignJoinColumns.getMappedBy() ) - .getValue().getTable() + .getValue().getTable() : foreignJoinColumns.getTable(); collection.setCollectionTable( collectionTable ); @@ -1608,11 +1667,11 @@ private void createOneToManyBackref(org.hibernate.mapping.OneToMany oneToMany) { final var referencedEntity = collector.getEntityBinding( entityName ); final Backref backref = new Backref(); final String backrefName = '_' + foreignJoinColumns.getPropertyName() - + '_' + foreignJoinColumns.getColumns().get(0).getLogicalColumnName() + + '_' + foreignJoinColumns.getColumns().get( 0 ).getLogicalColumnName() + "Backref"; backref.setName( backrefName ); backref.setOptional( true ); - backref.setUpdatable( false); + backref.setUpdatable( false ); backref.setSelectable( false ); backref.setCollectionRole( getRole() ); backref.setEntityName( collection.getOwner().getEntityName() ); @@ -1646,7 +1705,10 @@ private void bindSynchronize() { private String toPhysicalName(String logicalName) { final var jdbcEnvironment = getMetadataCollector().getDatabase().getJdbcEnvironment(); return buildingContext.getBuildingOptions().getPhysicalNamingStrategy() - .toPhysicalTableName( jdbcEnvironment.getIdentifierHelper().toIdentifier( logicalName ), jdbcEnvironment ) + .toPhysicalTableName( + jdbcEnvironment.getIdentifierHelper().toIdentifier( logicalName ), + jdbcEnvironment + ) .render( jdbcEnvironment.getDialect() ); } @@ -1670,15 +1732,19 @@ private static void fillAliasMaps( private void bindFilters(boolean hasAssociationTable) { final var context = modelsContext(); - property.forEachAnnotationUsage( Filter.class, context, - usage -> addFilter( hasAssociationTable, usage ) ); - property.forEachAnnotationUsage( FilterJoinTable.class, context, - usage -> addFilterJoinTable( hasAssociationTable, usage ) ); + property.forEachAnnotationUsage( + Filter.class, context, + usage -> addFilter( hasAssociationTable, usage ) + ); + property.forEachAnnotationUsage( + FilterJoinTable.class, context, + usage -> addFilterJoinTable( hasAssociationTable, usage ) + ); } private void addFilter(boolean hasAssociationTable, Filter filter) { - final Map aliasTableMap = new HashMap<>(); - final Map aliasEntityMap = new HashMap<>(); + final Map aliasTableMap = new HashMap<>(); + final Map aliasEntityMap = new HashMap<>(); fillAliasMaps( filter.aliases(), aliasTableMap, aliasEntityMap ); final String filterCondition = getFilterCondition( filter ); if ( hasAssociationTable ) { @@ -1765,8 +1831,8 @@ private String getWhereOnClassClause() { private void addFilterJoinTable(boolean hasAssociationTable, FilterJoinTable filter) { if ( hasAssociationTable ) { - final Map aliasTableMap = new HashMap<>(); - final Map aliasEntityMap = new HashMap<>(); + final Map aliasTableMap = new HashMap<>(); + final Map aliasEntityMap = new HashMap<>(); fillAliasMaps( filter.aliases(), aliasTableMap, aliasEntityMap ); collection.addFilter( filter.name(), @@ -1778,7 +1844,7 @@ private void addFilterJoinTable(boolean hasAssociationTable, FilterJoinTable fil } else { throw new AnnotationException( "Collection '" + qualify( propertyHolder.getPath(), propertyName ) - + "' is an association with no join table and may not have a '@FilterJoinTable'" ); + + "' is an association with no join table and may not have a '@FilterJoinTable'" ); } } @@ -1800,15 +1866,15 @@ private String getDefaultFilterCondition(String name, Annotation annotation) { final var definition = getMetadataCollector().getFilterDefinition( name ); if ( definition == null ) { throw new AnnotationException( "Collection '" + qualify( propertyHolder.getPath(), propertyName ) - + "' has a '@" + annotation.annotationType().getSimpleName() - + "' for an undefined filter named '" + name + "'" ); + + "' has a '@" + annotation.annotationType().getSimpleName() + + "' for an undefined filter named '" + name + "'" ); } final String defaultCondition = definition.getDefaultFilterCondition(); if ( isBlank( defaultCondition ) ) { throw new AnnotationException( "Collection '" + qualify( propertyHolder.getPath(), propertyName ) + - "' has a '@" + annotation.annotationType().getSimpleName() - + "' with no 'condition' and no default condition was given by the '@FilterDef' named '" - + name + "'" ); + "' has a '@" + annotation.annotationType().getSimpleName() + + "' with no 'condition' and no default condition was given by the '@FilterDef' named '" + + name + "'" ); } return defaultCondition; } @@ -1862,7 +1928,7 @@ else if ( "desc".equalsIgnoreCase( orderByFragment ) ) { private static String buildOrderById(PersistentClass associatedClass, String direction) { final var order = new StringBuilder(); - for ( var selectable: associatedClass.getIdentifier().getSelectables() ) { + for ( var selectable : associatedClass.getIdentifier().getSelectables() ) { order.append( selectable.getText() ); order.append( direction ); order.append( ", " ); @@ -1916,8 +1982,8 @@ private DependantValue buildCollectionKey(AnnotatedJoinColumns joinColumns, OnDe key.setTypeName( null ); joinColumns.checkPropertyConsistency(); final var columns = joinColumns.getColumns(); - key.setNullable( columns.isEmpty() || columns.get(0).isNullable() ); - key.setUpdateable( columns.isEmpty() || columns.get(0).isUpdatable() ); + key.setNullable( columns.isEmpty() || columns.get( 0 ).isNullable() ); + key.setUpdateable( columns.isEmpty() || columns.get( 0 ).isUpdatable() ); key.setOnDeleteAction( onDeleteAction ); collection.setKey( key ); @@ -2012,7 +2078,7 @@ private static void handleForeignKeyConstraint( ForeignKey foreignKey) { final var constraintMode = foreignKey.value(); if ( constraintMode == NO_CONSTRAINT - || constraintMode == PROVIDER_DEFAULT && noConstraintByDefault) { + || constraintMode == PROVIDER_DEFAULT && noConstraintByDefault ) { key.disableForeignKey(); } else { @@ -2222,7 +2288,7 @@ private AnnotatedClassType annotatedElementType( } else { //force in case of attribute override - final boolean attributeOverride = mappingDefinedAttributeOverrideOnElement(property); + final boolean attributeOverride = mappingDefinedAttributeOverrideOnElement( property ); // todo : force in the case of Convert annotation(s) with embedded paths (beyond key/value prefixes)? return isEmbedded || attributeOverride ? EMBEDDABLE @@ -2232,7 +2298,7 @@ private AnnotatedClassType annotatedElementType( protected boolean mappingDefinedAttributeOverrideOnElement(MemberDetails property) { return property.hasDirectAnnotationUsage( AttributeOverride.class ) - || property.hasDirectAnnotationUsage( AttributeOverrides.class ); + || property.hasDirectAnnotationUsage( AttributeOverrides.class ); } static AnnotatedColumns createElementColumnsIfNecessary( @@ -2266,7 +2332,7 @@ private ManyToOne handleCollectionOfEntities( TypeDetails elementType, PersistentClass collectionEntity, String hqlOrderBy) { - final var element = new ManyToOne( buildingContext, collection.getCollectionTable() ); + final var element = new ManyToOne( buildingContext, collection.getCollectionTable() ); collection.setElement( element ); element.setReferencedEntityName( elementType.getName() ); //element.setFetchMode( fetchMode ); @@ -2301,7 +2367,7 @@ private ManyToOne handleCollectionOfEntities( final var constraintMode = inverseForeignKey.value(); if ( constraintMode == NO_CONSTRAINT || constraintMode == PROVIDER_DEFAULT - && buildingContext.getBuildingOptions().isNoConstraintByDefault() ) { + && buildingContext.getBuildingOptions().isNoConstraintByDefault() ) { element.disableForeignKey(); } else { @@ -2402,21 +2468,25 @@ private void handleOwnedManyToMany(PersistentClass collectionEntity, boolean isC } private void handleCheckConstraints(Table collectionTable) { - property.forEachAnnotationUsage( Check.class, modelsContext(), - usage -> addCheckToCollection( collectionTable, usage ) ); - property.forEachAnnotationUsage( jakarta.persistence.JoinTable.class, modelsContext(), usage -> { - TableBinder.addTableCheck( collectionTable, usage.check() ); - TableBinder.addTableComment( collectionTable, usage.comment() ); - TableBinder.addTableOptions( collectionTable, usage.options() ); - } ); + property.forEachAnnotationUsage( + Check.class, modelsContext(), + usage -> addCheckToCollection( collectionTable, usage ) + ); + property.forEachAnnotationUsage( + jakarta.persistence.JoinTable.class, modelsContext(), usage -> { + TableBinder.addTableCheck( collectionTable, usage.check() ); + TableBinder.addTableComment( collectionTable, usage.comment() ); + TableBinder.addTableOptions( collectionTable, usage.options() ); + } + ); } private static void addCheckToCollection(Table collectionTable, Check check) { final String name = check.name(); final String constraint = check.constraints(); collectionTable.addCheck( name.isBlank() - ? new CheckConstraint( constraint ) - : new CheckConstraint( name, constraint ) ); + ? new CheckConstraint( constraint ) + : new CheckConstraint( name, constraint ) ); } private void processSoftDeletes() { @@ -2445,7 +2515,7 @@ private void handleUnownedManyToMany( boolean isCollectionOfEntities) { if ( !isCollectionOfEntities ) { throw new AnnotationException( "Association '" + safeCollectionRole() + "'" - + targetEntityMessage( elementType ) ); + + targetEntityMessage( elementType ) ); } joinColumns.setManyToManyOwnerSideEntityName( collectionEntity.getEntityName() ); @@ -2466,9 +2536,9 @@ private void handleUnownedManyToMany( private void checkCheckAnnotation() { if ( property.hasDirectAnnotationUsage( Checks.class ) - || property.hasDirectAnnotationUsage( Check.class ) ) { + || property.hasDirectAnnotationUsage( Check.class ) ) { throw new AnnotationException( "Association '" + safeCollectionRole() - + " is an unowned collection and may not be annotated '@Check'" ); + + " is an unowned collection and may not be annotated '@Check'" ); } } @@ -2477,24 +2547,24 @@ private void detectManyToManyProblems( boolean isCollectionOfEntities, boolean isManyToAny) { - if ( !isCollectionOfEntities) { + if ( !isCollectionOfEntities ) { if ( property.hasDirectAnnotationUsage( ManyToMany.class ) || property.hasDirectAnnotationUsage( OneToMany.class ) ) { throw new AnnotationException( "Association '" + safeCollectionRole() + "'" - + targetEntityMessage( elementType ) ); + + targetEntityMessage( elementType ) ); } - else if (isManyToAny) { + else if ( isManyToAny ) { if ( propertyHolder.getJoinTable( property ) == null ) { throw new AnnotationException( "Association '" + safeCollectionRole() - + "' is a '@ManyToAny' and must specify a '@JoinTable'" ); + + "' is a '@ManyToAny' and must specify a '@JoinTable'" ); } } else { final var joinTableAnn = propertyHolder.getJoinTable( property ); if ( joinTableAnn != null && !ArrayHelper.isEmpty( joinTableAnn.inverseJoinColumns() ) ) { throw new AnnotationException( "Association '" + safeCollectionRole() - + " has a '@JoinTable' with 'inverseJoinColumns' and" - + targetEntityMessage( elementType ) ); + + " has a '@JoinTable' with 'inverseJoinColumns' and" + + targetEntityMessage( elementType ) ); } } } @@ -2694,9 +2764,9 @@ private void bindUnownedManyToManyInverseForeignKey( AnnotatedJoinColumns joinColumns, SimpleValue value) { final var mappedByProperty = targetEntity.getRecursiveProperty( mappedBy ); - final var firstColumn = joinColumns.getJoinColumns().get(0); - for ( var selectable: mappedByColumns( targetEntity, mappedByProperty ) ) { - firstColumn.linkValueUsingAColumnCopy( (Column) selectable, value); + final var firstColumn = joinColumns.getJoinColumns().get( 0 ); + for ( var selectable : mappedByColumns( targetEntity, mappedByProperty ) ) { + firstColumn.linkValueUsingAColumnCopy( (Column) selectable, value ); } final var manyToOne = (ManyToOne) value; setReferencedProperty( targetEntity.getEntityName(), mappedBy, manyToOne ); @@ -2805,4 +2875,164 @@ else if ( isManyToAny ) { } } } + + /** + * Finds a @CollectionTableOverride annotation for the given collection property. + * + * @param propertyHolder The PropertyHolder containing the property + * @param property The collection property MemberDetails + * @param inferredData The PropertyData for the property + * @param buildingContext The MetadataBuildingContext + * + * @return The CollectionTableOverride if found, null otherwise + */ + private static CollectionTableOverride findCollectionTableOverride( + PropertyHolder propertyHolder, + MemberDetails property, + PropertyData inferredData, + MetadataBuildingContext buildingContext) { + + // Get the entity's PersistentClass + final PersistentClass persistentClass = propertyHolder.getPersistentClass(); + if ( persistentClass == null ) { + return null; + } + + // Get the class name - may be null for dynamic models (DynamicMap, DynamicModel) + final String className = persistentClass.getClassName(); + if ( className == null ) { + return null; + } + + // Get the ClassDetails from the models context + final var modelsContext = buildingContext.getBootstrapContext().getModelsContext(); + final var entityClassDetails = modelsContext.getClassDetailsRegistry() + .resolveClassDetails( className ); + + if ( entityClassDetails == null ) { + return null; + } + + // Get the full relative path (e.g., "address.phones" for embeddable collections) + final String fullPath = getRelativePath( propertyHolder, property.getName() ); + + // Extract the first part of the path (e.g., "address" from "address.phones") + // This is the field name in the entity class where @CollectionTableOverride is attached + final String fieldName; + if ( fullPath.contains( "." ) ) { + fieldName = fullPath.substring( 0, fullPath.indexOf( '.' ) ); + } + else { + // Direct collection on entity, use property name directly + fieldName = property.getName(); + } + + // Find the field property in the entity class + MemberDetails entityField = null; + try { + // Try to get the property from the entity class + for ( var member : entityClassDetails.getFields() ) { + if ( member.getName().equals( fieldName ) ) { + entityField = member; + break; + } + } + if ( entityField == null ) { + for ( var member : entityClassDetails.getMethods() ) { + if ( member.getName().equals( fieldName ) || + ( member.getName().startsWith( "get" ) && + member.getName() + .substring( 3 ) + .toLowerCase() + .equals( fieldName.toLowerCase() ) ) ) { + entityField = member; + break; + } + } + } + } + catch (Exception e) { + // Ignore and fall back to class-level annotation + } + + // Check field-level annotations first (if field found) + if ( entityField != null ) { + // Check for @CollectionTableOverrides (plural) on field + final var overridesAnnotation = entityField.getDirectAnnotationUsage( CollectionTableOverrides.class ); + if ( overridesAnnotation != null ) { + for ( CollectionTableOverride override : overridesAnnotation.value() ) { + if ( matchesPath( override.name(), propertyHolder, property, inferredData ) ) { + return override; + } + } + } + + // Check for @CollectionTableOverride (singular) on field + final var singleOverride = entityField.getDirectAnnotationUsage( CollectionTableOverride.class ); + if ( singleOverride != null && matchesPath( + singleOverride.name(), + propertyHolder, + property, + inferredData + ) ) { + return singleOverride; + } + } + + // Fall back to class-level annotations + // Check for @CollectionTableOverrides (plural) on class + final var classOverridesAnnotation = entityClassDetails.getDirectAnnotationUsage( CollectionTableOverrides.class ); + if ( classOverridesAnnotation != null ) { + for ( CollectionTableOverride override : classOverridesAnnotation.value() ) { + if ( matchesPath( override.name(), propertyHolder, property, inferredData ) ) { + return override; + } + } + } + + // Check for @CollectionTableOverride (singular) on class + final var classSingleOverride = entityClassDetails.getDirectAnnotationUsage( CollectionTableOverride.class ); + if ( classSingleOverride != null && matchesPath( + classSingleOverride.name(), + propertyHolder, + property, + inferredData + ) ) { + return classSingleOverride; + } + + return null; + } + + /** + * Checks if the override name matches the current collection property path. + * + * @param overrideName The name from @CollectionTableOverride + * @param propertyHolder The PropertyHolder containing the property + * @param property The collection property MemberDetails + * @param inferredData The PropertyData for the property + * + * @return true if the path matches + */ + private static boolean matchesPath( + String overrideName, + PropertyHolder propertyHolder, + MemberDetails property, + PropertyData inferredData) { + + // Get the full relative path (e.g., "address.phones" for embeddable collections) + final String fullPath = getRelativePath( propertyHolder, property.getName() ); + + // Direct match: override name matches the full path + if ( overrideName.equals( fullPath ) ) { + return true; + } + + // Simple case: property name directly matches (for direct entity collections) + if ( overrideName.equals( property.getName() ) ) { + return true; + } + + return false; + } } diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/bootstrap/binding/annotations/collectionTableOverride/CollectionTableOverrideTest.java b/hibernate-core/src/test/java/org/hibernate/orm/test/bootstrap/binding/annotations/collectionTableOverride/CollectionTableOverrideTest.java new file mode 100644 index 000000000000..273438a929a6 --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/bootstrap/binding/annotations/collectionTableOverride/CollectionTableOverrideTest.java @@ -0,0 +1,475 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.orm.test.bootstrap.binding.annotations.collectionTableOverride; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Embeddable; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Table; + +import org.hibernate.annotations.CollectionTableOverride; +import org.hibernate.annotations.CollectionTableOverrides; +import org.hibernate.boot.spi.MetadataImplementor; +import org.hibernate.mapping.Collection; +import org.hibernate.mapping.Column; + +import org.hibernate.testing.orm.junit.DomainModel; +import org.hibernate.testing.orm.junit.DomainModelScope; +import org.hibernate.testing.orm.junit.SessionFactory; +import org.hibernate.testing.orm.junit.SessionFactoryScope; + +import org.hibernate.orm.test.util.SchemaUtil; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for @CollectionTableOverride annotation functionality. + *

+ * This test verifies that the @CollectionTableOverride annotation correctly + * overrides collection table names for collections within embeddable classes. + * + * @author Test + */ +@DomainModel( + annotatedClasses = { + CollectionTableOverrideTest.Person.class, + CollectionTableOverrideTest.Company.class, + CollectionTableOverrideTest.Organization.class, + CollectionTableOverrideTest.Customer.class + } +) +@SessionFactory +public class CollectionTableOverrideTest { + + /** + * Test purpose: Verify that @CollectionTableOverride is correctly applied at the metadata level. + *

+ * Verification: + * 1. Verify that Person entity's address.phones collection is overridden to table name "person_phones" + * 2. Verify that Company entity's address.phones collection is overridden to table name "company_phones" + * 3. Verify that Organization entity's contactInfo.emails collection is overridden to table name "organization_emails" + */ + @Test + public void testCollectionTableNameOverride(DomainModelScope scope) { + final MetadataImplementor metadata = scope.getDomainModel(); + + // Verify Person entity's address.phones collection table name + final Collection personPhonesCollection = metadata.getCollectionBinding( + Person.class.getName() + ".address.phones" + ); + assertThat( + "Collection table name should be overridden to 'person_phones'", + personPhonesCollection.getCollectionTable().getName(), + is( "person_phones" ) + ); + + // Verify Company entity's address.phones collection table name + final Collection companyPhonesCollection = metadata.getCollectionBinding( + Company.class.getName() + ".address.phones" + ); + assertThat( + "Collection table name should be overridden to 'company_phones'", + companyPhonesCollection.getCollectionTable().getName(), + is( "company_phones" ) + ); + + // Verify Organization entity's contactInfo.emails collection table name (using @CollectionTableOverrides) + final Collection orgEmailsCollection = metadata.getCollectionBinding( + Organization.class.getName() + ".contactInfo.emails" + ); + assertThat( + "Collection table name should be overridden to 'organization_emails'", + orgEmailsCollection.getCollectionTable().getName(), + is( "organization_emails" ) + ); + } + + /** + * Test purpose: Verify that overridden table names are correctly reflected in the schema during DDL generation. + *

+ * Verification: + * 1. Verify that overridden table names (person_phones, company_phones, organization_emails) exist in the schema + * 2. Verify that default table names (default_phones, default_emails) do NOT exist in the schema + */ + @Test + public void testSchemaGeneration(DomainModelScope scope) { + final MetadataImplementor metadata = scope.getDomainModel(); + + // Verify that overridden table names exist in the schema + assertTrue( + SchemaUtil.isTablePresent( "person_phones", metadata ), + "Table 'person_phones' should be present in schema" + ); + assertTrue( + SchemaUtil.isTablePresent( "company_phones", metadata ), + "Table 'company_phones' should be present in schema" + ); + assertTrue( + SchemaUtil.isTablePresent( "organization_emails", metadata ), + "Table 'organization_emails' should be present in schema" + ); + + // Verify that default table names do NOT exist in the schema + assertFalse( + SchemaUtil.isTablePresent( "default_phones", metadata ), + "Default table 'default_phones' should NOT be present in schema" + ); + assertFalse( + SchemaUtil.isTablePresent( "default_emails", metadata ), + "Default table 'default_emails' should NOT be present in schema" + ); + } + + /** + * Test purpose: Verify that actual database operations (save/retrieve) work correctly with overridden table names. + *

+ * Verification: + * 1. Verify that when saving Person, Company, Organization entities, data is stored in the overridden tables + * 2. Verify that saved data can be retrieved correctly + */ + @Test + public void testQueryExecution(SessionFactoryScope scope) { + // Test data persistence + scope.inTransaction( session -> { + // Persist Person entity + Person person = new Person(); + person.setName( "John Doe" ); + Address address = new Address(); + address.setStreet( "123 Main St" ); + address.setCity( "New York" ); + address.getPhones().add( "123-456-7890" ); + address.getPhones().add( "098-765-4321" ); + person.setAddress( address ); + session.persist( person ); + + // Persist Company entity + Company company = new Company(); + company.setName( "Acme Corp" ); + Address companyAddress = new Address(); + companyAddress.setStreet( "456 Business Ave" ); + companyAddress.setCity( "Boston" ); + companyAddress.getPhones().add( "555-123-4567" ); + company.setAddress( companyAddress ); + session.persist( company ); + + // Persist Organization entity + Organization organization = new Organization(); + organization.setName( "Tech Inc" ); + ContactInfo contactInfo = new ContactInfo(); + contactInfo.getEmails().add( "info@techinc.com" ); + contactInfo.getEmails().add( "support@techinc.com" ); + organization.setContactInfo( contactInfo ); + session.persist( organization ); + } ); + + // Test data retrieval + scope.inTransaction( session -> { + Person person = session.createQuery( "from Person", Person.class ).getSingleResult(); + assertThat( person.getName(), is( "John Doe" ) ); + assertThat( person.getAddress().getPhones().size(), is( 2 ) ); + + Company company = session.createQuery( "from Company", Company.class ).getSingleResult(); + assertThat( company.getName(), is( "Acme Corp" ) ); + assertThat( company.getAddress().getPhones().size(), is( 1 ) ); + + Organization organization = session.createQuery( "from Organization", Organization.class ) + .getSingleResult(); + assertThat( organization.getName(), is( "Tech Inc" ) ); + assertThat( organization.getContactInfo().getEmails().size(), is( 2 ) ); + } ); + } + + /** + * Test purpose: Verify that joinColumns and indexes from nested @CollectionTable + * in @CollectionTableOverride are correctly applied. + *

+ * Verification: + * 1. Verify that custom join column name is applied + * 2. Verify that indexes are correctly created on the collection table + */ + @Test + public void testCollectionTableOverrideWithJoinColumnsAndIndexes(DomainModelScope scope) { + final MetadataImplementor metadata = scope.getDomainModel(); + + // Get the collection binding + final Collection customerTagsCollection = metadata.getCollectionBinding( + Customer.class.getName() + ".contact.tags" + ); + assertNotNull( customerTagsCollection, "Collection should not be null" ); + + final org.hibernate.mapping.Table collectionTable = customerTagsCollection.getCollectionTable(); + assertNotNull( collectionTable, "Collection table should not be null" ); + assertEquals( "customer_tags", collectionTable.getName(), "Table name should be overridden" ); + + // Verify join column name is overridden by checking the key columns + @SuppressWarnings("unchecked") + java.util.Iterator keyColumns = customerTagsCollection.getKey().getColumns().iterator(); + assertTrue( keyColumns.hasNext(), "Join column should exist" ); + Column joinColumn = keyColumns.next(); + assertEquals( "customer_id", joinColumn.getName(), "Join column name should be overridden to 'customer_id'" ); + + // Verify indexes are applied + java.util.Iterator indexIterator = collectionTable.getIndexes().values().iterator(); + assertTrue( indexIterator.hasNext(), "Index should exist" ); + org.hibernate.mapping.Index index = indexIterator.next(); + assertNotNull( index.getName(), "Index name should not be null" ); + assertEquals( 1, index.getColumnSpan(), "Index should have one column" ); + Column indexColumn = index.getColumns().iterator().next(); + // The column name might be auto-generated, so we just verify the index exists + assertNotNull( indexColumn.getName(), "Index column should have a name" ); + } + + @AfterEach + public void cleanupTestData(SessionFactoryScope scope) { + scope.inTransaction( session -> { + session.createQuery( "delete from Person" ).executeUpdate(); + session.createQuery( "delete from Company" ).executeUpdate(); + session.createQuery( "delete from Organization" ).executeUpdate(); + session.createQuery( "delete from Customer" ).executeUpdate(); + } ); + } + + @Embeddable + public static class Address { + private String street; + private String city; + + @ElementCollection + @CollectionTable(name = "default_phones") + private List phones = new ArrayList<>(); + + public String getStreet() { + return street; + } + + public void setStreet(String street) { + this.street = street; + } + + public String getCity() { + return city; + } + + public void setCity(String city) { + this.city = city; + } + + public List getPhones() { + return phones; + } + + public void setPhones(List phones) { + this.phones = phones; + } + } + + @Embeddable + public static class ContactInfo { + @ElementCollection + @CollectionTable(name = "default_emails") + private List emails = new ArrayList<>(); + + public List getEmails() { + return emails; + } + + public void setEmails(List emails) { + this.emails = emails; + } + } + + @Entity(name = "Person") + @Table(name = "PERSON") + public static class Person { + @Id + @GeneratedValue + private Long id; + + private String name; + + @Embedded + @CollectionTableOverride(name = "phones", collectionTable = @CollectionTable(name = "person_phones")) + private Address address; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Address getAddress() { + return address; + } + + public void setAddress(Address address) { + this.address = address; + } + } + + @Entity(name = "Company") + @Table(name = "COMPANY") + public static class Company { + @Id + @GeneratedValue + private Long id; + + private String name; + + @Embedded + @CollectionTableOverride(name = "phones", collectionTable = @CollectionTable(name = "company_phones")) + private Address address; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Address getAddress() { + return address; + } + + public void setAddress(Address address) { + this.address = address; + } + } + + @Entity(name = "Organization") + @Table(name = "ORGANIZATION") + public static class Organization { + @Id + @GeneratedValue + private Long id; + + private String name; + + @Embedded + @CollectionTableOverrides({ + @CollectionTableOverride(name = "emails", collectionTable = @CollectionTable(name = "organization_emails")) + }) + private ContactInfo contactInfo; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public ContactInfo getContactInfo() { + return contactInfo; + } + + public void setContactInfo(ContactInfo contactInfo) { + this.contactInfo = contactInfo; + } + } + + @Embeddable + public static class Contact { + @ElementCollection + @CollectionTable(name = "default_tags") + private List tags = new ArrayList<>(); + + public List getTags() { + return tags; + } + + public void setTags(List tags) { + this.tags = tags; + } + } + + @Entity(name = "Customer") + @Table(name = "CUSTOMER") + public static class Customer { + @Id + @GeneratedValue + private Long id; + + private String name; + + @Embedded + @CollectionTableOverride( + name = "tags", + collectionTable = @CollectionTable( + name = "customer_tags", + joinColumns = @JoinColumn(name = "customer_id"), + indexes = @Index(name = "idx_tag_value", columnList = "tag_value") + ) + ) + private Contact contact; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Contact getContact() { + return contact; + } + + public void setContact(Contact contact) { + this.contact = contact; + } + } +}