Skip to content

Commit 3102c28

Browse files
committed
TOMEE-4650 - Guard undeploy against a closed EMF and add JPA 3.2 CDI qualifier beans
Two independent problems, both reproducing on Plume (EclipseLink) and the webprofile distribution (OpenJPA), so neither is a provider defect. 1. When an application closes a container-managed EntityManagerFactory itself, TomEE's undeploy path closed it again, and Assembler.destroyApplication then failed with "Attempting to execute an operation on a closed EntityManagerFactory". ReloadableEntityManagerFactory.close() now checks isOpen() before delegating. 2. TomEE did not register the CDI beans required by the Jakarta Persistence 3.2 / Jakarta EE 11 CDI integration for persistence.xml-declared units, so injecting a qualified EntityManagerFactory / EntityManager / PersistenceUnitUtil failed with UnsatisfiedResolutionException. A new JpaCDIExtension registers, per persistence unit, an @ApplicationScoped EntityManagerFactory (bean name = PU name), an EntityManager in the <scope> element's scope (default TransactionScoped), and @dependent CriteriaBuilder, PersistenceUnitUtil, Cache, SchemaManager and Metamodel beans, all carrying the <qualifier> elements or @default when none is declared. Supporting this, <qualifier>/<scope> are added to the persistence.xml JAXB model (PersistenceUnit), to PersistenceUnitInfo, and copied through AppInfoBuilder, which also honours the jakarta.persistence.qualifiers / jakarta.persistence.scope override properties. Bean-registration contract verified against the Jakarta EE 11 Platform spec (CDI-JPA) and the Persistence 3.2 schema rather than from memory. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent a3e8806 commit 3102c28

9 files changed

Lines changed: 635 additions & 1 deletion

File tree

