diff --git a/spring-webflow/.classpath b/spring-webflow/.classpath
index c23acd06..e92101ae 100644
--- a/spring-webflow/.classpath
+++ b/spring-webflow/.classpath
@@ -9,6 +9,7 @@
+
@@ -19,11 +20,18 @@
-
-
+
+
+
+
+
+
+
+
+
diff --git a/spring-webflow/changelog.txt b/spring-webflow/changelog.txt
index 0f384a57..c79b8bfd 100644
--- a/spring-webflow/changelog.txt
+++ b/spring-webflow/changelog.txt
@@ -38,6 +38,9 @@ Package org.springframework.webflow.executor
* JSF integration code now binds the current FlowExecutionContext to a thread local for use by the Spring scoping
mechanism (SWF-163).
+Package org.springframework.webflow.support
+* Added native support for Hibernate's session-per-conversation pattern (SWF-92).
+
Changes in version 1.0.3 (24.04.2007)
-------------------------------------
diff --git a/spring-webflow/ivy.xml b/spring-webflow/ivy.xml
index 84c0be5a..ce464d3d 100644
--- a/spring-webflow/ivy.xml
+++ b/spring-webflow/ivy.xml
@@ -55,11 +55,16 @@
+
+
+
+
+
diff --git a/spring-webflow/src/main/java/org/springframework/webflow/support/persistence/HibernateSessionPerConversationListener.java b/spring-webflow/src/main/java/org/springframework/webflow/support/persistence/HibernateSessionPerConversationListener.java
new file mode 100644
index 00000000..4e5b8243
--- /dev/null
+++ b/spring-webflow/src/main/java/org/springframework/webflow/support/persistence/HibernateSessionPerConversationListener.java
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2004-2007 the original author or authors.
+ *
+ * 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 org.springframework.webflow.support.persistence;
+
+import org.hibernate.FlushMode;
+import org.hibernate.Session;
+import org.hibernate.SessionFactory;
+import org.springframework.orm.hibernate3.SessionHolder;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+import org.springframework.webflow.core.collection.AttributeMap;
+import org.springframework.webflow.execution.FlowExecution;
+import org.springframework.webflow.execution.FlowExecutionListener;
+import org.springframework.webflow.execution.FlowExecutionListenerAdapter;
+import org.springframework.webflow.execution.FlowSession;
+import org.springframework.webflow.execution.RequestContext;
+import org.springframework.webflow.execution.ViewSelection;
+
+/**
+ * A {@link FlowExecutionListener} that implements the Hibernate
+ * Session-Per-Conversation pattern as described in Java Persistence with
+ * Hibernate (chapter 11).
+ *
+ * This implementation uses raw Hibernate APIs and binds the current session to
+ * the thread-local location identified by Spring's
+ * HibernateTransactionManager.
+ *
+ * This listener assumes that you are accessing Hibernate via Spring support
+ * such as HibernateTemplate or the LocalSessionFactoryBean. If not, Hibernate
+ * data access code will not participate in the proper transaction.
+ *
+ * @author Ben Hale
+ * @since 1.1
+ */
+public class HibernateSessionPerConversationListener extends FlowExecutionListenerAdapter {
+
+ private static final String HIBERNATE_SESSION = "hibernate.session";
+
+ private SessionFactory sessionFactory;
+
+ public HibernateSessionPerConversationListener(SessionFactory sessionFactory) {
+ this.sessionFactory = sessionFactory;
+ }
+
+ /**
+ * When a {@link FlowSession} is created a Hibernate Session
+ * is opened and put into MANUAL flush mode. From there it is bound to the
+ * conversation scope of this {@link FlowSession}. Since
+ * {@link #resumed(RequestContext)} will not be called on start of the
+ * session, this method also binds the session to the thread-local location.
+ */
+ public void sessionCreated(RequestContext context, FlowSession session) {
+ Session hibSession = createSession(context);
+ bindSession(hibSession);
+ }
+
+ /**
+ * When a {@link FlowExecution} is resumed, the conversationally scoped
+ * Session is bound to the thread-local location and a
+ * transaction is opened.
+ */
+ public void resumed(RequestContext context) {
+ Session hibSession = getHibernateSession(context);
+ bindSession(hibSession);
+ }
+
+ /**
+ * When a {@link FlowExecution} is paused the conversationally scoped
+ * Session is unbound from the thread-local location and the
+ * transaction is committed. The transaction that is closed is a Hibernate
+ * transaction and with a FlushMode of MANUAL will not flush anything to the
+ * database.
+ */
+ public void paused(RequestContext context, ViewSelection selectedView) {
+ Session hibSession = getHibernateSession(context);
+ unBindSession(hibSession);
+ }
+
+ /**
+ * When a {@link FlowSession} is destroyed all changes are flushed to the
+ * database, the current transaction committed, and the Hibernate
+ * Session is closed. Since
+ * {@link #paused(RequestContext, ViewSelection)} will not be called on the
+ * ending of this session, this method also unbinds the session from the
+ * thread-local location.
+ */
+ public void sessionEnded(RequestContext context, FlowSession session, AttributeMap output) {
+ Session hibSession = (Session) context.getConversationScope().remove(HIBERNATE_SESSION);
+ hibSession.flush();
+ unBindSession(hibSession);
+ destroySession(hibSession);
+ }
+
+ private Session createSession(RequestContext context) {
+ Session hibSession = sessionFactory.openSession();
+ hibSession.setFlushMode(FlushMode.MANUAL);
+ context.getConversationScope().put(HIBERNATE_SESSION, hibSession);
+ return hibSession;
+ }
+
+ private void destroySession(Session hibSession) {
+ hibSession.close();
+ }
+
+ private Session getHibernateSession(RequestContext context) {
+ return (Session) context.getConversationScope().get(HIBERNATE_SESSION);
+ }
+
+ private void bindSession(Session hibSession) {
+ hibSession.beginTransaction();
+ SessionHolder sessionHolder = new SessionHolder(hibSession);
+ TransactionSynchronizationManager.bindResource(sessionFactory, sessionHolder);
+ }
+
+ private void unBindSession(Session hibSession) {
+ hibSession.getTransaction().commit();
+ TransactionSynchronizationManager.unbindResource(sessionFactory);
+ }
+}
diff --git a/spring-webflow/src/test/java/org/springframework/webflow/support/persistence/HibernateSessionPerConversationListenerTests.java b/spring-webflow/src/test/java/org/springframework/webflow/support/persistence/HibernateSessionPerConversationListenerTests.java
new file mode 100644
index 00000000..e28d38a8
--- /dev/null
+++ b/spring-webflow/src/test/java/org/springframework/webflow/support/persistence/HibernateSessionPerConversationListenerTests.java
@@ -0,0 +1,155 @@
+/*
+ * Copyright 2004-2007 the original author or authors.
+ *
+ * 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 org.springframework.webflow.support.persistence;
+
+import java.sql.Connection;
+import java.sql.SQLException;
+
+import javax.sql.DataSource;
+
+import junit.framework.TestCase;
+
+import org.hibernate.HibernateException;
+import org.hibernate.Session;
+import org.hibernate.SessionFactory;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.core.io.Resource;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.DriverManagerDataSource;
+import org.springframework.orm.hibernate3.HibernateCallback;
+import org.springframework.orm.hibernate3.HibernateTemplate;
+import org.springframework.orm.hibernate3.LocalSessionFactoryBean;
+import org.springframework.webflow.execution.ViewSelection;
+import org.springframework.webflow.test.MockFlowSession;
+import org.springframework.webflow.test.MockRequestContext;
+
+/**
+ * Tests for {@link HibernateSessionPerConversationListener}
+ *
+ * @author Ben Hale
+ */
+public class HibernateSessionPerConversationListenerTests extends TestCase {
+
+ private JdbcTemplate jdbcTemplate;
+
+ private HibernateTemplate hibernateTemplate;
+
+ private HibernateSessionPerConversationListener listener;
+
+ protected void setUp() throws Exception {
+ DataSource dataSource = getDataSource();
+ populateDataBase(dataSource);
+ jdbcTemplate = new JdbcTemplate(dataSource);
+ SessionFactory sessionFactory = getSessionFactory(dataSource);
+ hibernateTemplate = new HibernateTemplate(sessionFactory);
+ hibernateTemplate.setCheckWriteOperations(false);
+ listener = new HibernateSessionPerConversationListener(sessionFactory);
+ }
+
+ public void testSameSession() {
+ MockRequestContext context = new MockRequestContext();
+ MockFlowSession flowSession = new MockFlowSession();
+ listener.sessionCreated(context, flowSession);
+
+ // Session created and bound to conversation
+ final Session hibSession = (Session) context.getConversationScope().get("hibernate.session");
+ assertNotNull("Should have been populated", hibSession);
+ listener.paused(context, ViewSelection.NULL_VIEW);
+
+ // Session bound to thread local variable
+ listener.resumed(context);
+ hibernateTemplate.execute(new HibernateCallback() {
+ public Object doInHibernate(Session session) throws HibernateException, SQLException {
+ assertSame("Should have been original instance", hibSession, session);
+ return null;
+ }
+ }, true);
+ listener.paused(context, ViewSelection.NULL_VIEW);
+ }
+
+ public void testSingleState() {
+ assertEquals("Table should only have one row", 1, jdbcTemplate.queryForInt("select count(*) from T_BEAN"));
+ MockRequestContext context = new MockRequestContext();
+ MockFlowSession flowSession = new MockFlowSession();
+ listener.sessionCreated(context, flowSession);
+
+ TestBean bean = new TestBean("Keith Donald");
+ hibernateTemplate.save(bean);
+ assertEquals("Table should still only have one row", 1, jdbcTemplate.queryForInt("select count(*) from T_BEAN"));
+
+ listener.sessionEnded(context, flowSession, null);
+ assertEquals("Table should only have two rows", 2, jdbcTemplate.queryForInt("select count(*) from T_BEAN"));
+ }
+
+ public void testMultipleState() {
+ assertEquals("Table should only have one row", 1, jdbcTemplate.queryForInt("select count(*) from T_BEAN"));
+ MockRequestContext context = new MockRequestContext();
+ MockFlowSession flowSession = new MockFlowSession();
+ listener.sessionCreated(context, flowSession);
+
+ TestBean bean1 = new TestBean("Keith Donald");
+ hibernateTemplate.save(bean1);
+ assertEquals("Table should still only have one row", 1, jdbcTemplate.queryForInt("select count(*) from T_BEAN"));
+ listener.paused(context, ViewSelection.NULL_VIEW);
+
+ listener.resumed(context);
+ TestBean bean2 = new TestBean("Keith Donald");
+ hibernateTemplate.save(bean2);
+ assertEquals("Table should still only have one row", 1, jdbcTemplate.queryForInt("select count(*) from T_BEAN"));
+
+
+ listener.sessionEnded(context, flowSession, null);
+ assertEquals("Table should only have two rows", 3, jdbcTemplate.queryForInt("select count(*) from T_BEAN"));
+ }
+
+ private DataSource getDataSource() {
+ DriverManagerDataSource dataSource = new DriverManagerDataSource();
+ dataSource.setDriverClassName("org.hsqldb.jdbcDriver");
+ dataSource.setUrl("jdbc:hsqldb:mem:hspcl");
+ dataSource.setUsername("sa");
+ dataSource.setPassword("");
+ return dataSource;
+ }
+
+ private void populateDataBase(DataSource dataSource) {
+ Connection connection = null;
+ try {
+ connection = dataSource.getConnection();
+ connection.createStatement().execute("drop table T_BEAN if exists;");
+ connection.createStatement().execute(
+ "create table T_BEAN (ID integer primary key, NAME varchar(50) not null);");
+ connection.createStatement().execute("insert into T_BEAN (ID, NAME) values (0, 'Ben Hale');");
+ } catch (SQLException e) {
+ throw new RuntimeException("SQL exception occurred acquiring connection", e);
+ } finally {
+ if (connection != null) {
+ try {
+ connection.close();
+ } catch (SQLException e) {
+ }
+ }
+ }
+ }
+
+ private SessionFactory getSessionFactory(DataSource dataSource) throws Exception {
+ LocalSessionFactoryBean factory = new LocalSessionFactoryBean();
+ factory.setDataSource(dataSource);
+ factory.setMappingLocations(new Resource[] { new ClassPathResource(
+ "org/springframework/webflow/support/persistence/TestBean.hbm.xml") });
+ factory.afterPropertiesSet();
+ return (SessionFactory) factory.getObject();
+ }
+}
diff --git a/spring-webflow/src/test/java/org/springframework/webflow/support/persistence/TestBean.hbm.xml b/spring-webflow/src/test/java/org/springframework/webflow/support/persistence/TestBean.hbm.xml
new file mode 100644
index 00000000..6fe806b7
--- /dev/null
+++ b/spring-webflow/src/test/java/org/springframework/webflow/support/persistence/TestBean.hbm.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/spring-webflow/src/test/java/org/springframework/webflow/support/persistence/TestBean.java b/spring-webflow/src/test/java/org/springframework/webflow/support/persistence/TestBean.java
new file mode 100644
index 00000000..de53b624
--- /dev/null
+++ b/spring-webflow/src/test/java/org/springframework/webflow/support/persistence/TestBean.java
@@ -0,0 +1,16 @@
+package org.springframework.webflow.support.persistence;
+
+public class TestBean {
+
+ private long entityId;
+
+ private String name;
+
+ public TestBean(String name) {
+ this.name = name;
+ }
+
+ public String getName() {
+ return name;
+ }
+}