Hibernate @OneToMany removing from Set doesn't trigger constraint validation on update of parent entity

I have the following spring app

package ru.salamon.hiber.model;

import javax.persistence.*;
import javax.validation.constraints.Size;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

@Entity
public class Parent {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "parent_ids")
    @SequenceGenerator(name = "parent_ids", sequenceName = "parent_id_seq", allocationSize = 1)
    private Long id;

    @Size(min = 1)
    @OneToMany(mappedBy = "parent", orphanRemoval = true, fetch = FetchType.EAGER, cascade = CascadeType.ALL)
    private Set<Child> children = new HashSet<>();

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

    public Long getId() {
        return id;
    }

    @Size(min = 1)
    public Set<Child> getChildren() {
        return children;
    }

    public Parent setChildren(Set<Child> children) {
        this.children = children;
        return this;
    }

    public void removeChild(Child child) {
        if (child == null) return;
        children.remove(child);
        child.setParent(null);
    }

    public void addChildren(Set<Child>children) {
        children.addAll(children);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        Parent parent = (Parent) o;

        if (!Objects.equals(id, parent.id)) return false;
        return Objects.equals(children, parent.children);
    }

    @Override
    public int hashCode() {
        int result = id != null ? id.hashCode() : 0;
        result = 31 * result + (children != null ? children.hashCode() : 0);
        return result;
    }

}
package ru.salamon.hiber.model;

import javax.persistence.*;
import javax.validation.constraints.NotNull;
import java.util.Objects;

@Entity
public class Child {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "child_ids")
    @SequenceGenerator(name = "child_ids_ids", sequenceName = "child_ids_id_seq", allocationSize = 1)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "parent_id")
    private Parent parent;

    public String getName() {
        return name;
    }

    public Child setName(String name) {
        this.name = name;
        return this;
    }

    @NotNull
    private String name;


    public Child setId(Long id) {
        this.id = id;
        return this;
    }


    public Long getId() {
        return id;
    }

    public Parent getParent() {
        return parent;
    }

    public Child setParent(Parent parent) {
        this.parent = parent;
        return this;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        Child child = (Child) o;

        if (!Objects.equals(id, child.id)) return false;
        if (parent != child.parent && parent.getId() != child.id) {
            return parent.getId().equals(child.id);
        }

        return name.equals(child.name);
    }

     //Due to natural key is absent
    @Override
    public int hashCode() {
        return 1001;
    }
}
    @GetMapping("/store")
    @Transactional
    public void store() {
        var p = new Parent();
        var c = new HashSet<Child>();
        c.add(new Child().setParent(p).setName("777"));
        childRepository.saveAll(c);
        parentRepo.save(p);
    }

    @GetMapping("/update")
    @Transactional
    public void update() {
        var p = parentRepo.findAllParentsWithChildren().get(0);
        var c = p.getChildren().iterator().next();
        p.removeChildren(c);
        parentRepo.save(p);
    }

At first I am invoking store method to initialize data. When I’m invoking update expected that after parentRepo.save(p) validation exception will be raised due to I have constraint @Size(min = 1) on Set<Child> children
I’ve tried to debug DefaultFlushEntityEventListener::dirtyCheck, I see that loadedState initialized with empty set. That strange due to as I read about dirty checking, it should to keep the previous state of the object

Did you check if the PersistentSet#operationQueue is also empty?

Hello! Seems that I found the problem. This happens due to what type hibernate will inject into Set field. When you perform findAll - Set field will be injected as PersistenSet type by default. This is shared reference between yours business logic side and dirty check mechanism of hibernate. So when you

p.removeChildren(c);
parentRepo.save(p);

That operation triggers DefaultFlushEntityEventListener::dirtyCheck. But due to final Object[] loadedState = entry.getLoadedState(); keeps shared reference, for hibernate it looks like entity wasn’t change state of the entity. That’s why no validation error was occurred(but child entity will be deleted as expected by orphan removal)
PS
If you will change any field of the entity validation will be performed as expected

@beikov How do you think this is expected behavior or this is a bug?

That is expected behavior. Altering an unowned collection (one which uses mappedBy) will never cause the entity to be considered dirty. I think that owned collections behave the same way, but not 100% sure.

@beikov But the main question is - Why does this working fine(I mean validation) when you persist it for the first time ? If this unknown collection the behavior should be the same as the update

Well, during persist the Parent entity is “new” and Hibernate ORM just passes that new entity to the validation library before emitting the insert statement. If you update the entity though, Hibernate ORM will only pass objects to the validation library if they are considered dirty. If you want validation to happen even when you just change a collection, you will have to mutate the entity somehow.

You can for example nullify some field (e.g. lastUpdatedTimestamp) which will be updated automatically in a pre update listener.

Thank you for answer! That is exactly what we did in our project