diff --git a/integrations/cdi/jpa-cdi/src/main/java/io/helidon/integrations/cdi/jpa/PersistenceExtension.java b/integrations/cdi/jpa-cdi/src/main/java/io/helidon/integrations/cdi/jpa/PersistenceExtension.java index bd6de0e7b5e..31767003a1d 100644 --- a/integrations/cdi/jpa-cdi/src/main/java/io/helidon/integrations/cdi/jpa/PersistenceExtension.java +++ b/integrations/cdi/jpa-cdi/src/main/java/io/helidon/integrations/cdi/jpa/PersistenceExtension.java @@ -74,6 +74,7 @@ import jakarta.enterprise.inject.spi.BeforeBeanDiscovery; import jakarta.enterprise.inject.spi.Extension; import jakarta.enterprise.inject.spi.ProcessAnnotatedType; +import jakarta.enterprise.inject.spi.ProcessBeanAttributes; import jakarta.enterprise.inject.spi.ProcessInjectionPoint; import jakarta.enterprise.inject.spi.WithAnnotations; import jakarta.enterprise.inject.spi.configurator.AnnotatedFieldConfigurator; @@ -150,6 +151,8 @@ public final class PersistenceExtension implements Extension { private static final Annotation[] EMPTY_ANNOTATION_ARRAY = new Annotation[0]; + private static final Logger LOGGER = Logger.getLogger(PersistenceExtension.class.getName()); + /* * Instance fields. @@ -298,6 +301,31 @@ private void makePersistencePropertyARepeatableQualifier(@Observes BeforeBea event.addQualifier(PersistenceProperty.class); } + /** + * {@linkplain ProcessBeanAttributes#veto() Vetoes} any bean whose bean types includes the deprecated {@link + * JtaDataSourceProvider} class, since it is replaced by {@link JtaAdaptingDataSourceProvider}. + * + * @param event the {@link ProcessBeanAttributes} event in question; must not be {@code null} + * + * @exception NullPointerException if {@code event} is {@code null} + * + * @see JtaAdaptingDataSourceProvider + */ + private void vetoDeprecatedJtaDataSourceProvider(@Observes ProcessBeanAttributes event) { + if (!this.enabled) { + return; + } + if (LOGGER.isLoggable(Level.FINE)) { + LOGGER.logp(Level.FINE, this.getClass().getName(), "vetoDeprecatedJtaDataSourceProvider", + "Vetoing BeanAttributes {0} representing " + + JtaDataSourceProvider.class + + " because it is deprecated and " + + JtaAdaptingDataSourceProvider.class + + " replaces it", event.getBeanAttributes()); + } + event.veto(); + } + private void rewriteJpaAnnotations(@Observes @WithAnnotations({PersistenceContext.class, PersistenceUnit.class}) ProcessAnnotatedType event) { diff --git a/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/TestPersistenceExtension.java b/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/TestPersistenceExtension.java new file mode 100644 index 00000000000..6f94ae737e0 --- /dev/null +++ b/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/TestPersistenceExtension.java @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.integrations.cdi.jpa; + +import jakarta.annotation.sql.DataSourceDefinition; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.Dependent; +import jakarta.enterprise.inject.se.SeContainer; +import jakarta.enterprise.inject.se.SeContainerInitializer; +import jakarta.enterprise.inject.Instance; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.fail; + +class TestPersistenceExtension { + + private SeContainer sec; + + private TestPersistenceExtension() { + super(); + } + + @BeforeEach + @SuppressWarnings("unchecked") + final void initializeCdiContainer() { + System.setProperty(JpaExtension.class.getName() + ".enabled", "false"); + System.setProperty(PersistenceExtension.class.getName() + ".enabled", "true"); + Class cdiSeJtaPlatformClass; + try { + // Load it dynamically because Hibernate won't be on the classpath when we're testing with Eclipselink + cdiSeJtaPlatformClass = + Class.forName("io.helidon.integrations.cdi.hibernate.CDISEJtaPlatform", + false, + Thread.currentThread().getContextClassLoader()); + } catch (ClassNotFoundException e) { + cdiSeJtaPlatformClass = null; + } + SeContainerInitializer i = SeContainerInitializer.newInstance() + .disableDiscovery() + .addExtensions(PersistenceExtension.class, + com.arjuna.ats.jta.cdi.TransactionExtension.class, + io.helidon.integrations.datasource.hikaricp.cdi.HikariCPBackedDataSourceExtension.class) + .addBeanClasses(Caturgiator.class, + Frobnicator.class); + if (cdiSeJtaPlatformClass != null) { + i = i.addBeanClasses(cdiSeJtaPlatformClass); + } + this.sec = i.initialize(); + } + + @AfterEach + final void closeCdiContainer() { + if (this.sec != null) { + this.sec.close(); + } + System.setProperty(PersistenceExtension.class.getName() + ".enabled", "false"); + System.setProperty(JpaExtension.class.getName() + ".enabled", "true"); + } + + // @Disabled + @Test + final void testSpike() { + Instance fi = sec.select(Frobnicator.class); + Frobnicator f = fi.get(); + assertThat(f.em.isOpen(), is(true)); + assertThat(f.em, instanceOf(JtaEntityManager.class)); + + Instance ci = sec.select(Caturgiator.class); + Caturgiator c = ci.get(); + assertThat(c.em, is(f.em)); + + fi.destroy(f); + assertThat(c.em.isOpen(), is(true)); + ci.destroy(c); + } + + @DataSourceDefinition( + name = "test", + className = "org.h2.jdbcx.JdbcDataSource", + url = "jdbc:h2:mem:TestPersistenceExtension", + serverName = "", + properties = { + "user=sa" + } + ) + @Dependent + private static class Frobnicator { + + @PersistenceContext(unitName = "test") + private EntityManager em; + + @Inject + Frobnicator() { + super(); + } + + } + + @Dependent + private static class Caturgiator { + + @PersistenceContext(unitName = "test") + private EntityManager em; + + @Inject + Caturgiator() { + super(); + } + + } + +} diff --git a/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp/Microblog.java b/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp/Microblog.java index a948f951992..9b298eb0414 100644 --- a/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp/Microblog.java +++ b/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp/Microblog.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019, 2022 Oracle and/or its affiliates. + * Copyright (c) 2019, 2023 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -95,6 +95,7 @@ public class Microblog implements Serializable { mappedBy = "microblog", targetEntity = Chirp.class ) + @SuppressWarnings("serial") private List chirps; @Deprecated diff --git a/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/Author.java b/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/Author.java new file mode 100644 index 00000000000..a8162526ed0 --- /dev/null +++ b/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/Author.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.integrations.cdi.jpa.chirp2; + +import java.io.Serializable; +import java.util.Objects; + +import jakarta.persistence.Access; +import jakarta.persistence.AccessType; +import jakarta.persistence.Basic; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Access(value = AccessType.FIELD) +@Entity(name = "Author") +@Table(name = "AUTHOR") +public class Author implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @Column(name = "ID", + insertable = true, + nullable = false, + updatable = false) + Integer id; + + @Basic(optional = false) + @Column(name = "NAME", + insertable = true, + nullable = false, + unique = true, + updatable = true) + private String name; + + @Deprecated + protected Author() { + super(); + } + + public Author(int id, String name) { + super(); + this.id = id; + this.setName(name); + } + + public Integer getId() { + return id; + } + + public String getName() { + return this.name; + } + + public void setName(final String name) { + this.name = Objects.requireNonNull(name); + } + + @Override + public int hashCode() { + final Object name = this.getName(); + return name == null ? 0 : name.hashCode(); + } + + @Override + public boolean equals(final Object other) { + if (other == this) { + return true; + } else if (other instanceof Author) { + final Author her = (Author) other; + final Object name = this.getName(); + if (name == null) { + if (her.getName() != null) { + return false; + } + } else if (!name.equals(her.getName())) { + return false; + } + return true; + } else { + return false; + } + } + +} diff --git a/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/Chirp.java b/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/Chirp.java new file mode 100644 index 00000000000..15974a34b97 --- /dev/null +++ b/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/Chirp.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.integrations.cdi.jpa.chirp2; + +import java.io.Serializable; +import java.util.Objects; + +import jakarta.persistence.Access; +import jakarta.persistence.AccessType; +import jakarta.persistence.Basic; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +@Access(value = AccessType.FIELD) +@Entity(name = "Chirp") +@Table(name = "CHIRP") +public class Chirp implements Serializable { + + private static final long serialVersionUID = 1L; + + @Column( + insertable = true, + name = "ID", + nullable = false, + updatable = false + ) + @Id + private Integer id; + + @JoinColumn( + insertable = true, + name = "MICROBLOG_ID", + nullable = false, + referencedColumnName = "ID", + updatable = false + ) + @ManyToOne( + optional = false, + targetEntity = Microblog.class + ) + private Microblog microblog; + + @Basic(optional = false) + @Column( + name = "CONTENT", + insertable = true, + nullable = false, + updatable = true) + private String contents; + + /** + * This constructor exists to fulfil the requirement that all JPA + * entities have a zero-argument constructor and for no other + * purpose. + * + * @deprecated Please use the {@link #Chirp(int, Microblog, String)} + * constructor instead. + */ + @Deprecated + protected Chirp() { + super(); + } + + public Chirp(int id, Microblog microblog, String contents) { + super(); + this.id = id; + this.setMicroblog(microblog); + this.setContents(contents); + } + + public Integer getId() { + return this.id; + } + + public String getContents() { + return this.contents; + } + + public void setContents(final String contents) { + this.contents = Objects.requireNonNull(contents); + } + + public Microblog getMicroblog() { + return this.microblog; + } + + void setMicroblog(final Microblog microblog) { + this.microblog = Objects.requireNonNull(microblog); + } + + @Override + public int hashCode() { + int hashCode = 17; + + final Object contents = this.getContents(); + int c = contents == null ? 0 : contents.hashCode(); + hashCode = 37 * hashCode + c; + + return hashCode; + } + + @Override + public boolean equals(final Object other) { + if (other == this) { + return true; + } else if (other instanceof Chirp) { + final Chirp her = (Chirp) other; + final Object contents = this.getContents(); + if (contents == null) { + if (her.getContents() != null) { + return false; + } + } else if (!contents.equals(her.getContents())) { + return false; + } + + return true; + } else { + return false; + } + } + + @Override + public String toString() { + return this.getContents(); + } + +} diff --git a/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/Microblog.java b/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/Microblog.java new file mode 100644 index 00000000000..e2118a41c4f --- /dev/null +++ b/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/Microblog.java @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.integrations.cdi.jpa.chirp2; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import jakarta.persistence.Access; +import jakarta.persistence.AccessType; +import jakarta.persistence.Basic; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +/** + * A simple collection of small blog articles that will not scale to + * any appreciable volume and is useful only for unit tests. + */ +@Access(value = AccessType.FIELD) +@Entity(name = "Microblog") +@Table( + name = "MICROBLOG", + uniqueConstraints = { + @UniqueConstraint( + columnNames = { + "NAME", + "AUTHOR_ID" + } + ) + } +) +public class Microblog implements Serializable { + + private static final long serialVersionUID = 1L; + + @Column( + insertable = true, + name = "ID", + updatable = false + ) + @Id + private Integer id; + + @JoinColumn( + insertable = true, + name = "AUTHOR_ID", + referencedColumnName = "ID", + updatable = false + ) + @ManyToOne( + cascade = { + CascadeType.PERSIST + }, + optional = false, + targetEntity = Author.class + ) + private Author author; + + @Basic(optional = false) + @Column( + insertable = true, + name = "NAME", + updatable = false + ) + private String name; + + @OneToMany( + cascade = CascadeType.ALL, + fetch = FetchType.LAZY, + mappedBy = "microblog", + targetEntity = Chirp.class + ) + @SuppressWarnings("serial") + private List chirps; + + @Deprecated + protected Microblog() { + super(); + } + + public Microblog(int id, Author author, String name) { + super(); + this.id = id; + this.author = Objects.requireNonNull(author); + this.name = Objects.requireNonNull(name); + } + + public Integer getId() { + return this.id; + } + + public Author getAuthor() { + return this.author; + } + + public String getName() { + return this.name; + } + + public List getChirps() { + return this.chirps; + } + + public void addChirp(final Chirp chirp) { + if (chirp != null) { + List chirps = this.chirps; + if (chirps == null) { + chirps = new ArrayList<>(); + this.chirps = chirps; + } + chirps.add(chirp); + chirp.setMicroblog(this); + } + } + + @Override + public int hashCode() { + int hashCode = 17; + + final Object name = this.getName(); + int c = name == null ? 0 : name.hashCode(); + hashCode = 37 * hashCode + c; + + final Object author = this.getAuthor(); + c = author == null ? 0 : name.hashCode(); + hashCode = 37 * hashCode + c; + + return hashCode; + } + + @Override + public boolean equals(final Object other) { + if (other == this) { + return true; + } else if (other instanceof Microblog) { + final Microblog her = (Microblog) other; + final Object name = this.getName(); + if (name == null) { + if (her.getName() != null) { + return false; + } + } else if (!name.equals(her.getName())) { + return false; + } + + final Object author = this.getAuthor(); + if (author == null) { + if (her.getAuthor() != null) { + return false; + } + } else if (!author.equals(her.getAuthor())) { + return false; + } + + return true; + } else { + return false; + } + } + +} diff --git a/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/TestCascadePersist2.java b/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/TestCascadePersist2.java new file mode 100644 index 00000000000..59d91e62831 --- /dev/null +++ b/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/TestCascadePersist2.java @@ -0,0 +1,293 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.integrations.cdi.jpa.chirp2; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import javax.sql.DataSource; + +import jakarta.annotation.sql.DataSourceDefinition; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.BeforeDestroyed; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.inject.se.SeContainer; +import jakarta.enterprise.inject.se.SeContainerInitializer; +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.PersistenceContextType; +import jakarta.persistence.SynchronizationType; +import jakarta.transaction.HeuristicMixedException; +import jakarta.transaction.HeuristicRollbackException; +import jakarta.transaction.NotSupportedException; +import jakarta.transaction.RollbackException; +import jakarta.transaction.Status; +import jakarta.transaction.SystemException; +import jakarta.transaction.TransactionManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.CoreMatchers.sameInstance; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.fail; + +@ApplicationScoped +@DataSourceDefinition( + name = "chirp2", + className = "org.h2.jdbcx.JdbcDataSource", + url = "jdbc:h2:mem:TestCascadePersist2;DB_CLOSE_DELAY=-1;MODE=LEGACY;INIT=SET TRACE_LEVEL_FILE=4\\;RUNSCRIPT FROM 'classpath:chirp2.ddl'\\;", + serverName = "", + properties = { + "user=sa" + } +) +class TestCascadePersist2 { + + private SeContainer cdiContainer; + + @Inject + private TransactionManager transactionManager; + + @PersistenceContext( + type = PersistenceContextType.TRANSACTION, + synchronization = SynchronizationType.SYNCHRONIZED, + unitName = "chirp2" + ) + private EntityManager em; + + + /* + * Constructors. + */ + + + TestCascadePersist2() { + super(); + } + + + /* + * Setup and teardown methods. + */ + + + @BeforeEach + void startCdiContainerAndRunDDL() throws SQLException { + System.setProperty(io.helidon.integrations.cdi.jpa.JpaExtension.class.getName() + ".enabled", "false"); + System.setProperty(io.helidon.integrations.cdi.jpa.PersistenceExtension.class.getName() + ".enabled", "true"); + final SeContainerInitializer initializer = SeContainerInitializer.newInstance() + .addBeanClasses(this.getClass()); + this.cdiContainer = initializer.initialize(); + } + + @AfterEach + void shutDownCdiContainer() { + if (this.cdiContainer != null) { + this.cdiContainer.close(); + } + System.setProperty(io.helidon.integrations.cdi.jpa.PersistenceExtension.class.getName() + ".enabled", "false"); + System.setProperty(io.helidon.integrations.cdi.jpa.JpaExtension.class.getName() + ".enabled", "true"); + } + + + /* + * Support methods. + */ + + + TestCascadePersist2 self() { + return this.cdiContainer.select(TestCascadePersist2.class).get(); + } + + EntityManager getEntityManager() { + return this.em; + } + + TransactionManager getTransactionManager() { + return this.transactionManager; + } + + private void onShutdown(@Observes @BeforeDestroyed(ApplicationScoped.class) final Object event, + final TransactionManager tm) throws SystemException { + // If an assertion fails, or some other error happens in the + // CDI container, there may be a current transaction that has + // neither been committed nor rolled back. Because the + // Narayana transaction engine is fundamentally static, this + // means that a transaction affiliation with the main thread + // may "leak" into another JUnit test (since JUnit, by + // default, forks once, and then runs all tests in the same + // JVM). CDI, thankfully, will fire an event for the + // application context shutting down, even in the case of + // errors. + if (tm.getStatus() != Status.STATUS_NO_TRANSACTION) { + tm.rollback(); + } + } + + + /* + * Test methods. + */ + + + @Test + void testCascadePersist() + throws HeuristicMixedException, + HeuristicRollbackException, + InterruptedException, + NotSupportedException, + RollbackException, + SQLException, + SystemException + { + + // Get a CDI contextual reference to this test instance. It + // is important to use "self" in this test instead of "this". + final TestCascadePersist2 self = self(); + assertThat(self, notNullValue()); + + // Get the EntityManager that is synchronized with and scoped + // to a JTA transaction. + final EntityManager em = self.getEntityManager(); + assertThat(em, notNullValue()); + assertThat(em.isOpen(), is(true)); + + // We haven't started any kind of transaction yet and we + // aren't testing anything using + // the @jakarta.transaction.Transactional annotation so there is + // no transaction in effect so the EntityManager cannot be + // joined to one. + assertThat(em.isJoinedToTransaction(), is(false)); + + // Get the TransactionManager that normally is behind the + // scenes and use it to start a Transaction. This simulates + // entering a method annotated + // with @Transactional(TxType.REQUIRES_NEW) or similar. + final TransactionManager tm = self.getTransactionManager(); + tm.setTransactionTimeout(20 * 60); // 20 minutes for debugging + tm.begin(); + assertThat(tm.getStatus(), is(Status.STATUS_ACTIVE)); + + // Now magically our EntityManager should be joined to it. + assertThat(em.isJoinedToTransaction(), is(true)); + + // Create an author but don't persist him explicitly. + Author author = new Author(1, "Abraham Lincoln"); + assertThat(author.getId(), is(1)); + + // Set up a blog for that Author. + Microblog blog = new Microblog(1, author, "Gettysburg Address Draft 1"); + + // Persist the blog. The Author should be persisted too. + em.persist(blog); + assertThat(em.contains(blog), is(true)); + assertThat(em.contains(author), is(true)); + + // Commit the transaction. + tm.commit(); + assertThat(tm.getStatus(), is(Status.STATUS_NO_TRANSACTION)); + assertThat(author.getId(), is(1)); + assertThat(blog.getId(), is(1)); + + // We're no longer in a transaction. + assertThat(em.isJoinedToTransaction(), is(false)); + + // The persistence context should be cleared. + assertThat(em.contains(blog), is(false)); + assertThat(em.contains(author), is(false)); + + // Let's check the database directly. + final DataSource ds = this.cdiContainer.select(DataSource.class).get(); + try (final Connection connection = ds.getConnection(); + final Statement statement = connection.createStatement()) { + ResultSet rs = statement.executeQuery("SELECT COUNT(1) FROM MICROBLOG"); + try { + assertThat(rs.next(), is(true)); + assertThat(rs.getInt(1), is(1)); + } finally { + rs.close(); + } + rs = statement.executeQuery("SELECT COUNT(1) FROM AUTHOR"); + try { + assertThat(rs.next(), is(true)); + assertThat(rs.getInt(1), is(1)); + } finally { + rs.close(); + } + } + + // Start a new transaction. + tm.begin(); + + assertThat(em.contains(blog), is(false)); + final Microblog newBlog = em.find(Microblog.class, Integer.valueOf(1)); + assertThat(newBlog, notNullValue()); + assertThat(em.contains(newBlog), is(true)); + + assertThat(newBlog.getId(), is(blog.getId())); + blog = newBlog; + + // Now let's have our author write some stuff. + final Chirp chirp1 = new Chirp(1, blog, "Four score and seven years ago"); + final Chirp chirp2 = new Chirp(2, blog, "our fathers brought forth on this continent,"); + final Chirp chirp3 = new Chirp(3, blog, "a new nation, conceived in Liberty, " + + "and dedicated to the proposition that all men are created " + + "equal. Now we are engaged in a great civil war, testing " + + "whether that nation, or any nation so conceived and so " + + "dedicated, can long endure."); + blog.addChirp(chirp1); + assertThat(chirp1.getMicroblog(), sameInstance(blog)); + blog.addChirp(chirp2); + assertThat(chirp2.getMicroblog(), sameInstance(blog)); + blog.addChirp(chirp3); + assertThat(chirp3.getMicroblog(), sameInstance(blog)); + + // Commit the transaction. The changes should be propagated. + // However, this will fail, because the third chirp above is + // (deliberately) too long. The transaction should roll back. + try { + tm.commit(); + fail("Commit was able to happen"); + } catch (final RollbackException expected) { + + } + + // Now the question is: were any chirps written? They + // should not have been written (i.e. the rollback should have + // functioned properly. Let's make sure. + try (final Connection connection = ds.getConnection(); + final Statement statement = connection.createStatement()) { + assertThat(connection.getTransactionIsolation(), is(Connection.TRANSACTION_READ_COMMITTED)); + ResultSet rs = statement.executeQuery("SELECT COUNT(1) FROM CHIRP"); + try { + assertThat(rs.next(), is(true)); + // XXX This fails from time to time, returning 1 or 2. + assertThat(rs.getInt(1), is(0)); + } finally { + rs.close(); + } + } + + } + +} diff --git a/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/TestExtendedSynchronizedEntityManager2.java b/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/TestExtendedSynchronizedEntityManager2.java new file mode 100644 index 00000000000..696061460da --- /dev/null +++ b/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/TestExtendedSynchronizedEntityManager2.java @@ -0,0 +1,235 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.integrations.cdi.jpa.chirp2; + +import jakarta.annotation.sql.DataSourceDefinition; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.BeforeDestroyed; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.inject.se.SeContainer; +import jakarta.enterprise.inject.se.SeContainerInitializer; +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.PersistenceContextType; +import jakarta.persistence.PersistenceException; +import jakarta.persistence.SynchronizationType; +import jakarta.transaction.HeuristicMixedException; +import jakarta.transaction.HeuristicRollbackException; +import jakarta.transaction.NotSupportedException; +import jakarta.transaction.RollbackException; +import jakarta.transaction.Status; +import jakarta.transaction.SystemException; +import jakarta.transaction.TransactionManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; + +@ApplicationScoped +@DataSourceDefinition( + name = "chirp2", + className = "org.h2.jdbcx.JdbcDataSource", + url = "jdbc:h2:mem:TestExtendedSynchronizedEntityManager2;MODE=LEGACY;INIT=SET TRACE_LEVEL_FILE=4\\;RUNSCRIPT FROM 'classpath:chirp2.ddl'", + serverName = "", + properties = { + "user=sa" + } +) +class TestExtendedSynchronizedEntityManager2 { + + static { + System.setProperty("jpaAnnotationRewritingEnabled", "true"); + } + + private SeContainer cdiContainer; + + @Inject + private TransactionManager transactionManager; + + @PersistenceContext( + type = PersistenceContextType.EXTENDED, + synchronization = SynchronizationType.SYNCHRONIZED, + unitName = "chirp2" + ) + private EntityManager extendedSynchronizedEntityManager; + + + /* + * Constructors. + */ + + + TestExtendedSynchronizedEntityManager2() { + super(); + } + + + /* + * Setup and teardown methods. + */ + + + @BeforeEach + void startCdiContainer() { + System.setProperty(io.helidon.integrations.cdi.jpa.JpaExtension.class.getName() + ".enabled", "false"); + System.setProperty(io.helidon.integrations.cdi.jpa.PersistenceExtension.class.getName() + ".enabled", "true"); + SeContainerInitializer initializer = SeContainerInitializer.newInstance() + .addBeanClasses(this.getClass()); + assertThat(initializer, notNullValue()); + this.cdiContainer = initializer.initialize(); + } + + @AfterEach + void shutDownCdiContainer() { + if (this.cdiContainer != null) { + this.cdiContainer.close(); + } + System.setProperty(io.helidon.integrations.cdi.jpa.PersistenceExtension.class.getName() + ".enabled", "false"); + System.setProperty(io.helidon.integrations.cdi.jpa.JpaExtension.class.getName() + ".enabled", "true"); + } + + + /* + * Support methods. + */ + + + /** + * A "business method" providing access to one of this {@link + * TestJpaTransactionScopedEntityManager}'s {@link EntityManager} + * instances for use by {@link Test}-annotated methods. + * + * @return a non-{@code null} {@link EntityManager} + */ + EntityManager getExtendedSynchronizedEntityManager() { + return this.extendedSynchronizedEntityManager; + } + + TransactionManager getTransactionManager() { + return this.transactionManager; + } + + private void onShutdown(@Observes @BeforeDestroyed(ApplicationScoped.class) Object event, + TransactionManager tm) throws SystemException { + // If an assertion fails, or some other error happens in the + // CDI container, there may be a current transaction that has + // neither been committed nor rolled back. Because the + // Narayana transaction engine is fundamentally static, this + // means that a transaction affiliation with the main thread + // may "leak" into another JUnit test (since JUnit, by + // default, forks once, and then runs all tests in the same + // JVM). CDI, thankfully, will fire an event for the + // application context shutting down, even in the case of + // errors. + if (tm.getStatus() != Status.STATUS_NO_TRANSACTION) { + tm.rollback(); + } + } + + + /* + * Test methods. + */ + + + @Test + void testExtendedSynchronizedEntityManager() + throws HeuristicMixedException, + HeuristicRollbackException, + NotSupportedException, + RollbackException, + SystemException + { + + // Get a CDI contextual reference to this test instance. It + // is important to use "self" in this test instead of "this". + TestExtendedSynchronizedEntityManager2 self = + this.cdiContainer.select(TestExtendedSynchronizedEntityManager2.class).get(); + assertThat(self, notNullValue()); + + // Get the EntityManager that is synchronized with but whose + // persistence context extends past a single JTA transaction. + EntityManager em = self.getExtendedSynchronizedEntityManager(); + assertThat(em, notNullValue()); + assertThat(em.isOpen(), is(true)); + + // We haven't started any kind of transaction yet and we + // aren't testing anything using + // the @jakarta.transaction.Transactional annotation so there is + // no transaction in effect so the EntityManager cannot be + // joined to one. + assertThat(em.isJoinedToTransaction(), is(false)); + + // Create a JPA entity and try to insert it. Should be just + // fine. + Author author = new Author(1, "Abraham Lincoln"); + + // With an EXTENDED EntityManager, persisting outside of a + // transaction is OK. + em.persist(author); + + // Our PersistenceContextType is EXTENDED, not TRANSACTION, so + // the underlying persistence context spans transactions. + assertThat(em.contains(author), is(true)); + + // Get the TransactionManager that normally is behind the + // scenes and use it to start a Transaction. + TransactionManager tm = self.getTransactionManager(); + tm.begin(); + + // Now magically our EntityManager should be joined to it. + assertThat(em.isJoinedToTransaction(), is(true)); + + // Roll the transaction back and note that our EntityManager + // is no longer joined to it. + tm.rollback(); + assertThat(em.isJoinedToTransaction(), is(false)); + assertThat(em.contains(author), is(false)); + + // Start another transaction and persist our Author. + tm.begin(); + + try { + // See + // https://www.baeldung.com/hibernate-detached-entity-passed-to-persist#trying-to-persist-a-detached-entity + // and + // https://hibernate.atlassian.net/browse/HHH-15738. Eclipselink + // handles all this just fine. + em.persist(author); + + assertThat(em.contains(author), is(true)); + tm.commit(); + + // The transaction is over, so our EntityManager is not + // joined to one anymore. + assertThat(em.isJoinedToTransaction(), is(false)); + + // Our PersistenceContextType is EXTENDED, not + // TRANSACTION, so the underlying persistence context + // spans transactions. + assertThat(em.contains(author), is(true)); + } catch (PersistenceException hhh15738) { + assertThat(tm.getStatus(), is(Status.STATUS_MARKED_ROLLBACK)); + tm.rollback(); + } + } + +} diff --git a/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/TestExtendedUnsynchronizedEntityManager2.java b/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/TestExtendedUnsynchronizedEntityManager2.java new file mode 100644 index 00000000000..575d17440a5 --- /dev/null +++ b/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/TestExtendedUnsynchronizedEntityManager2.java @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.integrations.cdi.jpa.chirp2; + +import jakarta.annotation.sql.DataSourceDefinition; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.BeforeDestroyed; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.inject.se.SeContainer; +import jakarta.enterprise.inject.se.SeContainerInitializer; +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.PersistenceContextType; +import jakarta.persistence.PersistenceException; +import jakarta.persistence.SynchronizationType; +import jakarta.transaction.HeuristicMixedException; +import jakarta.transaction.HeuristicRollbackException; +import jakarta.transaction.NotSupportedException; +import jakarta.transaction.RollbackException; +import jakarta.transaction.Status; +import jakarta.transaction.SystemException; +import jakarta.transaction.TransactionManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; + +@ApplicationScoped +@DataSourceDefinition( + name = "chirp2", + className = "org.h2.jdbcx.JdbcDataSource", + url = "jdbc:h2:mem:TestExtendedUnsynchronizedEntityManager2;MODE=LEGACY;INIT=SET TRACE_LEVEL_FILE=4\\;RUNSCRIPT FROM 'classpath:chirp2.ddl'", + serverName = "", + properties = { + "user=sa" + } +) +class TestExtendedUnsynchronizedEntityManager2 { + + static { + System.setProperty("jpaAnnotationRewritingEnabled", "true"); + } + + private SeContainer cdiContainer; + + @Inject + private TransactionManager transactionManager; + + @PersistenceContext( + type = PersistenceContextType.EXTENDED, + synchronization = SynchronizationType.UNSYNCHRONIZED, + unitName = "chirp2" + ) + private EntityManager extendedUnsynchronizedEntityManager; + + + /* + * Constructors. + */ + + + TestExtendedUnsynchronizedEntityManager2() { + super(); + } + + + /* + * Setup and teardown methods. + */ + + + @BeforeEach + void startCdiContainer() { + System.setProperty(io.helidon.integrations.cdi.jpa.JpaExtension.class.getName() + ".enabled", "false"); + System.setProperty(io.helidon.integrations.cdi.jpa.PersistenceExtension.class.getName() + ".enabled", "true"); + SeContainerInitializer initializer = SeContainerInitializer.newInstance() + .addBeanClasses(this.getClass()); + assertThat(initializer, notNullValue()); + this.cdiContainer = initializer.initialize(); + } + + @AfterEach + void shutDownCdiContainer() { + if (this.cdiContainer != null) { + this.cdiContainer.close(); + } + System.setProperty(io.helidon.integrations.cdi.jpa.PersistenceExtension.class.getName() + ".enabled", "false"); + System.setProperty(io.helidon.integrations.cdi.jpa.JpaExtension.class.getName() + ".enabled", "true"); + } + + + /* + * Support methods. + */ + + + /** + * A "business method" providing access to one of this {@link + * TestJpaTransactionScopedEntityManager}'s {@link EntityManager} + * instances for use by {@link Test}-annotated methods. + * + * @return a non-{@code null} {@link EntityManager} + */ + EntityManager getExtendedUnsynchronizedEntityManager() { + return this.extendedUnsynchronizedEntityManager; + } + + TransactionManager getTransactionManager() { + return this.transactionManager; + } + + private void onShutdown(@Observes @BeforeDestroyed(ApplicationScoped.class) Object event, + TransactionManager tm) throws SystemException { + // If an assertion fails, or some other error happens in the + // CDI container, there may be a current transaction that has + // neither been committed nor rolled back. Because the + // Narayana transaction engine is fundamentally static, this + // means that a transaction affiliation with the main thread + // may "leak" into another JUnit test (since JUnit, by + // default, forks once, and then runs all tests in the same + // JVM). CDI, thankfully, will fire an event for the + // application context shutting down, even in the case of + // errors. + if (tm.getStatus() != Status.STATUS_NO_TRANSACTION) { + tm.rollback(); + } + } + + /* + * Test methods. + */ + + + @Test + void testExtendedUnsynchronizedEntityManager() + throws HeuristicMixedException, + HeuristicRollbackException, + NotSupportedException, + RollbackException, + SystemException + { + + // Get a CDI contextual reference to this test instance. It + // is important to use "self" in this test instead of "this". + TestExtendedUnsynchronizedEntityManager2 self = + this.cdiContainer.select(TestExtendedUnsynchronizedEntityManager2.class).get(); + assertThat(self, notNullValue()); + + // Get the EntityManager that is not synchronized with and + // whose persistence context extends past a single JTA + // transaction. + EntityManager em = self.getExtendedUnsynchronizedEntityManager(); + assertThat(em, notNullValue()); + assertThat(em.isOpen(), is(true)); + + // We haven't started any kind of transaction yet and we + // aren't testing anything using + // the @jakarta.transaction.Transactional annotation so there is + // no transaction in effect so the EntityManager cannot be + // joined to one. + assertThat(em.isJoinedToTransaction(), is(false)); + + // Create a JPA entity and try to insert it. Should be just + // fine. + Author author = new Author(1, "Abraham Lincoln"); + + // With an EXTENDED EntityManager, persisting outside of a + // transaction is OK. + em.persist(author); + + // Get the TransactionManager that normally is behind the + // scenes and use it to start a Transaction. + TransactionManager tm = self.getTransactionManager(); + assertThat(tm, notNullValue()); + tm.begin(); + + // Because we're UNSYNCHRONIZED, no automatic joining takes place. + assertThat(em.isJoinedToTransaction(), is(false)); + + // We can join manually. + em.joinTransaction(); + + // Now we should be joined to it. + assertThat(em.isJoinedToTransaction(), is(true)); + + // Roll the transaction back and note that our EntityManager + // is no longer joined to it. + tm.rollback(); + assertThat(em.isJoinedToTransaction(), is(false)); + + // Start another transaction and persist our Author. But note + // that joining the transaction must be manual. + tm.begin(); + assertThat(em.isJoinedToTransaction(), is(false)); + + try { + // See + // https://www.baeldung.com/hibernate-detached-entity-passed-to-persist#trying-to-persist-a-detached-entity + // and + // https://hibernate.atlassian.net/browse/HHH-15738. Eclipselink + // handles all this just fine. + em.persist(author); + assertThat(em.isJoinedToTransaction(), is(false)); + assertThat(em.contains(author), is(true)); + + // (Remember, we weren't ever joined to this transaction.) + tm.commit(); + + // The transaction is over, and our EntityManager is STILL + // not joined to one. + assertThat(em.isJoinedToTransaction(), is(false)); + + // Our PersistenceContextType is EXTENDED, not + // TRANSACTION, so the underlying persistence context + // spans transactions. + assertThat(em.contains(author), is(true)); + } catch (PersistenceException hhh15738) { + assertThat(tm.getStatus(), is(Status.STATUS_MARKED_ROLLBACK)); + tm.rollback(); + } + } + +} diff --git a/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/TestJpaTransactionScopedSynchronizedEntityManager2.java b/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/TestJpaTransactionScopedSynchronizedEntityManager2.java new file mode 100644 index 00000000000..994e167a71f --- /dev/null +++ b/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/TestJpaTransactionScopedSynchronizedEntityManager2.java @@ -0,0 +1,442 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.integrations.cdi.jpa.chirp2; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import javax.sql.DataSource; + +import jakarta.annotation.sql.DataSourceDefinition; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.BeforeDestroyed; +import jakarta.enterprise.context.spi.Context; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.inject.se.SeContainer; +import jakarta.enterprise.inject.se.SeContainerInitializer; +import jakarta.enterprise.inject.spi.BeanManager; +import jakarta.inject.Inject; +import jakarta.inject.Named; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.PersistenceContextType; +import jakarta.persistence.SynchronizationType; +import jakarta.persistence.TransactionRequiredException; +import jakarta.transaction.HeuristicMixedException; +import jakarta.transaction.HeuristicRollbackException; +import jakarta.transaction.NotSupportedException; +import jakarta.transaction.RollbackException; +import jakarta.transaction.Status; +import jakarta.transaction.SystemException; +import jakarta.transaction.TransactionManager; +import jakarta.transaction.TransactionScoped; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.fail; + +@ApplicationScoped +@DataSourceDefinition( + name = "chirp2", + className = "org.h2.jdbcx.JdbcDataSource", + url = "jdbc:h2:mem:TestJpaTransactionScopedSynchronizedEntityManager2;" + + "MODE=LEGACY;" + + "INIT=SET TRACE_LEVEL_FILE=4\\;RUNSCRIPT FROM 'classpath:chirp2.ddl'", + serverName = "", + properties = { + "user=sa" + } +) +class TestJpaTransactionScopedSynchronizedEntityManager2 { + + /* + static { + System.setProperty("jpaAnnotationRewritingEnabled", "true"); + } + */ + + private SeContainer cdiContainer; + + @Inject + private TransactionManager transactionManager; + + @Inject + @Named("chirp2") + private DataSource dataSource; + + @PersistenceContext( + type = PersistenceContextType.TRANSACTION, + synchronization = SynchronizationType.SYNCHRONIZED, + unitName = "chirp2" + ) + private EntityManager jpaTransactionScopedSynchronizedEntityManager; + + + /* + * Constructors. + */ + + + TestJpaTransactionScopedSynchronizedEntityManager2() { + super(); + } + + + /* + * Setup and teardown methods. + */ + + + @BeforeEach + void startCdiContainer() { + System.setProperty(io.helidon.integrations.cdi.jpa.JpaExtension.class.getName() + ".enabled", "false"); + System.setProperty(io.helidon.integrations.cdi.jpa.PersistenceExtension.class.getName() + ".enabled", "true"); + final SeContainerInitializer initializer = SeContainerInitializer.newInstance() + .addBeanClasses(this.getClass()); + assertThat(initializer, notNullValue()); + this.cdiContainer = initializer.initialize(); + } + + @AfterEach + void shutDownCdiContainer() { + if (this.cdiContainer != null) { + this.cdiContainer.close(); + } + System.setProperty(io.helidon.integrations.cdi.jpa.PersistenceExtension.class.getName() + ".enabled", "false"); + System.setProperty(io.helidon.integrations.cdi.jpa.JpaExtension.class.getName() + ".enabled", "true"); + } + + + /* + * Support methods. + */ + + + /** + * A "business method" providing access to one of this {@link + * TestJpaTransactionScopedEntityManager}'s {@link EntityManager} + * instances for use by {@link Test}-annotated methods. + * + * @return a non-{@code null} {@link EntityManager} + */ + EntityManager getJpaTransactionScopedSynchronizedEntityManager() { + return this.jpaTransactionScopedSynchronizedEntityManager; + } + + TransactionManager getTransactionManager() { + return this.transactionManager; + } + + DataSource getDataSource() { + return this.dataSource; + } + + private void onShutdown(@Observes @BeforeDestroyed(ApplicationScoped.class) final Object event, + final TransactionManager tm) throws SystemException { + // If an assertion fails, or some other error happens in the + // CDI container, there may be a current transaction that has + // neither been committed nor rolled back. Because the + // Narayana transaction engine is fundamentally static, this + // means that a transaction affiliation with the main thread + // may "leak" into another JUnit test (since JUnit, by + // default, forks once, and then runs all tests in the same + // JVM). CDI, thankfully, will fire an event for the + // application context shutting down, even in the case of + // errors. + if (tm.getStatus() != Status.STATUS_NO_TRANSACTION) { + tm.rollback(); + } + } + + + /* + * Test methods. + */ + + + @Test + void testJpaTransactionScopedSynchronizedEntityManager() + throws HeuristicMixedException, + HeuristicRollbackException, + NotSupportedException, + RollbackException, + SQLException, + SystemException + { + + // Get a BeanManager for later use. + final BeanManager beanManager = this.cdiContainer.getBeanManager(); + assertThat(beanManager, notNullValue()); + + // Get a CDI contextual reference to this test instance. It + // is important to use "self" in this test instead of "this". + final TestJpaTransactionScopedSynchronizedEntityManager2 self = + this.cdiContainer.select(TestJpaTransactionScopedSynchronizedEntityManager2.class).get(); + assertThat(self, notNullValue()); + + // Get the EntityManager that is synchronized with and scoped + // to a JTA transaction. + final EntityManager em = self.getJpaTransactionScopedSynchronizedEntityManager(); + assertThat(em, notNullValue()); + assertThat(em.isOpen(), is(true)); + + // Get a DataSource for JPA-independent testing and assertions. + final DataSource dataSource = self.getDataSource(); + assertThat(dataSource, notNullValue()); + + // We haven't started any kind of transaction yet and we + // aren't testing anything using + // the @jakarta.transaction.Transactional annotation so there is + // no transaction in effect so the EntityManager cannot be + // joined to one. + assertThat(em.isJoinedToTransaction(), is(false)); + + // Create a JPA entity and try to insert it. This should fail + // because according to JPA a TransactionRequiredException + // will be thrown. + Author author1 = new Author(1, "Abraham Lincoln"); + try { + em.persist(author1); + fail("A TransactionRequiredException should have been thrown"); + } catch (final TransactionRequiredException expected) { + + } + assertThat(em.contains(author1), is(false)); + assertThat(author1.getId(), is(1)); + + // Get the TransactionManager that normally is behind the + // scenes and use it to start a Transaction. + final TransactionManager tm = self.getTransactionManager(); + tm.setTransactionTimeout(60 * 20); // Set to 20 minutes for debugging purposes only + + // Create a new transaction. + tm.begin(); + + // Grab the TransactionScoped context while the transaction is + // active. We want to make sure it's active at various + // points. + final Context transactionScopedContext = beanManager.getContext(TransactionScoped.class); + assertThat(transactionScopedContext, notNullValue()); + assertThat(transactionScopedContext.isActive(), is(true)); + + // Now magically our EntityManager should be joined to it. + assertThat(em.isJoinedToTransaction(), is(true)); + + // Roll the transaction back and note that our EntityManager + // is no longer joined to it. + tm.rollback(); + assertThat(em.isJoinedToTransaction(), is(false)); + assertThat(transactionScopedContext.isActive(), is(false)); + assertThat(em.contains(author1), is(false)); + assertThat(author1.getId(), is(1)); + + // Start another transaction. + tm.begin(); + assertThat(transactionScopedContext.isActive(), is(true)); + assertThat(em.isJoinedToTransaction(), is(true)); + + // Persist our Author. + assertThat(author1.getId(), is(1)); + em.persist(author1); + assertThat(em.contains(author1), is(true)); + + // Commit the transaction and flush changes to the database. + tm.commit(); + + // After the transaction commits, a flush should happen, and + // the Author is managed, so we should see his ID. + assertThat(author1.getId(), is(1)); + + // Make sure the database contains the changes. + try (final Connection connection = dataSource.getConnection(); + final Statement statement = connection.createStatement(); + final ResultSet resultSet = statement.executeQuery("SELECT ID, NAME FROM AUTHOR WHERE ID = 1");) { + assertThat(resultSet, notNullValue()); + assertThat(resultSet.next(), is(true)); + assertThat(resultSet.getInt(1), is(1)); + assertThat(resultSet.getString(2), is("Abraham Lincoln")); + assertThat(resultSet.next(), is(false)); + } + + // The Author, however, is detached, because the transaction + // is over, and because our PersistenceContextType is + // TRANSACTION, not EXTENDED, the underlying persistence + // context dies with the transaction. + assertThat(em.contains(author1), is(false)); + + // The transaction is over, so our EntityManager is not joined + // to one anymore. + assertThat(em.isJoinedToTransaction(), is(false)); + assertThat(transactionScopedContext.isActive(), is(false)); + + // Start a new transaction. + tm.begin(); + assertThat(transactionScopedContext.isActive(), is(true)); + assertThat(em.isJoinedToTransaction(), is(true)); + + // Remove the Author we successfully committed before. We + // have to merge because author1 became detached a few lines + // above. + author1 = em.merge(author1); + assertThat(author1.getId(), is(1)); + assertThat(em.contains(author1), is(true)); + em.remove(author1); + assertThat(em.contains(author1), is(false)); + assertThat(author1.getId(), is(1)); + + // Commit and flush the removal. + tm.commit(); + assertThat(em.isJoinedToTransaction(), is(false)); + assertThat(transactionScopedContext.isActive(), is(false)); + + // Note that its ID is still 1. + assertThat(author1.getId(), is(1)); + + // After all this activity we should have no rows in any + // tables. + assertTableRowCount(dataSource, "AUTHOR", 0); + + // Start a new transaction, merge our detached author1, and + // commit. + tm.begin(); + assertThat(em.isJoinedToTransaction(), is(true)); + assertThat(transactionScopedContext.isActive(), is(true)); + + // Actually, this really should throw an + // IllegalArgumentException, since author1 was + // removed. Neither Eclipselink nor Hibernate throws an + // exception here. + author1 = em.merge(author1); + + assertThat(em.contains(author1), is(true)); + assertThat(author1.getId(), is(1)); + + tm.commit(); + assertThat(em.isJoinedToTransaction(), is(false)); + assertThat(em.contains(author1), is(false)); + assertThat(transactionScopedContext.isActive(), is(false)); + assertThat(author1.getId(), is(1)); + + // Make sure the database contains the changes. + try (final Connection connection = dataSource.getConnection(); + final Statement statement = connection.createStatement(); + final ResultSet resultSet = statement.executeQuery("SELECT ID, NAME FROM AUTHOR");) { + assertThat(resultSet, notNullValue()); + assertThat(resultSet.next(), is(true)); + assertThat(resultSet.getInt(1), is(1)); + assertThat(resultSet.getString(2), is("Abraham Lincoln")); + assertThat(resultSet.next(), is(false)); + } + + // Discard author1 in this unit test so we'll get a + // NullPointerException if we try to use him again. + author1 = null; + + // Let's find the new author that got merged in. We'll use a + // transaction just for kicks. + tm.begin(); + assertThat(em.isJoinedToTransaction(), is(true)); + assertThat(transactionScopedContext.isActive(), is(true)); + + Author author2 = em.find(Author.class, Integer.valueOf(1)); + assertThat(author2, notNullValue()); + assertThat(em.contains(author2), is(true)); + assertThat(author2.getId(), is(1)); + assertThat(author2.getName(), is("Abraham Lincoln")); + + // No need, really, but it's what a @Transactional method + // would do. + tm.commit(); + assertThat(em.isJoinedToTransaction(), is(false)); + assertThat(em.contains(author2), is(false)); + assertThat(transactionScopedContext.isActive(), is(false)); + + // New transaction. Let's change the name. + tm.begin(); + assertThat(em.isJoinedToTransaction(), is(true)); + assertThat(transactionScopedContext.isActive(), is(true)); + + author2 = em.find(Author.class, Integer.valueOf(1)); + assertThat(author2, notNullValue()); + + // Remember that finding an entity causes it to become + // managed. + assertThat(em.contains(author2), is(true)); + + assertThat(author2.getId(), is(1)); + assertThat(author2.getName(), is("Abraham Lincoln")); + + author2.setName("Abe Lincoln"); + assertThat(author2.getId(), is(1)); + assertThat(author2.getName(), is("Abe Lincoln")); + + tm.commit(); + assertThat(em.isJoinedToTransaction(), is(false)); + assertThat(em.contains(author2), is(false)); + assertThat(transactionScopedContext.isActive(), is(false)); + + // Make sure the database contains the changes. + try (final Connection connection = dataSource.getConnection(); + final Statement statement = connection.createStatement(); + final ResultSet resultSet = statement.executeQuery("SELECT ID, NAME FROM AUTHOR");) { + assertThat(resultSet, notNullValue()); + assertThat(resultSet.next(), is(true)); + assertThat(resultSet.getInt(1), is(1)); + assertThat(resultSet.getString(2), is("Abe Lincoln")); + assertThat(resultSet.next(), is(false)); + } + + // Let's go find him again. + tm.begin(); + assertThat(em.isJoinedToTransaction(), is(true)); + assertThat(transactionScopedContext.isActive(), is(true)); + + author2 = em.find(Author.class, Integer.valueOf(1)); + assertThat(author2, notNullValue()); + assertThat(em.contains(author2), is(true)); + assertThat(author2.getId(), is(1)); + assertThat(author2.getName(), is("Abe Lincoln")); + + // No need, really, but it's what a @Transactional method + // would do. + tm.commit(); + assertThat(em.isJoinedToTransaction(), is(false)); + assertThat(em.contains(author2), is(false)); + assertThat(transactionScopedContext.isActive(), is(false)); + + } + + private static final void assertTableRowCount(final DataSource dataSource, + final String upperCaseTableName, + final int expectedCount) + throws SQLException { + try (final Connection connection = dataSource.getConnection(); + final Statement statement = connection.createStatement(); + final ResultSet resultSet = statement.executeQuery("SELECT COUNT(*) FROM " + upperCaseTableName);) { + assertThat(resultSet, notNullValue()); + assertThat(resultSet.next(), is(true)); + assertThat(resultSet.getInt(1), is(expectedCount)); + } + } + +} diff --git a/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/TestJpaTransactionScopedUnsynchronizedEntityManager2.java b/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/TestJpaTransactionScopedUnsynchronizedEntityManager2.java new file mode 100644 index 00000000000..8119f238aaf --- /dev/null +++ b/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/TestJpaTransactionScopedUnsynchronizedEntityManager2.java @@ -0,0 +1,237 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.integrations.cdi.jpa.chirp2; + +import jakarta.annotation.sql.DataSourceDefinition; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.BeforeDestroyed; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.inject.se.SeContainer; +import jakarta.enterprise.inject.se.SeContainerInitializer; +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.PersistenceContextType; +import jakarta.persistence.SynchronizationType; +import jakarta.persistence.TransactionRequiredException; +import jakarta.transaction.HeuristicMixedException; +import jakarta.transaction.HeuristicRollbackException; +import jakarta.transaction.NotSupportedException; +import jakarta.transaction.RollbackException; +import jakarta.transaction.Status; +import jakarta.transaction.SystemException; +import jakarta.transaction.TransactionManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.fail; + +@ApplicationScoped +@DataSourceDefinition( + name = "chirp2", + className = "org.h2.jdbcx.JdbcDataSource", + url = "jdbc:h2:mem:TestJpaTransactionScopedUnsynchronizedEntityManager2;" + + "MODE=LEGACY;" + + "INIT=SET TRACE_LEVEL_FILE=4\\;RUNSCRIPT FROM 'classpath:chirp2.ddl'", + serverName = "", + properties = { + "user=sa" + } +) +class TestJpaTransactionScopedUnsynchronizedEntityManager2 { + + static { + System.setProperty("jpaAnnotationRewritingEnabled", "true"); + } + + private SeContainer cdiContainer; + + @Inject + private TransactionManager transactionManager; + + @PersistenceContext( + type = PersistenceContextType.TRANSACTION, + synchronization = SynchronizationType.UNSYNCHRONIZED, + unitName = "chirp2" + ) + private EntityManager jpaTransactionScopedUnsynchronizedEntityManager; + + + /* + * Constructors. + */ + + + TestJpaTransactionScopedUnsynchronizedEntityManager2() { + super(); + } + + + /* + * Setup and teardown methods. + */ + + + @BeforeEach + void startCdiContainer() { + System.setProperty(io.helidon.integrations.cdi.jpa.JpaExtension.class.getName() + ".enabled", "false"); + System.setProperty(io.helidon.integrations.cdi.jpa.PersistenceExtension.class.getName() + ".enabled", "true"); + final SeContainerInitializer initializer = SeContainerInitializer.newInstance() + .addBeanClasses(this.getClass()); + assertThat(initializer, notNullValue()); + this.cdiContainer = initializer.initialize(); + } + + @AfterEach + void shutDownCdiContainer() { + if (this.cdiContainer != null) { + this.cdiContainer.close(); + } + System.setProperty(io.helidon.integrations.cdi.jpa.PersistenceExtension.class.getName() + ".enabled", "false"); + System.setProperty(io.helidon.integrations.cdi.jpa.JpaExtension.class.getName() + ".enabled", "true"); + } + + + /* + * Support methods. + */ + + + /** + * A "business method" providing access to one of this {@link + * TestJpaTransactionScopedEntityManager}'s {@link EntityManager} + * instances for use by {@link Test}-annotated methods. + * + * @return a non-{@code null} {@link EntityManager} + */ + EntityManager getJpaTransactionScopedUnsynchronizedEntityManager() { + return this.jpaTransactionScopedUnsynchronizedEntityManager; + } + + TransactionManager getTransactionManager() { + return this.transactionManager; + } + + private void onShutdown(@Observes @BeforeDestroyed(ApplicationScoped.class) final Object event, + final TransactionManager tm) throws SystemException { + // If an assertion fails, or some other error happens in the + // CDI container, there may be a current transaction that has + // neither been committed nor rolled back. Because the + // Narayana transaction engine is fundamentally static, this + // means that a transaction affiliation with the main thread + // may "leak" into another JUnit test (since JUnit, by + // default, forks once, and then runs all tests in the same + // JVM). CDI, thankfully, will fire an event for the + // application context shutting down, even in the case of + // errors. + if (tm.getStatus() != Status.STATUS_NO_TRANSACTION) { + tm.rollback(); + } + } + + + /* + * Test methods. + */ + + + @Test + void testJpaTransactionScopedUnsynchronizedEntityManager() + throws HeuristicMixedException, + HeuristicRollbackException, + NotSupportedException, + RollbackException, + SystemException + { + + // Get a CDI contextual reference to this test instance. It + // is important to use "self" in this test instead of "this". + final TestJpaTransactionScopedUnsynchronizedEntityManager2 self = + this.cdiContainer.select(TestJpaTransactionScopedUnsynchronizedEntityManager2.class).get(); + assertThat(self, notNullValue()); + + // Get the EntityManager that is not synchronized with but is + // scoped to a JTA transaction. + final EntityManager em = self.getJpaTransactionScopedUnsynchronizedEntityManager(); + assertThat(em, notNullValue()); + assertThat(em.isOpen(), is(true)); + + // We haven't started any kind of transaction yet and we + // aren't testing anything using + // the @jakarta.transaction.Transactional annotation so there is + // no transaction in effect so the EntityManager cannot be + // joined to one. + assertThat(em.isJoinedToTransaction(), is(false)); + + // Create a JPA entity and try to insert it. This should fail + // because according to JPA a TransactionRequiredException + // will be thrown. + final Author author = new Author(1, "Abraham Lincoln"); + try { + em.persist(author); + fail("A TransactionRequiredException should have been thrown"); + } catch (final TransactionRequiredException expected) { + + } + + // Get the TransactionManager that normally is behind the + // scenes and use it to start a Transaction. + final TransactionManager tm = self.getTransactionManager(); + assertThat(tm, notNullValue()); + tm.begin(); + + // Because we're UNSYNCHRONIZED, no automatic joining takes place. + assertThat(em.isJoinedToTransaction(), is(false)); + + // We can join manually. + em.joinTransaction(); + + // Now we should be joined to it. + assertThat(em.isJoinedToTransaction(), is(true)); + + // Roll the transaction back and note that our EntityManager + // is no longer joined to it. + tm.rollback(); + assertThat(em.isJoinedToTransaction(), is(false)); + + // Start another transaction and persist our Author. But note + // that joining the transaction must be manual. + tm.begin(); + assertThat(em.isJoinedToTransaction(), is(false)); + em.persist(author); + assertThat(em.isJoinedToTransaction(), is(false)); + assertThat(em.contains(author), is(true)); + + // (Remember, we weren't ever joined to this transaction.) + tm.commit(); + + // The transaction is over, and our EntityManager is STILL not joined + // to one. + assertThat(em.isJoinedToTransaction(), is(false)); + + // Now the weird part. Our EntityManager was of type + // PersistenceContextType.TRANSACTION, but + // SynchronizationType.UNSYNCHRONIZED. So it never joins + // transactions automatically, but its backing persistence + // context does NOT span transactions. + assertThat(em.contains(author), is(false)); + } + +} diff --git a/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/TestRollbackScenarios2.java b/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/TestRollbackScenarios2.java new file mode 100644 index 00000000000..a19da894cf6 --- /dev/null +++ b/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/TestRollbackScenarios2.java @@ -0,0 +1,352 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.integrations.cdi.jpa.chirp2; + +import jakarta.annotation.sql.DataSourceDefinition; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.BeforeDestroyed; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.inject.se.SeContainer; +import jakarta.enterprise.inject.se.SeContainerInitializer; +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.PersistenceContextType; +import jakarta.persistence.SynchronizationType; +import jakarta.persistence.TransactionRequiredException; +import jakarta.transaction.HeuristicMixedException; +import jakarta.transaction.HeuristicRollbackException; +import jakarta.transaction.NotSupportedException; +import jakarta.transaction.RollbackException; +import jakarta.transaction.Status; +import jakarta.transaction.SystemException; +import jakarta.transaction.TransactionManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.fail; + +@ApplicationScoped +@DataSourceDefinition( + name = "chirp2", + className = "org.h2.jdbcx.JdbcDataSource", + url = "jdbc:h2:mem:TestRollbackScenarios2;MODE=LEGACY;INIT=SET TRACE_LEVEL_FILE=4\\;RUNSCRIPT FROM 'classpath:chirp2.ddl'", + serverName = "", + properties = { + "user=sa" + } +) +class TestRollbackScenarios2 { + + private SeContainer cdiContainer; + + @Inject + private TransactionManager transactionManager; + + @PersistenceContext( + type = PersistenceContextType.TRANSACTION, + synchronization = SynchronizationType.SYNCHRONIZED, + unitName = "chirp2" + ) + private EntityManager jpaTransactionScopedSynchronizedEntityManager; + + + /* + * Constructors. + */ + + + TestRollbackScenarios2() { + super(); + } + + + /* + * Setup and teardown methods. + */ + + + @BeforeEach + void startCdiContainer() { + System.setProperty(io.helidon.integrations.cdi.jpa.JpaExtension.class.getName() + ".enabled", "false"); + System.setProperty(io.helidon.integrations.cdi.jpa.PersistenceExtension.class.getName() + ".enabled", "true"); + SeContainerInitializer initializer = SeContainerInitializer.newInstance() + .addBeanClasses(this.getClass()); + assertThat(initializer, notNullValue()); + this.cdiContainer = initializer.initialize(); + } + + @AfterEach + void shutDownCdiContainer() { + try { + + } finally { + if (this.cdiContainer != null) { + this.cdiContainer.close(); + } + } + System.setProperty(io.helidon.integrations.cdi.jpa.PersistenceExtension.class.getName() + ".enabled", "false"); + System.setProperty(io.helidon.integrations.cdi.jpa.JpaExtension.class.getName() + ".enabled", "true"); + } + + + /* + * Support methods. + */ + + + TestRollbackScenarios2 self() { + return this.cdiContainer.select(TestRollbackScenarios2.class).get(); + } + + /** + * A "business method" providing access to one of this {@link + * TestRollbackScenarios}' {@link EntityManager} + * instances for use by {@link Test}-annotated methods. + * + * @return a non-{@code null} {@link EntityManager} + */ + EntityManager getJpaTransactionScopedSynchronizedEntityManager() { + return this.jpaTransactionScopedSynchronizedEntityManager; + } + + TransactionManager getTransactionManager() { + return this.transactionManager; + } + + private void onShutdown(@Observes @BeforeDestroyed(ApplicationScoped.class) Object event, TransactionManager tm) + throws SystemException { + // If an assertion fails, or some other error happens in the + // CDI container, there may be a current transaction that has + // neither been committed nor rolled back. Because the + // Narayana transaction engine is fundamentally static, this + // means that a transaction affiliation with the main thread + // may "leak" into another JUnit test (since JUnit, by + // default, forks once, and then runs all tests in the same + // JVM). CDI, thankfully, will fire an event for the + // application context shutting down, even in the case of + // errors. + try { + this.jpaTransactionScopedSynchronizedEntityManager.clear(); + this.jpaTransactionScopedSynchronizedEntityManager.getEntityManagerFactory().getCache().evictAll(); + } finally { + if (tm.getStatus() != Status.STATUS_NO_TRANSACTION) { + tm.rollback(); + } + } + } + + + /* + * Test methods. + */ + + + @Test + void testRollbackScenarios() + throws HeuristicMixedException, + HeuristicRollbackException, + InterruptedException, + NotSupportedException, + RollbackException, + SystemException + { + + // Get a CDI contextual reference to this test instance. It + // is important to use "self" in this test instead of "this". + TestRollbackScenarios2 self = self(); + assertThat(self, notNullValue()); + + // Get the EntityManager that is synchronized with and scoped + // to a JTA transaction. + EntityManager em = self.getJpaTransactionScopedSynchronizedEntityManager(); + assertThat(em, notNullValue()); + assertThat(em.isOpen(), is(true)); + + // We haven't started any kind of transaction yet and we + // aren't testing anything using + // the @jakarta.transaction.Transactional annotation so there is + // no transaction in effect so the EntityManager cannot be + // joined to one. + assertThat(em.isJoinedToTransaction(), is(false)); + + // Get the TransactionManager that normally is behind the + // scenes and use it to start a Transaction. + TransactionManager tm = self.getTransactionManager(); + assertThat(tm, notNullValue()); + tm.setTransactionTimeout(20 * 60); // 20 minutes for debugging + tm.begin(); + assertThat(tm.getStatus(), is(Status.STATUS_ACTIVE)); + + // Now magically our EntityManager should be joined to it. + assertThat(em.isJoinedToTransaction(), is(true)); + + // Create a JPA entity and insert it. + Author author = new Author(1, "Abraham Lincoln"); + assertThat(author.getId(), is(1)); + + em.persist(author); + // assertThat(author.getId(), is(nullValue())); + + // Commit the transaction. Because we're relying on the + // default flush mode, this will cause a flush to the + // database, which, in turn, will result in author identifier + // generation. + tm.commit(); + assertThat(tm.getStatus(), is(Status.STATUS_NO_TRANSACTION)); + assertThat(author.getId(), is(1)); + + // We're no longer in a transaction. + assertThat(em.isJoinedToTransaction(), is(false)); + + // The persistence context should be cleared. + assertThat(em.contains(author), is(false)); + + // Ensure transaction statuses are what we think they are. + tm.begin(); + tm.setRollbackOnly(); + try { + assertThat(tm.getStatus(), is(Status.STATUS_MARKED_ROLLBACK)); + } finally { + tm.rollback(); + } + assertThat(tm.getStatus(), is(Status.STATUS_NO_TRANSACTION)); + + // We can do non-transactional things. + assertThat(em.isOpen(), is(true)); + author = em.find(Author.class, Integer.valueOf(1)); + assertThat(author, notNullValue()); + + // Note that because we've invoked this somehow outside of a + // transaction everything it touches is detached, per section + // 7.6.2 of the JPA 2.2 specification. + assertThat(em.contains(author), is(false)); + + // Remove everything. + tm.begin(); + author = em.merge(author); + assertThat(author, notNullValue()); + assertThat(em.contains(author), is(true)); + em.remove(author); + tm.commit(); + assertThat(em.contains(author), is(false)); + + // Create a new unmanaged Author. + author = new Author(2, "John Kennedy"); + assertThat(author.getId(), is(2)); + + tm.begin(); + em.persist(author); + + // This assertion depends on the ID generation strategy, + // sadly, and will not necessarily work across JPA providers + // for identity column ID generation. + assertThat(author.getId(), is(2)); + + assertThat(em.contains(author), is(true)); + + // Perform a rollback "in the middle" of a sequence of + // operations and observe that the EntityManager is in the + // proper state throughout. + tm.rollback(); + assertThat(tm.getStatus(), is(Status.STATUS_NO_TRANSACTION)); + assertThat(em.contains(author), is(false)); + + assertThat(author.getId(), is(2)); + + // Try to remove the now-detached author outside of a + // transaction. Should fail. + try { + em.remove(author); + fail("remove() was allowed to complete without a transaction"); + } catch (IllegalArgumentException | TransactionRequiredException expected) { + // The javadocs say only that either of these exceptions may + // be thrown in this case but do not indicate which one is + // preferred. EclipseLink 2.7.4 throws a + // TransactionRequiredException here. It probably should + // throw an IllegalArgumentException; see + // https://bugs.eclipse.org/bugs/show_bug.cgi?id=553117 + // which is related. + } + + assertThat(tm.getStatus(), is(Status.STATUS_NO_TRANSACTION)); + assertThat(em.contains(author), is(false)); + assertThat(author.getId(), is(2)); + + // Start a transaction. + tm.begin(); + assertThat(tm.getStatus(), is(Status.STATUS_ACTIVE)); + assertThat(em.isJoinedToTransaction(), is(true)); + + // author is still detached + assertThat(em.contains(author), is(false)); + em.detach(author); // redundant; just making a point + assertThat(em.contains(author), is(false)); + assertThat(author.getId(), is(2)); + + // Try again to remove the detached author, but this time in a + // transaction. Shouldn't matter; should also fail. + try { + em.remove(author); + // We shouldn't get here because author is detached but with + // EclipseLink 2.7.4 we do. See + // https://bugs.eclipse.org/bugs/show_bug.cgi?id=553117. + // fail("remove() was allowed to accept a detached object"); + } catch (IllegalArgumentException expected) { + + } + tm.rollback(); + assertThat(tm.getStatus(), is(Status.STATUS_NO_TRANSACTION)); + assertThat(em.contains(author), is(false)); + assertThat(author.getId(), is(2)); + + // Remove the author properly. + tm.begin(); + assertThat(tm.getStatus(), is(Status.STATUS_ACTIVE)); + assertThat(em.isJoinedToTransaction(), is(true)); + assertThat(em.contains(author), is(false)); + author = em.merge(author); + assertThat(em.contains(author), is(true)); + em.remove(author); + tm.commit(); + assertThat(em.contains(author), is(false)); + + // Cause a timeout-tripped rollback. + tm.setTransactionTimeout(1); // 1 second + author = new Author(3, "Woodrow Wilson"); + tm.begin(); + assertThat(tm.getStatus(), is(Status.STATUS_ACTIVE)); + Thread.sleep(1500L); // 1.5 seconds (arbitrarily greater than 1 second) + assertThat(tm.getStatus(), is(Status.STATUS_ROLLEDBACK)); + try { + em.persist(author); + fail("Transaction rolled back but persist still happened"); + } catch (TransactionRequiredException expected) { + + } + tm.rollback(); + assertThat(tm.getStatus(), is(Status.STATUS_NO_TRANSACTION)); + + tm.setTransactionTimeout(0); // set the timeout back to the default (that's what 0 means (!)) + + } + +} diff --git a/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/TestWithTransactionalInterceptors2.java b/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/TestWithTransactionalInterceptors2.java new file mode 100644 index 00000000000..acd9b33213d --- /dev/null +++ b/integrations/cdi/jpa-cdi/src/test/java/io/helidon/integrations/cdi/jpa/chirp2/TestWithTransactionalInterceptors2.java @@ -0,0 +1,322 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.integrations.cdi.jpa.chirp2; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import javax.sql.DataSource; + +import jakarta.annotation.sql.DataSourceDefinition; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.BeforeDestroyed; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.inject.se.SeContainer; +import jakarta.enterprise.inject.se.SeContainerInitializer; +import jakarta.inject.Inject; +import jakarta.inject.Named; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.PersistenceContextType; +import jakarta.persistence.SynchronizationType; +import jakarta.transaction.Status; +import jakarta.transaction.SystemException; +import jakarta.transaction.TransactionManager; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; + +@ApplicationScoped +@DataSourceDefinition( + name = "chirp2", + className = "org.h2.jdbcx.JdbcDataSource", + url = "jdbc:h2:mem:TestWithTransactionalInterceptors2;" + + "MODE=LEGACY;" + + "INIT=SET TRACE_LEVEL_FILE=4\\;RUNSCRIPT FROM 'classpath:chirp2.ddl'", + serverName = "", + properties = { + "user=sa" + } +) +class TestWithTransactionalInterceptors2 { + + private SeContainer cdiContainer; + + private TestWithTransactionalInterceptors2 self; + + @Inject + private TransactionManager tm; + + @Inject + @Named("chirp2") + private DataSource dataSource; + + @PersistenceContext( + type = PersistenceContextType.TRANSACTION, + synchronization = SynchronizationType.SYNCHRONIZED, + unitName = "chirp2" + ) + private EntityManager em; + + + /* + * Constructors. + */ + + + TestWithTransactionalInterceptors2() { + super(); + } + + + /* + * Setup and teardown methods. + */ + + + @BeforeEach + void startCdiContainer() throws SQLException, SystemException { + System.setProperty(io.helidon.integrations.cdi.jpa.JpaExtension.class.getName() + ".enabled", "false"); + System.setProperty(io.helidon.integrations.cdi.jpa.PersistenceExtension.class.getName() + ".enabled", "true"); + assertThat(this.dataSource, nullValue()); + assertThat(this.em, nullValue()); + assertThat(this.tm, nullValue()); + assertThat(this.self, nullValue()); + + SeContainerInitializer initializer = SeContainerInitializer.newInstance().addBeanClasses(this.getClass()); + assertThat(initializer, notNullValue()); + this.cdiContainer = initializer.initialize(); + assertThat(this.cdiContainer, notNullValue()); + + this.self = this.cdiContainer.select(this.getClass()).get(); + assertThat(this.self, notNullValue()); + + this.em = this.self.getEntityManager(); + assertThat(this.em, notNullValue()); + + this.tm = this.self.getTransactionManager(); + assertThat(this.tm, notNullValue()); + + this.dataSource = this.self.getDataSource(); + assertThat(this.dataSource, notNullValue()); + + assertAuthorTableIsEmpty(); + assertNoTransaction(); + } + + @AfterEach + void shutDownCdiContainer() throws Exception { + try { + this.em.clear(); + this.em.getEntityManagerFactory().getCache().evictAll(); + } finally { + if (this.cdiContainer != null) { + this.cdiContainer.close(); + } + } + System.setProperty(io.helidon.integrations.cdi.jpa.PersistenceExtension.class.getName() + ".enabled", "false"); + System.setProperty(io.helidon.integrations.cdi.jpa.JpaExtension.class.getName() + ".enabled", "true"); + } + + + /* + * Support methods. + */ + + + public EntityManager getEntityManager() { + return this.em; + } + + public DataSource getDataSource() { + return this.dataSource; + } + + public TransactionManager getTransactionManager() { + return this.tm; + } + + private void onShutdown(@Observes @BeforeDestroyed(ApplicationScoped.class) Object event, + TransactionManager tm) throws SystemException { + // If an assertion fails, or some other error happens in the + // CDI container, there may be a current transaction that has + // neither been committed nor rolled back. Because the + // Narayana transaction engine is fundamentally static, this + // means that a transaction affiliation with the main thread + // may "leak" into another JUnit test (since JUnit, by + // default, forks once, and then runs all tests in the same + // JVM). CDI, thankfully, will fire an event for the + // application context shutting down, even in the case of + // errors. + if (tm.getStatus() != Status.STATUS_NO_TRANSACTION) { + tm.rollback(); + } + } + + + /* + * Test methods. + */ + + + @Test + void runTestInsertAndVerifyResults() throws Exception { + + // Test a simple insert. Note testInsert() is annotated + // with @Transactional so we invoke it through our "self" + // proxy. + self.testInsert(); + + // The transaction should have committed so is no longer + // active. + assertNoTransaction(); + + // Make sure the operation worked. + try (Connection connection = this.dataSource.getConnection(); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT ID FROM AUTHOR");) { + assertThat(resultSet, notNullValue()); + assertThat(resultSet.next(), is(true)); + // assertThat(resultSet.getInt(1), is(1)); + assertThat(resultSet.next(), is(false)); + } + + } + + @Test + void runTestFindAndUpdateAndVerifyResults() throws Exception { + // First (re-)run our runTestInsertAndVerifyResults() method, + // which will put an author with ID 1 in the database and + // verify that that worked. + this.runTestInsertAndVerifyResults(); + + // Find him and change his name. Note testFindAndUpdate() is + // annotated with @Transactional so we invoke it through our + // "self" proxy. + self.testFindAndUpdate(); + + // The transaction should have committed so is no longer + // active. + assertNoTransaction(); + + // Make sure the operation worked. + try (Connection connection = this.dataSource.getConnection(); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT ID, NAME FROM AUTHOR");) { + assertThat(resultSet, notNullValue()); + assertThat(resultSet.next(), is(true)); + // assertThat(resultSet.getInt(1), is(1)); + assertThat(resultSet.getString(2), is("Abe Lincoln")); + assertThat(resultSet.next(), is(false)); + } finally { + self.removeAbe(); + } + } + + + /* + * Transactional methods under test. + */ + + + @Transactional + public void testInsert() throws Exception { + assertActiveTransaction(); + + // Make sure there's nothing in there. + assertAuthorTableIsEmpty(); + + // Persist an Author. + Author author = new Author(1, "Abraham Lincoln"); + em.persist(author); + assertThat(em.contains(author), is(true)); + } + + @Transactional + public void testFindAndUpdate() throws Exception { + assertActiveTransaction(); + // Author author = this.em.find(Author.class, Integer.valueOf(1)); + Author author = (Author) this.em.createQuery("SELECT a FROM Author a WHERE a.name = 'Abraham Lincoln'").getResultList().get(0); + assertThat(author, notNullValue()); + // assertThat(author.getId(), is(1)); + assertThat(author.getName(), is("Abraham Lincoln")); + assertThat(this.em.contains(author), is(true)); + author.setName("Abe Lincoln"); + } + + @Transactional + public void removeAbe() throws Exception { + assertActiveTransaction(); + this.em.remove(this.em.getReference(Author.class, 1)); + } + + + /* + * Assertion-style methods. + */ + + + private void assertAuthorTableIsEmpty() throws SQLException { + assertThat(this.dataSource, notNullValue()); + try (Connection connection = this.dataSource.getConnection(); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT COUNT(*) FROM AUTHOR");) { + assertThat(resultSet, notNullValue()); + assertThat(resultSet.next(), is(true)); + assertThat(resultSet.getInt(1), is(0)); + assertThat(resultSet.next(), is(false)); + } + } + + private void deleteAllFromAuthorTableAfterTest() throws SQLException, SystemException { + assertThat(this.dataSource, notNullValue()); + // assertNoTransaction(); + try (Connection connection = this.dataSource.getConnection(); + Statement statement = connection.createStatement()) { + statement.executeUpdate("DELETE FROM AUTHOR"); + } + } + + private void resetAuthorIdentityColumn() throws SQLException { + try (Connection connection = this.dataSource.getConnection(); + Statement statement = connection.createStatement()) { + statement.executeUpdate("ALTER TABLE AUTHOR ALTER COLUMN ID RESTART WITH 1"); + } + } + + private void assertActiveTransaction() throws SystemException { + assertThat(this.tm, notNullValue()); + assertThat(this.tm.getStatus(), is(Status.STATUS_ACTIVE)); + assertThat(this.em, notNullValue()); + assertThat(this.em.isJoinedToTransaction(), is(true)); + } + + private void assertNoTransaction() throws SystemException { + assertThat(this.tm, notNullValue()); + assertThat(this.tm.getStatus(), is(Status.STATUS_NO_TRANSACTION)); + assertThat(this.em, notNullValue()); + assertThat(this.em.isJoinedToTransaction(), is(false)); + } + +} diff --git a/integrations/cdi/jpa-cdi/src/test/logging.properties b/integrations/cdi/jpa-cdi/src/test/logging.properties index 1da40d587cb..03bb676621e 100644 --- a/integrations/cdi/jpa-cdi/src/test/logging.properties +++ b/integrations/cdi/jpa-cdi/src/test/logging.properties @@ -1,5 +1,5 @@ # -# Copyright (c) 2019, 2021 Oracle and/or its affiliates. +# Copyright (c) 2019, 2023 Oracle and/or its affiliates. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -14,9 +14,13 @@ # limitations under the License. # .level=INFO -handlers=io.helidon.common.HelidonConsoleHandler - -io.helidon.integrations.cdi.jpa.level=WARNING -org.eclipse.persistence.level=WARNING -org.jboss.weld.level=WARNING -h2database.level=WARNING +com.arjuna.ats.level=FINER +h2database.level=FINE +#handlers=io.helidon.common.HelidonConsoleHandler +handlers=java.util.logging.ConsoleHandler +#io.helidon.integrations.cdi.jpa.level=WARNING +io.helidon.integrations.level=FINER +java.util.logging.ConsoleHandler.level=FINEST +org.eclipse.persistence.level=FINER +org.h2.level=FINE +org.jboss.weld.level=FINE diff --git a/integrations/cdi/jpa-cdi/src/test/resources/META-INF/persistence.xml b/integrations/cdi/jpa-cdi/src/test/resources/META-INF/persistence.xml index 8eb7b3b91a6..e7cbeaf71bf 100644 --- a/integrations/cdi/jpa-cdi/src/test/resources/META-INF/persistence.xml +++ b/integrations/cdi/jpa-cdi/src/test/resources/META-INF/persistence.xml @@ -1,7 +1,7 @@