CrudRepository.save() игнорирует FetchType.EAGER

Для работы с БД в приложении используется Spring Data. Имеется две сущности. Film:

//аннотации опущены
public class Film {

    // другие аннотации опущены
    @ManyToMany(fetch = FetchType.EAGER)
    private List<User> users;
}

И User:

//аннотации опущены
public class User {

    // другие аннотации опущены
    @ManyToMany(fetch = FetchType.LAZY)
    private List<Film> films;
}

Они связаны отношением ManyToMany. Причём для Film список User загружается EAGER. Теперь я хочу сохранить новый объект Film в репозиторий:

@Transactional(readOnly = true)
public interface CrudFilmRepository extends JpaRepository<Film, Integer> {
}

При вызове CrudFilmRepository#save мне возвращается новосозданный Film, причём ещё и с id. Здесь всё работает без нареканий.


Теперь я создал новый класс FilmTo. Он будет содержать в себе не List<User> users, а List<Integer> userIds, благодаря чему с представления надо будет передавать меньше данных. Разумеется, я не могу передать в CrudFilmRepository#save объект FilmTo, потому что репозиторий не поймёт, что это за объект. Поэтому я делаю промежуточный метод, который получает из объекта FilmTo объект Film:

private Film getFromTo(FilmTo film) {
    return new Film(
        Arrays.stream(film.getUserIds())
                .map(crudUserRepository::getOne)
                .collect(Collectors.toList()));
}

План действий такой:

  1. В сервис приходит объект FilmTo, содержащий не список пользователей, а список их айдишников.
  2. В методе getFromTo() из объекта FilmTo получаем Film. Важно! Нам надо из списка айдишников получить список пользователей. Для лучшего перфоманса я вытаскиваю из crudUserRepository не целые объекты User, а ссылки на них (метод getOne).
  3. Сохраняем полученный объект Film в репозиторий.

ПРОБЛЕМА:

При передаче в CrudFilmRepository#save объекта Film, полученного из FilmTo при помощи метода getFromTo(), возвращается объект Film с новым id, но при этом список List<User> users в нём при итерации возвращает LazyInitializationException, то есть этот список инициализируется прокси-объектами, а должен инициализироваться реальными объектами, потому что поле users в Film отмечен как EAGER.

Почему при передаче в CrudFilmRepository#save объекта с полями, инициализированными при помощи getOne(), возвращается объект с полями, так же проинициализированными не реальными объектами, а прокси-обёртками? Почему EAGER в данном случае не имеет силы и как это исправить?


Ответы (2 шт):

Автор решения: Алексей Осецкий

Вам необходимо реализовать интерфейс Persistable

Судя по этой статье

Точка принятия решения находится в классе

org.springframework.data.jdbc.core.JdbcAggregateTemplate
метод: public <T> T save(T instance)

вот фрагмент этого метода:

Function<T, MutableAggregateChange<T>> changeCreator = 
persistentEntity.isNew(instance) ? this::createInsertChange : this::createUpdateChange;

return store(instance, changeCreator, persistentEntity);

Вся суть находится в persistentEntity.

Заглянем в persistentEntity.isNew, там увидим:

public boolean isNew(Object bean) {
        this.verifyBeanType(bean);
        return ((IsNewStrategy)this.isNewStrategy.get()).isNew(bean);
}

Получается, что есть некая «стратегия», которая и определяет, новый объект или нет. Вопрос сводится к изучению, что это такое.

Обратимся к определению стратегии

Это конструктор класса: org.springframework.data.mapping.model.

Вот фрагмент:

this.isNewStrategy = Lazy.of(() -> 
Persistable.class.isAssignableFrom(information.getType()) 
        ? PersistableIsNewStrategy.INSTANCE
        : getFallbackIsNewStrategy());

Что тут происходит?

Если сохраняемый объект имплементирует интерфейс Persistable, то используется соответствующая стратегия (об этом мы еще поговорим), если нет, то работает логика, представленная в методе getFallbackIsNewStrategy. Давайте на этом моменте остановимся подробнее.

Немного пройдем по цепочке вызовов getFallbackIsNewStrategy и окажемся в методе

public boolean isNew(Object entity) класса PersistentEntityIsNewStrategy.

В этом методе и определяется – новый объект или нет. Для этого берется значение поля, отмеченного аннотацией Id.

  • Если значение null – значит объект новый.
  • Если не null, то возможно варианты.
  • Если это не примитивный тип данных, значит все понятно – это объект не новый.
  • Если тип данных примитивный, то он по определению не может быть null, и выполняется проверка на 0.

