Skip to content

Commit

Permalink
feat: rewrite AR transaction to using EntityManager
Browse files Browse the repository at this point in the history
  • Loading branch information
roxblnfk committed Jul 24, 2024
1 parent 9b55326 commit 10dea8f
Show file tree
Hide file tree
Showing 8 changed files with 144 additions and 114 deletions.
106 changes: 48 additions & 58 deletions src/ActiveRecord.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,11 @@

namespace Cycle\ActiveRecord;

use Cycle\ActiveRecord\Exception\Transaction\TransactionException;
use Cycle\ActiveRecord\Internal\TransactionFacade;
use Cycle\ActiveRecord\Query\ActiveQuery;
use Cycle\ORM\EntityManager;
use Cycle\ORM\EntityManagerInterface;
use Cycle\ORM\ORMInterface;
use Cycle\ORM\RepositoryInterface;
use Cycle\ORM\Transaction\StateInterface;

/**
* A base class for entities that are managed by the ORM.
Expand Down Expand Up @@ -72,11 +70,22 @@ final public static function findAll(array $scope = []): iterable

/**
* Execute a callback within a single transaction.
* Only {@see ActiveRecord} methods will be registered in the transaction and run on the callback completion.
*
* All the ActiveRecord write operations within the callback will be registered
* using the Entity Manager without being executed until the end of the callback.
*
* @template TResult
* @param callable(): TResult $callback
* @return TResult
*
* @throws TransactionException
* @throws \Throwable
*/
public static function transact(callable $callback, TransactionMode $mode = TransactionMode::OpenNew): void
{
TransactionFacade::transact($callback, $mode);
public static function transact(
callable $callback,
TransactionMode $mode = TransactionMode::OpenNew,
): mixed {
return TransactionFacade::transact($callback, $mode);
}

/**
Expand All @@ -96,81 +105,62 @@ public static function getRepository(): RepositoryInterface

/**
* Persist the entity.
*
* @throws \Throwable
*/
final public function save(bool $cascade = true): StateInterface
final public function save(bool $cascade = true): bool
{
/** @var EntityManager $entityManager */
$entityManager = Facade::getEntityManager();
$entityManager->persist($this, $cascade);

return $entityManager->run(throwException: false);
$transacting = TransactionFacade::getEntityManager();
if ($transacting === null) {
return Facade::getEntityManager()

Check failure on line 113 in src/ActiveRecord.php

View workflow job for this annotation

GitHub Actions / phpstan (ubuntu-latest, 8.2, locked)

Method Cycle\ORM\EntityManagerInterface::run() invoked with 1 parameter, 0 required.
->persist($this, $cascade)
->run(false)

Check failure on line 115 in src/ActiveRecord.php

View workflow job for this annotation

GitHub Actions / psalm (ubuntu-latest, 8.2, locked)

TooManyArguments

src/ActiveRecord.php:115:19: TooManyArguments: Too many arguments for method Cycle\ORM\EntityManagerInterface::run - saw 1 (see https://psalm.dev/026)
->isSuccess();
}

$transacting->persist($this, $cascade);
return true;
}

/**
* Persist the entity and throw an exception if an error occurs.
* The exception will be thrown if the action is happening not in a {@see self::transcat()} scope.
*
* @throws \Throwable
*/
final public function saveOrFail(bool $cascade = true): StateInterface
{
/** @var EntityManager $entityManager */
$entityManager = Facade::getEntityManager();
$entityManager->persist($this, $cascade);

return $entityManager->run();
}

/**
* Prepare the entity for persistence.
*
* @note This function is experimental and may be removed in the future.
*/
final public function persist(bool $cascade = true): EntityManagerInterface
final public function saveOrFail(bool $cascade = true): void
{
return Facade::getEntityManager()->persist($this, $cascade);
TransactionFacade::getEntityManager()?->persist($this, $cascade) ?? Facade::getEntityManager()
->persist($this, $cascade)
->run();
}

/**
* Delete the entity.
*
* @throws \Throwable
*/
final public function delete(bool $cascade = true): StateInterface
final public function delete(bool $cascade = true): bool
{
/** @var EntityManager $entityManager */
$entityManager = Facade::getEntityManager();
$entityManager->delete($this, $cascade);

return $entityManager->run(throwException: false);
$transacting = TransactionFacade::getEntityManager();
if ($transacting === null) {
return Facade::getEntityManager()

Check failure on line 143 in src/ActiveRecord.php

View workflow job for this annotation

GitHub Actions / phpstan (ubuntu-latest, 8.2, locked)

Method Cycle\ORM\EntityManagerInterface::run() invoked with 1 parameter, 0 required.
->delete($this, $cascade)
->run(false)

Check failure on line 145 in src/ActiveRecord.php

View workflow job for this annotation

GitHub Actions / psalm (ubuntu-latest, 8.2, locked)

TooManyArguments

src/ActiveRecord.php:145:19: TooManyArguments: Too many arguments for method Cycle\ORM\EntityManagerInterface::run - saw 1 (see https://psalm.dev/026)
->isSuccess();
}

$transacting->delete($this, $cascade);
return true;
}

/**
* Delete the entity and throw an exception if an error occurs.
* The exception will be thrown if the action is happening not in a {@see self::transcat()} scope.
*
* @throws \Throwable
*/
final public function deleteOrFail(bool $cascade = true): StateInterface
final public function deleteOrFail(bool $cascade = true): void
{
/** @var EntityManager $entityManager */
$entityManager = Facade::getEntityManager();
$entityManager->delete($this, $cascade);

return $entityManager->run();
}

/**
* Prepare the entity for deletion.
*
* @note This function is experimental and may be removed in the future.
*/
final public function remove(bool $cascade = true): EntityManagerInterface
{
/** @var EntityManager $entityManager */
$entityManager = Facade::getEntityManager();

return $entityManager->delete($this, $cascade);
TransactionFacade::getEntityManager()?->delete($this, $cascade) ?? Facade::getEntityManager()
->delete($this, $cascade)
->run();
}

private static function getOrm(): ORMInterface
Expand Down
9 changes: 9 additions & 0 deletions src/Exception/Transaction/TransactionException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Cycle\ActiveRecord\Exception\Transaction;

use Cycle\ActiveRecord\Exception\ActiveRecordException;

class TransactionException extends \RuntimeException implements ActiveRecordException {}
7 changes: 0 additions & 7 deletions src/Exception/TransactionDesyncException.php

This file was deleted.

51 changes: 29 additions & 22 deletions src/Internal/TransactionFacade.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,43 +4,50 @@

namespace Cycle\ActiveRecord\Internal;

use Cycle\ActiveRecord\Exception\TransactionDesyncException;
use Cycle\ActiveRecord\Exception\Transaction\TransactionException;
use Cycle\ActiveRecord\Facade;
use Cycle\ActiveRecord\TransactionMode;
use Cycle\ORM\EntityManagerInterface;
use Cycle\ORM\Transaction\Runner;
use Cycle\ORM\Transaction\StateInterface;
use Cycle\ORM\Transaction\UnitOfWork;

final class TransactionFacade
{
private static ?UnitOfWork $uow = null;
private static ?EntityManagerInterface $em = null;

public static function getUoW(): ?UnitOfWork
public static function getEntityManager(): ?EntityManagerInterface
{
return self::$uow;
return self::$em;
}

/**
* @template TResult
* @param callable(): TResult $callback
* @return TResult
*
* @throws TransactionException
* @throws \Throwable
*/
public static function transact(
callable $callable,
callable $callback,
TransactionMode $mode = TransactionMode::OpenNew,
): StateInterface {
$previous = self::$uow;
self::$uow = $uow = new UnitOfWork(
Facade::getOrm(),
match ($mode) {
TransactionMode::Ignore => Runner::outerTransaction(strict: false),
TransactionMode::Continue => Runner::outerTransaction(strict: true),
TransactionMode::OpenNew => Runner::innerTransaction(),
},
): mixed {
$runner = match ($mode) {
TransactionMode::Ignore => Runner::outerTransaction(strict: false),
TransactionMode::Current => Runner::outerTransaction(strict: true),
TransactionMode::OpenNew => Runner::innerTransaction(),
};

self::$em === null or throw new TransactionException(
'A transaction is already in progress.',
);
self::$em = Facade::getEntityManager();

try {
$callable();
return $uow->run();
$result = $callback();
self::$em->run(true, $runner);

Check failure on line 47 in src/Internal/TransactionFacade.php

View workflow job for this annotation

GitHub Actions / phpstan (ubuntu-latest, 8.2, locked)

Method Cycle\ORM\EntityManagerInterface::run() invoked with 2 parameters, 0 required.

Check failure on line 47 in src/Internal/TransactionFacade.php

View workflow job for this annotation

GitHub Actions / psalm (ubuntu-latest, 8.2, locked)

TooManyArguments

src/Internal/TransactionFacade.php:47:24: TooManyArguments: Too many arguments for method Cycle\ORM\EntityManagerInterface::run - saw 2 (see https://psalm.dev/026)
return $result;
} finally {
self::$uow === $uow or throw new TransactionDesyncException(
'A transaction was started outside of the previous transaction scope.',
);
self::$uow = $previous;
self::$em = null;
}
}
}
2 changes: 1 addition & 1 deletion src/TransactionMode.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ enum TransactionMode
*
* @see \Cycle\ORM\Transaction\Runner::innerTransaction() with strict mode.
*/
case Continue;
case Current;
/**
* A new transaction will be open for each used driver connection and will close they on finish.
*
Expand Down
1 change: 0 additions & 1 deletion tests/app/Repository/RepositoryWithActiveQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
*/
final class RepositoryWithActiveQuery extends ActiveRepository
{
#[\Override]
public function __construct()
{
parent::__construct(User::class);
Expand Down
74 changes: 53 additions & 21 deletions tests/src/Functional/ActiveRecordTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@

namespace Cycle\Tests\Functional;

use Cycle\ActiveRecord\Facade;
use Cycle\ActiveRecord\ActiveRecord;
use Cycle\ActiveRecord\Exception\Transaction\TransactionException;
use Cycle\ActiveRecord\TransactionMode;
use Cycle\App\Entity\User;
use Cycle\ORM\Exception\RunnerException;
use Cycle\ORM\Select\Repository;
use PHPUnit\Framework\Attributes\Test;

Expand Down Expand Up @@ -69,7 +72,7 @@ public function it_saves_entity(): void
{
$user = new User('Alex');

self::assertTrue($user->save()->isSuccess());
self::assertTrue($user->save());
self::assertCount(3, User::findAll());

$result = $this->selectEntity(User::class, cleanHeap: true)->wherePK($user->id)->fetchOne();
Expand All @@ -85,13 +88,13 @@ public function it_triggers_exception_when_tries_to_save_entity_using_save_or_fa
{
$user = new User('John');

$this::expectException(\Throwable::class);
self::expectException(\Throwable::class);

// pgsql-response: SQLSTATE[23505]: Unique violation: 7 ERROR: duplicate key value violates unique constraint "user_index_name_663d5b6bf1e34
// sqlite-response: SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: user.name
// mysql-response: SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry 'John' for key 'user.user_index_name_663d5bc589edb'

$this::expectExceptionMessage('SQLSTATE');
self::expectExceptionMessage('SQLSTATE');

$entityManager = $user->saveOrFail();

Check failure on line 99 in tests/src/Functional/ActiveRecordTest.php

View workflow job for this annotation

GitHub Actions / phpstan (ubuntu-latest, 8.2, locked)

Result of method Cycle\ActiveRecord\ActiveRecord::saveOrFail() (void) is used.

Check failure on line 99 in tests/src/Functional/ActiveRecordTest.php

View workflow job for this annotation

GitHub Actions / psalm (ubuntu-latest, 8.2, locked)

AssignmentToVoid

tests/src/Functional/ActiveRecordTest.php:99:9: AssignmentToVoid: Cannot assign $entityManager to type void (see https://psalm.dev/121)

Check failure on line 99 in tests/src/Functional/ActiveRecordTest.php

View workflow job for this annotation

GitHub Actions / psalm (ubuntu-latest, 8.2, locked)

UnusedVariable

tests/src/Functional/ActiveRecordTest.php:99:9: UnusedVariable: $entityManager is never referenced or the value is not used (see https://psalm.dev/077)

Expand All @@ -103,16 +106,15 @@ public function it_triggers_exception_when_tries_to_save_entity_using_save_or_fa
* @throws \Throwable
*/
#[Test]
public function it_persists_multiple_entities(): void
public function it_persists_multiple_entities_in_single_transaction(): void

Check failure on line 109 in tests/src/Functional/ActiveRecordTest.php

View workflow job for this annotation

GitHub Actions / psalm (ubuntu-latest, 8.2, locked)

PossiblyUnusedMethod

tests/src/Functional/ActiveRecordTest.php:109:21: PossiblyUnusedMethod: Cannot find any calls to method Cycle\Tests\Functional\ActiveRecordTest::it_persists_multiple_entities_in_single_transaction (see https://psalm.dev/087)
{
$userOne = new User('Foo');
$userOne->persist();
ActiveRecord::transact(static function () use (&$userOne, &$userTwo) {

Check failure on line 111 in tests/src/Functional/ActiveRecordTest.php

View workflow job for this annotation

GitHub Actions / psalm (ubuntu-latest, 8.2, locked)

UndefinedVariable

tests/src/Functional/ActiveRecordTest.php:111:57: UndefinedVariable: Cannot find referenced variable $userOne (see https://psalm.dev/024)
$userOne = new User('Foo');
$userOne->saveOrFail();

$userTwo = new User('Bar');
$userTwo->persist();

$entityManager = Facade::getEntityManager();
$entityManager->run();
$userTwo = new User('Bar');
$userTwo->saveOrFail();
});

self::assertCount(4, User::findAll());

Expand All @@ -132,28 +134,27 @@ public function it_deletes_entity(): void
$user = User::findByPK(1);
self::assertNotNull($user);

self::assertTrue($user->delete()->isSuccess());
self::assertTrue($user->delete());
self::assertCount(1, User::findAll());
}

/**
* @throws \Throwable
*/
#[Test]
public function it_deletes_multiple_entities_using_remove_method(): void
public function it_deletes_multiple_entities_in_single_transaction(): void
{
self::assertCount(2, User::findAll());

/** @var User $userOne */
$userOne = User::findByPK(1);
/** @var User $userTwo */
$userTwo = User::findByPK(2);

$userOne->remove();
$userTwo->remove();

self::assertCount(2, User::findAll());

$entityManager = Facade::getEntityManager();
self::assertTrue($entityManager->run()->isSuccess());
ActiveRecord::transact(static function () use ($userOne, $userTwo) {
$userOne->delete();
$userTwo->delete();
});

self::assertCount(0, User::findAll());
}
Expand All @@ -165,4 +166,35 @@ public function it_gets_default_repository_of_entity(): void

self::assertInstanceOf(Repository::class, $repository);
}

#[Test]
public function it_runs_transaction_without_actions(): void
{
$result = ActiveRecord::transact(static function () {
return 'foo';
});

self::assertSame('foo', $result);
}

#[Test]
public function it_runs_transaction_in_current_transaction_mode_without_opened_transaction(): void
{
self::expectException(RunnerException::class);

ActiveRecord::transact(static function () {
$user = User::findByPK(1);
$user->delete();
}, TransactionMode::Current);
}

#[Test]
public function it_runs_transaction_in_transaction(): void
{
self::expectException(TransactionException::class);

ActiveRecord::transact(static function () {
return ActiveRecord::transact(fn () => true);
}, TransactionMode::Current);
}
}
Loading

0 comments on commit 10dea8f

Please sign in to comment.