Skip to content
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

[10.x] Fix "after commit" callbacks not running on nested transactions using RefreshDatabase or DatabaseMigrations #48523

Merged
merged 79 commits into from Sep 26, 2023
Merged
Show file tree
Hide file tree
Changes from 76 commits
Commits
Show all changes
79 commits
Select commit Hold shift + click to select a range
1f10b5e
Tests observer using afterCommit
tonysm Sep 20, 2023
d9538d6
Adds failing test for savepoint and observers using afterCommit
tonysm Sep 20, 2023
e6de881
Ignore the createOrFirst savepoint to run callbacks
tonysm Sep 20, 2023
06adc98
Fix typo
tonysm Sep 20, 2023
bf2764b
Remove DatabaseEloquentAppTest to see if CI passes
tonysm Sep 20, 2023
451fd8b
wip
crynobone Sep 20, 2023
f49260e
wip
crynobone Sep 20, 2023
a7a2fa2
wip
crynobone Sep 20, 2023
e3df8b1
Update src/Illuminate/Database/DatabaseTransactionsManager.php
taylorotwell Sep 20, 2023
f5aacf5
Changes the way the after commit callbacks are executed
tonysm Sep 21, 2023
1d7b8ee
Fix DatabaseTransactionsTest
tonysm Sep 21, 2023
65c75ae
Fix DatabaseTransactionsManagerTest
tonysm Sep 21, 2023
2037453
CSFixer
tonysm Sep 21, 2023
c7c7f3a
wip
tonysm Sep 21, 2023
0d8f1fa
Rename method and property and inline usage
tonysm Sep 21, 2023
393a69c
Adds a depply nested transaction test
tonysm Sep 21, 2023
d3dcb03
Simplify deeply nesting test
tonysm Sep 21, 2023
68ed29a
Sets default level value to one (since it's a new parameter)
tonysm Sep 21, 2023
5825929
Rename method
tonysm Sep 22, 2023
c660879
Adds back the removed methods from the db.transactions and mark them …
tonysm Sep 22, 2023
e111e70
StyleCI
tonysm Sep 22, 2023
239e6ec
Inline the if statement using then collection when() method
tonysm Sep 22, 2023
3510970
Tests observer using afterCommit
tonysm Sep 25, 2023
7bb02ae
wip
crynobone Sep 25, 2023
6799bde
wip
crynobone Sep 25, 2023
9cc6900
wip
crynobone Sep 25, 2023
ad9b10c
wip
crynobone Sep 25, 2023
4a0232b
wip
crynobone Sep 25, 2023
36f1d21
wip
crynobone Sep 25, 2023
2f3a943
wip
crynobone Sep 25, 2023
1e8bcf4
Apply fixes from StyleCI
StyleCIBot Sep 25, 2023
f245912
wip
crynobone Sep 25, 2023
be843ef
wip
crynobone Sep 25, 2023
6519d0d
wip
crynobone Sep 25, 2023
e2a079b
wip
crynobone Sep 25, 2023
21c6548
wip
crynobone Sep 25, 2023
5863f0c
wip
crynobone Sep 25, 2023
7571318
wip
crynobone Sep 25, 2023
a8882f5
wip
crynobone Sep 25, 2023
d4ec4a2
Apply fixes from StyleCI
StyleCIBot Sep 25, 2023
8e9eae3
wip
crynobone Sep 25, 2023
d2266a2
Merge remote-tracking branch 'upstream/48466-redux' into 48466-redux
crynobone Sep 25, 2023
9968ad2
wip
crynobone Sep 25, 2023
b3c3951
wip
crynobone Sep 25, 2023
d13db21
wip
crynobone Sep 25, 2023
e599333
wip
crynobone Sep 25, 2023
7c99f62
Apply fixes from StyleCI
StyleCIBot Sep 25, 2023
c645396
wip
crynobone Sep 25, 2023
b1decbf
Merge branch 'fix-create-or-first-transaction-callbacks' into 48466-r…
crynobone Sep 25, 2023
4f93300
wip
crynobone Sep 25, 2023
1e93349
Apply fixes from StyleCI
StyleCIBot Sep 25, 2023
082f4f5
wip
crynobone Sep 25, 2023
9514f3b
Merge remote-tracking branch 'upstream/48466-redux' into 48466-redux
crynobone Sep 25, 2023
f5877c2
wip
crynobone Sep 25, 2023
8ff509f
wip
crynobone Sep 25, 2023
55e7044
wip
crynobone Sep 25, 2023
9474b23
wip
crynobone Sep 25, 2023
a7aa391
wip
crynobone Sep 25, 2023
f9ccf6c
wip
crynobone Sep 25, 2023
410bb9c
wip
crynobone Sep 25, 2023
1ae7684
wip
crynobone Sep 25, 2023
bc9a4d5
Apply fixes from StyleCI
StyleCIBot Sep 25, 2023
8c503a3
wip
crynobone Sep 25, 2023
f81cab0
Merge remote-tracking branch 'upstream/48466-redux' into 48466-redux
crynobone Sep 25, 2023
9a3a21e
wip
crynobone Sep 25, 2023
8beb730
wip
crynobone Sep 25, 2023
541af7b
wip
crynobone Sep 25, 2023
9aa3c32
wip
crynobone Sep 25, 2023
c107aec
wip
crynobone Sep 25, 2023
565c579
wip
crynobone Sep 25, 2023
0542d0e
wip
crynobone Sep 25, 2023
95dfe34
wip
crynobone Sep 25, 2023
34f3933
wip
crynobone Sep 25, 2023
7dd9c57
wip
crynobone Sep 25, 2023
bcedae8
Merge branch '10.x' into 48466-redux
crynobone Sep 25, 2023
c291c3a
Merge branch '10.x' into 48466-redux
crynobone Sep 26, 2023
681154c
Update src/Illuminate/Foundation/Testing/DatabaseTransactionsManager.php
crynobone Sep 26, 2023
fa66e08
Update src/Illuminate/Database/DatabaseTransactionsManager.php
crynobone Sep 26, 2023
a519fde
formatting
taylorotwell Sep 26, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Expand Up @@ -104,7 +104,7 @@
"league/flysystem-read-only": "^3.3",
"league/flysystem-sftp-v3": "^3.0",
"mockery/mockery": "^1.5.1",
"orchestra/testbench-core": "^8.10",
"orchestra/testbench-core": "^8.12",
"pda/pheanstalk": "^4.0",
"phpstan/phpstan": "^1.4.7",
"phpunit/phpunit": "^10.0.7",
Expand Down
11 changes: 5 additions & 6 deletions src/Illuminate/Database/Concerns/ManagesTransactions.php
Expand Up @@ -47,8 +47,6 @@ public function transaction(Closure $callback, $attempts = 1)
$this->getPdo()->commit();
}

