Про Демо проект
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());
}
}
Далее для проверки можно повторить описанные эксперименты и увидеть что ошибки нет.









