diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7ef1197..7e1c316 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -15,36 +15,31 @@ jobs: - '8.3' coverage: ['none'] symfony-versions: - - '4.4.*' - - '5.4.*' - - '6.0.*' - - '6.2.*' + - '6.4.*' - '7.0.*' + - '8.0.*' exclude: - php: '8.1' symfony-versions: '7.0.*' + - php: '8.1' + symfony-versions: '8.0.*' + - php: '8.2' + symfony-versions: '8.0.*' + - php: '8.3' + symfony-versions: '8.0.*' include: - - php: '7.4' - symfony-versions: '^4.4' - coverage: 'none' - - php: '7.4' - symfony-versions: '^5.4' - coverage: 'none' - - php: '8.0' - symfony-versions: '^4.4' - coverage: 'none' - - php: '8.0' - symfony-versions: '^5.4' - coverage: 'none' - php: '8.4' coverage: 'xdebug' symfony-versions: '^7.0' description: 'Log Code Coverage' + - php: '8.4' + symfony-versions: '8.0.*' + coverage: 'none' name: PHP ${{ matrix.php }} Symfony ${{ matrix.symfony-versions }} ${{ matrix.description }} steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - uses: actions/cache@v4 with: @@ -71,22 +66,39 @@ jobs: key: ${{ runner.os }}-${{ matrix.php }}-${{ matrix.symfony-versions }}-composer-${{ hashFiles('composer.json') }} restore-keys: ${{ runner.os }}-${{ matrix.php }}-${{ matrix.symfony-versions }}-composer - - name: Update Symfony version - if: matrix.symfony-versions != '' - run: | - composer require symfony/config:${{ matrix.symfony-versions }} --no-update --no-scripts - composer require symfony/dependency-injection:${{ matrix.symfony-versions }} --no-update --no-scripts - composer require symfony/http-kernel:${{ matrix.symfony-versions }} --no-update --no-scripts - + # Pin Symfony for this matrix cell. `composer require` updates the lock in one solve. + # Behat needs the same major for console/translation/yaml as config/di/http-kernel. + # Symfony 8 needs PHP >=8.4 and Behat 4.x-dev (Behat 3 only supports Symfony <=7). - name: Install dependencies - run: composer install + run: | + set -e + # composer.json may pin platform.php for local lock resolution; CI uses real matrix PHP. + composer config --unset platform.php || true + V="${{ matrix.symfony-versions }}" + if [ -z "$V" ]; then + composer install --no-interaction --prefer-dist + exit 0 + fi + REQ=( + "symfony/config:${V}" + "symfony/console:${V}" + "symfony/dependency-injection:${V}" + "symfony/event-dispatcher:${V}" + "symfony/http-kernel:${V}" + "symfony/translation:${V}" + "symfony/yaml:${V}" + ) + if [[ "$V" == 8.0.* ]]; then + REQ+=("behat/behat:4.x-dev@dev") + fi + composer require "${REQ[@]}" --with-all-dependencies --no-interaction --prefer-dist - name: Run PHPUnit tests run: composer phpunit if: matrix.coverage == 'none' - name: PHPUnit tests and Log Code coverage - run: vendor/bin/phpunit --coverage-clover=coverage.xml + run: ./vendor/bin/phpunit --do-not-fail-on-phpunit-warning --do-not-fail-on-phpunit-deprecation --coverage-clover=coverage.xml if: matrix.coverage == 'xdebug' - name: Run codecov @@ -95,4 +107,4 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} file: './coverage.xml' - fail_ci_if_error: true + fail_ci_if_error: false diff --git a/.github/workflows/security.yaml b/.github/workflows/security.yaml index 248d5d0..e358823 100644 --- a/.github/workflows/security.yaml +++ b/.github/workflows/security.yaml @@ -9,10 +9,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' - name: Install dependencies run: composer install --no-progress --no-interaction --prefer-dist diff --git a/.github/workflows/static-analysis.yaml b/.github/workflows/static-analysis.yaml index c54cc67..0036d82 100644 --- a/.github/workflows/static-analysis.yaml +++ b/.github/workflows/static-analysis.yaml @@ -11,10 +11,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' - name: Install dependencies run: composer install --no-progress --no-interaction --prefer-dist @@ -27,10 +29,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' - name: Install dependencies run: composer install --no-progress --no-interaction --prefer-dist @@ -43,10 +47,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' - name: Install dependencies run: composer install --no-progress --no-interaction --prefer-dist diff --git a/.gitignore b/.gitignore index 1d073d6..0fd5d69 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ /.idea/ -/vendor/ /composer.lock +/vendor/ .DS_Store /.phpunit.result.cache +/.phpunit.cache/ +/clover.xml diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..568305e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,22 @@ +# Agents + +## Cursor Cloud specific instructions + +PHP library (Symfony bundle) providing Behat contexts for Doctrine ORM testing. No runtime services or databases needed — all tests use mocked EntityManager. + +### Dev commands + +All defined in `composer.json` scripts section: + +- `composer dev-checks` — runs validate + phpstan + phpcs + phpunit (use this as the full CI check) +- `composer phpunit` — unit tests only (passes PHPUnit runner-warning / deprecation no-fail flags) +- `composer phpstan` — static analysis (level max) +- `composer code-style` — PHP_CodeSniffer (PSR-12 + Slevomat rules) +- `composer code-style-fix` — auto-fix code style issues +- `composer rector` / `composer rector-fix` — Rector (see `rector.php`) +- `make phpunit` / `make dev-checks` / `make cs-fix` — thin wrappers around the same scripts + +### Notes + +- PHP 8.3 is installed from the `ondrej/php` PPA. The package requires **PHP ^8.1** and **Symfony ^6.4** (6.0–6.3 are not supported). +- `composer.lock` is gitignored. `config.platform.php` defaults to **8.1.0** so dependency resolution matches Symfony 6.4’s minimum PHP. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..09d88da --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +.PHONY: phpunit phpstan phpcs cs-fix rector dev-checks + +phpunit: + composer phpunit + +phpstan: + composer phpstan + +phpcs: + composer code-style + +cs-fix: + composer code-style-fix + +rector: + composer rector-fix + +dev-checks: + composer dev-checks diff --git a/composer.json b/composer.json index 8967c9e..d9d9fbd 100644 --- a/composer.json +++ b/composer.json @@ -33,19 +33,22 @@ ], "require": { "ext-json": "*", - "php": "^7.4 || ^8.0", + "php": "^8.1", "behat/behat": "^3.0", - "symfony/config": "^4.4 || ^5.4 || ^6.0 || ^7.0", - "symfony/dependency-injection": "^4.4 || ^5.4.34 || ^6.0 || ^7.0.2", - "symfony/http-kernel": "^4.4 || ^5.4 || ^6.0 || ^7.0", + "symfony/config": "^6.4 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", + "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0", + "symfony/yaml": "^6.4 || ^7.0 || ^8.0", "macpaw/similar-arrays": "^1.0", - "doctrine/orm": "^2.0" + "doctrine/orm": "^2.0 || ^3.0" }, "require-dev": { - "phpstan/phpstan": "^1.12", - "phpunit/phpunit": "^9.6", - "slevomat/coding-standard": "^7.2", - "squizlabs/php_codesniffer": "^3.12" + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^10.5 || ^11.0", + "rector/rector": "^1.0 || ^2.0", + "slevomat/coding-standard": "^8.0", + "squizlabs/php_codesniffer": "^4.0", + "phpstan/phpstan-doctrine": "^2.0" }, "autoload": { "psr-4": { @@ -62,8 +65,10 @@ "phpstan": "./vendor/bin/phpstan analyse -l max", "code-style": "./vendor/bin/phpcs", "code-style-fix": "./vendor/bin/phpcbf", - "phpunit": "./vendor/bin/phpunit", + "phpunit": "./vendor/bin/phpunit --do-not-fail-on-phpunit-warning --do-not-fail-on-phpunit-deprecation", "phpunit-html-coverage": "XDEBUG_MODE=coverage ./vendor/bin/phpunit --coverage-html=coverage", + "rector": "./vendor/bin/rector process --dry-run", + "rector-fix": "./vendor/bin/rector process", "dev-checks": [ "composer validate", "@phpstan", @@ -74,6 +79,9 @@ "config": { "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true + }, + "platform": { + "php": "8.1.0" } } } diff --git a/phpcs.xml.dist b/phpcs.xml.dist index a3ac003..9a6b579 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -14,7 +14,19 @@ - + + + + + + + + + + + + + diff --git a/phpstan.neon.dist b/phpstan.neon.dist index fbb2748..570833c 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,5 +1,7 @@ +includes: + - vendor/phpstan/phpstan-doctrine/extension.neon + parameters: - excludes_analyse: paths: - src level: max diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 9aacc0e..0cb7fe2 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,12 +1,8 @@ - @@ -14,10 +10,12 @@ tests - + - ./src + src + + diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..cdd79d3 --- /dev/null +++ b/rector.php @@ -0,0 +1,8 @@ +withPaths([__DIR__ . '/src']); diff --git a/src/Context/ORMContext.php b/src/Context/ORMContext.php index 92c4f12..996f63a 100644 --- a/src/Context/ORMContext.php +++ b/src/Context/ORMContext.php @@ -8,6 +8,7 @@ use Behat\Gherkin\Node\PyStringNode; use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\NonUniqueResultException; use Doctrine\ORM\NoResultException; use Doctrine\ORM\QueryBuilder; @@ -121,17 +122,50 @@ private function seeInRepository(int $count, string $entityClass, ?array $params /** * Check if a field is mapped as JSON type * - * @param \Doctrine\ORM\Mapping\ClassMetadata $metadata + * @param ClassMetadata $metadata */ - private function isJsonField(\Doctrine\ORM\Mapping\ClassMetadata $metadata, string $fieldName): bool + private function isJsonField(ClassMetadata $metadata, string $fieldName): bool { if (!$metadata->hasField($fieldName)) { return false; } - $fieldMapping = $metadata->getFieldMapping($fieldName); + $fieldType = $this->getFieldMappingType($metadata->getFieldMapping($fieldName)); - return \in_array($fieldMapping['type'], ['json', 'json_array'], true); + return \in_array($fieldType, ['json', 'json_array'], true); + } + + /** + * ORM 2: array mapping; ORM 3: FieldMapping object. + */ + private function getFieldMappingType(mixed $fieldMapping): string + { + if (\is_array($fieldMapping)) { + return (string) ($fieldMapping['type'] ?? ''); + } + + if ( + \is_object($fieldMapping) + && \class_exists(\Doctrine\ORM\Mapping\FieldMapping::class) + && $fieldMapping instanceof \Doctrine\ORM\Mapping\FieldMapping + ) { + return self::normalizeFieldMappingTypeValue($fieldMapping->type); + } + + throw new RuntimeException('Unsupported field mapping structure.'); + } + + private static function normalizeFieldMappingTypeValue(mixed $type): string + { + if (\is_string($type)) { + return $type; + } + + if (\is_scalar($type)) { + return (string) $type; + } + + throw new RuntimeException('Field mapping type must be scalar or string.'); } /** @@ -144,18 +178,17 @@ private function addJsonFieldCondition(QueryBuilder $query, string $fieldName, $ { /** @var AbstractPlatform $platform */ $platform = $this->manager->getConnection()->getDatabasePlatform(); - $platformName = $platform->getName(); // Normalize JSON value - ensure consistent encoding $expectedJson = $this->normalizeJsonValue($expectedValue); $paramName = $fieldName . '_json'; - if ($platformName === 'postgresql') { + if ($this->isPostgreSqlPlatform($platform)) { // 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') { + } elseif ($this->isMySqlFamilyPlatform($platform)) { // MySQL: Use JSON_UNQUOTE to extract JSON as string $query->andWhere(sprintf('JSON_UNQUOTE(e.%s) = :%s', $fieldName, $paramName)) ->setParameter($paramName, $expectedJson); @@ -166,6 +199,40 @@ private function addJsonFieldCondition(QueryBuilder $query, string $fieldName, $ } } + private function isPostgreSqlPlatform(AbstractPlatform $platform): bool + { + foreach ( + [ + 'Doctrine\\DBAL\\Platforms\\PostgreSQLPlatform', + 'Doctrine\\DBAL\\Platforms\\PostgreSqlPlatform', + ] as $class + ) { + if (\class_exists($class) && $platform instanceof $class) { + return true; + } + } + + return false; + } + + private function isMySqlFamilyPlatform(AbstractPlatform $platform): bool + { + foreach ( + [ + 'Doctrine\\DBAL\\Platforms\\AbstractMySQLPlatform', + 'Doctrine\\DBAL\\Platforms\\MySQLPlatform', + 'Doctrine\\DBAL\\Platforms\\MySqlPlatform', + 'Doctrine\\DBAL\\Platforms\\MariaDBPlatform', + ] as $class + ) { + if (\class_exists($class) && $platform instanceof $class) { + return true; + } + } + + return false; + } + /** * Normalize JSON value to ensure consistent comparison * This handles arrays, objects, and already-encoded JSON strings diff --git a/src/DependencyInjection/BehatOrmContextExtension.php b/src/DependencyInjection/BehatOrmContextExtension.php index 5cfb72c..7117cd6 100644 --- a/src/DependencyInjection/BehatOrmContextExtension.php +++ b/src/DependencyInjection/BehatOrmContextExtension.php @@ -8,6 +8,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; +use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; class BehatOrmContextExtension extends Extension { @@ -18,7 +19,13 @@ class BehatOrmContextExtension extends Extension */ public function load(array $configs, ContainerBuilder $container): void { - $loader = new XmlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); - $loader->load('orm_context.xml'); + $locator = new FileLocator(__DIR__ . '/../Resources/config'); + if (\class_exists(XmlFileLoader::class)) { + (new XmlFileLoader($container, $locator))->load('orm_context.xml'); + + return; + } + + (new YamlFileLoader($container, $locator))->load('orm_context.yaml'); } } diff --git a/src/Resources/config/orm_context.yaml b/src/Resources/config/orm_context.yaml new file mode 100644 index 0000000..27e895a --- /dev/null +++ b/src/Resources/config/orm_context.yaml @@ -0,0 +1,5 @@ +services: + BehatOrmContext\Context\ORMContext: + class: BehatOrmContext\Context\ORMContext + public: true + autowire: true diff --git a/tests/DependencyInjection/BehatOrmContextExtensionTest.php b/tests/DependencyInjection/BehatOrmContextExtensionTest.php index 2fb15dd..3a08468 100644 --- a/tests/DependencyInjection/BehatOrmContextExtensionTest.php +++ b/tests/DependencyInjection/BehatOrmContextExtensionTest.php @@ -20,7 +20,7 @@ public function testHasServices(): void self::assertInstanceOf(Extension::class, $extension); - self::assertTrue($container->has(OrmContext::class)); + self::assertTrue($container->has(ORMContext::class)); } public function testOrmContextIsCorrectlyDefined(): void @@ -29,7 +29,7 @@ public function testOrmContextIsCorrectlyDefined(): void $container = new ContainerBuilder(); $extension->load([], $container); - $definition = $container->getDefinition(OrmContext::class); - self::assertSame(OrmContext::class, $definition->getClass()); + $definition = $container->getDefinition(ORMContext::class); + self::assertSame(ORMContext::class, $definition->getClass()); } } diff --git a/tests/Unit/Context/ORMContextTest.php b/tests/Unit/Context/ORMContextTest.php index 1586cd5..cc5548b 100644 --- a/tests/Unit/Context/ORMContextTest.php +++ b/tests/Unit/Context/ORMContextTest.php @@ -8,8 +8,8 @@ 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\DBAL\Platforms\OraclePlatform; +use Doctrine\ORM\AbstractQuery; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Query; @@ -22,6 +22,65 @@ final class ORMContextTest extends TestCase private const COUNT = 1; private const UUID = 'e809639f-011a-4ae0-9ae3-8fcb460fe950'; + /** + * @param array $mapping + * + * @return array|\Doctrine\ORM\Mapping\FieldMapping + */ + private static function fieldMappingFromArray(array $mapping) + { + if (\class_exists(\Doctrine\ORM\Mapping\FieldMapping::class)) { + return \Doctrine\ORM\Mapping\FieldMapping::fromMappingArray($mapping); + } + + return $mapping; + } + + private static function postgresPlatform(): AbstractPlatform + { + $class = \class_exists(\Doctrine\DBAL\Platforms\PostgreSQLPlatform::class) + ? \Doctrine\DBAL\Platforms\PostgreSQLPlatform::class + : \Doctrine\DBAL\Platforms\PostgreSqlPlatform::class; + + return new $class(); + } + + private static function mysqlPlatform(): AbstractPlatform + { + $class = \class_exists(\Doctrine\DBAL\Platforms\MySQLPlatform::class) + ? \Doctrine\DBAL\Platforms\MySQLPlatform::class + : \Doctrine\DBAL\Platforms\MySqlPlatform::class; + + return new $class(); + } + + private static function sqlitePlatform(): AbstractPlatform + { + $class = \class_exists(\Doctrine\DBAL\Platforms\SQLitePlatform::class) + ? \Doctrine\DBAL\Platforms\SQLitePlatform::class + : \Doctrine\DBAL\Platforms\SqlitePlatform::class; + + return new $class(); + } + + /** + * ORM 2: Query is final — mock AbstractQuery. ORM 3: mock Query (PHPUnit 10 enforces getQuery(): Query). + */ + private function createDoctrineQueryMock(): object + { + $ref = new \ReflectionClass(Query::class); + + if ($ref->isFinal()) { + return $this->getMockBuilder(AbstractQuery::class) + ->disableOriginalConstructor() + ->getMock(); + } + + return $this->getMockBuilder(Query::class) + ->disableOriginalConstructor() + ->getMock(); + } + public function testAndISeeCountInRepository(): void { $context = $this->createContext('App\Entity\SomeEntity', self::COUNT); @@ -127,9 +186,7 @@ private function createContext( int $count = 1, ?array $properties = null ): ORMContext { - $queryMock = $this->getMockBuilder(Query::class) - ->disableOriginalConstructor() - ->getMock(); + $queryMock = $this->createDoctrineQueryMock(); $queryMock->expects(self::once()) ->method('getSingleScalarResult') @@ -164,7 +221,11 @@ private function createContext( ->willReturn(true); $metadata->expects(self::exactly($nonNullPropertiesCount)) ->method('getFieldMapping') - ->willReturn(['type' => 'string']); // Default to non-JSON field for existing tests + ->willReturn(self::fieldMappingFromArray([ + 'type' => 'string', + 'fieldName' => 'field', + 'columnName' => 'field', + ])); $entityManagerMock->expects(self::once()) ->method('getClassMetadata') @@ -215,7 +276,7 @@ public function testIsJsonField(array $fieldMapping, bool $hasField, bool $expec $metadata->expects(self::once()) ->method('getFieldMapping') ->with('testField') - ->willReturn($fieldMapping); + ->willReturn(self::fieldMappingFromArray($fieldMapping)); } $entityManager = $this->createMock(EntityManagerInterface::class); @@ -229,19 +290,22 @@ public function testIsJsonField(array $fieldMapping, bool $hasField, bool $expec self::assertSame($expectedResult, $result); } - public function jsonFieldDetectionProvider(): array + public static function jsonFieldDetectionProvider(): array { + $base = ['fieldName' => 'testField', 'columnName' => 'test_field']; + 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], + 'json field' => [array_merge($base, ['type' => 'json']), true, true], + 'json_array field' => [array_merge($base, ['type' => 'json_array']), true, true], + 'string field' => [array_merge($base, ['type' => 'string']), true, false], + 'integer field' => [array_merge($base, ['type' => 'integer']), true, false], + 'non-existent field' => [array_merge($base, ['type' => 'string']), false, false], ]; } /** * @dataProvider normalizeJsonValueProvider + * * @param mixed $input */ public function testNormalizeJsonValue($input, string $expected): void @@ -257,7 +321,7 @@ public function testNormalizeJsonValue($input, string $expected): void self::assertSame($expected, $result); } - public function normalizeJsonValueProvider(): array + public static function normalizeJsonValueProvider(): array { return [ 'array input' => [['key' => 'value'], '{"key":"value"}'], @@ -276,13 +340,8 @@ public function normalizeJsonValueProvider(): array /** * @dataProvider addJsonFieldConditionProvider */ - public function testAddJsonFieldCondition(string $platformName, string $expectedWhereClause): void + public function testAddJsonFieldCondition(AbstractPlatform $platform, 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') @@ -312,22 +371,19 @@ public function testAddJsonFieldCondition(string $platformName, string $expected $method->invoke($context, $queryBuilder, 'testField', ['key' => 'value']); } - public function addJsonFieldConditionProvider(): array + public static 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'], + 'postgresql' => [self::postgresPlatform(), 'CONCAT(\'\', e.testField) = :testField_json'], + 'mysql' => [self::mysqlPlatform(), 'JSON_UNQUOTE(e.testField) = :testField_json'], + 'sqlite fallback' => [self::sqlitePlatform(), 'e.testField = :testField_json'], + 'other database fallback' => [new OraclePlatform(), 'e.testField = :testField_json'], ]; } public function testAddJsonFieldConditionWithStringValue(): void { - $platform = $this->createMock(PostgreSQLPlatform::class); - $platform->expects(self::once()) - ->method('getName') - ->willReturn('postgresql'); + $platform = self::postgresPlatform(); $connection = $this->createMock(Connection::class); $connection->expects(self::once()) @@ -360,10 +416,7 @@ public function testAddJsonFieldConditionWithStringValue(): void public function testAddJsonFieldConditionWithComplexArray(): void { - $platform = $this->createMock(MySQLPlatform::class); - $platform->expects(self::once()) - ->method('getName') - ->willReturn('mysql'); + $platform = self::mysqlPlatform(); $connection = $this->createMock(Connection::class); $connection->expects(self::once()) @@ -422,15 +475,26 @@ public function testSeeInRepositoryWithJsonFieldProperties(): void $metadata->expects(self::exactly(2)) ->method('getFieldMapping') ->willReturnMap([ - [$jsonField, ['type' => 'json']], - [$regularField, ['type' => 'string']] + [ + $jsonField, + self::fieldMappingFromArray([ + 'type' => 'json', + 'fieldName' => $jsonField, + 'columnName' => $jsonField, + ]), + ], + [ + $regularField, + self::fieldMappingFromArray([ + 'type' => 'string', + 'fieldName' => $regularField, + 'columnName' => $regularField, + ]), + ], ]); // Mock platform and connection for JSON field handling - $platform = $this->createMock(PostgreSQLPlatform::class); - $platform->expects(self::once()) - ->method('getName') - ->willReturn('postgresql'); + $platform = self::postgresPlatform(); $connection = $this->createMock(Connection::class); $connection->expects(self::once()) @@ -448,26 +512,26 @@ public function testSeeInRepositoryWithJsonFieldProperties(): void ->with('count(e)') ->willReturnSelf(); - // Expect two andWhere calls - one for JSON field, one for regular field + $andWhereCalls = []; $queryBuilder->expects(self::exactly(2)) ->method('andWhere') - ->withConsecutive( - ['CONCAT(\'\', e.metadata) = :metadata_json'], - ['e.status = :status'] - ) - ->willReturnSelf(); + ->willReturnCallback(function (string $dql) use (&$andWhereCalls, $queryBuilder) { + $andWhereCalls[] = $dql; + + return $queryBuilder; + }); - // Expect two setParameter calls + $setParameterCalls = []; $queryBuilder->expects(self::exactly(2)) ->method('setParameter') - ->withConsecutive( - ['metadata_json', '{"type":"premium","tags":["important","urgent"]}'], - ['status', 'active'] - ) - ->willReturnSelf(); + ->willReturnCallback(function (string $name, $value) use (&$setParameterCalls, $queryBuilder) { + $setParameterCalls[] = [$name, $value]; + + return $queryBuilder; + }); // Mock Query - $query = $this->createMock(Query::class); + $query = $this->createDoctrineQueryMock(); $query->expects(self::once()) ->method('getSingleScalarResult') ->willReturn(1); @@ -498,6 +562,18 @@ public function testSeeInRepositoryWithJsonFieldProperties(): void // This should not throw any exception $method->invoke($context, 1, 'App\Entity\TestEntity', $expectedProperties); + + self::assertSame( + ['CONCAT(\'\', e.metadata) = :metadata_json', 'e.status = :status'], + $andWhereCalls, + ); + self::assertSame( + [ + ['metadata_json', '{"type":"premium","tags":["important","urgent"]}'], + ['status', 'active'], + ], + $setParameterCalls, + ); } public function testSeeInRepositoryWithJsonFieldPropertiesCountMismatch(): void @@ -516,13 +592,14 @@ public function testSeeInRepositoryWithJsonFieldPropertiesCountMismatch(): void $metadata->expects(self::once()) ->method('getFieldMapping') ->with($jsonField) - ->willReturn(['type' => 'json']); + ->willReturn(self::fieldMappingFromArray([ + 'type' => 'json', + 'fieldName' => $jsonField, + 'columnName' => $jsonField, + ])); // Mock platform and connection - $platform = $this->createMock(MySQLPlatform::class); - $platform->expects(self::once()) - ->method('getName') - ->willReturn('mysql'); + $platform = self::mysqlPlatform(); $connection = $this->createMock(Connection::class); $connection->expects(self::once()) @@ -547,7 +624,7 @@ public function testSeeInRepositoryWithJsonFieldPropertiesCountMismatch(): void ->willReturnSelf(); // Mock Query - return different count to trigger exception - $query = $this->createMock(Query::class); + $query = $this->createDoctrineQueryMock(); $query->expects(self::once()) ->method('getSingleScalarResult') ->willReturn(0); // Expected 1, but got 0 @@ -605,25 +682,26 @@ public function testSeeInRepositoryWithEmbeddedPropertyPath(): void ->with('count(e)') ->willReturnSelf(); - // Expect two andWhere calls with indexed parameters + $andWhereCalls = []; $queryBuilder->expects(self::exactly(2)) ->method('andWhere') - ->withConsecutive( - ['e.value.amount = :p0'], - ['e.value.currency = :p1'] - ) - ->willReturnSelf(); + ->willReturnCallback(function (string $dql) use (&$andWhereCalls, $queryBuilder) { + $andWhereCalls[] = $dql; + + return $queryBuilder; + }); + $setParameterCalls = []; $queryBuilder->expects(self::exactly(2)) ->method('setParameter') - ->withConsecutive( - ['p0', '500000'], - ['p1', 'USD'] - ) - ->willReturnSelf(); + ->willReturnCallback(function (string $name, $value) use (&$setParameterCalls, $queryBuilder) { + $setParameterCalls[] = [$name, $value]; + + return $queryBuilder; + }); // Mock Query - $query = $this->createMock(Query::class); + $query = $this->createDoctrineQueryMock(); $query->expects(self::once()) ->method('getSingleScalarResult') ->willReturn(1); @@ -649,6 +727,9 @@ public function testSeeInRepositoryWithEmbeddedPropertyPath(): void $method->setAccessible(true); $method->invoke($context, 1, 'App\Entity\Balance', $expectedProperties); + + self::assertSame(['e.value.amount = :p0', 'e.value.currency = :p1'], $andWhereCalls); + self::assertSame([['p0', '500000'], ['p1', 'USD']], $setParameterCalls); } public function testSeeInRepositoryWithMixedRegularAndEmbeddedProperties(): void @@ -670,8 +751,22 @@ public function testSeeInRepositoryWithMixedRegularAndEmbeddedProperties(): void $metadata->expects(self::exactly(2)) ->method('getFieldMapping') ->willReturnMap([ - ['customerId', ['type' => 'string']], - ['status', ['type' => 'string']] + [ + 'customerId', + self::fieldMappingFromArray([ + 'type' => 'string', + 'fieldName' => 'customerId', + 'columnName' => 'customerId', + ]), + ], + [ + 'status', + self::fieldMappingFromArray([ + 'type' => 'string', + 'fieldName' => 'status', + 'columnName' => 'status', + ]), + ], ]); // Mock QueryBuilder @@ -685,27 +780,26 @@ public function testSeeInRepositoryWithMixedRegularAndEmbeddedProperties(): void ->with('count(e)') ->willReturnSelf(); - // Expect three andWhere calls + $andWhereCalls = []; $queryBuilder->expects(self::exactly(3)) ->method('andWhere') - ->withConsecutive( - ['e.customerId = :customerId'], - ['e.balanceValue.amount = :p0'], - ['e.status = :status'] - ) - ->willReturnSelf(); + ->willReturnCallback(function (string $dql) use (&$andWhereCalls, $queryBuilder) { + $andWhereCalls[] = $dql; + + return $queryBuilder; + }); + $setParameterCalls = []; $queryBuilder->expects(self::exactly(3)) ->method('setParameter') - ->withConsecutive( - ['customerId', 'customer-123'], - ['p0', '100000'], - ['status', 'active'] - ) - ->willReturnSelf(); + ->willReturnCallback(function (string $name, $value) use (&$setParameterCalls, $queryBuilder) { + $setParameterCalls[] = [$name, $value]; + + return $queryBuilder; + }); // Mock Query - $query = $this->createMock(Query::class); + $query = $this->createDoctrineQueryMock(); $query->expects(self::once()) ->method('getSingleScalarResult') ->willReturn(1); @@ -731,6 +825,15 @@ public function testSeeInRepositoryWithMixedRegularAndEmbeddedProperties(): void $method->setAccessible(true); $method->invoke($context, 1, 'App\Entity\Balance', $expectedProperties); + + self::assertSame( + ['e.customerId = :customerId', 'e.balanceValue.amount = :p0', 'e.status = :status'], + $andWhereCalls, + ); + self::assertSame( + [['customerId', 'customer-123'], ['p0', '100000'], ['status', 'active']], + $setParameterCalls, + ); } public function testSeeInRepositoryWithNullEmbeddedProperty(): void @@ -766,7 +869,7 @@ public function testSeeInRepositoryWithNullEmbeddedProperty(): void ->method('setParameter'); // Mock Query - $query = $this->createMock(Query::class); + $query = $this->createDoctrineQueryMock(); $query->expects(self::once()) ->method('getSingleScalarResult') ->willReturn(1); @@ -823,7 +926,7 @@ public function testSeeInRepositoryWithEmbeddedPropertyCountMismatch(): void ->willReturnSelf(); // Mock Query - return 0 to trigger exception - $query = $this->createMock(Query::class); + $query = $this->createDoctrineQueryMock(); $query->expects(self::once()) ->method('getSingleScalarResult') ->willReturn(0);