diff --git a/CHANGELOG.md b/CHANGELOG.md index a977db501..10090c6ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # CHANGELOG +## [v2.3.5](https://github.com/zenstruck/foundry/releases/tag/v2.3.5) + +February 24th, 2025 - [v2.3.4...v2.3.5](https://github.com/zenstruck/foundry/compare/v2.3.4...v2.3.5) + +* fbf0981 fix: actually disable persistence cascade (#817) by @nikophil +* 2426f3e fix: trigger after persist callbacks for entities scheduled for insert (#822) by @nikophil + ## [v2.3.4](https://github.com/zenstruck/foundry/releases/tag/v2.3.4) February 14th, 2025 - [v2.3.3...v2.3.4](https://github.com/zenstruck/foundry/compare/v2.3.3...v2.3.4) diff --git a/bin/tools/phpstan/composer.lock b/bin/tools/phpstan/composer.lock index d1e48b6e7..597484faf 100644 --- a/bin/tools/phpstan/composer.lock +++ b/bin/tools/phpstan/composer.lock @@ -122,16 +122,16 @@ }, { "name": "phpstan/phpstan", - "version": "2.0.3", + "version": "2.1.6", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "46b4d3529b12178112d9008337beda0cc2a1a6b4" + "reference": "6eaec7c6c9e90dcfe46ad1e1ffa5171e2dab641c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/46b4d3529b12178112d9008337beda0cc2a1a6b4", - "reference": "46b4d3529b12178112d9008337beda0cc2a1a6b4", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/6eaec7c6c9e90dcfe46ad1e1ffa5171e2dab641c", + "reference": "6eaec7c6c9e90dcfe46ad1e1ffa5171e2dab641c", "shasum": "" }, "require": { @@ -176,7 +176,7 @@ "type": "github" } ], - "time": "2024-11-28T22:19:37+00:00" + "time": "2025-02-19T15:46:42+00:00" }, { "name": "phpstan/phpstan-doctrine", @@ -251,21 +251,21 @@ }, { "name": "phpstan/phpstan-phpunit", - "version": "2.0.1", + "version": "2.0.4", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-phpunit.git", - "reference": "4b6ad7fab8683ff4efd7887ba26ef8ee171c7475" + "reference": "d09e152f403c843998d7a52b5d87040c937525dd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/4b6ad7fab8683ff4efd7887ba26ef8ee171c7475", - "reference": "4b6ad7fab8683ff4efd7887ba26ef8ee171c7475", + "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/d09e152f403c843998d7a52b5d87040c937525dd", + "reference": "d09e152f403c843998d7a52b5d87040c937525dd", "shasum": "" }, "require": { "php": "^7.4 || ^8.0", - "phpstan/phpstan": "^2.0" + "phpstan/phpstan": "^2.0.4" }, "conflict": { "phpunit/phpunit": "<7.0" @@ -296,28 +296,28 @@ "description": "PHPUnit extensions and rules for PHPStan", "support": { "issues": "https://github.com/phpstan/phpstan-phpunit/issues", - "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.1" + "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.4" }, - "time": "2024-11-12T12:48:00+00:00" + "time": "2025-01-22T13:07:38+00:00" }, { "name": "phpstan/phpstan-symfony", - "version": "2.0.0", + "version": "2.0.2", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-symfony.git", - "reference": "1ef4dce2baabd464c2dd3109d051bad94efa1e79" + "reference": "65f02c7e585f3c7372e42e14d3d87da034031553" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/1ef4dce2baabd464c2dd3109d051bad94efa1e79", - "reference": "1ef4dce2baabd464c2dd3109d051bad94efa1e79", + "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/65f02c7e585f3c7372e42e14d3d87da034031553", + "reference": "65f02c7e585f3c7372e42e14d3d87da034031553", "shasum": "" }, "require": { "ext-simplexml": "*", "php": "^7.4 || ^8.0", - "phpstan/phpstan": "^2.0" + "phpstan/phpstan": "^2.1.2" }, "conflict": { "symfony/framework-bundle": "<3.0" @@ -367,9 +367,9 @@ "description": "Symfony Framework extensions and rules for PHPStan", "support": { "issues": "https://github.com/phpstan/phpstan-symfony/issues", - "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.0" + "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.2" }, - "time": "2024-11-06T10:13:40+00:00" + "time": "2025-01-21T18:57:07+00:00" } ], "packages-dev": [], diff --git a/phpstan.neon b/phpstan.neon index 0025a4e03..04b821bc1 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -38,7 +38,6 @@ parameters: - identifier: property.readOnlyByPhpDocDefaultValue paths: - src/Object/Hydrator.php - - src/Factory.php - src/ObjectFactory.php - src/Persistence/PersistentObjectFactory.php diff --git a/src/Factory.php b/src/Factory.php index c4489582a..54e8ce061 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -25,13 +25,6 @@ */ abstract class Factory { - /** - * Memoization of normalized parameters. - * - * @internal - * @var Parameters|null - */ - protected ?array $normalizedParameters = null; /** @phpstan-var Attributes[] */ private array $attributes; @@ -208,7 +201,7 @@ protected function initializeInternal(): static */ protected function normalizeParameters(array $parameters): array { - return $this->normalizedParameters = \array_combine( + return \array_combine( \array_keys($parameters), \array_map($this->normalizeParameter(...), \array_keys($parameters), $parameters) ); diff --git a/src/Persistence/IsProxy.php b/src/Persistence/IsProxy.php index 0c140b2b4..868cf718a 100644 --- a/src/Persistence/IsProxy.php +++ b/src/Persistence/IsProxy.php @@ -97,12 +97,14 @@ public function _set(string $property, mixed $value): static return $this; } - public function _real(): object + public function _real(bool $withAutoRefresh = true): object { - try { - // we don't want the auto-refresh mechanism to break "real" object retrieval - $this->_autoRefresh(); - } catch (\Throwable) { + if ($withAutoRefresh) { + try { + // we don't want the auto-refresh mechanism to break "real" object retrieval + $this->_autoRefresh(); + } catch (\Throwable) { + } } return $this->initializeLazyObject(); diff --git a/src/Persistence/PersistenceManager.php b/src/Persistence/PersistenceManager.php index aaf4d621f..13c6002de 100644 --- a/src/Persistence/PersistenceManager.php +++ b/src/Persistence/PersistenceManager.php @@ -31,6 +31,9 @@ final class PersistenceManager private bool $flush = true; private bool $persist = true; + /** @var list */ + private array $afterPersistCallbacks = []; + /** * @param iterable $strategies */ @@ -72,17 +75,28 @@ public function save(object $object): object $om->persist($object); $this->flush($om); + if ($this->afterPersistCallbacks) { + foreach ($this->afterPersistCallbacks as $afterPersistCallback) { + $afterPersistCallback(); + } + + $this->afterPersistCallbacks = []; + + $this->save($object); + } + return $object; } /** * @template T of object * - * @param T $object + * @param T $object + * @param list $afterPersistCallbacks * * @return T */ - public function scheduleForInsert(object $object): object + public function scheduleForInsert(object $object, array $afterPersistCallbacks = []): object { if ($object instanceof Proxy) { $object = unproxy($object); @@ -91,6 +105,8 @@ public function scheduleForInsert(object $object): object $om = $this->strategyFor($object::class)->objectManagerFor($object::class); $om->persist($object); + $this->afterPersistCallbacks = [...$this->afterPersistCallbacks, ...$afterPersistCallbacks]; + return $object; } diff --git a/src/Persistence/PersistentObjectFactory.php b/src/Persistence/PersistentObjectFactory.php index 3ce4e6064..9f84187cb 100644 --- a/src/Persistence/PersistentObjectFactory.php +++ b/src/Persistence/PersistentObjectFactory.php @@ -218,16 +218,6 @@ public function create(callable|array $attributes = []): object $configuration->persistence()->save($object); - if ($this->afterPersist) { - $attributes = $this->normalizedParameters ?? throw new \LogicException('Factory::$normalizedParameters has not been initialized.'); - - foreach ($this->afterPersist as $callback) { - $callback($object, $attributes, $this); - } - - $configuration->persistence()->save($object); - } - return $object; } @@ -310,8 +300,7 @@ protected function normalizeParameter(string $field, mixed $value): mixed // auto-refresh computes changeset and prevents the placeholder object to be cleanly // forgotten fom the persistence manager if ($inversedObject instanceof Proxy) { - $inversedObject->_disableAutoRefresh(); - $inversedObject = $inversedObject->_real(); + $inversedObject = $inversedObject->_real(withAutoRefresh: false); } $this->tempAfterInstantiate[] = static function(object $object) use ($inversedObject, $inverseField, $pm, $placeholder) { @@ -363,36 +352,26 @@ protected function normalizeCollection(string $field, FactoryCollection $collect } /** + * This method will try to find entities in database if they are detached. + * * @internal */ protected function normalizeObject(object $object): object { - $reflectionClass = new \ReflectionClass($object::class); - - if ($reflectionClass->isFinal()) { - return $object; - } - - // readonly classes exist since php 8.2 and proxyHelper supports them since 8.3 - if (80200 <= \PHP_VERSION_ID && \PHP_VERSION_ID < 80300 && $reflectionClass->isReadonly()) { - return $object; - } - $configuration = Configuration::instance(); - if (!$configuration->isPersistenceAvailable()) { + if ( + !$this->isPersisting() + || !$configuration->isPersistenceAvailable() + ) { return $object; } - $persistenceManager = $configuration->persistence(); - if ($object instanceof Proxy) { - $proxy = $object; - $proxy->_disableAutoRefresh(); - $object = $proxy->_real(); - $proxy->_enableAutoRefresh(); + $object = $object->_real(withAutoRefresh: false); } + $persistenceManager = $configuration->persistence(); if (!$persistenceManager->hasPersistenceFor($object)) { return $object; } @@ -429,7 +408,15 @@ static function(object $object, array $parameters, PersistentObjectFactory $fact return; } - Configuration::instance()->persistence()->scheduleForInsert($object); + $afterPersistCallbacks = []; + + foreach ($factoryUsed->afterPersist as $afterPersist) { + $afterPersistCallbacks[] = static function() use ($object, $afterPersist, $parameters, $factoryUsed): void { + $afterPersist($object, $parameters, $factoryUsed); + }; + } + + Configuration::instance()->persistence()->scheduleForInsert($object, $afterPersistCallbacks); } ); diff --git a/src/Persistence/Proxy.php b/src/Persistence/Proxy.php index 637e0e2ff..687eea52d 100644 --- a/src/Persistence/Proxy.php +++ b/src/Persistence/Proxy.php @@ -69,7 +69,7 @@ public function _set(string $property, mixed $value): static; /** * @return T */ - public function _real(): object; + public function _real(bool $withAutoRefresh = true): object; /** * @psalm-return T&Proxy diff --git a/tests/Fixture/Maker/expected/can_create_factory_for_entity_with_repository.php b/tests/Fixture/Maker/expected/can_create_factory_for_entity_with_repository.php index f9e415086..496868d36 100644 --- a/tests/Fixture/Maker/expected/can_create_factory_for_entity_with_repository.php +++ b/tests/Fixture/Maker/expected/can_create_factory_for_entity_with_repository.php @@ -78,6 +78,7 @@ protected function defaults(): array|callable { return [ 'prop1' => self::faker()->text(), + 'propInteger' => self::faker()->randomNumber(), ]; } diff --git a/tests/Fixture/Maker/expected/can_create_factory_with_all_fields.php b/tests/Fixture/Maker/expected/can_create_factory_with_all_fields.php index 32393af80..02af9c43d 100644 --- a/tests/Fixture/Maker/expected/can_create_factory_with_all_fields.php +++ b/tests/Fixture/Maker/expected/can_create_factory_with_all_fields.php @@ -43,6 +43,7 @@ protected function defaults(): array|callable return [ 'date' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()), 'prop1' => self::faker()->text(), + 'propInteger' => self::faker()->randomNumber(), ]; } diff --git a/tests/Fixture/Model/GenericModel.php b/tests/Fixture/Model/GenericModel.php index 9a288f3fc..afd247151 100644 --- a/tests/Fixture/Model/GenericModel.php +++ b/tests/Fixture/Model/GenericModel.php @@ -33,6 +33,10 @@ abstract class GenericModel #[MongoDB\Field(type: 'string')] private string $prop1; + #[ORM\Column] + #[MongoDB\Field(type: 'int')] + private int $propInteger = 0; + #[ORM\Column(nullable: true)] #[MongoDB\Field(type: 'date_immutable', nullable: true)] private ?\DateTimeImmutable $date = null; @@ -52,6 +56,16 @@ public function setProp1(string $prop1): void $this->prop1 = $prop1; } + public function getPropInteger(): int + { + return $this->propInteger; + } + + public function setPropInteger(int $propInteger): void + { + $this->propInteger = $propInteger; + } + public function getDate(): ?\DateTimeImmutable { return $this->date; diff --git a/tests/Integration/ORM/EntityRelationship/EntityFactoryRelationshipTestCase.php b/tests/Integration/ORM/EntityRelationship/EntityFactoryRelationshipTestCase.php index 8ec50a06c..bb64c6e29 100644 --- a/tests/Integration/ORM/EntityRelationship/EntityFactoryRelationshipTestCase.php +++ b/tests/Integration/ORM/EntityRelationship/EntityFactoryRelationshipTestCase.php @@ -308,6 +308,9 @@ public function disabling_persistence_cascades_to_children(): void 'category' => static::categoryFactory(), ]); + // ensure nothing was persisted in Doctrine by flushing + self::getContainer()->get(EntityManagerInterface::class)->flush(); // @phpstan-ignore method.notFound + static::contactFactory()::assert()->empty(); static::categoryFactory()::assert()->empty(); static::tagFactory()::assert()->empty(); @@ -400,6 +403,75 @@ public function it_can_add_unmanaged_entity_to_many_to_one(): void ); } + /** @test */ + public function it_uses_after_persist_with_many_to_many(): void + { + $contact = static::contactFactory() + ->with( + [ + 'tags' => static::tagFactory() + ->afterPersist(static function(Tag $tag) {$tag->setName('foobar'); }) + ->many(1), + ] + ) + ->create(); + + self::assertEquals('foobar', $contact->getTags()[0]?->getName()); + } + + /** @test */ + public function it_uses_after_persist_with_one_to_many(): void + { + $category = static::categoryFactory() + ->with([ + 'contacts' => static::contactFactory() + ->afterPersist(static function(Contact $contact) { + $contact->setName('foobar'); + }) + ->many(1), + ])->create(); + + self::assertEquals('foobar', $category->getContacts()[0]?->getName()); + } + + /** @test */ + public function it_uses_after_persist_with_many_to_one(): void + { + $contact = static::contactFactory() + ->with([ + 'category' => static::categoryFactory() + ->afterPersist(static function(Category $category) { + $category->setName('foobar'); + }), + ])->create(); + + self::assertEquals('foobar', $contact->getCategory()?->getName()); + } + + /** @test */ + public function it_uses_after_persist_with_one_to_one(): void + { + $contact = static::contactFactory() + ->with([ + 'address' => static::addressFactory() + ->afterPersist(static function(Address $address) {$address->setCity('foobar'); }), + ])->create(); + + self::assertEquals('foobar', $contact->getAddress()->getCity()); + } + + /** @test */ + public function it_uses_after_persist_with_inversed_one_to_one(): void + { + $address = static::addressFactory() + ->with([ + 'contact' => static::contactFactory() + ->afterPersist(static function(Contact $contact) {$contact->setName('foobar'); }), + ])->create(); + + self::assertEquals('foobar', $address->getContact()?->getName()); + } + /** @return PersistentObjectFactory */ protected static function contactFactoryWithoutCategory(): PersistentObjectFactory { diff --git a/tests/Integration/Persistence/GenericFactoryTestCase.php b/tests/Integration/Persistence/GenericFactoryTestCase.php index de4b4c2ef..4163cbadd 100644 --- a/tests/Integration/Persistence/GenericFactoryTestCase.php +++ b/tests/Integration/Persistence/GenericFactoryTestCase.php @@ -14,6 +14,7 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Zenstruck\Foundry\Configuration; use Zenstruck\Foundry\Exception\PersistenceDisabled; +use Zenstruck\Foundry\Object\Instantiator; use Zenstruck\Foundry\Persistence\Exception\NotEnoughObjects; use Zenstruck\Foundry\Persistence\PersistentObjectFactory; use Zenstruck\Foundry\Persistence\ProxyGenerator; @@ -544,6 +545,25 @@ public function it_should_not_create_proxy_for_not_persistable_objects(): void self::assertFalse(\class_exists(ProxyGenerator::proxyClassNameFor(\DateTimeImmutable::class))); } + /** + * @test + */ + public function can_use_after_persist_with_attributes(): void + { + $object = static::factory() + ->instantiateWith(Instantiator::withConstructor()->allowExtra('extra')) + ->afterPersist(function(GenericModel $object, array $attributes) { + $object->setProp1($attributes['extra']); + $object->setPropInteger($object->getPropInteger() + 1); + }) + ->create(['extra' => $value = 'value set with after persist']); + + $this->assertSame($value, $object->getProp1()); + + // ensure after persist is only called once + $this->assertSame(1, $object->getPropInteger()); + } + /** * @return class-string */ diff --git a/tests/Integration/Persistence/GenericProxyFactoryTestCase.php b/tests/Integration/Persistence/GenericProxyFactoryTestCase.php index 014ed2a0f..66da90b39 100644 --- a/tests/Integration/Persistence/GenericProxyFactoryTestCase.php +++ b/tests/Integration/Persistence/GenericProxyFactoryTestCase.php @@ -267,21 +267,6 @@ public function can_delete_proxified_object_and_still_access_its_methods(): void $this->assertSame('default1', $object->getProp1()); } - /** - * @test - */ - public function can_use_after_persist_with_attributes(): void - { - $object = static::factory() - ->instantiateWith(Instantiator::withConstructor()->allowExtra('extra')) - ->afterPersist(function(GenericModel $object, array $attributes) { - $object->setProp1($attributes['extra']); - }) - ->create(['extra' => $value = 'value set with after persist']); - - $this->assertSame($value, $object->getProp1()); - } - /** * @test */