container/openejb-core/src/main/java/org/apache/openejb/assembler/classic/PersistenceUnitInfo.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ public class PersistenceUnitInfo extends InfoObject {
2727
public String id;
2828
public String name;
2929
public String provider;
30+
// Jakarta Persistence 3.2 CDI integration: <qualifier>/<scope> from persistence.xml
31+
public final List<String> qualifiers = new ArrayList<>();
32+
public String scope;
3033
public String transactionType;
3134
public String jtaDataSource;
3235
public String nonJtaDataSource;

container/openejb-core/src/main/java/org/apache/openejb/assembler/classic/ReloadableEntityManagerFactory.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,8 @@ public boolean isOpen() {
279279

280280
@Override
281281
public synchronized void close() {
282-
if (delegate != null) {
282+
// the application may have closed the EMF itself, closing it twice is an error
283+
if (delegate != null && delegate.isOpen()) {
283284
delegate.close();
284285
}
285286
}

container/openejb-core/src/main/java/org/apache/openejb/cdi/OptimizedLoaderService.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ protected List<? extends Extension> loadExtensions(final ClassLoader classLoader
127127
}
128128

129129
list.add(new org.apache.openejb.cdi.concurrency.ConcurrencyCDIExtension());
130+
list.add(new org.apache.openejb.cdi.persistence.JpaCDIExtension());
130131

131132
final Collection<Extension> extensionCopy = new ArrayList<>(list);
132133

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.openejb.cdi.persistence;
18+
19+
import jakarta.enterprise.context.ApplicationScoped;
20+
import jakarta.enterprise.context.Dependent;
21+
import jakarta.enterprise.event.Observes;
22+
import jakarta.enterprise.inject.Any;
23+
import jakarta.enterprise.inject.Default;
24+
import jakarta.enterprise.inject.spi.AfterBeanDiscovery;
25+
import jakarta.enterprise.inject.spi.Extension;
26+
import jakarta.inject.Qualifier;
27+
import jakarta.persistence.Cache;
28+
import jakarta.persistence.EntityManager;
29+
import jakarta.persistence.EntityManagerFactory;
30+
import jakarta.persistence.PersistenceUnitUtil;
31+
import jakarta.persistence.SchemaManager;
32+
import jakarta.persistence.criteria.CriteriaBuilder;
33+
import jakarta.persistence.metamodel.Metamodel;
34+
import org.apache.openejb.assembler.classic.AppInfo;
35+
import org.apache.openejb.assembler.classic.PersistenceUnitInfo;
36+
import org.apache.openejb.cdi.OpenEJBLifecycle;
37+
import org.apache.openejb.loader.SystemInstance;
38+
import org.apache.openejb.spi.ContainerSystem;
39+
import org.apache.openejb.util.LogCategory;
40+
import org.apache.openejb.util.Logger;
41+
42+
import javax.naming.NamingException;
43+
import java.lang.annotation.Annotation;
44+
import java.lang.reflect.InvocationHandler;
45+
import java.lang.reflect.Method;
46+
import java.lang.reflect.Proxy;
47+
import java.util.LinkedHashMap;
48+
import java.util.LinkedHashSet;
49+
import java.util.Map;
50+
import java.util.Set;
51+
import java.util.function.Function;
52+
53+
/**
54+
* CDI extension registering the beans required by the Jakarta Persistence 3.2 /
55+
* Jakarta EE 11 CDI integration (Platform specification, "Jakarta Persistence &amp;
56+
* Jakarta Context and Dependency Injection (CDI) Integration").
57+
*
58+
* <p>For each persistence unit the container must make available:
59+
* <ul>
60+
* <li>an {@link EntityManagerFactory} bean, {@code @ApplicationScoped}, whose bean name
61+
* is the persistence unit name;</li>
62+
* <li>an {@link EntityManager} bean, in the scope given by the {@code <scope>} element
63+
* (defaulting to {@code jakarta.transaction.TransactionScoped});</li>
64+
* <li>{@link CriteriaBuilder}, {@link PersistenceUnitUtil}, {@link Cache},
65+
* {@link SchemaManager} and {@link Metamodel} beans, {@code @Dependent}, each simply
66+
* obtained from the matching getter of the {@code EntityManagerFactory}.</li>
67+
* </ul>
68+
*
69+
* <p>All of them carry the qualifiers given by the {@code <qualifier>} elements of
70+
* {@code persistence.xml}, or {@code @Default} when none is declared.
71+
*/
72+
public class JpaCDIExtension implements Extension {
73+
74+
private static final Logger logger = Logger.getInstance(LogCategory.OPENEJB.createChild("cdi"), JpaCDIExtension.class);
75+
76+
private static final String PERSISTENCE_UNIT_NAMING_CONTEXT = "openejb/PersistenceUnit/";
77+
78+
/**
79+
* Default scope of the {@code EntityManager} bean. Resolved reflectively so that the
80+
* extension keeps working on distributions without the Jakarta Transactions API.
81+
*/
82+
private static final String TRANSACTION_SCOPED = "jakarta.transaction.TransactionScoped";
83+
84+
void registerBeans(@Observes final AfterBeanDiscovery afterBeanDiscovery) {
85+
final AppInfo appInfo = OpenEJBLifecycle.CURRENT_APP_INFO.get();
86+
if (appInfo == null) {
87+
return;
88+
}
89+
90+
for (final PersistenceUnitInfo unitInfo : appInfo.persistenceUnits) {
91+
final Set<Annotation> qualifiers = validateAndCreateQualifiers(unitInfo, afterBeanDiscovery);
92+
if (qualifiers == null) {
93+
continue;
94+
}
95+
96+
final Class<? extends Annotation> entityManagerScope = resolveEntityManagerScope(unitInfo, afterBeanDiscovery);
97+
if (entityManagerScope == null) {
98+
continue;
99+
}
100+
101+
logger.debug("Registering CDI beans for persistence unit '" + unitInfo.name + "'");
102+
103+
afterBeanDiscovery.addBean()
104+
.id("tomee.jpa." + EntityManagerFactory.class.getName() + "#" + unitInfo.id)
105+
.beanClass(EntityManagerFactory.class)
106+
.types(Object.class, EntityManagerFactory.class)
107+
.qualifiers(qualifiers.toArray(new Annotation[0]))
108+
.scope(ApplicationScoped.class)
109+
.name(unitInfo.name)
110+
.createWith(cc -> lookupEntityManagerFactory(unitInfo.id));
111+
112+
// the EntityManager is created per contextual instance and closed when the context ends
113+
afterBeanDiscovery.addBean()
114+
.id("tomee.jpa." + EntityManager.class.getName() + "#" + unitInfo.id)
115+
.beanClass(EntityManager.class)
116+
.types(Object.class, EntityManager.class)
117+
.qualifiers(qualifiers.toArray(new Annotation[0]))
118+
.scope(entityManagerScope)
119+
.produceWith(instance -> lookupEntityManagerFactory(unitInfo.id).createEntityManager())
120+
.disposeWith((em, cc) -> {
121+
if (em.isOpen()) {
122+
em.close();
123+
}
124+
});
125+
126+
addUtilityBean(afterBeanDiscovery, unitInfo, qualifiers, CriteriaBuilder.class, EntityManagerFactory::getCriteriaBuilder);
127+
addUtilityBean(afterBeanDiscovery, unitInfo, qualifiers, PersistenceUnitUtil.class, EntityManagerFactory::getPersistenceUnitUtil);
128+
addUtilityBean(afterBeanDiscovery, unitInfo, qualifiers, Cache.class, EntityManagerFactory::getCache);
129+
addUtilityBean(afterBeanDiscovery, unitInfo, qualifiers, SchemaManager.class, EntityManagerFactory::getSchemaManager);
130+
addUtilityBean(afterBeanDiscovery, unitInfo, qualifiers, Metamodel.class, EntityManagerFactory::getMetamodel);
131+
}
132+
}
133+
134+
/**
135+
* The five utility beans are {@code @Dependent} and simply delegate to the matching
136+
* getter of the {@code EntityManagerFactory}.
137+
*/
138+
private <T> void addUtilityBean(final AfterBeanDiscovery afterBeanDiscovery,
139+
final PersistenceUnitInfo unitInfo,
140+
final Set<Annotation> qualifiers,
141+
final Class<T> type,
142+
final Function<EntityManagerFactory, T> accessor) {
143+
afterBeanDiscovery.addBean()
144+
.id("tomee.jpa." + type.getName() + "#" + unitInfo.id)
145+
.beanClass(type)
146+
.types(Object.class, type)
147+
.qualifiers(qualifiers.toArray(new Annotation[0]))
148+
.scope(Dependent.class)
149+
.createWith(cc -> accessor.apply(lookupEntityManagerFactory(unitInfo.id)));
150+
}
151+
152+
/**
153+
* Builds the qualifier set of a persistence unit: the {@code <qualifier>} elements, or
154+
* {@code @Default} when none is declared. {@code @Any} is always added, as for any bean.
155+
*
156+
* @return {@code null} if a qualifier is invalid, in which case a definition error has
157+
* been reported
158+
*/
159+
private Set<Annotation> validateAndCreateQualifiers(final PersistenceUnitInfo unitInfo,
160+
final AfterBeanDiscovery afterBeanDiscovery) {
161+
final Set<Annotation> qualifiers = new LinkedHashSet<>();
162+
qualifiers.add(Any.Literal.INSTANCE);
163+
164+
if (unitInfo.qualifiers.isEmpty()) {
165+
qualifiers.add(Default.Literal.INSTANCE);
166+
return qualifiers;
167+
}
168+
169+
final ClassLoader loader = Thread.currentThread().getContextClassLoader();
170+
for (final String qualifierName : unitInfo.qualifiers) {
171+
final Class<?> qualifierClass;
172+
try {
173+
qualifierClass = loader.loadClass(qualifierName);
174+
} catch (final ClassNotFoundException e) {
175+
afterBeanDiscovery.addDefinitionError(new IllegalArgumentException("Qualifier class " + qualifierName
176+
+ " of persistence unit " + unitInfo.name + " cannot be loaded", e));
177+
return null;
178+
}
179+
180+
if (!qualifierClass.isAnnotation()) {
181+
afterBeanDiscovery.addDefinitionError(new IllegalArgumentException("Qualifier " + qualifierName
182+
+ " of persistence unit " + unitInfo.name + " must be an annotation type"));
183+
return null;
184+
}
185+
186+
@SuppressWarnings("unchecked")
187+
final Class<? extends Annotation> annotationClass = (Class<? extends Annotation>) qualifierClass;
188+
if (!annotationClass.isAnnotationPresent(Qualifier.class)) {
189+
afterBeanDiscovery.addDefinitionError(new IllegalArgumentException("Qualifier " + qualifierName
190+
+ " of persistence unit " + unitInfo.name + " must be annotated with @Qualifier"));
191+
return null;
192+
}
193+
194+
qualifiers.add(createAnnotation(annotationClass));
195+
}
196+
197+
return qualifiers;
198+
}
199+
200+
/**
201+
* Resolves the {@code <scope>} element of the persistence unit, defaulting to
202+
* {@code jakarta.transaction.TransactionScoped}.
203+
*
204+
* @return {@code null} if the scope is invalid, in which case a definition error has
205+
* been reported
206+
*/
207+
@SuppressWarnings("unchecked")
208+
private Class<? extends Annotation> resolveEntityManagerScope(final PersistenceUnitInfo unitInfo,
209+
final AfterBeanDiscovery afterBeanDiscovery) {
210+
final String scope = unitInfo.scope == null || unitInfo.scope.isBlank() ? TRANSACTION_SCOPED : unitInfo.scope.trim();
211+
final ClassLoader loader = Thread.currentThread().getContextClassLoader();
212+
213+
final Class<?> scopeClass;
214+
try {
215+
scopeClass = loader.loadClass(scope);
216+
} catch (final ClassNotFoundException e) {
217+
if (unitInfo.scope == null || unitInfo.scope.isBlank()) {
218+
// no Jakarta Transactions on the classpath, fall back to @Dependent
219+
return Dependent.class;
220+
}
221+
afterBeanDiscovery.addDefinitionError(new IllegalArgumentException("Scope class " + scope
222+
+ " of persistence unit " + unitInfo.name + " cannot be loaded", e));
223+
return null;
224+
}
225+
226+
if (!scopeClass.isAnnotation()) {
227+
afterBeanDiscovery.addDefinitionError(new IllegalArgumentException("Scope " + scope
228+
+ " of persistence unit " + unitInfo.name + " must be an annotation type"));
229+
return null;
230+
}
231+
232+
return (Class<? extends Annotation>) scopeClass;
233+
}
234+
235+
private EntityManagerFactory lookupEntityManagerFactory(final String unitId) {
236+
final ContainerSystem containerSystem = SystemInstance.get().getComponent(ContainerSystem.class);
237+
if (containerSystem == null) {
238+
throw new IllegalStateException("ContainerSystem is not available");
239+
}
240+
241+
final Object instance;
242+
try {
243+
instance = containerSystem.getJNDIContext().lookup(PERSISTENCE_UNIT_NAMING_CONTEXT + unitId);
244+
} catch (final NamingException e) {
245+
throw new IllegalStateException("Unable to lookup persistence unit " + unitId, e);
246+
}
247+
248+
if (!(instance instanceof EntityManagerFactory)) {
249+
throw new IllegalStateException("Persistence unit " + unitId + " is not an EntityManagerFactory, found "
250+
+ (instance == null ? "null" : instance.getClass().getName()));
251+
}
252+
return EntityManagerFactory.class.cast(instance);
253+
}
254+
255+
/**
256+
* Creates an instance of a member-less annotation type. Members are answered with their
257+
* default value, which the CDI qualifier rules guarantee to exist.
258+
*/
259+
private Annotation createAnnotation(final Class<? extends Annotation> annotationType) {
260+
final Map<String, Object> values = new LinkedHashMap<>();
261+
for (final Method method : annotationType.getDeclaredMethods()) {
262+
values.put(method.getName(), method.getDefaultValue());
263+
}
264+
265+
final InvocationHandler handler = (final Object proxy, final Method method, final Object[] args) -> {
266+
final String name = method.getName();
267+
if ("annotationType".equals(name) && method.getParameterCount() == 0) {
268+
return annotationType;
269+
}
270+
if ("equals".equals(name) && method.getParameterCount() == 1) {
271+
return annotationType.isInstance(args[0]);
272+
}
273+
if ("hashCode".equals(name) && method.getParameterCount() == 0) {
274+
return annotationType.hashCode();
275+
}
276+
if ("toString".equals(name) && method.getParameterCount() == 0) {
277+
return "@" + annotationType.getName() + "()";
278+
}
279+
if (values.containsKey(name)) {
280+
return values.get(name);
281+
}
282+
throw new IllegalStateException("Unsupported annotation method: " + method);
283+
};
284+
285+
return Annotation.class.cast(Proxy.newProxyInstance(
286+
annotationType.getClassLoader(),
287+
new Class<?>[]{annotationType},
288+
handler));
289+
}
290+
}

container/openejb-core/src/main/java/org/apache/openejb/config/AppInfoBuilder.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,9 @@ private void buildPersistenceModules(final AppModule appModule, final AppInfo ap
691691
info.jtaDataSource = persistenceUnit.getJtaDataSource();
692692
info.nonJtaDataSource = persistenceUnit.getNonJtaDataSource();
693693

694+
info.qualifiers.addAll(persistenceUnit.getQualifier());
695+
info.scope = persistenceUnit.getScope();
696+
694697
info.jarFiles.addAll(persistenceUnit.getJarFile());
695698
info.classes.addAll(persistenceUnit.getClazz());
696699
info.mappingFiles.addAll(persistenceUnit.getMappingFile());
@@ -704,6 +707,21 @@ private void buildPersistenceModules(final AppModule appModule, final AppInfo ap
704707

705708
PersistenceProviderProperties.apply(appModule, info);
706709

710+
// Jakarta Persistence 3.2: these properties override the corresponding XML elements
711+
final String qualifiersProperty = info.properties.getProperty("jakarta.persistence.qualifiers");
712+
if (qualifiersProperty != null) {
713+
info.qualifiers.clear();
714+
for (final String qualifier : qualifiersProperty.split(",")) {
715+
if (!qualifier.isBlank()) {
716+
info.qualifiers.add(qualifier.trim());
717+
}
718+
}
719+
}
720+
final String scopeProperty = info.properties.getProperty("jakarta.persistence.scope");
721+
if (scopeProperty != null) {
722+
info.scope = scopeProperty;
723+
}
724+
707725

708726
// Persistence Unit Root Url
709727
appInfo.persistenceUnits.add(info);

0 commit comments

Comments
 (0)