$this->transactions = max(0, $this->transactions - 1);

if ($this->afterCommitCallbacksShouldBeExecuted()) {
$this->transactionsManager?->commit($this->getName());
}
Expand All @@ -58,6 +56,8 @@ public function transaction(Closure $callback, $attempts = 1)
);

continue;
} finally {
$this->transactions = max(0, $this->transactions - 1);
}

$this->fireConnectionEvent('committed');
Expand Down Expand Up @@ -194,12 +194,12 @@ public function commit()
$this->getPdo()->commit();
}

$this->transactions = max(0, $this->transactions - 1);

if ($this->afterCommitCallbacksShouldBeExecuted()) {
$this->transactionsManager?->commit($this->getName());
}

$this->transactions = max(0, $this->transactions - 1);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reset $this->transactions after calling afterCommitCallbacksShouldBeExecuted() so we can get the true transaction levels during the commit.


$this->fireConnectionEvent('committed');
}

Expand All @@ -211,8 +211,7 @@ public function commit()
protected function afterCommitCallbacksShouldBeExecuted()
{
return $this->transactions == 0 ||
($this->transactionsManager &&
$this->transactionsManager->callbackApplicableTransactions()->count() === 1);
$this->transactionsManager?->afterCommitCallbacksShouldBeExecuted($this->transactions);
}

