99
1010use MongoDB \BSON \Binary ;
1111use MongoDB \BSON \Type ;
12+ use MongoDB \BSON \ObjectId ;
1213use Yii ;
1314use yii \base \InvalidConfigException ;
1415use yii \db \BaseActiveRecord ;
2526 */
2627abstract 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