Skip to content

Commit ace273f

Browse files
authored
Fix #294: Add transactions support
1 parent f42f1ac commit ace273f

File tree

10 files changed

+1060
-201
lines changed

10 files changed

+1060
-201
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@ Yii Framework 2 mongodb extension Change Log
2222
2.1.10 November 10, 2020
2323
------------------------
2424

25+
- Enh #294: Add transactions support (ziaratban)
2526
- Bug #308: Fix `yii\mongodb\file\Upload::addFile()` error when uploading file with readonly permissions (sparchatus)
2627
- Enh #319: Added support for the 'session.use_strict_mode' ini directive in `yii\web\Session` (rhertogh)
2728

2829

30+
2931
2.1.9 November 19, 2019
3032
-----------------------
3133

src/ActiveRecord.php

Lines changed: 253 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
use MongoDB\BSON\Binary;
1111
use MongoDB\BSON\Type;
12+
use MongoDB\BSON\ObjectId;
1213
use Yii;
1314
use yii\base\InvalidConfigException;
1415
use yii\db\BaseActiveRecord;
@@ -25,6 +26,27 @@
2526
*/
2627
abstract class ActiveRecord extends BaseActiveRecord
2728
{
29+
/**
30+
* The insert operation. This is mainly used when overriding [[transactions()]] to specify which operations are transactional.
31+
*/
32+
const OP_INSERT = 0x01;
33+
34+
/**
35+
* The update operation. This is mainly used when overriding [[transactions()]] to specify which operations are transactional.
36+
*/
37+
const OP_UPDATE = 0x02;
38+
39+
/**
40+
* The delete operation. This is mainly used when overriding [[transactions()]] to specify which operations are transactional.
41+
*/
42+
const OP_DELETE = 0x04;
43+
44+
/**
45+
* All three operations: insert, update, delete.
46+
* This is a shortcut of the expression: OP_INSERT | OP_UPDATE | OP_DELETE.
47+
*/
48+
const OP_ALL = 0x07;
49+
2850
/**
2951
* Returns the Mongo connection used by this AR class.
3052
* By default, the "mongodb" application component is used as the Mongo connection.
@@ -208,8 +230,15 @@ public function insert($runValidation = true, $attributes = null)
208230
if ($runValidation && !$this->validate($attributes)) {
209231
return false;
210232
}
211-
$result = $this->insertInternal($attributes);
212233

234+
if (!$this->isTransactional(self::OP_INSERT)) {
235+
return $this->insertInternal($attributes);
236+
}
237+
238+
$result = null;
239+
static::getDb()->transaction(function() use ($attribute, &$result) {
240+
$result = $this->insertInternal($attributes);
241+
});
213242
return $result;
214243
}
215244

@@ -243,6 +272,76 @@ protected function insertInternal($attributes = null)
243272
return true;
244273
}
245274

275+
/**
276+
* Saves the changes to this active record into the associated database table.
277+
*
278+
* This method performs the following steps in order:
279+
*
280+
* 1. call [[beforeValidate()]] when `$runValidation` is `true`. If [[beforeValidate()]]
281+
* returns `false`, the rest of the steps will be skipped;
282+
* 2. call [[afterValidate()]] when `$runValidation` is `true`. If validation
283+
* failed, the rest of the steps will be skipped;
284+
* 3. call [[beforeSave()]]. If [[beforeSave()]] returns `false`,
285+
* the rest of the steps will be skipped;
286+
* 4. save the record into database. If this fails, it will skip the rest of the steps;
287+
* 5. call [[afterSave()]];
288+
*
289+
* In the above step 1, 2, 3 and 5, events [[EVENT_BEFORE_VALIDATE]],
290+
* [[EVENT_AFTER_VALIDATE]], [[EVENT_BEFORE_UPDATE]], and [[EVENT_AFTER_UPDATE]]
291+
* will be raised by the corresponding methods.
292+
*
293+
* Only the [[dirtyAttributes|changed attribute values]] will be saved into database.
294+
*
295+
* For example, to update a customer record:
296+
*
297+
* ```php
298+
* $customer = Customer::findOne($id);
299+
* $customer->name = $name;
300+
* $customer->email = $email;
301+
* $customer->update();
302+
* ```
303+
*
304+
* Note that it is possible the update does not affect any row in the table.
305+
* In this case, this method will return 0. For this reason, you should use the following
306+
* code to check if update() is successful or not:
307+
*
308+
* ```php
309+
* if ($customer->update() !== false) {
310+
* // update successful
311+
* } else {
312+
* // update failed
313+
* }
314+
* ```
315+
*
316+
* @param bool $runValidation whether to perform validation (calling [[validate()]])
317+
* before saving the record. Defaults to `true`. If the validation fails, the record
318+
* will not be saved to the database and this method will return `false`.
319+
* @param array $attributeNames list of attributes that need to be saved. Defaults to `null`,
320+
* meaning all attributes that are loaded from DB will be saved.
321+
* @return int|false the number of rows affected, or false if validation fails
322+
* or [[beforeSave()]] stops the updating process.
323+
* @throws StaleObjectException if [[optimisticLock|optimistic locking]] is enabled and the data
324+
* being updated is outdated.
325+
* @throws \Exception|\Throwable in case update failed.
326+
*/
327+
public function update($runValidation = true, $attributeNames = null)
328+
{
329+
if ($runValidation && !$this->validate($attributeNames)) {
330+
Yii::info('Model not updated due to validation error.', __METHOD__);
331+
return false;
332+
}
333+
334+
if (!$this->isTransactional(self::OP_UPDATE)) {
335+
return $this->updateInternal($attributeNames);
336+
}
337+
338+
$result = null;
339+
static::getDb()->transaction(function() use ($attributeNames, &$result) {
340+
$result = $this->updateInternal($attributeNames);
341+
});
342+
return $result;
343+
}
344+
246345
/**
247346
* @see ActiveRecord::update()
248347
* @throws StaleObjectException
@@ -308,12 +407,14 @@ protected function updateInternal($attributes = null)
308407
*/
309408
public function delete()
310409
{
311-
$result = false;
312-
if ($this->beforeDelete()) {
313-
$result = $this->deleteInternal();
314-
$this->afterDelete();
410+
if (!$this->isTransactional(self::OP_DELETE)) {
411+
return $this->deleteInternal();
315412
}
316413

414+
$result = null;
415+
static::getDb()->transaction(function() use (&$result) {
416+
$result = $this->deleteInternal();
417+
});
317418
return $result;
318419
}
319420

@@ -323,6 +424,9 @@ public function delete()
323424
*/
324425
protected function deleteInternal()
325426
{
427+
if (!$this->beforeDelete()) {
428+
return false;
429+
}
326430
// we do not check the return value of deleteAll() because it's possible
327431
// the record is already deleted in the database and thus the method will return 0
328432
$condition = $this->getOldPrimaryKey(true);
@@ -335,6 +439,7 @@ protected function deleteInternal()
335439
throw new StaleObjectException('The object being deleted is outdated.');
336440
}
337441
$this->setOldAttributes(null);
442+
$this->afterDelete();
338443

339444
return $result;
340445
}
@@ -411,4 +516,146 @@ private function dumpBsonObject(Type $object)
411516
}
412517
return ArrayHelper::toArray($object);
413518
}
414-
}
519+
520+
/**
521+
* Locks a document of the collection in a transaction (like `select for update` feature in MySQL)
522+
* @see https://www.mongodb.com/blog/post/how-to-select--for-update-inside-mongodb-transactions
523+
* @param mixed $id a document id (primary key > _id)
524+
* @param string $lockFieldName The name of the field you want to lock.
525+
* @param array $modifyOptions list of the options in format: optionName => optionValue.
526+
* @param Connection $db the Mongo connection uses it to execute the query.
527+
* @return ActiveRecord|null the locked document.
528+
* Returns instance of ActiveRecord. Null will be returned if the query does not have a result.
529+
*/
530+
public static function LockDocument($id, $lockFieldName, $modifyOptions = [], $db = null)
531+
{
532+
$db = $db ? $db : static::getDb();
533+
$db->transactionReady('lock document');
534+
$options['new'] = true;
535+
return static::find()
536+
->where(['_id' => $id])
537+
->modify(
538+
[
539+
'$set' =>[$lockFieldName => new ObjectId]
540+
],
541+
$modifyOptions,
542+
$db
543+
)
544+
;
545+
}
546+
547+
/**
548+
* Locking a document in stubborn mode on a transaction (like `select for update` feature in MySQL)
549+
* @see https://www.mongodb.com/blog/post/how-to-select--for-update-inside-mongodb-transactions
550+
* notice : you can not use stubborn mode if transaction is started in current session (or use your session with `mySession` parameter).
551+
* @param mixed $id a document id (primary key > _id)
552+
* @param array $options list of options in format:
553+
* [
554+
* 'mySession' => false, # A custom session instance of ClientSession for start a transaction.
555+
* 'transactionOptions' => [], # New transaction options. see $transactionOptions in Transaction::start()
556+
* 'modifyOptions' => [], # See $options in ActiveQuery::modify()
557+
* 'sleep' => 1000000, # A time parameter in microseconds to wait. the default is one second.
558+
* 'try' => 0, # Maximum count of retry. throw write conflict error after reached this value. the zero default is unlimited.
559+
* 'lockFieldName' => '_lock' # The name of the field you want to lock. default is '_lock'
560+
* ]
561+
* @param Connection $db the Mongo connection uses it to execute the query.
562+
* @return ActiveRecord|null returns the locked document.
563+
* Returns instance of ActiveRecord. Null will be returned if the query does not have a result.
564+
* When the total number of attempts to lock the document passes `try`, conflict error will be thrown
565+
*/
566+
public static function LockDocumentStubbornly($id, $lockFieldName, $options = [], $db = null)
567+
{
568+
$db = $db ? $db : static::getDb();
569+
570+
$options = array_replace_recursive(
571+
[
572+
'mySession' => false,
573+
'transactionOptions' => [],
574+
'modifyOptions' => [],
575+
'sleep' => 1000000,
576+
'try' => 0,
577+
],
578+
$options
579+
);
580+
581+
$options['modifyOptions']['new'] = true;
582+
583+
$session = $options['mySession'] ? $options['mySession'] : $db->startSessionOnce();
584+
585+
if ($session->getInTransaction()) {
586+
throw new Exception('You can\'t use stubborn lock feature because current connection is in a transaction.');
587+
}
588+
589+
// start stubborn
590+
$tiredCounter = 0;
591+
StartStubborn:
592+
$session->transaction->start($options['transactionOptions']);
593+
try {
594+
$doc = static::find()
595+
->where(['_id' => $id])
596+
->modify(
597+
[
598+
'$set' => [
599+
$lockFieldName => new ObjectId
600+
]
601+
],
602+
$options['modifyOptions'],
603+
$db
604+
);
605+
return $doc;
606+
} catch(\Exception $e) {
607+
$session->transaction->rollBack();
608+
$tiredCounter++;
609+
if ($options['try'] !== 0 && $tiredCounter === $options['try']) {
610+
throw $e;
611+
}
612+
usleep($options['sleep']);
613+
goto StartStubborn;
614+
}
615+
}
616+
617+
/**
618+
* Declares which DB operations should be performed within a transaction in different scenarios.
619+
* The supported DB operations are: [[OP_INSERT]], [[OP_UPDATE]] and [[OP_DELETE]],
620+
* which correspond to the [[insert()]], [[update()]] and [[delete()]] methods, respectively.
621+
* By default, these methods are NOT enclosed in a DB transaction.
622+
*
623+
* In some scenarios, to ensure data consistency, you may want to enclose some or all of them
624+
* in transactions. You can do so by overriding this method and returning the operations
625+
* that need to be transactional. For example,
626+
*
627+
* ```php
628+
* return [
629+
* 'admin' => self::OP_INSERT,
630+
* 'api' => self::OP_INSERT | self::OP_UPDATE | self::OP_DELETE,
631+
* // the above is equivalent to the following:
632+
* // 'api' => self::OP_ALL,
633+
*
634+
* ];
635+
* ```
636+
*
637+
* The above declaration specifies that in the "admin" scenario, the insert operation ([[insert()]])
638+
* should be done in a transaction; and in the "api" scenario, all the operations should be done
639+
* in a transaction.
640+
*
641+
* @return array the declarations of transactional operations. The array keys are scenarios names,
642+
* and the array values are the corresponding transaction operations.
643+
*/
644+
public function transactions()
645+
{
646+
return [];
647+
}
648+
649+
/**
650+
* Returns a value indicating whether the specified operation is transactional in the current [[$scenario]].
651+
* @param int $operation the operation to check. Possible values are [[OP_INSERT]], [[OP_UPDATE]] and [[OP_DELETE]].
652+
* @return bool whether the specified operation is transactional in the current [[scenario]].
653+
*/
654+
public function isTransactional($operation)
655+
{
656+
$scenario = $this->getScenario();
657+
$transactions = $this->transactions();
658+
659+
return isset($transactions[$scenario]) && ($transactions[$scenario] & $operation);
660+
}
661+
}

0 commit comments

Comments
 (0)