Ошибка unfetched attribute при сохранении сущности

Про Демо проект

entity-states-demo.zip (904,8 КБ)

В этом демо проекте следующая структура данных

В проекте часть сущностей нужно создавать через инспектор (лень было делать отдельные экраны), и сделаны отдельно экраны именно для воспроизведения ошибки (ExamBrowse, ExamEdit).
В целом необходимый набор данных, с которым ошибку можно воспроизвести уже добавлен в БД (hsqldb) и если повторить описанные ниже действия ошибка должна воспроизвестись.

Примечание: проект настроен на наши локальные репозитории, нужно перенастроить для сборки.

Про ошибку

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

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

В демо проекте ошибка воспроизводится в таком экране (в нём для тестирования сделаны onPreCommit и onPostCommit):

@UiController("esd_Exam.edit")
@UiDescriptor("exam-edit.xml")
@EditedEntityContainer("examDc")
public class ExamEdit extends StandardEditor<Exam> {
    private static final Logger logger = LoggerFactory.getLogger(ExamEdit.class);

    @Subscribe(target = Target.DATA_CONTEXT)
    public void onPreCommit(final DataContext.PreCommitEvent event) {
        for (Object object : event.getSource().getModified()) {
            if (object instanceof Exam) {
                Exam exam = (Exam) object;
                logger.info(String.format("---PreCommit starts. Exam: %s---", exam.getId()));
                logger.info(String.format("exam(%s).position.shortName: %s", exam.getId(), exam.getPosition() == null ? null : exam.getPosition().getShortName()));
                for (Displacement displacement : exam.getEmployee().getDisplacements()) {
                    Object longName = EntityValues.getValue(displacement.getPosition(), "longName");
                    logger.info(String.format("displacement(%s).position.longName: %s", displacement.getId(), longName));
                }
                logger.info(String.format("---PreCommit ends. Exam: %s---", exam.getId()));
            }
        }
    }

    @Subscribe(target = Target.DATA_CONTEXT)
    public void onPostCommit(final DataContext.PostCommitEvent event) {
        Exam exam = event.getCommittedInstances().get(getEditedEntity());
        logger.info(String.format("---PostCommit starts. Exam: %s---", exam.getId()));
        logger.info(String.format("exam(%s).position.shortName: %s", exam.getId(), exam.getPosition() == null ? null : exam.getPosition().getShortName()));
        for (Displacement displacement : exam.getEmployee().getDisplacements()) {
            Object longName = EntityValues.getValue(displacement.getPosition(), "longName");
            logger.info(String.format("displacement(%s).position.longName: %s", displacement.getId(), longName));
        }
        logger.info(String.format("---PostCommit ends. Exam: %s---", exam.getId()));
    }

}

Для сущности в экране определён такой фетч-план (для сохранения новой сущности он как бы и не важен, а важно с каким планом были загружены ассоциации, но для редактируемой он получается такой):

<instance id="examDc"
                  class="com.company.esd.entity.Exam">
    <fetchPlan extends="_base">
        <property name="employee" fetchPlan="_base">
            <property name="displacements" fetchPlan="_base">
                <property name="employee"/>
                <property name="position">
                    <property name="longName"/>
                </property>
            </property>
        </property>
        <property name="position">
            <property name="shortName"/>
        </property>
    </fetchPlan>
    <loader/>
</instance>

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

Все дальнейшие действия выполняются в экранах ExamBrowse и ExamEdit.

Эксперимент №1. Создаём новую сущность (экзамен):\

  • Exam.employee: Иванов
  • Exam.position: Pos #1
  • Exam.result: не важен, чисто для наглядности. На воспроизводимость ошибки не влияет.

При сохранении в консоли видно, что перед сохранением longName есть, а после он уже он unfetched.

