Commit a54c1cd6 by resurtm

Fixes #226: atomic operations and transaction support in AR.

parent 0f7f1936
...@@ -446,3 +446,7 @@ $customers = Customer::find()->olderThan(50)->all(); ...@@ -446,3 +446,7 @@ $customers = Customer::find()->olderThan(50)->all();
The parameters should follow after the `$query` parameter when defining the scope method, and they The parameters should follow after the `$query` parameter when defining the scope method, and they
can take default values like shown above. can take default values like shown above.
### Atomic operations and scenarios
TBD
ActiveRecord ActiveRecord
============ ============
Scenarios
---------
All possible scenario formats supported by ActiveRecord:
```php
public function scenarios()
{
return array(
// 1. attributes array
'scenario1' => array('attribute1', 'attribute2'),
// 2. insert, update and delete operations won't be wrapped with transaction (default mode)
'scenario2' => array(
'attributes' => array('attribute1', 'attribute2'),
'atomic' => array(), // default value
),
// 3. all three operations (insert, update and delete) will be wrapped with transaction
'scenario3' => array(
'attributes' => array('attribute1', 'attribute2'),
'atomic',
),
// 4. insert and update operations will be wrapped with transaction, delete won't
'scenario4' => array(
'attributes' => array('attribute1', 'attribute2'),
'atomic' => array(self::INSERT, self::UPDATE),
),
// 5. insert and update operations won't be wrapped with transaction, delete will
'scenario5' => array(
'attributes' => array('attribute1', 'attribute2'),
'atomic' => array(self::DELETE),
),
);
}
```
Query Query
----- -----
### Basic Queries ### Basic Queries
### Relational Queries ### Relational Queries
### Scopes ### Scopes
...@@ -590,18 +590,22 @@ class Model extends Component implements \IteratorAggregate, \ArrayAccess ...@@ -590,18 +590,22 @@ class Model extends Component implements \IteratorAggregate, \ArrayAccess
/** /**
* Returns the attribute names that are safe to be massively assigned in the current scenario. * Returns the attribute names that are safe to be massively assigned in the current scenario.
* @return array safe attribute names * @return string[] safe attribute names
*/ */
public function safeAttributes() public function safeAttributes()
{ {
$scenario = $this->getScenario(); $scenario = $this->getScenario();
$scenarios = $this->scenarios(); $scenarios = $this->scenarios();
if (!isset($scenarios[$scenario])) {
return array();
}
$attributes = array(); $attributes = array();
if (isset($scenarios[$scenario])) { if (isset($scenarios[$scenario]['attributes']) && is_array($scenarios[$scenario]['attributes'])) {
foreach ($scenarios[$scenario] as $attribute) { $scenarios[$scenario] = $scenarios[$scenario]['attributes'];
if ($attribute[0] !== '!') { }
$attributes[] = $attribute; foreach ($scenarios[$scenario] as $attribute) {
} if ($attribute[0] !== '!') {
$attributes[] = $attribute;
} }
} }
return $attributes; return $attributes;
...@@ -609,23 +613,26 @@ class Model extends Component implements \IteratorAggregate, \ArrayAccess ...@@ -609,23 +613,26 @@ class Model extends Component implements \IteratorAggregate, \ArrayAccess
/** /**
* Returns the attribute names that are subject to validation in the current scenario. * Returns the attribute names that are subject to validation in the current scenario.
* @return array safe attribute names * @return string[] safe attribute names
*/ */
public function activeAttributes() public function activeAttributes()
{ {
$scenario = $this->getScenario(); $scenario = $this->getScenario();
$scenarios = $this->scenarios(); $scenarios = $this->scenarios();
if (isset($scenarios[$scenario])) { if (!isset($scenarios[$scenario])) {
$attributes = $scenarios[$this->getScenario()];
foreach ($attributes as $i => $attribute) {
if ($attribute[0] === '!') {
$attributes[$i] = substr($attribute, 1);
}
}
return $attributes;
} else {
return array(); return array();
} }
if (isset($scenarios[$scenario]['attributes']) && is_array($scenarios[$scenario]['attributes'])) {
$attributes = $scenarios[$scenario]['attributes'];
} else {
$attributes = $scenarios[$scenario];
}
foreach ($attributes as $i => $attribute) {
if ($attribute[0] === '!') {
$attributes[$i] = substr($attribute, 1);
}
}
return $attributes;
} }
/** /**
......
...@@ -74,6 +74,22 @@ class ActiveRecord extends Model ...@@ -74,6 +74,22 @@ class ActiveRecord extends Model
const EVENT_AFTER_DELETE = 'afterDelete'; const EVENT_AFTER_DELETE = 'afterDelete';
/** /**
* Represents insert ActiveRecord operation. This constant is used for specifying set of atomic operations
* for particular scenario in the [[scenarios()]] method.
*/
const OPERATION_INSERT = 'insert';
/**
* Represents update ActiveRecord operation. This constant is used for specifying set of atomic operations
* for particular scenario in the [[scenarios()]] method.
*/
const OPERATION_UPDATE = 'update';
/**
* Represents delete ActiveRecord operation. This constant is used for specifying set of atomic operations
* for particular scenario in the [[scenarios()]] method.
*/
const OPERATION_DELETE = 'delete';
/**
* @var array attribute values indexed by attribute names * @var array attribute values indexed by attribute names
*/ */
private $_attributes = array(); private $_attributes = array();
...@@ -664,10 +680,39 @@ class ActiveRecord extends Model ...@@ -664,10 +680,39 @@ class ActiveRecord extends Model
* @param array $attributes list of attributes that need to be saved. Defaults to null, * @param array $attributes list of attributes that need to be saved. Defaults to null,
* meaning all attributes that are loaded from DB will be saved. * meaning all attributes that are loaded from DB will be saved.
* @return boolean whether the attributes are valid and the record is inserted successfully. * @return boolean whether the attributes are valid and the record is inserted successfully.
* @throws \Exception in case insert failed.
*/ */
public function insert($runValidation = true, $attributes = null) public function insert($runValidation = true, $attributes = null)
{ {
if ($runValidation && !$this->validate($attributes) || !$this->beforeSave(true)) { if ($runValidation && !$this->validate($attributes)) {
return false;
}
$db = static::getDb();
$transaction = $this->isOperationAtomic(self::OPERATION_INSERT) && null === $db->getTransaction() ? $db->beginTransaction() : null;
try {
$result = $this->internalInsert($attributes);
if (null !== $transaction) {
if (false === $result) {
$transaction->rollback();
} else {
$transaction->commit();
}
}
} catch (\Exception $e) {
if (null !== $transaction) {
$transaction->rollback();
}
throw $e;
}
return $result;
}
/**
* @see ActiveRecord::insert()
*/
private function internalInsert($attributes = null)
{
if (!$this->beforeSave(true)) {
return false; return false;
} }
$values = $this->getDirtyAttributes($attributes); $values = $this->getDirtyAttributes($attributes);
...@@ -678,22 +723,23 @@ class ActiveRecord extends Model ...@@ -678,22 +723,23 @@ class ActiveRecord extends Model
} }
$db = static::getDb(); $db = static::getDb();
$command = $db->createCommand()->insert($this->tableName(), $values); $command = $db->createCommand()->insert($this->tableName(), $values);
if ($command->execute()) { if (!$command->execute()) {
$table = $this->getTableSchema(); return false;
if ($table->sequenceName !== null) { }
foreach ($table->primaryKey as $name) { $table = $this->getTableSchema();
if (!isset($this->_attributes[$name])) { if ($table->sequenceName !== null) {
$this->_oldAttributes[$name] = $this->_attributes[$name] = $db->getLastInsertID($table->sequenceName); foreach ($table->primaryKey as $name) {
break; if (!isset($this->_attributes[$name])) {
} $this->_oldAttributes[$name] = $this->_attributes[$name] = $db->getLastInsertID($table->sequenceName);
break;
} }
} }
foreach ($values as $name => $value) {
$this->_oldAttributes[$name] = $value;
}
$this->afterSave(true);
return true;
} }
foreach ($values as $name => $value) {
$this->_oldAttributes[$name] = $value;
}
$this->afterSave(true);
return true;
} }
/** /**
...@@ -744,39 +790,67 @@ class ActiveRecord extends Model ...@@ -744,39 +790,67 @@ class ActiveRecord extends Model
* or [[beforeSave()]] stops the updating process. * or [[beforeSave()]] stops the updating process.
* @throws StaleObjectException if [[optimisticLock|optimistic locking]] is enabled and the data * @throws StaleObjectException if [[optimisticLock|optimistic locking]] is enabled and the data
* being updated is outdated. * being updated is outdated.
* @throws \Exception in case update failed.
*/ */
public function update($runValidation = true, $attributes = null) public function update($runValidation = true, $attributes = null)
{ {
if ($runValidation && !$this->validate($attributes) || !$this->beforeSave(false)) { if ($runValidation && !$this->validate($attributes)) {
return false; return false;
} }
$values = $this->getDirtyAttributes($attributes); $db = static::getDb();
if (!empty($values)) { $transaction = $this->isOperationAtomic(self::OPERATION_UPDATE) && null === $db->getTransaction() ? $db->beginTransaction() : null;
$condition = $this->getOldPrimaryKey(true); try {
$lock = $this->optimisticLock(); $result = $this->internalUpdate($attributes);
if ($lock !== null) { if (null !== $transaction) {
if (!isset($values[$lock])) { if (false === $result) {
$values[$lock] = $this->$lock + 1; $transaction->rollback();
} else {
$transaction->commit();
} }
$condition[$lock] = $this->$lock;
} }
// We do not check the return value of updateAll() because it's possible } catch (\Exception $e) {
// that the UPDATE statement doesn't change anything and thus returns 0. if (null !== $transaction) {
$rows = $this->updateAll($values, $condition); $transaction->rollback();
if ($lock !== null && !$rows) {
throw new StaleObjectException('The object being updated is outdated.');
} }
throw $e;
}
return $result;
}
foreach ($values as $name => $value) { /**
$this->_oldAttributes[$name] = $this->_attributes[$name]; * @see CActiveRecord::update()
* @throws StaleObjectException
*/
private function internalUpdate($attributes = null)
{
if (!$this->beforeSave(false)) {
return false;
}
$values = $this->getDirtyAttributes($attributes);
if (empty($values)) {
return 0;
}
$condition = $this->getOldPrimaryKey(true);
$lock = $this->optimisticLock();
if ($lock !== null) {
if (!isset($values[$lock])) {
$values[$lock] = $this->$lock + 1;
} }
$condition[$lock] = $this->$lock;
}
// We do not check the return value of updateAll() because it's possible
// that the UPDATE statement doesn't change anything and thus returns 0.
$rows = $this->updateAll($values, $condition);
$this->afterSave(false); if ($lock !== null && !$rows) {
return $rows; throw new StaleObjectException('The object being updated is outdated.');
} else { }
return 0;
foreach ($values as $name => $value) {
$this->_oldAttributes[$name] = $this->_attributes[$name];
} }
$this->afterSave(false);
return $rows;
} }
/** /**
...@@ -826,27 +900,43 @@ class ActiveRecord extends Model ...@@ -826,27 +900,43 @@ class ActiveRecord extends Model
* Note that it is possible the number of rows deleted is 0, even though the deletion execution is successful. * Note that it is possible the number of rows deleted is 0, even though the deletion execution is successful.
* @throws StaleObjectException if [[optimisticLock|optimistic locking]] is enabled and the data * @throws StaleObjectException if [[optimisticLock|optimistic locking]] is enabled and the data
* being deleted is outdated. * being deleted is outdated.
* @throws \Exception in case delete failed.
*/ */
public function delete() public function delete()
{ {
if ($this->beforeDelete()) { $db = static::getDb();
// we do not check the return value of deleteAll() because it's possible $transaction = $this->isOperationAtomic(self::OPERATION_DELETE) && null === $db->getTransaction() ? $db->beginTransaction() : null;
// the record is already deleted in the database and thus the method will return 0 try {
$condition = $this->getOldPrimaryKey(true); $result = false;
$lock = $this->optimisticLock(); if ($this->beforeDelete()) {
if ($lock !== null) { // we do not check the return value of deleteAll() because it's possible
$condition[$lock] = $this->$lock; // the record is already deleted in the database and thus the method will return 0
$condition = $this->getOldPrimaryKey(true);
$lock = $this->optimisticLock();
if (null !== $lock) {
$condition[$lock] = $this->$lock;
}
$result = $this->deleteAll($condition);
if (null !== $lock && !$result) {
throw new StaleObjectException('The object being deleted is outdated.');
}
$this->_oldAttributes = null;
$this->afterDelete();
} }
$rows = $this->deleteAll($condition); if (null !== $transaction) {
if ($lock !== null && !$rows) { if (false === $result) {
throw new StaleObjectException('The object being deleted is outdated.'); $transaction->rollback();
} else {
$transaction->commit();
}
} }
$this->_oldAttributes = null; } catch (\Exception $e) {
$this->afterDelete(); if (null !== $transaction) {
return $rows; $transaction->rollback();
} else { }
return false; throw $e;
} }
return $result;
} }
/** /**
...@@ -1336,4 +1426,19 @@ class ActiveRecord extends Model ...@@ -1336,4 +1426,19 @@ class ActiveRecord extends Model
} }
return true; return true;
} }
/**
* @param string $operation possible values are ActiveRecord::INSERT, ActiveRecord::UPDATE and ActiveRecord::DELETE.
* @return boolean whether given operation is atomic. Currently active scenario is taken into account.
*/
private function isOperationAtomic($operation)
{
$scenario = $this->getScenario();
$scenarios = $this->scenarios();
if (!isset($scenarios[$scenario]) || !isset($scenarios[$scenario]['attributes']) || !is_array($scenarios[$scenario]['attributes'])) {
return false;
}
return in_array('atomic', $scenarios[$scenario]) ||
isset($scenarios[$scenario]['atomic']) && is_array($scenarios[$scenario]['atomic']) && in_array($operation, $scenarios[$scenario]['atomic']);
}
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment