9
9
10
10
use MongoDB \BSON \Binary ;
11
11
use MongoDB \BSON \Type ;
12
+ use MongoDB \BSON \ObjectId ;
12
13
use Yii ;
13
14
use yii \base \InvalidConfigException ;
14
15
use yii \db \BaseActiveRecord ;
25
26
*/
26
27
abstract class ActiveRecord extends BaseActiveRecord
27
28
{
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
+
28
50
/**
29
51
* Returns the Mongo connection used by this AR class.
30
52
* By default, the "mongodb" application component is used as the Mongo connection.
@@ -208,8 +230,15 @@ public function insert($runValidation = true, $attributes = null)
208
230
if ($ runValidation && !$ this ->validate ($ attributes )) {
209
231
return false ;
210
232
}
211
- $ result = $ this ->insertInternal ($ attributes );
212
233
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
+ });
213
242
return $ result ;
214
243
}
215
244
@@ -243,6 +272,76 @@ protected function insertInternal($attributes = null)
243
272
return true ;
244
273
}
245
274
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
+
246
345
/**
247
346
* @see ActiveRecord::update()
248
347
* @throws StaleObjectException
@@ -308,12 +407,14 @@ protected function updateInternal($attributes = null)
308
407
*/
309
408
public function delete ()
310
409
{
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 ();
315
412
}
316
413
414
+ $ result = null ;
415
+ static ::getDb ()->transaction (function () use (&$ result ) {
416
+ $ result = $ this ->deleteInternal ();
417
+ });
317
418
return $ result ;
318
419
}
319
420
@@ -323,6 +424,9 @@ public function delete()
323
424
*/
324
425
protected function deleteInternal ()
325
426
{
427
+ if (!$ this ->beforeDelete ()) {
428
+ return false ;
429
+ }
326
430
// we do not check the return value of deleteAll() because it's possible
327
431
// the record is already deleted in the database and thus the method will return 0
328
432
$ condition = $ this ->getOldPrimaryKey (true );
@@ -335,6 +439,7 @@ protected function deleteInternal()
335
439
throw new StaleObjectException ('The object being deleted is outdated. ' );
336
440
}
337
441
$ this ->setOldAttributes (null );
442
+ $ this ->afterDelete ();
338
443
339
444
return $ result ;
340
445
}
@@ -411,4 +516,146 @@ private function dumpBsonObject(Type $object)
411
516
}
412
517
return ArrayHelper::toArray ($ object );
413
518
}
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