2026-01-22 16:41:00,420 INFO  [http-nio-8080-exec-1] c.c.e.s.e.ExamEdit: ---PreCommit starts. Exam: bc3fe34a-54b2-b159-1c83-3911d6ff2754---
2026-01-22 16:41:00,420 INFO  [http-nio-8080-exec-1] c.c.e.s.e.ExamEdit: exam(bc3fe34a-54b2-b159-1c83-3911d6ff2754).position.shortName: Pos #1
2026-01-22 16:41:00,420 INFO  [http-nio-8080-exec-1] c.c.e.s.e.ExamEdit: displacement(90ebcd88-84a7-ec70-001a-1a727e645d9e).position.longName: Position #1
2026-01-22 16:41:00,420 INFO  [http-nio-8080-exec-1] c.c.e.s.e.ExamEdit: displacement(bc1461a2-5366-08d3-d3d8-7fd5b48a7bbe).position.longName: Position #1
2026-01-22 16:41:00,420 INFO  [http-nio-8080-exec-1] c.c.e.s.e.ExamEdit: ---PreCommit ends. Exam: bc3fe34a-54b2-b159-1c83-3911d6ff2754---
2026-01-22 16:41:00,480 INFO  [http-nio-8080-exec-1] c.c.e.s.e.ExamEdit: ---PostCommit starts. Exam: bc3fe34a-54b2-b159-1c83-3911d6ff2754---
2026-01-22 16:41:00,480 INFO  [http-nio-8080-exec-1] c.c.e.s.e.ExamEdit: exam(bc3fe34a-54b2-b159-1c83-3911d6ff2754).position.shortName: Pos #1
2026-01-22 16:41:00,491 ERROR [http-nio-8080-exec-1] i.j.u.e.DefaultExceptionHandler: Unhandled exception

И кусок stacktrace:

Caused by: java.lang.IllegalStateException: Cannot get unfetched attribute [longName] from detached object com.company.esd.entity.Position-e4385eb9-88bc-4285-8d27-351256ef314f [detached].
	at org.eclipse.persistence.internal.queries.EntityFetchGroup.onUnfetchedAttribute(EntityFetchGroup.java:100)
	at io.jmix.eclipselink.impl.JmixEntityFetchGroup.onUnfetchedAttribute(JmixEntityFetchGroup.java:78)
	at org.eclipse.persistence.internal.jpa.EntityManagerImpl.processUnfetchedAttribute(EntityManagerImpl.java:3027)
	at com.company.esd.entity.Position._persistence_checkFetched(Position.java)
	at com.company.esd.entity.Position._persistence_get_longName(Position.java)
	at com.company.esd.entity.Position.getLongName(Position.java:59)
	at io.jmix.core.entity.BaseEntityEntry.getAttributeValue(BaseEntityEntry.java:85)
	at io.jmix.core.entity.EntityValues.getValue(EntityValues.java:100)
	at com.company.esd.screen.exam.ExamEdit.onPostCommit(ExamEdit.java:39)
	at io.jmix.core.common.event.EventHub.publish(EventHub.java:170)

Эксперимент №2. Создаём новую сущность (экзамен):\

  • Exam.employee: Петров
  • Exam.position: Pos #2
  • Exam.result: не важен, чисто для наглядности. На воспроизводимость ошибки не влияет.

Тут ошибки нет:

2026-01-22 16:41:48,471 INFO  [http-nio-8080-exec-8] c.c.e.s.e.ExamEdit: ---PreCommit starts. Exam: 93de3772-b2cf-da26-14e4-086ab64286ce---
2026-01-22 16:41:48,472 INFO  [http-nio-8080-exec-8] c.c.e.s.e.ExamEdit: exam(93de3772-b2cf-da26-14e4-086ab64286ce).position.shortName: Pos #2
2026-01-22 16:41:48,473 INFO  [http-nio-8080-exec-8] c.c.e.s.e.ExamEdit: displacement(bf729848-d891-94fa-20c5-a13e274c4f36).position.longName: Position #2
2026-01-22 16:41:48,474 INFO  [http-nio-8080-exec-8] c.c.e.s.e.ExamEdit: displacement(c2092ef6-708a-2a70-2338-92a24ee8a656).position.longName: Position #1
2026-01-22 16:41:48,474 INFO  [http-nio-8080-exec-8] c.c.e.s.e.ExamEdit: ---PreCommit ends. Exam: 93de3772-b2cf-da26-14e4-086ab64286ce---
2026-01-22 16:41:48,511 INFO  [http-nio-8080-exec-8] c.c.e.s.e.ExamEdit: ---PostCommit starts. Exam: 93de3772-b2cf-da26-14e4-086ab64286ce---
2026-01-22 16:41:48,511 INFO  [http-nio-8080-exec-8] c.c.e.s.e.ExamEdit: exam(93de3772-b2cf-da26-14e4-086ab64286ce).position.shortName: Pos #2
2026-01-22 16:41:48,511 INFO  [http-nio-8080-exec-8] c.c.e.s.e.ExamEdit: displacement(bf729848-d891-94fa-20c5-a13e274c4f36).position.longName: Position #2
2026-01-22 16:41:48,511 INFO  [http-nio-8080-exec-8] c.c.e.s.e.ExamEdit: displacement(c2092ef6-708a-2a70-2338-92a24ee8a656).position.longName: Position #1
2026-01-22 16:41:48,511 INFO  [http-nio-8080-exec-8] c.c.e.s.e.ExamEdit: ---PostCommit ends. Exam: 93de3772-b2cf-da26-14e4-086ab64286ce---

Эксперимент №3. Создаём новую сущность (экзамен):\

  • Exam.employee: Петров
  • Exam.position: Pos #1
  • Exam.result: не важен, чисто для наглядности. На воспроизводимость ошибки не влияет.

А для такого набора данных снова ошибка:

2026-01-22 16:42:09,946 INFO  [http-nio-8080-exec-7] c.c.e.s.e.ExamEdit: ---PreCommit starts. Exam: 0e3939b2-e6dd-ef23-c22d-19dc844b87bd---
2026-01-22 16:42:09,946 INFO  [http-nio-8080-exec-7] c.c.e.s.e.ExamEdit: exam(0e3939b2-e6dd-ef23-c22d-19dc844b87bd).position.shortName: Pos #1
2026-01-22 16:42:09,947 INFO  [http-nio-8080-exec-7] c.c.e.s.e.ExamEdit: displacement(bf729848-d891-94fa-20c5-a13e274c4f36).position.longName: Position #2
2026-01-22 16:42:09,948 INFO  [http-nio-8080-exec-7] c.c.e.s.e.ExamEdit: displacement(c2092ef6-708a-2a70-2338-92a24ee8a656).position.longName: Position #1
2026-01-22 16:42:09,948 INFO  [http-nio-8080-exec-7] c.c.e.s.e.ExamEdit: ---PreCommit ends. Exam: 0e3939b2-e6dd-ef23-c22d-19dc844b87bd---
2026-01-22 16:42:09,991 INFO  [http-nio-8080-exec-7] c.c.e.s.e.ExamEdit: ---PostCommit starts. Exam: 0e3939b2-e6dd-ef23-c22d-19dc844b87bd---
2026-01-22 16:42:09,991 INFO  [http-nio-8080-exec-7] c.c.e.s.e.ExamEdit: exam(0e3939b2-e6dd-ef23-c22d-19dc844b87bd).position.shortName: Pos #1
2026-01-22 16:42:09,992 ERROR [http-nio-8080-exec-7] i.j.u.e.DefaultExceptionHandler: Unhandled exception

Примечание: далее в процессе разбора ошибки мне показалось что она так же может возникать или не возникать по ряду причин, которые разработчик не может контролировать.
Например, наблюдал что при построении фетч-плана атрибуты в графе сущности могут перебираться в разном порядке, а это может влиять на то, возникнет ошибка или нет.
Поэтому выше приведённые примеры, могут не выдать ошибок.

Эксперимент №4. Создаём новую сущность (экзамен):\

  • Exam.employee: Иванов
  • Exam.position: оставляем пустым
  • Exam.result: не важен, чисто для наглядности. На воспроизводимость ошибки не влияет.

И тут тоже наблюдается ошибка. В отличие от экспериментов №1, №2, №3 в этом эксперименте на воспроизводимость ошибки не должны влиять случайные факторы.

