From cb71a9d43c9c9c7ec12a3bfa1aae962a5cb6118a Mon Sep 17 00:00:00 2001 From: Cristoforo Cervino Date: Fri, 13 Feb 2026 16:23:33 +0100 Subject: [PATCH 1/8] update environment --- .github/workflows/ci.yml | 69 +++++++++++++++++++ .github/workflows/workflow.yml | 62 ----------------- .gitignore | 2 + Makefile | 23 +++++++ docker-compose.override.yml.dist | 8 +++ docker-compose.yml | 13 ++++ docker/Dockerfile | 45 ++++++++++++ docker/Dockerfile.php82 | 45 ++++++++++++ docker/php/conf.d/xdebug.ini | 5 ++ phpstan.neon | 2 + .../TimestampableEventSubscriber.php | 2 +- tests/App/AppKernel.php | 67 ++++++++++++++++++ tests/App/TimestampableAppKernel.php | 9 +++ tests/App/config/config.php | 54 +++++++++++++++ tests/Functional/BaseFunctionalTest.php | 62 +++++++++++++++++ tests/Functional/MappingTest.php | 37 ++++++---- tests/Functional/SetupTest.php | 31 +++++---- tests/Functional/TimestampableTest.php | 37 ++++++---- .../HttpKernel/AndanteTimestampableKernel.php | 59 ---------------- tests/HttpKernel/config/basic.php | 17 ----- tests/HttpKernel/config/config_test.php | 39 ----------- tests/HttpKernel/config/custom_mapping.php | 27 -------- tests/KernelTestCase.php | 31 +-------- 23 files changed, 469 insertions(+), 277 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/workflow.yml create mode 100644 Makefile create mode 100644 docker-compose.override.yml.dist create mode 100644 docker-compose.yml create mode 100644 docker/Dockerfile create mode 100644 docker/Dockerfile.php82 create mode 100644 docker/php/conf.d/xdebug.ini create mode 100644 tests/App/AppKernel.php create mode 100644 tests/App/TimestampableAppKernel.php create mode 100644 tests/App/config/config.php create mode 100644 tests/Functional/BaseFunctionalTest.php delete mode 100644 tests/HttpKernel/AndanteTimestampableKernel.php delete mode 100644 tests/HttpKernel/config/basic.php delete mode 100644 tests/HttpKernel/config/config_test.php delete mode 100644 tests/HttpKernel/config/custom_mapping.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bcb8a19 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: CI + +on: + push: + branches: [ "main", "develop" ] + pull_request: + branches: [ "main", "develop" ] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - php-version: '8.2' + symfony-version: '6.4.*' + - php-version: '8.2' + symfony-version: '7.0.*' + + name: PHP ${{ matrix.php-version }} · SF ${{ matrix.symfony-version }} + + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: test + ports: + - 3306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: mbstring, xml, ctype, iconv, intl, json + coverage: ${{ matrix.symfony-version == '7.0.*' && 'pcov' || 'none' }} + + - name: Constrain Symfony version + run: | + sed -ri 's/"symfony\/([^"]+)": "[^"]+"/"symfony\/\1": "${{ matrix.symfony-version }}"/g' composer.json + + - name: Install Composer dependencies + uses: ramsey/composer-install@v3 + with: + composer-options: "--prefer-dist --no-progress --no-interaction --optimize-autoloader" + + - name: Run PHP-CS-Fixer + if: matrix.symfony-version == '7.0.*' + run: vendor/bin/php-cs-fixer fix --dry-run --stop-on-violation --using-cache=no + + - name: Run PHPStan + if: matrix.symfony-version == '7.0.*' + run: vendor/bin/phpstan analyse src tests --configuration=phpstan.neon --memory-limit=1G + + - name: Run PHPUnit tests + env: + DATABASE_URL: mysql://root:root@127.0.0.1:3306/test?serverVersion=8.0 + run: vendor/bin/phpunit ${{ matrix.symfony-version == '7.0.*' && '--coverage-clover coverage.xml' || '' }} + + - name: Upload coverage to Codecov + if: matrix.symfony-version == '7.0.*' + uses: codecov/codecov-action@v4 + with: + files: ./coverage.xml + fail_ci_if_error: false diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml deleted file mode 100644 index c10d2e9..0000000 --- a/.github/workflows/workflow.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: timestampable-bundle-CI -on: - [push, pull_request] -jobs: - phpstan: - runs-on: ubuntu-latest - name: PHPStan - steps: - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: 8.2 - - name: Checkout - uses: actions/checkout@v2 - - name: composer install - run: composer install - - name: PHPStan - run: composer phpstan - env: - REQUIRE_DEV: true - cs-fixer: - runs-on: ubuntu-latest - name: PHP-CS-Fixer - steps: - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: 8.2 - - name: Checkout - uses: actions/checkout@v2 - - name: composer install - run: composer install - - name: CS Check - run: composer cs-check - tests: - name: PHP ${{ matrix.php }} / SF ^${{ matrix.symfony }} - runs-on: ubuntu-latest - strategy: - matrix: - php: ['8.2'] - symfony: ['6.2.*', '6.3.*', '6.4.*', '7.0.*'] - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Setup MySQL - run: | - sudo systemctl enable mysql.service - sudo systemctl start mysql.service - mysql -uroot -proot -h localhost -e "CREATE DATABASE test CHARACTER SET utf8 COLLATE utf8_general_ci;" - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - - if: matrix.symfony == '5.4.*' - run: | - sed -ri 's/"symfony\/framework-bundle": "(.+)"/"symfony\/framework-bundle": "${{ matrix.symfony }}"/' composer.json; - sed -ri 's/"symfony\/yaml": "(.+)"/"symfony\/yaml": "${{ matrix.symfony }}"/' composer.json; - - if: matrix.symfony != '5.4.*' - run: | - sed -ri 's/"symfony\/(.+)": "(.+)"/"symfony\/\1": "${{ matrix.symfony }}"/' composer.json; - - run: composer update --no-interaction --no-progress --ansi - - run: composer phpunit diff --git a/.gitignore b/.gitignore index d7762a4..6a79138 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ composer.lock !bin/symfony_requirements /vendor/ /phpunit.xml +docker-compose.override.yml +coverage.xml diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..79b6a84 --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +.PHONY: setup php cs-fixer phpstan tests ci-local + +setup: + rm -f composer.lock + docker-compose up --build -d php + docker-compose exec php composer install + +php: + docker-compose exec php sh + +cs-fixer: + docker-compose exec php vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --allow-risky=yes + +phpstan: + docker-compose exec php vendor/bin/phpstan analyse src tests --configuration=phpstan.neon --memory-limit=1G + +tests: + rm -rf var/cache/test + mkdir -p var/cache/test + docker-compose exec php vendor/bin/phpunit + +ci-local: + act -j build diff --git a/docker-compose.override.yml.dist b/docker-compose.override.yml.dist new file mode 100644 index 0000000..309b9b2 --- /dev/null +++ b/docker-compose.override.yml.dist @@ -0,0 +1,8 @@ +services: + php: + environment: + XDEBUG_MODE: develop,debug + XDEBUG_CLIENT_HOST: host.docker.internal + XDEBUG_CLIENT_PORT: 9090 + PHP_IDE_CONFIG: serverName=andanteproject-timestampable-bundle + XDEBUG_START_WITH_REQUEST: yes diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d4e8687 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + php: + build: + context: . + dockerfile: docker/Dockerfile.php82 + volumes: + - .:/var/www/html + environment: + XDEBUG_MODE: ${XDEBUG_MODE:-develop,debug} + XDEBUG_CLIENT_HOST: ${XDEBUG_CLIENT_HOST:-host.docker.internal} + XDEBUG_CLIENT_PORT: ${XDEBUG_CLIENT_PORT:-9090} + PHP_IDE_CONFIG: ${PHP_IDE_CONFIG:-serverName=andanteproject-timestampable-bundle} + XDEBUG_START_WITH_REQUEST: ${XDEBUG_START_WITH_REQUEST:-yes} diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..94d1150 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,45 @@ +FROM php:8.2-fpm-alpine + +# Install dependencies +RUN apk add --no-cache \ + git \ + unzip \ + libzip-dev \ + icu-dev \ + libpq-dev \ + oniguruma-dev \ + libxml2-dev \ + autoconf \ + gcc \ + g++ \ + make \ + linux-headers \ + && docker-php-ext-install \ + pdo_mysql \ + zip \ + intl \ + pdo_pgsql \ + mbstring \ + soap \ + xml \ + && docker-php-ext-enable \ + pdo_mysql \ + zip \ + intl \ + pdo_pgsql \ + mbstring \ + soap \ + xml + +# Install Xdebug +RUN pecl install xdebug \ + && docker-php-ext-enable xdebug + +# Install Composer +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +COPY docker/php/conf.d/xdebug.ini /usr/local/etc/php/conf.d/xdebug.ini + +WORKDIR /var/www/html + +CMD ["php-fpm"] diff --git a/docker/Dockerfile.php82 b/docker/Dockerfile.php82 new file mode 100644 index 0000000..94d1150 --- /dev/null +++ b/docker/Dockerfile.php82 @@ -0,0 +1,45 @@ +FROM php:8.2-fpm-alpine + +# Install dependencies +RUN apk add --no-cache \ + git \ + unzip \ + libzip-dev \ + icu-dev \ + libpq-dev \ + oniguruma-dev \ + libxml2-dev \ + autoconf \ + gcc \ + g++ \ + make \ + linux-headers \ + && docker-php-ext-install \ + pdo_mysql \ + zip \ + intl \ + pdo_pgsql \ + mbstring \ + soap \ + xml \ + && docker-php-ext-enable \ + pdo_mysql \ + zip \ + intl \ + pdo_pgsql \ + mbstring \ + soap \ + xml + +# Install Xdebug +RUN pecl install xdebug \ + && docker-php-ext-enable xdebug + +# Install Composer +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +COPY docker/php/conf.d/xdebug.ini /usr/local/etc/php/conf.d/xdebug.ini + +WORKDIR /var/www/html + +CMD ["php-fpm"] diff --git a/docker/php/conf.d/xdebug.ini b/docker/php/conf.d/xdebug.ini new file mode 100644 index 0000000..d6c6351 --- /dev/null +++ b/docker/php/conf.d/xdebug.ini @@ -0,0 +1,5 @@ +zend_extension=xdebug.so +xdebug.mode=${XDEBUG_MODE} +xdebug.client_host=${XDEBUG_CLIENT_HOST} +xdebug.client_port=${XDEBUG_CLIENT_PORT} +xdebug.start_with_request=${XDEBUG_START_WITH_REQUEST} diff --git a/phpstan.neon b/phpstan.neon index 83fd84d..180c19b 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,3 +2,5 @@ parameters: level: 8 checkMissingIterableValueType: false checkGenericClassInNonGenericObjectType: false + excludePaths: + - src/DependencyInjection/Configuration.php diff --git a/src/EventSubscriber/TimestampableEventSubscriber.php b/src/EventSubscriber/TimestampableEventSubscriber.php index 45487bf..6f73472 100644 --- a/src/EventSubscriber/TimestampableEventSubscriber.php +++ b/src/EventSubscriber/TimestampableEventSubscriber.php @@ -22,7 +22,7 @@ class TimestampableEventSubscriber implements EventSubscriber public function __construct( Configuration $configuration, - ClockInterface $clock + ClockInterface $clock, ) { $this->configuration = $configuration; $this->clock = $clock; diff --git a/tests/App/AppKernel.php b/tests/App/AppKernel.php new file mode 100644 index 0000000..d8b0d29 --- /dev/null +++ b/tests/App/AppKernel.php @@ -0,0 +1,67 @@ +> */ + protected array $config = []; + + /** + * @param array> $config + */ + public function __construct(string $environment, bool $debug, array $config = []) + { + parent::__construct($environment, $debug); + $this->config = $config; + } + + /** + * @return iterable + */ + public function registerBundles(): iterable + { + return [ + new FrameworkBundle(), + new DoctrineBundle(), + new AndanteTimestampableBundle(), + ]; + } + + public function registerContainerConfiguration(LoaderInterface $loader): void + { + $loader->load(__DIR__.'/config/config.php'); + + if (\count($this->config) > 0) { + $loader->load(function (ContainerBuilder $container): void { + foreach ($this->config as $extension => $config) { + $container->loadFromExtension($extension, $config); + } + }); + } + } + + public function getProjectDir(): string + { + return \dirname(__DIR__, 2); + } + + public function getCacheDir(): string + { + return \dirname(__DIR__, 2).'/var/cache/test/'.\hash('crc32b', (string) \json_encode($this->config)).'/'; + } + + public function getLogDir(): string + { + return \dirname(__DIR__, 2).'/var/logs/test/'; + } +} diff --git a/tests/App/TimestampableAppKernel.php b/tests/App/TimestampableAppKernel.php new file mode 100644 index 0000000..3a5db31 --- /dev/null +++ b/tests/App/TimestampableAppKernel.php @@ -0,0 +1,9 @@ +parameters() + ->set('kernel.secret', 'test_secret') + ->set('locale', 'en'); + + $frameworkConfig = [ + 'secret' => '%kernel.secret%', + 'test' => true, + ]; + if (Kernel::VERSION_ID >= 60100) { + $frameworkConfig['http_method_override'] = false; + } + if (Kernel::VERSION_ID >= 60400) { + $frameworkConfig['handle_all_throwables'] = true; + $frameworkConfig['php_errors'] = ['log' => true]; + } + $containerConfigurator->extension('framework', $frameworkConfig); + + $services = $containerConfigurator->services(); + $services->defaults() + ->autowire() + ->public() + ->autoconfigure(); + + $doctrineOrm = [ + 'naming_strategy' => 'doctrine.orm.naming_strategy.underscore_number_aware', + 'auto_mapping' => true, + 'mappings' => [ + 'Fixtures' => [ + 'is_bundle' => false, + 'dir' => '%kernel.project_dir%/tests/Fixtures/Entity/', + 'prefix' => 'Andante\TimestampableBundle\Tests\Fixtures\Entity', + 'alias' => 'Fixtures', + ], + ], + ]; + if (Kernel::VERSION_ID >= 60400) { + $doctrineOrm['mappings']['Fixtures']['type'] = 'attribute'; + } + + $containerConfigurator->extension('doctrine', [ + 'dbal' => [ + 'url' => '%env(resolve:DATABASE_URL)%', + ], + 'orm' => $doctrineOrm, + ]); +}; diff --git a/tests/Functional/BaseFunctionalTest.php b/tests/Functional/BaseFunctionalTest.php new file mode 100644 index 0000000..4f400bb --- /dev/null +++ b/tests/Functional/BaseFunctionalTest.php @@ -0,0 +1,62 @@ +getContainer(); + } + + protected function createSchema(): void + { + /** @var ManagerRegistry $manager */ + $manager = self::getTestContainer()->get('doctrine'); + /** @var EntityManagerInterface[] $ems */ + $ems = $manager->getManagers(); + /** @var EntityManagerInterface $em */ + $em = \reset($ems); + /** @var array $metadatas */ + $metadatas = $em->getMetadataFactory()->getAllMetadata(); + $schemaTool = new SchemaTool($em); + $schemaTool->dropSchema($metadatas); + $schemaTool->createSchema($metadatas); + } +} diff --git a/tests/Functional/MappingTest.php b/tests/Functional/MappingTest.php index 47ff118..07ea2d7 100644 --- a/tests/Functional/MappingTest.php +++ b/tests/Functional/MappingTest.php @@ -4,35 +4,44 @@ namespace Andante\TimestampableBundle\Tests\Functional; +use Andante\TimestampableBundle\Tests\App\TimestampableAppKernel; use Andante\TimestampableBundle\Tests\Fixtures\Entity\Address; use Andante\TimestampableBundle\Tests\Fixtures\Entity\Organization; -use Andante\TimestampableBundle\Tests\HttpKernel\AndanteTimestampableKernel; use Andante\TimestampableBundle\Tests\KernelTestCase; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadata; +use Symfony\Component\HttpKernel\KernelInterface; class MappingTest extends KernelTestCase { - protected function setUp(): void + protected static function createKernel(array $options = []): KernelInterface { - parent::setUp(); - self::bootKernel(); - } - - protected static function createKernel(array $options = []): AndanteTimestampableKernel - { - /** @var AndanteTimestampableKernel $kernel */ - $kernel = parent::createKernel($options); - $kernel->addConfig('/config/custom_mapping.php'); - - return $kernel; + return new TimestampableAppKernel('test', true, [ + 'andante_timestampable' => [ + 'default' => [ + 'created_at_property_name' => 'createdAt', + 'updated_at_property_name' => 'updatedAt', + ], + 'entity' => [ + Organization::class => [ + 'created_at_property_name' => 'createdAt', + ], + Address::class => [ + 'created_at_property_name' => 'created', + 'updated_at_property_name' => 'updated', + 'created_at_column_name' => 'created_date', + 'updated_at_column_name' => 'updated_date', + ], + ], + ], + ]); } public function testMapping(): void { /** @var EntityManagerInterface $em */ - $em = static::getContainer()->get('doctrine.orm.default_entity_manager'); + $em = static::getTestContainer()->get('doctrine.orm.default_entity_manager'); $classMetadata = $em->getClassMetadata(Organization::class); self::assertArrayHasKey('createdAt', $classMetadata->fieldMappings); self::assertArrayHasKey('updatedAt', $classMetadata->fieldMappings); diff --git a/tests/Functional/SetupTest.php b/tests/Functional/SetupTest.php index 68d74dc..73efc15 100644 --- a/tests/Functional/SetupTest.php +++ b/tests/Functional/SetupTest.php @@ -5,33 +5,34 @@ namespace Andante\TimestampableBundle\Tests\Functional; use Andante\TimestampableBundle\EventSubscriber\TimestampableEventSubscriber; -use Andante\TimestampableBundle\Tests\HttpKernel\AndanteTimestampableKernel; +use Andante\TimestampableBundle\Tests\App\TimestampableAppKernel; +use Andante\TimestampableBundle\Tests\Fixtures\Entity\Address; use Andante\TimestampableBundle\Tests\KernelTestCase; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Events; use Doctrine\Persistence\ManagerRegistry; +use Symfony\Component\HttpKernel\KernelInterface; class SetupTest extends KernelTestCase { - protected function setUp(): void + protected static function createKernel(array $options = []): KernelInterface { - parent::setUp(); - self::bootKernel(); - } - - protected static function createKernel(array $options = []): AndanteTimestampableKernel - { - /** @var AndanteTimestampableKernel $kernel */ - $kernel = parent::createKernel($options); - $kernel->addConfig('/config/basic.php'); - - return $kernel; + return new TimestampableAppKernel('test', true, [ + 'andante_timestampable' => [ + 'entity' => [ + Address::class => [ + 'created_at_property_name' => 'created', + 'updated_at_property_name' => 'updated', + ], + ], + ], + ]); } public function testSubscriberSetup(): void { /** @var ManagerRegistry $managerRegistry */ - $managerRegistry = self::getContainer()->get('doctrine'); + $managerRegistry = self::getTestContainer()->get('doctrine'); /** @var EntityManagerInterface $em */ foreach ($managerRegistry->getManagers() as $em) { $evm = $em->getEventManager(); @@ -40,7 +41,7 @@ public function testSubscriberSetup(): void foreach ($allListeners as $name => $listeners) { if (\in_array($name, [Events::prePersist, Events::preUpdate, Events::loadClassMetadata])) { $listenerRegistered = \array_reduce($listeners, static fn ( - bool $carry, $service + bool $carry, $service, ) => $carry ? $carry : $service instanceof TimestampableEventSubscriber, false); self::assertTrue($listenerRegistered); diff --git a/tests/Functional/TimestampableTest.php b/tests/Functional/TimestampableTest.php index a29751a..b39f935 100644 --- a/tests/Functional/TimestampableTest.php +++ b/tests/Functional/TimestampableTest.php @@ -4,34 +4,43 @@ namespace Andante\TimestampableBundle\Tests\Functional; +use Andante\TimestampableBundle\Tests\App\TimestampableAppKernel; use Andante\TimestampableBundle\Tests\Fixtures\Entity\Address; use Andante\TimestampableBundle\Tests\Fixtures\Entity\Organization; -use Andante\TimestampableBundle\Tests\HttpKernel\AndanteTimestampableKernel; use Andante\TimestampableBundle\Tests\KernelTestCase; use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\HttpKernel\KernelInterface; class TimestampableTest extends KernelTestCase { - protected function setUp(): void + protected static function createKernel(array $options = []): KernelInterface { - parent::setUp(); - self::bootKernel(); - } - - protected static function createKernel(array $options = []): AndanteTimestampableKernel - { - /** @var AndanteTimestampableKernel $kernel */ - $kernel = parent::createKernel($options); - $kernel->addConfig('/config/custom_mapping.php'); - - return $kernel; + return new TimestampableAppKernel('test', true, [ + 'andante_timestampable' => [ + 'default' => [ + 'created_at_property_name' => 'createdAt', + 'updated_at_property_name' => 'updatedAt', + ], + 'entity' => [ + Organization::class => [ + 'created_at_property_name' => 'createdAt', + ], + Address::class => [ + 'created_at_property_name' => 'created', + 'updated_at_property_name' => 'updated', + 'created_at_column_name' => 'created_date', + 'updated_at_column_name' => 'updated_date', + ], + ], + ], + ]); } public function testShouldSetTimestamps(): void { $this->createSchema(); /** @var EntityManagerInterface $em */ - $em = self::getContainer()->get('doctrine.orm.default_entity_manager'); + $em = self::getTestContainer()->get('doctrine.orm.default_entity_manager'); $address1 = (new Address())->setName('Address1'); $organization1 = (new Organization())->setName('Organization1'); diff --git a/tests/HttpKernel/AndanteTimestampableKernel.php b/tests/HttpKernel/AndanteTimestampableKernel.php deleted file mode 100644 index e6e2a88..0000000 --- a/tests/HttpKernel/AndanteTimestampableKernel.php +++ /dev/null @@ -1,59 +0,0 @@ -load(__DIR__.'/config/config_test.php'); - - foreach ($this->configs as $config) { - $loader->load($config); - } - } - - public function getCacheDir(): string - { - return \sprintf(__DIR__.'/../../var/cache/test/%s/', \hash('crc32b', (string) \json_encode($this->configs))); - } - - public function getLogDir(): string - { - return __DIR__.'/../../var/logs/test/'; - } - - public function setConfigs(array $configs): self - { - $this->configs = $configs; - - return $this; - } - - public function addConfig(string $configPath, bool $addKernelDirPrefix = true): self - { - $configPath = $addKernelDirPrefix ? __DIR__.$configPath : $configPath; - $this->configs[] = $configPath; - - return $this; - } -} diff --git a/tests/HttpKernel/config/basic.php b/tests/HttpKernel/config/basic.php deleted file mode 100644 index c5fe26b..0000000 --- a/tests/HttpKernel/config/basic.php +++ /dev/null @@ -1,17 +0,0 @@ -extension('andante_timestampable', [ - 'entity' => [ - Address::class => [ - 'created_at_property_name' => 'created', - 'updated_at_property_name' => 'updated', - ], - ], - ]); -}; diff --git a/tests/HttpKernel/config/config_test.php b/tests/HttpKernel/config/config_test.php deleted file mode 100644 index 7153ff9..0000000 --- a/tests/HttpKernel/config/config_test.php +++ /dev/null @@ -1,39 +0,0 @@ -parameters() - ->set('kernel.secret', 'secret') - ->set('locale', 'en'); - - $containerConfigurator->extension('framework', [ - 'test' => true, - ]); - - $services = $containerConfigurator->services(); - $services->defaults() - ->autowire() - ->public() - ->autoconfigure(); - - $containerConfigurator->extension('doctrine', [ - 'dbal' => [ - 'url' => '%env(resolve:DATABASE_URL)%', - ], - 'orm' => [ - 'naming_strategy' => 'doctrine.orm.naming_strategy.underscore_number_aware', - 'auto_mapping' => true, - 'mappings' => [ - 'Fixtures' => [ - 'is_bundle' => false, - 'dir' => '%kernel.project_dir%/tests/Fixtures/Entity/', - 'prefix' => 'Andante\TimestampableBundle\Tests\Fixtures\Entity', - 'alias' => 'Fixtures', - ], - ], - ], - ]); -}; diff --git a/tests/HttpKernel/config/custom_mapping.php b/tests/HttpKernel/config/custom_mapping.php deleted file mode 100644 index a78f00b..0000000 --- a/tests/HttpKernel/config/custom_mapping.php +++ /dev/null @@ -1,27 +0,0 @@ -extension('andante_timestampable', [ - 'default' => [ - 'created_at_property_name' => 'createdAt', - 'updated_at_property_name' => 'updatedAt', - ], - 'entity' => [ - Organization::class => [ - 'created_at_property_name' => 'createdAt', - ], - Address::class => [ - 'created_at_property_name' => 'created', - 'updated_at_property_name' => 'updated', - 'created_at_column_name' => 'created_date', - 'updated_at_column_name' => 'updated_date', - ], - ], - ]); -}; diff --git a/tests/KernelTestCase.php b/tests/KernelTestCase.php index 4cfde3c..8b67ebf 100644 --- a/tests/KernelTestCase.php +++ b/tests/KernelTestCase.php @@ -4,35 +4,8 @@ namespace Andante\TimestampableBundle\Tests; -use Andante\TimestampableBundle\Tests\HttpKernel\AndanteTimestampableKernel; -use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\Mapping\ClassMetadata; -use Doctrine\ORM\Tools\SchemaTool; -use Doctrine\Persistence\ManagerRegistry; -use Psr\Container\ContainerInterface; +use Andante\TimestampableBundle\Tests\Functional\BaseFunctionalTest; -/** - * @property ContainerInterface $container - */ -class KernelTestCase extends \Symfony\Bundle\FrameworkBundle\Test\KernelTestCase +class KernelTestCase extends BaseFunctionalTest { - protected static function getKernelClass(): string - { - return AndanteTimestampableKernel::class; - } - - protected function createSchema(): void - { - /** @var ManagerRegistry $manager */ - $manager = self::getContainer()->get('doctrine'); - /** @var EntityManagerInterface[] $ems */ - $ems = $manager->getManagers(); - /** @var EntityManagerInterface $em */ - $em = \reset($ems); - /** @var array $metadatas */ - $metadatas = $em->getMetadataFactory()->getAllMetadata(); - $schemaTool = new SchemaTool($em); - $schemaTool->dropSchema($metadatas); - $schemaTool->createSchema($metadatas); - } } From b74e75b1d6c49fc953046e85c466e5b5a56019ab Mon Sep 17 00:00:00 2001 From: Cristoforo Cervino Date: Fri, 13 Feb 2026 16:28:15 +0100 Subject: [PATCH 2/8] update readme --- README.md | 81 +++++++++++++++++++++++++++---------------------------- 1 file changed, 39 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 2f1e9e7..1d0e496 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ # Timestampable Bundle #### Symfony Bundle - [AndanteProject](https://github.com/andanteproject) [![Latest Version](https://img.shields.io/github/release/andanteproject/timestampable-bundle.svg)](https://github.com/andanteproject/timestampable-bundle/releases) -![Github actions](https://github.com/andanteproject/timestampable-bundle/actions/workflows/workflow.yml/badge.svg?branch=main) +![Github actions](https://github.com/andanteproject/timestampable-bundle/actions/workflows/ci.yml/badge.svg?branch=main) ![Framework](https://img.shields.io/badge/Symfony-4.x|5.x|6.x|7.x-informational?Style=flat&logo=symfony) ![Php8](https://img.shields.io/badge/PHP-%208.x-informational?style=flat&logo=php) -![PhpStan](https://img.shields.io/badge/PHPStan-Level%208-syccess?style=flat&logo=php) +![PhpStan](https://img.shields.io/badge/PHPStan-Level%208-success?style=flat&logo=php) -A Symfony Bundle to handle entities createdAt and updatedAt dates with Doctrine. 🕰 +A Symfony Bundle to handle entity `createdAt` and `updatedAt` dates with Doctrine. 🕰 ## Requirements Symfony 4.x-7.x and PHP 8.2. @@ -19,27 +19,26 @@ $ composer require andanteproject/timestampable-bundle ``` ## Features -- No configuration required to be ready to go but fully customizabile; +- No configuration required to get started; fully customizable; - `createdAt` and `updatedAt` properties are `?\DateTimeImmutable`; - Uses [Symfony Clock](https://symfony.com/doc/current/components/clock.html); - Does not override your `createdAt` and `updatedAt` values when you set them explicitly; -- No annotation/attributes required; +- No annotations or attributes required; - Works like magic ✨. ## Basic usage -After [install](#install), make sure you have the bundle registered in your symfony bundles list (`config/bundles.php`): +After [install](#install), ensure the bundle is registered in your Symfony bundles list (`config/bundles.php`): ```php return [ - /// bundles... + // bundles... Andante\TimestampableBundle\AndanteTimestampableBundle::class => ['all' => true], - /// bundles... + // bundles... ]; ``` -This should have been done automagically if you are using [Symfony Flex](https://flex.symfony.com). Otherwise, just register it by yourself. +This is done automatically if you use [Symfony Flex](https://flex.symfony.com). Otherwise, register it manually. - -Let's suppose we have a `App\Entity\Article` doctrine entity we want to track created and update dates. -All you have to do is to implement `Andante\TimestampableBundle\Timestampable\TimestampableInterface` and use `Andante\TimestampableBundle\Timestampable\TimestampableTrait` trait. +Suppose you have an `App\Entity\Article` Doctrine entity and want to track created and updated dates. +All you need to do is implement `Andante\TimestampableBundle\Timestampable\TimestampableInterface` and use the `Andante\TimestampableBundle\Timestampable\TimestampableTrait` trait. ```php Date: Fri, 13 Feb 2026 16:35:29 +0100 Subject: [PATCH 3/8] use sqlite in local environment --- docker/Dockerfile.php82 | 3 +++ phpunit.xml.dist | 2 -- tests/bootstrap.php | 6 ++++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docker/Dockerfile.php82 b/docker/Dockerfile.php82 index 94d1150..777f790 100644 --- a/docker/Dockerfile.php82 +++ b/docker/Dockerfile.php82 @@ -9,6 +9,7 @@ RUN apk add --no-cache \ libpq-dev \ oniguruma-dev \ libxml2-dev \ + sqlite-dev \ autoconf \ gcc \ g++ \ @@ -16,6 +17,7 @@ RUN apk add --no-cache \ linux-headers \ && docker-php-ext-install \ pdo_mysql \ + pdo_sqlite \ zip \ intl \ pdo_pgsql \ @@ -24,6 +26,7 @@ RUN apk add --no-cache \ xml \ && docker-php-ext-enable \ pdo_mysql \ + pdo_sqlite \ zip \ intl \ pdo_pgsql \ diff --git a/phpunit.xml.dist b/phpunit.xml.dist index c153079..0a9742a 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -14,8 +14,6 @@ - - diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 174d7fd..311993a 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,3 +1,9 @@ Date: Fri, 13 Feb 2026 16:41:31 +0100 Subject: [PATCH 4/8] enhance composer.json and gitlab ci --- .github/workflows/ci.yml | 40 +++++++++++++++++----------------------- composer.json | 4 ++-- 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcb8a19..4a6f91d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,23 +11,15 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - include: + php-version: ['8.2', '8.4', '8.5'] + symfony-version: ['5.4.*', '6.4.*', '7.0.*'] + doctrine-orm-version: ['2.20.*', '3.0.*'] + exclude: + # Doctrine ORM 3.0 requires PHP 8.4+ - php-version: '8.2' - symfony-version: '6.4.*' - - php-version: '8.2' - symfony-version: '7.0.*' - - name: PHP ${{ matrix.php-version }} · SF ${{ matrix.symfony-version }} + doctrine-orm-version: '3.0.*' - services: - mysql: - image: mysql:8.0 - env: - MYSQL_ROOT_PASSWORD: root - MYSQL_DATABASE: test - ports: - - 3306:3306 - options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + name: PHP ${{ matrix.php-version }} · SF ${{ matrix.symfony-version }} · Doctrine ORM ${{ matrix.doctrine-orm-version }} steps: - uses: actions/checkout@v4 @@ -36,33 +28,35 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} - extensions: mbstring, xml, ctype, iconv, intl, json - coverage: ${{ matrix.symfony-version == '7.0.*' && 'pcov' || 'none' }} + extensions: mbstring, xml, ctype, iconv, intl, json, pdo_sqlite, sqlite3 + coverage: ${{ matrix.php-version == '8.5' && matrix.symfony-version == '7.0.*' && matrix.doctrine-orm-version == '3.0.*' && 'pcov' || 'none' }} - name: Constrain Symfony version run: | sed -ri 's/"symfony\/([^"]+)": "[^"]+"/"symfony\/\1": "${{ matrix.symfony-version }}"/g' composer.json + - name: Constrain Doctrine ORM version + run: | + sed -ri 's/"doctrine\/orm": "[^"]+"/"doctrine\/orm": "${{ matrix.doctrine-orm-version }}"/' composer.json + - name: Install Composer dependencies uses: ramsey/composer-install@v3 with: composer-options: "--prefer-dist --no-progress --no-interaction --optimize-autoloader" - name: Run PHP-CS-Fixer - if: matrix.symfony-version == '7.0.*' + if: matrix.php-version == '8.2' && matrix.symfony-version == '5.4.*' && matrix.doctrine-orm-version == '2.20.*' run: vendor/bin/php-cs-fixer fix --dry-run --stop-on-violation --using-cache=no - name: Run PHPStan - if: matrix.symfony-version == '7.0.*' + if: matrix.php-version == '8.5' && matrix.symfony-version == '7.0.*' && matrix.doctrine-orm-version == '3.0.*' run: vendor/bin/phpstan analyse src tests --configuration=phpstan.neon --memory-limit=1G - name: Run PHPUnit tests - env: - DATABASE_URL: mysql://root:root@127.0.0.1:3306/test?serverVersion=8.0 - run: vendor/bin/phpunit ${{ matrix.symfony-version == '7.0.*' && '--coverage-clover coverage.xml' || '' }} + run: vendor/bin/phpunit ${{ matrix.php-version == '8.5' && matrix.symfony-version == '7.0.*' && matrix.doctrine-orm-version == '3.0.*' && '--coverage-clover coverage.xml' || '' }} - name: Upload coverage to Codecov - if: matrix.symfony-version == '7.0.*' + if: matrix.php-version == '8.5' && matrix.symfony-version == '7.0.*' && matrix.doctrine-orm-version == '3.0.*' uses: codecov/codecov-action@v4 with: files: ./coverage.xml diff --git a/composer.json b/composer.json index 7530588..664cf98 100644 --- a/composer.json +++ b/composer.json @@ -30,15 +30,15 @@ "php": "^8.2", "symfony/framework-bundle": "^5.0 | ^6.0 | ^7.0", "doctrine/common": "^2.13 || ^3.0", + "doctrine/doctrine-bundle": "^2.10 || ^3.0", "doctrine/event-manager": "^1.2 | ^2.0", + "doctrine/orm": "^2.15.3 || ^3.0", "symfony/clock": "^6.2 | ^7.0" }, "require-dev": { "ext-json": "*", "roave/security-advisories": "dev-master", - "doctrine/orm": "^2.15.3", "phpunit/phpunit": "^9.5", - "doctrine/doctrine-bundle": "^2.10", "phpstan/phpstan": "^1.2", "phpstan/phpstan-phpunit": "^1.0", "phpstan/extension-installer": "^1.1", From 934c619868be82b38ce562c14fb2d839464ac93b Mon Sep 17 00:00:00 2001 From: Cristoforo Cervino Date: Fri, 13 Feb 2026 16:45:30 +0100 Subject: [PATCH 5/8] update ci --- .github/workflows/ci.yml | 4 ++++ tests/App/config/config.php | 9 +++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a6f91d..7dab16d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,10 @@ jobs: - name: Constrain Symfony version run: | sed -ri 's/"symfony\/([^"]+)": "[^"]+"/"symfony\/\1": "${{ matrix.symfony-version }}"/g' composer.json + # symfony/clock does not exist for 5.4 (added in 6.2); keep it installable on 5.4 jobs + if [ "${{ matrix.symfony-version }}" = "5.4.*" ]; then + sed -ri 's/"symfony\/clock": "[^"]+"/"symfony\/clock": "^6.2 | ^7.0"/' composer.json + fi - name: Constrain Doctrine ORM version run: | diff --git a/tests/App/config/config.php b/tests/App/config/config.php index 41194b3..a607140 100644 --- a/tests/App/config/config.php +++ b/tests/App/config/config.php @@ -2,22 +2,23 @@ declare(strict_types=1); +use Composer\InstalledVersions; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; -use Symfony\Component\HttpKernel\Kernel; return static function (ContainerConfigurator $containerConfigurator): void { $containerConfigurator->parameters() ->set('kernel.secret', 'test_secret') ->set('locale', 'en'); + $frameworkBundleVersion = InstalledVersions::getVersion('symfony/framework-bundle'); $frameworkConfig = [ 'secret' => '%kernel.secret%', 'test' => true, ]; - if (Kernel::VERSION_ID >= 60100) { + if (null !== $frameworkBundleVersion && \version_compare($frameworkBundleVersion, '6.1.0', '>=')) { $frameworkConfig['http_method_override'] = false; } - if (Kernel::VERSION_ID >= 60400) { + if (null !== $frameworkBundleVersion && \version_compare($frameworkBundleVersion, '6.4.0', '>=')) { $frameworkConfig['handle_all_throwables'] = true; $frameworkConfig['php_errors'] = ['log' => true]; } @@ -41,7 +42,7 @@ ], ], ]; - if (Kernel::VERSION_ID >= 60400) { + if (null !== $frameworkBundleVersion && \version_compare($frameworkBundleVersion, '6.4.0', '>=')) { $doctrineOrm['mappings']['Fixtures']['type'] = 'attribute'; } From e27d90acac579a3d5f13c1536e331a78b71dbb0b Mon Sep 17 00:00:00 2001 From: Cristoforo Cervino Date: Fri, 13 Feb 2026 16:59:15 +0100 Subject: [PATCH 6/8] add symfony 8 --- .github/workflows/ci.yml | 10 +++++----- README.md | 4 ++-- composer.json | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7dab16d..75fd1da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: php-version: ['8.2', '8.4', '8.5'] - symfony-version: ['5.4.*', '6.4.*', '7.0.*'] + symfony-version: ['5.4.*', '6.4.*', '7.0.*', '8.0.*'] doctrine-orm-version: ['2.20.*', '3.0.*'] exclude: # Doctrine ORM 3.0 requires PHP 8.4+ @@ -29,7 +29,7 @@ jobs: with: php-version: ${{ matrix.php-version }} extensions: mbstring, xml, ctype, iconv, intl, json, pdo_sqlite, sqlite3 - coverage: ${{ matrix.php-version == '8.5' && matrix.symfony-version == '7.0.*' && matrix.doctrine-orm-version == '3.0.*' && 'pcov' || 'none' }} + coverage: ${{ matrix.php-version == '8.5' && (matrix.symfony-version == '7.0.*' || matrix.symfony-version == '8.0.*') && matrix.doctrine-orm-version == '3.0.*' && 'pcov' || 'none' }} - name: Constrain Symfony version run: | @@ -53,14 +53,14 @@ jobs: run: vendor/bin/php-cs-fixer fix --dry-run --stop-on-violation --using-cache=no - name: Run PHPStan - if: matrix.php-version == '8.5' && matrix.symfony-version == '7.0.*' && matrix.doctrine-orm-version == '3.0.*' + if: matrix.php-version == '8.5' && (matrix.symfony-version == '7.0.*' || matrix.symfony-version == '8.0.*') && matrix.doctrine-orm-version == '3.0.*' run: vendor/bin/phpstan analyse src tests --configuration=phpstan.neon --memory-limit=1G - name: Run PHPUnit tests - run: vendor/bin/phpunit ${{ matrix.php-version == '8.5' && matrix.symfony-version == '7.0.*' && matrix.doctrine-orm-version == '3.0.*' && '--coverage-clover coverage.xml' || '' }} + run: vendor/bin/phpunit ${{ matrix.php-version == '8.5' && (matrix.symfony-version == '7.0.*' || matrix.symfony-version == '8.0.*') && matrix.doctrine-orm-version == '3.0.*' && '--coverage-clover coverage.xml' || '' }} - name: Upload coverage to Codecov - if: matrix.php-version == '8.5' && matrix.symfony-version == '7.0.*' && matrix.doctrine-orm-version == '3.0.*' + if: matrix.php-version == '8.5' && (matrix.symfony-version == '7.0.*' || matrix.symfony-version == '8.0.*') && matrix.doctrine-orm-version == '3.0.*' uses: codecov/codecov-action@v4 with: files: ./coverage.xml diff --git a/README.md b/README.md index 1d0e496..dae19d1 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,14 @@ #### Symfony Bundle - [AndanteProject](https://github.com/andanteproject) [![Latest Version](https://img.shields.io/github/release/andanteproject/timestampable-bundle.svg)](https://github.com/andanteproject/timestampable-bundle/releases) ![Github actions](https://github.com/andanteproject/timestampable-bundle/actions/workflows/ci.yml/badge.svg?branch=main) -![Framework](https://img.shields.io/badge/Symfony-4.x|5.x|6.x|7.x-informational?Style=flat&logo=symfony) +![Framework](https://img.shields.io/badge/Symfony-5.x|6.x|7.x|8.x-informational?Style=flat&logo=symfony) ![Php8](https://img.shields.io/badge/PHP-%208.x-informational?style=flat&logo=php) ![PhpStan](https://img.shields.io/badge/PHPStan-Level%208-success?style=flat&logo=php) A Symfony Bundle to handle entity `createdAt` and `updatedAt` dates with Doctrine. 🕰 ## Requirements -Symfony 4.x-7.x and PHP 8.2. +Symfony 5.x–8.x and PHP 8.2. ## Install Via [Composer](https://getcomposer.org/): diff --git a/composer.json b/composer.json index 664cf98..2eaa01e 100644 --- a/composer.json +++ b/composer.json @@ -28,12 +28,12 @@ ], "require": { "php": "^8.2", - "symfony/framework-bundle": "^5.0 | ^6.0 | ^7.0", + "symfony/framework-bundle": "^5.0 | ^6.0 | ^7.0 | ^8.0", "doctrine/common": "^2.13 || ^3.0", "doctrine/doctrine-bundle": "^2.10 || ^3.0", "doctrine/event-manager": "^1.2 | ^2.0", "doctrine/orm": "^2.15.3 || ^3.0", - "symfony/clock": "^6.2 | ^7.0" + "symfony/clock": "^6.2 | ^7.0 | ^8.0" }, "require-dev": { "ext-json": "*", From e84fd19d392343d7636122e9ed83dd04c22ccca2 Mon Sep 17 00:00:00 2001 From: Cristoforo Cervino Date: Fri, 13 Feb 2026 17:02:26 +0100 Subject: [PATCH 7/8] update ci for symfony 8 --- .github/workflows/ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75fd1da..843199b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,12 @@ jobs: # Doctrine ORM 3.0 requires PHP 8.4+ - php-version: '8.2' doctrine-orm-version: '3.0.*' + # Symfony 8.0 requires PHP 8.4+ + - php-version: '8.2' + symfony-version: '8.0.*' + # Symfony 8 only works with doctrine-bundle 3.1+, which conflicts with ORM 2.x + - symfony-version: '8.0.*' + doctrine-orm-version: '2.20.*' name: PHP ${{ matrix.php-version }} · SF ${{ matrix.symfony-version }} · Doctrine ORM ${{ matrix.doctrine-orm-version }} @@ -42,6 +48,10 @@ jobs: - name: Constrain Doctrine ORM version run: | sed -ri 's/"doctrine\/orm": "[^"]+"/"doctrine\/orm": "${{ matrix.doctrine-orm-version }}"/' composer.json + # Symfony 8 requires doctrine-bundle 3.1+, which requires doctrine/persistence ^4. ORM 3.0/3.1 only support persistence ^3. Use ORM 3.5.* (supports ^3.3.1 || ^4) for SF 8. + if [ "${{ matrix.symfony-version }}" = "8.0.*" ] && [ "${{ matrix.doctrine-orm-version }}" = "3.0.*" ]; then + sed -ri 's/"doctrine\/orm": "[^"]+"/"doctrine\/orm": "3.5.*"/' composer.json + fi - name: Install Composer dependencies uses: ramsey/composer-install@v3 From a90054b774183fd62ccc21a96f1c5e5189dc5152 Mon Sep 17 00:00:00 2001 From: Cristoforo Cervino Date: Fri, 13 Feb 2026 18:36:57 +0100 Subject: [PATCH 8/8] refactor configuration into a metadata registry and reorganize tests --- phpunit.xml.dist | 7 +- src/AndanteTimestampableBundle.php | 2 + src/Cache/Adapter/ArrayAdapter.php | 11 + .../TimestampableCacheClearer.php | 29 ++ src/CacheWarmer/TimestampableCacheWarmer.php | 51 ++++ .../AndanteTimestampableExtension.php | 35 +++ .../Compiler/CacheWarmerCompilerPass.php | 23 ++ .../Compiler/DoctrineEventSubscriberPass.php | 3 +- src/DependencyInjection/Configuration.php | 3 + .../TimestampableEventSubscriber.php | 71 ++--- src/Timestampable/Metadata/FieldMapping.php | 24 ++ src/Timestampable/Metadata/Metadata.php | 33 +++ .../Metadata/MetadataFactory.php | 50 ++++ src/Timestampable/Registry.php | 46 ++++ src/Timestampable/Util/CacheKeyGenerator.php | 13 + tests/App/CacheWarmerTestAppKernel.php | 17 ++ tests/App/Clock/ClockMock.php | 54 ++++ ...eCacheWarmerServicesPublicCompilerPass.php | 27 ++ tests/App/TimestampableAppKernel.php | 8 + tests/App/config/config.php | 11 + tests/Fixtures/CreatedAtOnlyEntity.php | 22 ++ tests/Fixtures/UpdatedAtOnlyEntity.php | 22 ++ .../CacheClearer/CacheClearerTest.php | 78 ++++++ .../CacheWarmer/CacheWarmerTest.php | 153 +++++++++++ .../DependencyInjection/DefaultConfigTest.php | 55 ++++ .../{ => EventSubscriber}/MappingTest.php | 27 +- .../{ => EventSubscriber}/SetupTest.php | 2 +- .../EventSubscriber/TimestampableTest.php | 248 ++++++++++++++++++ ...ctionalTest.php => FunctionalTestCase.php} | 2 +- .../Timestampable/RegistryColdPathTest.php | 43 +++ tests/Functional/TimestampableConfigTrait.php | 37 +++ tests/Functional/TimestampableTest.php | 155 ----------- tests/KernelTestCase.php | 4 +- .../TimestampableCacheClearerTest.php | 41 +++ .../Metadata/FieldMappingTest.php | 25 ++ .../Timestampable/Metadata/MetadataTest.php | 52 ++++ .../Timestampable/MetadataFactoryTest.php | 90 +++++++ tests/Unit/Timestampable/RegistryTest.php | 63 +++++ .../Util/CacheKeyGeneratorTest.php | 30 +++ 39 files changed, 1450 insertions(+), 217 deletions(-) create mode 100644 src/Cache/Adapter/ArrayAdapter.php create mode 100644 src/CacheClearer/TimestampableCacheClearer.php create mode 100644 src/CacheWarmer/TimestampableCacheWarmer.php create mode 100644 src/DependencyInjection/Compiler/CacheWarmerCompilerPass.php create mode 100644 src/Timestampable/Metadata/FieldMapping.php create mode 100644 src/Timestampable/Metadata/Metadata.php create mode 100644 src/Timestampable/Metadata/MetadataFactory.php create mode 100644 src/Timestampable/Registry.php create mode 100644 src/Timestampable/Util/CacheKeyGenerator.php create mode 100644 tests/App/CacheWarmerTestAppKernel.php create mode 100644 tests/App/Clock/ClockMock.php create mode 100644 tests/App/Compiler/MakeCacheWarmerServicesPublicCompilerPass.php create mode 100644 tests/Fixtures/CreatedAtOnlyEntity.php create mode 100644 tests/Fixtures/UpdatedAtOnlyEntity.php create mode 100644 tests/Functional/CacheClearer/CacheClearerTest.php create mode 100644 tests/Functional/CacheWarmer/CacheWarmerTest.php create mode 100644 tests/Functional/DependencyInjection/DefaultConfigTest.php rename tests/Functional/{ => EventSubscriber}/MappingTest.php (68%) rename tests/Functional/{ => EventSubscriber}/SetupTest.php (96%) create mode 100644 tests/Functional/EventSubscriber/TimestampableTest.php rename tests/Functional/{BaseFunctionalTest.php => FunctionalTestCase.php} (96%) create mode 100644 tests/Functional/Timestampable/RegistryColdPathTest.php create mode 100644 tests/Functional/TimestampableConfigTrait.php delete mode 100644 tests/Functional/TimestampableTest.php create mode 100644 tests/Unit/CacheClearer/TimestampableCacheClearerTest.php create mode 100644 tests/Unit/Timestampable/Metadata/FieldMappingTest.php create mode 100644 tests/Unit/Timestampable/Metadata/MetadataTest.php create mode 100644 tests/Unit/Timestampable/MetadataFactoryTest.php create mode 100644 tests/Unit/Timestampable/RegistryTest.php create mode 100644 tests/Unit/Timestampable/Util/CacheKeyGeneratorTest.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 0a9742a..8a25b6b 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -16,8 +16,11 @@ - - tests + + tests/Unit + + + tests/Functional diff --git a/src/AndanteTimestampableBundle.php b/src/AndanteTimestampableBundle.php index e8a8021..fe1d1c1 100644 --- a/src/AndanteTimestampableBundle.php +++ b/src/AndanteTimestampableBundle.php @@ -4,6 +4,7 @@ namespace Andante\TimestampableBundle; +use Andante\TimestampableBundle\DependencyInjection\Compiler\CacheWarmerCompilerPass; use Andante\TimestampableBundle\DependencyInjection\Compiler\DoctrineEventSubscriberPass; use Symfony\Component\DependencyInjection\Compiler\PassConfig; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -16,5 +17,6 @@ public function build(ContainerBuilder $container): void parent::build($container); $container->addCompilerPass(new DoctrineEventSubscriberPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 10); + $container->addCompilerPass(new CacheWarmerCompilerPass()); } } diff --git a/src/Cache/Adapter/ArrayAdapter.php b/src/Cache/Adapter/ArrayAdapter.php new file mode 100644 index 0000000..8a65854 --- /dev/null +++ b/src/Cache/Adapter/ArrayAdapter.php @@ -0,0 +1,11 @@ +filesystem = $filesystem ?? new Filesystem(); + } + + public function clear(string $cacheDir): void + { + $filesystem = $this->filesystem ?? new Filesystem(); + $metadataFile = $cacheDir.'/'.self::METADATA_CACHE_FILENAME; + if ($filesystem->exists($metadataFile)) { + $filesystem->remove($metadataFile); + } + } +} diff --git a/src/CacheWarmer/TimestampableCacheWarmer.php b/src/CacheWarmer/TimestampableCacheWarmer.php new file mode 100644 index 0000000..36f57a9 --- /dev/null +++ b/src/CacheWarmer/TimestampableCacheWarmer.php @@ -0,0 +1,51 @@ +metadataFactory = $metadataFactory; + $this->managerRegistry = $managerRegistry; + } + + protected function doWarmUp(string $cacheDir, ArrayAdapter $arrayAdapter, ?string $buildDir = null): bool + { + /** @var EntityManagerInterface $manager */ + foreach ($this->managerRegistry->getManagers() as $manager) { + foreach ($manager->getMetadataFactory()->getAllMetadata() as $classMetadata) { + $metadata = $this->metadataFactory->create($classMetadata->getName()); + if (null !== $metadata) { + $cacheKey = CacheKeyGenerator::generateCacheKey($classMetadata->getName()); + $item = $arrayAdapter->getItem($cacheKey); + $item->set($metadata); + $arrayAdapter->save($item); + } + } + } + + return true; + } + + public function isOptional(): bool + { + return true; + } +} diff --git a/src/DependencyInjection/AndanteTimestampableExtension.php b/src/DependencyInjection/AndanteTimestampableExtension.php index d4dc158..4bf7dc6 100644 --- a/src/DependencyInjection/AndanteTimestampableExtension.php +++ b/src/DependencyInjection/AndanteTimestampableExtension.php @@ -4,10 +4,17 @@ namespace Andante\TimestampableBundle\DependencyInjection; +use Andante\TimestampableBundle\Cache\Adapter\ArrayAdapter; +use Andante\TimestampableBundle\CacheClearer\TimestampableCacheClearer; +use Andante\TimestampableBundle\CacheWarmer\TimestampableCacheWarmer; use Andante\TimestampableBundle\Config\Configuration; use Andante\TimestampableBundle\DependencyInjection\Configuration as BundleConfiguration; +use Andante\TimestampableBundle\Timestampable\Metadata\MetadataFactory; +use Andante\TimestampableBundle\Timestampable\Registry; +use Symfony\Component\Cache\Adapter\PhpArrayAdapter; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpKernel\DependencyInjection\Extension; class AndanteTimestampableExtension extends Extension @@ -17,9 +24,37 @@ public function load(array $configs, ContainerBuilder $container): void $configuration = new BundleConfiguration(); $config = $this->processConfiguration($configuration, $configs); + $container->setParameter('andante_timestampable.metadata_cache_warmer_enabled', $config['metadata_cache_warmer_enabled']); + $container ->setDefinition('andante_timestampable.configuration', new Definition(Configuration::class)) ->setFactory([Configuration::class, 'createFromArray']) ->setArguments([$config]); + + $metadataPhpArrayFile = '%kernel.cache_dir%/timestampable_metadata.php'; + + $container->register(MetadataFactory::class, MetadataFactory::class) + ->addArgument(new Reference('andante_timestampable.configuration')); + + $container->register(ArrayAdapter::class, ArrayAdapter::class); + + $container->register(Registry::class, Registry::class) + ->addArgument(new Reference(MetadataFactory::class)) + ->addArgument(new Definition( + PhpArrayAdapter::class, + [ + $metadataPhpArrayFile, + new Reference(ArrayAdapter::class), + ] + )); + + $container->register(TimestampableCacheWarmer::class, TimestampableCacheWarmer::class) + ->addArgument(new Reference(MetadataFactory::class)) + ->addArgument(new Reference('doctrine')) + ->addArgument($metadataPhpArrayFile) + ->addTag('kernel.cache_warmer', ['priority' => -10]); + + $container->register(TimestampableCacheClearer::class, TimestampableCacheClearer::class) + ->addTag('kernel.cache_clearer'); } } diff --git a/src/DependencyInjection/Compiler/CacheWarmerCompilerPass.php b/src/DependencyInjection/Compiler/CacheWarmerCompilerPass.php new file mode 100644 index 0000000..d82d871 --- /dev/null +++ b/src/DependencyInjection/Compiler/CacheWarmerCompilerPass.php @@ -0,0 +1,23 @@ +hasDefinition(TimestampableCacheWarmer::class)) { + return; + } + if (!$container->hasParameter('andante_timestampable.metadata_cache_warmer_enabled') + || !$container->getParameter('andante_timestampable.metadata_cache_warmer_enabled')) { + $container->getDefinition(TimestampableCacheWarmer::class)->clearTag('kernel.cache_warmer'); + } + } +} diff --git a/src/DependencyInjection/Compiler/DoctrineEventSubscriberPass.php b/src/DependencyInjection/Compiler/DoctrineEventSubscriberPass.php index 7f3b770..6a65821 100644 --- a/src/DependencyInjection/Compiler/DoctrineEventSubscriberPass.php +++ b/src/DependencyInjection/Compiler/DoctrineEventSubscriberPass.php @@ -5,6 +5,7 @@ namespace Andante\TimestampableBundle\DependencyInjection\Compiler; use Andante\TimestampableBundle\EventSubscriber\TimestampableEventSubscriber; +use Andante\TimestampableBundle\Timestampable\Registry; use Composer\InstalledVersions; use Doctrine\ORM\Events; use Psr\Clock\ClockInterface; @@ -25,7 +26,7 @@ public function process(ContainerBuilder $container): void self::TIMESTAMPABLE_SUBSCRIBER_SERVICE_ID, TimestampableEventSubscriber::class ) - ->addArgument(new Reference('andante_timestampable.configuration')) + ->addArgument(new Reference(Registry::class)) ->addTag('doctrine.event_subscriber'); $symfonyDoctrineBridgeVersion = InstalledVersions::getVersion('symfony/doctrine-bridge'); if (null !== $symfonyDoctrineBridgeVersion && \version_compare($symfonyDoctrineBridgeVersion, '6.3', '>=')) { diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 1db71d2..be61f7d 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -19,6 +19,9 @@ public function getConfigTreeBuilder(): TreeBuilder /** @var ArrayNodeDefinition $node */ $node = $treeBuilder->getRootNode(); $node->children() + ->booleanNode('metadata_cache_warmer_enabled') + ->defaultFalse() + ->end() ->arrayNode('default') ->addDefaultsIfNotSet() ->children() diff --git a/src/EventSubscriber/TimestampableEventSubscriber.php b/src/EventSubscriber/TimestampableEventSubscriber.php index 6f73472..03b189c 100644 --- a/src/EventSubscriber/TimestampableEventSubscriber.php +++ b/src/EventSubscriber/TimestampableEventSubscriber.php @@ -4,8 +4,8 @@ namespace Andante\TimestampableBundle\EventSubscriber; -use Andante\TimestampableBundle\Config\Configuration; use Andante\TimestampableBundle\Timestampable\CreatedAtTimestampableInterface; +use Andante\TimestampableBundle\Timestampable\Registry; use Andante\TimestampableBundle\Timestampable\UpdatedAtTimestampableInterface; use Doctrine\Common\EventSubscriber; use Doctrine\DBAL\Types\Types; @@ -17,14 +17,14 @@ class TimestampableEventSubscriber implements EventSubscriber { - private Configuration $configuration; + private Registry $registry; private ClockInterface $clock; public function __construct( - Configuration $configuration, + Registry $registry, ClockInterface $clock, ) { - $this->configuration = $configuration; + $this->registry = $registry; $this->clock = $clock; } @@ -48,14 +48,18 @@ public function prePersist(PrePersistEventArgs $onFlushEventArgs): void public function preUpdate(PreUpdateEventArgs $onFlushEventArgs): void { $entity = $onFlushEventArgs->getObject(); - if ($entity instanceof UpdatedAtTimestampableInterface) { - // Skipping update of updatedAt property if it has been changed manually - $metadata = $onFlushEventArgs->getObjectManager()->getClassMetadata(\get_class($entity)); - $updatedAtPropertyName = $this->configuration->getUpdatedAtPropertyNameForClass($metadata->getName()); - $entityUpdatedPropertiesNames = \array_keys($onFlushEventArgs->getEntityChangeSet()); - if (!\in_array($updatedAtPropertyName, $entityUpdatedPropertiesNames, true)) { - $entity->setUpdatedAt($this->clock->now()); - } + $metadata = $this->registry->getTimestampableMetadata(\get_class($entity)); + if (null === $metadata || null === $metadata->getUpdatedAt()) { + return; + } + if (!$entity instanceof UpdatedAtTimestampableInterface) { + return; + } + // Skipping update of updatedAt property if it has been changed manually + $updatedAtPropertyName = $metadata->getUpdatedAt()->getPropertyName(); + $entityUpdatedPropertiesNames = \array_keys($onFlushEventArgs->getEntityChangeSet()); + if (!\in_array($updatedAtPropertyName, $entityUpdatedPropertiesNames, true)) { + $entity->setUpdatedAt($this->clock->now()); } } @@ -70,29 +74,28 @@ public function loadClassMetadata(LoadClassMetadataEventArgs $loadClassMetadataE } $className = $rClass->getName(); - if (\is_a($className, CreatedAtTimestampableInterface::class, true)) { - $createdAtPropertyName = $this->configuration->getCreatedAtPropertyNameForClass($className); - if (!$classMetadata->hasField($createdAtPropertyName)) { - // Map field - $classMetadata->mapField([ - 'fieldName' => $createdAtPropertyName, - 'type' => Types::DATETIME_IMMUTABLE, - 'nullable' => false, - 'columnName' => $this->configuration->getCreatedAtColumnNameForClass($className), - ]); - } + $metadata = $this->registry->getTimestampableMetadata($className); + if (null === $metadata) { + return; + } + + $createdAt = $metadata->getCreatedAt(); + if (null !== $createdAt && !$classMetadata->hasField($createdAt->getPropertyName())) { + $classMetadata->mapField([ + 'fieldName' => $createdAt->getPropertyName(), + 'type' => Types::DATETIME_IMMUTABLE, + 'nullable' => false, + 'columnName' => $createdAt->getColumnName(), + ]); } - if (\is_a($className, UpdatedAtTimestampableInterface::class, true)) { - $updatedAtPropertyName = $this->configuration->getUpdatedAtPropertyNameForClass($className); - if (!$classMetadata->hasField($updatedAtPropertyName)) { - // Map field - $classMetadata->mapField([ - 'fieldName' => $updatedAtPropertyName, - 'type' => Types::DATETIME_IMMUTABLE, - 'nullable' => true, - 'columnName' => $this->configuration->getUpdatedAtColumnNameForClass($className), - ]); - } + $updatedAt = $metadata->getUpdatedAt(); + if (null !== $updatedAt && !$classMetadata->hasField($updatedAt->getPropertyName())) { + $classMetadata->mapField([ + 'fieldName' => $updatedAt->getPropertyName(), + 'type' => Types::DATETIME_IMMUTABLE, + 'nullable' => true, + 'columnName' => $updatedAt->getColumnName(), + ]); } } } diff --git a/src/Timestampable/Metadata/FieldMapping.php b/src/Timestampable/Metadata/FieldMapping.php new file mode 100644 index 0000000..59f3872 --- /dev/null +++ b/src/Timestampable/Metadata/FieldMapping.php @@ -0,0 +1,24 @@ +propertyName; + } + + public function getColumnName(): ?string + { + return $this->columnName; + } +} diff --git a/src/Timestampable/Metadata/Metadata.php b/src/Timestampable/Metadata/Metadata.php new file mode 100644 index 0000000..3e3e6b0 --- /dev/null +++ b/src/Timestampable/Metadata/Metadata.php @@ -0,0 +1,33 @@ +entityClass; + } + + public function getCreatedAt(): ?FieldMapping + { + return $this->createdAt; + } + + public function getUpdatedAt(): ?FieldMapping + { + return $this->updatedAt; + } +} diff --git a/src/Timestampable/Metadata/MetadataFactory.php b/src/Timestampable/Metadata/MetadataFactory.php new file mode 100644 index 0000000..f7f0ded --- /dev/null +++ b/src/Timestampable/Metadata/MetadataFactory.php @@ -0,0 +1,50 @@ +configuration->getCreatedAtPropertyNameForClass($entityClass), + $this->configuration->getCreatedAtColumnNameForClass($entityClass), + ) + : null; + + $updatedAt = $hasUpdatedAt + ? new FieldMapping( + $this->configuration->getUpdatedAtPropertyNameForClass($entityClass), + $this->configuration->getUpdatedAtColumnNameForClass($entityClass), + ) + : null; + + return new Metadata( + entityClass: $entityClass, + createdAt: $createdAt, + updatedAt: $updatedAt, + ); + } +} diff --git a/src/Timestampable/Registry.php b/src/Timestampable/Registry.php new file mode 100644 index 0000000..c4fa04e --- /dev/null +++ b/src/Timestampable/Registry.php @@ -0,0 +1,46 @@ + */ + private array $loadedMetadata = []; + + public function __construct( + private MetadataFactory $metadataFactory, + private PhpArrayAdapter $phpArrayAdapter, + ) { + } + + /** + * @param class-string $entityClass + * + * @throws \Psr\Cache\InvalidArgumentException + */ + public function getTimestampableMetadata(string $entityClass): ?Metadata + { + if (\array_key_exists($entityClass, $this->loadedMetadata)) { + return $this->loadedMetadata[$entityClass]; + } + + $cacheKey = CacheKeyGenerator::generateCacheKey($entityClass); + if ($this->phpArrayAdapter->hasItem($cacheKey)) { + $cachedMetadata = $this->phpArrayAdapter->getItem($cacheKey)->get(); + $this->loadedMetadata[$entityClass] = $cachedMetadata instanceof Metadata ? $cachedMetadata : null; + + return $this->loadedMetadata[$entityClass]; + } + + $this->loadedMetadata[$entityClass] = $this->metadataFactory->create($entityClass); + + return $this->loadedMetadata[$entityClass]; + } +} diff --git a/src/Timestampable/Util/CacheKeyGenerator.php b/src/Timestampable/Util/CacheKeyGenerator.php new file mode 100644 index 0000000..223d0c7 --- /dev/null +++ b/src/Timestampable/Util/CacheKeyGenerator.php @@ -0,0 +1,13 @@ +addCompilerPass(new MakeCacheWarmerServicesPublicCompilerPass()); + } +} diff --git a/tests/App/Clock/ClockMock.php b/tests/App/Clock/ClockMock.php new file mode 100644 index 0000000..29cda0c --- /dev/null +++ b/tests/App/Clock/ClockMock.php @@ -0,0 +1,54 @@ +frozenTime) { + return $this->frozenTime; + } + + return $this->innerClock->now(); + } + + /** + * Set the time returned by now() until unfreeze() is called. + */ + public function setFrozenTime(\DateTimeImmutable $time): void + { + $this->frozenTime = $time; + } + + /** + * Freeze time to the current time from the inner clock. + */ + public function freeze(): void + { + $this->frozenTime = $this->innerClock->now(); + } + + /** + * Resume delegating to the inner clock. + */ + public function unfreeze(): void + { + $this->frozenTime = null; + } +} diff --git a/tests/App/Compiler/MakeCacheWarmerServicesPublicCompilerPass.php b/tests/App/Compiler/MakeCacheWarmerServicesPublicCompilerPass.php new file mode 100644 index 0000000..daf195c --- /dev/null +++ b/tests/App/Compiler/MakeCacheWarmerServicesPublicCompilerPass.php @@ -0,0 +1,27 @@ +hasDefinition(TimestampableCacheWarmer::class)) { + $container->getDefinition(TimestampableCacheWarmer::class)->setPublic(true); + } + if ($container->hasDefinition(TimestampableCacheClearer::class)) { + $container->getDefinition(TimestampableCacheClearer::class)->setPublic(true); + } + if ($container->hasDefinition(Registry::class)) { + $container->getDefinition(Registry::class)->setPublic(true); + } + } +} diff --git a/tests/App/TimestampableAppKernel.php b/tests/App/TimestampableAppKernel.php index 3a5db31..33f4ae1 100644 --- a/tests/App/TimestampableAppKernel.php +++ b/tests/App/TimestampableAppKernel.php @@ -4,6 +4,14 @@ namespace Andante\TimestampableBundle\Tests\App; +use Andante\TimestampableBundle\Tests\App\Compiler\MakeCacheWarmerServicesPublicCompilerPass; +use Symfony\Component\DependencyInjection\ContainerBuilder; + class TimestampableAppKernel extends AppKernel { + public function build(ContainerBuilder $container): void + { + parent::build($container); + $container->addCompilerPass(new MakeCacheWarmerServicesPublicCompilerPass()); + } } diff --git a/tests/App/config/config.php b/tests/App/config/config.php index a607140..5792451 100644 --- a/tests/App/config/config.php +++ b/tests/App/config/config.php @@ -2,14 +2,25 @@ declare(strict_types=1); +use Andante\TimestampableBundle\Tests\App\Clock\ClockMock; use Composer\InstalledVersions; +use Psr\Clock\ClockInterface; +use Symfony\Component\Clock\Clock; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use Symfony\Component\DependencyInjection\Loader\Configurator\ReferenceConfigurator; return static function (ContainerConfigurator $containerConfigurator): void { $containerConfigurator->parameters() ->set('kernel.secret', 'test_secret') ->set('locale', 'en'); + $services = $containerConfigurator->services(); + + // Clock mock for tests: decorates real clock and allows freezing time + $services->set('andante_timestampable.clock.inner', Clock::class); + $services->set(ClockInterface::class, ClockMock::class) + ->args([new ReferenceConfigurator('andante_timestampable.clock.inner')]); + $frameworkBundleVersion = InstalledVersions::getVersion('symfony/framework-bundle'); $frameworkConfig = [ 'secret' => '%kernel.secret%', diff --git a/tests/Fixtures/CreatedAtOnlyEntity.php b/tests/Fixtures/CreatedAtOnlyEntity.php new file mode 100644 index 0000000..c28ff27 --- /dev/null +++ b/tests/Fixtures/CreatedAtOnlyEntity.php @@ -0,0 +1,22 @@ +createdAt; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): void + { + $this->createdAt = $createdAt; + } +} diff --git a/tests/Fixtures/UpdatedAtOnlyEntity.php b/tests/Fixtures/UpdatedAtOnlyEntity.php new file mode 100644 index 0000000..46fbe2e --- /dev/null +++ b/tests/Fixtures/UpdatedAtOnlyEntity.php @@ -0,0 +1,22 @@ +updatedAt; + } + + public function setUpdatedAt(\DateTimeImmutable $updatedAt): void + { + $this->updatedAt = $updatedAt; + } +} diff --git a/tests/Functional/CacheClearer/CacheClearerTest.php b/tests/Functional/CacheClearer/CacheClearerTest.php new file mode 100644 index 0000000..e0faeb9 --- /dev/null +++ b/tests/Functional/CacheClearer/CacheClearerTest.php @@ -0,0 +1,78 @@ + $options + * @param array> $config + */ + protected static function createKernel(array $options = [], array $config = []): KernelInterface + { + $env = $options['environment'] ?? 'test'; + $debug = (bool) ($options['debug'] ?? true); + + return new CacheWarmerTestAppKernel($env, $debug, $config); + } + + protected function tearDown(): void + { + if (null !== $this->cacheDir) { + $metadataFile = $this->cacheDir.'/'.TimestampableCacheClearer::METADATA_CACHE_FILENAME; + if (\file_exists($metadataFile)) { + \unlink($metadataFile); + } + } + self::ensureKernelShutdown(); + parent::tearDown(); + } + + public function testClearRemovesMetadataFileAfterWarmup(): void + { + $kernel = self::createKernel([], [ + 'andante_timestampable' => [ + 'metadata_cache_warmer_enabled' => true, + 'entity' => [ + Address::class => [ + 'created_at_property_name' => 'created', + 'updated_at_property_name' => 'updated', + ], + ], + ], + ]); + $this->cacheDir = $kernel->getCacheDir(); + $kernel->boot(); + $container = $kernel->getContainer(); + + $cacheWarmer = $container->get(TimestampableCacheWarmer::class); + self::assertInstanceOf(TimestampableCacheWarmer::class, $cacheWarmer); + $cacheWarmer->warmUp($this->cacheDir); + + $metadataFile = $this->cacheDir.'/'.TimestampableCacheClearer::METADATA_CACHE_FILENAME; + self::assertFileExists($metadataFile, 'Cache should be warmed before clear.'); + + $clearer = $container->get(TimestampableCacheClearer::class); + self::assertInstanceOf(TimestampableCacheClearer::class, $clearer); + $clearer->clear($this->cacheDir); + + self::assertFileDoesNotExist($metadataFile, 'Clearer should remove the metadata file.'); + $kernel->shutdown(); + } +} diff --git a/tests/Functional/CacheWarmer/CacheWarmerTest.php b/tests/Functional/CacheWarmer/CacheWarmerTest.php new file mode 100644 index 0000000..08f75a0 --- /dev/null +++ b/tests/Functional/CacheWarmer/CacheWarmerTest.php @@ -0,0 +1,153 @@ + $options + * @param array> $config + */ + protected static function createKernel(array $options = [], array $config = []): KernelInterface + { + $env = $options['environment'] ?? 'test'; + $debug = (bool) ($options['debug'] ?? true); + + return new CacheWarmerTestAppKernel($env, $debug, $config); + } + + protected function setUp(): void + { + parent::setUp(); + } + + protected function getFilesystem(): Filesystem + { + if (null === $this->filesystem) { + $this->filesystem = new Filesystem(); + } + + return $this->filesystem; + } + + protected function tearDown(): void + { + if (null !== $this->filesystem && null !== $this->cacheDir) { + $metadataFile = $this->cacheDir.'/'.TimestampableCacheClearer::METADATA_CACHE_FILENAME; + if ($this->getFilesystem()->exists($metadataFile)) { + $this->getFilesystem()->remove($metadataFile); + } + } + self::ensureKernelShutdown(); + parent::tearDown(); + } + + public function testCacheWarmerPopulatesCache(): void + { + $kernel = self::createKernel([], [ + 'andante_timestampable' => [ + 'metadata_cache_warmer_enabled' => true, + 'entity' => [ + Address::class => [ + 'created_at_property_name' => 'created', + 'updated_at_property_name' => 'updated', + ], + ], + ], + ]); + $this->cacheDir = $kernel->getCacheDir(); + $kernel->boot(); + $container = $kernel->getContainer(); + + $cacheDir = $kernel->getCacheDir(); + $cacheWarmer = $container->get(TimestampableCacheWarmer::class); + self::assertInstanceOf(TimestampableCacheWarmer::class, $cacheWarmer); + + $cacheWarmer->warmUp($cacheDir); + + $metadataFile = $cacheDir.'/'.TimestampableCacheClearer::METADATA_CACHE_FILENAME; + self::assertFileExists($metadataFile); + + $registry = $container->get(Registry::class); + self::assertInstanceOf(Registry::class, $registry); + $timestampableMetadata = $registry->getTimestampableMetadata(Address::class); + self::assertNotNull($timestampableMetadata); + self::assertNotNull($timestampableMetadata->getCreatedAt()); + self::assertNotNull($timestampableMetadata->getUpdatedAt()); + self::assertSame('created', $timestampableMetadata->getCreatedAt()->getPropertyName()); + self::assertSame('updated', $timestampableMetadata->getUpdatedAt()->getPropertyName()); + + $em = $container->get('doctrine.orm.default_entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $em); + $metadata = $em->getClassMetadata(Address::class); + self::assertTrue($metadata->hasField('created')); + self::assertTrue($metadata->hasField('updated')); + + $kernel->shutdown(); + } + + public function testMetadataCacheWarmerEnabledConfiguration(): void + { + $filesystem = $this->getFilesystem(); + + $addressEntityConfig = [ + Address::class => [ + 'created_at_property_name' => 'created', + 'updated_at_property_name' => 'updated', + ], + ]; + + $kernel = self::createKernel(['environment' => 'test', 'debug' => true], [ + 'andante_timestampable' => [ + 'metadata_cache_warmer_enabled' => false, + 'entity' => $addressEntityConfig, + ], + ]); + $this->cacheDir = $kernel->getCacheDir(); + $metadataFile = $this->cacheDir.'/'.TimestampableCacheClearer::METADATA_CACHE_FILENAME; + $filesystem->remove($metadataFile); + + $kernel->boot(); + self::assertFileDoesNotExist($metadataFile, 'Cache file should not exist when warmer is disabled.'); + $kernel->shutdown(); + + $kernel = self::createKernel(['environment' => 'test', 'debug' => true], [ + 'andante_timestampable' => [ + 'metadata_cache_warmer_enabled' => true, + 'entity' => $addressEntityConfig, + ], + ]); + $this->cacheDir = $kernel->getCacheDir(); + $kernel->boot(); + $container = $kernel->getContainer(); + $metadataFile = $this->cacheDir.'/'.TimestampableCacheClearer::METADATA_CACHE_FILENAME; + $filesystem->remove($metadataFile); + + $cacheWarmer = $container->get(TimestampableCacheWarmer::class); + self::assertInstanceOf(TimestampableCacheWarmer::class, $cacheWarmer); + $cacheWarmer->warmUp($this->cacheDir); + + self::assertFileExists($metadataFile, 'Cache file should exist when warmer is enabled.'); + $kernel->shutdown(); + } +} diff --git a/tests/Functional/DependencyInjection/DefaultConfigTest.php b/tests/Functional/DependencyInjection/DefaultConfigTest.php new file mode 100644 index 0000000..21b3752 --- /dev/null +++ b/tests/Functional/DependencyInjection/DefaultConfigTest.php @@ -0,0 +1,55 @@ + [ + 'entity' => [ + Address::class => [ + 'created_at_property_name' => 'created', + 'updated_at_property_name' => 'updated', + ], + ], + ], + ]); + } + + public function testDefaultPropertyNamesWorkWithNoConfig(): void + { + $this->createSchema(); + /** @var EntityManagerInterface $em */ + $em = self::getTestContainer()->get('doctrine.orm.default_entity_manager'); + + $organization = (new Organization())->setName('Org1'); + $em->persist($organization); + $em->flush(); + + $repository = $em->getRepository(Organization::class); + /** @var Organization|null $loaded */ + $loaded = $repository->findOneBy(['name' => 'Org1']); + self::assertNotNull($loaded); + self::assertNotNull($loaded->getCreatedAt(), 'Default createdAt should be set on persist.'); + self::assertNull($loaded->getUpdatedAt(), 'updatedAt should be null before update.'); + + $loaded->setName('Org1-updated'); + $em->flush(); + + /** @var Organization|null $reloaded */ + $reloaded = $repository->findOneBy(['name' => 'Org1-updated']); + self::assertNotNull($reloaded); + self::assertNotNull($reloaded->getUpdatedAt(), 'Default updatedAt should be set on update.'); + } +} diff --git a/tests/Functional/MappingTest.php b/tests/Functional/EventSubscriber/MappingTest.php similarity index 68% rename from tests/Functional/MappingTest.php rename to tests/Functional/EventSubscriber/MappingTest.php index 07ea2d7..c1e21b4 100644 --- a/tests/Functional/MappingTest.php +++ b/tests/Functional/EventSubscriber/MappingTest.php @@ -2,11 +2,12 @@ declare(strict_types=1); -namespace Andante\TimestampableBundle\Tests\Functional; +namespace Andante\TimestampableBundle\Tests\Functional\EventSubscriber; use Andante\TimestampableBundle\Tests\App\TimestampableAppKernel; use Andante\TimestampableBundle\Tests\Fixtures\Entity\Address; use Andante\TimestampableBundle\Tests\Fixtures\Entity\Organization; +use Andante\TimestampableBundle\Tests\Functional\TimestampableConfigTrait; use Andante\TimestampableBundle\Tests\KernelTestCase; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\EntityManagerInterface; @@ -15,30 +16,14 @@ class MappingTest extends KernelTestCase { + use TimestampableConfigTrait; + protected static function createKernel(array $options = []): KernelInterface { - return new TimestampableAppKernel('test', true, [ - 'andante_timestampable' => [ - 'default' => [ - 'created_at_property_name' => 'createdAt', - 'updated_at_property_name' => 'updatedAt', - ], - 'entity' => [ - Organization::class => [ - 'created_at_property_name' => 'createdAt', - ], - Address::class => [ - 'created_at_property_name' => 'created', - 'updated_at_property_name' => 'updated', - 'created_at_column_name' => 'created_date', - 'updated_at_column_name' => 'updated_date', - ], - ], - ], - ]); + return new TimestampableAppKernel('test', true, self::getTimestampableConfig()); } - public function testMapping(): void + public function testDoctrineFieldMappingsMatchBundleConfig(): void { /** @var EntityManagerInterface $em */ $em = static::getTestContainer()->get('doctrine.orm.default_entity_manager'); diff --git a/tests/Functional/SetupTest.php b/tests/Functional/EventSubscriber/SetupTest.php similarity index 96% rename from tests/Functional/SetupTest.php rename to tests/Functional/EventSubscriber/SetupTest.php index 73efc15..da472ce 100644 --- a/tests/Functional/SetupTest.php +++ b/tests/Functional/EventSubscriber/SetupTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Andante\TimestampableBundle\Tests\Functional; +namespace Andante\TimestampableBundle\Tests\Functional\EventSubscriber; use Andante\TimestampableBundle\EventSubscriber\TimestampableEventSubscriber; use Andante\TimestampableBundle\Tests\App\TimestampableAppKernel; diff --git a/tests/Functional/EventSubscriber/TimestampableTest.php b/tests/Functional/EventSubscriber/TimestampableTest.php new file mode 100644 index 0000000..d02108b --- /dev/null +++ b/tests/Functional/EventSubscriber/TimestampableTest.php @@ -0,0 +1,248 @@ +createSchema(); + } + + /** + * Persist sets createdAt; updatedAt stays null until first update. + * Asserts both default (Organization) and custom property names (Address). + */ + public function testCreatedAtSetOnPersistUpdatedAtNullUntilFirstUpdate(): void + { + $em = $this->getEntityManager(); + $em->persist($address = (new Address())->setName('Address1')); + $em->persist($organization = (new Organization())->setName('Organization1')); + $em->flush(); + + $address = $this->reload($em, Address::class, ['name' => 'Address1']); + $organization = $this->reload($em, Organization::class, ['name' => 'Organization1']); + + self::assertNotNull($address->getCreatedAt(), 'Address: createdAt should be set on persist.'); + self::assertNotNull($organization->getCreatedAt(), 'Organization: createdAt should be set on persist.'); + self::assertNull($address->getUpdatedAt(), 'Address: updatedAt should be null before any update.'); + self::assertNull($organization->getUpdatedAt(), 'Organization: updatedAt should be null before any update.'); + } + + /** + * First update sets updatedAt; createdAt is unchanged. + */ + public function testUpdatedAtSetOnFirstUpdateCreatedAtUnchanged(): void + { + $em = $this->getEntityManager(); + $em->persist((new Address())->setName('Address1')); + $em->persist((new Organization())->setName('Organization1')); + $em->flush(); + + $address = $this->reload($em, Address::class, ['name' => 'Address1']); + $organization = $this->reload($em, Organization::class, ['name' => 'Organization1']); + $createdAtAddress = $address->getCreatedAt(); + $createdAtOrganization = $organization->getCreatedAt(); + self::assertNotNull($createdAtAddress); + self::assertNotNull($createdAtOrganization); + + $address->setName('Address1-updated'); + $organization->setName('Organization1-updated'); + \sleep(self::SLEEP_BETWEEN_UPDATES_SECONDS); + $em->flush(); + + $address = $this->reload($em, Address::class, ['name' => 'Address1-updated']); + $organization = $this->reload($em, Organization::class, ['name' => 'Organization1-updated']); + + $addressCreatedAt = $address->getCreatedAt(); + $organizationCreatedAt = $organization->getCreatedAt(); + $addressUpdatedAt = $address->getUpdatedAt(); + $organizationUpdatedAt = $organization->getUpdatedAt(); + self::assertNotNull($addressCreatedAt); + self::assertNotNull($organizationCreatedAt); + self::assertNotNull($addressUpdatedAt, 'Address: updatedAt should be set on first update.'); + self::assertNotNull($organizationUpdatedAt, 'Organization: updatedAt should be set on first update.'); + self::assertSame( + $createdAtAddress->format(\DateTimeInterface::ATOM), + $addressCreatedAt->format(\DateTimeInterface::ATOM), + 'Address: createdAt must not change after update.' + ); + self::assertSame( + $createdAtOrganization->format(\DateTimeInterface::ATOM), + $organizationCreatedAt->format(\DateTimeInterface::ATOM), + 'Organization: createdAt must not change after update.' + ); + self::assertNotEquals( + $addressCreatedAt->getTimestamp(), + $addressUpdatedAt->getTimestamp(), + 'Address: updatedAt should differ from createdAt after update.' + ); + self::assertNotEquals( + $organizationCreatedAt->getTimestamp(), + $organizationUpdatedAt->getTimestamp(), + 'Organization: updatedAt should differ from createdAt after update.' + ); + } + + /** + * Second update changes updatedAt again. + */ + public function testUpdatedAtChangesOnSubsequentUpdate(): void + { + $em = $this->getEntityManager(); + $em->persist((new Address())->setName('Address1')); + $em->flush(); + $address = $this->reload($em, Address::class, ['name' => 'Address1']); + $address->setName('Address1-updated'); + \sleep(self::SLEEP_BETWEEN_UPDATES_SECONDS); + $em->flush(); + $address = $this->reload($em, Address::class, ['name' => 'Address1-updated']); + $firstUpdatedAt = $address->getUpdatedAt(); + self::assertNotNull($firstUpdatedAt, 'updatedAt should be set after first update.'); + + $address->setName('Address1-updated2'); + \sleep(self::SLEEP_BETWEEN_UPDATES_SECONDS); + $em->flush(); + $address = $this->reload($em, Address::class, ['name' => 'Address1-updated2']); + $addressUpdatedAtAfterSecondUpdate = $address->getUpdatedAt(); + self::assertNotNull($addressUpdatedAtAfterSecondUpdate); + self::assertNotEquals( + $firstUpdatedAt->format(\DateTimeInterface::ATOM), + $addressUpdatedAtAfterSecondUpdate->format(\DateTimeInterface::ATOM), + 'updatedAt should change on second update.' + ); + } + + /** + * Subscriber uses ClockInterface from the container: freeze time, persist and update, + * then assert createdAt and updatedAt match the times returned by the clock. + */ + public function testCreatedAtAndUpdatedAtUseClockServiceFromContainer(): void + { + $clock = $this->getClockMock(); + $em = $this->getEntityManager(); + + $createdTime = new \DateTimeImmutable('2024-06-15 10:30:00'); + $clock->setFrozenTime($createdTime); + $em->persist($address = (new Address())->setName('AddressWithFrozenTime')); + $em->flush(); + + $address = $this->reload($em, Address::class, ['name' => 'AddressWithFrozenTime']); + $addressCreatedAt = $address->getCreatedAt(); + self::assertNotNull($addressCreatedAt); + self::assertSame( + $createdTime->format(\DateTimeInterface::ATOM), + $addressCreatedAt->format(\DateTimeInterface::ATOM), + 'createdAt must equal the time returned by the Clock service.' + ); + self::assertNull($address->getUpdatedAt(), 'updatedAt should be null until first update.'); + + $updatedTime = new \DateTimeImmutable('2024-06-15 14:45:00'); + $clock->setFrozenTime($updatedTime); + $address->setName('AddressWithFrozenTime-updated'); + $em->flush(); + + $address = $this->reload($em, Address::class, ['name' => 'AddressWithFrozenTime-updated']); + $addressUpdatedAt = $address->getUpdatedAt(); + self::assertNotNull($addressUpdatedAt); + self::assertSame( + $updatedTime->format(\DateTimeInterface::ATOM), + $addressUpdatedAt->format(\DateTimeInterface::ATOM), + 'updatedAt must equal the time returned by the Clock service on update.' + ); + $addressCreatedAtAfterUpdate = $address->getCreatedAt(); + self::assertNotNull($addressCreatedAtAfterUpdate); + self::assertSame( + $createdTime->format(\DateTimeInterface::ATOM), + $addressCreatedAtAfterUpdate->format(\DateTimeInterface::ATOM), + 'createdAt must remain unchanged after update.' + ); + } + + /** + * Manually set updatedAt is preserved on flush; subscriber does not overwrite it. + */ + public function testManualUpdatedAtIsNotOverwrittenBySubscriber(): void + { + $em = $this->getEntityManager(); + $em->persist($organization = (new Organization())->setName('Organization1')); + $em->flush(); + $organization = $this->reload($em, Organization::class, ['name' => 'Organization1']); + $createdAt = $organization->getCreatedAt(); + self::assertNotNull($createdAt, 'createdAt should be set after persist.'); + $manualUpdatedAt = new \DateTimeImmutable('2023-01-01 00:00:00'); + $organization->setUpdatedAt($manualUpdatedAt); + $organization->setName('Organization1-updated'); + $em->flush(); + $organization = $this->reload($em, Organization::class, ['name' => 'Organization1-updated']); + $organizationCreatedAtAfterFlush = $organization->getCreatedAt(); + $organizationUpdatedAtAfterFlush = $organization->getUpdatedAt(); + self::assertNotNull($organizationCreatedAtAfterFlush, 'createdAt must remain set.'); + self::assertNotNull($organizationUpdatedAtAfterFlush, 'updatedAt must remain set.'); + self::assertSame( + $createdAt->format(\DateTimeInterface::ATOM), + $organizationCreatedAtAfterFlush->format(\DateTimeInterface::ATOM), + 'createdAt must remain unchanged.' + ); + self::assertSame( + $manualUpdatedAt->format(\DateTimeInterface::ATOM), + $organizationUpdatedAtAfterFlush->format(\DateTimeInterface::ATOM), + 'Manually set updatedAt must not be overwritten by the subscriber.' + ); + } + + private function getEntityManager(): EntityManagerInterface + { + $em = self::getTestContainer()->get('doctrine.orm.default_entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $em); + + return $em; + } + + private function getClockMock(): ClockMock + { + $clock = self::getTestContainer()->get(ClockInterface::class); + self::assertInstanceOf(ClockMock::class, $clock); + + return $clock; + } + + /** + * @template T of object + * + * @param class-string $entityClass + * @param array $criteria + * + * @return T + */ + private function reload(EntityManagerInterface $em, string $entityClass, array $criteria): object + { + $repository = $em->getRepository($entityClass); + $entity = $repository->findOneBy($criteria); + self::assertNotNull($entity, \sprintf('Entity %s with criteria %s should exist.', $entityClass, \json_encode($criteria))); + + return $entity; + } +} diff --git a/tests/Functional/BaseFunctionalTest.php b/tests/Functional/FunctionalTestCase.php similarity index 96% rename from tests/Functional/BaseFunctionalTest.php rename to tests/Functional/FunctionalTestCase.php index 4f400bb..04d39a5 100644 --- a/tests/Functional/BaseFunctionalTest.php +++ b/tests/Functional/FunctionalTestCase.php @@ -12,7 +12,7 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\DependencyInjection\ContainerInterface; -abstract class BaseFunctionalTest extends KernelTestCase +abstract class FunctionalTestCase extends KernelTestCase { protected function setUp(): void { diff --git a/tests/Functional/Timestampable/RegistryColdPathTest.php b/tests/Functional/Timestampable/RegistryColdPathTest.php new file mode 100644 index 0000000..ca63fd4 --- /dev/null +++ b/tests/Functional/Timestampable/RegistryColdPathTest.php @@ -0,0 +1,43 @@ + [ + 'metadata_cache_warmer_enabled' => false, + 'entity' => [ + Address::class => [ + 'created_at_property_name' => 'created', + 'updated_at_property_name' => 'updated', + ], + ], + ], + ]); + } + + public function testRegistryReturnsMetadataFromFactoryWhenCacheIsCold(): void + { + $registry = self::getTestContainer()->get(Registry::class); + self::assertInstanceOf(Registry::class, $registry); + + $metadata = $registry->getTimestampableMetadata(Address::class); + self::assertNotNull($metadata); + self::assertSame(Address::class, $metadata->getEntityClass()); + self::assertNotNull($metadata->getCreatedAt()); + self::assertNotNull($metadata->getUpdatedAt()); + self::assertSame('created', $metadata->getCreatedAt()->getPropertyName()); + self::assertSame('updated', $metadata->getUpdatedAt()->getPropertyName()); + } +} diff --git a/tests/Functional/TimestampableConfigTrait.php b/tests/Functional/TimestampableConfigTrait.php new file mode 100644 index 0000000..5d8bab6 --- /dev/null +++ b/tests/Functional/TimestampableConfigTrait.php @@ -0,0 +1,37 @@ +> + */ + protected static function getTimestampableConfig(): array + { + return [ + 'andante_timestampable' => [ + 'default' => [ + 'created_at_property_name' => 'createdAt', + 'updated_at_property_name' => 'updatedAt', + ], + 'entity' => [ + Organization::class => [ + 'created_at_property_name' => 'createdAt', + ], + Address::class => [ + 'created_at_property_name' => 'created', + 'updated_at_property_name' => 'updated', + 'created_at_column_name' => 'created_date', + 'updated_at_column_name' => 'updated_date', + ], + ], + ], + ]; + } +} diff --git a/tests/Functional/TimestampableTest.php b/tests/Functional/TimestampableTest.php deleted file mode 100644 index b39f935..0000000 --- a/tests/Functional/TimestampableTest.php +++ /dev/null @@ -1,155 +0,0 @@ - [ - 'default' => [ - 'created_at_property_name' => 'createdAt', - 'updated_at_property_name' => 'updatedAt', - ], - 'entity' => [ - Organization::class => [ - 'created_at_property_name' => 'createdAt', - ], - Address::class => [ - 'created_at_property_name' => 'created', - 'updated_at_property_name' => 'updated', - 'created_at_column_name' => 'created_date', - 'updated_at_column_name' => 'updated_date', - ], - ], - ], - ]); - } - - public function testShouldSetTimestamps(): void - { - $this->createSchema(); - /** @var EntityManagerInterface $em */ - $em = self::getTestContainer()->get('doctrine.orm.default_entity_manager'); - - $address1 = (new Address())->setName('Address1'); - $organization1 = (new Organization())->setName('Organization1'); - - $em->persist($address1); - $em->persist($organization1); - $em->flush(); - - $addressRepository = $em->getRepository(Address::class); - $organizationRepository = $em->getRepository(Organization::class); - - /** @var Address|null $address1 */ - $address1 = $addressRepository->findOneBy(['name' => 'Address1']); - /** @var Organization|null $organization1 */ - $organization1 = $organizationRepository->findOneBy(['name' => 'Organization1']); - self::assertNotNull($address1); - self::assertNotNull($organization1); - self::assertNotNull($address1->getCreatedAt()); - self::assertNotNull($organization1->getCreatedAt()); - self::assertNull($address1->getUpdatedAt()); - self::assertNull($organization1->getUpdatedAt()); - - $createdAtAddress1 = $address1->getCreatedAt(); - $createdAtOrganization1 = $organization1->getCreatedAt(); - - $address1->setName('Address1-updated'); - $organization1->setName('Organization1-updated'); - - \sleep(2); - $em->flush(); - \sleep(2); // Giving time to mysqlite to update file - - /** @var Address|null $address1 */ - $address1 = $addressRepository->findOneBy(['name' => 'Address1-updated']); - /** @var Organization|null $organization1 */ - $organization1 = $organizationRepository->findOneBy(['name' => 'Organization1-updated']); - self::assertNotNull($address1); - self::assertNotNull($organization1); - self::assertNotNull($address1->getCreatedAt()); - self::assertNotNull($organization1->getCreatedAt()); - self::assertNotNull($address1->getUpdatedAt()); - self::assertNotNull($organization1->getUpdatedAt()); - - self::assertSame( - $createdAtAddress1->format(\DateTimeInterface::ATOM), - $address1->getCreatedAt()->format(\DateTimeInterface::ATOM) - ); - self::assertSame( - $createdAtOrganization1->format(\DateTimeInterface::ATOM), - $organization1->getCreatedAt()->format(\DateTimeInterface::ATOM) - ); - - self::assertNotSame( - $address1->getCreatedAt()->format(\DateTimeInterface::ATOM), - $address1->getUpdatedAt()->format(\DateTimeInterface::ATOM) - ); - self::assertNotSame( - $organization1->getCreatedAt()->format(\DateTimeInterface::ATOM), - $organization1->getUpdatedAt()->format(\DateTimeInterface::ATOM) - ); - - $updatedAtAddress1 = $address1->getUpdatedAt(); - $updatedAtOrganization1 = $organization1->getUpdatedAt(); - - $address1->setName('Address1-updated2'); - $organization1->setName('Organization1-updated2'); - - \sleep(2); - $em->flush(); - \sleep(2); // Giving time to mysqlite to update file - - /** @var Address|null $address1 */ - $address1 = $addressRepository->findOneBy(['name' => 'Address1-updated2']); - /** @var Organization|null $organization1 */ - $organization1 = $organizationRepository->findOneBy(['name' => 'Organization1-updated2']); - self::assertNotNull($address1); - self::assertNotNull($organization1); - self::assertNotNull($address1->getCreatedAt()); - self::assertNotNull($organization1->getCreatedAt()); - self::assertNotNull($address1->getUpdatedAt()); - self::assertNotNull($organization1->getUpdatedAt()); - - self::assertNotSame( - $updatedAtAddress1->format(\DateTimeInterface::ATOM), - $address1->getUpdatedAt()->format(\DateTimeInterface::ATOM) - ); - self::assertNotSame( - $updatedAtOrganization1->format(\DateTimeInterface::ATOM), - $organization1->getUpdatedAt()->format(\DateTimeInterface::ATOM) - ); - - // Manual changing updatedAt property should not trigger the subscriber - $currentOrganization1CreatedAt = $organization1->getCreatedAt(); - /** @var \DateTimeImmutable $newOrganization1UpdatedAt */ - $newOrganization1UpdatedAt = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '2023-01-01 00:00:00'); - $organization1->setUpdatedAt($newOrganization1UpdatedAt); - - \sleep(2); - $em->flush(); - \sleep(2); // Giving time to mysqlite to update file - - self::assertSame( - $currentOrganization1CreatedAt->format(\DateTimeInterface::ATOM), - $organization1->getCreatedAt()?->format(\DateTimeInterface::ATOM) - ); - - self::assertSame( - $newOrganization1UpdatedAt->format(\DateTimeInterface::ATOM), - $organization1->getUpdatedAt()?->format(\DateTimeInterface::ATOM) - ); - } -} diff --git a/tests/KernelTestCase.php b/tests/KernelTestCase.php index 8b67ebf..1753689 100644 --- a/tests/KernelTestCase.php +++ b/tests/KernelTestCase.php @@ -4,8 +4,8 @@ namespace Andante\TimestampableBundle\Tests; -use Andante\TimestampableBundle\Tests\Functional\BaseFunctionalTest; +use Andante\TimestampableBundle\Tests\Functional\FunctionalTestCase; -class KernelTestCase extends BaseFunctionalTest +class KernelTestCase extends FunctionalTestCase { } diff --git a/tests/Unit/CacheClearer/TimestampableCacheClearerTest.php b/tests/Unit/CacheClearer/TimestampableCacheClearerTest.php new file mode 100644 index 0000000..89103de --- /dev/null +++ b/tests/Unit/CacheClearer/TimestampableCacheClearerTest.php @@ -0,0 +1,41 @@ +mkdir($cacheDir); + (new Filesystem())->touch($metadataFile); + self::assertFileExists($metadataFile); + + $clearer = new TimestampableCacheClearer(new Filesystem()); + $clearer->clear($cacheDir); + + self::assertFileDoesNotExist($metadataFile); + (new Filesystem())->remove($cacheDir); + } + + public function testClearDoesNothingWhenFileDoesNotExist(): void + { + $cacheDir = \sys_get_temp_dir().'/timestampable_clearer_test_'.\uniqid('', true); + (new Filesystem())->mkdir($cacheDir); + $metadataFile = $cacheDir.'/'.TimestampableCacheClearer::METADATA_CACHE_FILENAME; + self::assertFileDoesNotExist($metadataFile); + + $clearer = new TimestampableCacheClearer(new Filesystem()); + $clearer->clear($cacheDir); + + self::assertFileDoesNotExist($metadataFile); + (new Filesystem())->remove($cacheDir); + } +} diff --git a/tests/Unit/Timestampable/Metadata/FieldMappingTest.php b/tests/Unit/Timestampable/Metadata/FieldMappingTest.php new file mode 100644 index 0000000..2a15ea0 --- /dev/null +++ b/tests/Unit/Timestampable/Metadata/FieldMappingTest.php @@ -0,0 +1,25 @@ +getPropertyName()); + self::assertSame('created_at', $mapping->getColumnName()); + } + + public function testGetColumnNameCanBeNull(): void + { + $mapping = new FieldMapping('updatedAt', null); + self::assertSame('updatedAt', $mapping->getPropertyName()); + self::assertNull($mapping->getColumnName()); + } +} diff --git a/tests/Unit/Timestampable/Metadata/MetadataTest.php b/tests/Unit/Timestampable/Metadata/MetadataTest.php new file mode 100644 index 0000000..5765cf5 --- /dev/null +++ b/tests/Unit/Timestampable/Metadata/MetadataTest.php @@ -0,0 +1,52 @@ +getEntityClass()); + self::assertSame($createdAt, $metadata->getCreatedAt()); + self::assertSame($updatedAt, $metadata->getUpdatedAt()); + } + + public function testGettersWithOnlyCreatedAtSet(): void + { + $createdAt = new FieldMapping('createdAt', null); + $metadata = new Metadata(\stdClass::class, $createdAt, null); + + self::assertSame(\stdClass::class, $metadata->getEntityClass()); + self::assertSame($createdAt, $metadata->getCreatedAt()); + self::assertNull($metadata->getUpdatedAt()); + } + + public function testGettersWithOnlyUpdatedAtSet(): void + { + $updatedAt = new FieldMapping('updatedAt', 'updated_at'); + $metadata = new Metadata(\stdClass::class, null, $updatedAt); + + self::assertSame(\stdClass::class, $metadata->getEntityClass()); + self::assertNull($metadata->getCreatedAt()); + self::assertSame($updatedAt, $metadata->getUpdatedAt()); + } + + public function testGettersWithBothNull(): void + { + $metadata = new Metadata(\stdClass::class, null, null); + + self::assertSame(\stdClass::class, $metadata->getEntityClass()); + self::assertNull($metadata->getCreatedAt()); + self::assertNull($metadata->getUpdatedAt()); + } +} diff --git a/tests/Unit/Timestampable/MetadataFactoryTest.php b/tests/Unit/Timestampable/MetadataFactoryTest.php new file mode 100644 index 0000000..0a46901 --- /dev/null +++ b/tests/Unit/Timestampable/MetadataFactoryTest.php @@ -0,0 +1,90 @@ +create(\stdClass::class)); + } + + public function testCreateReturnsMetadataWithOnlyCreatedAtForCreatedAtOnlyInterface(): void + { + $configuration = Configuration::createFromArray([ + 'default' => [ + 'created_at_property_name' => 'createdAt', + 'created_at_column_name' => 'created_at', + ], + ]); + $factory = new MetadataFactory($configuration); + + $metadata = $factory->create(CreatedAtOnlyEntity::class); + self::assertNotNull($metadata); + self::assertSame(CreatedAtOnlyEntity::class, $metadata->getEntityClass()); + self::assertNotNull($metadata->getCreatedAt()); + self::assertSame('createdAt', $metadata->getCreatedAt()->getPropertyName()); + self::assertSame('created_at', $metadata->getCreatedAt()->getColumnName()); + self::assertNull($metadata->getUpdatedAt()); + } + + public function testCreateReturnsMetadataWithOnlyUpdatedAtForUpdatedAtOnlyInterface(): void + { + $configuration = Configuration::createFromArray([ + 'default' => [ + 'updated_at_property_name' => 'updatedAt', + 'updated_at_column_name' => 'updated_at', + ], + ]); + $factory = new MetadataFactory($configuration); + + $metadata = $factory->create(UpdatedAtOnlyEntity::class); + self::assertNotNull($metadata); + self::assertSame(UpdatedAtOnlyEntity::class, $metadata->getEntityClass()); + self::assertNull($metadata->getCreatedAt()); + self::assertNotNull($metadata->getUpdatedAt()); + self::assertSame('updatedAt', $metadata->getUpdatedAt()->getPropertyName()); + self::assertSame('updated_at', $metadata->getUpdatedAt()->getColumnName()); + } + + public function testCreateReturnsMetadataWithBothForTimestampableInterfaceUsingConfig(): void + { + $configuration = Configuration::createFromArray([ + 'default' => [ + 'created_at_property_name' => 'createdAt', + 'updated_at_property_name' => 'updatedAt', + 'created_at_column_name' => 'created_at', + 'updated_at_column_name' => 'updated_at', + ], + 'entity' => [ + \Andante\TimestampableBundle\Tests\Fixtures\Entity\Address::class => [ + 'created_at_property_name' => 'created', + 'updated_at_property_name' => 'updated', + 'created_at_column_name' => 'created_date', + 'updated_at_column_name' => 'updated_date', + ], + ], + ]); + $factory = new MetadataFactory($configuration); + + $metadata = $factory->create(\Andante\TimestampableBundle\Tests\Fixtures\Entity\Address::class); + self::assertNotNull($metadata); + self::assertNotNull($metadata->getCreatedAt()); + self::assertNotNull($metadata->getUpdatedAt()); + self::assertSame('created', $metadata->getCreatedAt()->getPropertyName()); + self::assertSame('created_date', $metadata->getCreatedAt()->getColumnName()); + self::assertSame('updated', $metadata->getUpdatedAt()->getPropertyName()); + self::assertSame('updated_date', $metadata->getUpdatedAt()->getColumnName()); + } +} diff --git a/tests/Unit/Timestampable/RegistryTest.php b/tests/Unit/Timestampable/RegistryTest.php new file mode 100644 index 0000000..c81a674 --- /dev/null +++ b/tests/Unit/Timestampable/RegistryTest.php @@ -0,0 +1,63 @@ +createMock(MetadataFactory::class); + $factory->expects(self::once()) + ->method('create') + ->with(\stdClass::class) + ->willReturn($metadata); + + $arrayAdapter = new ArrayAdapter(); + $phpArrayAdapter = new PhpArrayAdapter( + \sys_get_temp_dir().'/timestampable_registry_test_'.\uniqid('', true).'.php', + $arrayAdapter + ); + $registry = new Registry($factory, $phpArrayAdapter); + + $result1 = $registry->getTimestampableMetadata(\stdClass::class); + $result2 = $registry->getTimestampableMetadata(\stdClass::class); + + self::assertSame($metadata, $result1); + self::assertSame($metadata, $result2); + } + + public function testGetTimestampableMetadataReturnsCachedValueFromAdapterWhenPresent(): void + { + $cachedMetadata = new Metadata(\stdClass::class, null, null); + $arrayAdapter = new ArrayAdapter(); + $cacheKey = \str_replace('\\', '_', \stdClass::class); + $item = $arrayAdapter->getItem($cacheKey); + $item->set($cachedMetadata); + $arrayAdapter->save($item); + + $phpArrayAdapter = new PhpArrayAdapter( + \sys_get_temp_dir().'/timestampable_registry_test_'.\uniqid('', true).'.php', + $arrayAdapter + ); + $factory = $this->createMock(MetadataFactory::class); + $factory->expects(self::never())->method('create'); + + $registry = new Registry($factory, $phpArrayAdapter); + $result = $registry->getTimestampableMetadata(\stdClass::class); + + self::assertNotNull($result); + self::assertSame(\stdClass::class, $result->getEntityClass()); + self::assertNull($result->getCreatedAt()); + self::assertNull($result->getUpdatedAt()); + } +} diff --git a/tests/Unit/Timestampable/Util/CacheKeyGeneratorTest.php b/tests/Unit/Timestampable/Util/CacheKeyGeneratorTest.php new file mode 100644 index 0000000..067c8cc --- /dev/null +++ b/tests/Unit/Timestampable/Util/CacheKeyGeneratorTest.php @@ -0,0 +1,30 @@ +