Еще раз сформулируем работу этой стратегии.

  • Берем значение поля Id
  • Если null – объект новый
  • Иначе, если не примитивный тип, значит – объект не новый.
  • Если примитивный тип и значение 0, то новый, иначе не новый.

Получается, все довольно просто и логично. У нового объекта идентификатора нет, поэтому он и новый. Значение ключевого поля формируется на стороне базы данных и возвращается вместе с сохраненным объектом.

А что делать, если по каким-то причинам Id-шник надо сформировать в java-коде и передать в базу данных. В этом случае даже в новом объекте поле идентификатора будет заполнено и описанная выше стратегия уже не сработает.

Что делать в этой ситуации?

Использовать вторую стратегию, основанную на интерфейсе Persistable.

У этого интерфейса есть два метода getId и isNew.

Чтобы воспользоваться этим механизмом надо у объекта, который мы хотим сохранить, имплементировать этот интерфейс и самостоятельно определить, когда объект новый, а когда нет.

При выполнении кода:

this.isNewStrategy = Lazy.of(() -> 
Persistable.class.isAssignableFrom(information.getType()) 
        ? PersistableIsNewStrategy.INSTANCE
        : getFallbackIsNewStrategy());

Spring определит, что сохраняемый объект имплементирует интерфейс Persistable и вызовет метод isNew.

→ Ссылка
Автор решения: Zhenyria

Я получил верный ответ на en.so, причём от разработчика Hibernate (да, я проверил, это вроде бы правда). Вот как он звучит:

save() просто делегирует EntityManager.persist или EntityManager.merge и возвращает объект, возвращенный EntityManager.merge или переданный вами. Здесь FetchType не играет роли. Если вы хотите, чтобы ассоциация загружалась, вы можете использовать refresh или findOne.

И да, я провёл тщательный дебаг, чтобы выяснить, что происходит в методе JpaRepository#save(). Лично для меня это не то что было откровением, но дало некоторое представление.

Когда мы создаём репозиторий, используя Spring DataJPA, то в нём нет никаких методов:

@Transactional(readOnly = true)
public interface CrudFilmRepository extends JpaRepository<Film, Integer> {
}

то есть Spring должен сам инициализировать его подходящим классом. И в моём случае для этого использовался класс SimpleJpaRepository.

вызов CrudFilmRepository#save по сути является вызовом SimpleJpaRepository#save, который выглядит так:

@Transactional
@Override
public <S extends T> S save(S entity) {

    Assert.notNull(entity, "Entity must not be null.");

    if (entityInformation.isNew(entity)) {
        em.persist(entity);
        return entity;
    } else {
        return em.merge(entity);
    }
}

Ну и всё в принципе становится более-менее понятно. К моему огромному сожалению я так и не смог найти способ посмотреть, как выглядит persist() под капотом. Но как минимум persist() декларируется просто как метод, который, насколько я понимаю, загружает сущность в контекст персистентности. Нигде не декларируется, что этот метод должен инициализировать прокси в полях сохраняемого объекта реальными объектами. Для этого может служить refresh().

Ну и вроде бы получается, что FetchType.EAGER относится только к методам получения объекта из базы, но не к методам сохранения, поэтому он и игнорировался. Единственное, что я ещё хочу - это посмотреть на persist() под капотом.


Вообще странно, что мне никто не указал на основополагающую проблему в моей архитектуре, которую я сам заметил не так давно и которая является причиной проблемы, которую я пытался решить в этом вопросе.

Для сохранения нового объекта Film, я заполнил его поле List<User>users ссылками, полученными при помощи getOne(). Зачем я так сделал? Чтобы сэкономить обращения к базе, потому что getOne() не обращается к базе. Но если я ожидал, что FetchType.EAGER сработает и метод save() вернёт мне Film с нормально пронициализированными полями, то это всё равно бы вызвало обращение к базе данных!

В таком случае было бы минимум два запроса:

  1. Сохранение Film в базу данных.
  2. Загрузка содержимого List<User>users из базы данных.

И поэтому, если для меня имеет значение, что будет возвращать CrudFilmRepository#save, то мне не следует использовать для инициализации getOne(). Я в любом случае не смогу избежать лишнего обращения к базе, если буду передавать с фронта FilmTo, а не Film.

→ Ссылка