Skip to content
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# CHANGELOG

## [v2.3.6](https://github.com/zenstruck/foundry/releases/tag/v2.3.6)

February 25th, 2025 - [v2.3.5...v2.3.6](https://github.com/zenstruck/foundry/compare/v2.3.5...v2.3.6)

* 300645b fix: can call ->create() in after persist callback (#833) by @nikophil

## [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)
Expand Down
2 changes: 1 addition & 1 deletion phpunit
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ SHOULD_UPDATE_PHPUNIT=$(check_phpunit_version "${PHPUNIT_VERSION}")

if [ "${SHOULD_UPDATE_PHPUNIT}" = "0" ]; then
echo "ℹ️ Upgrading PHPUnit to ${PHPUNIT_VERSION}"
composer update brianium/paratest "phpunit/phpunit:^${PHPUNIT_VERSION}" -W
composer update dama/doctrine-test-bundle brianium/paratest "phpunit/phpunit:^${PHPUNIT_VERSION}" -W
fi
### <<

Expand Down
4 changes: 2 additions & 2 deletions src/ObjectFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public function __construct()
{
parent::__construct();

$this->validationEnabled = Configuration::instance()->validationEnabled;
$this->validationEnabled = Configuration::isBooted() && Configuration::instance()->validationEnabled;
}

/**
Expand Down Expand Up @@ -194,7 +194,7 @@ public function getValidationGroups(): string|GroupSequence|array|null
*/
protected function initializeInternal(): static
{
if (!Configuration::instance()->hasEventDispatcher()) {
if (!Configuration::isBooted() || !Configuration::instance()->hasEventDispatcher()) {
return $this;
}

Expand Down
58 changes: 32 additions & 26 deletions src/Persistence/PersistentObjectFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Doctrine\Persistence\ObjectRepository;
use Symfony\Component\VarExporter\Exception\LogicException as VarExportLogicException;
use Zenstruck\Foundry\Configuration;
use Zenstruck\Foundry\Exception\FoundryNotBooted;
use Zenstruck\Foundry\Exception\PersistenceDisabled;
use Zenstruck\Foundry\Exception\PersistenceNotAvailable;
use Zenstruck\Foundry\Factory;
Expand Down Expand Up @@ -48,7 +49,7 @@ public function __construct()
{
parent::__construct();

$this->persist = Configuration::instance()->isPersistenceEnabled() ? PersistMode::PERSIST : PersistMode::WITHOUT_PERSISTING;
$this->persist = $this->isPersistenceEnabled() ? PersistMode::PERSIST : PersistMode::WITHOUT_PERSISTING;
}

/**
Expand Down Expand Up @@ -271,17 +272,11 @@ final public function afterPersist(callable $callback): static
*/
public function persistMode(): PersistMode
{
return Configuration::instance()->isPersistenceEnabled() ? $this->persist : PersistMode::WITHOUT_PERSISTING;
return $this->isPersistenceEnabled() ? $this->persist : PersistMode::WITHOUT_PERSISTING;
}

final public function isPersisting(): bool
{
$config = Configuration::instance();

if (!$config->isPersistenceEnabled()) {
return false;
}

return $this->persistMode()->isPersisting();
}

Expand All @@ -304,16 +299,17 @@ protected function normalizeParameter(string $field, mixed $value): mixed
if ($inversedRelationshipMetadata && !$inversedRelationshipMetadata->isCollection) {
$inverseField = $inversedRelationshipMetadata->inverseField;

// we need to handle the circular dependency involved by inversed one-to-one relationship:
// a placeholder object is used, which will be replaced by the real object, after its instantiation
$inversedObject = $value->withPersistMode(PersistMode::NO_PERSIST_BUT_SCHEDULE_FOR_INSERT)
->create([$inverseField => $placeholder = (new \ReflectionClass(static::class()))->newInstanceWithoutConstructor()]);
$inversedObject = $value->withPersistMode(
$this->isPersisting() ? PersistMode::NO_PERSIST_BUT_SCHEDULE_FOR_INSERT : PersistMode::WITHOUT_PERSISTING
)

// auto-refresh computes changeset and prevents the placeholder object to be cleanly
// forgotten fom the persistence manager
if ($inversedObject instanceof Proxy) {
$inversedObject = $inversedObject->_real(withAutoRefresh: false);
}
// we need to handle the circular dependency involved by inversed one-to-one relationship:
// a placeholder object is used, which will be replaced by the real object, after its instantiation
->create([
$inverseField => $placeholder = (new \ReflectionClass(static::class()))->newInstanceWithoutConstructor(),
]);

$inversedObject = unproxy($inversedObject, withAutoRefresh: false);

$this->tempAfterInstantiate[] = static function(object $object) use ($inversedObject, $inverseField, $pm, $placeholder) {
$pm->forget($placeholder);
Expand All @@ -324,12 +320,12 @@ protected function normalizeParameter(string $field, mixed $value): mixed
}
}

return unproxy(parent::normalizeParameter($field, $value));
return unproxy(parent::normalizeParameter($field, $value), withAutoRefresh: false);
}

protected function normalizeCollection(string $field, FactoryCollection $collection): array
{
if (!$this->isPersisting() || !$collection->factory instanceof self) {
if (!Configuration::instance()->isPersistenceAvailable() || !$collection->factory instanceof self) {
return parent::normalizeCollection($field, $collection);
}

Expand All @@ -338,12 +334,15 @@ protected function normalizeCollection(string $field, FactoryCollection $collect
$inverseRelationshipMetadata = $pm->inverseRelationshipMetadata(static::class(), $collection->factory::class(), $field);

if ($inverseRelationshipMetadata && $inverseRelationshipMetadata->isCollection) {
$this->tempAfterInstantiate[] = static function(object $object) use ($collection, $inverseRelationshipMetadata, $field) {
$this->tempAfterInstantiate[] = function(object $object) use ($collection, $inverseRelationshipMetadata, $field) {
$inverseField = $inverseRelationshipMetadata->inverseField;

$inverseObjects = $collection->withPersistMode(PersistMode::NO_PERSIST_BUT_SCHEDULE_FOR_INSERT)->create([$inverseField => $object]);
$inverseObjects = $collection->withPersistMode(
$this->isPersisting() ? PersistMode::NO_PERSIST_BUT_SCHEDULE_FOR_INSERT : PersistMode::WITHOUT_PERSISTING
)
->create([$inverseField => $object]);

$inverseObjects = unproxy($inverseObjects);
$inverseObjects = unproxy($inverseObjects, withAutoRefresh: false);

// if the collection is indexed by a field, index the array
if ($inverseRelationshipMetadata->collectionIndexedBy) {
Expand Down Expand Up @@ -379,9 +378,7 @@ protected function normalizeObject(object $object): object
return $object;
}

if ($object instanceof Proxy) {
$object = $object->_real(withAutoRefresh: false);
}
$object = unproxy($object, withAutoRefresh: false);

$persistenceManager = $configuration->persistence();
if (!$persistenceManager->hasPersistenceFor($object)) {
Expand Down Expand Up @@ -421,7 +418,7 @@ static function(object $object, array $parameters, PersistentObjectFactory $fact
}
);

if (!Configuration::instance()->hasEventDispatcher()) {
if (!Configuration::isBooted() || !Configuration::instance()->hasEventDispatcher()) {
return $factory;
}

Expand Down Expand Up @@ -456,4 +453,13 @@ private function throwIfCannotCreateObject(): void

throw new \LogicException(\sprintf('Cannot create object in a data provider for non-proxy factories. Transform your factory into a "%s", or call "create()" method in the test. See https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#phpunit-data-providers', PersistentProxyObjectFactory::class));
}

private function isPersistenceEnabled(): bool
{
try {
return Configuration::instance()->isPersistenceEnabled();
} catch (FoundryNotBooted) {
return false;
}
}
}
6 changes: 3 additions & 3 deletions src/Persistence/ProxyGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,18 +66,18 @@ public static function wrapFactory(PersistentProxyObjectFactory $factory, callab
*
* @return T
*/
public static function unwrap(mixed $what): mixed
public static function unwrap(mixed $what, bool $withAutoRefresh = true): mixed
{
if (\is_array($what)) {
return \array_map(self::unwrap(...), $what); // @phpstan-ignore return.type
return \array_map(static fn(mixed $w) => self::unwrap($w, $withAutoRefresh), $what); // @phpstan-ignore return.type
}

if (\is_string($what) && \is_a($what, Proxy::class, true)) {
return \get_parent_class($what) ?: throw new \LogicException('Could not unwrap proxy.'); // @phpstan-ignore return.type
}

if ($what instanceof Proxy) {
return $what->_real(); // @phpstan-ignore return.type
return $what->_real($withAutoRefresh); // @phpstan-ignore return.type
}

return $what;
Expand Down
4 changes: 2 additions & 2 deletions src/Persistence/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,9 @@ function proxy(object $object): object
*
* @return T
*/
function unproxy(mixed $what): mixed
function unproxy(mixed $what, bool $withAutoRefresh = true): mixed
{
return ProxyGenerator::unwrap($what);
return ProxyGenerator::unwrap($what, $withAutoRefresh);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use PHPUnit\Framework\Attributes\DataProvider;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Zenstruck\Foundry\Configuration;
use Zenstruck\Foundry\Persistence\PersistenceManager;
use Zenstruck\Foundry\Tests\Integration\RequiresORM;

Expand Down Expand Up @@ -123,6 +124,8 @@ public static function provideCascadeRelationshipsCombinations(): iterable
}

yield from DoctrineCascadeRelationshipMetadata::allCombinations($relationshipFields);

Configuration::shutdown();
}

public static function setCurrentProvidedMethodName(string $methodName): void
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -323,11 +323,21 @@ public function disabling_persistence_cascades_to_children(): void
foreach ($contact->getTags() as $tag) {
$this->assertNull($tag->id);
}
}

/** @test */
#[Test]
#[DataProvider('provideCascadeRelationshipsCombinations')]
#[UsingRelationships(Contact::class, ['category'])]
public function disabling_persistence_cascades_to_children_one_to_many(): void
{
$category = static::categoryFactory()->withoutPersisting()->create([
'contacts' => static::contactFactory()->many(3),
]);

// 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();

Expand All @@ -339,6 +349,27 @@ public function disabling_persistence_cascades_to_children(): void
}
}

/** @test */
#[Test]
#[DataProvider('provideCascadeRelationshipsCombinations')]
#[UsingRelationships(Contact::class, ['address'])]
public function disabling_persistence_cascades_to_children_inversed_one_to_one(): void
{
$address = static::addressFactory()->withoutPersisting()->create([
'contact' => static::contactFactory(),
]);

// ensure nothing was persisted in Doctrine by flushing
self::getContainer()->get(EntityManagerInterface::class)->flush(); // @phpstan-ignore method.notFound

static::contactFactory()::assert()->empty();
static::addressFactory()::assert()->empty();

$this->assertNull($address->id);
$this->assertInstanceOf(Contact::class, $address->getContact());
$this->assertNull($address->getContact()->id);
}

/** @test */
#[Test]
#[DataProvider('provideCascadeRelationshipsCombinations')]
Expand Down
30 changes: 30 additions & 0 deletions tests/Unit/Persistence/PersistentObjectFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@

namespace Zenstruck\Foundry\Tests\Unit\Persistence;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Zenstruck\Foundry\FactoryCollection;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Tests\Fixture\Entity\GenericEntity;
use Zenstruck\Foundry\Tests\Fixture\Factories\Entity\GenericEntityFactory;
Expand Down Expand Up @@ -62,4 +64,32 @@ public function random_or_create(): void

$this->assertSame('foo', $entity->getProp1());
}

/**
* @test
* @dataProvider factoryCollectionDataProvider
* @param FactoryCollection<GenericEntity, GenericEntityFactory> $collection
*/
#[Test] // @phpstan-ignore generics.notSubtype
#[DataProvider('factoryCollectionDataProvider')]
public function can_use_factory_collection_methods_in_data_providers(FactoryCollection $collection): void
{
self::assertEquals(
[
new GenericEntity('foo'),
],
$collection->create(),
);
}

public static function factoryCollectionDataProvider(): iterable
{
yield [
GenericEntityFactory::new()->sequence([
[
'prop1' => 'foo',
],
]),
];
}
}
Loading