2026-01-22 16:42:37,299 INFO  [http-nio-8080-exec-5] c.c.e.s.e.ExamEdit: ---PreCommit starts. Exam: 9052dbab-9ae7-d88d-94e8-42dea7cd91f1---
2026-01-22 16:42:37,300 INFO  [http-nio-8080-exec-5] c.c.e.s.e.ExamEdit: exam(9052dbab-9ae7-d88d-94e8-42dea7cd91f1).position.shortName: null
2026-01-22 16:42:37,300 INFO  [http-nio-8080-exec-5] c.c.e.s.e.ExamEdit: displacement(90ebcd88-84a7-ec70-001a-1a727e645d9e).position.longName: Position #1
2026-01-22 16:42:37,301 INFO  [http-nio-8080-exec-5] c.c.e.s.e.ExamEdit: displacement(bc1461a2-5366-08d3-d3d8-7fd5b48a7bbe).position.longName: Position #1
2026-01-22 16:42:37,301 INFO  [http-nio-8080-exec-5] c.c.e.s.e.ExamEdit: ---PreCommit ends. Exam: 9052dbab-9ae7-d88d-94e8-42dea7cd91f1---
2026-01-22 16:42:37,332 INFO  [http-nio-8080-exec-5] c.c.e.s.e.ExamEdit: ---PostCommit starts. Exam: 9052dbab-9ae7-d88d-94e8-42dea7cd91f1---
2026-01-22 16:42:37,333 INFO  [http-nio-8080-exec-5] c.c.e.s.e.ExamEdit: exam(9052dbab-9ae7-d88d-94e8-42dea7cd91f1).position.shortName: null
2026-01-22 16:42:37,333 ERROR [http-nio-8080-exec-5] i.j.u.e.DefaultExceptionHandler: Unhandled exception

Отладка

Далее я начал дебажить ошибку и ниже коротко к чему пришёл.

После сохранения сущности (если не отключено, а стандартными экшенами не отключено) происходит её загрузка из БД.

package io.jmix.core.datastore;

public abstract class AbstractDataStore implements DataStore {
    @Override
    public Set<?> save(SaveContext context) {
        //
        return context.isDiscardSaved() ? Collections.emptySet() : loadAllAfterSave(context, savedEntities);
    }
}

Если дальше углубиться, то доберёмся до EntityStates#getCurrentFetchPlan().

Здесь видно, что в сущности, для которой необходимо построить фетч-план у position есть longName.

А после построения фетч-плана видим, что longName там нет.

И далее уже при загрузке из БД тоже longName нет.

А для сущности, для которой ошибка при сохранении не возникает, фетч-план строится такой:

В итоге всё это происходит из-за неправильной логики в рекурсивном методе EntityStates#recursivelyConstructCurrentFetchPlan.

Вот у нас есть граф сохраняемой сущности Exam, в экзамене Employee и список displacements, в котором два разных Displacement,
но в каждом из них один и тот же Position.

Exam(id=1)
|---Employee(id=1)
    |---displacements(List<Displacement>)
        |---Displacement(id=1)
        |   |---Position(id=1)
        |       |---longName
        |---Displacement(id=2)
            |---Position(id=1)
                |---longName

И вот эта сущность попадает в рекурсивный метод EntityStates#recursivelyConstructCurrentFetchPlan.

В результате выполнения в какой-то момент фетч-план построился вплоть до Displacement(id=1) и построение перешло к Displacement(id=2).

Начиная строить фетч-план функция доходит до атрибута position из сущности Displacement(id=2)
и в строке (1) заменяет уже построенный фетч-план (построен он был на основе сущности Position(id=1) из атрибута position сущности Displacement(id=1)) на новый, пустой.
Далее рекурсивно вызывается этот же метод (2) где первым делом проверяется была ли переданная сущность уже посещена (3).
А так как у нас Position(id=1) в обоих Displacement, то получается что да, была посещена (в Displacement(id=1)) и выполнение выходит из рекурсии (4).
Ну и в итоге получается что мы фетч-план для атрибута position сначала заменили на новый (пустой),
а потом не стали его строить, так как уже гуляли по этому экземпляру сущности (Position(id=1)).

И данная ошибка, конечно не возникает если граф сущности такой:

Exam(id=1)
|---Employee(id=1)
    |---displacements(List<Displacement>)
        |---Displacement(id=1)
        |   |---Position(id=1)
        |       |---longName
        |---Displacement(id=2)
            |---Position(id=2)
                |---longName

Но это ещё не вся проблема.

Теперь допустим у нас такой граф сущности Exam где у нас Position(id=1) есть и в самом Exam, и в Displacement.

