From 674fe11d36ed0eddb41821815f55e5e50cf08283 Mon Sep 17 00:00:00 2001 From: Richard Zowalla Date: Thu, 20 Aug 2026 12:29:47 +0200 Subject: [PATCH 1/5] [OPENJPA-2985] Implement EntityManager.createQuery(TypedQueryReference) EntityManagerImpl.createQuery(TypedQueryReference) no longer throws UnsupportedOperationException. It now: - calls assertNotCloseInvoked() first, so a closed EntityManager yields IllegalStateException; - rejects a null reference (and a null reference name) with IllegalArgumentException; - delegates to createNamedQuery(name, resultType) so the named query metadata (query string, hints, flush mode, max results, lock mode) is applied and the reference's result type becomes the query result class; an unknown query name propagates as ArgumentException, which extends IllegalArgumentException, matching the createNamedQuery contract; - applies the hints carried by the reference afterwards, so they take precedence over hints declared on the @NamedQuery itself; failures there go through translateException like the rest of the named query path. A null result type falls back to createNamedQuery(name) defensively. Tested by the new TestTypedQueryReference in org.apache.openjpa.persistence.query, which covers query creation and execution from a reference, the applied result class, reference hints surviving in getHints(), positional parameters of the named query, null reference, unknown query name and a closed EntityManager. All seven cases failed against the previous UnsupportedOperationException. --- .../query/TestTypedQueryReference.java | 156 ++++++++++++++++++ .../persistence/EntityManagerImpl.java | 23 ++- 2 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/query/TestTypedQueryReference.java diff --git a/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/query/TestTypedQueryReference.java b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/query/TestTypedQueryReference.java new file mode 100644 index 0000000000..bcd60495ff --- /dev/null +++ b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/query/TestTypedQueryReference.java @@ -0,0 +1,156 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.openjpa.persistence.query; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.TypedQuery; +import jakarta.persistence.TypedQueryReference; + +import org.apache.openjpa.persistence.OpenJPAQuery; +import org.apache.openjpa.persistence.test.SingleEMFTestCase; + +/** + * Tests {@code EntityManager.createQuery(TypedQueryReference)} added in JPA 3.2. + */ +public class TestTypedQueryReference extends SingleEMFTestCase { + + private static final String TIMEOUT_HINT = "jakarta.persistence.query.timeout"; + + @Override + public void setUp() { + setUp(SimpleEntity.class, CLEAR_TABLES); + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + em.persist(new SimpleEntity("Name One", "Value One")); + em.persist(new SimpleEntity("Name Two", "Value Two")); + em.getTransaction().commit(); + em.close(); + } + + private static TypedQueryReference ref(final String name, final Class type, + final Map hints) { + return new TypedQueryReference() { + + @Override + public String getName() { + return name; + } + + @Override + public Class getResultType() { + return type; + } + + @Override + public Map getHints() { + return hints; + } + }; + } + + public void testCreateQueryFromReference() { + EntityManager em = emf.createEntityManager(); + try { + TypedQuery q = + em.createQuery(ref("FindAll", SimpleEntity.class, Collections.emptyMap())); + assertNotNull(q); + List list = q.getResultList(); + assertEquals(2, list.size()); + for (SimpleEntity s : list) { + assertNotNull(s.getName()); + } + } finally { + em.close(); + } + } + + public void testResultTypeIsApplied() { + EntityManager em = emf.createEntityManager(); + try { + TypedQuery q = + em.createQuery(ref("FindAll", SimpleEntity.class, Collections.emptyMap())); + assertEquals(SimpleEntity.class, ((OpenJPAQuery) q).getResultClass()); + } finally { + em.close(); + } + } + + public void testReferenceHintsAreApplied() { + EntityManager em = emf.createEntityManager(); + try { + Map hints = + Collections.singletonMap(TIMEOUT_HINT, Integer.valueOf(12345)); + TypedQuery q = em.createQuery(ref("FindAll", SimpleEntity.class, hints)); + assertEquals(Integer.valueOf(12345), q.getHints().get(TIMEOUT_HINT)); + } finally { + em.close(); + } + } + + public void testParametersStillWork() { + EntityManager em = emf.createEntityManager(); + try { + TypedQuery q = + em.createQuery(ref("FindOne", SimpleEntity.class, Collections.emptyMap())); + q.setParameter(1, "Name One"); + assertEquals("Value One", q.getSingleResult().getValue()); + } finally { + em.close(); + } + } + + public void testNullReference() { + EntityManager em = emf.createEntityManager(); + try { + em.createQuery((TypedQueryReference) null); + fail("expected IllegalArgumentException"); + } catch (IllegalArgumentException expected) { + // expected + } finally { + em.close(); + } + } + + public void testUnknownQueryName() { + EntityManager em = emf.createEntityManager(); + try { + em.createQuery(ref("NoSuchNamedQuery", SimpleEntity.class, Collections.emptyMap())); + fail("expected IllegalArgumentException"); + } catch (IllegalArgumentException expected) { + // expected + } finally { + em.close(); + } + } + + public void testClosedEntityManager() { + EntityManager em = emf.createEntityManager(); + em.close(); + try { + em.createQuery(ref("FindAll", SimpleEntity.class, Collections.emptyMap())); + fail("expected IllegalStateException"); + } catch (IllegalStateException expected) { + // expected + } + } +} diff --git a/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/EntityManagerImpl.java b/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/EntityManagerImpl.java index bd693bb52c..d202c7ac8a 100644 --- a/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/EntityManagerImpl.java +++ b/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/EntityManagerImpl.java @@ -2775,8 +2775,29 @@ public TypedQuery createQuery(CriteriaSelect selectQuery) { } @Override + @SuppressWarnings("unchecked") public TypedQuery createQuery(TypedQueryReference reference) { - throw new UnsupportedOperationException("Not yet implemented (JPA 3.2)"); + assertNotCloseInvoked(); + if (reference == null) { + throw new IllegalArgumentException("TypedQueryReference must not be null"); + } + String name = reference.getName(); + if (name == null) { + throw new IllegalArgumentException("TypedQueryReference name must not be null"); + } + Class resultType = reference.getResultType(); + Query query = (resultType == null) ? createNamedQuery(name) : createNamedQuery(name, resultType); + Map hints = reference.getHints(); + if (hints != null) { + try { + for (Map.Entry hint : hints.entrySet()) { + query.setHint(hint.getKey(), hint.getValue()); + } + } catch (RuntimeException re) { + throw translateException(re); + } + } + return (TypedQuery) query; } @Override From 7c8df6d949d939d5c7a8a3cecb0bcf947c859448 Mon Sep 17 00:00:00 2001 From: Richard Zowalla Date: Thu, 20 Aug 2026 12:33:29 +0200 Subject: [PATCH 2/5] [OPENJPA-2985] Implement EntityManagerFactory.getNamedQueries(Class) EntityManagerFactoryImpl.getNamedQueries(Class) no longer throws UnsupportedOperationException. It now forces loading of the persistent types (mirroring the private MetaDataRepository.resolveAll() that getQueryMetaData() uses, so a cold repository does not yield an empty result), iterates the query metadata and returns a freshly built map of TypedQueryReference for every named query whose declared result type is assignable to the requested type. The declared result type is QueryMetaData.getResultType(), falling back to getCandidateType(), which makes the predicate exactly "createNamedQuery(name, resultType) is legal". Named queries without any declared result type are never returned, for any type including Object.class. The returned map is a copy; each reference carries the query name, the result type and an unmodifiable copy of the query hints. A null result type is rejected with IllegalArgumentException and a closed factory raises the usual IllegalStateException. For this to be answerable from metadata, AnnotationPersistenceMetaDataParser.parseNamedQueries() now honours the JPA 3.2 @NamedQuery.resultClass() the same way parseNamedNativeQueries() already honours @NamedNativeQuery.resultClass(): a managed result class is stored as the candidate type, any other non-void class as the result type. This is the identical branch that QueryImpl.setResultClass() performs for createNamedQuery(name, resultClass), so behaviour matches an explicit result class at the call site; note that setting the candidate type also makes JPQLExpressionBuilder skip its own candidate inference, which is the same situation as today's createNamedQuery(name, X.class). XMLPersistenceMetaDataParser is deliberately untouched: the bundled orm_3_2.xsd.rsrc declares only a "name" attribute on , so a "result-class" attribute would fail schema validation before reaching the parser. Refreshing the schema is left to a separate issue; until then XML-declared named queries have no declared result type and are therefore not returned by getNamedQueries(). New TypedQueryReferenceImpl is an immutable TypedQueryReference with equals/hashCode/toString. Tested by the new TestGetNamedQueries (with NamedQueryRefEntity, which declares named queries with an entity resultClass plus a hint, a scalar resultClass, no resultClass, and a native query with a resultClass): covers the parser change, entity/scalar/supertype lookups, exclusion of untyped queries, hints and their immutability, the returned map being a copy, null argument handling, a cold second factory, addNamedQuery(), and round-tripping a reference through EntityManager.createQuery(). --- .../query/NamedQueryRefEntity.java | 70 ++++++++ .../query/TestGetNamedQueries.java | 161 ++++++++++++++++++ .../AnnotationPersistenceMetaDataParser.java | 8 + .../persistence/EntityManagerFactoryImpl.java | 71 +++++++- .../persistence/TypedQueryReferenceImpl.java | 99 +++++++++++ 5 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/query/NamedQueryRefEntity.java create mode 100644 openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/query/TestGetNamedQueries.java create mode 100644 openjpa-persistence/src/main/java/org/apache/openjpa/persistence/TypedQueryReferenceImpl.java diff --git a/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/query/NamedQueryRefEntity.java b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/query/NamedQueryRefEntity.java new file mode 100644 index 0000000000..6ec45c7bdf --- /dev/null +++ b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/query/NamedQueryRefEntity.java @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.openjpa.persistence.query; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.NamedNativeQuery; +import jakarta.persistence.NamedQueries; +import jakarta.persistence.NamedQuery; +import jakarta.persistence.QueryHint; +import jakarta.persistence.Table; + +/** + * Entity declaring named queries with and without a JPA 3.2 {@code resultClass}. + */ +@Entity +@Table(name = "NQ_REF_ENTITY") +@NamedQueries({ + @NamedQuery(name = "NQRef.all", + query = "select o from NamedQueryRefEntity o", + resultClass = NamedQueryRefEntity.class, + hints = @QueryHint(name = "openjpa.FetchPlan.MaxFetchDepth", value = "2")), + @NamedQuery(name = "NQRef.names", + query = "select o.name from NamedQueryRefEntity o", + resultClass = String.class), + @NamedQuery(name = "NQRef.untyped", + query = "select o from NamedQueryRefEntity o where o.name = 'x'") +}) +@NamedNativeQuery(name = "NQRef.native", + query = "select id, name from NQ_REF_ENTITY", + resultClass = NamedQueryRefEntity.class) +public class NamedQueryRefEntity { + + @Id + private int id; + + private String name; + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/query/TestGetNamedQueries.java b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/query/TestGetNamedQueries.java new file mode 100644 index 0000000000..10a8306d19 --- /dev/null +++ b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/query/TestGetNamedQueries.java @@ -0,0 +1,161 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.openjpa.persistence.query; + +import java.util.Map; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import jakarta.persistence.TypedQueryReference; + +import org.apache.openjpa.meta.MetaDataRepository; +import org.apache.openjpa.meta.QueryMetaData; +import org.apache.openjpa.persistence.OpenJPAEntityManagerFactorySPI; +import org.apache.openjpa.persistence.test.SingleEMFTestCase; + +/** + * Tests the JPA 3.2 {@code EntityManagerFactory.getNamedQueries(Class)} and the + * {@code @NamedQuery.resultClass()} support it relies on. + */ +public class TestGetNamedQueries extends SingleEMFTestCase { + + @Override + public void setUp() { + setUp(NamedQueryRefEntity.class, CLEAR_TABLES); + } + + /** + * Regression test for @NamedQuery.resultClass() being parsed at all. + */ + public void testAnnotationResultClassParsed() { + MetaDataRepository mdr = emf.getConfiguration().getMetaDataRepositoryInstance(); + + QueryMetaData all = mdr.getQueryMetaData(null, "NQRef.all", null, true); + assertEquals(NamedQueryRefEntity.class, all.getCandidateType()); + assertNull(all.getResultType()); + + QueryMetaData names = mdr.getQueryMetaData(null, "NQRef.names", null, true); + assertEquals(String.class, names.getResultType()); + assertNull(names.getCandidateType()); + + // resultClass() defaults to void.class - it must not leak into the metadata + QueryMetaData untyped = mdr.getQueryMetaData(null, "NQRef.untyped", null, true); + assertNull(untyped.getCandidateType()); + assertNull(untyped.getResultType()); + } + + public void testEntityResultType() { + Map> refs = + emf.getNamedQueries(NamedQueryRefEntity.class); + assertTrue(refs.containsKey("NQRef.all")); + assertTrue(refs.containsKey("NQRef.native")); + assertFalse(refs.containsKey("NQRef.names")); + assertFalse(refs.containsKey("NQRef.untyped")); + + TypedQueryReference ref = refs.get("NQRef.all"); + assertEquals("NQRef.all", ref.getName()); + assertEquals(NamedQueryRefEntity.class, ref.getResultType()); + } + + public void testScalarResultType() { + Map> refs = emf.getNamedQueries(String.class); + assertTrue(refs.containsKey("NQRef.names")); + assertEquals(String.class, refs.get("NQRef.names").getResultType()); + assertFalse(refs.containsKey("NQRef.all")); + assertFalse(refs.containsKey("NQRef.untyped")); + } + + /** + * Queries without a declared result type are never returned - not even for Object.class. + */ + public void testSupertypeIsAssignable() { + Map> refs = emf.getNamedQueries(Object.class); + assertTrue(refs.containsKey("NQRef.all")); + assertTrue(refs.containsKey("NQRef.names")); + assertTrue(refs.containsKey("NQRef.native")); + assertFalse(refs.containsKey("NQRef.untyped")); + } + + public void testHints() { + Map hints = emf.getNamedQueries(NamedQueryRefEntity.class) + .get("NQRef.all").getHints(); + assertEquals("2", hints.get("openjpa.FetchPlan.MaxFetchDepth")); + try { + hints.put("x", "y"); + fail("hints must be unmodifiable"); + } catch (UnsupportedOperationException expected) { + // expected + } + } + + public void testReturnedMapIsACopy() { + Map> refs = + emf.getNamedQueries(NamedQueryRefEntity.class); + refs.clear(); + assertTrue(emf.getNamedQueries(NamedQueryRefEntity.class).containsKey("NQRef.all")); + } + + public void testNullResultTypeRejected() { + try { + emf.getNamedQueries(null); + fail("expected IllegalArgumentException"); + } catch (IllegalArgumentException expected) { + // expected + } + } + + /** + * getNamedQueries() must work as the very first operation on a factory, i.e. before any + * entity manager forced the metadata repository to load the persistent types. + */ + public void testColdRepository() { + OpenJPAEntityManagerFactorySPI emf2 = createEMF(NamedQueryRefEntity.class); + try { + assertTrue(emf2.getNamedQueries(NamedQueryRefEntity.class).containsKey("NQRef.all")); + } finally { + closeEMF(emf2); + } + } + + public void testDynamicallyAddedNamedQuery() { + EntityManager em = emf.createEntityManager(); + try { + Query q = em.createQuery("select o from NamedQueryRefEntity o"); + emf.addNamedQuery("NQRef.dyn", q); + assertTrue(emf.getNamedQueries(NamedQueryRefEntity.class).containsKey("NQRef.dyn")); + } finally { + em.close(); + } + } + + /** + * The declared result class must not break execution of the named queries. + */ + public void testCreateNamedQueryWithDeclaredResultClassStillWorks() { + EntityManager em = emf.createEntityManager(); + try { + assertNotNull(em.createNamedQuery("NQRef.all").getResultList()); + assertNotNull(em.createNamedQuery("NQRef.names", String.class).getResultList()); + assertNotNull(em.createQuery(emf.getNamedQueries(NamedQueryRefEntity.class) + .get("NQRef.all")).getResultList()); + } finally { + em.close(); + } + } +} diff --git a/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/AnnotationPersistenceMetaDataParser.java b/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/AnnotationPersistenceMetaDataParser.java index 72b1a5a143..7ab5207b05 100644 --- a/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/AnnotationPersistenceMetaDataParser.java +++ b/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/AnnotationPersistenceMetaDataParser.java @@ -2029,6 +2029,14 @@ private void parseNamedQueries(AnnotatedElement el, NamedQuery... queries) { meta = getRepository().addQueryMetaData(_cls, query.name()); meta.setLanguage(JPQLParser.LANG_JPQL); meta.setQueryString(query.query()); + // JPA 3.2 added @NamedQuery.resultClass(); handle it exactly like + // @NamedNativeQuery.resultClass() so that a declared result type is + // available from the metadata (and to EntityManagerFactory.getNamedQueries). + Class res = query.resultClass(); + if (ImplHelper.isManagedType(getConfiguration(), res)) + meta.setCandidateType(res); + else if (!void.class.equals(res)) + meta.setResultType(res); for (QueryHint hint : query.hints()) meta.addHint(hint.name(), hint.value()); LockModeType lmt = processNamedQueryLockModeType(query); diff --git a/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/EntityManagerFactoryImpl.java b/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/EntityManagerFactoryImpl.java index 61df456888..1927ba33f7 100644 --- a/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/EntityManagerFactoryImpl.java +++ b/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/EntityManagerFactoryImpl.java @@ -24,6 +24,7 @@ import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -92,6 +93,8 @@ public class EntityManagerFactoryImpl private final java.util.concurrent.ConcurrentHashMap> _entityGraphs = new java.util.concurrent.ConcurrentHashMap<>(); private volatile boolean _entityGraphsInitialized; + private final Object _namedQueriesLock = new Object(); + private volatile boolean _namedQueriesInitialized; private transient Map properties; private transient Map emEmptyPropsProperties; @@ -926,9 +929,75 @@ public Class getClass(T entity) { return OpenJPAPersistenceUtil.getClass(this, entity); } + /** + * Return the named queries whose declared result type is assignable to the given result type. + *

+ * The declared result type of a query is {@link QueryMetaData#getResultType()} if set, otherwise + * {@link QueryMetaData#getCandidateType()} - managed result classes are recorded as the candidate + * type by the metadata parsers, exactly as {@code createNamedQuery(name, resultClass)} would do. + * Named queries without any declared result type (i.e. a JPQL {@code @NamedQuery} without + * {@code resultClass}, or a query declared in XML - the bundled orm_3_2 schema does not allow a + * {@code result-class} attribute on {@code named-query}) are never returned, for any result type + * including {@code Object.class}, because their result type cannot be proven. + *

+ * The hints carried by the returned references are the hints of the named query, including the + * OpenJPA specific hints synthesized by the parsers (e.g. {@code openjpa.FetchPlan.ReadLockMode} + * derived from {@code @NamedQuery.lockMode()}); these are exactly the hints replayed by + * {@code createNamedQuery}. + *

+ * A freshly computed, modifiable map is returned on every call; it is not a live view of the + * metadata repository. + */ @Override + @SuppressWarnings("unchecked") public Map> getNamedQueries(Class resultType) { - throw new UnsupportedOperationException("Not yet implemented (JPA 3.2)"); + if (resultType == null) { + throw new IllegalArgumentException("resultType is required"); + } + _factory.assertOpen(); + loadQueryMetaData(); + Map> result = new HashMap<>(); + for (QueryMetaData qmd : getConfiguration().getMetaDataRepositoryInstance().getQueryMetaDatas()) { + Class declared = qmd.getResultType(); + if (declared == null) { + declared = qmd.getCandidateType(); + } + if (declared == null || !resultType.isAssignableFrom(declared)) { + continue; + } + String[] keys = qmd.getHintKeys(); + Object[] values = qmd.getHintValues(); + Map hints = new LinkedHashMap<>(); + for (int i = 0; i < keys.length && i < values.length; i++) { + hints.put(keys[i], values[i]); + } + result.put(qmd.getName(), + new TypedQueryReferenceImpl<>(qmd.getName(), (Class) declared, hints)); + } + return result; + } + + /** + * Force loading of all persistent types so that their named queries are registered in the + * metadata repository. Mirrors the private {@code MetaDataRepository.resolveAll()} that + * {@code getQueryMetaData(...)} uses for the same reason: on a cold repository + * {@code getQueryMetaDatas()} would otherwise return an empty array. + */ + private void loadQueryMetaData() { + if (_namedQueriesInitialized) { + return; + } + synchronized (_namedQueriesLock) { + if (_namedQueriesInitialized) { + return; + } + MetaDataRepository mdr = getConfiguration().getMetaDataRepositoryInstance(); + ClassLoader loader = getConfiguration().getClassResolverInstance().getClassLoader(null, null); + for (Class cls : mdr.loadPersistentTypes(false, loader)) { + mdr.getMetaData(cls, loader, false); + } + _namedQueriesInitialized = true; + } } @Override diff --git a/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/TypedQueryReferenceImpl.java b/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/TypedQueryReferenceImpl.java new file mode 100644 index 0000000000..4bbc0ab64e --- /dev/null +++ b/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/TypedQueryReferenceImpl.java @@ -0,0 +1,99 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.openjpa.persistence; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +import jakarta.persistence.TypedQueryReference; + +/** + * Immutable implementation of the JPA 3.2 {@link TypedQueryReference}. Instances are handed out by + * {@link EntityManagerFactoryImpl#getNamedQueries(Class)} and can be passed back to + * {@link jakarta.persistence.EntityManager#createQuery(TypedQueryReference)}. + * + * @param the result type of the referenced named query + * + * @since 4.2.0 + */ +public class TypedQueryReferenceImpl implements TypedQueryReference { + + private final String _name; + private final Class _resultType; + private final Map _hints; + + /** + * Constructor. + * + * @param name the name of the named query, must not be null + * @param resultType the declared result type of the named query, must not be null + * @param hints the query hints, may be null or empty. A defensive, unmodifiable copy is taken. + */ + public TypedQueryReferenceImpl(String name, Class resultType, Map hints) { + if (name == null) + throw new IllegalArgumentException("name is required"); + if (resultType == null) + throw new IllegalArgumentException("resultType is required"); + _name = name; + _resultType = resultType; + _hints = (hints == null || hints.isEmpty()) + ? Collections.emptyMap() + : Collections.unmodifiableMap(new LinkedHashMap<>(hints)); + } + + @Override + public String getName() { + return _name; + } + + @Override + public Class getResultType() { + return _resultType; + } + + /** + * The hints declared for this named query. The returned map is unmodifiable. + */ + @Override + public Map getHints() { + return _hints; + } + + @Override + public boolean equals(Object other) { + if (this == other) + return true; + if (!(other instanceof TypedQueryReferenceImpl)) + return false; + TypedQueryReferenceImpl o = (TypedQueryReferenceImpl) other; + return _name.equals(o._name) && _resultType.equals(o._resultType) && _hints.equals(o._hints); + } + + @Override + public int hashCode() { + return Objects.hash(_name, _resultType, _hints); + } + + @Override + public String toString() { + return "TypedQueryReference[name=" + _name + ", resultType=" + _resultType.getName() + "]"; + } +} From c52b5f053c35fc3e8876f16f5dd8def7b8bd1d4d Mon Sep 17 00:00:00 2001 From: Richard Zowalla Date: Thu, 20 Aug 2026 12:38:29 +0200 Subject: [PATCH 3/5] [OPENJPA-2985] Implement EntityManager.find(EntityGraph, Object, FindOption...) find(EntityGraph, Object, FindOption...) threw UnsupportedOperationException. It now looks up an instance of the root entity type of the given graph (EntityGraphImpl.getEntityType()) by primary key and applies the graph as a JPA 3.2 load graph: every attribute named by the graph and, recursively, by its subgraphs and key subgraphs is added to the fetch plan, while attributes outside the graph keep their declared fetch behaviour - the default fetch group is deliberately left in place, which is what distinguishes a load graph from a fetch graph. Details: - The graph is applied to a fetch plan pushed for the duration of the call only, so neither the entity manager's fetch plan nor its maximum fetch depth is affected afterwards. The maximum fetch depth is raised only when the graph is nested deeper than a currently configured finite depth; an infinite depth (-1) is never lowered. - Attributes are resolved through ClassMetaData/FieldMetaData and added by their full name (declaring type + field), so attributes inherited from a mapped superclass or a superclass entity are matched correctly. - A subgraph's declared class type is preferred, falling back to the declared type of the owning value's element/key, because for a plural attribute EntityGraphImpl derives the subgraph type from Attribute.getJavaType(), which is the collection type. - Recursion is bounded by a path scoped identity guard, so a graph that references itself terminates. - A null graph, a null primary key, a non-entity root type and a foreign EntityGraph implementation all raise IllegalArgumentException; the message for a foreign implementation mirrors EntityManagerFactoryImpl.addNamedEntityGraph. - The FindOption parsing of find(Class, Object, FindOption...) was extracted into a shared private helper so both overloads behave identically. The only observable delta on the existing overload is that an explicit null option array is now a no-op instead of an NPE, matching lock(Object, LockModeType, LockOption...). Tested by the new TestEntityGraphFind (10 tests) in the existing entitygraph test package; EGDepartment gained a lazy inverse collection so a graph has something to change. The tests assert the load state via the state manager's loaded bit set and pair every positive assertion with a fresh entity manager control that asserts the attribute is NOT loaded without the graph, covering a flat graph, a subgraph, a cyclic graph, find options, a missing row and the three IllegalArgumentException cases. --- .../persistence/entitygraph/EGDepartment.java | 10 + .../entitygraph/TestEntityGraphFind.java | 253 ++++++++++++++++++ .../persistence/EntityManagerImpl.java | 177 +++++++++++- 3 files changed, 426 insertions(+), 14 deletions(-) create mode 100644 openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/entitygraph/TestEntityGraphFind.java diff --git a/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/entitygraph/EGDepartment.java b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/entitygraph/EGDepartment.java index 83b4b12b61..054b21682f 100644 --- a/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/entitygraph/EGDepartment.java +++ b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/entitygraph/EGDepartment.java @@ -18,8 +18,13 @@ */ package org.apache.openjpa.persistence.entitygraph; +import java.util.ArrayList; +import java.util.List; + import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; import jakarta.persistence.Table; @Entity @@ -31,6 +36,9 @@ public class EGDepartment { private String name; + @OneToMany(mappedBy = "department", fetch = FetchType.LAZY) + private List employees = new ArrayList<>(); + public EGDepartment() {} public EGDepartment(int id, String name) { @@ -42,4 +50,6 @@ public EGDepartment(int id, String name) { public void setId(int id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } + public List getEmployees() { return employees; } + public void setEmployees(List employees) { this.employees = employees; } } diff --git a/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/entitygraph/TestEntityGraphFind.java b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/entitygraph/TestEntityGraphFind.java new file mode 100644 index 0000000000..7b3393c0a0 --- /dev/null +++ b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/entitygraph/TestEntityGraphFind.java @@ -0,0 +1,253 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.openjpa.persistence.entitygraph; + +import java.lang.reflect.Proxy; + +import jakarta.persistence.CacheRetrieveMode; +import jakarta.persistence.CacheStoreMode; +import jakarta.persistence.EntityGraph; +import jakarta.persistence.EntityManager; + +import org.apache.openjpa.enhance.PersistenceCapable; +import org.apache.openjpa.kernel.OpenJPAStateManager; +import org.apache.openjpa.meta.FieldMetaData; +import org.apache.openjpa.persistence.test.SingleEMFTestCase; +import org.apache.openjpa.util.ImplHelper; + +/** + * Tests {@code EntityManager.find(EntityGraph, Object, FindOption...)} (JPA 3.2). + * The graph is interpreted as a load graph: attributes named by the graph are fetched eagerly, + * attributes outside the graph keep their declared fetch behaviour. + */ +public class TestEntityGraphFind extends SingleEMFTestCase { + + private static final int DEPT_ID = 1; + private static final int EMP_ID = 10; + + @Override + public void setUp() { + setUp(EGEmployee.class, EGEmployee2.class, EGEmployee3.class, + EGDepartment.class, CLEAR_TABLES); + createData(); + } + + private void createData() { + EntityManager em = emf.createEntityManager(); + try { + em.getTransaction().begin(); + EGDepartment dept = new EGDepartment(DEPT_ID, "Engineering"); + em.persist(dept); + for (int i = 0; i < 2; i++) { + EGEmployee emp = new EGEmployee(); + emp.setId(EMP_ID + i); + emp.setFirstName("First" + i); + emp.setLastName("Last" + i); + emp.setSalary(1000 + i); + emp.setDepartment(dept); + dept.getEmployees().add(emp); + em.persist(emp); + } + em.getTransaction().commit(); + } finally { + if (em.getTransaction().isActive()) { + em.getTransaction().rollback(); + } + em.close(); + } + } + + /** + * Direct, non-brittle load state check: looks at the state manager's loaded bit for the given attribute + * rather than counting SQL statements. + */ + private boolean isLoaded(Object entity, String attribute) { + PersistenceCapable pc = ImplHelper.toPersistenceCapable(entity, emf.getConfiguration()); + assertNotNull("not a persistence capable instance", pc); + OpenJPAStateManager sm = (OpenJPAStateManager) pc.pcGetStateManager(); + assertNotNull("entity is not managed", sm); + FieldMetaData fmd = sm.getMetaData().getField(attribute); + assertNotNull("no such persistent attribute: " + attribute, fmd); + return sm.getLoaded().get(fmd.getIndex()); + } + + /** + * Baseline: without a graph the lazy collection is not loaded. Without this control the positive + * assertion below would be vacuous. + */ + public void testFindWithoutGraphLeavesLazyAttributeUnloaded() { + EntityManager em = emf.createEntityManager(); + try { + EGDepartment dept = em.find(EGDepartment.class, DEPT_ID); + assertNotNull(dept); + assertFalse("employees must be lazy without a graph", isLoaded(dept, "employees")); + } finally { + em.close(); + } + } + + /** + * The load bearing test: the very same lookup with a graph naming the lazy collection loads it eagerly. + */ + public void testFindWithEntityGraphLoadsGraphAttribute() { + EntityManager em = emf.createEntityManager(); + try { + EntityGraph graph = em.createEntityGraph(EGDepartment.class); + graph.addAttributeNodes("employees"); + EGDepartment dept = em.find(graph, DEPT_ID); + assertNotNull(dept); + assertEquals(DEPT_ID, dept.getId()); + assertTrue("employees must be loaded by the load graph", isLoaded(dept, "employees")); + assertEquals(2, dept.getEmployees().size()); + } finally { + em.close(); + } + } + + /** + * Load graph, not fetch graph: attributes outside the graph keep their declared (eager) behaviour. + */ + public void testFindWithEntityGraphKeepsNonGraphAttributesDefault() { + EntityManager em = emf.createEntityManager(); + try { + EntityGraph graph = em.createEntityGraph(EGEmployee.class); + graph.addAttributeNodes("firstName"); + EGEmployee emp = em.find(graph, EMP_ID); + assertNotNull(emp); + assertEquals("First0", emp.getFirstName()); + assertTrue("lastName is outside the graph but eager by default", isLoaded(emp, "lastName")); + assertNotNull(emp.getDepartment()); + } finally { + em.close(); + } + } + + /** + * Subgraphs are applied recursively: the department reached from the employee has its lazy collection + * loaded. The control below proves the plain find() does not. + */ + public void testFindWithSubgraph() { + EntityManager control = emf.createEntityManager(); + try { + EGEmployee emp = control.find(EGEmployee.class, EMP_ID); + assertNotNull(emp); + assertFalse(isLoaded(emp.getDepartment(), "employees")); + } finally { + control.close(); + } + + EntityManager em = emf.createEntityManager(); + try { + EntityGraph graph = em.createEntityGraph(EGEmployee.class); + graph.addSubgraph("department").addAttributeNodes("employees"); + EGEmployee emp = em.find(graph, EMP_ID); + assertNotNull(emp); + assertNotNull(emp.getDepartment()); + assertTrue("the subgraph must load department.employees", isLoaded(emp.getDepartment(), "employees")); + assertEquals(2, emp.getDepartment().getEmployees().size()); + } finally { + em.close(); + } + } + + /** + * A graph that references itself must not send the graph traversal into an endless recursion. + */ + public void testFindWithCyclicGraph() { + EntityManager em = emf.createEntityManager(); + try { + EntityGraph graph = em.createEntityGraph(EGDepartment.class); + jakarta.persistence.Subgraph emps = graph.addSubgraph("employees", EGEmployee.class); + emps.addSubgraph("department", EGDepartment.class).addAttributeNodes("employees"); + EGDepartment dept = em.find(graph, DEPT_ID); + assertNotNull(dept); + assertTrue(isLoaded(dept, "employees")); + } finally { + em.close(); + } + } + + /** + * FindOptions are honoured on the graph based overload as well, sharing the option parsing with + * {@code find(Class, Object, FindOption...)}. + */ + public void testFindWithGraphAndFindOptions() { + EntityManager em = emf.createEntityManager(); + try { + EntityGraph graph = em.createEntityGraph(EGDepartment.class); + graph.addAttributeNodes("employees"); + EGDepartment dept = em.find(graph, DEPT_ID, CacheStoreMode.BYPASS, CacheRetrieveMode.BYPASS); + assertNotNull(dept); + assertTrue(isLoaded(dept, "employees")); + } finally { + em.close(); + } + } + + public void testFindWithGraphReturnsNullWhenMissing() { + EntityManager em = emf.createEntityManager(); + try { + EntityGraph graph = em.createEntityGraph(EGDepartment.class); + graph.addAttributeNodes("employees"); + assertNull(em.find(graph, 9999)); + } finally { + em.close(); + } + } + + public void testFindWithNullGraphThrowsIAE() { + EntityManager em = emf.createEntityManager(); + try { + em.find((EntityGraph) null, DEPT_ID); + fail("expected IllegalArgumentException"); + } catch (IllegalArgumentException expected) { + // expected + } finally { + em.close(); + } + } + + public void testFindWithNullPrimaryKeyThrowsIAE() { + EntityManager em = emf.createEntityManager(); + try { + EntityGraph graph = em.createEntityGraph(EGDepartment.class); + em.find(graph, null); + fail("expected IllegalArgumentException"); + } catch (IllegalArgumentException expected) { + // expected + } finally { + em.close(); + } + } + + @SuppressWarnings("unchecked") + public void testFindWithForeignEntityGraphThrowsIAE() { + EntityManager em = emf.createEntityManager(); + try { + EntityGraph foreign = (EntityGraph) Proxy.newProxyInstance( + getClass().getClassLoader(), new Class[] { EntityGraph.class }, (p, m, a) -> null); + em.find(foreign, DEPT_ID); + fail("expected IllegalArgumentException"); + } catch (IllegalArgumentException expected) { + assertTrue(String.valueOf(expected.getMessage()).contains("Unknown EntityGraph implementation")); + } finally { + em.close(); + } + } +} diff --git a/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/EntityManagerImpl.java b/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/EntityManagerImpl.java index d202c7ac8a..a7762b94cc 100644 --- a/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/EntityManagerImpl.java +++ b/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/EntityManagerImpl.java @@ -33,6 +33,7 @@ import java.sql.Connection; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.EnumSet; import java.util.HashMap; import java.util.IdentityHashMap; @@ -41,6 +42,7 @@ import java.util.Map; import java.util.Set; +import jakarta.persistence.AttributeNode; import jakarta.persistence.CacheRetrieveMode; import jakarta.persistence.CacheStoreMode; import jakarta.persistence.ConnectionConsumer; @@ -55,6 +57,7 @@ import jakarta.persistence.Query; import jakarta.persistence.RefreshOption; import jakarta.persistence.StoredProcedureQuery; +import jakarta.persistence.Subgraph; import jakarta.persistence.Timeout; import jakarta.persistence.Tuple; import jakarta.persistence.TypedQuery; @@ -98,6 +101,7 @@ import org.apache.openjpa.meta.MultiQueryMetaData; import org.apache.openjpa.meta.QueryMetaData; import org.apache.openjpa.meta.SequenceMetaData; +import org.apache.openjpa.meta.ValueMetaData; import org.apache.openjpa.persistence.meta.MetamodelImpl; import org.apache.openjpa.persistence.criteria.CriteriaBuilderImpl; import org.apache.openjpa.persistence.criteria.OpenJPACriteriaBuilder; @@ -721,22 +725,38 @@ public T find(Class cls, Object oid, @Override public T find(Class cls, Object oid, FindOption... options) { Map props = new HashMap<>(); + LockModeType mode = parseFindOptions(props, options); + return find(cls, oid, mode, props); + } + + /** + * Translates the given JPA 3.2 {@link FindOption}s into kernel level properties, which are put into the + * given (modifiable) property map, and returns the {@link LockModeType} found among the options, if any. + * Unknown (custom) options are ignored. + * + * @param props the map to receive the translated properties, never null + * @param options the options to translate, may be null or empty + * @return the lock mode given among the options or null if none was given + */ + private LockModeType parseFindOptions(Map props, FindOption... options) { LockModeType mode = null; - for (FindOption opt: options) { - if (opt instanceof LockModeType lmt) { - mode = lmt; - } else if (opt instanceof CacheRetrieveMode crm) { - props.put(JPAProperties.CACHE_RETRIEVE_MODE, crm); - } else if (opt instanceof CacheStoreMode csm) { - props.put(JPAProperties.CACHE_STORE_MODE, csm); - } else if (opt instanceof PessimisticLockScope pls) { - props.put(JPAProperties.LOCK_SCOPE, pls); - } else if (opt instanceof Timeout timeout) { - props.put(JPAProperties.LOCK_TIMEOUT, timeout.milliseconds()); + if (options != null) { + for (FindOption opt: options) { + if (opt instanceof LockModeType lmt) { + mode = lmt; + } else if (opt instanceof CacheRetrieveMode crm) { + props.put(JPAProperties.CACHE_RETRIEVE_MODE, crm); + } else if (opt instanceof CacheStoreMode csm) { + props.put(JPAProperties.CACHE_STORE_MODE, csm); + } else if (opt instanceof PessimisticLockScope pls) { + props.put(JPAProperties.LOCK_SCOPE, pls); + } else if (opt instanceof Timeout timeout) { + props.put(JPAProperties.LOCK_TIMEOUT, timeout.milliseconds()); + } + // open to custom options } - // open to custom options } - return find(cls, oid, mode, props); + return mode; } @Override @@ -2640,9 +2660,138 @@ private QueryMetaData getQueryMetadata(String name) { return meta; } + /** + * Finds an instance of the root entity type of the given entity graph by primary key, using the graph + * as a load graph as mandated by JPA 3.2: every attribute named by the graph (and, recursively, + * by its subgraphs) is fetched eagerly, while attributes not mentioned by the graph keep their declared + * fetch behaviour. This is in contrast to a fetch graph, which would additionally force every attribute + * outside the graph to be lazy; therefore the default fetch group is deliberately left untouched here. + *

+ * The graph is applied to a fetch plan that is pushed for the duration of this call only, so neither the + * entity manager's fetch plan nor its maximum fetch depth is affected once the call returns. + * + * @param entityGraph the load graph, its root type determines the type to look up + * @param primaryKey the primary key of the instance to find + * @param options optional find options, interpreted exactly as by {@link #find(Class, Object, FindOption...)} + * @return the found instance or null if no instance with the given primary key exists + * @throws IllegalArgumentException if the graph is null, is not an OpenJPA entity graph, its root type is + * not an entity type, or the primary key is null or of an invalid type + */ @Override + @SuppressWarnings("unchecked") public T find(EntityGraph entityGraph, Object primaryKey, FindOption... options) { - throw new UnsupportedOperationException("Not yet implemented (JPA 3.2)"); + assertNotCloseInvoked(); + if (entityGraph == null) { + throw new IllegalArgumentException("entityGraph is null"); + } + if (!(entityGraph instanceof EntityGraphImpl)) { + throw new IllegalArgumentException("Unknown EntityGraph implementation: " + entityGraph.getClass()); + } + EntityGraphImpl graph = (EntityGraphImpl) entityGraph; + Class cls = graph.getEntityType(); + Map props = new HashMap<>(); + LockModeType mode = parseFindOptions(props, options); + try { + validateFindArguments(cls, primaryKey); + configureCurrentCacheModes(pushFetchPlan(), props); + try { + FetchPlan fetch = getFetchPlan(); + configureCurrentFetchPlan(fetch, props, mode, true); + applyEntityGraph(fetch, graph); + Object oid = _broker.newObjectId(cls, primaryKey); + return (T) _broker.find(oid, true, this); + } finally { + popFetchPlan(); + } + } catch (RuntimeException re) { + throw translateException(re); + } + } + + /** + * Applies the given entity graph to the given (already pushed) fetch plan as a load graph, and raises the + * plan's maximum fetch depth if - and only if - the graph is nested deeper than the currently configured + * finite depth. A max fetch depth of {@link FetchPlan#DEPTH_INFINITE} already covers any graph and is never + * lowered. + */ + private void applyEntityGraph(FetchPlan fetch, EntityGraphImpl graph) { + MetaDataRepository repos = _broker.getConfiguration().getMetaDataRepositoryInstance(); + ClassMetaData meta = toMetaData(repos, graph.getEntityType()); + if (meta == null) { + return; + } + int depth = applyGraphNodes(fetch, meta, graph.getAttributeNodes(), repos, + Collections.newSetFromMap(new IdentityHashMap())); + int max = fetch.getMaxFetchDepth(); + if (max != FetchPlan.DEPTH_INFINITE && max < depth) { + fetch.setMaxFetchDepth(depth); + } + } + + /** + * Adds every attribute named by the given graph nodes to the given fetch plan and recurses into their + * subgraphs. Nodes that do not name a persistent attribute of the given type are silently skipped, so a + * loosely declared named entity graph cannot turn a find() into a hard failure. + * + * @return the number of relation levels spanned by the given nodes, 0 if there are none + */ + private int applyGraphNodes(FetchPlan fetch, ClassMetaData meta, List> nodes, + MetaDataRepository repos, Set path) { + if (meta == null || nodes == null || nodes.isEmpty()) { + return 0; + } + int depth = 1; + for (AttributeNode node : nodes) { + FieldMetaData fmd = meta.getField(node.getAttributeName()); + if (fmd == null) { + continue; + } + // the full name is declaringType.fieldName, which is what FetchConfiguration matches against; + // building the name from the root class instead would silently miss inherited attributes + fetch.addField(fmd.getFullName(false)); + ValueMetaData element = fmd.getElement(); + ValueMetaData key = fmd.getKey(); + depth = Math.max(depth, 1 + applySubgraphs(fetch, node.getSubgraphs(), + (element == null) ? null : element.getDeclaredType(), repos, path)); + depth = Math.max(depth, 1 + applySubgraphs(fetch, node.getKeySubgraphs(), + (key == null) ? null : key.getDeclaredType(), repos, path)); + } + return depth; + } + + /** + * Recurses into the given subgraphs. The type declared by a subgraph is preferred, but for a plural + * attribute it may be the collection type rather than the element type, in which case the declared type of + * the owning value is used instead. Recursion is bounded by a path scoped identity guard, so a graph that + * references itself terminates while a subgraph shared by sibling branches is still expanded. + * + * @return the number of relation levels spanned below the given subgraphs + */ + private int applySubgraphs(FetchPlan fetch, Map subgraphs, Class fallbackType, + MetaDataRepository repos, Set path) { + int depth = 0; + if (subgraphs == null || subgraphs.isEmpty()) { + return depth; + } + for (Subgraph sub : subgraphs.values()) { + if (sub == null || !path.add(sub)) { + continue; + } + try { + ClassMetaData subMeta = toMetaData(repos, sub.getClassType()); + if (subMeta == null) { + subMeta = toMetaData(repos, fallbackType); + } + depth = Math.max(depth, applyGraphNodes(fetch, subMeta, sub.getAttributeNodes(), repos, path)); + } finally { + path.remove(sub); + } + } + return depth; + } + + private ClassMetaData toMetaData(MetaDataRepository repos, Class cls) { + return (cls == null) ? null : repos.getMetaData(cls, _broker.getClassLoader(), false); } @Override From 0821ba774180de9b5dd716f6f1236882d84beb12 Mon Sep 17 00:00:00 2001 From: Richard Zowalla Date: Thu, 20 Aug 2026 14:00:39 +0200 Subject: [PATCH 4/5] [OPENJPA-2985] Keep EntityManagerFactoryImpl serializable The getNamedQueries(Class) implementation guarded its lazy metadata load with a dedicated "private final Object _namedQueriesLock = new Object()". EntityManagerFactoryImpl is serialized field by field (it implements OpenJPAEntityManagerFactory, which extends Serializable, and the class declares no writeObject/readObject), so that non-transient bare Object field made the whole factory unserializable: java.io.NotSerializableException: java.lang.Object. The targeted tests for the new API never serialize a factory, so they stayed green; CI runs the full suite and org.apache.openjpa.persistence.simple.TestSerializedFactory, which writes the EntityManagerFactory to an ObjectOutputStream, failed on all four jobs. The neighbouring _entityGraphs guard did not have this problem because it locks on a ConcurrentHashMap, which is itself serializable. Fix: use a java.util.concurrent.locks.ReentrantLock as the monitor, the same idiom AbstractBrokerFactory already uses for its internal lock in the kernel. ReentrantLock is Serializable and deserializes unlocked, so the field can stay final and non-transient, no serialization hooks are needed, and the double-checked initialization (volatile flag read outside, re-checked inside the lock, written last) is unchanged. No new test: TestSerializedFactory already reproduces the failure exactly and passes with the fix. --- .../openjpa/persistence/EntityManagerFactoryImpl.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/EntityManagerFactoryImpl.java b/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/EntityManagerFactoryImpl.java index 1927ba33f7..51c2709d3e 100644 --- a/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/EntityManagerFactoryImpl.java +++ b/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/EntityManagerFactoryImpl.java @@ -28,6 +28,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.locks.ReentrantLock; import java.util.function.Consumer; import java.util.function.Function; @@ -93,7 +94,8 @@ public class EntityManagerFactoryImpl private final java.util.concurrent.ConcurrentHashMap> _entityGraphs = new java.util.concurrent.ConcurrentHashMap<>(); private volatile boolean _entityGraphsInitialized; - private final Object _namedQueriesLock = new Object(); + // internal lock: must stay serializable, this factory is serialized as-is + private final ReentrantLock _namedQueriesLock = new ReentrantLock(); private volatile boolean _namedQueriesInitialized; private transient Map properties; private transient Map emEmptyPropsProperties; @@ -987,7 +989,8 @@ private void loadQueryMetaData() { if (_namedQueriesInitialized) { return; } - synchronized (_namedQueriesLock) { + _namedQueriesLock.lock(); + try { if (_namedQueriesInitialized) { return; } @@ -997,6 +1000,8 @@ private void loadQueryMetaData() { mdr.getMetaData(cls, loader, false); } _namedQueriesInitialized = true; + } finally { + _namedQueriesLock.unlock(); } } From c83f543e4275b2620953f659c6f985e2b406ce9c Mon Sep 17 00:00:00 2001 From: Richard Zowalla Date: Thu, 20 Aug 2026 17:27:43 +0200 Subject: [PATCH 5/5] [OPENJPA-2985] Apply review suggestions from PR 156 - EntityManagerImpl.parseFindOptions(): flatten the null check into an early return instead of wrapping the whole loop in "if (options != null)". - TypedQueryReferenceImpl: use Map.of() for the empty-hints branch. Deviation: the non-empty branch keeps unmodifiableMap(new LinkedHashMap<>(hints)) instead of Map.copyOf(hints), because Map.copyOf randomizes iteration order per JVM run while createQuery(TypedQueryReference) replays the hints in that order and OpenJPA has aliased hint keys (openjpa.FetchPlan.LockTimeout / jakarta.persistence.lock.timeout, likewise for QueryTimeout) where the last write wins, and because Map.copyOf rejects null keys and values that this public constructor has always tolerated. TestGetNamedQueries now pins the order. --- .../query/TestGetNamedQueries.java | 18 ++++++++++++ .../persistence/EntityManagerImpl.java | 29 ++++++++++--------- .../persistence/TypedQueryReferenceImpl.java | 5 +++- 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/query/TestGetNamedQueries.java b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/query/TestGetNamedQueries.java index 10a8306d19..0658a48593 100644 --- a/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/query/TestGetNamedQueries.java +++ b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/query/TestGetNamedQueries.java @@ -18,6 +18,8 @@ */ package org.apache.openjpa.persistence.query; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.Map; import jakarta.persistence.EntityManager; @@ -27,6 +29,7 @@ import org.apache.openjpa.meta.MetaDataRepository; import org.apache.openjpa.meta.QueryMetaData; import org.apache.openjpa.persistence.OpenJPAEntityManagerFactorySPI; +import org.apache.openjpa.persistence.TypedQueryReferenceImpl; import org.apache.openjpa.persistence.test.SingleEMFTestCase; /** @@ -104,6 +107,21 @@ public void testHints() { } } + /** + * The hints of a {@link TypedQueryReference} must keep the declaration order they have in the + * metadata, because {@link jakarta.persistence.EntityManager#createQuery(TypedQueryReference)} + * replays them in iteration order and OpenJPA has aliased hint keys writing the same setting. + */ + public void testHintsKeepDeclarationOrder() { + Map declared = new LinkedHashMap<>(); + declared.put("openjpa.FetchPlan.LockTimeout", "1000"); + declared.put("jakarta.persistence.lock.timeout", "2000"); + declared.put("openjpa.FetchPlan.MaxFetchDepth", "2"); + Map hints = new TypedQueryReferenceImpl<>( + "NQRef.all", NamedQueryRefEntity.class, declared).getHints(); + assertEquals(new ArrayList<>(declared.keySet()), new ArrayList<>(hints.keySet())); + } + public void testReturnedMapIsACopy() { Map> refs = emf.getNamedQueries(NamedQueryRefEntity.class); diff --git a/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/EntityManagerImpl.java b/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/EntityManagerImpl.java index a7762b94cc..6ce4b39210 100644 --- a/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/EntityManagerImpl.java +++ b/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/EntityManagerImpl.java @@ -739,22 +739,23 @@ public T find(Class cls, Object oid, FindOption... options) { * @return the lock mode given among the options or null if none was given */ private LockModeType parseFindOptions(Map props, FindOption... options) { + if (options == null) { + return null; + } LockModeType mode = null; - if (options != null) { - for (FindOption opt: options) { - if (opt instanceof LockModeType lmt) { - mode = lmt; - } else if (opt instanceof CacheRetrieveMode crm) { - props.put(JPAProperties.CACHE_RETRIEVE_MODE, crm); - } else if (opt instanceof CacheStoreMode csm) { - props.put(JPAProperties.CACHE_STORE_MODE, csm); - } else if (opt instanceof PessimisticLockScope pls) { - props.put(JPAProperties.LOCK_SCOPE, pls); - } else if (opt instanceof Timeout timeout) { - props.put(JPAProperties.LOCK_TIMEOUT, timeout.milliseconds()); - } - // open to custom options + for (FindOption opt: options) { + if (opt instanceof LockModeType lmt) { + mode = lmt; + } else if (opt instanceof CacheRetrieveMode crm) { + props.put(JPAProperties.CACHE_RETRIEVE_MODE, crm); + } else if (opt instanceof CacheStoreMode csm) { + props.put(JPAProperties.CACHE_STORE_MODE, csm); + } else if (opt instanceof PessimisticLockScope pls) { + props.put(JPAProperties.LOCK_SCOPE, pls); + } else if (opt instanceof Timeout timeout) { + props.put(JPAProperties.LOCK_TIMEOUT, timeout.milliseconds()); } + // open to custom options } return mode; } diff --git a/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/TypedQueryReferenceImpl.java b/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/TypedQueryReferenceImpl.java index 4bbc0ab64e..cbdd14dff5 100644 --- a/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/TypedQueryReferenceImpl.java +++ b/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/TypedQueryReferenceImpl.java @@ -54,8 +54,11 @@ public TypedQueryReferenceImpl(String name, Class resultType, Map(hints)); }