This is an automated email from the ASF dual-hosted git repository.

jungm pushed a commit to branch claude/tomee-4650-fix-26d0cd
in repository https://gitbox.apache.org/repos/asf/tomee.git

commit 3102c285c89f81b8972591b0f32d102fca1e62b3
Author: Markus Jung <[email protected]>
AuthorDate: Thu Jul 23 20:53:50 2026 +0200

    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)
---
 .../assembler/classic/PersistenceUnitInfo.java     |   3 +
 .../classic/ReloadableEntityManagerFactory.java    |   3 +-
 .../apache/openejb/cdi/OptimizedLoaderService.java |   1 +
 .../openejb/cdi/persistence/JpaCDIExtension.java   | 290 +++++++++++++++++++++
 .../org/apache/openejb/config/AppInfoBuilder.java  |  18 ++
 .../ReloadableEntityManagerFactoryCloseTest.java   | 101 +++++++
 .../cdi/persistence/JpaCDIExtensionPerson.java     |  48 ++++
 .../cdi/persistence/JpaCDIExtensionTest.java       | 151 +++++++++++
 .../openejb/jee/jpa/unit/PersistenceUnit.java      |  21 ++
 9 files changed, 635 insertions(+), 1 deletion(-)

diff --git 
a/container/openejb-core/src/main/java/org/apache/openejb/assembler/classic/PersistenceUnitInfo.java
 
