Skip to content

HHH-19542 embeddable property order #10356

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@
*/
package org.hibernate.boot.model.internal;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;

import org.hibernate.AnnotationException;
import org.hibernate.boot.spi.MetadataBuildingContext;
import org.hibernate.boot.spi.PropertyData;
import org.hibernate.internal.util.StringHelper;
import org.hibernate.mapping.AggregateColumn;
import org.hibernate.mapping.Component;
import org.hibernate.mapping.Join;
Expand Down Expand Up @@ -68,6 +72,7 @@ public class ComponentPropertyHolder extends AbstractPropertyHolder {

private final String embeddedAttributeName;
private final Map<String,AttributeConversionInfo> attributeConversionInfoMap;
private final List<AnnotatedColumn> annotatedColumns;

public ComponentPropertyHolder(
Component component,
Expand All @@ -94,6 +99,12 @@ public ComponentPropertyHolder(
this.embeddedAttributeName = "";
this.attributeConversionInfoMap = processAttributeConversions( inferredData.getClassOrElementType() );
}

if ( parent instanceof ComponentPropertyHolder parentHolder ) {
this.annotatedColumns = parentHolder.annotatedColumns;
} else {
this.annotatedColumns = new ArrayList<>();
}
}

/**
Expand Down Expand Up @@ -221,26 +232,45 @@ public String getEntityName() {

@Override
public void addProperty(Property property, MemberDetails attributeMemberDetails, AnnotatedColumns columns, ClassDetails declaringClass) {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since we are not using columns at all i wonder if in term of design i should keep the logic in this method at all here ? let me know

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @itsmoonrack, this solution looks much better!

There's one last thing that makes me wonder: if the property.getValue().getTable() method returns the expected result (i.e. the @AttributeOverride's table for nested embeddables), why was it not the case in the AnnotatedColumns?

Could you please have a look at the binders logic and check if there might be something missing there? It would be great for consistency to understand why this is happening as well.

Copy link
Author

@itsmoonrack itsmoonrack Jun 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @mbellade, thanks !

That's indeed a good question. From what I have seen earlier the property is already constructed from those columns (higher in the hierarchy call). So in effect the property contains the same informations as the AnnotatedColumns.

What I found however, is that in the special event of an embeddable in an embeddable (our nested use case), that the columns were missing (columns = null), effectively skipping the whole logic of replacing a table. I have ran the full test suit to ensure non regression and it passed. So conceptually I am a bit bugged about this AnnotatedColumns versus Property, for me, after analysing, the later contains the former;

I thought about doing the following,

columns != null ? columns.getTable() : property.getValue().getTable()

But eventually rolled back as I did not find any value of doing so.

In both cases the full test suit passed.

Please correct me if I'm wrong or if I missed something obvious. We can put back the proposed line in this comment if we are more confident with it.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, you mean why the columns were empty in the first place ?

That's a good question, I will investigate this evening.

Copy link
Author

@itsmoonrack itsmoonrack Jun 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mbellade I tried to pass the columns to the EmbeddableBinder.createEmbeddedProperty to see what happen. Now the AnnotatedColumns are correctly passed, however the getTable() resolve to the component.getTable(), at this stage, the Person component getTable() returns UserEntity, while the property.getValue().getTable() returns Person. The later being correct.

So we cannot blatantly passes the columns to the PropertyBinder.

I am also reading that when we have an underlying value we use the makePropertyAndBind(), instead of the makePropertyValueAndBind() //used when the value is provided and the binding is done elsewhere

Copy link
Author

@itsmoonrack itsmoonrack Jun 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the full test suit passes, and i also make this work:

@Embeddable
public record Person(
	FullName fullName,
	@Column(table = "Person")
	Integer age) {

}

@Embeddable
public record FullName(
	@Column(table = "Person")
	String firstName,
	@Column(table = "Person")
	String lastName) {

}

Copy link
Author

@itsmoonrack itsmoonrack Jun 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

while this work, i feel i should find a way to pass columns directly, the solution proposed here is actually messy.

My idea is - just like the AnnotatedJoinColumns works - create a "virtual column parent AnnotatedColumns for the component. That way the checkPropertyConsistency will do its work

Copy link
Author

@itsmoonrack itsmoonrack Jun 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have changed the approach completely using my idea but improve to check on the fly and i am pretty happy with the results now.

	@Entity
	@Table(name = "UserEntity")
	@SecondaryTable(name = "Person")
	public static class UserEntity {
		@Id
		@GeneratedValue
		private Integer id;
		@AttributeOverride(name = "fullName.firstName", column = @Column(table = "Person"))
		@AttributeOverride(name = "fullName.lastName", column = @Column(table = "Person"))
		@AttributeOverride(name = "age", column = @Column(table = "Person"))
		private Person person;

		public UserEntity(
				final Person person) {
			this.person = person;
		}

		protected UserEntity() {

		}
	}

	@Embeddable
	public record Person(
			FullName fullName,
			Integer age) {

	}

	@Embeddable
	public record FullName(
			String firstName,
			String lastName) {

	}

Checked that every combination of mapping works, and when remove one override or one mapping for 1 or 2 fields, it fails as excepted.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gavinking, @mbellade, its in your hands now :)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@beikov you were right, I found out that the last version did not threw an exception when the columns did not matched. its now resolved

//Ejb3Column.checkPropertyConsistency( ); //already called earlier
//AnnotatedColumns.checkPropertyConsistency( ); //already called earlier
// Check table matches between the component and the columns
// if not, change the component table if no properties are set
// if a property is set already the core cannot support that
final Table table = property.getValue().getTable();
if ( !table.equals( getTable() ) ) {
if ( component.getPropertySpan() == 0 ) {
component.setTable( table );
}
else {
throw new AnnotationException(
"Embeddable class '" + component.getComponentClassName()
+ "' has properties mapped to two different tables"
+ " (all properties of the embeddable class must map to the same table)"
);
}
}
if ( columns != null ) {
final Table table = columns.getTable();
if ( !table.equals( getTable() ) ) {
if ( component.getPropertySpan() == 0 ) {
component.setTable( table );
}
else {
annotatedColumns.addAll( columns.getColumns() );
}
addProperty( property, attributeMemberDetails, declaringClass );
}

public void checkPropertyConsistency() {
if ( annotatedColumns.size() > 1 ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't you just use component.getColumns() instead?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no we cannot because the Column does not contains the secondaryTable information here

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Column.getValue().getTable() doesn't work?

Copy link
Author

@itsmoonrack itsmoonrack Jul 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@beikov ok I see what you are saying, here we construct this annotatedColumns as part of the component and its nested components so we have the full list of columns so we can compare: in our case firstName, lastName and age.

Now i am on the debugger and you are right, the component.getColumns() indeed contains the three columns and i can do the same check here without the need of this annotatedColumns. Let me try to make this work, including the negative test.

Copy link
Author

@itsmoonrack itsmoonrack Jul 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@beikov ok so I tried this idea, but the table is already set previously so they are all resolving to Person and the negative test fails because no exception is thrown here.

I know this annotatedColumns is not ideal but i could not find a better way here.

for ( int currentIndex = 1; currentIndex < annotatedColumns.size(); currentIndex++ ) {
final AnnotatedColumn current = annotatedColumns.get( currentIndex );
final AnnotatedColumn previous = annotatedColumns.get( currentIndex - 1 );
if ( !Objects.equals(
StringHelper.nullIfEmpty( current.getExplicitTableName() ),
StringHelper.nullIfEmpty( previous.getExplicitTableName() ) ) ) {
throw new AnnotationException(
"Embeddable class '" + component.getComponentClassName()
+ "' has properties mapped to two different tables"
+ " (all properties of the embeddable class must map to the same table)"
+ "' has properties mapped to two different tables"
+ " (all properties of the embeddable class must map to the same table)"
Comment on lines -237 to +269
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you mind reverting this whitespace change (yes, I know IntelliJ makes this change since a recent release, but I'm not keen on it, and I wish there was a way to revert to the old behavior to stay consistent with the existing code.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gavinking which whitespace ? in the EmbeddableBinder ?

those ?

binder.setEmbedded( isComponentEmbedded );
		binder.setHolder( propertyHolder );
		binder.setId( isId );
		binder.setEntityBinder( entityBinder );
		binder.setInheritanceStatePerClass( inheritanceStatePerClass );
		binder.setBuildingContext( context );

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, look at the quoted lines, which removed two tabs.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gavinking its actually an optical illusion, the AnnotatedException is threw twice here (i could not really find a better way of doing this) and git says there is a change in whitespace but if you pull the PR there is actually none.

For the squash, I create a new PR based on main correct ? And close this one ? Or there is a way to squash inside this branch ?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gavinking ok nevermind i force pushed the squash.

In github do i update with merge commit or update with rebase ?

Also should i create backport for Hibernate 7 and 6 ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Always use rebase. Let's first try to get this PR through the finish line. Afterwards we can do the backports.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@beikov thanks, I did that, do you think we can go ahead ? Or you want some changes ? Did I answered your concerns ?

);
}
}
}
addProperty( property, attributeMemberDetails, declaringClass );
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -352,15 +352,15 @@ private static PropertyBinder createEmbeddedProperty(
final PropertyBinder binder = new PropertyBinder();
binder.setDeclaringClass( inferredData.getDeclaringClass() );
binder.setName( inferredData.getPropertyName() );
binder.setValue(component);
binder.setValue( component );
binder.setMemberDetails( inferredData.getAttributeMember() );
binder.setAccessType( inferredData.getDefaultAccess() );
binder.setEmbedded(isComponentEmbedded);
binder.setHolder(propertyHolder);
binder.setId(isId);
binder.setEntityBinder(entityBinder);
binder.setInheritanceStatePerClass(inheritanceStatePerClass);
binder.setBuildingContext(context);
binder.setEmbedded( isComponentEmbedded );
binder.setHolder( propertyHolder );
binder.setId( isId );
binder.setEntityBinder( entityBinder );
binder.setInheritanceStatePerClass( inheritanceStatePerClass );
binder.setBuildingContext( context );
binder.makePropertyAndBind();
return binder;
}
Expand Down Expand Up @@ -455,7 +455,7 @@ static Component fillEmbeddable(
if ( LOG.isDebugEnabled() ) {
LOG.debug( "Binding component with path: " + subpath );
}
final PropertyHolder subholder = buildPropertyHolder(
final ComponentPropertyHolder subholder = buildPropertyHolder(
component,
subpath,
inferredData,
Expand Down Expand Up @@ -579,6 +579,8 @@ else if ( member.hasDirectAnnotationUsage( GeneratedValue.class ) ) {
}
}

subholder.checkPropertyConsistency();

if ( compositeUserType != null ) {
processCompositeUserType( component, compositeUserType );
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public static PropertyHolder buildPropertyHolder(
*
* @return PropertyHolder
*/
public static PropertyHolder buildPropertyHolder(
public static ComponentPropertyHolder buildPropertyHolder(
Component component,
String path,
PropertyData inferredData,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public class EmbeddableA {
@AttributeOverrides({@AttributeOverride(name = "embedAttrB" , column = @Column(table = "TableB"))})
private EmbeddableB embedB;

@Column(table = "TableB")
private String embedAttrA;

public EmbeddableB getEmbedB() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
import jakarta.persistence.Table;
import org.hibernate.boot.MetadataSources;
import org.hibernate.testing.orm.junit.JiraKey;
import org.hibernate.testing.orm.junit.NotImplementedYet;
import org.hibernate.testing.orm.junit.ServiceRegistry;
import org.hibernate.testing.orm.junit.ServiceRegistryScope;
import org.junit.jupiter.api.Test;
Expand All @@ -32,7 +31,6 @@
class NestedEmbeddedObjectWithASecondaryTableTest {

@Test
@NotImplementedYet
void testNestedEmbeddedAndSecondaryTables(ServiceRegistryScope registryScope) {
final MetadataSources metadataSources = new MetadataSources( registryScope.getRegistry() )
.addAnnotatedClasses( Book.class, Author.class, House.class );
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/*
* SPDX-License-Identifier: Apache-2.0
* Copyright Red Hat Inc. and Hibernate Authors
*/
package org.hibernate.orm.test.records;

import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.SecondaryTable;
import jakarta.persistence.Table;
import org.hibernate.AnnotationException;
import org.hibernate.boot.MetadataSources;
import org.hibernate.boot.registry.StandardServiceRegistry;
import org.hibernate.testing.orm.junit.DomainModel;
import org.hibernate.testing.orm.junit.JiraKey;
import org.hibernate.testing.orm.junit.ServiceRegistryScope;
import org.hibernate.testing.orm.junit.SessionFactory;
import org.hibernate.testing.orm.junit.SessionFactoryScope;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.fail;

@JiraKey("HHH-19542")
@DomainModel(annotatedClasses = {
RecordNestedEmbeddedWithASecondaryTableTest.UserEntity.class
})
@SessionFactory
class RecordNestedEmbeddedWithASecondaryTableTest {

private UserEntity user;

@BeforeAll
void prepare(SessionFactoryScope scope) {
scope.inTransaction( session -> {
Person person = new Person( new FullName( "Sylvain", "Lecoy" ), 38 );
user = new UserEntity( person );
session.persist( user );
} );
}

@Test
void test(SessionFactoryScope scope) {
scope.inTransaction(session -> {
UserEntity entity = session.find( UserEntity.class, user.id );
assertThat( entity ).isNotNull();
assertThat( entity.id ).isEqualTo( user.id );
assertThat( entity.person ).isNotNull();
assertThat( entity.person.age ).isEqualTo( 38 );
assertThat( entity.person.fullName.firstName ).isEqualTo( "Sylvain" );
assertThat( entity.person.fullName.lastName ).isEqualTo( "Lecoy" );
});
}

@Test
void test(ServiceRegistryScope scope) {
final StandardServiceRegistry registry = scope.getRegistry();
final MetadataSources sources = new MetadataSources( registry ).addAnnotatedClass( UserEntity1.class );

try {
sources.buildMetadata();
fail( "Expecting to fail" );
} catch (AnnotationException expected) {
assertThat( expected ).hasMessageContaining( "all properties of the embeddable class must map to the same table" );
}
}

@Entity
@Table(name = "UserEntity")
@SecondaryTable(name = "Person")
static class UserEntity {
@Id
@GeneratedValue
private Integer id;
private Person person;

public UserEntity(
final Person person) {
this.person = person;
}

protected UserEntity() {

}
}

@Embeddable
record Person(
FullName fullName,
@Column(table = "Person")
Integer age) {

}

@Embeddable
record FullName(
@Column(table = "Person")
String firstName,
@Column(table = "Person")
String lastName) {

}

@Entity
@Table(name = "UserEntity")
@SecondaryTable(name = "Person")
public static class UserEntity1 {
@Id
@GeneratedValue
private Integer id;
private Person1 person;

public UserEntity1(
final Person1 person) {
this.person = person;
}

protected UserEntity1() {

}
}

@Embeddable
public record Person1(
FullName1 fullName,
@Column(table = "Person")
Integer age) {

}

@Embeddable
public record FullName1(
@Column(table = "Person")
String firstName,
String lastName) {

}
}