diff --git a/examples/authentication/Identity/Persister.php b/examples/authentication/Identity/Persister.php index f58cb22..af9c5e8 100644 --- a/examples/authentication/Identity/Persister.php +++ b/examples/authentication/Identity/Persister.php @@ -46,68 +46,99 @@ public function select(object $transaction, mixed $criteria): iterable } } - public function insert(object $transaction, object $entity): void + public function persist(object $transaction, ORM\Changes $changes): void { - try { - $transaction->execute( - <<<'SQL' - insert into identity (id, password_hash) - values (?, ?) - SQL, - [ - $entity->id->toString(), - $entity->passwordHash, - ], - ); - } catch (PostgresQueryError $error) { - if (str_contains(strtolower($error->getMessage()), 'duplicate key value violates unique constraint')) { - throw new Exception\DuplicateEntity(previous: $error); - } + $this->insert($transaction, $changes->inserts); + $this->update($transaction, $changes->updates); + $this->delete($transaction, $changes->deletes); + } + + /** + * @param list $entities + */ + private function insert(PostgresLink $transaction, array $entities): void + { + if ($entities === []) { + return; + } + + $statement = $transaction->prepare( + <<<'SQL' + insert into identity (id, password_hash) + values (?, ?) + SQL, + ); + + foreach ($entities as $entity) { + try { + $statement->execute([$entity->id->toString(), $entity->passwordHash]); + } catch (PostgresQueryError $error) { + if (str_contains(strtolower($error->getMessage()), 'duplicate key value violates unique constraint')) { + throw new Exception\DuplicateEntity(previous: $error); + } - throw $error; + throw $error; + } } } - public function update(object $transaction, object $entity, object $snapshot): void + /** + * @param list> $updates + */ + private function update(PostgresLink $transaction, array $updates): void { - if ($entity->passwordHash === $snapshot->passwordHash) { + if ($updates === []) { return; } - $result = $transaction->execute( + $statement = $transaction->prepare( <<<'SQL' update identity set password_hash = ?, version = version + 1 where id = ? and version = ? SQL, - [ - $entity->passwordHash, - $entity->id->toString(), - $entity->version, - ], ); - if ($result->getRowCount() !== 1) { - throw new Exception\ConcurrentModification(); + foreach ($updates as $update) { + if ($update->entity->passwordHash === $update->snapshot->passwordHash) { + continue; + } + + $result = $statement->execute([ + $update->entity->passwordHash, + $update->entity->id->toString(), + $update->entity->version, + ]); + + if ($result->getRowCount() !== 1) { + throw new Exception\ConcurrentModification(); + } } } - public function delete(object $transaction, object $entity): void + /** + * @param list $entities + */ + private function delete(PostgresLink $transaction, array $entities): void { - $result = $transaction->execute( + if ($entities === []) { + return; + } + + $statement = $transaction->prepare( <<<'SQL' delete from identity where id = ? and version = ? SQL, - [ - $entity->id->toString(), - $entity->version, - ], ); - if ($result->getRowCount() !== 1) { - throw new Exception\ConcurrentModification(); + foreach ($entities as $entity) { + $result = $statement->execute([$entity->id->toString(), $entity->version]); + + if ($result->getRowCount() !== 1) { + throw new Exception\ConcurrentModification(); + } } } } diff --git a/src/Changes.php b/src/Changes.php new file mode 100644 index 0000000..ab61ca4 --- /dev/null +++ b/src/Changes.php @@ -0,0 +1,37 @@ +> $changes + * @return self + */ + public static function merge(array $changes): self + { + return new self( + inserts: array_merge(...array_column($changes, 'inserts')), + updates: array_merge(...array_column($changes, 'updates')), + deletes: array_merge(...array_column($changes, 'deletes')), + ); + } + + /** + * @param list $inserts + * @param list> $updates + * @param list $deletes + */ + public function __construct( + public array $inserts = [], + public array $updates = [], + public array $deletes = [], + ) {} +} diff --git a/src/Internal/ExistingEntity.php b/src/Internal/Existing.php similarity index 64% rename from src/Internal/ExistingEntity.php rename to src/Internal/Existing.php index 79bab17..442b234 100644 --- a/src/Internal/ExistingEntity.php +++ b/src/Internal/Existing.php @@ -4,18 +4,17 @@ namespace Thesis\ORM\Internal; +use Thesis\ORM\Changes; use Thesis\ORM\Exception\DuplicateEntity; use Thesis\ORM\Exception\EntityNotManaged; -use Thesis\ORM\Persister; +use Thesis\ORM\Update; /** * @internal * - * @template TTransaction of object * @template TEntity of object - * @implements ManagedEntity */ -final class ExistingEntity implements ManagedEntity +final class Existing { private bool $remove = false; @@ -25,16 +24,18 @@ final class ExistingEntity implements ManagedEntity private readonly object $snapshot; /** - * @param Persister $persister * @param TEntity $entity */ public function __construct( - private readonly Persister $persister, public readonly object $entity, ) { $this->snapshot = clone $entity; } + /** + * @param TEntity $entity + * @throws DuplicateEntity + */ public function add(object $entity): void { if ($entity !== $this->entity) { @@ -44,6 +45,10 @@ public function add(object $entity): void $this->remove = false; } + /** + * @param TEntity $entity + * @throws EntityNotManaged + */ public function remove(object $entity): void { if ($entity !== $this->entity) { @@ -53,12 +58,15 @@ public function remove(object $entity): void $this->remove = true; } - public function flush(object $transaction): void + /** + * @return Changes + */ + public function collectChanges(): Changes { if ($this->remove) { - $this->persister->delete($transaction, $this->entity); - } else { - $this->persister->update($transaction, $this->entity, $this->snapshot); + return new Changes(deletes: [$this->entity]); } + + return new Changes(updates: [new Update($this->entity, $this->snapshot)]); } } diff --git a/src/Internal/ManagedEntity.php b/src/Internal/ManagedEntity.php index 6551a62..6b2c3bd 100644 --- a/src/Internal/ManagedEntity.php +++ b/src/Internal/ManagedEntity.php @@ -4,38 +4,66 @@ namespace Thesis\ORM\Internal; -use Thesis\ORM\Exception\ConcurrentModification; +use Thesis\ORM\Changes; use Thesis\ORM\Exception\DuplicateEntity; use Thesis\ORM\Exception\EntityNotManaged; /** * @internal * - * @template TTransaction of object * @template TEntity of object */ -interface ManagedEntity +final class ManagedEntity { /** - * @var ?TEntity + * @param Existing|NonExisting $state */ - public ?object $entity { get; } + public function __construct( + private Existing|NonExisting $state, + ) {} + + /** + * @param TEntity $entity + * @return TEntity + */ + public function resolveFound(object $entity): object + { + if ($this->state instanceof Existing) { + return $this->state->entity; + } + + if ($this->state->entity !== null) { + $entity = $this->state->entity; + $this->state = new Existing($entity); + + return $entity; + } + + $this->state = new Existing($entity); + + return $entity; + } /** * @param TEntity $entity * @throws DuplicateEntity */ - public function add(object $entity): void; + public function add(object $entity): void + { + $this->state->add($entity); + } /** * @param TEntity $entity * @throws EntityNotManaged */ - public function remove(object $entity): void; + public function remove(object $entity): void + { + $this->state->remove($entity); + } /** - * @param TTransaction $transaction - * @throws DuplicateEntity|ConcurrentModification + * @var Changes */ - public function flush(object $transaction): void; + public Changes $changes { get => $this->state->collectChanges(); } } diff --git a/src/Internal/ManagedPersister.php b/src/Internal/ManagedPersister.php new file mode 100644 index 0000000..f77d1b5 --- /dev/null +++ b/src/Internal/ManagedPersister.php @@ -0,0 +1,45 @@ +> + */ + private array $entities = []; + + /** + * @param Persister $persister + */ + public function __construct( + private readonly Persister $persister, + ) {} + + /** + * @param ManagedEntity $entity + */ + public function addEntity(ManagedEntity $entity): void + { + $this->entities[] = $entity; + } + + /** + * @param TTransaction $transaction + */ + public function persist(object $transaction): void + { + $this->persister->persist($transaction, Changes::merge(array_column($this->entities, 'changes'))); + } +} diff --git a/src/Internal/NonExistingEntity.php b/src/Internal/NonExisting.php similarity index 61% rename from src/Internal/NonExistingEntity.php rename to src/Internal/NonExisting.php index 1fb4a7f..0e294dd 100644 --- a/src/Internal/NonExistingEntity.php +++ b/src/Internal/NonExisting.php @@ -4,28 +4,28 @@ namespace Thesis\ORM\Internal; +use Thesis\ORM\Changes; use Thesis\ORM\Exception\DuplicateEntity; use Thesis\ORM\Exception\EntityNotManaged; -use Thesis\ORM\Persister; /** * @internal * - * @template TTransaction of object * @template TEntity of object - * @implements ManagedEntity */ -final class NonExistingEntity implements ManagedEntity +final class NonExisting { - public private(set) ?object $entity = null; - /** - * @param Persister $persister + * @param ?TEntity $entity */ public function __construct( - private readonly Persister $persister, + public private(set) ?object $entity = null, ) {} + /** + * @param TEntity $entity + * @throws DuplicateEntity + */ public function add(object $entity): void { if ($this->entity === null) { @@ -39,6 +39,10 @@ public function add(object $entity): void } } + /** + * @param TEntity $entity + * @throws EntityNotManaged + */ public function remove(object $entity): void { if ($this->entity === null) { @@ -52,10 +56,11 @@ public function remove(object $entity): void $this->entity = null; } - public function flush(object $transaction): void + /** + * @return Changes + */ + public function collectChanges(): Changes { - if ($this->entity !== null) { - $this->persister->insert($transaction, $this->entity); - } + return new Changes(inserts: $this->entity === null ? [] : [$this->entity]); } } diff --git a/src/Persister.php b/src/Persister.php index 5ec7831..b97f2ff 100644 --- a/src/Persister.php +++ b/src/Persister.php @@ -25,23 +25,9 @@ public function select(object $transaction, mixed $criteria): iterable; /** * @param TTransaction $transaction - * @param TEntity $entity + * @param Changes $changes * @throws DuplicateEntity - */ - public function insert(object $transaction, object $entity): void; - - /** - * @param TTransaction $transaction - * @param TEntity $entity - * @param TEntity $snapshot - * @throws ConcurrentModification - */ - public function update(object $transaction, object $entity, object $snapshot): void; - - /** - * @param TTransaction $transaction - * @param TEntity $entity * @throws ConcurrentModification */ - public function delete(object $transaction, object $entity): void; + public function persist(object $transaction, Changes $changes): void; } diff --git a/src/Persister/InMemory.php b/src/Persister/InMemory.php index d9226be..cba85dd 100644 --- a/src/Persister/InMemory.php +++ b/src/Persister/InMemory.php @@ -4,6 +4,7 @@ namespace Thesis\ORM\Persister; +use Thesis\ORM\Changes; use Thesis\ORM\Persister; /** @@ -15,6 +16,18 @@ */ final class InMemory implements Persister { + /** + * @var list + */ + public array $entities { + get => iterator_to_array($this->storage, preserve_keys: false); + } + + /** + * @var \SplObjectStorage + */ + private readonly \SplObjectStorage $storage; + /** * @var ?\Closure(TEntity, TCriteria): bool */ @@ -25,11 +38,6 @@ final class InMemory implements Persister */ private readonly ?\Closure $sorter; - /** - * @var list - */ - public array $entities; - /** * @param ?callable(TEntity, TCriteria): bool $filter * @param ?callable(TEntity, TEntity): (-1|0|1) $sorter @@ -41,9 +49,14 @@ public function __construct( // default null prevents TEntity = never inference from an empty list ?array $entities = null, ) { + $this->storage = new \SplObjectStorage(); + $this->filter = $filter === null ? null : $filter(...); $this->sorter = $sorter === null ? null : $sorter(...); - $this->entities = $entities ?? []; + + foreach ($entities ?? [] as $entity) { + $this->storage->offsetSet($entity); + } } public function select(object $transaction, mixed $criteria): iterable @@ -51,11 +64,9 @@ public function select(object $transaction, mixed $criteria): iterable $entities = $this->entities; if ($this->filter !== null) { - $entities = array_values( - array_filter( - $this->entities, - fn(object $entity) => ($this->filter)($entity, $criteria), - ), + $entities = array_filter( + $entities, + fn(object $entity) => ($this->filter)($entity, $criteria), ); } @@ -63,23 +74,17 @@ public function select(object $transaction, mixed $criteria): iterable usort($entities, $this->sorter); } - return $entities; + return array_values($entities); } - public function insert(object $transaction, object $entity): void + public function persist(object $transaction, Changes $changes): void { - $this->entities[] = $entity; - } - - public function update(object $transaction, object $entity, object $snapshot): void {} + foreach ($changes->inserts as $insert) { + $this->storage->offsetSet($insert); + } - public function delete(object $transaction, object $entity): void - { - $this->entities = array_values( - array_filter( - $this->entities, - static fn(object $persisted) => $persisted !== $entity, - ), - ); + foreach ($changes->deletes as $delete) { + $this->storage->offsetUnset($delete); + } } } diff --git a/src/UnitOfWork.php b/src/UnitOfWork.php index 83e0f45..6b7b6cd 100644 --- a/src/UnitOfWork.php +++ b/src/UnitOfWork.php @@ -8,9 +8,10 @@ use Thesis\ORM\Exception\DuplicateEntity; use Thesis\ORM\Exception\EntityNotManaged; use Thesis\ORM\Exception\UnitOfWorkClosed; -use Thesis\ORM\Internal\ExistingEntity; +use Thesis\ORM\Internal\Existing; use Thesis\ORM\Internal\ManagedEntity; -use Thesis\ORM\Internal\NonExistingEntity; +use Thesis\ORM\Internal\ManagedPersister; +use Thesis\ORM\Internal\NonExisting; /** * @api @@ -22,9 +23,14 @@ final class UnitOfWork private bool $closed = false; /** - * @var array> + * @var array> */ - private array $managed = []; + private array $identityMap = []; + + /** + * @var array> + */ + private array $persisters = []; /** * @param TTransaction $transaction @@ -56,18 +62,36 @@ class: $class, /** * @template TEntity of object * @template TCriteria - * @param callable(TEntity): non-empty-string $key + * @param callable(TEntity): non-empty-string $keyFactory * @param Persister $persister * @param TCriteria $criteria * @return list */ - public function findBy(callable $key, Persister $persister, mixed $criteria): array + public function findBy(callable $keyFactory, Persister $persister, mixed $criteria): array { $this->ensureNotClosed(); return array_map( - fn(object $entity) => $this->manage($key($entity), $persister, $entity), - iterator_to_array($persister->select($this->transaction, $criteria), preserve_keys: false), + function (object $entity) use ($keyFactory, $persister): object { + $key = $keyFactory($entity); + + /** @var ?ManagedEntity */ + $managedEntity = $this->identityMap[$key] ?? null; + + if ($managedEntity !== null) { + return $managedEntity->resolveFound($entity); + } + + $managedEntity = new ManagedEntity(new Existing($entity)); + $this->identityMap[$key] = $managedEntity; + $this->managePersister($persister, $managedEntity); + + return $entity; + }, + iterator_to_array( + $persister->select($this->transaction, $criteria), + preserve_keys: false, + ), ); } @@ -76,26 +100,24 @@ public function findBy(callable $key, Persister $persister, mixed $criteria): ar * @param non-empty-string $key * @param Persister $persister * @param TEntity $entity - * @return TEntity + * @throws DuplicateEntity */ - private function manage(string $key, Persister $persister, object $entity): object + public function add(string $key, Persister $persister, object $entity): void { - /** @var ?ManagedEntity */ - $managed = $this->managed[$key] ?? null; + $this->ensureNotClosed(); - if ($managed instanceof ExistingEntity) { - return $managed->entity; - } + /** @var ?ManagedEntity */ + $managedEntity = $this->identityMap[$key] ?? null; - if ($managed instanceof NonExistingEntity && $managed->entity !== null) { - $this->managed[$key] = new ExistingEntity($persister, $managed->entity); + if ($managedEntity !== null) { + $managedEntity->add($entity); - return $managed->entity; + return; } - $this->managed[$key] = new ExistingEntity($persister, $entity); - - return $entity; + $managedEntity = new ManagedEntity(new NonExisting($entity)); + $this->identityMap[$key] = $managedEntity; + $this->managePersister($persister, $managedEntity); } /** @@ -103,31 +125,37 @@ private function manage(string $key, Persister $persister, object $entity): obje * @param non-empty-string $key * @param Persister $persister * @param TEntity $entity - * @throws DuplicateEntity + * @throws EntityNotManaged */ - public function add(string $key, Persister $persister, object $entity): void + public function remove(string $key, Persister $persister, object $entity): void { $this->ensureNotClosed(); - /** @var ManagedEntity */ - $managed = $this->managed[$key] ??= new NonExistingEntity($persister); - $managed->add($entity); + /** @var ?ManagedEntity */ + $managedEntity = $this->identityMap[$key] ?? null; + + if ($managedEntity !== null) { + $managedEntity->remove($entity); + + return; + } + + /** @var ManagedEntity */ + $managedEntity = new ManagedEntity(new NonExisting()); + $this->identityMap[$key] = $managedEntity; + $this->managePersister($persister, $managedEntity); } /** * @template TEntity of object - * @param non-empty-string $key * @param Persister $persister - * @param TEntity $entity - * @throws EntityNotManaged + * @param ManagedEntity $entity */ - public function remove(string $key, Persister $persister, object $entity): void + private function managePersister(Persister $persister, ManagedEntity $entity): void { - $this->ensureNotClosed(); - - /** @var ManagedEntity */ - $managed = $this->managed[$key] ??= new NonExistingEntity($persister); - $managed->remove($entity); + /** @var ManagedPersister */ + $managedPersister = $this->persisters[spl_object_id($persister)] ??= new ManagedPersister($persister); + $managedPersister->addEntity($entity); } /** @@ -138,8 +166,8 @@ public function flush(): void $this->ensureNotClosed(); try { - foreach ($this->managed as $entity) { - $entity->flush($this->transaction); + foreach ($this->persisters as $persister) { + $persister->persist($this->transaction); } } finally { $this->close(); @@ -148,7 +176,8 @@ public function flush(): void public function close(): void { - $this->managed = []; + $this->identityMap = []; + $this->persisters = []; $this->closed = true; } diff --git a/src/Update.php b/src/Update.php new file mode 100644 index 0000000..a32e4d2 --- /dev/null +++ b/src/Update.php @@ -0,0 +1,22 @@ +findBy( - key: static fn(Article $article) => (string) $article->id, + keyFactory: static fn(Article $article) => (string) $article->id, persister: $persister, criteria: null, ); @@ -37,7 +37,7 @@ public function testFindByCriteria(): void $unitOfWork = new UnitOfWork(new \stdClass()); $found = $unitOfWork->findBy( - key: static fn(Article $article) => (string) $article->id, + keyFactory: static fn(Article $article) => (string) $article->id, persister: $persister, criteria: 1, );