b/container/openejb-core/src/main/java/org/apache/openejb/assembler/classic/PersistenceUnitInfo.java
index 5af0c4266c..f38ca2df6d 100644
--- 
a/container/openejb-core/src/main/java/org/apache/openejb/assembler/classic/PersistenceUnitInfo.java
+++ 
b/container/openejb-core/src/main/java/org/apache/openejb/assembler/classic/PersistenceUnitInfo.java
@@ -27,6 +27,9 @@ public class PersistenceUnitInfo extends InfoObject {
     public String id;
     public String name;
     public String provider;
+    // Jakarta Persistence 3.2 CDI integration: <qualifier>/<scope> from 
persistence.xml
+    public final List<String> qualifiers = new ArrayList<>();
+    public String scope;
     public String transactionType;
     public String jtaDataSource;
     public String nonJtaDataSource;
diff --git 
a/container/openejb-core/src/main/java/org/apache/openejb/assembler/classic/ReloadableEntityManagerFactory.java
 
b/container/openejb-core/src/main/java/org/apache/openejb/assembler/classic/ReloadableEntityManagerFactory.java
index 2b3f94439c..8b791d4130 100644
--- 
a/container/openejb-core/src/main/java/org/apache/openejb/assembler/classic/ReloadableEntityManagerFactory.java
+++ 
b/container/openejb-core/src/main/java/org/apache/openejb/assembler/classic/ReloadableEntityManagerFactory.java
@@ -279,7 +279,8 @@ public class ReloadableEntityManagerFactory implements 
EntityManagerFactory, Ser
 
     @Override
     public synchronized void close() {
-        if (delegate != null) {
+        // the application may have closed the EMF itself, closing it twice is 
an error
+        if (delegate != null && delegate.isOpen()) {
             delegate.close();
         }
     }
diff --git 
a/container/openejb-core/src/main/java/org/apache/openejb/cdi/OptimizedLoaderService.java
 
b/container/openejb-core/src/main/java/org/apache/openejb/cdi/OptimizedLoaderService.java
index 99f3ec075d..94e4ecadf6 100644
--- 
a/container/openejb-core/src/main/java/org/apache/openejb/cdi/OptimizedLoaderService.java
+++ 
b/container/openejb-core/src/main/java/org/apache/openejb/cdi/OptimizedLoaderService.java
@@ -127,6 +127,7 @@ public class OptimizedLoaderService implements 
LoaderService {
         }
 
         list.add(new 
org.apache.openejb.cdi.concurrency.ConcurrencyCDIExtension());
+        list.add(new org.apache.openejb.cdi.persistence.JpaCDIExtension());
 
         final Collection<Extension> extensionCopy = new ArrayList<>(list);
 
diff --git 
a/container/openejb-core/src/main/java/org/apache/openejb/cdi/persistence/JpaCDIExtension.java
 
b/container/openejb-core/src/main/java/org/apache/openejb/cdi/persistence/JpaCDIExtension.java
new file mode 100644
index 0000000000..6261a47a81
--- /dev/null
+++ 
b/container/openejb-core/src/main/java/org/apache/openejb/cdi/persistence/JpaCDIExtension.java
@@ -0,0 +1,290 @@
+/*
+ * 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.openejb.cdi.persistence;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.context.Dependent;
+import jakarta.enterprise.event.Observes;
+import jakarta.enterprise.inject.Any;
+import jakarta.enterprise.inject.Default;
+import jakarta.enterprise.inject.spi.AfterBeanDiscovery;
+import jakarta.enterprise.inject.spi.Extension;
+import jakarta.inject.Qualifier;
+import jakarta.persistence.Cache;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.EntityManagerFactory;
+import jakarta.persistence.PersistenceUnitUtil;
+import jakarta.persistence.SchemaManager;
+import jakarta.persistence.criteria.CriteriaBuilder;
+import jakarta.persistence.metamodel.Metamodel;
+import org.apache.openejb.assembler.classic.AppInfo;
+import org.apache.openejb.assembler.classic.PersistenceUnitInfo;
+import org.apache.openejb.cdi.OpenEJBLifecycle;
+import org.apache.openejb.loader.SystemInstance;
+import org.apache.openejb.spi.ContainerSystem;
+import org.apache.openejb.util.LogCategory;
+import org.apache.openejb.util.Logger;
+
+import javax.naming.NamingException;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+
+/**
+ * CDI extension registering the beans required by the Jakarta Persistence 3.2 
/
+ * Jakarta EE 11 CDI integration (Platform specification, "Jakarta Persistence 
&amp;
+ * Jakarta Context and Dependency Injection (CDI) Integration").
+ *
+ * <p>For each persistence unit the container must make available:
+ * <ul>
+ *   <li>an {@link EntityManagerFactory} bean, {@code @ApplicationScoped}, 
whose bean name
+ *       is the persistence unit name;</li>
+ *   <li>an {@link EntityManager} bean, in the scope given by the {@code 
<scope>} element
+ *       (defaulting to {@code jakarta.transaction.TransactionScoped});</li>
+ *   <li>{@link CriteriaBuilder}, {@link PersistenceUnitUtil}, {@link Cache},
+ *       {@link SchemaManager} and {@link Metamodel} beans, {@code 
@Dependent}, each simply
+ *       obtained from the matching getter of the {@code 
EntityManagerFactory}.</li>
+ * </ul>
+ *
+ * <p>All of them carry the qualifiers given by the {@code <qualifier>} 
elements of
+ * {@code persistence.xml}, or {@code @Default} when none is declared.
+ */
+public class JpaCDIExtension implements Extension {
+
+    private static final Logger logger = 
Logger.getInstance(LogCategory.OPENEJB.createChild("cdi"), 
JpaCDIExtension.class);
+
+    private static final String PERSISTENCE_UNIT_NAMING_CONTEXT = 
"openejb/PersistenceUnit/";
+
+    /**
+     * Default scope of the {@code EntityManager} bean. Resolved reflectively 
so that the
+     * extension keeps working on distributions without the Jakarta 
Transactions API.
+     */
+    private static final String TRANSACTION_SCOPED = 
"jakarta.transaction.TransactionScoped";
+
+    void registerBeans(@Observes final AfterBeanDiscovery afterBeanDiscovery) {
+        final AppInfo appInfo = OpenEJBLifecycle.CURRENT_APP_INFO.get();
+        if (appInfo == null) {
+            return;
+        }
+
+        for (final PersistenceUnitInfo unitInfo : appInfo.persistenceUnits) {
+            final Set<Annotation> qualifiers = 
validateAndCreateQualifiers(unitInfo, afterBeanDiscovery);
+            if (qualifiers == null) {
+                continue;
+            }
+
+            final Class<? extends Annotation> entityManagerScope = 
resolveEntityManagerScope(unitInfo, afterBeanDiscovery);
+            if (entityManagerScope == null) {
+                continue;
+            }
+
+            logger.debug("Registering CDI beans for persistence unit '" + 
unitInfo.name + "'");
+
+            afterBeanDiscovery.addBean()
+                    .id("tomee.jpa." + EntityManagerFactory.class.getName() + 
"#" + unitInfo.id)
+                    .beanClass(EntityManagerFactory.class)
+                    .types(Object.class, EntityManagerFactory.class)
+                    .qualifiers(qualifiers.toArray(new Annotation[0]))
+                    .scope(ApplicationScoped.class)
+                    .name(unitInfo.name)
+                    .createWith(cc -> lookupEntityManagerFactory(unitInfo.id));
+
+            // the EntityManager is created per contextual instance and closed 
when the context ends
+            afterBeanDiscovery.addBean()
+                    .id("tomee.jpa." + EntityManager.class.getName() + "#" + 
unitInfo.id)
+                    .beanClass(EntityManager.class)
+                    .types(Object.class, EntityManager.class)
+                    .qualifiers(qualifiers.toArray(new Annotation[0]))
+                    .scope(entityManagerScope)
+                    .produceWith(instance -> 
lookupEntityManagerFactory(unitInfo.id).createEntityManager())
+                    .disposeWith((em, cc) -> {
+                        if (em.isOpen()) {
+                            em.close();
+                        }
+                    });
+
+            addUtilityBean(afterBeanDiscovery, unitInfo, qualifiers, 
CriteriaBuilder.class, EntityManagerFactory::getCriteriaBuilder);
+            addUtilityBean(afterBeanDiscovery, unitInfo, qualifiers, 
PersistenceUnitUtil.class, EntityManagerFactory::getPersistenceUnitUtil);
+            addUtilityBean(afterBeanDiscovery, unitInfo, qualifiers, 
Cache.class, EntityManagerFactory::getCache);
+            addUtilityBean(afterBeanDiscovery, unitInfo, qualifiers, 
SchemaManager.class, EntityManagerFactory::getSchemaManager);
+            addUtilityBean(afterBeanDiscovery, unitInfo, qualifiers, 
Metamodel.class, EntityManagerFactory::getMetamodel);
+        }
+    }
+
+    /**
+     * The five utility beans are {@code @Dependent} and simply delegate to 
the matching
+     * getter of the {@code EntityManagerFactory}.
+     */
+    private <T> void addUtilityBean(final AfterBeanDiscovery 
afterBeanDiscovery,
+                                    final PersistenceUnitInfo unitInfo,
+                                    final Set<Annotation> qualifiers,
+                                    final Class<T> type,
+                                    final Function<EntityManagerFactory, T> 
accessor) {
+        afterBeanDiscovery.addBean()
+                .id("tomee.jpa." + type.getName() + "#" + unitInfo.id)
+                .beanClass(type)
+                .types(Object.class, type)
+                .qualifiers(qualifiers.toArray(new Annotation[0]))
+                .scope(Dependent.class)
+                .createWith(cc -> 
accessor.apply(lookupEntityManagerFactory(unitInfo.id)));
+    }
+
+    /**
+     * Builds the qualifier set of a persistence unit: the {@code <qualifier>} 
elements, or
+     * {@code @Default} when none is declared. {@code @Any} is always added, 
as for any bean.
+     *
+     * @return {@code null} if a qualifier is invalid, in which case a 
definition error has
+     * been reported
+     */
+    private Set<Annotation> validateAndCreateQualifiers(final 
PersistenceUnitInfo unitInfo,
+                                                        final 
AfterBeanDiscovery afterBeanDiscovery) {
+        final Set<Annotation> qualifiers = new LinkedHashSet<>();
+        qualifiers.add(Any.Literal.INSTANCE);
+
+        if (unitInfo.qualifiers.isEmpty()) {
+            qualifiers.add(Default.Literal.INSTANCE);
+            return qualifiers;
+        }
+
+        final ClassLoader loader = 
Thread.currentThread().getContextClassLoader();
+        for (final String qualifierName : unitInfo.qualifiers) {
+            final Class<?> qualifierClass;
+            try {
+                qualifierClass = loader.loadClass(qualifierName);
+            } catch (final ClassNotFoundException e) {
+                afterBeanDiscovery.addDefinitionError(new 
IllegalArgumentException("Qualifier class " + qualifierName
+                        + " of persistence unit " + unitInfo.name + " cannot 
be loaded", e));
+                return null;
+            }
+
+            if (!qualifierClass.isAnnotation()) {
+                afterBeanDiscovery.addDefinitionError(new 
IllegalArgumentException("Qualifier " + qualifierName
+                        + " of persistence unit " + unitInfo.name + " must be 
an annotation type"));
+                return null;
+            }
+
+            @SuppressWarnings("unchecked")
+            final Class<? extends Annotation> annotationClass = (Class<? 
extends Annotation>) qualifierClass;
+            if (!annotationClass.isAnnotationPresent(Qualifier.class)) {
+                afterBeanDiscovery.addDefinitionError(new 
IllegalArgumentException("Qualifier " + qualifierName
+                        + " of persistence unit " + unitInfo.name + " must be 
annotated with @Qualifier"));
+                return null;
+            }
+
+            qualifiers.add(createAnnotation(annotationClass));
+        }
+
+        return qualifiers;
+    }
+
+    /**
+     * Resolves the {@code <scope>} element of the persistence unit, 
defaulting to
+     * {@code jakarta.transaction.TransactionScoped}.
+     *
+     * @return {@code null} if the scope is invalid, in which case a 
definition error has
+     * been reported
+     */
+    @SuppressWarnings("unchecked")
+    private Class<? extends Annotation> resolveEntityManagerScope(final 
PersistenceUnitInfo unitInfo,
+                                                                  final 
AfterBeanDiscovery afterBeanDiscovery) {
+        final String scope = unitInfo.scope == null || 
unitInfo.scope.isBlank() ? TRANSACTION_SCOPED : unitInfo.scope.trim();
+        final ClassLoader loader = 
Thread.currentThread().getContextClassLoader();
+
+        final Class<?> scopeClass;
+        try {
+            scopeClass = loader.loadClass(scope);
+        } catch (final ClassNotFoundException e) {
+            if (unitInfo.scope == null || unitInfo.scope.isBlank()) {
+                // no Jakarta Transactions on the classpath, fall back to 
@Dependent
+                return Dependent.class;
+            }
+            afterBeanDiscovery.addDefinitionError(new 
IllegalArgumentException("Scope class " + scope
+                    + " of persistence unit " + unitInfo.name + " cannot be 
loaded", e));
+            return null;
+        }
+
+        if (!scopeClass.isAnnotation()) {
+            afterBeanDiscovery.addDefinitionError(new 
IllegalArgumentException("Scope " + scope
+                    + " of persistence unit " + unitInfo.name + " must be an 
annotation type"));
+            return null;
+        }
+
+        return (Class<? extends Annotation>) scopeClass;
+    }
+
+    private EntityManagerFactory lookupEntityManagerFactory(final String 
unitId) {
+        final ContainerSystem containerSystem = 
SystemInstance.get().getComponent(ContainerSystem.class);
+        if (containerSystem == null) {
+            throw new IllegalStateException("ContainerSystem is not 
available");
+        }
+
+        final Object instance;
+        try {
+            instance = 
containerSystem.getJNDIContext().lookup(PERSISTENCE_UNIT_NAMING_CONTEXT + 
unitId);
+        } catch (final NamingException e) {
+            throw new IllegalStateException("Unable to lookup persistence unit 
" + unitId, e);
+        }
+
+        if (!(instance instanceof EntityManagerFactory)) {
+            throw new IllegalStateException("Persistence unit " + unitId + " 
is not an EntityManagerFactory, found "
+                    + (instance == null ? "null" : 
instance.getClass().getName()));
+        }
+        return EntityManagerFactory.class.cast(instance);
+    }
+
+    /**
+     * Creates an instance of a member-less annotation type. Members are 
answered with their
+     * default value, which the CDI qualifier rules guarantee to exist.
+     */
+    private Annotation createAnnotation(final Class<? extends Annotation> 
annotationType) {
+        final Map<String, Object> values = new LinkedHashMap<>();
+        for (final Method method : annotationType.getDeclaredMethods()) {
+            values.put(method.getName(), method.getDefaultValue());
+        }
+
+        final InvocationHandler handler = (final Object proxy, final Method 
method, final Object[] args) -> {
+            final String name = method.getName();
+            if ("annotationType".equals(name) && method.getParameterCount() == 
0) {
+                return annotationType;
+            }
+            if ("equals".equals(name) && method.getParameterCount() == 1) {
+                return annotationType.isInstance(args[0]);
+            }
+            if ("hashCode".equals(name) && method.getParameterCount() == 0) {
+                return annotationType.hashCode();
+            }
+            if ("toString".equals(name) && method.getParameterCount() == 0) {
+                return "@" + annotationType.getName() + "()";
+            }
+            if (values.containsKey(name)) {
+                return values.get(name);
+            }
+            throw new IllegalStateException("Unsupported annotation method: " 
+ method);
+        };
+
+        return Annotation.class.cast(Proxy.newProxyInstance(
+                annotationType.getClassLoader(),
+                new Class<?>[]{annotationType},
+                handler));
+    }
+}
diff --git 
a/container/openejb-core/src/main/java/org/apache/openejb/config/AppInfoBuilder.java
 
b/container/openejb-core/src/main/java/org/apache/openejb/config/AppInfoBuilder.java
index 15b41ba69c..9f73d2b8c0 100644
--- 
a/container/openejb-core/src/main/java/org/apache/openejb/config/AppInfoBuilder.java
+++ 
b/container/openejb-core/src/main/java/org/apache/openejb/config/AppInfoBuilder.java
@@ -691,6 +691,9 @@ class AppInfoBuilder {
                 info.jtaDataSource = persistenceUnit.getJtaDataSource();
                 info.nonJtaDataSource = persistenceUnit.getNonJtaDataSource();
 
+                info.qualifiers.addAll(persistenceUnit.getQualifier());
+                info.scope = persistenceUnit.getScope();
+
                 info.jarFiles.addAll(persistenceUnit.getJarFile());
                 info.classes.addAll(persistenceUnit.getClazz());
                 info.mappingFiles.addAll(persistenceUnit.getMappingFile());
@@ -704,6 +707,21 @@ class AppInfoBuilder {
 
                 PersistenceProviderProperties.apply(appModule, info);
 
+                // Jakarta Persistence 3.2: these properties override the 
corresponding XML elements
+                final String qualifiersProperty = 
info.properties.getProperty("jakarta.persistence.qualifiers");
+                if (qualifiersProperty != null) {
+                    info.qualifiers.clear();
+                    for (final String qualifier : 
qualifiersProperty.split(",")) {
+                        if (!qualifier.isBlank()) {
+                            info.qualifiers.add(qualifier.trim());
+                        }
+                    }
+                }
+                final String scopeProperty = 
info.properties.getProperty("jakarta.persistence.scope");
+                if (scopeProperty != null) {
+                    info.scope = scopeProperty;
+                }
+
 
                 // Persistence Unit Root Url
                 appInfo.persistenceUnits.add(info);
diff --git 
a/container/openejb-core/src/test/java/org/apache/openejb/assembler/classic/ReloadableEntityManagerFactoryCloseTest.java
 
b/container/openejb-core/src/test/java/org/apache/openejb/assembler/classic/ReloadableEntityManagerFactoryCloseTest.java
new file mode 100644
index 0000000000..fbb91195a9
--- /dev/null
+++ 
b/container/openejb-core/src/test/java/org/apache/openejb/assembler/classic/ReloadableEntityManagerFactoryCloseTest.java
@@ -0,0 +1,101 @@
+/*
+ * 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.openejb.assembler.classic;
+
+import jakarta.persistence.EntityManagerFactory;
+import org.apache.openejb.persistence.PersistenceUnitInfoImpl;
+import org.apache.openejb.util.reflection.Reflections;
+import org.junit.Test;
+
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.util.Properties;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.Assert.assertEquals;
+
+/**
+ * An application may close a container-managed {@code EntityManagerFactory} 
itself. Undeploy
+ * must then not call {@code close()} on it a second time, which the providers 
reject with
+ * "Attempting to execute an operation on a closed EntityManagerFactory" and 
which made
+ * {@code Assembler.destroyApplication} report an undeploy error.
+ *
+ * @see <a 
href="https://issues.apache.org/jira/browse/TOMEE-4650";>TOMEE-4650</a>
+ */
+public class ReloadableEntityManagerFactoryCloseTest {
+
+    @Test
+    public void closeIsNotPropagatedToAnAlreadyClosedDelegate() {
+        final AtomicInteger closeCalls = new AtomicInteger();
+        final EntityManagerFactory delegate = 
closeCountingEntityManagerFactory(closeCalls);
+
+        final ReloadableEntityManagerFactory remf = 
newReloadableEntityManagerFactory(delegate);
+
+        // the application closes the EMF itself
+        remf.close();
+        assertEquals(1, closeCalls.get());
+
+        // undeploy closes it again, which must be a no-op
+        remf.close();
+        assertEquals("close() must not be propagated to an already closed 
EntityManagerFactory",
+                1, closeCalls.get());
+    }
+
+    private ReloadableEntityManagerFactory 
newReloadableEntityManagerFactory(final EntityManagerFactory delegate) {
+        final PersistenceUnitInfoImpl unitInfo = new PersistenceUnitInfoImpl();
+        unitInfo.setPersistenceUnitName("close-test-unit");
+        unitInfo.setProperties(new Properties());
+        // keep the constructor from eagerly building the real EMF
+        unitInfo.setLazilyInitialized(true);
+
+        final EntityManagerFactoryCallable callable = new 
EntityManagerFactoryCallable(
+                "org.apache.openjpa.persistence.PersistenceProviderImpl", 
unitInfo,
+                getClass().getClassLoader(), null, false);
+
+        final ReloadableEntityManagerFactory remf =
+                new 
ReloadableEntityManagerFactory(getClass().getClassLoader(), callable, unitInfo);
+        Reflections.set(remf, "delegate", delegate);
+        return remf;
+    }
+
+    /**
+     * A minimal {@code EntityManagerFactory} counting {@code close()} calls 
and reporting
+     * itself as closed afterwards, as the providers do.
+     */
+    private EntityManagerFactory closeCountingEntityManagerFactory(final 
AtomicInteger closeCalls) {
+        final boolean[] open = {true};
+
+        final InvocationHandler handler = (final Object proxy, final Method 
method, final Object[] args) -> {
+            switch (method.getName()) {
+                case "close":
+                    closeCalls.incrementAndGet();
+                    open[0] = false;
+                    return null;
+                case "isOpen":
+                    return open[0];
+                default:
+                    return null;
+            }
+        };
+
+        return EntityManagerFactory.class.cast(Proxy.newProxyInstance(
+                getClass().getClassLoader(),
+                new Class<?>[]{EntityManagerFactory.class},
+                handler));
+    }
+}
diff --git 
a/container/openejb-core/src/test/java/org/apache/openejb/cdi/persistence/JpaCDIExtensionPerson.java
 
b/container/openejb-core/src/test/java/org/apache/openejb/cdi/persistence/JpaCDIExtensionPerson.java
new file mode 100644
index 0000000000..51cff5f3ce
--- /dev/null
+++ 
b/container/openejb-core/src/test/java/org/apache/openejb/cdi/persistence/JpaCDIExtensionPerson.java
@@ -0,0 +1,48 @@
+/*
+ * 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.openejb.cdi.persistence;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+
+/**
+ * Entity of the persistence units used by {@link JpaCDIExtensionTest}.
+ */
+@Entity
+public class JpaCDIExtensionPerson {
+
+    @Id
+    private int id;
+
+    private String name;
+
+    public int getId() {
+        return id;
+    }
+
+    public void setId(final int id) {
+        this.id = id;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public void setName(final String name) {
+        this.name = name;
+    }
+}
diff --git 
a/container/openejb-core/src/test/java/org/apache/openejb/cdi/persistence/JpaCDIExtensionTest.java
 
b/container/openejb-core/src/test/java/org/apache/openejb/cdi/persistence/JpaCDIExtensionTest.java
new file mode 100644
index 0000000000..7ccdc4ea9e
--- /dev/null
+++ 
b/container/openejb-core/src/test/java/org/apache/openejb/cdi/persistence/JpaCDIExtensionTest.java
@@ -0,0 +1,151 @@
+/*
+ * 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.openejb.cdi.persistence;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import jakarta.inject.Qualifier;
+import jakarta.persistence.EntityManagerFactory;
+import jakarta.persistence.PersistenceUnitUtil;
+import org.apache.openejb.jee.jpa.unit.Persistence;
+import org.apache.openejb.jee.jpa.unit.PersistenceUnit;
+import org.apache.openejb.junit.ApplicationComposer;
+import org.apache.openejb.testing.Configuration;
+import org.apache.openejb.testing.Module;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.util.Properties;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNotSame;
+
+/**
+ * Verifies the Jakarta Persistence 3.2 CDI integration: for each persistence 
unit the
+ * container registers an {@code EntityManagerFactory} and the five utility 
beans, carrying
+ * the qualifiers declared by the {@code <qualifier>} elements of 
persistence.xml, or
+ * {@code @Default} when none is declared.
+ */
+@RunWith(ApplicationComposer.class)
+public class JpaCDIExtensionTest {
+
+    @Inject
+    private QualifiedBean qualifiedBean;
+
+    @Inject
+    private DefaultBean defaultBean;
+
+    @Configuration
+    public Properties config() {
+        final Properties p = new Properties();
+        p.setProperty("JpaCDIExtensionTestDb", 
"new://Resource?type=DataSource");
+        p.setProperty("JpaCDIExtensionTestDb.JdbcDriver", 
"org.hsqldb.jdbcDriver");
+        p.setProperty("JpaCDIExtensionTestDb.JdbcUrl", 
"jdbc:hsqldb:mem:jpa-cdi-extension");
+        return p;
+    }
+
+    @Module
+    public Persistence persistence() {
+        final PersistenceUnit qualified = new 
PersistenceUnit("qualified-unit");
+        qualified.addClass(JpaCDIExtensionPerson.class);
+        qualified.setExcludeUnlistedClasses(true);
+        qualified.getQualifier().add(PersonUnit.class.getName());
+        qualified.setJtaDataSource("JpaCDIExtensionTestDb");
+        qualified.setProperty("openjpa.jdbc.SynchronizeMappings", 
"buildSchema(ForeignKeys=true)");
+        
qualified.getProperties().setProperty("openjpa.RuntimeUnenhancedClasses", 
"supported");
+
+        final PersistenceUnit unqualified = new 
PersistenceUnit("default-unit");
+        unqualified.addClass(JpaCDIExtensionPerson.class);
+        unqualified.setExcludeUnlistedClasses(true);
+        unqualified.setJtaDataSource("JpaCDIExtensionTestDb");
+        unqualified.setProperty("openjpa.jdbc.SynchronizeMappings", 
"buildSchema(ForeignKeys=true)");
+        
unqualified.getProperties().setProperty("openjpa.RuntimeUnenhancedClasses", 
"supported");
+
+        final Persistence persistence = new Persistence(qualified);
+        persistence.getPersistenceUnit().add(unqualified);
+        persistence.setVersion("3.2");
+        return persistence;
+    }
+
+    @Module
+    public Class<?>[] beans() {
+        return new Class<?>[]{QualifiedBean.class, DefaultBean.class};
+    }
+
+    @Test
+    public void qualifiedEntityManagerFactoryIsInjectable() {
+        assertNotNull("EntityManagerFactory should be injectable via its 
persistence.xml qualifier",
+                qualifiedBean.getEntityManagerFactory());
+    }
+
+    @Test
+    public void qualifierSelectsTheMatchingPersistenceUnit() {
+        // the two units are distinct beans, so the qualifier must not resolve 
to the default one
+        assertNotSame(qualifiedBean.getEntityManagerFactory(), 
defaultBean.getEntityManagerFactory());
+    }
+
+    @Test
+    public void persistenceUnitUtilIsInjectableWithTheQualifier() {
+        assertNotNull("PersistenceUnitUtil should be injectable", 
qualifiedBean.getPersistenceUnitUtil());
+    }
+
+    @Test
+    public void unqualifiedPersistenceUnitIsInjectableWithDefaultQualifier() {
+        assertNotNull("A unit without <qualifier> should be injectable with 
@Default",
+                defaultBean.getEntityManagerFactory());
+    }
+
+    @Qualifier
+    @Retention(RetentionPolicy.RUNTIME)
+    @Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, 
ElementType.TYPE})
+    public @interface PersonUnit {
+    }
+
+    @ApplicationScoped
+    public static class QualifiedBean {
+        @Inject
+        @PersonUnit
+        private EntityManagerFactory entityManagerFactory;
+
+        @Inject
+        @PersonUnit
+        private PersistenceUnitUtil persistenceUnitUtil;
+
+        public EntityManagerFactory getEntityManagerFactory() {
+            return entityManagerFactory;
+        }
+
+        public PersistenceUnitUtil getPersistenceUnitUtil() {
+            return persistenceUnitUtil;
+        }
+
+    }
+
+    @ApplicationScoped
+    public static class DefaultBean {
+        @Inject
+        private EntityManagerFactory entityManagerFactory;
+
+        public EntityManagerFactory getEntityManagerFactory() {
+            return entityManagerFactory;
+        }
+    }
+}
diff --git 
a/container/openejb-jee/src/main/java/org/apache/openejb/jee/jpa/unit/PersistenceUnit.java
 
b/container/openejb-jee/src/main/java/org/apache/openejb/jee/jpa/unit/PersistenceUnit.java
index 0af278cab2..447cfb4d63 100644
--- 
a/container/openejb-jee/src/main/java/org/apache/openejb/jee/jpa/unit/PersistenceUnit.java
+++ 
b/container/openejb-jee/src/main/java/org/apache/openejb/jee/jpa/unit/PersistenceUnit.java
@@ -82,6 +82,8 @@ import java.util.Properties;
 @XmlType(name = "", propOrder = {
     "description",
     "provider",
+    "qualifier",
+    "scope",
     "jtaDataSource",
     "nonJtaDataSource",
     "mappingFile",
@@ -99,6 +101,10 @@ public class PersistenceUnit {
 
     protected String description;
     protected String provider;
+    @XmlElement(name = "qualifier")
+    protected List<String> qualifier;
+    @XmlElement(name = "scope")
+    protected String scope;
     @XmlElement(name = "jta-data-source")
     protected String jtaDataSource;
     @XmlElement(name = "non-jta-data-source")
@@ -174,6 +180,21 @@ public class PersistenceUnit {
         setProvider(value == null ? null : value.getName());
     }
 
+    public List<String> getQualifier() {
+        if (qualifier == null) {
+            qualifier = new ArrayList<>();
+        }
+        return this.qualifier;
+    }
+
+    public String getScope() {
+        return scope;
+    }
+
+    public void setScope(final String value) {
+        this.scope = value;
+    }
+
     public String getJtaDataSource() {
         return jtaDataSource;
     }


Reply via email to