diff --git a/jmix-data/eclipselink/src/main/java/io/jmix/eclipselink/impl/lazyloading/ValueHoldersSupport.java b/jmix-data/eclipselink/src/main/java/io/jmix/eclipselink/impl/lazyloading/ValueHoldersSupport.java index f4dcfacef3..a0772b4e52 100644 --- a/jmix-data/eclipselink/src/main/java/io/jmix/eclipselink/impl/lazyloading/ValueHoldersSupport.java +++ b/jmix-data/eclipselink/src/main/java/io/jmix/eclipselink/impl/lazyloading/ValueHoldersSupport.java @@ -27,6 +27,8 @@ import org.eclipse.persistence.internal.indirection.UnitOfWorkQueryValueHolder; import io.jmix.core.common.util.ReflectionHelper; import org.eclipse.persistence.internal.indirection.WrappingValueHolder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.util.ReflectionUtils; import java.lang.reflect.Field; @@ -34,13 +36,26 @@ public class ValueHoldersSupport { + private static final Logger log = LoggerFactory.getLogger(ValueHoldersSupport.class); + + /** + * Returns a value holder of the given single-value reference attribute, woven into the entity class + * by the enhancing process. + *

+ * Returns {@code null} if no value holder field is woven for the attribute, which means the attribute + * was not lazy at enhancing time (e.g. a {@code @OneToOne(mappedBy = ...)} attribute declared without + * {@code fetch = FetchType.LAZY}). Such attributes are always loaded eagerly by the ORM and do not + * participate in lazy loading. + */ + @Nullable public static Object getSingleValueHolder(Object entity, String propertyName) { Object valueHolder; try { Field valueHolderField = ReflectionHelper.findField(entity.getClass(), String.format("_persistence_%s_vh", propertyName)); if (valueHolderField == null) { - throw new RuntimeException(String.format("Unable to access value holder for property: %s on entity %s", - propertyName, entity.getClass().getName())); + log.trace("No value holder field is woven for property '{}' of entity {} - the attribute is not lazy", + propertyName, entity.getClass().getName()); + return null; } ReflectionUtils.makeAccessible(valueHolderField); diff --git a/jmix-data/eclipselink/src/test/groovy/lazy_loading/EagerMappedByOneToOneLazyLoadingTest.groovy b/jmix-data/eclipselink/src/test/groovy/lazy_loading/EagerMappedByOneToOneLazyLoadingTest.groovy new file mode 100644 index 0000000000..198eed3b8a --- /dev/null +++ b/jmix-data/eclipselink/src/test/groovy/lazy_loading/EagerMappedByOneToOneLazyLoadingTest.groovy @@ -0,0 +1,107 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lazy_loading + +import io.jmix.core.DataManager +import io.jmix.core.EntityStates +import org.springframework.beans.factory.annotation.Autowired +import spock.lang.IgnoreIf +import test_support.DataSpec +import test_support.entity.lazyloading.eager_mapped_by.EagerMappedByPassport +import test_support.entity.lazyloading.eager_mapped_by.EagerMappedByPerson +import test_support.entity.lazyloading.eager_mapped_by.EagerMappedByVisit + +/** + * Covers "Unable to access value holder for property" error for {@code @OneToOne(mappedBy = ...)} + * attributes declared without {@code fetch = FetchType.LAZY}. + *

+ * Such attributes are EAGER at enhancing time, so no {@code _persistence__vh} field is woven + * for them, and lazy loading post-processing must skip them instead of failing. + */ +@IgnoreIf({ Boolean.valueOf(System.getenv("JMIX_ECLIPSELINK_DISABLELAZYLOADING")) }) +class EagerMappedByOneToOneLazyLoadingTest extends DataSpec { + + @Autowired + DataManager dataManager + @Autowired + EntityStates entityStates + + EagerMappedByPassport passport + EagerMappedByPerson person + EagerMappedByVisit visit + + void setup() { + passport = dataManager.create(EagerMappedByPassport) + passport.number = '123' + + person = dataManager.create(EagerMappedByPerson) + person.name = 'Bob' + person.passport = passport + + visit = dataManager.create(EagerMappedByVisit) + visit.passport = passport + + dataManager.save(passport, person, visit) + } + + void cleanup() { + jdbc.update('delete from TEST_EAGER_MAPPED_BY_VISIT') + jdbc.update('delete from TEST_EAGER_MAPPED_BY_PERSON') + jdbc.update('delete from TEST_EAGER_MAPPED_BY_PASSPORT') + } + + def "direct load of entity with non-lazy mappedBy one-to-one attribute"() { + when: + EagerMappedByPassport loadedPassport = dataManager.load(EagerMappedByPassport).id(passport.id).one() + + then: + loadedPassport.number == '123' + loadedPassport.person.id == person.id + } + + def "lazy loading a reference to entity with non-lazy mappedBy one-to-one, owner is of the same class as the attribute"() { + // the value holder post-processing traversal takes the 'replaceToExistingReferences' branch + when: + EagerMappedByPerson loadedPerson = dataManager.load(EagerMappedByPerson).id(person.id).one() + + then: + !entityStates.isLoaded(loadedPerson, 'passport') + + when: + EagerMappedByPassport loadedPassport = loadedPerson.passport + + then: + loadedPassport.number == '123' + loadedPassport.person.id == person.id + } + + def "lazy loading a reference to entity with non-lazy mappedBy one-to-one, owner is of an unrelated class"() { + // the value holder post-processing traversal takes the 'replaceLoadOptions' branch + when: + EagerMappedByVisit loadedVisit = dataManager.load(EagerMappedByVisit).id(visit.id).one() + + then: + !entityStates.isLoaded(loadedVisit, 'passport') + + when: + EagerMappedByPassport loadedPassport = loadedVisit.passport + + then: + loadedPassport.number == '123' + loadedPassport.person.id == person.id + } +} diff --git a/jmix-data/eclipselink/src/test/java/test_support/entity/lazyloading/eager_mapped_by/EagerMappedByPassport.java b/jmix-data/eclipselink/src/test/java/test_support/entity/lazyloading/eager_mapped_by/EagerMappedByPassport.java new file mode 100644 index 0000000000..6d99e6c502 --- /dev/null +++ b/jmix-data/eclipselink/src/test/java/test_support/entity/lazyloading/eager_mapped_by/EagerMappedByPassport.java @@ -0,0 +1,50 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test_support.entity.lazyloading.eager_mapped_by; + +import io.jmix.core.metamodel.annotation.JmixEntity; +import jakarta.persistence.*; +import test_support.entity.BaseEntity; + +@Table(name = "TEST_EAGER_MAPPED_BY_PASSPORT") +@JmixEntity +@Entity(name = "test_EagerMappedByPassport") +public class EagerMappedByPassport extends BaseEntity { + @Column(name = "NUMBER_") + protected String number; + + // intentionally declared without fetch = FetchType.LAZY: the attribute is EAGER at enhancing time, + // so no '_persistence_person_vh' field is woven for it + @OneToOne(mappedBy = "passport") + protected EagerMappedByPerson person; + + public String getNumber() { + return number; + } + + public void setNumber(String number) { + this.number = number; + } + + public EagerMappedByPerson getPerson() { + return person; + } + + public void setPerson(EagerMappedByPerson person) { + this.person = person; + } +} diff --git a/jmix-data/eclipselink/src/test/java/test_support/entity/lazyloading/eager_mapped_by/EagerMappedByPerson.java b/jmix-data/eclipselink/src/test/java/test_support/entity/lazyloading/eager_mapped_by/EagerMappedByPerson.java new file mode 100644 index 0000000000..717a438a2d --- /dev/null +++ b/jmix-data/eclipselink/src/test/java/test_support/entity/lazyloading/eager_mapped_by/EagerMappedByPerson.java @@ -0,0 +1,49 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test_support.entity.lazyloading.eager_mapped_by; + +import io.jmix.core.metamodel.annotation.JmixEntity; +import jakarta.persistence.*; +import test_support.entity.BaseEntity; + +@Table(name = "TEST_EAGER_MAPPED_BY_PERSON") +@JmixEntity +@Entity(name = "test_EagerMappedByPerson") +public class EagerMappedByPerson extends BaseEntity { + @Column(name = "NAME") + protected String name; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "PASSPORT_ID") + protected EagerMappedByPassport passport; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public EagerMappedByPassport getPassport() { + return passport; + } + + public void setPassport(EagerMappedByPassport passport) { + this.passport = passport; + } +} diff --git a/jmix-data/eclipselink/src/test/java/test_support/entity/lazyloading/eager_mapped_by/EagerMappedByVisit.java b/jmix-data/eclipselink/src/test/java/test_support/entity/lazyloading/eager_mapped_by/EagerMappedByVisit.java new file mode 100644 index 0000000000..e9b7a5483f --- /dev/null +++ b/jmix-data/eclipselink/src/test/java/test_support/entity/lazyloading/eager_mapped_by/EagerMappedByVisit.java @@ -0,0 +1,38 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package test_support.entity.lazyloading.eager_mapped_by; + +import io.jmix.core.metamodel.annotation.JmixEntity; +import jakarta.persistence.*; +import test_support.entity.BaseEntity; + +@Table(name = "TEST_EAGER_MAPPED_BY_VISIT") +@JmixEntity +@Entity(name = "test_EagerMappedByVisit") +public class EagerMappedByVisit extends BaseEntity { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "PASSPORT_ID") + protected EagerMappedByPassport passport; + + public EagerMappedByPassport getPassport() { + return passport; + } + + public void setPassport(EagerMappedByPassport passport) { + this.passport = passport; + } +}