diff --git a/src/Annotation/Document.php b/src/Annotation/Document.php index 03bc5da..ec919f9 100644 --- a/src/Annotation/Document.php +++ b/src/Annotation/Document.php @@ -2,7 +2,6 @@ namespace Refugis\ODM\Elastica\Annotation; -use Doctrine\Common\Annotations\Annotation\Required; use Doctrine\Common\Annotations\Annotation\Target; /** @@ -15,8 +14,6 @@ final class Document * The elastica index/type name. * * @var string - * - * @Required() */ public $type; diff --git a/src/Exception/DocumentNotManagedException.php b/src/Exception/DocumentNotManagedException.php new file mode 100644 index 0000000..b976c1d --- /dev/null +++ b/src/Exception/DocumentNotManagedException.php @@ -0,0 +1,13 @@ +getLastInsertedId(); } + /** + * {@inheritdoc} + */ public function isPostInsertGenerator(): bool { return true; diff --git a/src/Id/PostInsertId.php b/src/Id/PostInsertId.php index 4eab27f..cf06fb8 100644 --- a/src/Id/PostInsertId.php +++ b/src/Id/PostInsertId.php @@ -18,17 +18,23 @@ final class PostInsertId */ private $id; - public function __construct($document, string $id) + public function __construct(object $document, string $id) { $this->document = $document; $this->id = $id; } - public function getDocument() + /** + * Gets the document for this id. + */ + public function getDocument(): object { return $this->document; } + /** + * Returns the newly generated id. + */ public function getId(): string { return $this->id; diff --git a/src/Metadata/Processor/DocumentProcessor.php b/src/Metadata/Processor/DocumentProcessor.php index ed3cda4..f6cd1a4 100644 --- a/src/Metadata/Processor/DocumentProcessor.php +++ b/src/Metadata/Processor/DocumentProcessor.php @@ -2,6 +2,8 @@ namespace Refugis\ODM\Elastica\Metadata\Processor; +use Doctrine\Common\Inflector\Inflector; +use Elastica\Type; use Kcs\Metadata\Loader\Processor\Annotation\Processor; use Kcs\Metadata\Loader\Processor\ProcessorInterface; use Kcs\Metadata\MetadataInterface; @@ -22,7 +24,29 @@ class DocumentProcessor implements ProcessorInterface public function process(MetadataInterface $metadata, $subject): void { $metadata->document = true; - $metadata->collectionName = $subject->type; + + $metadata->collectionName = $subject->type ?? $this->calculateType($metadata->name); + if (\class_exists(Type::class) && false === \strpos($metadata->collectionName, '/')) { + $metadata->collectionName .= '/'.$metadata->collectionName; + } + $metadata->customRepositoryClassName = $subject->repositoryClass; } + + /** + * Build a collection name from class name. + * + * @param string $name + * + * @return string + */ + private function calculateType(string $name): string + { + $indexName = Inflector::tableize($name); + if (\class_exists(Type::class)) { + return "$indexName/$indexName"; + } + + return $indexName; + } } diff --git a/src/UnitOfWork.php b/src/UnitOfWork.php index 77ddd5a..249439d 100644 --- a/src/UnitOfWork.php +++ b/src/UnitOfWork.php @@ -9,8 +9,11 @@ use ProxyManager\Proxy\LazyLoadingInterface; use Refugis\ODM\Elastica\Events\LifecycleEventManager; use Refugis\ODM\Elastica\Events\PreFlushEventArgs; +use Refugis\ODM\Elastica\Exception\DocumentNotManagedException; use Refugis\ODM\Elastica\Exception\IndexNotFoundException; +use Refugis\ODM\Elastica\Exception\InvalidArgumentException; use Refugis\ODM\Elastica\Exception\InvalidIdentifierException; +use Refugis\ODM\Elastica\Exception\UnexpectedDocumentStateException; use Refugis\ODM\Elastica\Id\AssignedIdGenerator; use Refugis\ODM\Elastica\Id\GeneratorInterface; use Refugis\ODM\Elastica\Id\IdentityGenerator; @@ -149,7 +152,8 @@ public function clear(?string $documentClass = null): void $this->readOnlyObjects = $this->originalDocumentData = []; } else { - throw new \Exception('Not implemented yet.'); + $this->clearIdentityMapForDocumentClass($documentClass); + $this->clearEntityInsertionsForDocumentClass($documentClass); } if ($this->evm->hasListeners(Events::onClear)) { @@ -313,9 +317,67 @@ public function computeChangeSets(): void } } - public function recomputeSingleDocumentChangeset($document) + /** + * INTERNAL: + * Computes the changeset of an individual document, independently of the + * computeChangeSets() routine that is used at the beginning of a UnitOfWork#commit(). + * + * The passed entity must be a managed entity. If the entity already has a change set + * because this method is invoked during a commit cycle then the change sets are added. + * whereby changes detected in this method prevail. + * + * @ignore + * + * @param object $document the entity for which to (re)calculate the change set + * + * @return void + * + * @throws InvalidArgumentException if the passed entity is not MANAGED + */ + public function recomputeSingleDocumentChangeset(object $document): void { - // @todo + $oid = \spl_object_hash($document); + if (! isset($this->documentStates[$oid]) || self::STATE_MANAGED !== $this->documentStates[$oid]) { + throw new DocumentNotManagedException($document); + } + + $class = $this->manager->getClassMetadata(ClassUtil::getClass($document)); + \assert($class instanceof DocumentMetadata); + + $actualData = []; + foreach ($class->attributesMetadata as $field) { + if (! $field instanceof FieldMetadata || ! $field->isStored()) { + continue; + } + + $actualData[$field->fieldName] = $field->getValue($document); + } + + if (! isset($this->originalDocumentData[$oid])) { + throw new \RuntimeException('Cannot call recomputeSingleDocumentChangeset before computeChangeSet on a document.'); + } + + $originalData = $this->originalDocumentData[$oid]; + $changeSet = []; + + foreach ($actualData as $propName => $actualValue) { + $orgValue = $originalData[$propName] ?? null; + + if ($orgValue !== $actualValue) { + $changeSet[$propName] = [$orgValue, $actualValue]; + } + } + + if ($changeSet) { + if (isset($this->entityChangeSets[$oid])) { + $this->documentChangeSets[$oid] = \array_merge($this->documentChangeSets[$oid], $changeSet); + } elseif (! isset($this->entityInsertions[$oid])) { + $this->documentChangeSets[$oid] = $changeSet; + $this->documentUpdates[$oid] = $document; + } + + $this->originalDocumentData[$oid] = $actualData; + } } /** @@ -396,8 +458,9 @@ public function merge(object $object): object */ public function createDocument(Document $document, object $result, ?array $fields = null): void { - /** @var DocumentMetadata $class */ $class = $this->manager->getClassMetadata(ClassUtil::getClass($result)); + \assert($class instanceof DocumentMetadata); + $typeManager = $this->manager->getTypeManager(); $documentData = $document->getData(); @@ -498,6 +561,85 @@ public function getIdGenerator(int $generatorType): GeneratorInterface return $generators[$generatorType] = $generator; } + /** + * Checks whether an entity is registered for insertion within this unit of work. + * + * @param object $document + * + * @return bool + */ + public function isScheduledForInsert(object $document): bool + { + return isset($this->documentInsertions[\spl_object_hash($document)]); + } + + /** + * Checks whether an entity is registered as removed/deleted with the unit + * of work. + * + * @param object $document + * + * @return bool + */ + public function isScheduledForDelete(object $document): bool + { + return isset($this->documentDeletions[\spl_object_hash($document)]); + } + + /** + * INTERNAL: + * Registers a document as managed. + * + * @param object $document the document + * @param array $data the original document data + * + * @return void + * + * @throws InvalidIdentifierException + */ + public function registerManaged(object $document, array $data): void + { + $oid = \spl_object_hash($document); + + $this->documentStates[$oid] = self::STATE_MANAGED; + $this->originalDocumentData[$oid] = $data; + + $this->addToIdentityMap($document); + } + + /** + * Clears the identity map for the given document class. + * + * @param string $entityName + */ + private function clearIdentityMapForDocumentClass(string $documentClass): void + { + if (! isset($this->identityMap[$documentClass])) { + return; + } + + $visited = []; + + foreach ($this->identityMap[$documentClass] as $document) { + $this->doDetach($document, $visited); + } + } + + /** + * Clears the document insertions for the given document class. + * + * @param string $entityName + */ + private function clearEntityInsertionsForDocumentClass(string $documentClass): void + { + foreach ($this->documentInsertions as $hash => $document) { + // note: performance optimization - `instanceof` is much faster than a function call + if ($document instanceof $documentClass && \get_class($document) === $documentClass) { + unset($this->documentInsertions[$hash]); + } + } + } + /** * Computes the changes that happened to a single document. * @@ -581,10 +723,6 @@ private function computeChangeSet(DocumentMetadata $class, object $document): vo private function addToIdentityMap(object $object): void { $oid = \spl_object_hash($object); - if (isset($this->objects[$oid])) { - return; - } - $class = $this->manager->getClassMetadata(ClassUtil::getClass($object)); $id = $class->getSingleIdentifier($object); @@ -645,6 +783,7 @@ private function doPersist(object $object, array &$visited): void case self::STATE_REMOVED: unset($this->documentDeletions[$oid]); + $this->addToIdentityMap($object); $this->documentStates[$oid] = self::STATE_MANAGED; break; @@ -659,7 +798,7 @@ private function doPersist(object $object, array &$visited): void * @param object $object * @param array $visited * - * @throws \InvalidArgumentException if document state is equal to NEW + * @throws InvalidArgumentException if document state is equal to NEW */ private function doRemove(object $object, array &$visited): void { @@ -688,9 +827,9 @@ private function doRemove(object $object, array &$visited): void break; case self::STATE_DETACHED: - throw new \InvalidArgumentException('Detached document cannot be removed'); + throw new InvalidArgumentException('Detached document cannot be removed'); default: - throw new \InvalidArgumentException('Unexpected document state '.$documentState); + throw new UnexpectedDocumentStateException($documentState); } } @@ -707,16 +846,14 @@ private function doRemove(object $object, array &$visited): void private function doMerge(object $object, array &$visited): object { $oid = \spl_object_hash($object); - if (isset($visited[$oid])) { return $visited[$oid]; } - $visited[$oid] = $object; - - /** @var DocumentMetadata $class */ $class = $this->manager->getClassMetadata(ClassUtil::getClass($object)); - $managedCopy = $object; + $managedCopy = $visited[$oid] = $object; + + \assert($class instanceof DocumentMetadata); if (self::STATE_MANAGED !== $this->getDocumentState($object, self::STATE_DETACHED)) { $this->manager->initializeObject($object); @@ -732,7 +869,7 @@ private function doMerge(object $object, array &$visited): object } if (null !== $managedCopy && self::STATE_REMOVED === $this->getDocumentState($managedCopy)) { - throw new \InvalidArgumentException('Removed document detected during merge.'); + throw new InvalidArgumentException('Removed document detected during merge.'); } $this->manager->initializeObject($managedCopy); @@ -902,14 +1039,14 @@ private function dispatchPostFlush() private function executeInserts(string $className): void { foreach ($this->documentInsertions as $oid => $document) { - /** @var DocumentMetadata $class */ $class = $this->manager->getClassMetadata(ClassUtil::getClass($document)); + \assert($class instanceof DocumentMetadata); + if ($className !== $class->name) { continue; } $persister = $this->getDocumentPersister($class->name); - $postInsertId = $persister->insert($document); if (null !== $postInsertId) { @@ -929,8 +1066,9 @@ private function executeInserts(string $className): void private function executeUpdates(string $className): void { foreach ($this->documentUpdates as $oid => $document) { - /** @var DocumentMetadata $class */ $class = $this->manager->getClassMetadata(ClassUtil::getClass($document)); + \assert($class instanceof DocumentMetadata); + if ($className !== $class->name) { continue; } @@ -950,8 +1088,9 @@ private function executeUpdates(string $className): void private function executeDeletions(string $className): void { foreach ($this->documentDeletions as $oid => $document) { - /** @var DocumentMetadata $class */ $class = $this->manager->getClassMetadata(ClassUtil::getClass($document)); + \assert($class instanceof DocumentMetadata); + if ($className !== $class->name) { continue; } diff --git a/tests/Fixtures/Document/CMS/CmsPhoneNumber.php b/tests/Fixtures/Document/CMS/CmsPhoneNumber.php new file mode 100644 index 0000000..719aa9d --- /dev/null +++ b/tests/Fixtures/Document/CMS/CmsPhoneNumber.php @@ -0,0 +1,19 @@ +id; + } + + public function getUsername(): string + { + return $this->username; + } +} diff --git a/tests/Fixtures/Document/GeoNames/City.php b/tests/Fixtures/Document/GeoNames/City.php new file mode 100644 index 0000000..b7c8afa --- /dev/null +++ b/tests/Fixtures/Document/GeoNames/City.php @@ -0,0 +1,29 @@ +id = $id; + $this->name = $name; + } +} diff --git a/tests/Fixtures/Document/GeoNames/Country.php b/tests/Fixtures/Document/GeoNames/Country.php new file mode 100644 index 0000000..4c00f28 --- /dev/null +++ b/tests/Fixtures/Document/GeoNames/Country.php @@ -0,0 +1,29 @@ +id = $id; + $this->name = $name; + } +} diff --git a/tests/Mocks/DocumentManagerMock.php b/tests/Mocks/DocumentManagerMock.php new file mode 100644 index 0000000..12c5821 --- /dev/null +++ b/tests/Mocks/DocumentManagerMock.php @@ -0,0 +1,85 @@ +uowMock ?? parent::getUnitOfWork(); + } + + /** + * {@inheritdoc} + */ + public function getProxyFactory(): LazyLoadingGhostFactory + { + return $this->proxyFactoryMock ?? parent::getProxyFactory(); + } + + /* Mock API */ + + /** + * Sets a (mock) UnitOfWork that will be returned when getUnitOfWork() is called. + */ + public function setUnitOfWork(?UnitOfWork $uowMock): void + { + $this->uowMock = $uowMock; + } + + /** + * @param LazyLoadingGhostFactory|null $proxyFactoryMock + */ + public function setProxyFactory($proxyFactoryMock): void + { + $this->proxyFactoryMock = $proxyFactoryMock; + } + + /** + * Mock factory method to create a DocumentManager. + */ + public static function create(DatabaseInterface $database, Configuration $config = null, EventManager $eventManager = null) + { + if (null === $config) { + $processorFactory = new ProcessorFactory(); + $processorFactory->registerProcessors(__DIR__.'/../../src/Metadata/Processor'); + + $loader = new AnnotationLoader($processorFactory, __DIR__.'/../Fixtures/Document'); + $loader->setReader(new AnnotationReader()); + + $config = new Configuration(); + $config->setProxyFactory(new LazyLoadingGhostFactory()); + $config->setMetadataFactory(new MetadataFactory($loader)); + } + + if (null === $eventManager) { + $eventManager = new EventManager(); + } + + return new self($database, $config, $eventManager); + } +} diff --git a/tests/Mocks/DocumentPersisterMock.php b/tests/Mocks/DocumentPersisterMock.php new file mode 100644 index 0000000..5665a3c --- /dev/null +++ b/tests/Mocks/DocumentPersisterMock.php @@ -0,0 +1,111 @@ +inserts[] = $document; + + if ( + DocumentMetadata::GENERATOR_TYPE_AUTO === $this->generatorType || + DocumentMetadata::GENERATOR_TYPE_AUTO === $this->getClassMetadata()->idGeneratorType + ) { + return $this->postInsertIds[] = new PostInsertId($document, (string) $this->identityValueCounter++); + } + + return null; + } + + public function exists(array $criteria): bool + { + $this->existsCalled = true; + + return false; + } + + /** + * {@inheritdoc} + */ + public function update($document): void + { + $this->updates[] = $document; + } + + /** + * {@inheritdoc} + */ + public function delete($document): void + { + $this->deletes[] = $document; + } + + /** + * @param int|null $generatorType + */ + public function setMockGeneratorType(?int $generatorType): void + { + $this->generatorType = $generatorType; + } + + /** + * @return array + */ + public function getInserts(): array + { + return $this->inserts; + } + + /** + * @return array + */ + public function getUpdates(): array + { + return $this->updates; + } + + /** + * @return array + */ + public function getDeletes(): array + { + return $this->deletes; + } + + /** + * @return bool + */ + public function isExistsCalled(): bool + { + return $this->existsCalled; + } + + /** + * Resets mock. + */ + public function reset(): void + { + $this->inserts = + $this->updates = + $this->deletes = + $this->postInsertIds = []; + $this->generatorType = null; + $this->existsCalled = false; + } +} diff --git a/tests/UnitOfWorkTest.php b/tests/UnitOfWorkTest.php new file mode 100644 index 0000000..637a983 --- /dev/null +++ b/tests/UnitOfWorkTest.php @@ -0,0 +1,158 @@ +transport = $this->prophesize(AbstractTransport::class); + $this->eventManager = $this->prophesize(EventManager::class); + + $database = new Database(new Client([ + 'transport' => $this->transport->reveal(), + ])); + + $this->dm = DocumentManagerMock::create($database, null, $this->eventManager->reveal()); + + $this->unitOfWork = new UnitOfWork($this->dm); + $this->dm->setUnitOfWork($this->unitOfWork); + } + + public function testRegisterRemovedOnNewEntityIsIgnored(): void + { + $user = new ForumUser(); + $user->username = 'romanb'; + + self::assertFalse($this->unitOfWork->isScheduledForDelete($user)); + $this->unitOfWork->remove($user); + self::assertFalse($this->unitOfWork->isScheduledForDelete($user)); + } + + public function testSavingSingleDocumentWithIdentityFieldForcesInsert(): void + { + $persister = new DocumentPersisterMock($this->dm, $this->dm->getClassMetadata(ForumUser::class)); + $persister->setMockGeneratorType(DocumentMetadata::GENERATOR_TYPE_AUTO); + + self::setDocumentPersister($this->unitOfWork, $persister, ForumUser::class); + + $user = new ForumUser(); + $user->username = 'romanb'; + $this->unitOfWork->persist($user); + + self::assertCount(0, $persister->getInserts()); + self::assertCount(0, $persister->getUpdates()); + self::assertCount(0, $persister->getDeletes()); + + self::assertFalse($this->unitOfWork->isInIdentityMap($user)); + self::assertTrue($this->unitOfWork->isScheduledForInsert($user)); + + $this->unitOfWork->commit(); + + self::assertCount(1, $persister->getInserts()); + self::assertCount(0, $persister->getUpdates()); + self::assertCount(0, $persister->getDeletes()); + + self::assertNotEmpty($user->id); + } + + public function testGetEntityStateWithAssignedIdentity(): void + { + $persister = new DocumentPersisterMock($this->dm, $this->dm->getClassMetadata(CmsPhoneNumber::class)); + self::setDocumentPersister($this->unitOfWork, $persister, CmsPhoneNumber::class); + + $ph = new CmsPhoneNumber(); + $ph->phonenumber = '12345'; + + self::assertEquals(UnitOfWork::STATE_NEW, $this->unitOfWork->getDocumentState($ph)); + self::assertTrue($persister->isExistsCalled()); + + $persister->reset(); + + $this->unitOfWork->registerManaged($ph, []); + self::assertEquals(UnitOfWork::STATE_MANAGED, $this->unitOfWork->getDocumentState($ph)); + self::assertFalse($persister->isExistsCalled()); + + $ph2 = new CmsPhoneNumber(); + $ph2->phonenumber = '12345'; + + self::assertEquals(UnitOfWork::STATE_DETACHED, $this->unitOfWork->getDocumentState($ph2)); + self::assertFalse($persister->isExistsCalled()); + } + + public function testRemovedAndRePersistedDocumentsAreInTheIdentityMapAndAreNotGarbageCollected(): void + { + $document = new ForumUser(); + $document->id = 123; + + $this->unitOfWork->registerManaged($document, []); + self::assertTrue($this->unitOfWork->isInIdentityMap($document)); + + $this->unitOfWork->remove($document); + self::assertFalse($this->unitOfWork->isInIdentityMap($document)); + + $this->unitOfWork->persist($document); + self::assertTrue($this->unitOfWork->isInIdentityMap($document)); + } + + public function testPersistedDocumentAndClearManager(): void + { + $document1 = new City(123, 'London'); + $document2 = new Country(456, 'United Kingdom'); + + $this->unitOfWork->persist($document1); + self::assertTrue($this->unitOfWork->isInIdentityMap($document1)); + + $this->unitOfWork->persist($document2); + self::assertTrue($this->unitOfWork->isInIdentityMap($document2)); + + $this->unitOfWork->clear(Country::class); + self::assertTrue($this->unitOfWork->isInIdentityMap($document1)); + self::assertFalse($this->unitOfWork->isInIdentityMap($document2)); + self::assertTrue($this->unitOfWork->isScheduledForInsert($document1)); + self::assertFalse($this->unitOfWork->isScheduledForInsert($document2)); + } + + private static function setDocumentPersister(UnitOfWork $uow, DocumentPersister $documentPersister, string $class): void + { + (function (DocumentPersister $persister, string $class): void { + $this->documentPersisters[$class] = $persister; + })->bindTo($uow, UnitOfWork::class)($documentPersister, $class); + } +}