Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

JPA entity with collection returns false for contains method on detached member

I have two JPA entity classes, Group and User

Group.java:

@Entity
@Table(name = "groups")
public class Group {

    @Id
    @GeneratedValue
    private int id;


    @ManyToMany
    @JoinTable(name = "groups_members", joinColumns = {
            @JoinColumn(name = "group_id", referencedColumnName = "id")
    }, inverseJoinColumns = {
            @JoinColumn(name = "user_id", referencedColumnName = "id")
    })
    private Collection<User> members;


    //getters/setters here

}

User.java:

@Entity
@Table(name = "users")
public class User {

    private int id;
    private String email;

    private Collection<Group> groups;

    public User() {}

    @Id
    @GeneratedValue
    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    @Column(name = "email", unique = true, nullable = false)
    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    @ManyToMany(fetch = FetchType.LAZY)
    @JoinTable(name = "groups_members", joinColumns = {
            @JoinColumn(name = "user_id")
    }, inverseJoinColumns = {@JoinColumn(name = "group_id")})
    public Collection<Group> getGroups() {
        return groups;
    }

    public void setGroups(Collection<Group> groups) {
        this.groups = groups;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof User)) return false;

        User user = (User) o;

        if (id != user.id) return false;
        return email.equals(user.email);
    }

    @Override
    public int hashCode() {
        int result = id;
        result = 31 * result + email.hashCode();
        return result;
    }
}

I tried to run the following snippet for a group with one member, where group is an entity just retrieved from JpaRepository and user is the member of that group and detached entity.

            Collection<User> members = group.getMembers();
            System.out.println(members.contains(user)); //false
            User user1 = members.iterator().next();
            System.out.println(user1.equals(user)); //true

After some debugging I found out that User.equals() was called during .contains() call, but the user in Hibernate collection had null fields, and thus .equals() evaluated to false.

So why it is so odd and what is the correct way of calling .contains() here?

like image 687
A. K. Avatar asked Oct 15 '25 19:10

A. K.


1 Answers

There's a couple of pieces to this puzzle. First, the fetch type for @ManyToMany associations is LAZY. So in your Group, the members field employs lazy loading. When lazy loading is used, Hibernate will use proxies for objects to only do the actual loading on accessing them. The actual collection is most likely some implementation of PersistentBag or PersistentCollection (forgot which, and the Hibernate javadocs seem inaccessible at the moment) which does some of the magic behind your back.

Now, you may wonder, when you're calling group.getMembers() shouldn't you then get the actual collection and be capable of using it without worrying about its implementation? Yes, but there's still a catch to the lazy loading. You see, the objects in the collection themselves are proxies, which initially only have their identifier loaded but not other properties. It's only on accessing such a property that the full object is initialized. This allows Hibernate to do some clever things:

  • It lets you check the size of the collection without having to load everything.
  • You can fetch only the identifiers (primary keys) of objects in the collection without the whole object being queried. The foreign keys are normally quite efficient to get when the parent object is being loaded with a join, and are used for a lot of things, such as checking whether an object is known in the persistence context.
  • You can get a specific object in the collection and have that initialized without every object in the collection needing to be initialized. Although this can result in many queries (the "N+1 problem") it can also make sure that not more data is sent over the network and loaded in memory than needed.

The next piece of the puzzle is that in your User class, you've used property access instead of field access. Your annotations are on the getters instead of the fields (like in Group). Maybe this has changed, but in at least some older versions of Hibernate, getting only the identifier via the proxies only worked with property access, because the proxy operates by substituting the methods, but can't circumvent field access.

So what happens is that in your equals method this part probably works fine: if (id != user.id) return false;

... but this doesn't: return email.equals(user.email);

You might as well have gotten a nullpointer exception there it didn't so happen that the contains method calls the equal on the provided object (your filled-in, detached user) with its collection entries as argument. The other way around could've cause a nullpointer. This is the final piece of the puzzle. You're using the fields directly here instead of using the getter for email, so you're not forcing Hibernate to load the data.

So here's some experiments you may perform. I'd try them myself, but it's getting late here and I must be going. Let me know what the outcome is, to see if my answer is correct and make it more useful for later visitors.

  • Change the property access in User to field access by putting the JPA/Hibernate annotations on the fields. Unless this has changed in recent versions, it should cause all properties of the User instances to be initialized when accessing the collection, rather than just proxies with the identifiers filled in. This might not work anymore, however.
  • Try getting that user1 instance from the collection first via the iterator. Seeing how you didn't do explicit property access, I strongly suspect that getting an iterator on the collection and fetching an element from it also forces initialization of that element. The Java implementation of contains for a List, for example, calls indexOf which just goes through the internal array, but doesn't call any methods like get which could trigger initialization.
  • Try using the getters in your equals method instead of direct field access. I've found that when dealing with JPA, it's better to always use the getters and setters, even for methods in the class itself, to avoid issues like these. As an actual solution, this is probably the most robust way. Do make sure to handle cases where email may be null, though.

JPA does some crazy magic behind your back and tries to make it mostly invisible for you, but sometimes it comes back to bite you. I'd dig a bit more in the Hibernate source code and run some experiments if I had the time, but I might revisit this later to verify the above claims.

like image 52
G_H Avatar answered Oct 18 '25 10:10

G_H