diff --git a/src/DoctrineWriter.php b/src/DoctrineWriter.php index 3476718..a6389c6 100644 --- a/src/DoctrineWriter.php +++ b/src/DoctrineWriter.php @@ -10,6 +10,7 @@ use Doctrine\Persistence\ObjectManager; use Doctrine\Persistence\ObjectRepository; use Port\Doctrine\Exception\UnsupportedDatabaseTypeException; +use Port\Doctrine\LookupStrategy\FieldsLookupStrategy; use Port\Writer; /** @@ -72,25 +73,46 @@ class DoctrineWriter implements Writer, Writer\FlushableWriter /** * Method used for looking up the item * - * @var array + * @var array|callable */ protected $lookupMethod; + /** + * Strategy used to look up existing entities when truncate is disabled. + */ + private LookupStrategy $lookupStrategy; + private Inflector $inflector; + /** + * Create a Doctrine writer with a custom object lookup strategy. + * + * Prefer this when you need QueryBuilder-based or other non-field lookups + * (see https://github.com/portphp/doctrine/issues/3). + */ + public static function withLookupStrategy( + ObjectManager $objectManager, + string $objectName, + LookupStrategy $lookupStrategy + ): self { + return new self($objectManager, $objectName, null, 'findOneBy', $lookupStrategy); + } + /** * Constructor * - * @param ObjectManager $objectManager - * @param string $objectName - * @param string|array $index Field or fields to find current entities by - * @param string $lookupMethod Method used for looking up the item + * @param ObjectManager $objectManager + * @param string $objectName + * @param string|array|null $index Field or fields to find current entities by + * @param string $lookupMethod Method used for looking up the item + * @param LookupStrategy|null $lookupStrategy Optional custom strategy (overrides $index / $lookupMethod) */ public function __construct( ObjectManager $objectManager, $objectName, $index = null, - $lookupMethod = 'findOneBy' + $lookupMethod = 'findOneBy', + ?LookupStrategy $lookupStrategy = null ) { $this->ensureSupportedObjectManager($objectManager); $this->objectManager = $objectManager; @@ -116,6 +138,11 @@ public function __construct( ); } $this->lookupMethod = [$this->objectRepository, $lookupMethod]; + $this->lookupStrategy = $lookupStrategy ?? new FieldsLookupStrategy( + $this->objectRepository, + $index, + $lookupMethod + ); $this->inflector = InflectorFactory::create()->build(); } @@ -308,27 +335,15 @@ protected function reEnableLogging() protected function findOrCreateItem(array $item): object { - $object = null; - // If the table was not truncated to begin with, find current object - // first + // If the table was not truncated to begin with, find current object first if (!$this->truncate) { - if (!empty($this->lookupFields)) { - $lookupConditions = []; - foreach ($this->lookupFields as $fieldName) { - $lookupConditions[$fieldName] = $item[$fieldName]; - } - - $object = call_user_func($this->lookupMethod, $lookupConditions); - } else { - $object = $this->objectRepository->find(current($item)); + $object = $this->lookupStrategy->lookup($item); + if ($object !== null) { + return $object; } } - if (!$object) { - return $this->getNewInstance(); - } - - return $object; + return $this->getNewInstance(); } protected function ensureSupportedObjectManager(ObjectManager $objectManager) diff --git a/src/LookupStrategy.php b/src/LookupStrategy.php new file mode 100644 index 0000000..b86c593 --- /dev/null +++ b/src/LookupStrategy.php @@ -0,0 +1,21 @@ + value) + * + * @return object|null Null if no object was found (writer will create a new instance) + */ + public function lookup(array $item): ?object; +} diff --git a/src/LookupStrategy/FieldsLookupStrategy.php b/src/LookupStrategy/FieldsLookupStrategy.php new file mode 100644 index 0000000..461ceaf --- /dev/null +++ b/src/LookupStrategy/FieldsLookupStrategy.php @@ -0,0 +1,151 @@ + */ + private array $lookupFields; + + private string $lookupMethod; + + /** + * @param ObjectRepository $objectRepository + * @param string|array|null $index Field or fields used as lookup criteria (null = find by first item value) + * @param string $lookupMethod Repository method used when lookup fields are set + */ + public function __construct( + ObjectRepository $objectRepository, + string|array|null $index = null, + string $lookupMethod = 'findOneBy' + ) { + $this->objectRepository = $objectRepository; + $this->lookupFields = $this->normalizeIndex($index); + $this->assertLookupMethod($lookupMethod); + $this->lookupMethod = $lookupMethod; + } + + /** + * Convenience factory from an object manager and class name. + */ + public static function fromObjectManager( + ObjectManager $objectManager, + string $objectName, + string|array|null $index = null, + string $lookupMethod = 'findOneBy' + ): self { + return new self( + $objectManager->getRepository($objectName), + $index, + $lookupMethod + ); + } + + /** + * @param string $field Field to find current objects by + */ + public function withLookupField(string $field): self + { + return $this->withLookupFields([$field]); + } + + /** + * @param list $fields Fields to find current objects by + */ + public function withLookupFields(array $fields): self + { + $new = clone $this; + $new->lookupFields = array_values($fields); + + return $new; + } + + /** + * Accept string or list of fields (mirrors DoctrineWriter $index constructor arg). + * + * @param string|array $index + */ + public function withIndex(string|array $index): self + { + if (is_array($index)) { + return $this->withLookupFields($index); + } + + return $this->withLookupField($index); + } + + /** + * Doctrine repository method for finding objects when lookup fields are set. + */ + public function withLookupMethod(string $lookupMethod): self + { + $this->assertLookupMethod($lookupMethod); + + $new = clone $this; + $new->lookupMethod = $lookupMethod; + + return $new; + } + + public function lookup(array $item): ?object + { + if (!empty($this->lookupFields)) { + $lookupConditions = []; + foreach ($this->lookupFields as $fieldName) { + $lookupConditions[$fieldName] = $item[$fieldName] ?? null; + } + + $result = $this->objectRepository->{$this->lookupMethod}($lookupConditions); + + return is_object($result) ? $result : null; + } + + $result = $this->objectRepository->find(current($item)); + + return is_object($result) ? $result : null; + } + + /** + * @param string|array|null $index + * + * @return list + */ + private function normalizeIndex(string|array|null $index): array + { + if ($index === null) { + return []; + } + + if (is_array($index)) { + return array_values($index); + } + + return [$index]; + } + + private function assertLookupMethod(string $lookupMethod): void + { + if (!method_exists($this->objectRepository, $lookupMethod)) { + throw new \InvalidArgumentException( + sprintf( + 'Repository %s has no method %s', + get_class($this->objectRepository), + $lookupMethod + ) + ); + } + } +} diff --git a/tests/DoctrineWriterTest.php b/tests/DoctrineWriterTest.php index 13e86d9..93dcdeb 100644 --- a/tests/DoctrineWriterTest.php +++ b/tests/DoctrineWriterTest.php @@ -14,6 +14,8 @@ use MongoDB\Collection; use PHPUnit\Framework\TestCase; use Port\Doctrine\DoctrineWriter; +use Port\Doctrine\LookupStrategy; +use Port\Doctrine\LookupStrategy\FieldsLookupStrategy; use Port\Doctrine\Tests\Fixtures\Entity\TestEntity; class DoctrineWriterTest extends TestCase @@ -65,89 +67,6 @@ public function testUnsupportedDatabaseTypeException() new DoctrineWriter($em, 'Port:TestEntity'); } - protected function getEntityManager() - { - $em = $this->getMockBuilder(EntityManager::class) - ->setMethods(['getRepository', 'getClassMetadata', 'persist', 'flush', 'clear', 'getConnection', 'getReference']) - ->disableOriginalConstructor() - ->getMock(); - - $repo = $this->getMockBuilder(EntityRepository::class) - ->disableOriginalConstructor() - ->getMock(); - - $metadata = $this->getMockBuilder(ClassMetadata::class) - ->setMethods(['getName', 'getFieldNames', 'getAssociationNames', 'setFieldValue', 'getAssociationMappings']) - ->disableOriginalConstructor() - ->getMock(); - - $metadata->expects($this->any()) - ->method('getName') - ->will($this->returnValue(self::TEST_ENTITY)); - - $metadata->expects($this->any()) - ->method('getFieldNames') - ->will($this->returnValue(['firstProperty', 'secondProperty'])); - - $metadata->expects($this->any()) - ->method('getAssociationNames') - ->will($this->returnValue(['firstAssociation'])); - - $metadata->expects($this->any()) - ->method('getAssociationMappings') - ->will($this->returnValue([['fieldName' => 'firstAssociation', 'targetEntity' => self::TEST_ENTITY]])); - - $configuration = $this->getMockBuilder(Configuration::class) - ->setMethods(['getConnection']) - ->disableOriginalConstructor() - ->getMock(); - - $connection = $this->getMockBuilder(Connection::class) - ->setMethods(['getConfiguration', 'getDatabasePlatform', 'getTruncateTableSQL', 'executeQuery']) - ->disableOriginalConstructor() - ->getMock(); - - $connection->expects($this->any()) - ->method('getConfiguration') - ->will($this->returnValue($configuration)); - - $connection->expects($this->any()) - ->method('getDatabasePlatform') - ->will($this->returnSelf()); - - $connection->expects($this->any()) - ->method('getTruncateTableSQL') - ->will($this->returnValue('TRUNCATE SQL')); - - $connection->expects($this->any()) - ->method('executeQuery') - ->with('TRUNCATE SQL'); - - $em->expects($this->once()) - ->method('getRepository') - ->will($this->returnValue($repo)); - - $em->expects($this->once()) - ->method('getClassMetadata') - ->will($this->returnValue($metadata)); - - $em->expects($this->any()) - ->method('getConnection') - ->will($this->returnValue($connection)); - - $self = $this; - $em->expects($this->any()) - ->method('persist') - ->will( - $this->returnCallback(function ($argument) use ($self) { - $self->assertNotNull($argument->getFirstAssociation()); - return true; - })); - - return $em; - } - - protected function getMongoDocumentManager() { $dm = $this->getMockBuilder(DocumentManager::class) @@ -296,4 +215,273 @@ public function testFlushAndClear() $writer = new DoctrineWriter($em, 'Port:TestEntity'); $writer->finish(); } + + public function testWithLookupStrategyFactory() + { + $em = $this->getEntityManager(); + + $lookupStrategy = $this->createMock(LookupStrategy::class); + // Default truncate=true means lookup is not used during writeItem + $lookupStrategy->expects($this->never()) + ->method('lookup'); + + $writer = DoctrineWriter::withLookupStrategy( + $em, + 'Port:TestEntity', + $lookupStrategy + ); + + $item = [ + 'firstProperty' => 'some value', + 'secondProperty' => 'some other value', + 'firstAssociation' => new TestEntity(), + ]; + $writer->writeItem($item); + $writer->finish(); + + $this->assertInstanceOf(DoctrineWriter::class, $writer); + } + + public function testDisableTruncateUsesLookupStrategy() + { + $em = $this->getEntityManager(['getRepository' => $this->any()]); + $existing = new TestEntity(); + $existing->setFirstProperty('from-db'); + + $lookupStrategy = $this->createMock(LookupStrategy::class); + $lookupStrategy->expects($this->once()) + ->method('lookup') + ->with($this->callback(function (array $item) { + return $item['firstProperty'] === 'lookup-key'; + })) + ->willReturn($existing); + + $writer = DoctrineWriter::withLookupStrategy( + $em, + 'Port:TestEntity', + $lookupStrategy + ); + $writer->disableTruncate(); + + $writer->writeItem([ + 'firstProperty' => 'lookup-key', + 'secondProperty' => 'updated', + 'firstAssociation' => new TestEntity(), + ]); + + $this->assertSame('updated', $existing->getSecondProperty()); + } + + public function testDisableTruncateCreatesWhenLookupReturnsNull() + { + $persisted = null; + $em = $this->getEntityManager([ + 'onPersist' => function ($object) use (&$persisted) { + $persisted = $object; + }, + ]); + + $lookupStrategy = $this->createMock(LookupStrategy::class); + $lookupStrategy->expects($this->once()) + ->method('lookup') + ->willReturn(null); + + $writer = DoctrineWriter::withLookupStrategy( + $em, + 'Port:TestEntity', + $lookupStrategy + ); + $writer->disableTruncate(); + + $writer->writeItem([ + 'firstProperty' => 'new-value', + 'secondProperty' => 'other', + 'firstAssociation' => new TestEntity(), + ]); + + $this->assertInstanceOf(TestEntity::class, $persisted); + $this->assertSame('new-value', $persisted->getFirstProperty()); + } + + public function testConstructorIndexUsesFieldsLookupStrategy() + { + $repo = $this->getMockBuilder(EntityRepository::class) + ->disableOriginalConstructor() + ->onlyMethods(['findOneBy']) + ->getMock(); + + $existing = new TestEntity(); + $repo->expects($this->once()) + ->method('findOneBy') + ->with(['firstProperty' => 'key']) + ->willReturn($existing); + + $em = $this->getEntityManager([ + 'repository' => $repo, + 'getRepository' => $this->any(), + ]); + + $writer = new DoctrineWriter($em, 'Port:TestEntity', 'firstProperty'); + $writer->disableTruncate(); + + $writer->writeItem([ + 'firstProperty' => 'key', + 'secondProperty' => 'updated-via-index', + 'firstAssociation' => new TestEntity(), + ]); + + $this->assertSame('updated-via-index', $existing->getSecondProperty()); + } + + public function testFieldsLookupStrategyWithoutIndexUsesFind() + { + $repo = $this->getMockBuilder(EntityRepository::class) + ->disableOriginalConstructor() + ->onlyMethods(['find', 'findOneBy']) + ->getMock(); + + $existing = new TestEntity(); + $repo->expects($this->once()) + ->method('find') + ->with('first-value') + ->willReturn($existing); + $repo->expects($this->never()) + ->method('findOneBy'); + + $strategy = new FieldsLookupStrategy($repo); + $this->assertSame($existing, $strategy->lookup([ + 'firstProperty' => 'first-value', + 'secondProperty' => 'x', + ])); + } + + public function testFieldsLookupStrategyWithFieldsIsImmutable() + { + $repo = $this->getMockBuilder(EntityRepository::class) + ->disableOriginalConstructor() + ->onlyMethods(['findOneBy']) + ->getMock(); + + $existing = new TestEntity(); + $repo->expects($this->once()) + ->method('findOneBy') + ->with(['secondProperty' => 'match']) + ->willReturn($existing); + + $base = new FieldsLookupStrategy($repo); + $withField = $base->withLookupField('secondProperty'); + + // Original must not be mutated + $this->assertNotSame($base, $withField); + $this->assertSame($existing, $withField->lookup([ + 'firstProperty' => 'ignored', + 'secondProperty' => 'match', + ])); + } + + public function testFieldsLookupStrategyInvalidMethodThrows() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('has no method doesNotExist'); + + $repo = $this->getMockBuilder(EntityRepository::class) + ->disableOriginalConstructor() + ->getMock(); + + new FieldsLookupStrategy($repo, null, 'doesNotExist'); + } + + /** + * @param array{ + * repository?: EntityRepository|\PHPUnit\Framework\MockObject\MockObject, + * getRepository?: mixed, + * getClassMetadata?: mixed, + * onPersist?: callable + * } $options + */ + protected function getEntityManager(array $options = []) + { + $em = $this->getMockBuilder(EntityManager::class) + ->setMethods(['getRepository', 'getClassMetadata', 'persist', 'flush', 'clear', 'getConnection', 'getReference']) + ->disableOriginalConstructor() + ->getMock(); + + $repo = $options['repository'] ?? $this->getMockBuilder(EntityRepository::class) + ->disableOriginalConstructor() + ->getMock(); + + $metadata = $this->getMockBuilder(ClassMetadata::class) + ->setMethods(['getName', 'getFieldNames', 'getAssociationNames', 'setFieldValue', 'getAssociationMappings', 'getFieldValue']) + ->disableOriginalConstructor() + ->getMock(); + + $metadata->expects($this->any()) + ->method('getName') + ->will($this->returnValue(self::TEST_ENTITY)); + + $metadata->expects($this->any()) + ->method('getFieldNames') + ->will($this->returnValue(['firstProperty', 'secondProperty'])); + + $metadata->expects($this->any()) + ->method('getAssociationNames') + ->will($this->returnValue(['firstAssociation'])); + + $metadata->expects($this->any()) + ->method('getAssociationMappings') + ->will($this->returnValue([['fieldName' => 'firstAssociation', 'targetEntity' => self::TEST_ENTITY]])); + + $configuration = $this->getMockBuilder(Configuration::class) + ->setMethods(['getConnection']) + ->disableOriginalConstructor() + ->getMock(); + + $connection = $this->getMockBuilder(Connection::class) + ->setMethods(['getConfiguration', 'getDatabasePlatform', 'getTruncateTableSQL', 'executeQuery']) + ->disableOriginalConstructor() + ->getMock(); + + $connection->expects($this->any()) + ->method('getConfiguration') + ->will($this->returnValue($configuration)); + + $connection->expects($this->any()) + ->method('getDatabasePlatform') + ->will($this->returnSelf()); + + $connection->expects($this->any()) + ->method('getTruncateTableSQL') + ->will($this->returnValue('TRUNCATE SQL')); + + $connection->expects($this->any()) + ->method('executeQuery') + ->with('TRUNCATE SQL'); + + $em->expects($options['getRepository'] ?? $this->once()) + ->method('getRepository') + ->will($this->returnValue($repo)); + + $em->expects($options['getClassMetadata'] ?? $this->once()) + ->method('getClassMetadata') + ->will($this->returnValue($metadata)); + + $em->expects($this->any()) + ->method('getConnection') + ->will($this->returnValue($connection)); + + $self = $this; + $onPersist = $options['onPersist'] ?? null; + $em->expects($this->any()) + ->method('persist') + ->will( + $this->returnCallback(function ($argument) use ($self, $onPersist) { + $self->assertNotNull($argument->getFirstAssociation()); + if ($onPersist !== null) { + $onPersist($argument); + } + return true; + })); + + return $em; + } }