/**
Expand Down
21 changes: 15 additions & 6 deletions src/Illuminate/Database/DatabaseTransactionsManager.php
Expand Up @@ -7,14 +7,14 @@ class DatabaseTransactionsManager
/**
* All of the recorded transactions.
*
* @var \Illuminate\Support\Collection
* @var \Illuminate\Support\Collection<int, \Illuminate\Database\DatabaseTransactionRecord>
*/
protected $transactions;

/**
* The database transaction that should be ignored by callbacks.
*
* @var \Illuminate\Database\DatabaseTransactionRecord
* @var \Illuminate\Database\DatabaseTransactionRecord|null
*/
protected $callbacksShouldIgnore;

Expand Down Expand Up @@ -112,13 +112,22 @@ public function callbacksShouldIgnore(DatabaseTransactionRecord $transaction)
/**
* Get the transactions that are applicable to callbacks.
*
* @return \Illuminate\Support\Collection
* @return \Illuminate\Support\Collection<int, \Illuminate\Database\DatabaseTransactionRecord>
*/
public function callbackApplicableTransactions()
{
return $this->transactions->reject(function ($transaction) {
return $transaction === $this->callbacksShouldIgnore;
})->values();
return $this->transactions->values();
crynobone marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Determine if after commit callbacks should be executed.
*
* @param int $level
* @return bool
*/
public function afterCommitCallbacksShouldBeExecuted($level)
{
return $level === 1;
}

/**
Expand Down
9 changes: 3 additions & 6 deletions src/Illuminate/Foundation/Testing/DatabaseTransactions.php
Expand Up @@ -13,19 +13,16 @@ public function beginDatabaseTransaction()
{
$database = $this->app->make('db');

$this->app->instance('db.transactions', $transactionsManager = new DatabaseTransactionsManager());

foreach ($this->connectionsToTransact() as $name) {
$connection = $database->connection($name);
$connection->setTransactionManager($transactionsManager);
$dispatcher = $connection->getEventDispatcher();

$connection->unsetEventDispatcher();
$connection->beginTransaction();
$connection->setEventDispatcher($dispatcher);

if ($this->app->resolved('db.transactions')) {
$this->app->make('db.transactions')->callbacksShouldIgnore(
$this->app->make('db.transactions')->getTransactions()->first()
);
}
}

$this->beforeApplicationDestroyed(function () use ($database) {
Expand Down
47 changes: 47 additions & 0 deletions src/Illuminate/Foundation/Testing/DatabaseTransactionsManager.php
@@ -0,0 +1,47 @@
<?php

namespace Illuminate\Foundation\Testing;

class DatabaseTransactionsManager extends \Illuminate\Database\DatabaseTransactionsManager
{
/**
* Register a transaction callback.
*
* @param callable $callback
* @return void
*/
public function 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->callbackApplicableTransactions()->count() === 0) {
return $callback();
}

$this->transactions->last()->addCallback($callback);
}

