diff --git a/CHANGELOG.md b/CHANGELOG.md index cabfeb842..a977db501 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # CHANGELOG +## [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) + +* ad8d72c fix: can index one to many relationships based on "indexBy" (#815) by @nikophil + ## [v2.3.2](https://github.com/zenstruck/foundry/releases/tag/v2.3.2) February 1st, 2025 - [v2.3.1...v2.3.2](https://github.com/zenstruck/foundry/compare/v2.3.1...v2.3.2) diff --git a/src/ORM/OrmV2PersistenceStrategy.php b/src/ORM/OrmV2PersistenceStrategy.php index 2a1d14fd5..7e2ae2a2c 100644 --- a/src/ORM/OrmV2PersistenceStrategy.php +++ b/src/ORM/OrmV2PersistenceStrategy.php @@ -66,6 +66,7 @@ public function inversedRelationshipMetadata(string $parent, string $child, stri return new InverseRelationshipMetadata( inverseField: $association['fieldName'], isCollection: $inversedAssociationMetadata->isCollectionValuedAssociation($inversedAssociation['fieldName']), + collectionIndexedBy: $inversedAssociation['indexBy'] ?? null ); } diff --git a/src/ORM/OrmV3PersistenceStrategy.php b/src/ORM/OrmV3PersistenceStrategy.php index 6d8d187e3..e105b176c 100644 --- a/src/ORM/OrmV3PersistenceStrategy.php +++ b/src/ORM/OrmV3PersistenceStrategy.php @@ -56,6 +56,7 @@ public function inversedRelationshipMetadata(string $parent, string $child, stri return new InverseRelationshipMetadata( inverseField: $association->fieldName, isCollection: $inversedAssociation instanceof ToManyAssociationMapping, + collectionIndexedBy: $inversedAssociation->isIndexed() ? $inversedAssociation->indexBy() : null ); } diff --git a/src/Persistence/InverseRelationshipMetadata.php b/src/Persistence/InverseRelationshipMetadata.php index 50b35c9ec..1287b8f56 100644 --- a/src/Persistence/InverseRelationshipMetadata.php +++ b/src/Persistence/InverseRelationshipMetadata.php @@ -21,6 +21,7 @@ final class InverseRelationshipMetadata public function __construct( public readonly string $inverseField, public readonly bool $isCollection, + public readonly ?string $collectionIndexedBy, ) { } } diff --git a/src/Persistence/PersistentObjectFactory.php b/src/Persistence/PersistentObjectFactory.php index 7267096c9..825dca681 100644 --- a/src/Persistence/PersistentObjectFactory.php +++ b/src/Persistence/PersistentObjectFactory.php @@ -22,6 +22,7 @@ use Zenstruck\Foundry\Persistence\Exception\NotEnoughObjects; use Zenstruck\Foundry\Persistence\Exception\RefreshObjectFailed; +use function Zenstruck\Foundry\get; use function Zenstruck\Foundry\set; /** @@ -335,11 +336,22 @@ protected function normalizeCollection(string $field, FactoryCollection $collect $inverseRelationshipMetadata = $pm->inverseRelationshipMetadata(static::class(), $collection->factory::class(), $field); if ($inverseRelationshipMetadata && $inverseRelationshipMetadata->isCollection) { - $inverseField = $inverseRelationshipMetadata->inverseField; + $this->tempAfterInstantiate[] = static function(object $object) use ($collection, $inverseRelationshipMetadata, $field) { + $inverseField = $inverseRelationshipMetadata->inverseField; - $this->tempAfterInstantiate[] = static function(object $object) use ($collection, $inverseField, $field) { $inverseObjects = $collection->withPersistMode(PersistMode::NO_PERSIST_BUT_SCHEDULE_FOR_INSERT)->create([$inverseField => $object]); - set($object, $field, unproxy($inverseObjects)); + + $inverseObjects = unproxy($inverseObjects); + + // if the collection is indexed by a field, index the array + if ($inverseRelationshipMetadata->collectionIndexedBy) { + $inverseObjects = \array_combine( + \array_map(static fn($o) => get($o, $inverseRelationshipMetadata->collectionIndexedBy), $inverseObjects), + \array_values($inverseObjects) + ); + } + + set($object, $field, $inverseObjects); }; // creation delegated to afterPersist hook - return empty array here diff --git a/tests/Fixture/Entity/EdgeCases/IndexedOneToMany/Child.php b/tests/Fixture/Entity/EdgeCases/IndexedOneToMany/Child.php new file mode 100644 index 000000000..0bcf49c12 --- /dev/null +++ b/tests/Fixture/Entity/EdgeCases/IndexedOneToMany/Child.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\IndexedOneToMany; + +use Doctrine\ORM\Mapping as ORM; +use Zenstruck\Foundry\Tests\Fixture\Model\Base; + +/** + * @author Nicolas PHILIPPE + */ +#[ORM\Entity] +#[ORM\Table('index_by_one_to_many_level_1')] +class Child extends Base +{ + public function __construct( + #[ORM\ManyToOne(inversedBy: 'items')] + #[ORM\JoinColumn(nullable: false)] + public ParentEntity $parent, + + #[ORM\Column()] + public string $language, + ) { + } +} diff --git a/tests/Fixture/Entity/EdgeCases/IndexedOneToMany/ParentEntity.php b/tests/Fixture/Entity/EdgeCases/IndexedOneToMany/ParentEntity.php new file mode 100644 index 000000000..4ddc7b58d --- /dev/null +++ b/tests/Fixture/Entity/EdgeCases/IndexedOneToMany/ParentEntity.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\IndexedOneToMany; + +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; +use Zenstruck\Foundry\Tests\Fixture\Model\Base; + +/** + * @author Nicolas PHILIPPE + */ +#[ORM\Entity] +#[ORM\Table('index_by_one_to_many_parent')] +class ParentEntity extends Base +{ + /** @var Collection */ + #[ORM\OneToMany(targetEntity: Child::class, mappedBy: 'parent', indexBy: 'language')] + private Collection $items; + + public function __construct() + { + $this->items = new ArrayCollection(); + } + + /** + * @return Collection + */ + public function getItems(): Collection + { + return $this->items; + } + + public function addItem(Child $item): void + { + if (!$this->items->contains($item)) { + $this->items->add($item); + } + } + + public function removeItem(Child $item): void + { + if ($this->items->contains($item)) { + $this->items->removeElement($item); + } + } +} diff --git a/tests/Integration/ORM/EdgeCasesRelationshipTest.php b/tests/Integration/ORM/EdgeCasesRelationshipTest.php index 27a94e13c..5605c2f1b 100644 --- a/tests/Integration/ORM/EdgeCasesRelationshipTest.php +++ b/tests/Integration/ORM/EdgeCasesRelationshipTest.php @@ -21,6 +21,7 @@ use Zenstruck\Foundry\Test\ResetDatabase; use Zenstruck\Foundry\Tests\Fixture\DoctrineCascadeRelationship\ChangesEntityRelationshipCascadePersist; use Zenstruck\Foundry\Tests\Fixture\DoctrineCascadeRelationship\UsingRelationships; +use Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\IndexedOneToMany; use Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\InversedOneToOneWithNonNullableOwning; use Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\InversedOneToOneWithOneToMany; use Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\InversedOneToOneWithSetter; @@ -135,6 +136,28 @@ public function many_to_many_to_self_referencing_inverse_side(): void $inverseSideFactory::assert()->count(1); } + /** @test */ + #[Test] + #[DataProvider('provideCascadeRelationshipsCombinations')] + #[UsingRelationships(IndexedOneToMany\ParentEntity::class, ['items'])] + #[RequiresPhpunit('^11.4')] + public function indexed_one_to_many(): void + { + $parentFactory = persistent_factory(IndexedOneToMany\ParentEntity::class); + $childFactory = persistent_factory(IndexedOneToMany\Child::class); + + $parent = $parentFactory->create( + [ + 'items' => $childFactory->with(['language' => 'en', 'parent' => $parentFactory])->many(1), + ] + ); + + $parentFactory::assert()->count(1); + $childFactory::assert()->count(1); + + self::assertNotNull($parent->getItems()->get('en')); // @phpstan-ignore argument.type + } + /** * @test */