diff --git a/src/Context/ORMContext.php b/src/Context/ORMContext.php index 38a73dc..8c09877 100644 --- a/src/Context/ORMContext.php +++ b/src/Context/ORMContext.php @@ -9,6 +9,8 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\NonUniqueResultException; use Doctrine\ORM\NoResultException; +use Doctrine\ORM\QueryBuilder; +use JsonException; use RuntimeException; final class ORMContext implements Context @@ -22,6 +24,8 @@ public function __construct(EntityManagerInterface $manager) /** * @And I see :count entities :entityClass + * + * @param class-string $entityClass */ public function andISeeInRepository(int $count, string $entityClass): void { @@ -30,6 +34,8 @@ 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 { @@ -38,6 +44,8 @@ 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 { @@ -46,6 +54,8 @@ 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 { @@ -54,6 +64,8 @@ public function thenISeeEntityInRepositoryWithId(string $entityClass, string $id /** * @Then I see entity :entity with properties: + * + * @param class-string $entityClass */ public function andISeeEntityInRepositoryWithProperties(string $entityClass, PyStringNode $string): void { @@ -62,6 +74,7 @@ public function andISeeEntityInRepositoryWithProperties(string $entityClass, PyS } /** + * @param class-string $entityClass * @param array $params * * @throws NonUniqueResultException @@ -74,12 +87,20 @@ private function seeInRepository(int $count, string $entityClass, ?array $params ->select('count(e)'); if (null !== $params) { + $metadata = $this->manager->getClassMetadata($entityClass); + foreach ($params as $columnName => $columnValue) { if ($columnValue === null) { $query->andWhere(sprintf('e.%s IS NULL', $columnName)); } else { - $query->andWhere(sprintf('e.%s = :%s', $columnName, $columnName)) - ->setParameter($columnName, $columnValue); + 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); + } } } } @@ -93,4 +114,74 @@ 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 + { + if (!$metadata->hasField($fieldName)) { + return false; + } + + $fieldMapping = $metadata->getFieldMapping($fieldName); + + return \in_array($fieldMapping['type'], ['json', 'json_array'], true); + } + + /** + * Add JSON field condition using DQL-compatible functions + * Uses CONCAT for PostgreSQL to convert JSON to string for comparison + * + * @param mixed $expectedValue + */ + private function addJsonFieldCondition(QueryBuilder $query, string $fieldName, $expectedValue): void + { + $platform = $this->manager->getConnection()->getDatabasePlatform(); + $platformName = $platform->getName(); + + // Normalize JSON value - ensure consistent encoding + $expectedJson = $this->normalizeJsonValue($expectedValue); + $paramName = $fieldName . '_json'; + + if ($platformName === 'postgresql') { + // PostgreSQL: Use CONCAT to convert JSON to string for comparison + // CONCAT('', field) effectively casts JSON to text in a DQL-compatible way + $query->andWhere(sprintf('CONCAT(\'\', e.%s) = :%s', $fieldName, $paramName)) + ->setParameter($paramName, $expectedJson); + } elseif ($platformName === 'mysql') { + // MySQL: Use JSON_UNQUOTE to extract JSON as string + $query->andWhere(sprintf('JSON_UNQUOTE(e.%s) = :%s', $fieldName, $paramName)) + ->setParameter($paramName, $expectedJson); + } else { + // Fallback for other databases (SQLite, etc.) + $query->andWhere(sprintf('e.%s = :%s', $fieldName, $paramName)) + ->setParameter($paramName, $expectedJson); + } + } + + /** + * Normalize JSON value to ensure consistent comparison + * This handles arrays, objects, and already-encoded JSON strings + * + * @param mixed $value + */ + private function normalizeJsonValue($value): string + { + if (is_string($value)) { + // If it's already a JSON string, decode and re-encode for normalization + try { + $decoded = json_decode($value, true, 512, JSON_THROW_ON_ERROR); + return json_encode($decoded, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } catch (JsonException $e) { + // If it's not valid JSON, treat as regular string + return json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } + } + + // For arrays/objects, encode with consistent flags + return json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } } diff --git a/tests/Unit/Context/ORMContextTest.php b/tests/Unit/Context/ORMContextTest.php index f721a9d..c0f9d37 100644 --- a/tests/Unit/Context/ORMContextTest.php +++ b/tests/Unit/Context/ORMContextTest.php @@ -6,7 +6,12 @@ use Behat\Gherkin\Node\PyStringNode; use BehatOrmContext\Context\ORMContext; +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\DBAL\Platforms\MySQLPlatform; +use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Query; use Doctrine\ORM\QueryBuilder; use PHPUnit\Framework\TestCase; @@ -146,6 +151,26 @@ private function createContext( )->willReturn($queryBuilderMock); if (null !== $properties) { + // Mock ClassMetadata for field type checking + $metadata = $this->createMock(ClassMetadata::class); + + // Only non-null properties trigger field type checking + $nonNullPropertiesCount = count(array_filter($properties, function ($value) { + return !is_null($value); + })); + + $metadata->expects(self::exactly($nonNullPropertiesCount)) + ->method('hasField') + ->willReturn(true); + $metadata->expects(self::exactly($nonNullPropertiesCount)) + ->method('getFieldMapping') + ->willReturn(['type' => 'string']); // Default to non-JSON field for existing tests + + $entityManagerMock->expects(self::once()) + ->method('getClassMetadata') + ->with($entityName) + ->willReturn($metadata); + foreach ($properties as $name => $value) { $queryBuilderMock->expects(self::exactly(count($properties))) ->method('andWhere') @@ -174,4 +199,386 @@ private function createContext( return new ORMContext($entityManagerMock); } + + /** + * @dataProvider jsonFieldDetectionProvider + */ + public function testIsJsonField(array $fieldMapping, bool $hasField, bool $expectedResult): void + { + $metadata = $this->createMock(ClassMetadata::class); + $metadata->expects(self::once()) + ->method('hasField') + ->with('testField') + ->willReturn($hasField); + + if ($hasField) { + $metadata->expects(self::once()) + ->method('getFieldMapping') + ->with('testField') + ->willReturn($fieldMapping); + } + + $entityManager = $this->createMock(EntityManagerInterface::class); + $context = new ORMContext($entityManager); + + $reflection = new \ReflectionClass($context); + $method = $reflection->getMethod('isJsonField'); + $method->setAccessible(true); + + $result = $method->invoke($context, $metadata, 'testField'); + self::assertSame($expectedResult, $result); + } + + public function jsonFieldDetectionProvider(): array + { + return [ + 'json field' => [['type' => 'json'], true, true], + 'json_array field' => [['type' => 'json_array'], true, true], + 'string field' => [['type' => 'string'], true, false], + 'integer field' => [['type' => 'integer'], true, false], + 'non-existent field' => [[], false, false], + ]; + } + + /** + * @dataProvider normalizeJsonValueProvider + * @param mixed $input + */ + public function testNormalizeJsonValue($input, string $expected): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $context = new ORMContext($entityManager); + + $reflection = new \ReflectionClass($context); + $method = $reflection->getMethod('normalizeJsonValue'); + $method->setAccessible(true); + + $result = $method->invoke($context, $input); + self::assertSame($expected, $result); + } + + public function normalizeJsonValueProvider(): array + { + return [ + 'array input' => [['key' => 'value'], '{"key":"value"}'], + 'object input' => [(object)['key' => 'value'], '{"key":"value"}'], + 'valid json string' => ['{"key":"value"}', '{"key":"value"}'], + 'nested array' => [['items' => [1, 2, 3]], '{"items":[1,2,3]}'], + 'regular string' => ['not json', '"not json"'], + 'null value' => [null, 'null'], + 'boolean true' => [true, 'true'], + 'boolean false' => [false, 'false'], + 'integer' => [42, '42'], + 'invalid json string' => ['not valid json{', '"not valid json{"'], + ]; + } + + /** + * @dataProvider addJsonFieldConditionProvider + */ + public function testAddJsonFieldCondition(string $platformName, string $expectedWhereClause): void + { + $platform = $this->createMock(AbstractPlatform::class); + $platform->expects(self::once()) + ->method('getName') + ->willReturn($platformName); + + $connection = $this->createMock(Connection::class); + $connection->expects(self::once()) + ->method('getDatabasePlatform') + ->willReturn($platform); + + $queryBuilder = $this->createMock(QueryBuilder::class); + $queryBuilder->expects(self::once()) + ->method('andWhere') + ->with($expectedWhereClause) + ->willReturnSelf(); + $queryBuilder->expects(self::once()) + ->method('setParameter') + ->with('testField_json', '{"key":"value"}') + ->willReturnSelf(); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once()) + ->method('getConnection') + ->willReturn($connection); + + $context = new ORMContext($entityManager); + + $reflection = new \ReflectionClass($context); + $method = $reflection->getMethod('addJsonFieldCondition'); + $method->setAccessible(true); + + $method->invoke($context, $queryBuilder, 'testField', ['key' => 'value']); + } + + public function addJsonFieldConditionProvider(): array + { + return [ + 'postgresql' => ['postgresql', 'CONCAT(\'\', e.testField) = :testField_json'], + 'mysql' => ['mysql', 'JSON_UNQUOTE(e.testField) = :testField_json'], + 'sqlite fallback' => ['sqlite', 'e.testField = :testField_json'], + 'other database fallback' => ['oracle', 'e.testField = :testField_json'], + ]; + } + + public function testAddJsonFieldConditionWithStringValue(): void + { + $platform = $this->createMock(PostgreSQLPlatform::class); + $platform->expects(self::once()) + ->method('getName') + ->willReturn('postgresql'); + + $connection = $this->createMock(Connection::class); + $connection->expects(self::once()) + ->method('getDatabasePlatform') + ->willReturn($platform); + + $queryBuilder = $this->createMock(QueryBuilder::class); + $queryBuilder->expects(self::once()) + ->method('andWhere') + ->with('CONCAT(\'\', e.testField) = :testField_json') + ->willReturnSelf(); + $queryBuilder->expects(self::once()) + ->method('setParameter') + ->with('testField_json', '"test string"') + ->willReturnSelf(); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once()) + ->method('getConnection') + ->willReturn($connection); + + $context = new ORMContext($entityManager); + + $reflection = new \ReflectionClass($context); + $method = $reflection->getMethod('addJsonFieldCondition'); + $method->setAccessible(true); + + $method->invoke($context, $queryBuilder, 'testField', 'test string'); + } + + public function testAddJsonFieldConditionWithComplexArray(): void + { + $platform = $this->createMock(MySQLPlatform::class); + $platform->expects(self::once()) + ->method('getName') + ->willReturn('mysql'); + + $connection = $this->createMock(Connection::class); + $connection->expects(self::once()) + ->method('getDatabasePlatform') + ->willReturn($platform); + + $queryBuilder = $this->createMock(QueryBuilder::class); + $queryBuilder->expects(self::once()) + ->method('andWhere') + ->with('JSON_UNQUOTE(e.metadata) = :metadata_json') + ->willReturnSelf(); + $queryBuilder->expects(self::once()) + ->method('setParameter') + ->with('metadata_json', '{"items":[1,2,3],"nested":{"key":"value"}}') + ->willReturnSelf(); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once()) + ->method('getConnection') + ->willReturn($connection); + + $context = new ORMContext($entityManager); + + $reflection = new \ReflectionClass($context); + $method = $reflection->getMethod('addJsonFieldCondition'); + $method->setAccessible(true); + + $complexData = [ + 'items' => [1, 2, 3], + 'nested' => ['key' => 'value'] + ]; + + $method->invoke($context, $queryBuilder, 'metadata', $complexData); + } + + public function testSeeInRepositoryWithJsonFieldProperties(): void + { + $jsonField = 'metadata'; + $jsonValue = ['type' => 'premium', 'tags' => ['important', 'urgent']]; + $regularField = 'status'; + $regularValue = 'active'; + + $expectedProperties = [ + $jsonField => $jsonValue, + $regularField => $regularValue + ]; + + // Mock ClassMetadata + $metadata = $this->createMock(ClassMetadata::class); + $metadata->expects(self::exactly(2)) + ->method('hasField') + ->willReturnMap([ + [$jsonField, true], + [$regularField, true] + ]); + $metadata->expects(self::exactly(2)) + ->method('getFieldMapping') + ->willReturnMap([ + [$jsonField, ['type' => 'json']], + [$regularField, ['type' => 'string']] + ]); + + // Mock platform and connection for JSON field handling + $platform = $this->createMock(PostgreSQLPlatform::class); + $platform->expects(self::once()) + ->method('getName') + ->willReturn('postgresql'); + + $connection = $this->createMock(Connection::class); + $connection->expects(self::once()) + ->method('getDatabasePlatform') + ->willReturn($platform); + + // Mock QueryBuilder with expectations for both fields + $queryBuilder = $this->createMock(QueryBuilder::class); + $queryBuilder->expects(self::once()) + ->method('from') + ->with('App\Entity\TestEntity', 'e') + ->willReturnSelf(); + $queryBuilder->expects(self::once()) + ->method('select') + ->with('count(e)') + ->willReturnSelf(); + + // Expect two andWhere calls - one for JSON field, one for regular field + $queryBuilder->expects(self::exactly(2)) + ->method('andWhere') + ->withConsecutive( + ['CONCAT(\'\', e.metadata) = :metadata_json'], + ['e.status = :status'] + ) + ->willReturnSelf(); + + // Expect two setParameter calls + $queryBuilder->expects(self::exactly(2)) + ->method('setParameter') + ->withConsecutive( + ['metadata_json', '{"type":"premium","tags":["important","urgent"]}'], + ['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\TestEntity') + ->willReturn($metadata); + $entityManager->expects(self::once()) + ->method('getConnection') + ->willReturn($connection); + + $context = new ORMContext($entityManager); + + // Use reflection to call the private method + $reflection = new \ReflectionClass($context); + $method = $reflection->getMethod('seeInRepository'); + $method->setAccessible(true); + + // This should not throw any exception + $method->invoke($context, 1, 'App\Entity\TestEntity', $expectedProperties); + } + + public function testSeeInRepositoryWithJsonFieldPropertiesCountMismatch(): void + { + $jsonField = 'settings'; + $jsonValue = ['theme' => 'dark', 'notifications' => true]; + + $expectedProperties = [$jsonField => $jsonValue]; + + // Mock ClassMetadata + $metadata = $this->createMock(ClassMetadata::class); + $metadata->expects(self::once()) + ->method('hasField') + ->with($jsonField) + ->willReturn(true); + $metadata->expects(self::once()) + ->method('getFieldMapping') + ->with($jsonField) + ->willReturn(['type' => 'json']); + + // Mock platform and connection + $platform = $this->createMock(MySQLPlatform::class); + $platform->expects(self::once()) + ->method('getName') + ->willReturn('mysql'); + + $connection = $this->createMock(Connection::class); + $connection->expects(self::once()) + ->method('getDatabasePlatform') + ->willReturn($platform); + + // 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('JSON_UNQUOTE(e.settings) = :settings_json') + ->willReturnSelf(); + $queryBuilder->expects(self::once()) + ->method('setParameter') + ->with('settings_json', '{"theme":"dark","notifications":true}') + ->willReturnSelf(); + + // Mock Query - return different count to trigger exception + $query = $this->createMock(Query::class); + $query->expects(self::once()) + ->method('getSingleScalarResult') + ->willReturn(0); // Expected 1, but got 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); + $entityManager->expects(self::once()) + ->method('getConnection') + ->willReturn($connection); + + $context = new ORMContext($entityManager); + + // Use reflection to call the private method + $reflection = new \ReflectionClass($context); + $method = $reflection->getMethod('seeInRepository'); + $method->setAccessible(true); + + // This should throw a RuntimeException + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Real count is 0, not 1'); + + $method->invoke($context, 1, 'App\Entity\TestEntity', $expectedProperties); + } }