1919import org .hibernate .FetchMode ;
2020import org .hibernate .MappingException ;
2121import org .hibernate .annotations .*;
22+ import org .hibernate .annotations .CollectionTableOverride ;
23+ import org .hibernate .annotations .CollectionTableOverrides ;
2224import org .hibernate .boot .model .IdentifierGeneratorDefinition ;
2325import org .hibernate .boot .models .AnnotationPlacementException ;
2426import org .hibernate .boot .models .JpaAnnotations ;
103105import static org .hibernate .boot .model .internal .BinderHelper .extractFromPackage ;
104106import static org .hibernate .boot .model .internal .BinderHelper .getFetchMode ;
105107import static org .hibernate .boot .model .internal .BinderHelper .getPath ;
108+ import static org .hibernate .boot .model .internal .BinderHelper .getRelativePath ;
106109import static org .hibernate .boot .model .internal .BinderHelper .isDefault ;
107110import static org .hibernate .boot .model .internal .BinderHelper .isPrimitive ;
108111import static org .hibernate .boot .model .internal .DialectOverridesAnnotationHelper .getOverridableAnnotation ;
@@ -620,6 +623,14 @@ private static void bindJoinedTableAssociation(
620623 final var assocTable = propertyHolder .getJoinTable ( property );
621624 final var collectionTable = property .getDirectAnnotationUsage ( CollectionTable .class );
622625
626+ // Check for @CollectionTableOverride annotation
627+ final var collectionTableOverride = findCollectionTableOverride (
628+ propertyHolder ,
629+ property ,
630+ inferredData ,
631+ buildingContext
632+ );
633+
623634 final JoinColumn [] annJoins ;
624635 final JoinColumn [] annInverseJoins ;
625636 if ( assocTable != null || collectionTable != null ) {
@@ -636,7 +647,10 @@ private static void bindJoinedTableAssociation(
636647 if ( collectionTable != null ) {
637648 catalog = collectionTable .catalog ();
638649 schema = collectionTable .schema ();
639- tableName = collectionTable .name ();
650+ // Use overridden table name if @CollectionTableOverride is present
651+ tableName = collectionTableOverride != null
652+ ? collectionTableOverride .table ()
653+ : collectionTable .name ();
640654 uniqueConstraints = collectionTable .uniqueConstraints ();
641655 joins = collectionTable .joinColumns ();
642656 inverseJoins = null ;
@@ -2805,4 +2819,149 @@ else if ( isManyToAny ) {
28052819 }
28062820 }
28072821 }
2822+
2823+ /**
2824+ * Finds a @CollectionTableOverride annotation for the given collection property.
2825+ *
2826+ * @param propertyHolder The PropertyHolder containing the property
2827+ * @param property The collection property MemberDetails
2828+ * @param inferredData The PropertyData for the property
2829+ * @param buildingContext The MetadataBuildingContext
2830+ * @return The CollectionTableOverride if found, null otherwise
2831+ */
2832+ private static CollectionTableOverride findCollectionTableOverride (
2833+ PropertyHolder propertyHolder ,
2834+ MemberDetails property ,
2835+ PropertyData inferredData ,
2836+ MetadataBuildingContext buildingContext ) {
2837+
2838+ // Get the entity's PersistentClass
2839+ final PersistentClass persistentClass = propertyHolder .getPersistentClass ();
2840+ if (persistentClass == null ) {
2841+ return null ;
2842+ }
2843+
2844+ // Get the ClassDetails from the models context
2845+ final var modelsContext = buildingContext .getBootstrapContext ().getModelsContext ();
2846+ final ClassDetails entityClassDetails = modelsContext .getClassDetailsRegistry ()
2847+ .resolveClassDetails (persistentClass .getClassName ());
2848+
2849+ if (entityClassDetails == null ) {
2850+ return null ;
2851+ }
2852+
2853+ // Get the full relative path (e.g., "address.phones" for embeddable collections)
2854+ final String fullPath = getRelativePath (propertyHolder , property .getName ());
2855+
2856+ // Extract the first part of the path (e.g., "address" from "address.phones")
2857+ // This is the field name in the entity class where @CollectionTableOverride is attached
2858+ final String fieldName ;
2859+ if (fullPath .contains ("." )) {
2860+ fieldName = fullPath .substring (0 , fullPath .indexOf ('.' ));
2861+ } else {
2862+ // Direct collection on entity, use property name directly
2863+ fieldName = property .getName ();
2864+ }
2865+
2866+ // Find the field property in the entity class
2867+ MemberDetails entityField = null ;
2868+ try {
2869+ // Try to get the property from the entity class
2870+ for (MemberDetails member : entityClassDetails .getFields ()) {
2871+ if (member .getName ().equals (fieldName )) {
2872+ entityField = member ;
2873+ break ;
2874+ }
2875+ }
2876+ if (entityField == null ) {
2877+ for (MemberDetails member : entityClassDetails .getMethods ()) {
2878+ if (member .getName ().equals (fieldName ) ||
2879+ (member .getName ().startsWith ("get" ) &&
2880+ member .getName ().substring (3 ).toLowerCase ().equals (fieldName .toLowerCase ()))) {
2881+ entityField = member ;
2882+ break ;
2883+ }
2884+ }
2885+ }
2886+ } catch (Exception e ) {
2887+ // Ignore and fall back to class-level annotation
2888+ }
2889+
2890+ // Check field-level annotations first (if field found)
2891+ if (entityField != null ) {
2892+ // Check for @CollectionTableOverrides (plural) on field
2893+ final var overridesAnnotation = entityField .getDirectAnnotationUsage (CollectionTableOverrides .class );
2894+ if (overridesAnnotation != null ) {
2895+ for (CollectionTableOverride override : overridesAnnotation .value ()) {
2896+ if (matchesPath (override .name (), propertyHolder , property , inferredData )) {
2897+ return override ;
2898+ }
2899+ }
2900+ }
2901+
2902+ // Check for @CollectionTableOverride (singular) on field
2903+ final var singleOverride = entityField .getDirectAnnotationUsage (CollectionTableOverride .class );
2904+ if (singleOverride != null && matchesPath (
2905+ singleOverride .name (),
2906+ propertyHolder ,
2907+ property ,
2908+ inferredData )) {
2909+ return singleOverride ;
2910+ }
2911+ }
2912+
2913+ // Fall back to class-level annotations
2914+ // Check for @CollectionTableOverrides (plural) on class
2915+ final var classOverridesAnnotation = entityClassDetails .getDirectAnnotationUsage (CollectionTableOverrides .class );
2916+ if (classOverridesAnnotation != null ) {
2917+ for (CollectionTableOverride override : classOverridesAnnotation .value ()) {
2918+ if (matchesPath (override .name (), propertyHolder , property , inferredData )) {
2919+ return override ;
2920+ }
2921+ }
2922+ }
2923+
2924+ // Check for @CollectionTableOverride (singular) on class
2925+ final var classSingleOverride = entityClassDetails .getDirectAnnotationUsage (CollectionTableOverride .class );
2926+ if (classSingleOverride != null && matchesPath (
2927+ classSingleOverride .name (),
2928+ propertyHolder ,
2929+ property ,
2930+ inferredData )) {
2931+ return classSingleOverride ;
2932+ }
2933+
2934+ return null ;
2935+ }
2936+
2937+ /**
2938+ * Checks if the override name matches the current collection property path.
2939+ *
2940+ * @param overrideName The name from @CollectionTableOverride
2941+ * @param propertyHolder The PropertyHolder containing the property
2942+ * @param property The collection property MemberDetails
2943+ * @param inferredData The PropertyData for the property
2944+ * @return true if the path matches
2945+ */
2946+ private static boolean matchesPath (
2947+ String overrideName ,
2948+ PropertyHolder propertyHolder ,
2949+ MemberDetails property ,
2950+ PropertyData inferredData ) {
2951+
2952+ // Get the full relative path (e.g., "address.phones" for embeddable collections)
2953+ final String fullPath = getRelativePath (propertyHolder , property .getName ());
2954+
2955+ // Direct match: override name matches the full path
2956+ if (overrideName .equals (fullPath )) {
2957+ return true ;
2958+ }
2959+
2960+ // Simple case: property name directly matches (for direct entity collections)
2961+ if (overrideName .equals (property .getName ())) {
2962+ return true ;
2963+ }
2964+
2965+ return false ;
2966+ }
28082967}
0 commit comments