diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ef221ed..caca5e6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +v2.7.1 (13.02.2024) +-------------------- +- Fix inserting order in regular cases by @roxblnfk and @gam6itko (#381) + v2.7.0 (08.02.2024) -------------------- - Add Generated Fields option into ORM Schema by @roxblnfk (#462) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e60f0b8e..a7f2c237 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,20 +13,20 @@ Please make sure that the following requirements are satisfied before submitting To test ORM engine locally, download the `cycle/orm` repository and start docker containers inside the tests folder: ```bash -$ cd tests/ -$ docker-compose up +cd tests/ +docker-compose up ``` To run full test suite: ```bash -$ ./vendor/bin/phpunit +./vendor/bin/phpunit ``` To run quick test suite: ```bash -$ ./vendor/bin/phpunit tests/ORM/Driver/SQLite +./vendor/bin/phpunit tests/ORM/Functional/Driver/SQLite ``` ## Help Needed In diff --git a/src/Transaction/Pool.php b/src/Transaction/Pool.php index 2f62d539..2d04dbe2 100644 --- a/src/Transaction/Pool.php +++ b/src/Transaction/Pool.php @@ -10,7 +10,6 @@ use Cycle\ORM\ORMInterface; use Cycle\ORM\Reference\ReferenceInterface; use JetBrains\PhpStorm\ExpectedValues; -use SplObjectStorage; use Traversable; /** @@ -21,14 +20,9 @@ */ final class Pool implements \Countable { - /** @var SplObjectStorage */ - private SplObjectStorage $storage; - - /** @var SplObjectStorage */ - private SplObjectStorage $all; - - /** @var SplObjectStorage */ - private SplObjectStorage $priorityStorage; + private TupleStorage $storage; + private TupleStorage $all; + private TupleStorage $priorityStorage; /** * @var Tuple[] @@ -49,8 +43,8 @@ final class Pool implements \Countable public function __construct( private ORMInterface $orm ) { - $this->storage = new SplObjectStorage(); - $this->all = new SplObjectStorage(); + $this->storage = new TupleStorage(); + $this->all = new TupleStorage(); } public function someHappens(): void @@ -93,13 +87,13 @@ public function attach( private function smartAttachTuple(Tuple $tuple, bool $highPriority = false, bool $snap = false): Tuple { if ($tuple->status === Tuple::STATUS_PROCESSED) { - $this->all->attach($tuple->entity, $tuple); + $this->all->attach($tuple); return $tuple; } if ($tuple->status === Tuple::STATUS_PREPARING && $this->all->contains($tuple->entity)) { - return $this->all->offsetGet($tuple->entity); + return $this->all->getTuple($tuple->entity); } - $this->all->attach($tuple->entity, $tuple); + $this->all->attach($tuple); if ($this->iterating || $snap) { $this->snap($tuple); @@ -109,9 +103,9 @@ private function smartAttachTuple(Tuple $tuple, bool $highPriority = false, bool $tuple->state->setStatus(Node::SCHEDULED_DELETE); } if (($this->priorityAutoAttach || $highPriority) && $tuple->status === Tuple::STATUS_PREPARING) { - $this->priorityStorage->attach($tuple->entity, $tuple); + $this->priorityStorage->attach($tuple); } else { - $this->storage->attach($tuple->entity, $tuple); + $this->storage->attach($tuple); } return $tuple; } @@ -138,7 +132,7 @@ public function attachDelete( public function offsetGet(object $entity): ?Tuple { - return $this->all->contains($entity) ? $this->all->offsetGet($entity) : null; + return $this->all->contains($entity) ? $this->all->getTuple($entity) : null; } /** @@ -156,14 +150,11 @@ public function openIterator(): Traversable $this->unprocessed = []; // Snap all entities before store - while ($this->storage->valid()) { - /** @var Tuple $tuple */ - $tuple = $this->storage->getInfo(); + /** @var object $entity */ + foreach ($this->storage as $entity => $tuple) { $this->snap($tuple); if (!isset($tuple->node)) { - $this->storage->detach($this->storage->current()); - } else { - $this->storage->next(); + $this->storage->detach($entity); } } @@ -172,8 +163,7 @@ public function openIterator(): Traversable // High priority first if ($this->priorityStorage->count() > 0) { $priorityStorage = $this->priorityStorage; - foreach ($priorityStorage as $entity) { - $tuple = $priorityStorage->offsetGet($entity); + foreach ($priorityStorage as $entity => $tuple) { yield $entity => $tuple; $this->trashIt($entity, $tuple, $priorityStorage); } @@ -184,21 +174,14 @@ public function openIterator(): Traversable break; } $pool = $this->storage; - if (!$pool->valid() && $pool->count() > 0) { - $pool->rewind(); - } if ($stage === 0) { - // foreach ($pool as $entity) { - while ($pool->valid()) { - /** @var Tuple $tuple */ - $entity = $pool->current(); - $tuple = $pool->getInfo(); - $pool->next(); + foreach ($this->storage as $entity => $tuple) { if ($tuple->status !== Tuple::STATUS_PREPARING) { continue; } + yield $entity => $tuple; - $this->trashIt($entity, $tuple, $this->storage); + $this->trashIt($entity, $tuple, $pool); // Check priority if ($this->priorityStorage->count() > 0) { continue 2; @@ -206,17 +189,14 @@ public function openIterator(): Traversable } $this->priorityAutoAttach = true; $stage = 1; - $this->storage->rewind(); } if ($stage === 1) { - while ($pool->valid()) { - /** @var Tuple $tuple */ - $entity = $pool->current(); - $tuple = $pool->getInfo(); - $pool->next(); + /** @var object $entity */ + foreach ($this->storage as $entity => $tuple) { if ($tuple->status !== Tuple::STATUS_WAITING || $tuple->task === Tuple::TASK_DELETE) { continue; } + $tuple->status = Tuple::STATUS_WAITED; yield $entity => $tuple; $this->trashIt($entity, $tuple, $this->storage); @@ -226,14 +206,11 @@ public function openIterator(): Traversable } } $stage = 2; - $this->storage->rewind(); } if ($stage === 2) { $this->happens = 0; - while ($pool->valid()) { - /** @var Tuple $tuple */ - $entity = $pool->current(); - $tuple = $pool->getInfo(); + /** @var object $entity */ + foreach ($this->storage as $entity => $tuple) { if ($tuple->task === Tuple::TASK_DELETE) { $tuple->task = Tuple::TASK_FORCE_DELETE; } @@ -242,7 +219,6 @@ public function openIterator(): Traversable } elseif ($tuple->status === Tuple::STATUS_DEFERRED) { $tuple->status = Tuple::STATUS_PROPOSED; } - $pool->next(); yield $entity => $tuple; $this->trashIt($entity, $tuple, $this->storage); // Check priority @@ -254,7 +230,7 @@ public function openIterator(): Traversable if ($this->happens !== 0 && $hasUnresolved) { /** @psalm-suppress InvalidIterator */ foreach ($this->unprocessed as $item) { - $this->storage->attach($item->entity, $item); + $this->storage->attach($item); } $this->unprocessed = []; continue; @@ -290,8 +266,7 @@ public function getUnresolved(): iterable */ public function getAllTuples(): iterable { - foreach ($this->all as $entity) { - $tuple = $this->all->offsetGet($entity); + foreach ($this->all as $entity => $tuple) { if (isset($tuple->node)) { yield $entity => $tuple; } @@ -307,7 +282,6 @@ public function closeIterator(): void $this->priorityEnabled = false; $this->priorityAutoAttach = false; unset($this->priorityStorage, $this->unprocessed); - // $this->all = new SplObjectStorage(); } /** @@ -346,17 +320,17 @@ private function snap(Tuple $tuple, bool $forceUpdateState = false): void } } - private function trashIt(object $entity, Tuple $tuple, SplObjectStorage $storage): void + private function trashIt(object $entity, Tuple $tuple, TupleStorage $storage): void { - $storage->detach($entity); - if ($tuple->status === Tuple::STATUS_UNPROCESSED) { + $storage->detach($entity); $tuple->status = Tuple::STATUS_PREPROCESSED; $this->unprocessed[] = $tuple; return; } if ($tuple->status >= Tuple::STATUS_PREPROCESSED) { + $storage->detach($entity); $tuple->status = Tuple::STATUS_PROCESSED; ++$this->happens; return; @@ -366,7 +340,11 @@ private function trashIt(object $entity, Tuple $tuple, SplObjectStorage $storage ++$tuple->status; ++$this->happens; } - $this->storage->attach($tuple->entity, $tuple); + + if ($storage !== $this->storage) { + $storage->detach($entity); + $this->storage->attach($tuple); + } } private function activatePriorityStorage(): void @@ -375,7 +353,7 @@ private function activatePriorityStorage(): void return; } $this->priorityEnabled = true; - $this->priorityStorage = new SplObjectStorage(); + $this->priorityStorage = new TupleStorage(); } private function updateTuple(Tuple $tuple, int $task, ?int $status, bool $cascade, ?Node $node, ?State $state): void diff --git a/src/Transaction/TupleStorage.php b/src/Transaction/TupleStorage.php new file mode 100644 index 00000000..a51cea7e --- /dev/null +++ b/src/Transaction/TupleStorage.php @@ -0,0 +1,89 @@ + + */ +final class TupleStorage implements IteratorAggregate, Countable +{ + /** @var array */ + private array $storage = []; + + private array $iterators = []; + + /** + * @return \Traversable + */ + public function getIterator(): \Traversable + { + $iterator = $this->storage; + // When the generator is destroyed, the reference to the iterator is removed from the collection. + $cleaner = new class () { + public array $iterators; + public function __destruct() + { + unset($this->iterators[\spl_object_id($this)]); + } + }; + /** @psalm-suppress UnsupportedPropertyReferenceUsage */ + $cleaner->iterators = &$this->iterators; + $this->iterators[\spl_object_id($cleaner)] = &$iterator; + + while (\count($iterator) > 0) { + $tuple = \current($iterator); + unset($iterator[\key($iterator)]); + yield $tuple->entity => $tuple; + } + } + + /** + * Returns {@see Tuple} if exists, throws an exception otherwise. + * + * @throws \Throwable if the entity is not found in the storage + */ + public function getTuple(object $entity): Tuple + { + return $this->storage[\spl_object_id($entity)] ?? throw new \RuntimeException('Tuple not found'); + } + + public function attach(Tuple $tuple): void + { + if ($this->contains($tuple->entity)) { + return; + } + + $this->storage[\spl_object_id($tuple->entity)] = $tuple; + foreach ($this->iterators as &$collection) { + $collection[\spl_object_id($tuple->entity)] = $tuple; + } + } + + public function contains(object $entity): bool + { + return \array_key_exists(\spl_object_id($entity), $this->storage); + } + + public function detach(object $entity): void + { + $id = \spl_object_id($entity); + unset($this->storage[$id]); + foreach ($this->iterators as &$collection) { + unset($collection[$id]); + } + } + + /** + * @return int<0, max> + */ + public function count(): int + { + return \count($this->storage); + } +} diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case321/CaseTest.php b/tests/ORM/Functional/Driver/Common/Integration/Case321/CaseTest.php index 9f72f672..00c4c3f3 100644 --- a/tests/ORM/Functional/Driver/Common/Integration/Case321/CaseTest.php +++ b/tests/ORM/Functional/Driver/Common/Integration/Case321/CaseTest.php @@ -84,7 +84,6 @@ private function makeTables(): void 'id' => 'primary', // autoincrement ]); - $this->logger->display(); $this->makeTable('user4', [ 'id' => 'primary', 'counter' => 'int', diff --git a/tests/ORM/Functional/Driver/Common/Integration/Issue380/CaseTest.php b/tests/ORM/Functional/Driver/Common/Integration/Issue380/CaseTest.php new file mode 100644 index 00000000..88262639 --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Issue380/CaseTest.php @@ -0,0 +1,266 @@ +makeTables(); + $this->fillData(); + + $this->loadSchema(__DIR__ . '/schema.php'); + } + + public function testInsertOnce(): void + { + /** @var User $user */ + $user = $this->orm->getRepository(User::class)->findOne(['id' => 1]); + + $em = (new EntityManager($this->orm)); + $em->persist($user); + + $i = 0; + + $em->persist(new User\Alias($user, (string)++$i)); + $em->persist(new User\Alias($user, (string)++$i)); + + $em->persist(new User\Email($user, (string)++$i)); + $em->persist(new User\Email($user, (string)++$i)); + + $em->persist(new User\Phone($user, (string)++$i)); + $em->persist(new User\Phone($user, (string)++$i)); + + $em->run(); + + self::assertSame( + [ + 'alias' => [ + ['id' => 1, 'value' => '1'], + ['id' => 2, 'value' => '2'], + ], + 'email' => [ + ['id' => 1, 'value' => '3'], + ['id' => 2, 'value' => '4'], + ], + 'phone' => [ + ['id' => 1, 'value' => '5'], + ['id' => 2, 'value' => '6'], + ], + ], + [ + 'alias' => $this->fetchFromTable('user_alias'), + 'email' => $this->fetchFromTable('user_email'), + 'phone' => $this->fetchFromTable('user_phone'), + ] + ); + } + + /** + * @dataProvider dataMatrix + */ + public function testInsertMatrix(int $cnt1, int $cnt2, int $cnt3): void + { + /** @var User $user */ + $user = $this->orm->getRepository(User::class)->findOne(['id' => 1]); + + $em = (new EntityManager($this->orm)); + $em->persist($user); + + $i = 0; + + $expected = []; + for ($id = 1; $id <= $cnt1; $id++) { + $em->persist(new User\Alias($user, $v = (string)++$i)); + $expected['alias'][] = ['id' => $id, 'value' => $v]; + } + + for ($id = 1; $id <= $cnt2; $id++) { + $em->persist(new User\Email($user, $v = (string)++$i)); + $expected['email'][] = ['id' => $id, 'value' => $v]; + } + + for ($id = 1; $id <= $cnt3; $id++) { + $em->persist(new User\Phone($user, $v = (string)++$i)); + $expected['phone'][] = ['id' => $id, 'value' => $v]; + } + $em->run(); + + self::assertSame( + $expected, + [ + 'alias' => $this->fetchFromTable('user_alias'), + 'email' => $this->fetchFromTable('user_email'), + 'phone' => $this->fetchFromTable('user_phone'), + ] + ); + } + + public function dataMatrix(): iterable + { + yield [2, 2, 2]; + yield [3, 3, 1]; + yield [1, 7, 4]; + } + + public function testFailOnInsertUniqueDuplicate(): void + { + self::expectException(ConstrainException::class); + + /** @var User $user */ + $user = $this->orm->getRepository(User::class)->findOne(['id' => 1]); + + // insert unique + $em = (new EntityManager($this->orm)); + $em->persist($user); + $em + ->persist(new User\Alias($user, '1')) + ->persist(new User\Alias($user, '1')) + ->run(); + } + + public function testDeleteAndInsertFainOnDuplicateUniqueKey(): void + { + /** @var User $user */ + $user = $this->orm->getRepository(User::class)->findOne(['id' => 1]); + + // insert unique + $em = (new EntityManager($this->orm)); + $em->persist($user); + $em + ->persist($a0 = new User\Alias($user, '1')) + ->persist($a1 = new User\Alias($user, '2')); + $em + ->persist($e0 = new User\Email($user, '1')) + ->persist($e1 = new User\Email($user, '2')); + $em + ->persist($p0 = new User\Phone($user, '1')) + ->persist($p1 = new User\Phone($user, '2')); + $em->run(); + + self::assertCount(2, \array_intersect([1, 2], [$a0->id, $a1->id])); + self::assertCount(2, \array_intersect([1, 2], [$e0->id, $e1->id])); + self::assertCount(2, \array_intersect([1, 2], [$p0->id, $p1->id])); + unset($a0, $a1, $e0, $e1, $p0, $p1); + + $this->orm->getHeap()->clean(); + + // delete old and persist new with same unique value + $em = (new EntityManager($this->orm)); + + /** @var User $user */ + $user = $this->orm->getRepository(User::class)->findOne(['id' => 1]); + $user->username = 'up-username'; + $em->persist($user); + + $a0 = $this->orm->get(User\Alias::class, ['id' => 1]); + $a1 = $this->orm->get(User\Alias::class, ['id' => 2]); + + $e0 = $this->orm->get(User\Email::class, ['id' => 1]); + $e1 = $this->orm->get(User\Email::class, ['id' => 2]); + + $p0 = $this->orm->get(User\Phone::class, ['id' => 1]); + $p1 = $this->orm->get(User\Phone::class, ['id' => 2]); + + $em + ->delete($a0) + ->delete($a1); + $em + ->persist(new User\Alias($user, '1')) + ->persist(new User\Alias($user, '2')); + $em + ->delete($e0) + ->delete($e1); + $em + ->persist(new User\Email($user, '1')) + ->persist(new User\Email($user, '2')); + $em + ->delete($p0) + ->delete($p1); + $em + ->persist(new User\Phone($user, '1')) + ->persist(new User\Phone($user, '2')); + + $em->run(); + + self::assertTrue(true); + } + + private function fetchFromTable(string $tableName): array + { + $db = $this->orm->getSource(User::class)->getDatabase(); + $rows = $db + ->select('id', 'value') + ->from($tableName) + ->orderBy('id') + ->fetchAll(); + // cast id to int specially for mssql + return \array_map(function (array $row): array { + $row['id'] = (int)$row['id']; + return $row; + }, $rows); + } + + private function makeTables(): void + { + // Make tables + $this->makeTable('user', [ + 'id' => 'primary', // autoincrement + 'username' => 'string', + 'age' => 'int', + ]); + + $this->makeTable('user_alias', [ + 'id' => 'primary', + 'value' => 'string', + 'user_id' => 'int', + ]); + $this->makeFK('user_alias', 'user_id', 'user', 'id', 'NO ACTION', 'NO ACTION'); + $this->makeIndex('user_alias', ['value'], true); + + $this->makeTable('user_email', [ + 'id' => 'primary', + 'value' => 'string', + 'user_id' => 'int', + ]); + $this->makeFK('user_email', 'user_id', 'user', 'id', 'NO ACTION', 'NO ACTION'); + $this->makeIndex('user_email', ['value'], true); + + $this->makeTable('user_phone', [ + 'id' => 'primary', + 'value' => 'string', + 'user_id' => 'int', + ]); + $this->makeFK('user_phone', 'user_id', 'user', 'id', 'NO ACTION', 'NO ACTION'); + $this->makeIndex('user_phone', ['value'], true); + } + + private function fillData(): void + { + $this->getDatabase()->table('user')->delete(); + $this->getDatabase()->table('user_alias')->delete(); + $this->getDatabase()->table('user_email')->delete(); + $this->getDatabase()->table('user_phone')->delete(); + + $this->getDatabase() + ->table('user') + ->insertOne([ + 'username' => 'nobody', + 'age' => 0, + ]); + } +} diff --git a/tests/ORM/Functional/Driver/Common/Integration/Issue380/Entity/User.php b/tests/ORM/Functional/Driver/Common/Integration/Issue380/Entity/User.php new file mode 100644 index 00000000..89750e5c --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Issue380/Entity/User.php @@ -0,0 +1,19 @@ +username = $username; + } +} diff --git a/tests/ORM/Functional/Driver/Common/Integration/Issue380/Entity/User/Alias.php b/tests/ORM/Functional/Driver/Common/Integration/Issue380/Entity/User/Alias.php new file mode 100644 index 00000000..f99924a6 --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Issue380/Entity/User/Alias.php @@ -0,0 +1,22 @@ +user = $user; + $this->value = $value; + } +} diff --git a/tests/ORM/Functional/Driver/Common/Integration/Issue380/Entity/User/Email.php b/tests/ORM/Functional/Driver/Common/Integration/Issue380/Entity/User/Email.php new file mode 100644 index 00000000..ae020fc0 --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Issue380/Entity/User/Email.php @@ -0,0 +1,22 @@ +user = $user; + $this->value = $value; + } +} diff --git a/tests/ORM/Functional/Driver/Common/Integration/Issue380/Entity/User/Phone.php b/tests/ORM/Functional/Driver/Common/Integration/Issue380/Entity/User/Phone.php new file mode 100644 index 00000000..afa73a30 --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Issue380/Entity/User/Phone.php @@ -0,0 +1,22 @@ +user = $user; + $this->value = $value; + } +} diff --git a/tests/ORM/Functional/Driver/Common/Integration/Issue380/schema.php b/tests/ORM/Functional/Driver/Common/Integration/Issue380/schema.php new file mode 100644 index 00000000..3762a923 --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Issue380/schema.php @@ -0,0 +1,174 @@ + [ + Schema::ENTITY => User::class, + Schema::MAPPER => Mapper::class, + Schema::SOURCE => Source::class, + Schema::DATABASE => 'default', + Schema::TABLE => 'user', + Schema::PRIMARY_KEY => ['id'], + Schema::FIND_BY_KEYS => ['id'], + Schema::COLUMNS => [ + 'id' => 'id', + 'username' => 'username', + 'age' => 'age', + ], + Schema::RELATIONS => [ + 'aliases' => [ + Relation::TYPE => Relation::HAS_MANY, + Relation::TARGET => 'alias', + Relation::LOAD => Relation::LOAD_PROMISE, + Relation::SCHEMA => [ + Relation::CASCADE => true, + Relation::NULLABLE => false, + Relation::INNER_KEY => ['id'], + Relation::OUTER_KEY => ['user_id'], + ], + ], + 'emails' => [ + Relation::TYPE => Relation::HAS_MANY, + Relation::TARGET => 'email', + Relation::LOAD => Relation::LOAD_PROMISE, + Relation::SCHEMA => [ + Relation::CASCADE => true, + Relation::NULLABLE => false, + Relation::INNER_KEY => 'id', + Relation::OUTER_KEY => ['user_id'], + ], + ], + 'phones' => [ + Relation::TYPE => Relation::BELONGS_TO, + Relation::TARGET => 'phone', + Relation::LOAD => Relation::LOAD_PROMISE, + Relation::SCHEMA => [ + Relation::CASCADE => true, + Relation::NULLABLE => false, + Relation::INNER_KEY => 'id', + Relation::OUTER_KEY => ['user_id'], + ], + ], + ], + Schema::TYPECAST => [ + 'id' => 'int', + 'age' => 'int', + ], + Schema::SCHEMA => [], + ], + + 'alias' => [ + Schema::ENTITY => User\Alias::class, + Schema::SOURCE => Source::class, + Schema::DATABASE => 'default', + Schema::MAPPER => Mapper::class, + Schema::TABLE => 'user_alias', + Schema::PRIMARY_KEY => ['id'], + Schema::FIND_BY_KEYS => ['id'], + Schema::COLUMNS => [ + 'id' => 'id', + 'value' => 'value', + 'user_id' => 'user_id', + ], + Schema::RELATIONS => [ + 'user' => [ + Relation::TYPE => Relation::BELONGS_TO, + Relation::TARGET => 'user', + Relation::LOAD => Relation::LOAD_PROMISE, + Relation::SCHEMA => [ + Relation::CASCADE => true, + Relation::NULLABLE => false, + Relation::INNER_KEY => 'user_id', + Relation::OUTER_KEY => ['id'], + ], + ], + ], + Schema::TYPECAST => [ + 'id' => 'int', + 'value' => 'string', + 'user_id' => 'int', + ], + Schema::SCHEMA => [], + ], + + 'email' => [ + Schema::ENTITY => User\Email::class, + Schema::MAPPER => Mapper::class, + Schema::SOURCE => Source::class, + Schema::REPOSITORY => Repository::class, + Schema::DATABASE => 'default', + Schema::TABLE => 'user_email', + Schema::PRIMARY_KEY => ['id'], + Schema::FIND_BY_KEYS => ['id'], + Schema::COLUMNS => [ + 'id' => 'id', + 'value' => 'value', + 'user_id' => 'user_id', + ], + Schema::RELATIONS => [ + 'user' => [ + Relation::TYPE => Relation::BELONGS_TO, + Relation::TARGET => 'user', + Relation::LOAD => Relation::LOAD_PROMISE, + Relation::SCHEMA => [ + Relation::CASCADE => true, + Relation::NULLABLE => false, + Relation::INNER_KEY => 'user_id', + Relation::OUTER_KEY => ['id'], + ], + ], + ], + Schema::SCOPE => null, + Schema::TYPECAST => [ + 'id' => 'int', + 'value' => 'string', + 'user_id' => 'int', + ], + Schema::SCHEMA => [], + ], + + 'phone' => [ + Schema::ENTITY => User\Phone::class, + Schema::MAPPER => Mapper::class, + Schema::SOURCE => Source::class, + Schema::REPOSITORY => Repository::class, + Schema::DATABASE => 'default', + Schema::TABLE => 'user_phone', + Schema::PRIMARY_KEY => ['id'], + Schema::FIND_BY_KEYS => ['id'], + Schema::COLUMNS => [ + 'id' => 'id', + 'value' => 'value', + 'user_id' => 'user_id', + ], + Schema::RELATIONS => [ + 'user' => [ + Relation::TYPE => Relation::BELONGS_TO, + Relation::TARGET => 'user', + Relation::LOAD => Relation::LOAD_PROMISE, + Relation::SCHEMA => [ + Relation::CASCADE => true, + Relation::NULLABLE => false, + Relation::INNER_KEY => 'user_id', + Relation::OUTER_KEY => ['id'], + ], + ], + ], + Schema::SCOPE => null, + Schema::TYPECAST => [ + 'id' => 'int', + 'value' => 'string', + 'user_id' => 'int', + ], + Schema::SCHEMA => [], + ], + +]; diff --git a/tests/ORM/Functional/Driver/MySQL/Integration/Issue380/CaseTest.php b/tests/ORM/Functional/Driver/MySQL/Integration/Issue380/CaseTest.php new file mode 100644 index 00000000..3e092646 --- /dev/null +++ b/tests/ORM/Functional/Driver/MySQL/Integration/Issue380/CaseTest.php @@ -0,0 +1,17 @@ +assertCount(0, $storage); + $this->assertFalse($storage->contains($entity)); + $this->expectException(\Throwable::class); + $storage->getTuple(new \stdClass()); + } + + public function testAttachedEntity(): void + { + $storage = new TupleStorage(); + $tuple = $this->createTuple($entity = new \stdClass()); + + $storage->attach($tuple); + + self::assertCount(1, $storage); + self::assertTrue($storage->contains($entity)); + self::assertSame($tuple, $storage->getTuple($entity)); + + $storage->detach($entity); + + self::assertCount(0, $storage); + self::assertFalse($storage->contains($entity)); + $this->expectException(\Throwable::class); + $storage->getTuple($entity); + } + + public function testSequence(): void + { + $storage = new TupleStorage(); + $tuples = []; + for ($i = 0; $i < 100; $i++) { + $tuples[] = $this->createTuple(new \stdClass()); + } + // Randomize the order + \shuffle($tuples); + + // Store all + foreach ($tuples as $tuple) { + $storage->attach($tuple); + } + // and detach each second + $toRestore = []; + foreach ($tuples as $k => $tuple) { + if ($k % 2 === 0) { + $storage->detach($tuple->entity); + unset($tuples[$k]); + } + } + // and return each fourth + foreach ($toRestore as $k => $tuple) { + if ($k % 2 === 0) { + $storage->attach($tuple); + $tuples[] = $tuple; + } + } + + self::assertCount(\count($tuples), $storage); + foreach ($tuples as $tuple) { + self::assertTrue($storage->contains($tuple->entity)); + self::assertSame($tuple, $storage->getTuple($tuple->entity)); + } + + $collection = []; + foreach ($storage as $entity => $tuple) { + self::assertSame($entity, $tuple->entity); + $collection[] = $tuple; + } + + self::assertSame(\array_values($tuples), $collection); + } + + public function testAddItemsWhenIterating(): void + { + $storage = new TupleStorage(); + for ($i = 0; $i < 10; $i++) { + $storage->attach($this->createTuple(new \stdClass())); + } + + /** @see TupleStorage::$iterators */ + self::assertCount(0, (fn(): array => $this->iterators)->call($storage)); + + $iterator = $storage->getIterator(); + // Start generator + foreach ($iterator as $item) { + break; + } + /** @see TupleStorage::$iterators */ + self::assertCount(1, (fn(): array => $this->iterators)->call($storage)); + + // Cleanup on iterator destruction + unset($iterator); + /** @see TupleStorage::$iterators */ + self::assertCount(0, (fn(): array => $this->iterators)->call($storage)); + + // Cleanup on end of iteration + $iterator = $storage->getIterator(); + // Start generator + foreach ($iterator as $item) { + // do nothing + } + /** @see TupleStorage::$iterators */ + self::assertCount(0, (fn(): array => $this->iterators)->call($storage)); + } + + public function testDetachWhenIterating(): void + { + $storage = new TupleStorage(); + $tuple1 = $this->createTuple((object)['value' => 1]); + $tuple2 = $this->createTuple((object)['value' => 2]); + $tuple3 = $this->createTuple((object)['value' => 3]); + $tuple4 = $this->createTuple((object)['value' => 4]); + + $storage->attach($tuple1); + $storage->attach($tuple2); + $storage->attach($tuple3); + $storage->attach($tuple4); + + $collection = []; + foreach ($storage as $tuple) { + $collection[] = $tuple; + self::assertTrue($storage->contains($tuple->entity)); + self::assertSame($tuple, $storage->getTuple($tuple->entity)); + + if ($tuple === $tuple2) { + $storage->detach($tuple3->entity); + } + } + self::assertCount(3, $storage); + self::assertSame([$tuple1, $tuple2, $tuple4], $collection); + } + + public function testCleanupIteratorState(): void + { + $storage = new TupleStorage(); + $tuple1 = $this->createTuple((object)['value' => 1]); + $tuple2 = $this->createTuple((object)['value' => 2]); + $tuple3 = $this->createTuple((object)['value' => 3]); + $tuple4 = $this->createTuple((object)['value' => 4]); + + $storage->attach($tuple1); + $storage->attach($tuple2); + $storage->attach($tuple3); + $storage->attach($tuple4); + + $collection = []; + foreach ($storage as $tuple) { + $collection[] = $tuple; + self::assertTrue($storage->contains($tuple->entity)); + self::assertSame($tuple, $storage->getTuple($tuple->entity)); + + if ($tuple === $tuple2) { + $storage->detach($tuple3->entity); + } + } + self::assertCount(3, $storage); + self::assertSame([$tuple1, $tuple2, $tuple4], $collection); + } + + public function testParallelIterators(): void + { + $storage = new TupleStorage(); + for ($i = 0; $i < 5; $i++) { + $tuple = $this->createTuple(new \stdClass()); + $storage->attach($tuple); + } + + /** @var \Generator $iterator1 */ + $iterator1 = $storage->getIterator(); + + $i = 0; + foreach ($storage as $tuple) { + self::assertTrue($iterator1->valid()); + self::assertSame($tuple, $iterator1->current()); + + if (++$i % 2 === 0) { + $storage->attach($this->createTuple(new \stdClass())); + } + $iterator1->next(); + } + } + + private function createTuple(object $entity): Tuple + { + return new Tuple(Tuple::TASK_STORE, $entity, true, Tuple::STATUS_PREPARING); + } +}