/**
* Get the transactions that are applicable to callbacks.
*
* @return \Illuminate\Support\Collection<int, \Illuminate\Database\DatabaseTransactionRecord>
*/
public function callbackApplicableTransactions()
{
return $this->transactions->reject(
fn ($transaction) => $transaction === $this->transactions->first()
)->values();
crynobone marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Determine if after commit callbacks should be executed.
*
* @param int $level
* @return bool
*/
public function afterCommitCallbacksShouldBeExecuted($level)
{
return $level === 2;
}
}
9 changes: 3 additions & 6 deletions src/Illuminate/Foundation/Testing/RefreshDatabase.php
Expand Up @@ -89,19 +89,16 @@ public function beginDatabaseTransaction()
{
$database = $this->app->make('db');

$this->app->instance('db.transactions', $transactionsManager = new DatabaseTransactionsManager());

foreach ($this->connectionsToTransact() as $name) {
$connection = $database->connection($name);
$connection->setTransactionManager($transactionsManager);
$dispatcher = $connection->getEventDispatcher();

$connection->unsetEventDispatcher();
$connection->beginTransaction();
$connection->setEventDispatcher($dispatcher);

if ($this->app->resolved('db.transactions')) {
$this->app->make('db.transactions')->callbacksShouldIgnore(
$this->app->make('db.transactions')->getTransactions()->first()
);
}
}

$this->beforeApplicationDestroyed(function () use ($database) {
Expand Down
@@ -0,0 +1,8 @@
<?php

namespace Illuminate\Tests\Integration\Database;

class EloquentTransactionWithAfterCommitTest extends DatabaseTestCase
{
use EloquentTransactionWithAfterCommitTests;
}
104 changes: 104 additions & 0 deletions tests/Integration/Database/EloquentTransactionWithAfterCommitTests.php
@@ -0,0 +1,104 @@
<?php

namespace Illuminate\Tests\Integration\Database;

use Illuminate\Foundation\Auth\User;
use Illuminate\Support\Facades\DB;
use Orchestra\Testbench\Concerns\WithLaravelMigrations;
use Orchestra\Testbench\Factories\UserFactory;

trait EloquentTransactionWithAfterCommitTests
{
use WithLaravelMigrations;

protected function setUpEloquentTransactionWithAfterCommitTests(): void
{
User::unguard();
}

protected function tearDownEloquentTransactionWithAfterCommitTests(): void
{
User::reguard();
}

public function testObserverIsCalledOnTestsWithAfterCommit()
{
User::observe($observer = EloquentTransactionWithAfterCommitTestsUserObserver::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 = EloquentTransactionWithAfterCommitTestsUserObserver::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 = EloquentTransactionWithAfterCommitTestsUserObserver::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 = EloquentTransactionWithAfterCommitTestsUserObserver::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 = EloquentTransactionWithAfterCommitTestsUserObserver::resetting());

$user1 = DB::transaction(function () use ($observer) {
return tap(DB::transaction(function () use ($observer) {
return tap(DB::transaction(function () use ($observer) {
return tap(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');
});
}), 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 EloquentTransactionWithAfterCommitTestsUserObserver
{
public static $calledTimes = 0;

public $afterCommit = true;

public static function resetting()
{
static::$calledTimes = 0;

return new static();
}

public function created($user)
{
static::$calledTimes++;
}
}
@@ -0,0 +1,11 @@
<?php

namespace Illuminate\Tests\Integration\Database;

use Illuminate\Foundation\Testing\DatabaseMigrations;

class EloquentTransactionWithAfterCommitUsingDatabaseMigrationsTest extends DatabaseTestCase
{
use EloquentTransactionWithAfterCommitTests;
use DatabaseMigrations;
}
@@ -0,0 +1,41 @@
<?php

namespace Illuminate\Tests\Integration\Database;

use Illuminate\Foundation\Testing\DatabaseTransactions;
use Orchestra\Testbench\TestCase;

class EloquentTransactionWithAfterCommitUsingDatabaseTransactionsTest extends TestCase
{
use EloquentTransactionWithAfterCommitTests;
use DatabaseTransactions;

/**
* The current database driver.
*
* @return string
*/
protected $driver;

protected function setUp(): void
{
$this->beforeApplicationDestroyed(function () {
foreach (array_keys($this->app['db']->getConnections()) as $name) {
$this->app['db']->purge($name);
}
});

parent::setUp();

if ($this->usesSqliteInMemoryDatabaseConnection()) {
$this->markTestSkipped('Test cannot be used with in-memory SQLite connection.');
}
}

protected function getEnvironmentSetUp($app)
{
$connection = $app->make('config')->get('database.default');

$this->driver = $app['config']->get("database.connections.$connection.driver");
}
}
@@ -0,0 +1,37 @@
<?php

namespace Illuminate\Tests\Integration\Database;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Orchestra\Testbench\TestCase;

class EloquentTransactionWithAfterCommitUsingRefreshDatabaseTest extends TestCase
{
use EloquentTransactionWithAfterCommitTests;
use RefreshDatabase;

/**
* The current database driver.
*
* @return string
*/
protected $driver;

protected function setUp(): void
{
$this->beforeApplicationDestroyed(function () {
foreach (array_keys($this->app['db']->getConnections()) as $name) {
$this->app['db']->purge($name);
}
});

parent::setUp();
}

protected function getEnvironmentSetUp($app)
{
$connection = $app['config']->get('database.default');

$this->driver = $app['config']->get("database.connections.$connection.driver");
}
}