Exam(id=1)
|---Position(id=1)
|   |---shortName
|---Employee(id=1)
    |---displacements(List<Displacement>)
        |---Displacement(id=1)
        |   |---Position(id=1)
        |       |---longName
        |---Displacement(id=2)
            |---Position(id=1)
                |---longName

Разница между ними в том, какие атрибуты загружены в каждой из них. И фактически, это будут два разных объекта.
Но так как при сравнении по equals в visited.contains(entity) сущности сравниваются по id,
то для одного из этих position, фетч-план не будет постоен, потому что будет считаться что по ней уже пробежали (но нет).

Решение, которое я подготовил, может и не идеальное, но работает у нас там, где наблюдались ошибки:

public class CustomEntityStates extends EntityStates {

    @Override
    protected void recursivelyConstructCurrentFetchPlan(@Nonnull Entity entity, @Nonnull FetchPlanBuilder builder, @Nonnull HashSet<Object> visited) {
        /*
         * Same entity can be contained in entity graph in different places, yet be loaded with different fetch plans.
         * In this case we should construct resulting fetch plan where those instances of entity would have right fetch plan.
         * In this cause, we use IdentityHashMap to hold very entity with its fetch plan.
         * Using HashMap (or HashSet) is wrong because entities are equal if their id equal, but two entities loaded with different fetch plans are different objects.
         */
        recursivelyConstructCurrentFetchPlan(entity, builder, new IdentityHashMap<>());
    }

    private void recursivelyConstructCurrentFetchPlan(@Nonnull Entity entity,
                                                      @Nonnull FetchPlanBuilder builder,
                                                      @Nonnull IdentityHashMap<Object, FetchPlan> visited)
    {
        if (visited.containsKey(entity)) {
            return;
        }
        // Holding visited entity, but without fetch plan (for now).
        visited.put(entity, null);

        // Using MetaClass of the fetchPlan helps in the case when the entity is an item of a collection, and the collection
        // can contain instances of different subclasses. So we don't want to add specific properties of subclasses
        // to the resulting fetch plan.
        MetaClass metaClass = metadata.getClass(builder.getEntityClass());

        for (MetaProperty property : metaClass.getProperties()) {
            if (!isLoaded(entity, property.getName()))
                continue;
            if (property.getRange().isClass()) {
                Object value = EntityValues.getValue(entity, property.getName());
                if (value != null) {
                    final Class<Object> propertyClass = property.getRange().asClass().getJavaClass();
                    final FetchPlanBuilder propertyBuilder = fetchPlans.builder(propertyClass);
                    // Construct builder by each entity
                    for (Object item : (value instanceof Collection ? (Collection<?>) value : Set.of(value))) {
                        // Get fetch plan and merge to builder if it exists.
                        Optional.ofNullable(visited.get(item)).ifPresent(propertyBuilder::merge);
                        recursivelyConstructCurrentFetchPlan((Entity) item, propertyBuilder, visited);
                    }
                    // Finalize builder with build and attach to entities
                    final FetchPlan fetchPlan = propertyBuilder.build();
                    for (Object item : (value instanceof Collection ? (Collection<?>) value : Set.of(value))) {
                        visited.put(item, fetchPlan);
                    }
                    // The input object graph can be large, so we use FetchMode.UNDEFINED to avoid huge SQLs with
                    // unpredictably high number of joins
                    builder.add(property.getName(), fetchPlans.builder(propertyClass).merge(fetchPlan), FetchMode.UNDEFINED);
                }
            } else {
                builder.add(property.getName());
            }
        }
    }

}

В демо проекте исправление я сделал в классе CustomEntityStates, который можно подключиться с помощью CustomEntityStatesBeanFactoryPostProcessor раскомментировав @Component.

@Component
public class CustomEntityStatesBeanFactoryPostProcessor implements BeanFactoryPostProcessor {

    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
        BeanDefinition beanDefinition = beanFactory.getBeanDefinition("core_EntityStates");
        beanDefinition.setBeanClassName(CustomEntityStates.class.getCanonicalName());
    }

}

Далее для проверки можно повторить описанные эксперименты и увидеть что ошибки нет.

2 лайка

Евгений, здравствуйте!

Благодарю за подробный разбор проблемы с демо проектом и вариантом решения! Завел issue.

С уважением,
Дмитрий