-
Notifications
You must be signed in to change notification settings - Fork 11.4k
[10.x] Fix "after commit" callbacks not running on nested transactions #48466
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1f10b5e
d9538d6
e6de881
06adc98
bf2764b
451fd8b
f49260e
a7a2fa2
e3df8b1
f5aacf5
1d7b8ee
65c75ae
2037453
c7c7f3a
0d8f1fa
393a69c
d3dcb03
68ed29a
5825929
c660879
e111e70
239e6ec
116f9de
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -12,11 +12,11 @@ class DatabaseTransactionsManager | |
protected $transactions; | ||
|
||
/** | ||
* The database transaction that should be ignored by callbacks. | ||
* When in test mode, we'll run the after commit callbacks on the top-level transaction. | ||
* | ||
* @var \Illuminate\Database\DatabaseTransactionRecord | ||
* @var bool | ||
*/ | ||
protected $callbacksShouldIgnore; | ||
protected $afterCommitCallbacksRunningInTestTransaction = false; | ||
|
||
/** | ||
* Create a new database transactions manager instance. | ||
|
@@ -26,6 +26,19 @@ class DatabaseTransactionsManager | |
public function __construct() | ||
{ | ||
$this->transactions = collect(); | ||
$this->afterCommitCallbacksRunningInTestTransaction = false; | ||
} | ||
|
||
/** | ||
* Sets the transaction manager to test mode. | ||
* | ||
* @return self | ||
*/ | ||
public function withAfterCommitCallbacksInTestTransactionAwareMode() | ||
{ | ||
$this->afterCommitCallbacksRunningInTestTransaction = true; | ||
|
||
return $this; | ||
} | ||
|
||
/** | ||
|
@@ -54,30 +67,28 @@ public function rollback($connection, $level) | |
$this->transactions = $this->transactions->reject( | ||
fn ($transaction) => $transaction->connection == $connection && $transaction->level > $level | ||
)->values(); | ||
|
||
if ($this->transactions->isEmpty()) { | ||
$this->callbacksShouldIgnore = null; | ||
} | ||
} | ||
|
||
/** | ||
* Commit the active database transaction. | ||
* | ||
* @param string $connection | ||
* @param int $level | ||
* @return void | ||
*/ | ||
public function commit($connection) | ||
public function commit($connection, $level = 1) | ||
{ | ||
[$forThisConnection, $forOtherConnections] = $this->transactions->partition( | ||
fn ($transaction) => $transaction->connection == $connection | ||
); | ||
|
||
$this->transactions = $forOtherConnections->values(); | ||
// If the transaction level being commited reaches 1 (meaning it was the root | ||
// transaction), we'll run the callbacks. In test mode, since we wrap each | ||
// test in a transaction, we'll run the callbacks when reaching level 2. | ||
if ($level == 1 || ($this->afterCommitCallbacksRunningInTestTransaction && $level == 2)) { | ||
[$forThisConnection, $forOtherConnections] = $this->transactions->partition( | ||
fn ($transaction) => $transaction->connection == $connection | ||
); | ||
|
||
$forThisConnection->map->executeCallbacks(); | ||
$this->transactions = $forOtherConnections->values(); | ||
|
||
if ($this->transactions->isEmpty()) { | ||
$this->callbacksShouldIgnore = null; | ||
$forThisConnection->map->executeCallbacks(); | ||
} | ||
} | ||
|
||
|
@@ -89,36 +100,42 @@ public function commit($connection) | |
*/ | ||
public function addCallback($callback) | ||
{ | ||
if ($current = $this->callbackApplicableTransactions()->last()) { | ||
return $current->addCallback($callback); | ||
// If there are no transactions, we'll run the callbacks right away. Also, we'll run it | ||
// right away when we're in test mode and we only have the wrapping transaction. For | ||
// every other case, we'll queue up the callback to run after the commit happens. | ||
if ($this->transactions->isEmpty() || ($this->afterCommitCallbacksRunningInTestTransaction && $this->transactions->count() == 1)) { | ||
return $callback(); | ||
} | ||
|
||
$callback(); | ||
return $this->transactions->last()->addCallback($callback); | ||
} | ||
|
||
/** | ||
* Specify that callbacks should ignore the given transaction when determining if they should be executed. | ||
* | ||
* @param \Illuminate\Database\DatabaseTransactionRecord $transaction | ||
* @return $this | ||
* | ||
* @deprecated Will be removed in a future Laravel version. Use withAfterCommitCallbacksInTestTransactionAwareMode() instead. | ||
*/ | ||
public function callbacksShouldIgnore(DatabaseTransactionRecord $transaction) | ||
{ | ||
$this->callbacksShouldIgnore = $transaction; | ||
|
||
return $this; | ||
// This method was meant for testing only, so we're forwarding the call to the new method... | ||
return $this->withAfterCommitCallbacksInTestTransactionAwareMode(); | ||
} | ||
|
||
/** | ||
* Get the transactions that are applicable to callbacks. | ||
* | ||
* @return \Illuminate\Support\Collection | ||
* | ||
* @deprecated Will be removed in a future Laravel version. | ||
*/ | ||
public function callbackApplicableTransactions() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This one too, should I add back the signature? We could probably implement it to return all transactions when not in test mode and, if in test mode, skip the first one. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've added this method back with an implementation I believe should work the same as the previous one. We're also marking the method as deprecated. |
||
{ | ||
return $this->transactions->reject(function ($transaction) { | ||
return $transaction === $this->callbacksShouldIgnore; | ||
})->values(); | ||
return $this->transactions | ||
->when($this->afterCommitCallbacksRunningInTestTransaction, fn ($transactions) => $transactions->skip(1)) | ||
->values(); | ||
} | ||
|
||
/** | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
<?php | ||
|
||
namespace Illuminate\Tests\Integration\Database; | ||
|
||
use Illuminate\Foundation\Auth\User; | ||
use Illuminate\Foundation\Testing\RefreshDatabase; | ||
use Illuminate\Support\Facades\DB; | ||
use Orchestra\Testbench\Concerns\WithLaravelMigrations; | ||
use Orchestra\Testbench\Factories\UserFactory; | ||
|
||
class EloquentTransactionUsingRefreshDatabaseTest extends DatabaseTestCase | ||
{ | ||
use RefreshDatabase, WithLaravelMigrations; | ||
|
||
protected function setUp(): void | ||
{ | ||
$this->afterApplicationCreated(fn () => User::unguard()); | ||
$this->beforeApplicationDestroyed(fn () => User::reguard()); | ||
|
||
parent::setUp(); | ||
} | ||
|
||
public function testObserverIsCalledOnTestsWithAfterCommit() | ||
{ | ||
User::observe($observer = EloquentTransactionUsingRefreshDatabaseUserObserver::resetting()); | ||
|
||
$user1 = User::create(UserFactory::new()->raw()); | ||
|
||
$this->assertTrue($user1->exists); | ||
$this->assertEquals(1, $observer::$calledTimes, 'Failed to assert the observer was called once.'); | ||
} | ||
|
||
public function testObserverCalledWithAfterCommitWhenInsideTransaction() | ||
{ | ||
User::observe($observer = EloquentTransactionUsingRefreshDatabaseUserObserver::resetting()); | ||
|
||
$user1 = DB::transaction(fn () => User::create(UserFactory::new()->raw())); | ||
|
||
$this->assertTrue($user1->exists); | ||
$this->assertEquals(1, $observer::$calledTimes, 'Failed to assert the observer was called once.'); | ||
} | ||
|
||
public function testObserverIsCalledOnTestsWithAfterCommitWhenUsingSavepoint() | ||
{ | ||
User::observe($observer = EloquentTransactionUsingRefreshDatabaseUserObserver::resetting()); | ||
|
||
$user1 = User::createOrFirst(UserFactory::new()->raw()); | ||
|
||
$this->assertTrue($user1->exists); | ||
$this->assertEquals(1, $observer::$calledTimes, 'Failed to assert the observer was called once.'); | ||
} | ||
|
||
public function testObserverIsCalledOnTestsWithAfterCommitWhenUsingSavepointAndInsideTransaction() | ||
{ | ||
User::observe($observer = EloquentTransactionUsingRefreshDatabaseUserObserver::resetting()); | ||
|
||
$user1 = DB::transaction(fn () => User::createOrFirst(UserFactory::new()->raw())); | ||
|
||
$this->assertTrue($user1->exists); | ||
$this->assertEquals(1, $observer::$calledTimes, 'Failed to assert the observer was called once.'); | ||
} | ||
|
||
public function testObserverIsCalledEvenWhenDeeplyNestingTransactions() | ||
{ | ||
User::observe($observer = EloquentTransactionUsingRefreshDatabaseUserObserver::resetting()); | ||
|
||
$user1 = DB::transaction(function () use ($observer) { | ||
return tap(DB::transaction(function () use ($observer) { | ||
return tap(DB::transaction(function () { | ||
return User::createOrFirst(UserFactory::new()->raw()); | ||
}), function () use ($observer) { | ||
$this->assertEquals(0, $observer::$calledTimes, 'Should not have been called'); | ||
}); | ||
}), function () use ($observer) { | ||
$this->assertEquals(0, $observer::$calledTimes, 'Should not have been called'); | ||
}); | ||
}); | ||
|
||
$this->assertTrue($user1->exists); | ||
$this->assertEquals(1, $observer::$calledTimes, 'Failed to assert the observer was called once.'); | ||
} | ||
} | ||
|
||
class EloquentTransactionUsingRefreshDatabaseUserObserver | ||
{ | ||
public static $calledTimes = 0; | ||
|
||
public $afterCommit = true; | ||
|
||
public static function resetting() | ||
{ | ||
static::$calledTimes = 0; | ||
|
||
return new static(); | ||
} | ||
|
||
public function created($user) | ||
{ | ||
static::$calledTimes++; | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.