diff --git a/Controller/ControllerListener.php b/Controller/ControllerListener.php
deleted file mode 100644
index f628d9f..0000000
--- a/Controller/ControllerListener.php
+++ /dev/null
@@ -1,61 +0,0 @@
-container = $container;
- $this->matcher = $matcher;
- }
-
- public function onCoreController(FilterControllerEvent $event)
- {
- $request = $event->getRequest();
- $def = $this->matcher->match($request, $event->getController());
-
- if ($def) {
-
- list($controller, $action) = $event->getController();
-
- $txManagers = array();
- foreach ($def->getConnections() AS $txConnName) {
- if (($def->isInvokedOnSubrequest($txConnName) === true || $event->getRequestType() == HttpKernelInterface::SUB_REQUEST)) {
- $id = "simple_things_transactional.tx.".$txConnName;
- if (!$this->container->has($id)) {
- throw new \InvalidArgumentException(
- "A transactional manager by name of '".$txConnName."' was requested, but does not exist."
- );
- }
- $txManagers[$txConnName] = $this->container->get($id);
- }
- }
-
- $controller = new TransactionalControllerWrapper($controller, $txManagers, $def, $this->container->get('logger'));
- $event->setController(array($controller, $action));
- }
-
- }
-}
diff --git a/Controller/ControllerResolver.php b/Controller/ControllerResolver.php
deleted file mode 100644
index 8b53fb4..0000000
--- a/Controller/ControllerResolver.php
+++ /dev/null
@@ -1,29 +0,0 @@
-getController();
- }
- return parent::getArguments($request, $controller);
- }
-}
\ No newline at end of file
diff --git a/Controller/TraceableControllerResolver.php b/Controller/TraceableControllerResolver.php
deleted file mode 100644
index c010dfb..0000000
--- a/Controller/TraceableControllerResolver.php
+++ /dev/null
@@ -1,30 +0,0 @@
-getController();
- }
-
- return parent::getArguments($request, $controller);
- }
-}
\ No newline at end of file
diff --git a/Controller/TransactionalControllerWrapper.php b/Controller/TransactionalControllerWrapper.php
deleted file mode 100644
index e3e80b4..0000000
--- a/Controller/TransactionalControllerWrapper.php
+++ /dev/null
@@ -1,77 +0,0 @@
-controller = $controller;
- $this->txManagers = $txManagers;
- $this->def = $definition;
- $this->logger = $logger;
- }
-
- public function getController()
- {
- return $this->controller;
- }
-
- public function __call($method, $args)
- {
- foreach ($this->txManagers AS $txManager) {
- $txManager->beginTransaction();
- }
- if ($this->logger) {
- $this->logger->info("[TransactionBundle] Started transactions for " . implode(", ", array_keys($this->txManagers)));
- }
-
- try {
- $response = call_user_func_array(array($this->controller, $method), $args);
-
- foreach ($this->txManagers AS $txName => $txManager) {
- $txManager->commit();
- }
-
- if ($this->logger) {
- $this->logger->info("[TransactionBundle] Committed transactions for " . implode(", ", array_keys($this->txManagers)));
- }
-
- return $response;
- } catch(\Exception $e) {
- foreach ($this->txManagers AS $txName => $txManager) {
- if (!in_array(get_class($e), $this->def->getNoRollbackFor($txName))) {
- $txManager->rollBack();
- }
- }
- if ($this->logger) {
- $this->logger->info("[TransactionBundle] Aborted transactions for " . implode(", ", array_keys($this->txManagers)));
- }
- throw $e;
- }
- }
-}
\ No newline at end of file
diff --git a/DependencyInjection/CompilerPass/DetectConnectionPass.php b/DependencyInjection/CompilerPass/DetectConnectionPass.php
new file mode 100644
index 0000000..4c72e13
--- /dev/null
+++ b/DependencyInjection/CompilerPass/DetectConnectionPass.php
@@ -0,0 +1,65 @@
+hasParameter('doctrine.connections')) {
+ foreach ($builder->getParameter('doctrine.connections') AS $alias => $service) {
+ $builder->setDefinition(
+ 'simple_things_transactional.tx.dbal.'.$alias,
+ new DefinitionDecorator('simple_things_transactional.provider.dbal')
+ )->setArguments(array(new Reference($service)));
+ }
+ }
+
+ if ($builder->hasParameter('doctrine.entity_managers')) {
+ foreach ($builder->getParameter('doctrine.entity_managers') AS $alias => $service) {
+ $builder->setDefinition(
+ 'simple_things_transactional.tx.orm.'.$alias,
+ new DefinitionDecorator('simple_things_transactional.provider.orm')
+ )->setArguments(array(new Reference('service_container')));
+ }
+ }
+
+ if ($builder->hasParameter('doctrine_couchdb.document_managers')) {
+ foreach ($builder->getParameter('doctrine_couchdb.document_managers') AS $alias => $service) {
+ $builder->setDefinition(
+ 'simple_things_transactional.tx.couchdb.'.$alias,
+ new DefinitionDecorator('simple_things_transactional.provider.object_manager')
+ )->setArguments(array(new Reference('service_container')));
+ }
+ }
+
+ if ($builder->hasParameter('doctrine_mongodb.document_managers')) {
+ foreach ($builder->getParameter('doctrine_mongodb.document_managers') AS $alias => $service) {
+ $builder->setDefinition(
+ 'simple_things_transactional.tx.mongodb.'.$alias,
+ new DefinitionDecorator('simple_things_transactional.provider.object_manager')
+ )->setArguments(array(new Reference('service_container')));
+ }
+ }
+ }
+}
+
diff --git a/DependencyInjection/SimpleThingsTransactionalExtension.php b/DependencyInjection/SimpleThingsTransactionalExtension.php
index 5e7946d..065707c 100644
--- a/DependencyInjection/SimpleThingsTransactionalExtension.php
+++ b/DependencyInjection/SimpleThingsTransactionalExtension.php
@@ -15,11 +15,10 @@
namespace SimpleThings\TransactionalBundle\DependencyInjection;
use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Symfony\Component\Config\FileLocator;
-use Symfony\Component\DependencyInjection\Reference;
-use Symfony\Component\DependencyInjection\DefinitionDecorator;
use SimpleThings\TransactionalBundle\Transactions\TransactionDefinition;
class SimpleThingsTransactionalExtension extends Extension
@@ -48,11 +47,8 @@ public function load(array $configs, ContainerBuilder $builder)
$config['defaults'] = array_merge(array(
'conn' => array(),
'pattern' => '.*',
- 'propagation' => TransactionDefinition::PROPAGATION_REQUIRED,
- 'isolation' => TransactionDefinition::ISOLATION_DEFAULT,
'noRollbackFor' => array(),
'methods' => array('POST', 'PUT', 'DELETE', 'PATCH'),
- 'subrequest' => false,
), $config['defaults']);
if (isset($config['auto_transactional']) && $config['auto_transactional']) {
@@ -75,40 +71,5 @@ public function load(array $configs, ContainerBuilder $builder)
$def = $builder->getDefinition('simple_things_transactional.transactional_matcher');
$def->setArguments($args);
- if ($builder->hasParameter('doctrine.connections')) {
- foreach ($builder->getParameter('doctrine.connections') AS $alias => $service) {
- $builder->setDefinition(
- 'simple_things_transactional.tx.dbal.'.$alias,
- new DefinitionDecorator('simple_things_transactional.manager.dbal')
- )->setArguments(array(new Reference($service)));
- }
- }
-
- if ($builder->hasParameter('doctrine.entity_managers')) {
- foreach ($builder->getParameter('doctrine.entity_managers') AS $alias => $service) {
- $builder->setDefinition(
- 'simple_things_transactional.tx.orm.'.$alias,
- new DefinitionDecorator('simple_things_transactional.manager.orm')
- )->setArguments(array(new Reference('doctrine'), $alias));
- }
- }
-
- if ($builder->hasParameter('doctrine_couchdb.document_managers')) {
- foreach ($builder->getParameter('doctrine_couchdb.document_managers') AS $alias => $service) {
- $builder->setDefinition(
- 'simple_things_transactional.tx.couchdb.'.$alias,
- new DefinitionDecorator('simple_things_transactional.manager.couchdb')
- )->setArguments(array(new Reference($service)));
- }
- }
-
- if ($builder->hasParameter('doctrine_mongodb.document_managers')) {
- foreach ($builder->getParameter('doctrine_mongodb.document_managers') AS $alias => $service) {
- $builder->setDefinition(
- 'simple_things_transactional.tx.mongodb.'.$alias,
- new DefinitionDecorator('simple_things_transactional.manager.mongodb')
- )->setArguments(array(new Reference($service)));
- }
- }
}
-}
\ No newline at end of file
+}
diff --git a/Doctrine/DBALTransactionProvider.php b/Doctrine/DBALTransactionProvider.php
new file mode 100644
index 0000000..b3cf3e8
--- /dev/null
+++ b/Doctrine/DBALTransactionProvider.php
@@ -0,0 +1,47 @@
+container = $container;
+ }
+
+ /**
+ * Get a transaction status object.
+ *
+ * @param TransactionDefinition $def
+ * @return TransactionStatus
+ */
+ public function createTransaction(TransactionDefinition $def)
+ {
+ $conn = $this->container->get('doctrine.' . $def->getConnectionName().'_connection');
+ return new DBALTransactionStatus($conn, $def);
+ }
+}
+
diff --git a/Doctrine/DBALTransactionStatus.php b/Doctrine/DBALTransactionStatus.php
new file mode 100644
index 0000000..f1051b0
--- /dev/null
+++ b/Doctrine/DBALTransactionStatus.php
@@ -0,0 +1,126 @@
+conn = $conn;
+ $this->def = $def;
+ }
+
+ /**
+ * Checks if the transaction is read-only.
+ *
+ * A read-only transaction does not commit changes to the database when
+ * commit is called. It allows the underlying transaction manager to
+ * perform optimizations to this regard if possible.
+ *
+ * @return bool
+ */
+ public function isReadOnly()
+ {
+ return $this->def->isReadOnly();
+ }
+
+ /**
+ * Check if transaction is broken and has to be rolled back at this point.
+ *
+ * @return bool
+ */
+ public function isRollBackOnly()
+ {
+ $this->conn->isRollBackOnly();
+ }
+
+ /**
+ * Mark the transaction as rollback-only
+ *
+ * @return void
+ */
+ public function setRollBackOnly()
+ {
+ $this->conn->setRollBackOnly(true);
+ }
+
+ /**
+ * Check if this transaction was committed already.
+ *
+ * @return bool
+ */
+ public function isCompleted()
+ {
+ return $this->completed;
+ }
+
+ public function getWrappedConnection()
+ {
+ return $this->conn;
+ }
+
+ /**
+ * Begin the transaction
+ */
+ public function beginTransaction()
+ {
+ $this->conn->beginTransaction();
+ }
+
+ /**
+ * Commit the transaction
+ *
+ * Depending on the Transaction#isRollBackOnly status this method commits
+ * or rollbacks the transaction wrapped inside the status. If an error
+ * happens during commit the original exception of the underlying
+ * connection is thrown from this method.
+ *
+ * @throws Exception
+ * @return void
+ */
+ public function commit()
+ {
+ if ($this->isReadOnly()) {
+ return $this->rollBack();
+ }
+
+ $this->conn->commit();
+ if (0 === $this->conn->getTransactionNestingLevel()) {
+ $this->completed = true;
+ }
+ }
+
+ /**
+ * Rollback the transaction inside the status object.
+ *
+ * @return void
+ */
+ public function rollBack()
+ {
+ $this->conn->rollBack();
+ if (0 === $this->conn->getTransactionNestingLevel()) {
+ $this->completed = true;
+ }
+ }
+}
+
diff --git a/Doctrine/ObjectTransactionProvider.php b/Doctrine/ObjectTransactionProvider.php
new file mode 100644
index 0000000..296cf0e
--- /dev/null
+++ b/Doctrine/ObjectTransactionProvider.php
@@ -0,0 +1,38 @@
+container = $container;
+ }
+
+ public function createTransaction(TransactionDefinition $def)
+ {
+ $conn = $this->container->get('doctrine.' . $def->getConnectionName() . '_manager');
+ return new ObjectTransactionStatus($conn, $def);
+ }
+}
+
diff --git a/Doctrine/ObjectTransactionStatus.php b/Doctrine/ObjectTransactionStatus.php
new file mode 100644
index 0000000..9af5c97
--- /dev/null
+++ b/Doctrine/ObjectTransactionStatus.php
@@ -0,0 +1,136 @@
+manager = $manager;
+ $this->def = $def;
+ }
+
+ /**
+ * Checks if the transaction is read-only.
+ *
+ * A read-only transaction does not commit changes to the database when
+ * commit is called. It allows the underlying transaction manager to
+ * perform optimizations to this regard if possible.
+ *
+ * @return bool
+ */
+ public function isReadOnly()
+ {
+ return $this->def->isReadOnly();
+ }
+
+ /**
+ * Check if transaction is broken and has to be rolled back at this point.
+ *
+ * @return bool
+ */
+ public function isRollBackOnly()
+ {
+ return $this->rollBackOnly;
+ }
+
+ /**
+ * Mark the transaction as rollback-only
+ *
+ * @return void
+ */
+ public function setRollBackOnly()
+ {
+ $this->rollBackOnly = true;
+ }
+
+ /**
+ * Check if this transaction was committed already.
+ *
+ * @return bool
+ */
+ public function isCompleted()
+ {
+ return $this->completed;
+ }
+
+ /**
+ * Return the connection object that is wrapped in this status.
+ *
+ * @return object
+ */
+ public function getWrappedConnection()
+ {
+ return $this->manager;
+ }
+
+ /**
+ * Begin the transaction
+ */
+ public function beginTransaction()
+ {
+ $this->nestingLevel++;
+ }
+
+ /**
+ * Commit the transaction
+ *
+ * Depending on the Transaction#isRollBackOnly status this method commits
+ * or rollbacks the transaction wrapped inside the status. If an error
+ * happens during commit the original exception of the underlying
+ * connection is thrown from this method.
+ *
+ * @throws Exception
+ * @return void
+ */
+ public function commit()
+ {
+ if ( ! $this->isReadOnly() && ! $this->isRollBackOnly() && $this->nestingLevel === 1) {
+ $this->manager->flush();
+ }
+ $this->decreateNestingLevel();
+ }
+
+ private function decreateNestingLevel()
+ {
+ $this->nestingLevel--;
+
+ if ($this->nestingLevel === 0) {
+ $this->completed = true;
+ }
+ }
+
+ /**
+ * Rollback the transaction inside the status object.
+ *
+ * @return void
+ */
+ public function rollBack()
+ {
+ $this->manager->clear();
+ $this->rollBackOnly = true;
+ $this->decreateNestingLevel();
+ }
+}
+
diff --git a/Doctrine/OrmTransactionProvider.php b/Doctrine/OrmTransactionProvider.php
new file mode 100644
index 0000000..8a9ff4e
--- /dev/null
+++ b/Doctrine/OrmTransactionProvider.php
@@ -0,0 +1,33 @@
+container = $container;
+ }
+
+ public function createTransaction(TransactionDefinition $def)
+ {
+ $manager = $this->container->get('doctrine.' . $def->getConnectionName().'_entity_manager');
+ return new OrmTransactionStatus($manager, $def);
+ }
+}
diff --git a/Doctrine/OrmTransactionStatus.php b/Doctrine/OrmTransactionStatus.php
new file mode 100644
index 0000000..d0e3fe1
--- /dev/null
+++ b/Doctrine/OrmTransactionStatus.php
@@ -0,0 +1,134 @@
+manager = $manager;
+ $this->def = $def;
+ }
+
+ /**
+ * Checks if the transaction is read-only.
+ *
+ * A read-only transaction does not commit changes to the database when
+ * commit is called. It allows the underlying transaction manager to
+ * perform optimizations to this regard if possible.
+ *
+ * @return bool
+ */
+ public function isReadOnly()
+ {
+ return $this->def->isReadOnly();
+ }
+
+ /**
+ * Check if transaction is broken and has to be rolled back at this point.
+ *
+ * @return bool
+ */
+ public function isRollBackOnly()
+ {
+ return $this->manager->getConnection()->isRollBackOnly();
+ }
+
+ /**
+ * Mark the transaction as rollback-only
+ *
+ * @return void
+ */
+ public function setRollBackOnly()
+ {
+ return $this->manager->getConnection()->setRollBackOnly(True);
+ }
+
+ /**
+ * Check if this transaction was committed already.
+ *
+ * @return bool
+ */
+ public function isCompleted()
+ {
+ return $this->completed;
+ }
+
+ /**
+ * Return the connection object that is wrapped in this status.
+ *
+ * @return object
+ */
+ public function getWrappedConnection()
+ {
+ return $this->manager;
+ }
+
+ /**
+ * Begin the transaction
+ */
+ public function beginTransaction()
+ {
+ $this->manager->beginTransaction();
+ }
+
+ /**
+ * Commit the transaction
+ *
+ * Depending on the Transaction#isRollBackOnly status this method commits
+ * or rollbacks the transaction wrapped inside the status. If an error
+ * happens during commit the original exception of the underlying
+ * connection is thrown from this method.
+ *
+ * @throws Exception
+ * @return void
+ */
+ public function commit()
+ {
+ if ($this->isReadOnly()) {
+ return $this->rollBack();
+ }
+
+ if ( ! $this->isRollBackOnly() && $this->manager->getConnection()->getTransactionNestingLevel() == 1) {
+ $this->manager->flush();
+ $this->manager->getConnection()->commit();
+ }
+
+ if ($this->manager->getConnection()->getTransactionNestingLevel() == 0) {
+ $this->completed = true;
+ }
+ }
+
+ /**
+ * Rollback the transaction inside the status object.
+ *
+ * @return void
+ */
+ public function rollBack()
+ {
+ $this->manager->rollBack();
+ if ($this->manager->getConnection()->getTransactionNestingLevel() == 0) {
+ $this->completed = true;
+ }
+ }
+}
+
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..ce654c1
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,9 @@
+Copyright (c) 2012, Benjamin Eberlei
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.markdown b/README.markdown
index 223fec3..c7d6e7c 100644
--- a/README.markdown
+++ b/README.markdown
@@ -1,68 +1,66 @@
# SimpleThings TransactionalBundle
-Wraps calls to controllers into a transaction, be it Doctrine DBAL or Persistence Managers (ORM, MongoDB, CouchDB).
-Configuration is done via routing parameters or through a list of controllers/actions configured in the
-extension config.
+Provides the missing transactions support for Symfony2 on the framework level. The bundle wraps calls to controllers into transactions for either database connections or object managers. The bundle is stricly meant to be run in an HTTP context, using the HTTP verbs for differentating between read/write and read-only transactions.
## Installation
See at the end of this document.
-## Problem
+## Problems to solve
-Symfony2 allows to nest controllers into each other in unlimited amounts. These controllers can all modify and save
-data, probably with different transactional needs. The Doctrine persistence solutions (ORM, MongoDB, CouchDB) use a transactional write-behind
-mechanism to flush changes in batches, best executed at the end of the master request. If each controller
-or model service handles transactions themselves then you probably overuse the flush operation, which
-can lead to inconsistencies and performance penalities.
+Symfony2 allows to nest controllers into each other in unlimited amounts. These controllers can all modify and save data. The Doctrine persistence solutions (ORM, MongoDB, CouchDB) use a transactional write-behind mechanism to flush changes in batches, best executed at the end of the master request. If each controller or model service handles transactions themselves then you probably overuse the flush operation, which can lead to inconsistencies and performance penalities.
-These flushes should not be executed in the model/services but should be handled by the controller layer, because it knows when all operations are done.
+Additionally your domain code should not be cluttered with transactional code when its not stricly needed.
+
+Therefore transaction management should by seperated from your domain model, handled by the framework in a HTTP context.
## How it works
-For every Doctrine DBAL connection, every EntityManager and every DocumentManager the Transactional Bundle
-creates a service that implements a transactions manager interface:
+For every Doctrine DBAL connection, every EntityManager and every DocumentManager the Transactional Bundle creates a service that implements a transactions provider interface:
- interface TransactionManagerInterface
+ interface TransactionProviderInterface
{
- function beginTransaction();
- function commit();
- function rollBack();
+ /**
+ * @return TransactionStatus
+ */
+ function createTransaction(TransactionDefinition $def);
}
-With the transactional bundle the following workflow is applied to an action that is marked
-as transactional (by default always if POST, PUT, DELETE, PATCH request is found).
+With the transactional bundle the following workflow is applied to an action that is marked as transactional
-0. Detect which Transaction Manager(s) should wrap the to-be-excecuted action.
-1. A transaction is started before the controller is called.
-2. The controller execution is wrapped in a try-catch block
-3. On successful response generation (status code < 400) the transaction is committed. This includes a call to EntityManager::flush or DocumentManager::flush in case of an orm, mongodb or couchdb "transaction".
-4. On status-code >= 400 the transaction is rolled back.
-5. If an exception is thrown the transaction is rolled back.
+1. Detect which Connection should wrap the to-be-excecuted action and if its read/write or read-only.
+2. A transaction is started before the controller is called.
+3. The action is called by Symfony
+4. On successful response generation (status code < 400) the transaction is committed. This includes a call to EntityManager::flush or DocumentManager::flush in case of an orm, mongodb or couchdb "transaction".
+5. On status-code >= 400 the transaction is rolled back.
+6. If an exception is thrown the transaction is rolled back.
-Each transaction manager is named like the manager it belongs to:
+You can mark actions as transactional by means of configuration. There are three different ways to configure the transactional behavior:
- doctrine.orm.default_entity_manager => simplethings_tx.orm.default
- doctrine_mongodb.odm.default_document_manager => simplethings_tx.mongodb.default
- doctrine_couchdb.odm.default_document_manager => simplethings_tx.couchdb.default
+### Architectural Details
-You can mark actions as transactional by means of configuration. There are three different ways to configure the transactional behavior:
+1. There is only exactly one transaction per action. This bundle will not automatically handle transactions across multiple data-source as this is a very implementation specific problem. If you need multiple connections handle one transaction in your application then implement your own transaction provider that implements some kind of two-phase commit.
+2. A form extension is provided that will mark a transaction as rollback only validation on the form fails.
+3. Transactions are either read/write or read-only. A read-only transaction is rolled back at the end of the request no matter what. In the context of ObjectMAnagers this means the flush operation is NOT called. The read/write or read-only status is detected by matching against the HTTP Verbs. By default GET requests are read-only and PUT, POST, DELETE and PATCH are read/write transactions.
+4. If the read-only/read-write status switches during a sub-request an exception is thrown. Modes cannot be mixed.
-### Working with a default transaction manager
+## Configuration
-If you have a small RESTful application and you only use one transactional manager, for example the Doctrine ORM then your configuration
+### Auto-Transactional Mode
+
+If you have a RESTful application and you only use one transactional manager, for example the Doctrine ORM then your configuration
is as simple as configuring the transactional managers name in the app/config/config.yml extension configuration:
simple_things_transactional:
auto_transactional: true
defaults:
- conn: ["orm.default"]
+ conn: "orm.default"
With this configuration every POST, PUT, DELETE and PATCH request is wrapped inside a transaction of the given connection.
There is no way to disable this behavior except by throwing an exception. GET requests that need to write a transaction
have to do this explicitly.
-### Working with explicit configuration
+### Controller Pattern Matching
If you have an application that is either not RESTful, uses multiple transactional managers or has advanced
requirements with regard to transactions then you should configure the transactional behavior explicitly.
@@ -73,77 +71,96 @@ If a transaction is started for a connection multiple times then an exception is
simple_things_transactional:
defaults:
- conn: ["mongodb.default"]
+ conn: "mongodb.default"
methods: ["POST", "PUT", "DELETE", "PATCH"]
patterns:
fos_user:
pattern: "FOS\(.*)Controller::(.*)Action"
# not giving conn: uses the default
- propagation: REQUIRES_NEW
- noRollbackFor: ["NotFoundHttpException"]
- subrequest: true
acme:
pattern: "Acme(.*)"
- conn: ["orm.default", "couchdb.default"]
- subrequest: false
acme_logging:
pattern: "Acme\DemoBundle\Controller\IndexController::logAction"
- conn: ["dbal.other"]
+ conn: "orm.other"
methods: ["GET"]
### Annotations
-You can also configure transactional behavior with annotations. The configuration for annotations is as simple as:
+You can also configure transactional behavior with annotations. Enabling annotations is simple:
simple_things_transactional:
annotations: true
-The previous `Acme\DemoBundle\Controller\IndexController` then looks like:
+The previous `Acme\DemoBundle\Controller\IndexController` can then be configured by adding:
namespace Acme\DemoBundle\Controller;
- use SimpleThings\TransactionalBundle\Annotations AS Tx;
+ use SimpleThings\TransactionalBundle\Transactions\Annotations AS Tx;
/**
- * @Tx\Transactional(conn={"orm.default", "couchdb.default"})
+ * @Tx\Transactional(conn="orm.default")
*/
class IndexController
{
+ public function indexAction()
+ {
+ // orm.default transaction here
+ }
+
/**
- * @Tx\Transactional(conn={"orm.other"}, methods: {"GET"})
+ * @Tx\Transactional(conn="orm.other", methods: {"GET"})
*/
public function demoAction()
{
-
+ // orm.other transaction here
}
}
-## Example
+## Doctrine ORM Example
-Using the previous routes as example here is a sample action that does not require any calls to EntityManager::flush anymore.
+This example assumes:
+
+* You are using FrameworkExtraBundle and converters
+* You automatically use HTTP semantics for tx management.
+
+See this controller for a Form Edit/Display.
+
+* It will commit all the changes automatically when a post request occurs.
+* To rollback the transaction when a form error occurs the transactional
+ bundle automatically registers a form validator and sets all current
+ transactions to 'rollback only'.
+
+Here is the code:
class PostController extends Controller
{
- public function editAction($id)
+ /**
+ * @ParamConverter("post", class="AcmeBlogBundle:Post")
+ * @Template
+ */
+ public function editAction(Post $post, Request $request)
{
- $em = $this->container->get('doctrine.orm.default_entity_manager');
- $post = $em->find('Post', $id);
+ $form = $this->createForm(new PostType(), $post);
- if ($this->container->get('request')->getMethod() == 'POST') {
- $post->modifyState();
- // no need to call $em->flush(), the flush is executed in a transactional wrapper
+ if ($request->getMethod() == 'POST') {
+ $form->bindRequest($request);
- return $this->redirect($this->generateUrl("view_post", array("id" => $post->getId()));
+ if ($form->isValid()) {
+ return $this->redirect($this->generateUrl("view_post", array("id" => $post->getId()));
+ }
}
- return $this->render("MyBlogBundle:Post:edit.html.twig", array());
+ return array('form' => $form->createView());
}
}
-`EntityManager#flush()` is only called when the requet is using the POST-method.
## Installation
+On Composer as 'simplethings/transactional-bundle' package.
+
+Or oldschool:
+
1. Add TransactionalBundle to deps:
[SimpleThingsTransactionalBundle]
@@ -171,8 +188,8 @@ Using the previous routes as example here is a sample action that does not requi
simple_things_transactional: ~
-## Todos
+# Todos
+
+* Move configuration to Symfony\Component\Config
+* Add modes 'autocommit', 'commit_on_success' (default) and 'manual' that determine how an action should be handled transactionally.
-* Implement Propagation
-* Implement Isolation
-* Try to evaluate if hooking into exception_handler is a killing exceptions from controllers more gracefully and not having them loose the stack trace.
diff --git a/Resources/config/services.xml b/Resources/config/services.xml
index eb3e7ce..36f5f8d 100644
--- a/Resources/config/services.xml
+++ b/Resources/config/services.xml
@@ -5,32 +5,43 @@
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
- SimpleThings\TransactionalBundle\Transactions\Doctrine\DBALTransactionManager
- SimpleThings\TransactionalBundle\Transactions\Doctrine\EntityManagerTransactionManager
- SimpleThings\TransactionalBundle\Transactions\Doctrine\MongoDBTransactionManager
- SimpleThings\TransactionalBundle\Transactions\Doctrine\CouchDBTransactionManager
- SimpleThings\TransactionalBundle\Controller\ControllerListener
- SimpleThings\TransactionalBundle\Transactions\TransactionalMatcher
- SimpleThings\TransactionalBundle\Controller\ControllerResolver
- SimpleThings\TransactionalBundle\Controller\TraceableControllerResolver
+ SimpleThings\TransactionalBundle\Doctrine\DBALTransactionProvider
+ SimpleThings\TransactionalBundle\Doctrine\OrmTransactionProvider
+ SimpleThings\TransactionalBundle\Transactions\Doctrine\ObjectTransactionProvider
+ SimpleThings\TransactionalBundle\Transactions\Http\HttpTransactionsListener
+ SimpleThings\TransactionalBundle\Transactions\Http\TransactionalMatcher
+ SimpleThings\TransactionalBundle\Transactions\TransactionsRegistry
-
+
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
\ No newline at end of file
+
diff --git a/Resources/docs/DESIGN_DOCS b/Resources/docs/DESIGN_DOCS
new file mode 100644
index 0000000..2ca65ce
--- /dev/null
+++ b/Resources/docs/DESIGN_DOCS
@@ -0,0 +1,97 @@
+Design Document: Transaction Management in Symfony
+==================================================
+
+Symfony is a HTTP-framework. The framework transaction management should be based
+on this request/response semantic. A transaction should be opened for every request
+and committed when the response is sent and no error occured. Currently Symfony does
+not handle transactions at all, leaving this to the developer.
+
+HTTP Transaction Management
+---------------------------
+
+A transaction for all involved database resources of a controller should be started
+in the kernel controller event. The kernel.response and kernel.exception events handle
+successes and errors respectively. Different types of exceptions should either lead to
+commit or rollback of the transaction.
+
+Since Symfony supports sub-requests we have to decide how to handle subrequests:
+
+1. Re-use existing transaction scope. If the subrequest transaction fails the
+ parent requests transaction is rolled back as well.
+
+ This is useful for subrequests that are either not failing by design or
+ that explicitly should rollback the parent transactions as well.
+
+2. Don't care about transactions (re-use existing, or don't open a transaction)
+ This is useful for view slots (render) that only need the database for read access.
+
+3. Start their own sub-transaction. This poses several problems: The existing transactional
+ service could already be injected into several services that are container scoped and could be
+ resused in the action that "requires new" transaction. Additionally we have to handle
+ the transaction stack and "suspend" and "resume" transactions correctly.
+
+ This type is useful if subrequests do data-manipulation that should never affect other transactions,
+ or if several subrequests are started (slots) that should run independently of each other.
+
+4. Manually
+
+ No Transaction should be started already and the user wants to manually handle all the transactions.
+ This is the current Symfony default (with no support for transactions). It is however sometimes necessary
+ in complex scenarios to manually handle transactions.
+
+New Transaction in Sub-Requests
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Staring new transactions in sub-requests creates a bunch of problems that have to be solved:
+
+1. How to guarantee that all services in the sub-request contain references to the connection
+ with the new transaction?
+2. How to suspend and resume the previous transaction if the sub-request is completed.
+
+First we have to evaluate what we want from this feature:
+
+1. Independence of sub-request transactions from any other sources. The sub-request has to complete or rollback alone.
+2. Independence of the parent requests from the sub-request, which maybe unstable with regard to tranaction success.
+
+In short: A REQUIRES_NEW transaction should behave as if it were a master-request. No effects of the transaction should
+be visible outside of it.
+
+Solution 1: Transactional Scope
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+We may need a container scope "transactional" for this. Problems with this are:
+
+* Say service X is used in the REQUIRES_NEW controller. Then it has to be marked as "transactional" scope.
+* If it depends on any connection these have to be marked as "transactional" too.
+* Every service depending on any connection is then required to be "transactional".
+
+How can we break up this problem from point 2 to 3? We want services to be container scoped by default.
+
+Problems:
+
+* We have to automatically implicitly move all services depending on connections from container to transactional scope.
+* Transaction scope has to be the parent of request scope.
+
+Solution 2: Connection Proxies
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Each connection is a proxy to the real connection and returns the right/new connection.
+
+Combined Transactions
+---------------------
+
+What if one controller requires a transaction for several different resources.
+
+* Do commits have to be synchronized? (Two-phase commit) - This is very complex
+* Do rollbacks have to be synchronized (if one is isRollbackOnly, all others rollback as well)
+
+For the beginning I would say that transactions can fail and commit independently.
+
+If a user requires synchronization then he should implement his own transaction manager and
+implement two-phase-commit manually.
+
+Read Only
+---------
+
+Transactions are bound to the HTTP Verb semantics. By default GET requests create Read-Only transactions.
+Read-only transactions never commit at the end of the response/transaction cycle.
diff --git a/SimpleThingsTransactionalBundle.php b/SimpleThingsTransactionalBundle.php
index b3bd5ef..6480d47 100644
--- a/SimpleThingsTransactionalBundle.php
+++ b/SimpleThingsTransactionalBundle.php
@@ -15,8 +15,15 @@
namespace SimpleThings\TransactionalBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+use SimpleThings\TransactionalBundle\DependencyInjection\CompilerPass\DetectConnectionPass;
class SimpleThingsTransactionalBundle extends Bundle
{
-
-}
\ No newline at end of file
+ public function build(ContainerBuilder $container)
+ {
+ parent::build($container);
+
+ $container->addCompilerPass(new DetectConnectionPass());
+ }
+}
diff --git a/Tests/ContainerTest.php b/Tests/ContainerTest.php
new file mode 100644
index 0000000..50df762
--- /dev/null
+++ b/Tests/ContainerTest.php
@@ -0,0 +1,86 @@
+createTestContainer();
+ $this->assertInstanceOf('SimpleThings\TransactionalBundle\Transactions\Http\HttpTransactionsListener', $container->get('simple_things_transactional.http_transactions_listener'));
+ $this->assertInstanceOf('SimpleThings\TransactionalBundle\Transactions\TransactionsRegistry', $container->get('simple_things_transactional.registry'));
+ $this->assertInstanceOf('SimpleThings\TransactionalBundle\Doctrine\DBALTransactionProvider', $container->get('simple_things_transactional.tx.dbal.default'));
+ $this->assertInstanceOf('SimpleThings\TransactionalBundle\Doctrine\OrmTransactionProvider', $container->get('simple_things_transactional.tx.orm.default'));
+ $this->assertInstanceOf('SimpleThings\TransactionalBundle\Transactions\Form\RollbackInvalidFormExtension', $container->get('simple_things_transactional.form_extension'));
+ $this->assertInstanceOf('SimpleThings\TransactionalBundle\Transactions\Http\TransactionalMatcher', $container->get('simple_things_transactional.transactional_matcher'));
+ }
+
+ public function createTestContainer()
+ {
+ $container = new ContainerBuilder(new ParameterBag(array(
+ 'kernel.debug' => false,
+ 'kernel.bundles' => array(),
+ 'kernel.cache_dir' => sys_get_temp_dir(),
+ 'kernel.environment' => 'test',
+ 'kernel.root_dir' => __DIR__
+ )));
+ $container->set('annotation_reader', new AnnotationReader());
+ $loader = new DoctrineExtension();
+ $container->registerExtension($loader);
+ $loader->load(array(array(
+ 'dbal' => array(
+ 'connections' => array(
+ 'default' => array(
+ 'driver' => 'pdo_mysql',
+ 'charset' => 'UTF8',
+ 'platform-service' => 'my.platform',
+ )
+ ),
+ 'default_connection' => 'default',
+ 'types' => array(
+ 'test' => 'Symfony\Bundle\DoctrineBundle\Tests\DependencyInjection\TestType',
+ ),
+ ), 'orm' => array(
+ 'default_entity_manager' => 'default',
+ 'entity_managers' => array (
+ 'default' => array('auto_mapping' => true)
+ )
+ ))
+ ), $container);
+
+ $container->setDefinition('my.platform', new \Symfony\Component\DependencyInjection\Definition('Doctrine\DBAL\Platforms\MySqlPlatform'));
+
+ $loader = new SimpleThingsTransactionalExtension();
+ $container->registerExtension($loader);
+ $loader->load(array(array('auto_transactional' => true, 'defaults' => array('conn' => 'orm.default'))), $container);
+
+ $container->getCompilerPassConfig()->setOptimizationPasses(array(
+ new DetectConnectionPass(),
+ new ResolveDefinitionTemplatesPass()
+ ));
+ $container->getCompilerPassConfig()->setRemovingPasses(array());
+ $container->compile();
+
+ return $container;
+ }
+}
+
diff --git a/Tests/Doctrine/ObjectTransactionProviderTest.php b/Tests/Doctrine/ObjectTransactionProviderTest.php
new file mode 100644
index 0000000..9a77b53
--- /dev/null
+++ b/Tests/Doctrine/ObjectTransactionProviderTest.php
@@ -0,0 +1,35 @@
+getMock('Symfony\Component\DependencyInjection\ContainerInterface');
+ $container->expects($this->once())
+ ->method('get')
+ ->with($this->equalTo('doctrine.orm.default_entity_manager'))
+ ->will($this->returnValue($this->getMock('Doctrine\Common\Persistence\ObjectManager')));
+
+ $provider = new ObjectTransactionProvider($container);
+ $status = $provider->createTransaction(new TransactionDefinition('orm.default_entity'));
+
+ $this->assertInstanceOf('SimpleThings\TransactionalBundle\Doctrine\ObjectTransactionStatus', $status);
+ }
+}
+
diff --git a/Tests/Doctrine/OrmTransactionProviderTest.php b/Tests/Doctrine/OrmTransactionProviderTest.php
new file mode 100644
index 0000000..0c03391
--- /dev/null
+++ b/Tests/Doctrine/OrmTransactionProviderTest.php
@@ -0,0 +1,35 @@
+getMock('Symfony\Component\DependencyInjection\ContainerInterface');
+ $container->expects($this->once())
+ ->method('get')
+ ->with($this->equalTo('doctrine.orm.default_entity_manager'))
+ ->will($this->returnValue($this->getMock('Doctrine\ORM\EntityManager', array(), array(), '', false)));
+
+ $provider = new OrmTransactionProvider($container);
+ $status = $provider->createTransaction(new TransactionDefinition('orm.default'));
+
+ $this->assertInstanceOf('SimpleThings\TransactionalBundle\Doctrine\OrmTransactionStatus', $status);
+ }
+}
+
diff --git a/Tests/Functional/EndToEndTest.php b/Tests/Functional/EndToEndTest.php
new file mode 100644
index 0000000..d613fc6
--- /dev/null
+++ b/Tests/Functional/EndToEndTest.php
@@ -0,0 +1,156 @@
+setFactoryClass('Doctrine\DBAL\DriverManager');
+ $definition->setFactoryMethod('getConnection');
+ $definition->setArguments(array(array('driver' => 'pdo_sqlite', 'memory' => true)));
+ $definition->setScope('transactional');
+
+ $conn = $this->conn = DriverManager::getConnection(array('driver' => 'pdo_sqlite', 'memory' => true));
+ $table = new \Doctrine\DBAL\Schema\Table("testdata");
+ $table->addColumn('id', 'integer', array('auto_increment' => true));
+ $table->addColumn('val', 'string');
+ $table->setPrimaryKey(array('id'));
+
+ $conn->getSchemaManager()->createTable($table);
+
+ $this->logger = new FunctionalStackLogger;
+
+ $container = new ContainerBuilder();
+ $container->setDefinition('simple_things_transactional.connections.dbal.default', $definition);
+ $container->addScope(new Scope('transactional'));
+ $container->addScope(new Scope('request'));
+
+ // set does not work here, get has to work!
+ $container->set('doctrine.dbal.default_connection', $conn);
+ $container->set('simple_things_transactional.connections.dbal.default', $conn);
+
+ $txManager = new DBALTransactionProvider($container);
+ $container->set('simple_things_transactional.tx.dbal.default', $txManager);
+
+ $resolver = $this->getMock('Symfony\Component\HttpKernel\Controller\ControllerResolverInterface');
+ $resolver->expects($this->at(0))->method('getController')->will($this->returnValue(array(new DBALTestController($container), 'firstAction')));
+ $resolver->expects($this->at(2))->method('getController')->will($this->returnValue(array(new DBALTestController($container), 'secondAction')));
+ $resolver->expects($this->any())->method('getArguments')->will($this->returnValue(array()));
+
+ $registry = new TransactionsRegistry($container);
+ $matcher = new TransactionalMatcher(array(), array(
+ 'conn' => 'dbal.default',
+ 'methods' => array('POST'),
+ ));
+
+ $txListener = new HttpTransactionsListener($registry, $matcher, $this->logger);
+ $dispatcher = new EventDispatcher();
+ $dispatcher->addListener("kernel.controller", array($txListener, 'onCoreController'));
+ $dispatcher->addListener("kernel.exception", array($txListener, 'onKernelException'));
+ $dispatcher->addListener("kernel.response", array($txListener, 'onKernelResponse'));
+ $this->kernel = new HttpKernel($dispatcher, $container, $resolver);
+ $container->set('http_kernel', $this->kernel);
+ }
+
+ public function testGetRequest()
+ {
+ $request = Request::create('/foo', 'GET');
+ $this->kernel->handle($request);
+
+ $this->assertEquals(array(
+ "[TransactionBundle] Started transaction for dbal.default",
+ "[TransactionBundle] Started transaction for dbal.default",
+ "[TransactionBundle] Committed transaction.",
+ "[TransactionBundle] Committed transaction."
+ ), $this->logger->logs);
+
+ $this->assertEquals(0, count($this->conn->fetchAll("SELECT * FROM testdata")));
+ }
+
+ public function testPostRequest()
+ {
+ $request = Request::create('/foo', 'POST');
+ $this->kernel->handle($request);
+
+ $this->assertEquals(array(
+ "[TransactionBundle] Started transaction for dbal.default",
+ "[TransactionBundle] Started transaction for dbal.default",
+ "[TransactionBundle] Committed transaction.",
+ "[TransactionBundle] Committed transaction."
+ ), $this->logger->logs);
+
+ $this->assertEquals(2, count($this->conn->fetchAll("SELECT * FROM testdata")));
+ }
+}
+
+class FunctionalStackLogger extends NullLogger
+{
+ public $logs = array();
+
+ public function info($message, array $contexts = array())
+ {
+ $this->logs[] = $message;
+ }
+}
+
+class DBALTestController
+{
+ private $container;
+
+ public function __construct($container)
+ {
+ $this->container = $container;
+ }
+
+ public function firstAction()
+ {
+ $conn = $this->container->get('doctrine.dbal.default_connection');
+ $conn->insert("testdata", array("val" => "foo"));
+
+ $this->container->get('http_kernel')->forward('DBALTestController:secondAction');
+
+ return new Response('data', 200);
+ }
+
+ public function secondAction()
+ {
+ $conn = $this->container->get('doctrine.dbal.default_connection');
+ $conn->insert("testdata", array("val" => "foo"));
+
+ return new Response('data', 200);
+ }
+}
+
+
diff --git a/Tests/Functional/TransactionalKernelTest.php b/Tests/Functional/TransactionalKernelTest.php
new file mode 100644
index 0000000..e79f141
--- /dev/null
+++ b/Tests/Functional/TransactionalKernelTest.php
@@ -0,0 +1,105 @@
+getMock('Symfony\Component\HttpKernel\Controller\ControllerResolverInterface');
+ $resolver->expects($this->once())->method('getController')->will($this->returnValue(array(new TestController, 'indexAction')));
+ $resolver->expects($this->once())->method('getArguments')->will($this->returnValue(array()));
+
+ $txStatus1 = $this->getMock('SimpleThings\TransactionalBundle\Transactions\TransactionStatus');
+ $txStatus1->expects($this->any())->method('isReadOnly')->will($this->returnValue(false));
+ $provider = $this->getMock('SimpleThings\TransactionalBundle\Transactions\TransactionProviderInterface', array(), array(), '', false);
+ $provider->expects($this->at(0))->method('createTransaction')->will($this->returnValue($txStatus1));
+
+ $this->logger = new StackLogger;
+ $container = $this->getMock('Symfony\Component\DependencyInjection\ContainerInterface');
+ $container->expects($this->any())->method('has')->with($this->equalTo('simple_things_transactional.tx.dbal.default'))->will($this->returnValue(true));
+ $container->expects($this->any())->method('get')->with($this->equalTo('simple_things_transactional.tx.dbal.default'))->will($this->returnValue($provider));
+
+ $registry = new TransactionsRegistry($container);
+ $matcher = new TransactionalMatcher(array(), array(
+ 'conn' => 'dbal.default',
+ 'methods' => array('POST', 'GET'),
+ ));
+ $txListener = new HttpTransactionsListener($registry, $matcher, $this->logger);
+ $dispatcher = new EventDispatcher();
+ $dispatcher->addListener("kernel.controller", array($txListener, 'onCoreController'));
+ $dispatcher->addListener("kernel.exception", array($txListener, 'onKernelException'));
+ $dispatcher->addListener("kernel.response", array($txListener, 'onKernelResponse'));
+ $this->kernel = new HttpKernel($dispatcher, $resolver);
+ }
+
+ public function testGetRequest()
+ {
+ $request = Request::create('/foo', 'GET');
+ $this->kernel->handle($request);
+
+ $this->assertEquals(array(
+ "[TransactionBundle] Started transaction for dbal.default",
+ "[TransactionBundle] Committed transaction."
+ ), $this->logger->logs);
+ }
+
+ public function testPostRequest()
+ {
+ $request = Request::create('/foo', 'POST');
+ $this->kernel->handle($request);
+
+ $this->assertEquals(array(
+ "[TransactionBundle] Started transaction for dbal.default",
+ "[TransactionBundle] Committed transaction."
+ ), $this->logger->logs);
+ }
+}
+
+class StackLogger extends NullLogger
+{
+ public $logs = array();
+
+ public function info($message, array $contexts = array())
+ {
+ $this->logs[] = $message;
+ }
+}
+
+class TestController
+{
+ public function indexAction()
+ {
+ return new Response('data', 200);
+ }
+
+ public function subAction()
+ {
+ return new Response('data', 200);
+ }
+}
+
diff --git a/Tests/Transactions/Http/HttpTransactionsListenerTest.php b/Tests/Transactions/Http/HttpTransactionsListenerTest.php
new file mode 100644
index 0000000..463c286
--- /dev/null
+++ b/Tests/Transactions/Http/HttpTransactionsListenerTest.php
@@ -0,0 +1,53 @@
+matcher = $this->getMock('SimpleThings\TransactionalBundle\Transactions\Http\TransactionalMatcher', array(), array(), '', false);
+ $this->registry = $this->getMock('SimpleThings\TransactionalBundle\Transactions\TransactionsRegistry', array(), array(), '', false);
+ $this->listener = new HttpTransactionsListener($this->registry, $this->matcher);
+ }
+
+ public function testOnCoreController()
+ {
+ $kernel = $this->getMock('Symfony\Component\HttpKernel\HttpKernelInterface');
+ $request = Request::create('/foo', 'POST');
+ $event = new FilterControllerEvent($kernel, array(__CLASS__, 'testOnCoreController'), $request, null);
+
+ $txStatus = $this->getMock('SimpleThings\TransactionalBundle\Transactions\TransactionStatus');
+ $def = new TransactionDefinition('name', 1, 1, false, array());
+
+ $this->matcher->expects($this->once())->method('match')->will($this->returnValue($def));
+ $this->registry->expects($this->once())->method('getTransaction')->with($this->equalTo($def))->will($this->returnValue($txStatus));
+
+ $this->listener->onCoreController($event);
+
+ $this->assertSame($txStatus, $request->attributes->get('_transaction'));
+ }
+}
+
diff --git a/Tests/Transactions/Http/TransactionalMatcherTest.php b/Tests/Transactions/Http/TransactionalMatcherTest.php
new file mode 100644
index 0000000..23b93af
--- /dev/null
+++ b/Tests/Transactions/Http/TransactionalMatcherTest.php
@@ -0,0 +1,118 @@
+ $pattern,
+ 'methods' => array('POST', 'PUT'),
+ 'conn' => 'orm.default',
+ 'noRollbackFor' => array(),
+ );
+
+ $matcher = new TransactionalMatcher(array($pattern), array(), $this->reader);
+ $controller = new TestController();
+
+ $definition = $matcher->match($method, array($controller, 'fooAction'));
+
+ if ($matched) {
+ $this->assertInstanceOf('SimpleThings\TransactionalBundle\Transactions\TransactionDefinition', $definition);
+ $this->assertEquals('orm.default', $definition->getConnectionName());
+ $this->assertEquals($readOnly, $definition->isReadOnly());
+ } else {
+ $this->assertFalse($definition);
+ }
+ }
+
+ public function testMatchClassAnnotation()
+ {
+ $defaults = array(
+ 'noRollbackFor' => array(),
+ );
+
+ $matcher = new TransactionalMatcher(array(), $defaults, $this->reader);
+ $controller = new TestController();
+
+ $this->reader->expects($this->once())
+ ->method('getClassAnnotation')
+ ->will($this->returnValue(
+ new Transactional(array(
+ 'methods' => array('GET'),
+ 'conn' => 'orm.default',
+ ))
+ ));
+
+ $definition = $matcher->match('GET', array($controller, 'fooAction'));
+
+ $expectedDefinition = new TransactionDefinition(
+ 'orm.default',
+ false,
+ array()
+ );
+ $this->assertEquals($expectedDefinition, $definition);
+ }
+
+ public function testMatchMethodAnnotation()
+ {
+ $defaults = array(
+ 'noRollbackFor' => array(),
+ );
+
+ $matcher = new TransactionalMatcher(array(), $defaults, $this->reader);
+ $controller = new TestController();
+
+ $this->reader->expects($this->once())
+ ->method('getClassAnnotation');
+ $this->reader->expects($this->once())
+ ->method('getMethodAnnotation')
+ ->will($this->returnValue(
+ new Transactional(array(
+ 'methods' => array('GET'),
+ 'conn' => 'orm.default',
+ ))
+ ));
+
+ $definition = $matcher->match('GET', array($controller, 'fooAction'));
+
+ $expectedDefinition = new TransactionDefinition(
+ 'orm.default',
+ false,
+ array()
+ );
+ $this->assertEquals($expectedDefinition, $definition);
+ }
+
+ public function getPatterns()
+ {
+ return array(
+ array('.*', 'POST', true, false),
+ array('SimpleThings\\\\(.+)Controller::(.+)Action', 'POST', true, false),
+ array('SimpleThings\\\\TransactionalBundle\\\\Tests\\\\Transactions\\\\Http\\\\TestController::fooAction', 'POST', true, false),
+ array('.*', 'GET', true, true),
+ array('SimpleThings\\\\(.+)Controller::barAction', 'POST', false, false),
+ );
+ }
+
+ protected function setUp()
+ {
+ $this->reader = $this->getMock('Doctrine\Common\Annotations\Reader');
+ }
+}
+
+class TestController
+{
+ public function fooAction() {}
+}
diff --git a/Tests/Transactions/TransactionalMatcherTest.php b/Tests/Transactions/TransactionalMatcherTest.php
deleted file mode 100644
index 80632cd..0000000
--- a/Tests/Transactions/TransactionalMatcherTest.php
+++ /dev/null
@@ -1,20 +0,0 @@
-reader = $this->getMock('Doctrine\Common\Annotations\Reader');
- }
-
- public function testMatch()
- {
- $this->matcher = new TransactionalMatcher(array('/foo/bar'), array(), $this->reader);
- }
-}
-
diff --git a/TransactionalException.php b/TransactionException.php
similarity index 100%
rename from TransactionalException.php
rename to TransactionException.php
diff --git a/Annotations/Transactional.php b/Transactions/Annotations/Transactional.php
similarity index 66%
rename from Annotations/Transactional.php
rename to Transactions/Annotations/Transactional.php
index 6a51d23..cb07279 100644
--- a/Annotations/Transactional.php
+++ b/Transactions/Annotations/Transactional.php
@@ -12,10 +12,9 @@
* to kontakt@beberlei.de so I can send you a copy immediately.
*/
-namespace SimpleThings\TransactionalBundle\Annotations;
+namespace SimpleThings\TransactionalBundle\Transactions\Annotations;
use Doctrine\Common\Annotations\Annotation;
-use SimpleThings\TransactionalBundle\Transactions\TransactionDefinition;
/**
* @Annotation
@@ -23,17 +22,9 @@
class Transactional extends Annotation
{
/**
- * @var array
+ * @var string
*/
public $conn = null;
- /**
- * @var int
- */
- public $propagation = null;
- /**
- * @var int
- */
- public $isolation = null;
/**
* @var array
*/
@@ -42,9 +33,5 @@ class Transactional extends Annotation
* @var array
*/
public $methods = null;
+}
- /**
- * @var bool
- */
- public $subrequest = null;
-}
\ No newline at end of file
diff --git a/Transactions/Doctrine/CouchDBTransactionManager.php b/Transactions/Doctrine/CouchDBTransactionManager.php
deleted file mode 100644
index c9b8e73..0000000
--- a/Transactions/Doctrine/CouchDBTransactionManager.php
+++ /dev/null
@@ -1,44 +0,0 @@
-dm = $dm;
- }
-
- public function beginTransaction()
- {
-
- }
-
- public function commit()
- {
- $this->dm->flush();
- }
-
- public function rollBack()
- {
-
- }
-
-}
\ No newline at end of file
diff --git a/Transactions/Doctrine/DBALTransactionManager.php b/Transactions/Doctrine/DBALTransactionManager.php
deleted file mode 100644
index 755dd15..0000000
--- a/Transactions/Doctrine/DBALTransactionManager.php
+++ /dev/null
@@ -1,48 +0,0 @@
-conn = $conn;
- }
-
- public function beginTransaction()
- {
- $this->conn->beginTransaction();
- }
-
- public function commit()
- {
- $this->conn->commit();
- }
-
- public function rollBack()
- {
- $this->conn->rollBack();
- }
-}
\ No newline at end of file
diff --git a/Transactions/Doctrine/EntityManagerTransactionManager.php b/Transactions/Doctrine/EntityManagerTransactionManager.php
deleted file mode 100644
index 29b200f..0000000
--- a/Transactions/Doctrine/EntityManagerTransactionManager.php
+++ /dev/null
@@ -1,48 +0,0 @@
-doctrineRegistry = $doctrineRegistry;
- $this->name = $name;
- }
-
- public function beginTransaction()
- {
- $this->doctrineRegistry->getManager($this->name)->beginTransaction();
- }
-
- public function commit()
- {
- $em = $this->doctrineRegistry->getManager($this->name);
- $em->flush();
- $em->commit();
- }
-
- public function rollBack()
- {
- $this->doctrineRegistry->getManager($this->name)->rollback();
- $this->doctrineRegistry->resetEntityManager($this->name);
- }
-}
\ No newline at end of file
diff --git a/Transactions/Doctrine/MongoDBTransactionManager.php b/Transactions/Doctrine/MongoDBTransactionManager.php
deleted file mode 100644
index 8b31330..0000000
--- a/Transactions/Doctrine/MongoDBTransactionManager.php
+++ /dev/null
@@ -1,44 +0,0 @@
-dm = $dm;
- }
-
- public function beginTransaction()
- {
-
- }
-
- public function commit()
- {
- $this->dm->flush();
- }
-
- public function rollBack()
- {
-
- }
-
-}
\ No newline at end of file
diff --git a/Transactions/Form/RollbackInvalidFormExtension.php b/Transactions/Form/RollbackInvalidFormExtension.php
new file mode 100644
index 0000000..05a5317
--- /dev/null
+++ b/Transactions/Form/RollbackInvalidFormExtension.php
@@ -0,0 +1,38 @@
+validator = $rollbackValidator;
+ }
+
+ public function buildForm(FormBuilder $builder, array $options)
+ {
+ $builder->addValidator($this->validator);
+ }
+
+ public function getExtendedType()
+ {
+ return 'form';
+ }
+}
+
diff --git a/Transactions/Form/RollbackInvalidFormValidator.php b/Transactions/Form/RollbackInvalidFormValidator.php
new file mode 100644
index 0000000..1cfda71
--- /dev/null
+++ b/Transactions/Form/RollbackInvalidFormValidator.php
@@ -0,0 +1,47 @@
+
+ */
+class RollbackInvalidFormValidator implements FormValidatorInterface
+{
+ private $container;
+
+ public function __construct($container)
+ {
+ $this->container = $container;
+ }
+
+ public function validate(FormInterface $form)
+ {
+ if (!$this->container->has('request')) {
+ return;
+ }
+
+ $request = $this->container->get('request');
+ if ( ! $form->isValid() && $request->attributes->has('_transaction') ) {
+ $request->attributes->get('_transaction')->setRollBackOnly(true);
+ }
+ }
+}
+
diff --git a/Transactions/Http/HttpTransactionsListener.php b/Transactions/Http/HttpTransactionsListener.php
new file mode 100644
index 0000000..00b2ce2
--- /dev/null
+++ b/Transactions/Http/HttpTransactionsListener.php
@@ -0,0 +1,118 @@
+registry = $registry;
+ $this->matcher = $matcher;
+ $this->logger = $logger;
+ }
+
+ public function onCoreController(FilterControllerEvent $event)
+ {
+ $request = $event->getRequest();
+ $definition = $this->matcher->match($request->getMethod(), $event->getController());
+ if (!$definition) {
+ return;
+ }
+
+ $status = $this->registry->getTransaction($definition);
+ $status->beginTransaction();
+
+ $request->attributes->set('_transaction', $status);
+
+ if ($status && $this->logger) {
+ $this->logger->info("[TransactionBundle] Started transaction for " . $definition->getConnectionName());
+ }
+ }
+
+ public function onKernelResponse(FilterResponseEvent $event)
+ {
+ $request = $event->getRequest();
+ $response = $event->getResponse();
+
+ $txStatus = $request->attributes->get('_transaction');
+ if ($txStatus === null) {
+ return;
+ }
+
+ if ($response->getStatusCode() >= 400 && $response->getStatusCode() != 404) {
+ $this->rollBack($txStatus);
+ } else {
+ $this->commit($txStatus);
+ }
+ }
+
+ public function onKernelException(GetResponseForExceptionEvent $event)
+ {
+ $request = $event->getRequest();
+ $ex = $event->getException();
+
+ $txStatus = $request->attributes->get('_transaction');
+ if ($txStatus === null) {
+ return;
+ }
+
+ if ($ex instanceof NotFoundHttpException) {
+ $this->commit($txStatus);
+ } else {
+ $this->rollBack($txStatus);
+ }
+ }
+
+ private function commit($txStatus)
+ {
+ $this->registry->commit($txStatus);
+
+ if ($this->logger) {
+ $this->logger->info("[TransactionBundle] Committed transaction.");
+ }
+ }
+
+ private function rollBack($txStatus)
+ {
+ $this->registry->rollBack($txStatus);
+
+ if ($this->logger) {
+ $this->logger->info("[TransactionBundle] Aborted transaction.");
+ }
+ }
+}
+
diff --git a/Transactions/TransactionalMatcher.php b/Transactions/Http/TransactionalMatcher.php
similarity index 56%
rename from Transactions/TransactionalMatcher.php
rename to Transactions/Http/TransactionalMatcher.php
index e480764..d2e840c 100644
--- a/Transactions/TransactionalMatcher.php
+++ b/Transactions/Http/TransactionalMatcher.php
@@ -12,11 +12,19 @@
* to kontakt@beberlei.de so I can send you a copy immediately.
*/
-namespace SimpleThings\TransactionalBundle\Transactions;
+namespace SimpleThings\TransactionalBundle\Transactions\Http;
use Symfony\Component\HttpFoundation\Request;
use Doctrine\Common\Annotations\Reader;
+use SimpleThings\TransactionalBundle\TransactionException;
+use SimpleThings\TransactionalBundle\Transactions\TransactionDefinition;
+/**
+ * TransactionalMatcher finds the transaction definitions for matched
+ * controllers.
+ *
+ * @todo Extract configuration into its own loader classes
+ */
class TransactionalMatcher
{
/**
@@ -49,46 +57,56 @@ public function __construct(array $patterns, array $defaults = array(), Reader $
/**
* Match if he current controller/action should be transactional or not.
*
- * @param Request $request
+ * Important: Only Controller as services or Class#Action method
+ * controllers can be transactional. Closures or function calls can't.
+ *
+ * @param string $method HTTP Method
* @param mixed $controllerCallback
* @return TransactionDefinition|false
*/
- public function match(Request $request, $controllerCallback)
+ public function match($method, $controllerCallback)
{
if (!is_array($controllerCallback)) {
return false;
}
- $method = $request->getMethod();
list($controller, $action) = $controllerCallback;
$class = get_class($controller);
$subject = $class . "::" . $action;
- if (!isset($this->cache[$subject][$method])) {
- $this->matchPatterns($subject, $method);
- $this->matchAnnotations($subject, $method, $controller, $action);
+ if (!isset($this->cache[$subject])) {
+ $this->cache[$subject] = false;
+ $this->matchPatterns($subject);
+ $this->matchAnnotations($subject, $controller, $action);
- if (!isset($this->cache[$subject][$method])) {
- $this->cache[$subject][$method] = false;
- } else {
- $this->cache[$subject][$method] = new TransactionDefinition($this->cache[$subject][$method]);
+ if (!$this->cache[$subject] && $this->defaults) {
+ $this->cache[$subject] = $this->defaults;
}
}
- return $this->cache[$subject][$method];
+
+ if ($this->cache[$subject]) {
+ $definition = $this->cache[$subject];
+
+ return new TransactionDefinition(
+ $definition['conn'],
+ ! in_array($method, (array)$definition['methods'])
+ );
+ }
+
+ return false;
}
/**
* Match transactional patterns.
*
* @param string $subject
- * @param string $method
*/
- private function matchPatterns($subject, $method)
+ private function matchPatterns($subject)
{
foreach ($this->patterns AS $pattern) {
- if (in_array($method, $pattern['methods']) && preg_match('(' . $pattern['pattern'] . ')', $subject)) {
- $this->storeMatch($subject, $method, $pattern);
+ if (preg_match('(' . $pattern['pattern'] . ')', $subject)) {
+ $this->storeMatch($subject, $pattern);
}
}
}
@@ -97,12 +115,11 @@ private function matchPatterns($subject, $method)
* Match annotations on controllers for transactional behavior.
*
* @param string $subject
- * @param string $method
* @param object $controller
* @param string $action
* @return void
*/
- private function matchAnnotations($subject, $method, $controller, $action)
+ private function matchAnnotations($subject, $controller, $action)
{
if ($this->reader === null) {
return;
@@ -111,34 +128,21 @@ private function matchAnnotations($subject, $method, $controller, $action)
$reflClass = new \ReflectionObject($controller);
if ($txAnnot = $this->reader->getClassAnnotation($reflClass, 'SimpleThings\TransactionalBundle\Annotations\Transactional')) {
$annotData = array_merge($this->defaults, array_filter((array)$txAnnot, function($v) { return $v !== null; }));
-
- if (in_array($method, $annotData['methods'])) {
- $this->storeMatch($connections, $annotData);
- }
+ $this->storeMatch($subject, $annotData);
}
if ($txAnnot = $this->reader->getMethodAnnotation($reflClass->getMethod($action), 'SimpleThings\TransactionalBundle\Annotations\Transactional')) {
$annotData = array_merge($this->defaults, array_filter((array)$txAnnot, function($v) { return $v !== null; }));
-
- if (in_array($method, $annotData['methods'])) {
- $this->storeMatch($subject, $method, $annotData);
- }
+ $this->storeMatch($subject, $annotData);
}
}
- private function storeMatch($subject, $method, $pattern)
+ private function storeMatch($subject, $pattern)
{
- foreach ($pattern['conn'] AS $connectionName) {
- if (isset($this->cache[$subject][$method][$connectionName])) {
- throw TransactionException::duplicateConnectionMatch($connectionName, $pattern);
- }
-
- $this->cache[$subject][$method][$connectionName] = array(
- 'isolation' => $pattern['isolation'],
- 'propagation' => $pattern['propagation'],
- 'noRollbackFor' => $pattern['noRollbackFor'],
- 'subrequest' => $pattern['subrequest'],
- );
- }
+ $this->cache[$subject] = array(
+ 'conn' => $pattern['conn'],
+ 'noRollbackFor' => $pattern['noRollbackFor'],
+ 'methods' => $pattern['methods'],
+ );
}
}
diff --git a/Transactions/TransactionDefinition.php b/Transactions/TransactionDefinition.php
index e150f4e..82e3875 100644
--- a/Transactions/TransactionDefinition.php
+++ b/Transactions/TransactionDefinition.php
@@ -14,48 +14,62 @@
namespace SimpleThings\TransactionalBundle\Transactions;
+/**
+ * Describes the properties of a transaction
+ *
+ * @author Benjamin Eberlei
+ */
class TransactionDefinition
{
- const PROPAGATION_SUPPORTS = 1;
- const PROPAGATION_REQUIRED = 2;
- const PROPAGATION_REQUIRES_NEW = 3;
- const PROPAGATION_NEVER = 4;
+ /**
+ * @var string
+ */
+ private $connectionName;
- const ISOLATION_DEFAULT = 0;
- const ISOLATION_READ_UNCOMMITTED = 1;
- const ISOLATION_READ_COMMITTED = 2;
- const ISOLATION_REPEATABLE_READ = 3;
- const ISOLATION_SERIALIZABLE = 4;
+ /**
+ * @var bool
+ */
+ private $readOnly;
- private $connections = array();
-
- public function __construct(array $connections)
- {
- return $this->connections = $connections;
- }
-
- public function getConnections()
- {
- return array_keys($this->connections);
- }
+ /**
+ * @var array
+ */
+ private $noRollbackFor = array();
- public function getIsolationLevel($connection)
+ public function __construct($connectionName, $readOnly = false, $noRollbackFor = array())
{
- return $this->connections[$connection]['isolation'];
+ $this->connectionName = $connectionName;
+ $this->readOnly = (bool)$readOnly;
+ $this->noRollbackFor = $noRollbackFor;
}
- public function getPropagation($connection)
+ /**
+ * Get readOnly.
+ *
+ * @return readOnly.
+ */
+ public function isReadOnly()
{
- return $this->connections[$connection]['propagation'];
+ return $this->readOnly;
}
- public function getNoRollbackFor($connection)
+ /**
+ * Get connectionName.
+ *
+ * @return string
+ */
+ public function getConnectionName()
{
- return $this->connections[$connection]['noRollbackFor'];
+ return $this->connectionName;
}
- public function isInvokedOnSubrequest($connection)
+ /**
+ * Get noRollbackFor.
+ *
+ * @return noRollbackFor.
+ */
+ public function getNoRollbackFor()
{
- return $this->connections[$connection]['subrequest'];
+ return $this->noRollbackFor;
}
-}
\ No newline at end of file
+}
diff --git a/Transactions/TransactionManagerInterface.php b/Transactions/TransactionProviderInterface.php
similarity index 54%
rename from Transactions/TransactionManagerInterface.php
rename to Transactions/TransactionProviderInterface.php
index 0d4b032..10a3d5d 100644
--- a/Transactions/TransactionManagerInterface.php
+++ b/Transactions/TransactionProviderInterface.php
@@ -15,15 +15,20 @@
/**
* Wraps a transactional service into a common interface
- *
+ *
* @author Benjamin Eberlei
*/
-interface TransactionManagerInterface
+interface TransactionProviderInterface
{
-
- function beginTransaction();
-
- function commit();
-
- function rollBack();
-}
\ No newline at end of file
+ /**
+ * Get a transaction status object.
+ *
+ * Re-use an existing transaction status if there is already one for the
+ * currently active transaction. Throw an exception if the read-only status
+ * is not equal for the previously defined transaction.
+ *
+ * @param TransactionDefinition $def
+ * @return TransactionStatus
+ */
+ function createTransaction(TransactionDefinition $def);
+}
diff --git a/Transactions/TransactionStatus.php b/Transactions/TransactionStatus.php
new file mode 100644
index 0000000..95f4259
--- /dev/null
+++ b/Transactions/TransactionStatus.php
@@ -0,0 +1,85 @@
+container = $container;
+ }
+
+ public function getTransaction(TransactionDefinition $definition)
+ {
+ $connectionName = $definition->getConnectionName();
+ if ( ! isset($this->transactions[$connectionName] )) {
+ $status = $this->getTransactionProvider($connectionName)->createTransaction($definition);
+ $this->transactions[$connectionName] = $status;
+ }
+
+ if ($definition->isReadOnly() !== $this->transactions[$connectionName]->isReadOnly()) {
+ throw new \RuntimeException("Cannot switch from read-only to write/read-transaction or vice-versa.");
+ }
+
+ return $this->transactions[$connectionName];
+ }
+
+ public function commit(TransactionStatus $status)
+ {
+ $status->commit();
+ }
+
+ public function rollBack(TransactionStatus $status)
+ {
+ $status->rollBack();
+ }
+
+ private function getTransactionProvider($name)
+ {
+ $id = "simple_things_transactional.tx.".$name;
+ if (!$this->container->has($id)) {
+ throw new \InvalidArgumentException(
+ "A transactional connection by name of '".$name."' was requested, but does not exist."
+ );
+ }
+ return $this->container->get($id);
+ }
+}
+
diff --git a/composer.json b/composer.json
index ce6c161..d03fe61 100644
--- a/composer.json
+++ b/composer.json
@@ -3,14 +3,15 @@
"description": "This bundles provides missing transactional support for Symfony2",
"keywords": ["symfony2", "transactions", "persistence"],
"type": "symfony-bundle",
- "license": "MIT",
+ "license": "New BSD",
"authors": [{
"name" : "Benjamin Eberlei",
"email" : "kontakt@beberlei.de"
}],
"require": {
"php": ">=5.3.0",
- "symfony/symfony" : ">=2.0"
+ "symfony/symfony" : ">=2.0",
+ "doctrine/dbal": ">=2.0"
},
"autoload": {
"psr-0": {
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index a2a0bc8..5ba6b7d 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -13,6 +13,7 @@
./Resources
./Tests
+ ./vendor