diff --git a/docs/ORMContext/see-entity-in-repository-with-properties.md b/docs/ORMContext/see-entity-in-repository-with-properties.md index 1187bb2..5e0af7c 100644 --- a/docs/ORMContext/see-entity-in-repository-with-properties.md +++ b/docs/ORMContext/see-entity-in-repository-with-properties.md @@ -34,7 +34,25 @@ This step allows you to verify that a specific entity exists in the database by - Validating complex entity state with multiple property conditions - Testing business logic that modifies entity properties +#### Embedded Properties (Value Objects) + +Both steps support Doctrine embedded objects using dotted property paths: + +```gherkin +Then I see entity "App\Entity\Balance" with properties: + """ + { + "customerId": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "balanceValue.amount": "500000", + "balanceValue.currency": "USD", + } + """ +``` + +The dotted notation (e.g., `balanceValue.amount`) allows querying embedded value objects defined in your entity mappings. + #### Notes: - Properties with `null` values are queried using `IS NULL` condition -- All other properties are matched using equality \ No newline at end of file +- All other properties are matched using equality +- Embedded property paths use dotted notation (e.g., `embeddable.field`) \ No newline at end of file diff --git a/src/Context/ORMContext.php b/src/Context/ORMContext.php index 8c09877..92c4f12 100644 --- a/src/Context/ORMContext.php +++ b/src/Context/ORMContext.php @@ -6,6 +6,7 @@ use Behat\Behat\Context\Context; use Behat\Gherkin\Node\PyStringNode; +use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\NonUniqueResultException; use Doctrine\ORM\NoResultException; @@ -24,7 +25,7 @@ public function __construct(EntityManagerInterface $manager) /** * @And I see :count entities :entityClass - * + * * @param class-string $entityClass */ public function andISeeInRepository(int $count, string $entityClass): void @@ -34,7 +35,7 @@ public function andISeeInRepository(int $count, string $entityClass): void /** * @Then I see :count entities :entityClass - * + * * @param class-string $entityClass */ public function thenISeeInRepository(int $count, string $entityClass): void @@ -44,7 +45,7 @@ public function thenISeeInRepository(int $count, string $entityClass): void /** * @And I see entity :entity with id :id - * + * * @param class-string $entityClass */ public function andISeeEntityInRepositoryWithId(string $entityClass, string $id): void @@ -54,7 +55,7 @@ public function andISeeEntityInRepositoryWithId(string $entityClass, string $id) /** * @Then I see entity :entity with id :id - * + * * @param class-string $entityClass */ public function thenISeeEntityInRepositoryWithId(string $entityClass, string $id): void @@ -64,7 +65,8 @@ public function thenISeeEntityInRepositoryWithId(string $entityClass, string $id /** * @Then I see entity :entity with properties: - * + * @And I see entity :entity with properties: + * * @param class-string $entityClass */ public function andISeeEntityInRepositoryWithProperties(string $entityClass, PyStringNode $string): void @@ -88,19 +90,20 @@ private function seeInRepository(int $count, string $entityClass, ?array $params if (null !== $params) { $metadata = $this->manager->getClassMetadata($entityClass); + $paramIndex = 0; foreach ($params as $columnName => $columnValue) { + $isEmbeddedPath = str_contains($columnName, '.'); + $paramName = $isEmbeddedPath ? 'p' . $paramIndex++ : $columnName; + if ($columnValue === null) { $query->andWhere(sprintf('e.%s IS NULL', $columnName)); + } elseif (!$isEmbeddedPath && $this->isJsonField($metadata, $columnName)) { + // Handle JSON fields with proper DQL (skip for embedded paths) + $this->addJsonFieldCondition($query, $columnName, $columnValue); } else { - if ($this->isJsonField($metadata, $columnName)) { - // Handle JSON fields with proper DQL - $this->addJsonFieldCondition($query, $columnName, $columnValue); - } else { - // Regular field comparison - $query->andWhere(sprintf('e.%s = :%s', $columnName, $columnName)) - ->setParameter($columnName, $columnValue); - } + $query->andWhere(sprintf('e.%s = :%s', $columnName, $paramName)) + ->setParameter($paramName, $columnValue); } } } @@ -117,7 +120,7 @@ private function seeInRepository(int $count, string $entityClass, ?array $params /** * Check if a field is mapped as JSON type - * + * * @param \Doctrine\ORM\Mapping\ClassMetadata $metadata */ private function isJsonField(\Doctrine\ORM\Mapping\ClassMetadata $metadata, string $fieldName): bool @@ -139,6 +142,7 @@ private function isJsonField(\Doctrine\ORM\Mapping\ClassMetadata $metadata, stri */ private function addJsonFieldCondition(QueryBuilder $query, string $fieldName, $expectedValue): void { + /** @var AbstractPlatform $platform */ $platform = $this->manager->getConnection()->getDatabasePlatform(); $platformName = $platform->getName(); diff --git a/tests/Unit/Context/ORMContextTest.php b/tests/Unit/Context/ORMContextTest.php index c0f9d37..1586cd5 100644 --- a/tests/Unit/Context/ORMContextTest.php +++ b/tests/Unit/Context/ORMContextTest.php @@ -31,7 +31,7 @@ public function testAndISeeCountInRepository(): void public function testAndISeeCountInRepositoryFailed(): void { $context = $this->createContext('App\Entity\SomeEntity', self::COUNT); - self::expectException(RuntimeException::class); + $this->expectException(RuntimeException::class); $context->andISeeInRepository(self::COUNT + 1, 'App\Entity\SomeEntity'); } @@ -44,7 +44,7 @@ public function testThenISeeCountInRepository(): void public function testThenISeeCountInRepositoryFailed(): void { $context = $this->createContext('App\Entity\SomeEntity', self::COUNT); - self::expectException(RuntimeException::class); + $this->expectException(RuntimeException::class); $context->thenISeeInRepository(self::COUNT + 1, 'App\Entity\SomeEntity'); } @@ -581,4 +581,275 @@ public function testSeeInRepositoryWithJsonFieldPropertiesCountMismatch(): void $method->invoke($context, 1, 'App\Entity\TestEntity', $expectedProperties); } + + public function testSeeInRepositoryWithEmbeddedPropertyPath(): void + { + $expectedProperties = [ + 'value.amount' => '500000', + 'value.currency' => 'USD', + ]; + + // Mock ClassMetadata - not called for embedded paths + $metadata = $this->createMock(ClassMetadata::class); + $metadata->expects(self::never()) + ->method('hasField'); + + // Mock QueryBuilder + $queryBuilder = $this->createMock(QueryBuilder::class); + $queryBuilder->expects(self::once()) + ->method('from') + ->with('App\Entity\Balance', 'e') + ->willReturnSelf(); + $queryBuilder->expects(self::once()) + ->method('select') + ->with('count(e)') + ->willReturnSelf(); + + // Expect two andWhere calls with indexed parameters + $queryBuilder->expects(self::exactly(2)) + ->method('andWhere') + ->withConsecutive( + ['e.value.amount = :p0'], + ['e.value.currency = :p1'] + ) + ->willReturnSelf(); + + $queryBuilder->expects(self::exactly(2)) + ->method('setParameter') + ->withConsecutive( + ['p0', '500000'], + ['p1', 'USD'] + ) + ->willReturnSelf(); + + // Mock Query + $query = $this->createMock(Query::class); + $query->expects(self::once()) + ->method('getSingleScalarResult') + ->willReturn(1); + + $queryBuilder->expects(self::once()) + ->method('getQuery') + ->willReturn($query); + + // Mock EntityManager + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once()) + ->method('createQueryBuilder') + ->willReturn($queryBuilder); + $entityManager->expects(self::once()) + ->method('getClassMetadata') + ->with('App\Entity\Balance') + ->willReturn($metadata); + + $context = new ORMContext($entityManager); + + $reflection = new \ReflectionClass($context); + $method = $reflection->getMethod('seeInRepository'); + $method->setAccessible(true); + + $method->invoke($context, 1, 'App\Entity\Balance', $expectedProperties); + } + + public function testSeeInRepositoryWithMixedRegularAndEmbeddedProperties(): void + { + $expectedProperties = [ + 'customerId' => 'customer-123', + 'balanceValue.amount' => '100000', + 'status' => 'active', + ]; + + // Mock ClassMetadata - only called for non-embedded fields + $metadata = $this->createMock(ClassMetadata::class); + $metadata->expects(self::exactly(2)) + ->method('hasField') + ->willReturnMap([ + ['customerId', true], + ['status', true] + ]); + $metadata->expects(self::exactly(2)) + ->method('getFieldMapping') + ->willReturnMap([ + ['customerId', ['type' => 'string']], + ['status', ['type' => 'string']] + ]); + + // Mock QueryBuilder + $queryBuilder = $this->createMock(QueryBuilder::class); + $queryBuilder->expects(self::once()) + ->method('from') + ->with('App\Entity\Balance', 'e') + ->willReturnSelf(); + $queryBuilder->expects(self::once()) + ->method('select') + ->with('count(e)') + ->willReturnSelf(); + + // Expect three andWhere calls + $queryBuilder->expects(self::exactly(3)) + ->method('andWhere') + ->withConsecutive( + ['e.customerId = :customerId'], + ['e.balanceValue.amount = :p0'], + ['e.status = :status'] + ) + ->willReturnSelf(); + + $queryBuilder->expects(self::exactly(3)) + ->method('setParameter') + ->withConsecutive( + ['customerId', 'customer-123'], + ['p0', '100000'], + ['status', 'active'] + ) + ->willReturnSelf(); + + // Mock Query + $query = $this->createMock(Query::class); + $query->expects(self::once()) + ->method('getSingleScalarResult') + ->willReturn(1); + + $queryBuilder->expects(self::once()) + ->method('getQuery') + ->willReturn($query); + + // Mock EntityManager + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once()) + ->method('createQueryBuilder') + ->willReturn($queryBuilder); + $entityManager->expects(self::once()) + ->method('getClassMetadata') + ->with('App\Entity\Balance') + ->willReturn($metadata); + + $context = new ORMContext($entityManager); + + $reflection = new \ReflectionClass($context); + $method = $reflection->getMethod('seeInRepository'); + $method->setAccessible(true); + + $method->invoke($context, 1, 'App\Entity\Balance', $expectedProperties); + } + + public function testSeeInRepositoryWithNullEmbeddedProperty(): void + { + $expectedProperties = [ + 'value.amount' => null, + ]; + + // Mock ClassMetadata - not called for embedded paths + $metadata = $this->createMock(ClassMetadata::class); + $metadata->expects(self::never()) + ->method('hasField'); + + // Mock QueryBuilder + $queryBuilder = $this->createMock(QueryBuilder::class); + $queryBuilder->expects(self::once()) + ->method('from') + ->with('App\Entity\Balance', 'e') + ->willReturnSelf(); + $queryBuilder->expects(self::once()) + ->method('select') + ->with('count(e)') + ->willReturnSelf(); + + // Expect IS NULL for null value + $queryBuilder->expects(self::once()) + ->method('andWhere') + ->with('e.value.amount IS NULL') + ->willReturnSelf(); + + // No setParameter for null values + $queryBuilder->expects(self::never()) + ->method('setParameter'); + + // Mock Query + $query = $this->createMock(Query::class); + $query->expects(self::once()) + ->method('getSingleScalarResult') + ->willReturn(1); + + $queryBuilder->expects(self::once()) + ->method('getQuery') + ->willReturn($query); + + // Mock EntityManager + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once()) + ->method('createQueryBuilder') + ->willReturn($queryBuilder); + $entityManager->expects(self::once()) + ->method('getClassMetadata') + ->with('App\Entity\Balance') + ->willReturn($metadata); + + $context = new ORMContext($entityManager); + + $reflection = new \ReflectionClass($context); + $method = $reflection->getMethod('seeInRepository'); + $method->setAccessible(true); + + $method->invoke($context, 1, 'App\Entity\Balance', $expectedProperties); + } + + public function testSeeInRepositoryWithEmbeddedPropertyCountMismatch(): void + { + $expectedProperties = [ + 'value.amount' => '500000', + ]; + + // Mock ClassMetadata + $metadata = $this->createMock(ClassMetadata::class); + $metadata->expects(self::never()) + ->method('hasField'); + + // Mock QueryBuilder + $queryBuilder = $this->createMock(QueryBuilder::class); + $queryBuilder->expects(self::once()) + ->method('from') + ->willReturnSelf(); + $queryBuilder->expects(self::once()) + ->method('select') + ->willReturnSelf(); + $queryBuilder->expects(self::once()) + ->method('andWhere') + ->with('e.value.amount = :p0') + ->willReturnSelf(); + $queryBuilder->expects(self::once()) + ->method('setParameter') + ->with('p0', '500000') + ->willReturnSelf(); + + // Mock Query - return 0 to trigger exception + $query = $this->createMock(Query::class); + $query->expects(self::once()) + ->method('getSingleScalarResult') + ->willReturn(0); + + $queryBuilder->expects(self::once()) + ->method('getQuery') + ->willReturn($query); + + // Mock EntityManager + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once()) + ->method('createQueryBuilder') + ->willReturn($queryBuilder); + $entityManager->expects(self::once()) + ->method('getClassMetadata') + ->willReturn($metadata); + + $context = new ORMContext($entityManager); + + $reflection = new \ReflectionClass($context); + $method = $reflection->getMethod('seeInRepository'); + $method->setAccessible(true); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Real count is 0, not 1'); + + $method->invoke($context, 1, 'App\Entity\Balance', $expectedProperties); + } }