diff --git a/CHANGELOG-3.3.md b/CHANGELOG-3.3.md index 5e3deaf8361..cba3c1d8acd 100644 --- a/CHANGELOG-3.3.md +++ b/CHANGELOG-3.3.md @@ -21,3 +21,5 @@ - Fixed `Phalcon\Mvc\Model::allowEmptyStringValues` to correct works with saving empty string values when DEFAULT not set in SQL - Fixed `Phalcon\Mvc\Model\Behavior\SoftDelete` to correctly update snapshots after deleting item - Fixed `Phalcon\Mvc\Model` to set old snapshot when no fields are changed when dynamic update is enabled +- Changed `Phalcon\Mvc\Model` to allow to pass a transaction within the query context [#13226](https://github.com/phalcon/cphalcon/issues/13226) +- Added `Phalcon\Mvc\Query::setTransaction` to enable an override transaction [#13226](https://github.com/phalcon/cphalcon/issues/13226) \ No newline at end of file diff --git a/phalcon/mvc/model.zep b/phalcon/mvc/model.zep index f243828a1d1..f0dc2828385 100644 --- a/phalcon/mvc/model.zep +++ b/phalcon/mvc/model.zep @@ -14,6 +14,7 @@ +------------------------------------------------------------------------+ | Authors: Andres Gutierrez | | Eduar Carvajal | + | Jakob Oberhummer | +------------------------------------------------------------------------+ */ @@ -84,7 +85,6 @@ use Phalcon\Events\ManagerInterface as EventsManagerInterface; */ abstract class Model implements EntityInterface, ModelInterface, ResultInterface, InjectionAwareInterface, \Serializable, \JsonSerializable { - protected _dependencyInjector; protected _modelsManager; @@ -97,7 +97,7 @@ abstract class Model implements EntityInterface, ModelInterface, ResultInterface protected _dirtyState = 1; - protected _transaction; + protected _transaction { get }; protected _uniqueKey; @@ -113,6 +113,8 @@ abstract class Model implements EntityInterface, ModelInterface, ResultInterface protected _oldSnapshot = []; + const TRANSACTION_INDEX = "transaction"; + const OP_NONE = 0; const OP_CREATE = 1; @@ -467,7 +469,7 @@ abstract class Model implements EntityInterface, ModelInterface, ResultInterface { var key, keyMapped, value, attribute, attributeField, metaData, columnMap, dataMapped, disableAssignSetters; - let disableAssignSetters = globals_get("orm.disable_assign_setters"); + let disableAssignSetters = globals_get("orm.disable_assign_setters"); // apply column map for data, if exist if typeof dataColumnMap == "array" { @@ -809,14 +811,66 @@ abstract class Model implements EntityInterface, ModelInterface, ResultInterface * foreach ($robots as $robot) { * echo $robot->name, "\n"; * } + * + * // encapsulate find it into an running transaction esp. useful for application unit-tests + * // or complex business logic where we wanna control which transactions are used. + * + * $myTransaction = new Transaction(\Phalcon\Di::getDefault()); + * $myTransaction->begin(); + * $newRobot = new Robot(); + * $newRobot->setTransaction($myTransaction); + * $newRobot->save(['name' => 'test', 'type' => 'mechanical', 'year' => 1944]); + * + * $resultInsideTransaction = Robot::find(['name' => 'test', Model::TRANSACTION_INDEX => $myTransaction]); + * $resultOutsideTransaction = Robot::find(['name' => 'test']); + * + * foreach ($setInsideTransaction as $robot) { + * echo $robot->name, "\n"; + * } + * + * foreach ($setOutsideTransaction as $robot) { + * echo $robot->name, "\n"; + * } + * + * // reverts all not commited changes + * $myTransaction->rollback(); + * + * // creating two different transactions + * $myTransaction1 = new Transaction(\Phalcon\Di::getDefault()); + * $myTransaction1->begin(); + * $myTransaction2 = new Transaction(\Phalcon\Di::getDefault()); + * $myTransaction2->begin(); + * + * // add a new robots + * $firstNewRobot = new Robot(); + * $firstNewRobot->setTransaction($myTransaction1); + * $firstNewRobot->save(['name' => 'first-transaction-robot', 'type' => 'mechanical', 'year' => 1944]); + * + * $secondNewRobot = new Robot(); + * $secondNewRobot->setTransaction($myTransaction2); + * $secondNewRobot->save(['name' => 'second-transaction-robot', 'type' => 'fictional', 'year' => 1984]); + * + * // this transaction will find the robot. + * $resultInFirstTransaction = Robot::find(['name' => 'first-transaction-robot', Model::TRANSACTION_INDEX => $myTransaction1]); + * // this transaction won't find the robot. + * $resultInSecondTransaction = Robot::find(['name' => 'first-transaction-robot', Model::TRANSACTION_INDEX => $myTransaction2]); + * // this transaction won't find the robot. + * $resultOutsideAnyExplicitTransaction = Robot::find(['name' => 'first-transaction-robot']); + * + * // this transaction won't find the robot. + * $resultInFirstTransaction = Robot::find(['name' => 'second-transaction-robot', Model::TRANSACTION_INDEX => $myTransaction2]); + * // this transaction will find the robot. + * $resultInSecondTransaction = Robot::find(['name' => 'second-transaction-robot', Model::TRANSACTION_INDEX => $myTransaction1]); + * // this transaction won't find the robot. + * $resultOutsideAnyExplicitTransaction = Robot::find(['name' => 'second-transaction-robot']); + * + * $transaction1->rollback(); + * $transaction2->rollback(); * */ public static function find(var parameters = null) -> { - var params, builder, query, bindParams, bindTypes, cache, resultset, hydration, dependencyInjector, manager; - - let dependencyInjector = Di::getDefault(); - let manager = dependencyInjector->getShared("modelsManager"); + var params, query, resultset, hydration; if typeof parameters != "array" { let params = []; @@ -827,36 +881,7 @@ abstract class Model implements EntityInterface, ModelInterface, ResultInterface let params = parameters; } - /** - * Builds a query with the passed parameters - */ - let builder = manager->createBuilder(params); - builder->from(get_called_class()); - - let query = builder->getQuery(); - - /** - * Check for bind parameters - */ - if fetch bindParams, params["bind"] { - - if typeof bindParams == "array" { - query->setBindParams(bindParams, true); - } - - if fetch bindTypes, params["bindTypes"] { - if typeof bindTypes == "array" { - query->setBindTypes(bindTypes, true); - } - } - } - - /** - * Pass the cache options to the query - */ - if fetch cache, params["cache"] { - query->cache(cache); - } + let query = static::getPreparedQuery(params); /** * Execute the query passing the bind-params and casting-types @@ -886,7 +911,7 @@ abstract class Model implements EntityInterface, ModelInterface, ResultInterface * * // What's the first mechanical robot in robots table? * $robot = Robots::findFirst( - * "type = 'mechanical'" + * "type = 'mechanical'" * ); * * echo "The first mechanical robot name is ", $robot->name; @@ -900,18 +925,27 @@ abstract class Model implements EntityInterface, ModelInterface, ResultInterface * ); * * echo "The first virtual robot name is ", $robot->name; - * * - * @param string|array parameters - * @return static + * // behaviour with transaction + * $myTransaction = new Transaction(\Phalcon\Di::getDefault()); + * $myTransaction->begin(); + * $newRobot = new Robot(); + * $newRobot->setTransaction($myTransaction); + * $newRobot->save(['name' => 'test', 'type' => 'mechanical', 'year' => 1944]); + * + * $findsARobot = Robot::findFirst(['name' => 'test', Model::TRANSACTION_INDEX => $myTransaction]); + * $doesNotFindARobot = Robot::findFirst(['name' => 'test']); + * + * var_dump($findARobot); + * var_dump($doesNotFindARobot); + * + * $transaction->commit(); + * $doesFindTheRobotNow = Robot::findFirst(['name' => 'test']); + * */ public static function findFirst(var parameters = null) -> { - var params, builder, query, bindParams, bindTypes, cache, - dependencyInjector, manager; - - let dependencyInjector = Di::getDefault(); - let manager = dependencyInjector->getShared("modelsManager"); + var params, query; if typeof parameters != "array" { let params = []; @@ -922,16 +956,38 @@ abstract class Model implements EntityInterface, ModelInterface, ResultInterface let params = parameters; } + let query = static::getPreparedQuery(params, 1); + + /** + * Return only the first row + */ + query->setUniqueRow(true); + + /** + * Execute the query passing the bind-params and casting-types + */ + return query->execute(); + } + + + /** + * shared prepare query logic for find and findFirst method + */ + private static function getPreparedQuery(var params, var limit = null) -> { + var builder, bindParams, bindTypes, transaction, cache, manager, query, dependencyInjector; + + let dependencyInjector = Di::getDefault(); + let manager = dependencyInjector->getShared("modelsManager"); + /** * Builds a query with the passed parameters */ let builder = manager->createBuilder(params); builder->from(get_called_class()); - /** - * We only want the first record - */ - builder->limit(1); + if limit != null { + builder->limit(limit); + } let query = builder->getQuery(); @@ -939,7 +995,6 @@ abstract class Model implements EntityInterface, ModelInterface, ResultInterface * Check for bind parameters */ if fetch bindParams, params["bind"] { - if typeof bindParams == "array" { query->setBindParams(bindParams, true); } @@ -951,6 +1006,12 @@ abstract class Model implements EntityInterface, ModelInterface, ResultInterface } } + if fetch transaction, params[self::TRANSACTION_INDEX] { + if transaction instanceof TransactionInterface { + query->setTransaction(transaction); + } + } + /** * Pass the cache options to the query */ @@ -958,17 +1019,8 @@ abstract class Model implements EntityInterface, ModelInterface, ResultInterface query->cache(cache); } - /** - * Return only the first row - */ - query->setUniqueRow(true); - - /** - * Execute the query passing the bind-params and casting-types - */ - return query->execute(); + return query; } - /** * Create a criteria for a specific model */ @@ -2065,7 +2117,8 @@ abstract class Model implements EntityInterface, ModelInterface, ResultInterface } else { let automaticAttributes = metaData->getAutomaticCreateAttributes(this); } - let defaultValues = metaData->getDefaultValues(this); + + let defaultValues = metaData->getDefaultValues(this); /** * Get string attributes that allow empty strings as defaults @@ -2598,7 +2651,7 @@ abstract class Model implements EntityInterface, ModelInterface, ResultInterface let bindTypes[] = bindType; } } - let newSnapshot[attributeField] = value; + let newSnapshot[attributeField] = value; } else { let newSnapshot[attributeField] = null; diff --git a/phalcon/mvc/model/query.zep b/phalcon/mvc/model/query.zep index c63884fc33c..88d53393c0d 100644 --- a/phalcon/mvc/model/query.zep +++ b/phalcon/mvc/model/query.zep @@ -15,6 +15,7 @@ | Authors: Andres Gutierrez | | Eduar Carvajal | | Kenji Minamoto | + | Jakob Oberhummer | +------------------------------------------------------------------------+ */ @@ -23,6 +24,7 @@ namespace Phalcon\Mvc\Model; use Phalcon\Db\Column; use Phalcon\Db\RawValue; use Phalcon\Db\ResultInterface; +use Phalcon\Db\AdapterInterface; use Phalcon\DiInterface; use Phalcon\Mvc\Model\Row; use Phalcon\Mvc\ModelInterface; @@ -37,6 +39,8 @@ use Phalcon\Mvc\Model\ResultsetInterface; use Phalcon\Mvc\Model\Resultset\Simple; use Phalcon\Di\InjectionAwareInterface; use Phalcon\Mvc\Model\RelationInterface; +use Phalcon\Mvc\Model\TransactionInterface; +use Phalcon\Db\DialectInterface; /** * Phalcon\Mvc\Model\Query @@ -59,6 +63,34 @@ use Phalcon\Mvc\Model\RelationInterface; * echo "Price: ", $row->cars->price, "\n"; * echo "Taxes: ", $row->taxes, "\n"; * } + * + * // with transaction + * use Phalcon\Mvc\Model\Query; + * use Phalcon\Mvc\Model\Transaction; + * + * // $di needs to have the service "db" registered for this to work + * $di = Phalcon\Di\FactoryDefault::getDefault(); + * + * $phql = 'SELECT * FROM robot'; + * + * $myTransaction = new Transaction($di); + * $myTransaction->begin(); + * + * $newRobot = new Robot(); + * $newRobot->setTransaction($myTransaction); + * $newRobot->type = "mechanical"; + * $newRobot->name = "Astro Boy"; + * $newRobot->year = 1952; + * $newRobot->save(); + * + * $queryWithTransaction = new Query($phql, $di); + * $queryWithTransaction->setTransaction($myTransaction); + * + * $resultWithEntries = $queryWithTransaction->execute(); + * + * $queryWithOutTransaction = new Query($phql, $di); + * $resultWithOutEntries = $queryWithTransaction->execute() + * * */ class Query implements QueryInterface, InjectionAwareInterface @@ -106,6 +138,13 @@ class Query implements QueryInterface, InjectionAwareInterface protected _sharedLock; + /** + * TransactionInterface so that the query can wrap a transaction + * around batch updates and intermediate selects within the transaction. + * however if a model got a transaction set inside it will use the local transaction instead of this one + */ + protected _transaction { get }; + static protected _irPhqlCache; const TYPE_SELECT = 309; @@ -1910,7 +1949,7 @@ class Query implements QueryInterface, InjectionAwareInterface let selectColumns[] = [ "type": PHQL_T_DOMAINALL, - "column": joinAlias, + "column": joinAlias, "eager": alias, "eagerType": eagerType, "balias": bestAlias @@ -2544,21 +2583,14 @@ class Query implements QueryInterface, InjectionAwareInterface this->_modelsInstances[modelName] = model; } - // Get database connection - if method_exists(model, "selectReadConnection") { - // use selectReadConnection() if implemented in extended Model class - let connection = model->selectReadConnection(intermediate, bindParams, bindTypes); - if typeof connection != "object" { - throw new Exception("'selectReadConnection' didn't return a valid connection"); - } - } else { - let connection = model->getReadConnection(); - } + let connection = this->getReadConnection(model, intermediate, bindParams, bindTypes); - // More than one type of connection is not allowed - let connectionTypes[connection->getType()] = true; - if count(connectionTypes) == 2 { - throw new Exception("Cannot use models of different database systems in the same query"); + if typeof connection == "object" { + // More than one type of connection is not allowed + let connectionTypes[connection->getType()] = true; + if count(connectionTypes) == 2 { + throw new Exception("Cannot use models of different database systems in the same query"); + } } } @@ -2761,7 +2793,7 @@ class Query implements QueryInterface, InjectionAwareInterface /** * Check if the query has data */ - if result instanceof ResultInterface && result->numRows(result) { + if result instanceof ResultInterface && result->numRows() { let resultData = result; } else { let resultData = false; @@ -2876,17 +2908,7 @@ class Query implements QueryInterface, InjectionAwareInterface let model = manager->load(modelName, true); } - /** - * Get the model connection - */ - if method_exists(model, "selectWriteConnection") { - let connection = model->selectWriteConnection(intermediate, bindParams, bindTypes); - if typeof connection != "object" { - throw new Exception("'selectWriteConnection' didn't return a valid connection"); - } - } else { - let connection = model->getWriteConnection(); - } + let connection = this->getWriteConnection(model, intermediate, bindParams, bindTypes); let metaData = this->_metaData, attributes = metaData->getAttributes(model); @@ -3020,14 +3042,7 @@ class Query implements QueryInterface, InjectionAwareInterface let model = this->_manager->load(modelName); } - if method_exists(model, "selectWriteConnection") { - let connection = model->selectWriteConnection(intermediate, bindParams, bindTypes); - if typeof connection != "object" { - throw new Exception("'selectWriteConnection' didn't return a valid connection"); - } - } else { - let connection = model->getWriteConnection(); - } + let connection = this->getWriteConnection(model, intermediate, bindParams, bindTypes); let dialect = connection->getDialect(); @@ -3110,14 +3125,7 @@ class Query implements QueryInterface, InjectionAwareInterface return new Status(true); } - if method_exists(model, "selectWriteConnection") { - let connection = model->selectWriteConnection(intermediate, bindParams, bindTypes); - if typeof connection != "object" { - throw new Exception("'selectWriteConnection' didn't return a valid connection"); - } - } else { - let connection = model->getWriteConnection(); - } + let connection = this->getWriteConnection(model, intermediate, bindParams, bindTypes); /** * Create a transaction in the write connection @@ -3194,23 +3202,12 @@ class Query implements QueryInterface, InjectionAwareInterface return new Status(true); } - if method_exists(model, "selectWriteConnection") { - let connection = model->selectWriteConnection(intermediate, bindParams, bindTypes); - if typeof connection != "object" { - throw new Exception("'selectWriteConnection' didn't return a valid connection"); - } - } else { - let connection = model->getWriteConnection(); - } + let connection = this->getWriteConnection(model, intermediate, bindParams, bindTypes); /** * Create a transaction in the write connection */ connection->begin(); - - //for record in iterator(records) { - - records->rewind(); while records->valid() { @@ -3631,4 +3628,59 @@ class Query implements QueryInterface, InjectionAwareInterface { let self::_irPhqlCache = []; } + + /** + * Gets the read connection from the model if there is no transaction set inside the query object + */ + protected function getReadConnection( model, array intermediate = null, array bindParams = null, array bindTypes = null) -> + { + var connection = null, transaction; + let transaction = this->_transaction; + + if typeof transaction == "object" && transaction instanceof TransactionInterface { + return transaction->getConnection(); + } + + if method_exists(model, "selectReadConnection") { + // use selectReadConnection() if implemented in extended Model class + let connection = model->selectReadConnection(intermediate, bindParams, bindTypes); + if typeof connection != "object" { + throw new Exception("selectReadConnection did not return a connection"); + } + return connection; + } + return model->getReadConnection(); + } + + + /** + * Gets the write connection from the model if there is no transaction inside the query object + */ + protected function getWriteConnection( model, array intermediate = null, array bindParams = null, array bindTypes = null) -> + { + var connection = null, transaction; + let transaction = this->_transaction; + + if typeof transaction == "object" && transaction instanceof TransactionInterface { + return transaction->getConnection(); + } + + if method_exists(model, "selectWriteConnection") { + let connection = model->selectWriteConnection(intermediate, bindParams, bindTypes); + if typeof connection != "object" { + throw new Exception("selectWriteConnection did not return a connection"); + } + return connection; + } + return model->getWriteConnection(); + } + + /** + * allows to wrap a transaction around all queries + */ + public function setTransaction( transaction) -> + { + let this->_transaction = transaction; + return this; + } } diff --git a/tests/README.md b/tests/README.md index f8fd2e9004e..253b2beba2f 100644 --- a/tests/README.md +++ b/tests/README.md @@ -129,6 +129,8 @@ export TEST_DB_MONGO_NAME="phalcon_test" export TEST_RS_HOST="127.0.0.1" export TEST_RS_PORT="6379" export TEST_RS_DB="0" + +export TEST_CACHE_DIR="/tmp" ``` ## Run tests diff --git a/tests/_support/Helper/ModelTrait.php b/tests/_support/Helper/ModelTrait.php index e8ef9a27372..64a9ae2ca51 100644 --- a/tests/_support/Helper/ModelTrait.php +++ b/tests/_support/Helper/ModelTrait.php @@ -6,6 +6,7 @@ use Phalcon\Db\Adapter\Pdo; use Phalcon\Mvc\Model\Manager; use Phalcon\Mvc\Model\MetaData\Memory; +use Phalcon\Mvc\Model\Transaction\Manager as TransactionManager; /** * Helper\ModelTrait @@ -27,6 +28,10 @@ */ trait ModelTrait { + /** + * @param Pdo|null $connection + * @return Manager + */ protected function setUpModelsManager(Pdo $connection = null) { $di = Di::getDefault(); @@ -47,4 +52,29 @@ protected function setUpModelsManager(Pdo $connection = null) return $manager; } + + /** + * @return TransactionManager + */ + protected function setUpTransactionManager() + { + $di = Di::getDefault(); + $db = $di->getShared('db'); + + Di::reset(); + + $di = new Di(); + + $transactionManager = new TransactionManager($di); + $manager = new Manager(); + $manager->setDI($di); + $di->setShared('db', $db); + $di->setShared('transactionManager', $transactionManager); + $di->setShared('modelsManager', $manager); + $di->setShared('modelsMetadata', Memory::class); + + Di::setDefault($di); + + return $transactionManager; + } } diff --git a/tests/unit/Mvc/Model/QueryTest.php b/tests/unit/Mvc/Model/QueryTest.php index f2d468fb9bf..c4d1fd43c2a 100644 --- a/tests/unit/Mvc/Model/QueryTest.php +++ b/tests/unit/Mvc/Model/QueryTest.php @@ -4,6 +4,7 @@ use Phalcon\DiInterface; use Phalcon\Mvc\Model\Query; +use Phalcon\Mvc\Model\Transaction; use Phalcon\Test\Models\Robots; use Phalcon\Test\Module\UnitTest; use Phalcon\Test\Models\Robotters; @@ -42,6 +43,23 @@ protected function _before() $this->di = $app->getDI(); } + /** + * @test + */ + public function checkIfTransactionIsSet() + { + $this->specify( + "Check if transaction has been set", + function () { + $transaction = new Transaction($this->di); + $query = new Query(null, $this->di); + $query->setTransaction($transaction); + + expect($query->getTransaction(), $transaction); + } + ); + } + public function testSelectParsing() { $this->specify( diff --git a/tests/unit/Mvc/ModelTest.php b/tests/unit/Mvc/ModelTest.php index 7d97bcab12a..7c7b9c45714 100644 --- a/tests/unit/Mvc/ModelTest.php +++ b/tests/unit/Mvc/ModelTest.php @@ -749,4 +749,155 @@ function () { } ); } + + /** + * @test + * @author Jakob Oberhummer + * @since 2017-12-18 + */ + public function useTransactionWithinFind() + { + $this->specify( + 'Transaction Not Passed', + function () { + /** + * @var $transactionManager \Phalcon\Mvc\Model\Transaction\Manager + * @var $transaction Model\Transaction + */ + $transactionManager = $this->setUpTransactionManager(); + $transaction = $transactionManager->getOrCreateTransaction(); + + $newSubscriber = new Subscribers(); + $newSubscriber->setTransaction($transaction); + $newSubscriber->email = 'transaction@example.com'; + $newSubscriber->status = 'I'; + $newSubscriber->save(); + + $subscriber = Subscribers::find( + [ + 'email = "transaction@example.com"', + 'transaction' => $transaction + ] + ); + + expect(\count($subscriber), 1); + } + ); + } + + /** + * @test + * @author Jakob Oberhummer + * @since 2017-12-18 + */ + public function useTransactionWithinFindFirst() + { + $this->specify( + 'Transaction Not Passed', + function () { + /** + * @var $transactionManager \Phalcon\Mvc\Model\Transaction\Manager + * @var $transaction Model\Transaction + */ + $transactionManager = $this->setUpTransactionManager(); + $transaction = $transactionManager->getOrCreateTransaction(); + + $newSubscriber = new Subscribers(); + $newSubscriber->setTransaction($transaction); + $newSubscriber->email = 'transaction@example.com'; + $newSubscriber->status = 'I'; + $newSubscriber->save(); + + $subscriber = Subscribers::findFirst( + [ + 'email = "transaction@example.com"', + 'transaction' => $transaction + ] + ); + + expect(\get_class($subscriber), 'Subscriber'); + } + ); + } + + /** + * @test + * @author Jakob Oberhummer + * @since 2017-12-18 + */ + public function useTransactionOutsideFind() + { + $this->specify( + 'Transaction faulty passed', + function () { + /** + * @var $transactionManager \Phalcon\Mvc\Model\Transaction\Manager + */ + $transactionManager = $this->setUpTransactionManager(); + $transaction = $transactionManager->getOrCreateTransaction(); + + $newSubscriber = new Subscribers(); + $newSubscriber->setTransaction($transaction); + $newSubscriber->email = 'transaction@example.com'; + $newSubscriber->status = 'I'; + $newSubscriber->save(); + + /** + * @var $transactionManager \Phalcon\Mvc\Model\Transaction\Manager + */ + $transactionManager = $this->setUpTransactionManager(); + $secondTransaction = $transactionManager->getOrCreateTransaction(); + + $subscriber = Subscribers::find( + [ + 'email = "transaction@example.com"', + 'transaction' => $secondTransaction + ] + ); + + expect(\count($subscriber), 0); + } + ); + } + + /** + * @test + * @author Jakob Oberhummer + * @since 2017-12-18 + */ + public function useTransactionOutsideFindFirst() + { + $this->specify( + 'Transaction faulty passed', + function () { + /** + * @var $transactionManager \Phalcon\Mvc\Model\Transaction\Manager + */ + $transactionManager = $this->setUpTransactionManager(); + $transaction = $transactionManager->getOrCreateTransaction(); + + $newSubscriber = new Subscribers(); + $newSubscriber->setTransaction($transaction); + $newSubscriber->email = 'transaction@example.com'; + $newSubscriber->status = 'I'; + $newSubscriber->save(); + + /** + * @var $transactionManager \Phalcon\Mvc\Model\Transaction\Manager + * @var $transaction Model\Transaction + */ + $transactionManager = $this->setUpTransactionManager(); + $secondTransaction = $transactionManager->getOrCreateTransaction(); + + $subscriber = Subscribers::findFirst( + [ + 'email = "transaction@example.com"', + 'transaction' => $secondTransaction + ] + ); + + expect(false, $subscriber); + } + ); + } }