diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..e69f72b4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +.gitattributes export-ignore +.gitignore export-ignore +.php_cs export-ignore +.rmt.yml export-ignore +.styleci.yml export-ignore +.travis export-ignore +build.xml export-ignore +Makefile export-ignore +phpunit.xml.dist export-ignore +tests/ export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..5a2d6445 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +build +phpunit.xml +coverage +composer.lock +vendor +.php_cs.cache diff --git a/.php_cs b/.php_cs new file mode 100755 index 00000000..41c759d5 --- /dev/null +++ b/.php_cs @@ -0,0 +1,38 @@ + 0) { + require_once current($files); +} + +use SLLH\StyleCIBridge\ConfigBridge; + +$header = << + +For the full copyright and license information, please view the LICENSE +file that was distributed with this source code. +EOF; + +// PHP-CS-Fixer 1.x +if (class_exists('Symfony\CS\Fixer\Contrib\HeaderCommentFixer')) { + \Symfony\CS\Fixer\Contrib\HeaderCommentFixer::setHeader($header); +} + +$config = ConfigBridge::create() + ->setUsingCache(true) +; + +// PHP-CS-Fixer 2.x +if (method_exists($config, 'setRules')) { + $config->setRules(array_merge($config->getRules(), array( + 'header_comment' => array('header' => $header) + ))); +} + +return $config; diff --git a/.rmt.yml b/.rmt.yml new file mode 100644 index 00000000..5109dcfb --- /dev/null +++ b/.rmt.yml @@ -0,0 +1,30 @@ +vcs: git + +prerequisites: + - working-copy-check + - display-last-changes + - composer-json-check: + composer: composer + - command: + cmd: composer update + - tests-check: + command: vendor/bin/phpunit --stop-on-failure + - composer-security-check + - composer-stability-check + - command: + cmd: git remote -v + +pre-release-actions: + composer-update: ~ + changelog-update: + format: simple + dump-commits: true + exclude-merge-commits: true + vcs-commit: ~ + +version-generator: semantic +version-persister: vcs-tag + +post-release-actions: + vcs-publish: + ask-remote-name: true diff --git a/.styleci.yml b/.styleci.yml new file mode 100644 index 00000000..42db47dd --- /dev/null +++ b/.styleci.yml @@ -0,0 +1,22 @@ +preset: symfony + +enabled: + - align_double_arrow + - align_equals + - combine_consecutive_unsets + - long_array_syntax + - linebreak_after_opening_tag + - no_php4_constructor + - no_useless_else + - ordered_class_elements + - ordered_imports + - php_unit_construct + - php_unit_strict + +disabled: + - unalign_double_arrow + - unalign_equals + +finder: + exclude: + - 'tests/Fixtures' diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..bd549bc1 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,49 @@ +language: php + +php: + - 7.1 + - nightly + +sudo: false + +cache: + directories: + - $HOME/.composer/cache + +env: + global: + - PATH="$HOME/.composer/vendor/bin:$PATH" + - SYMFONY_DEPRECATIONS_HELPER=weak + - TARGET=test + +matrix: + fast_finish: true + include: + - php: 7.1 + env: TARGET=lint + - php: 7.1 + env: TARGET=phpstan + - php: 7.1 + env: COMPOSER_FLAGS="--prefer-lowest" + - php: 7.1 + env: SYMFONY_VERSION=2.8.* + - php: 7.1 + env: SYMFONY_VERSION=3.2.* + - php: 7.1 + env: SYMFONY_VERSION=3.3.* + allow_failures: + - php: nightly + - php: 7.1 + env: TARGET=phpstan + +install: + - composer global require satooshi/php-coveralls sllh/composer-lint codeclimate/php-test-reporter --prefer-dist --no-interaction + - if [ "$SYMFONY_VERSION" != "" ]; then composer require "symfony/symfony:${SYMFONY_VERSION}" --no-update; fi; + - if [ "$TARGET" == "phpstan" ]; then composer require --dev phpstan/phpstan:^0.8 --no-update; fi + - composer update --prefer-dist --no-interaction $COMPOSER_FLAGS + +script: make $TARGET + +after_script: + - coveralls -v + - test-reporter diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..42f9833f --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Christian Gripp + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..d00eb0c2 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +cs: + ./vendor/bin/php-cs-fixer fix --verbose + +cs_dry_run: + ./vendor/bin/php-cs-fixer fix --verbose --dry-run + +lint: + composer validate + +test: + ./vendor/bin/phpunit -c phpunit.xml.dist --coverage-clover build/logs/clover.xml + +phpstan: + ./vendor/bin/phpstan analyse -c phpstan.neon -l 4 src tests diff --git a/README.md b/README.md new file mode 100644 index 00000000..3190c954 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +What is the Doctrine Extensions PHP library? +============================================ +[![Latest Stable Version](https://poser.pugx.org/core23/doctrineextensions/v/stable)](https://packagist.org/packages/core23/doctrineextensions) +[![Latest Unstable Version](https://poser.pugx.org/core23/doctrineextensions/v/unstable)](https://packagist.org/packages/core23/doctrineextensions) +[![License](https://poser.pugx.org/core23/doctrineextensions/license)](https://packagist.org/packages/core23/doctrineextensions) + +[![Build Status](https://travis-ci.org/core23/doctrineextensions.svg)](http://travis-ci.org/core23/doctrineextensions) +[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/core23/doctrineextensions/badges/quality-score.png)](https://scrutinizer-ci.com/g/core23/doctrineextensions/) +[![Code Climate](https://codeclimate.com/github/core23/doctrineextensions/badges/gpa.svg)](https://codeclimate.com/github/core23/doctrineextensions) +[![Coverage Status](https://coveralls.io/repos/core23/doctrineextensions/badge.svg)](https://coveralls.io/r/core23/doctrineextensions) +[![SensioLabsInsight](https://insight.sensiolabs.com/projects/83024b06-03e0-4b04-a011-8ad598d93af4/mini.png)](https://insight.sensiolabs.com/projects/51aa4b42-d229-4994-bb3a-156da22a1375) + +[![Donate to this project using Flattr](https://img.shields.io/badge/flattr-donate-yellow.svg)](https://flattr.com/profile/core23) +[![Donate to this project using PayPal](https://img.shields.io/badge/paypal-donate-yellow.svg)](https://paypal.me/gripp) + +This library provides adds some useful doctrine hooks and a bridge for symfony. + +### Installation + +``` +php composer.phar require core23/doctrine-extensions +``` + +### Symfony usage + +#### Enabling the bundle + +```php + // app/AppKernel.php + + public function registerBundles() + { + return array( + // ... + + new Core23\DoctrineExtensions\Bridge\Symfony\Bundle\Core23DoctrineExtensionsBundle(), + + // ... + ); + } +``` + +This lib / bundle is available under the [MIT license](LICENSE.md). diff --git a/build.xml b/build.xml new file mode 100644 index 00000000..b59fbf3a --- /dev/null +++ b/build.xml @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/composer.json b/composer.json new file mode 100644 index 00000000..3a2afded --- /dev/null +++ b/composer.json @@ -0,0 +1,58 @@ +{ + "name": "core23/doctrine-extensions", + "type": "library", + "description": "Useful doctrine hooks.", + "keywords": [ + "symfony", + "bundle", + "doctrine", + "hooks", + "bridge", + "confirmable", + "deletable", + "lifecycle", + "sortable" + ], + "homepage": "https://core23.de", + "license": "MIT", + "authors": [ + { + "name": "Christian Gripp", + "email": "mail@core23.de" + } + ], + "require": { + "php": "^7.1", + "doctrine/common": "~2.8", + "doctrine/dbal": "~2.6", + "doctrine/orm": "~2.5", + "sonata-project/datagrid-bundle": "^2.2" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.0", + "matthiasnoback/symfony-dependency-injection-test": "^1.1", + "phpunit/phpunit": "^5.7", + "sllh/php-cs-fixer-styleci-bridge": "^2.0", + "sonata-project/core-bundle": "^3.6", + "symfony/framework-bundle": "^2.8 || ^3.3", + "symfony/phpunit-bridge": "^3.3.12" + }, + "autoload": { + "psr-4": { + "Core23\\DoctrineExtensions\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Core23\\DoctrineExtensions\\Tests\\": "tests/" + } + }, + "extra": { + "branch-alias": { + "dev-master": "0.x-dev" + } + }, + "config": { + "sort-packages": true + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 00000000..4052a137 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,3 @@ +parameters: + excludes_analyse: + - vendor diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 00000000..879c6017 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,16 @@ + + + + + ./tests + + + + + + + + ./src/ + + + diff --git a/src/Bridge/SonataCore/Manager/ORM/AbstractEntityManager.php b/src/Bridge/SonataCore/Manager/ORM/AbstractEntityManager.php new file mode 100644 index 00000000..8e59b580 --- /dev/null +++ b/src/Bridge/SonataCore/Manager/ORM/AbstractEntityManager.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Core23\DoctrineExtensions\Bridge\SonataCore\Manager\ORM; + +use Core23\DoctrineExtensions\Manager\BaseQueryTrait; +use Sonata\CoreBundle\Model\BaseEntityManager as SonataBaseEntityManager; + +abstract class AbstractEntityManager extends SonataBaseEntityManager +{ + use EntityManagerTrait, BaseQueryTrait; +} diff --git a/src/Bridge/SonataCore/Manager/ORM/EntityManagerTrait.php b/src/Bridge/SonataCore/Manager/ORM/EntityManagerTrait.php new file mode 100644 index 00000000..160390ec --- /dev/null +++ b/src/Bridge/SonataCore/Manager/ORM/EntityManagerTrait.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Core23\DoctrineExtensions\Bridge\SonataCore\Manager\ORM; + +use Doctrine\Common\Persistence\ObjectRepository; +use Doctrine\ORM\EntityManager; +use Doctrine\ORM\QueryBuilder; + +trait EntityManagerTrait +{ + /** + * Creates a new QueryBuilder instance that is prepopulated for this entity name. + * + * @param string $alias + * @param string $indexBy The index for the from + * + * @return QueryBuilder|EntityManager + */ + final protected function createQueryBuilder(string $alias, string $indexBy = null) + { + /* @noinspection PhpUndefinedMethodInspection */ + return $this->getRepository() + ->createQueryBuilder($alias, $indexBy); + } + + /** + * Returns the related Object Repository. + * + * @return ObjectRepository + */ + abstract protected function getRepository(); +} diff --git a/src/Bridge/Symfony/Bundle/Core23DoctrineExtensionsBundle.php b/src/Bridge/Symfony/Bundle/Core23DoctrineExtensionsBundle.php new file mode 100644 index 00000000..fd0db001 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Core23DoctrineExtensionsBundle.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Core23\DoctrineExtensions\Bridge\Symfony\Bundle; + +use Core23\DoctrineExtensions\Bridge\Symfony\DependencyInjection\Core23DoctrineExtensionsExtension; +use Symfony\Component\HttpKernel\Bundle\Bundle; + +final class Core23DoctrineExtensionsBundle extends Bundle +{ + /** + * {@inheritdoc} + */ + protected function getContainerExtensionClass() + { + return Core23DoctrineExtensionsExtension::class; + } +} diff --git a/src/Bridge/Symfony/DependencyInjection/Core23DoctrineExtensionsExtension.php b/src/Bridge/Symfony/DependencyInjection/Core23DoctrineExtensionsExtension.php new file mode 100755 index 00000000..8d26046e --- /dev/null +++ b/src/Bridge/Symfony/DependencyInjection/Core23DoctrineExtensionsExtension.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Core23\DoctrineExtensions\Bridge\Symfony\DependencyInjection; + +use Symfony\Component\Config\FileLocator; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader; +use Symfony\Component\HttpKernel\DependencyInjection\Extension; + +final class Core23DoctrineExtensionsExtension extends Extension +{ + /** + * {@inheritdoc} + */ + public function load(array $configs, ContainerBuilder $container) + { + $loader = new Loader\XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); + $loader->load('services.xml'); + } +} diff --git a/src/Bridge/Symfony/Resources/config/services.xml b/src/Bridge/Symfony/Resources/config/services.xml new file mode 100644 index 00000000..4cc1b4db --- /dev/null +++ b/src/Bridge/Symfony/Resources/config/services.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/EventListener/ORM/AbstractListener.php b/src/EventListener/ORM/AbstractListener.php new file mode 100644 index 00000000..76a7243a --- /dev/null +++ b/src/EventListener/ORM/AbstractListener.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Core23\DoctrineExtensions\EventListener\ORM; + +use Doctrine\Common\EventSubscriber; +use ReflectionClass; + +abstract class AbstractListener implements EventSubscriber +{ + /** + * @param ReflectionClass $reflection + * @param string $class + * + * @return bool + */ + final protected function containsTrait(ReflectionClass $reflection, string $class):bool + { + do { + $traits = $reflection->getTraitNames(); + + if (in_array($class, $traits)) { + return true; + } + + foreach ($reflection->getTraits() as $reflTraits) { + if ($this->containsTrait($reflTraits, $class)) { + return true; + } + } + } while ($reflection = $reflection->getParentClass()); + + return false; + } +} diff --git a/src/EventListener/ORM/ConfirmableListener.php b/src/EventListener/ORM/ConfirmableListener.php new file mode 100644 index 00000000..014e5ece --- /dev/null +++ b/src/EventListener/ORM/ConfirmableListener.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Core23\DoctrineExtensions\EventListener\ORM; + +use Core23\DoctrineExtensions\Model\Traits\ConfirmableTrait; +use Doctrine\ORM\Event\LoadClassMetadataEventArgs; +use Doctrine\ORM\Events; +use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Mapping\MappingException; + +final class ConfirmableListener extends AbstractListener +{ + /** + * {@inheritdoc} + */ + public function getSubscribedEvents() + { + return array( + Events::loadClassMetadata, + ); + } + + /** + * @param LoadClassMetadataEventArgs $eventArgs + * + * @throws MappingException + */ + public function loadClassMetadata(LoadClassMetadataEventArgs $eventArgs) : void + { + $meta = $eventArgs->getClassMetadata(); + + if (!$meta instanceof ClassMetadata) { + throw new \LogicException(sprintf('Class metadata was no ORM but %s', get_class($meta))); + } + + if (!$this->containsTrait($meta->getReflectionClass(), ConfirmableTrait::class)) { + return; + } + + if (!$meta->hasField('confirmedAt')) { + $meta->mapField(array( + 'type' => 'datetime', + 'fieldName' => 'confirmedAt', + 'nullable' => true, + )); + } + } +} diff --git a/src/EventListener/ORM/DeletableListener.php b/src/EventListener/ORM/DeletableListener.php new file mode 100644 index 00000000..aa3da093 --- /dev/null +++ b/src/EventListener/ORM/DeletableListener.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Core23\DoctrineExtensions\EventListener\ORM; + +use Core23\DoctrineExtensions\Model\Traits\DeleteableTrait; +use Doctrine\ORM\Event\LoadClassMetadataEventArgs; +use Doctrine\ORM\Events; +use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Mapping\MappingException; + +final class DeletableListener extends AbstractListener +{ + /** + * {@inheritdoc} + */ + public function getSubscribedEvents() + { + return array( + Events::loadClassMetadata, + ); + } + + /** + * @param LoadClassMetadataEventArgs $eventArgs + * + * @throws MappingException + */ + public function loadClassMetadata(LoadClassMetadataEventArgs $eventArgs) : void + { + $meta = $eventArgs->getClassMetadata(); + + if (!$meta instanceof ClassMetadata) { + throw new \LogicException(sprintf('Class metadata was no ORM but %s', get_class($meta))); + } + + if (!$this->containsTrait($meta->getReflectionClass(), DeleteableTrait::class)) { + return; + } + + if (!$meta->hasField('deletedAt')) { + $meta->mapField(array( + 'type' => 'datetime', + 'fieldName' => 'deletedAt', + 'nullable' => true, + )); + } + } +} diff --git a/src/EventListener/ORM/LifecycleDateListener.php b/src/EventListener/ORM/LifecycleDateListener.php new file mode 100644 index 00000000..bdec5695 --- /dev/null +++ b/src/EventListener/ORM/LifecycleDateListener.php @@ -0,0 +1,93 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Core23\DoctrineExtensions\EventListener\ORM; + +use Core23\DoctrineExtensions\Model\LifecycleDateTimeInterface; +use Core23\DoctrineExtensions\Model\Traits\LifecycleDateTimeTrait; +use Doctrine\ORM\Event\LifecycleEventArgs; +use Doctrine\ORM\Event\LoadClassMetadataEventArgs; +use Doctrine\ORM\Events; +use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Mapping\MappingException; + +final class LifecycleDateListener extends AbstractListener +{ + /** + * {@inheritdoc} + */ + public function getSubscribedEvents() + { + return array( + Events::prePersist, + Events::preUpdate, + Events::loadClassMetadata, + ); + } + + /** + * Start lifecycle. + * + * @param LifecycleEventArgs $args + */ + public function prePersist(LifecycleEventArgs $args): void + { + $object = $args->getObject(); + + if ($object instanceof LifecycleDateTimeInterface) { + $object->setCreatedAt(new \DateTime()); + $object->setUpdatedAt(new \DateTime()); + } + } + + /** + * Update LifecycleDateTime. + * + * @param LifecycleEventArgs $args + */ + public function preUpdate(LifecycleEventArgs $args): void + { + $object = $args->getObject(); + + if ($object instanceof LifecycleDateTimeInterface) { + $object->setUpdatedAt(new \DateTime()); + } + } + + /** + * @param LoadClassMetadataEventArgs $eventArgs + * + * @throws MappingException + */ + public function loadClassMetadata(LoadClassMetadataEventArgs $eventArgs) : void + { + $meta = $eventArgs->getClassMetadata(); + + if (!$meta instanceof ClassMetadata) { + throw new \LogicException(sprintf('Class metadata was no ORM but %s', get_class($meta))); + } + + if (!$this->containsTrait($meta->getReflectionClass(), LifecycleDateTimeTrait::class)) { + return; + } + + if (!$meta->hasField('createdAt')) { + $meta->mapField(array( + 'type' => 'datetime', + 'fieldName' => 'createdAt', + )); + } + if (!$meta->hasField('updatedAt')) { + $meta->mapField(array( + 'type' => 'datetime', + 'fieldName' => 'updatedAt', + )); + } + } +} diff --git a/src/EventListener/ORM/SortableListener.php b/src/EventListener/ORM/SortableListener.php new file mode 100644 index 00000000..b912dc61 --- /dev/null +++ b/src/EventListener/ORM/SortableListener.php @@ -0,0 +1,188 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Core23\DoctrineExtensions\EventListener\ORM; + +use Core23\DoctrineExtensions\Model\PositionAwareInterface; +use Core23\DoctrineExtensions\Model\Traits\SortableTrait; +use Doctrine\ORM\EntityManager; +use Doctrine\ORM\Event\LifecycleEventArgs; +use Doctrine\ORM\Event\LoadClassMetadataEventArgs; +use Doctrine\ORM\Event\PreUpdateEventArgs; +use Doctrine\ORM\Events; +use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Mapping\MappingException; +use Doctrine\ORM\NonUniqueResultException; +use Symfony\Component\PropertyAccess\PropertyAccess; + +final class SortableListener extends AbstractListener +{ + /** + * {@inheritdoc} + */ + public function getSubscribedEvents() + { + return array( + Events::prePersist, + Events::preUpdate, + Events::preRemove, + Events::loadClassMetadata, + ); + } + + /** + * @param LifecycleEventArgs $args + */ + public function prePersist(LifecycleEventArgs $args): void + { + if ($args->getEntity() instanceof PositionAwareInterface) { + $this->uniquePosition($args); + } + } + + /** + * @param PreUpdateEventArgs $args + */ + public function preUpdate(PreUpdateEventArgs $args): void + { + if ($args->getEntity() instanceof PositionAwareInterface) { + $position = $args->getEntity()->getPosition(); + + if ($args->hasChangedField('position')) { + $position = $args->getOldValue('position'); + } + + $this->uniquePosition($args, $position); + } + } + + /** + * @param LifecycleEventArgs $args + */ + public function preRemove(LifecycleEventArgs $args): void + { + $entity = $args->getEntity(); + + if ($entity instanceof PositionAwareInterface) { + $this->movePosition($args->getEntityManager(), $entity, -1); + } + } + + /** + * @param LoadClassMetadataEventArgs $eventArgs + * + * @throws MappingException + */ + public function loadClassMetadata(LoadClassMetadataEventArgs $eventArgs) : void + { + $meta = $eventArgs->getClassMetadata(); + + if (!$meta instanceof ClassMetadata) { + throw new \LogicException(sprintf('Class metadata was no ORM but %s', get_class($meta))); + } + + if (!$this->containsTrait($meta->getReflectionClass(), SortableTrait::class)) { + return; + } + + if (!$meta->hasField('position')) { + $meta->mapField(array( + 'type' => 'integer', + 'fieldName' => 'position', + )); + } + } + + /** + * @param LifecycleEventArgs $args + * @param int|null $oldPosition + */ + private function uniquePosition(LifecycleEventArgs $args, ? int $oldPosition = null) : void + { + $entity = $args->getEntity(); + + if ($entity instanceof PositionAwareInterface) { + if (null === $entity->getPosition()) { + $position = $this->getNextPosition($args->getEntityManager(), $entity); + $entity->setPosition($position); + } elseif ($oldPosition && $oldPosition !== $entity->getPosition()) { + $this->movePosition($args->getEntityManager(), $entity); + } + } + } + + /** + * @param EntityManager $em + * @param PositionAwareInterface $entity + * @param int $direction + */ + private function movePosition(EntityManager $em, PositionAwareInterface $entity, int $direction = 1): void + { + $uow = $em->getUnitOfWork(); + $meta = $em->getClassMetadata(get_class($entity)); + + $qb = $em->createQueryBuilder() + ->update($meta->getName(), 'e') + ->set('e.position', 'e.position + '.$direction); + + if ($direction > 0) { + $qb->andWhere('e.position <= :position')->setParameter('position', $entity->getPosition()); + } elseif ($direction < 0) { + $qb->andWhere('e.position >= :position')->setParameter('position', $entity->getPosition()); + } else { + return; + } + + $propertyAccessor = PropertyAccess::createPropertyAccessor(); + + foreach ($entity->getPositionGroup() as $field) { + $value = $propertyAccessor->getValue($entity, $field); + + if (is_object($value) && null === $uow->getSingleIdentifierValue($value)) { + continue; + } + + $qb->andWhere('e.'.$field.' = :'.$field)->setParameter($field, $value); + } + + $qb->getQuery()->execute(); + } + + /** + * @param EntityManager $em + * @param PositionAwareInterface $entity + * + * @return int + */ + private function getNextPosition(EntityManager $em, PositionAwareInterface $entity): int + { + $meta = $em->getClassMetadata(get_class($entity)); + + $qb = $em->createQueryBuilder() + ->select('e') + ->from($meta->getName(), 'e') + ->addOrderBy('e.position', 'DESC') + ->setMaxResults(1); + + $propertyAccessor = PropertyAccess::createPropertyAccessor(); + + foreach ($entity->getPositionGroup() as $field) { + $value = $propertyAccessor->getValue($entity, $field); + $qb->andWhere('e.'.$field.' = :'.$field)->setParameter($field, $value); + } + + /** @var PositionAwareInterface $result */ + try { + $result = $qb->getQuery()->getOneOrNullResult(); + } catch (NonUniqueResultException $ignored) { + } + + return ($result ? $result->getPosition() : 0) + 1; + } +} diff --git a/src/Manager/BaseQueryTrait.php b/src/Manager/BaseQueryTrait.php new file mode 100644 index 00000000..2f64994d --- /dev/null +++ b/src/Manager/BaseQueryTrait.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Core23\DoctrineExtensions\Manager; + +use Doctrine\ORM\QueryBuilder; +use Sonata\DatagridBundle\Pager\Doctrine\Pager; +use Sonata\DatagridBundle\Pager\PagerInterface; +use Sonata\DatagridBundle\ProxyQuery\Doctrine\ProxyQuery; + +trait BaseQueryTrait +{ + /** + * Builds a pager for a given query builder. + * + * @param QueryBuilder $builder + * @param int $limit + * @param int $page + * + * @return PagerInterface + */ + public function createPager(QueryBuilder $builder, int $limit, int $page): PagerInterface + { + $pager = new Pager(); + $pager->setMaxPerPage($limit); + $pager->setQuery(new ProxyQuery($builder)); + $pager->setPage($page); + $pager->init(); + + return $pager; + } + + /** + * @param QueryBuilder $builder + * @param array $sort + * @param string $defaultEntity + * @param array $aliasMapping + * @param string $defaultOrder + * + * @return QueryBuilder + */ + public function addOrder(QueryBuilder $builder, array $sort, string $defaultEntity, array $aliasMapping = array(), string $defaultOrder = 'asc'): QueryBuilder + { + foreach ($sort as $field => $order) { + if (is_int($field)) { + $field = $order; + $order = $defaultOrder; + } + + $fieldSpl = explode('.', $field); + + if (count($fieldSpl) > 2) { + continue; + } + + $table = $defaultEntity; + + // Map entity to table name + if (count($fieldSpl) === 2) { + foreach ($aliasMapping as $k => $v) { + if ($fieldSpl[0] === $k) { + $table = $v; + $field = $fieldSpl[1]; + break; + } + } + } + + $builder->addOrderBy($table.'.'.$field, $order); + } + + return $builder; + } +} diff --git a/src/Manager/SearchQueryTrait.php b/src/Manager/SearchQueryTrait.php new file mode 100644 index 00000000..1d44a3ef --- /dev/null +++ b/src/Manager/SearchQueryTrait.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Core23\DoctrineExtensions\Manager; + +use Doctrine\ORM\Query\Expr\Composite; +use Doctrine\ORM\Query\Expr\Orx; +use Doctrine\ORM\QueryBuilder; + +trait SearchQueryTrait +{ + /** + * Creates a like search for a given field and text values. + * + * @param QueryBuilder $qb + * @param string $field + * @param array $values + * @param bool $strict + * + * @return Composite + */ + public function searchWhere(QueryBuilder $qb, string $field, array $values, bool $strict = false): Composite + { + $orx = $qb->expr()->orX(); + foreach ($values as $index => $word) { + $orx->add(sprintf('%s = :name'.$index, $field)); + $qb->setParameter('name'.$index, $word); + + if (!$strict) { + $this->buildLikeExpressions($qb, $orx, $field, $word, $index); + } + } + + return $orx; + } + + /** + * @param QueryBuilder $qb + * @param Orx $orx + * @param string $field + * @param string $word + * @param int $index + */ + private function buildLikeExpressions(QueryBuilder $qb, Orx $orx, string $field, string $word, int $index) : void + { + $orx->add(sprintf('%s LIKE :name'.$index.'_any', $field)); + $orx->add(sprintf('%s LIKE :name'.$index.'_pre', $field)); + $orx->add(sprintf('%s LIKE :name'.$index.'_suf', $field)); + + $qb->setParameter('name'.$index.'_any', '% '.$word.' %'); + $qb->setParameter('name'.$index.'_pre', '% '.$word); + $qb->setParameter('name'.$index.'_suf', $word.' %'); + } +} diff --git a/src/Model/ConfirmableInterface.php b/src/Model/ConfirmableInterface.php new file mode 100755 index 00000000..fc799494 --- /dev/null +++ b/src/Model/ConfirmableInterface.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Core23\DoctrineExtensions\Model; + +interface ConfirmableInterface +{ + /** + * Get confirmedAt. + * + * @return \DateTime|null + */ + public function getConfirmedAt(): ? \DateTime; + + /** + * Set confirmedAt. + * + * @param \DateTime|null $confirmedAt + * + * @return $this + */ + public function setConfirmedAt(? \DateTime $confirmedAt); + + /** + * Set confirmed. + * + * @param bool $confirmed + * + * @return $this + */ + public function setConfirmed(bool $confirmed); + + /** + * Get deleted. + * + * @return bool + */ + public function isConfirmed() : bool; +} diff --git a/src/Model/DeletableInterface.php b/src/Model/DeletableInterface.php new file mode 100755 index 00000000..66fb7ae1 --- /dev/null +++ b/src/Model/DeletableInterface.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Core23\DoctrineExtensions\Model; + +interface DeletableInterface +{ + /** + * Get deletedAt. + * + * @return \DateTime + */ + public function getDeletedAt(): ? \DateTime; + + /** + * Set deletedAt. + * + * @param \DateTime|null $deletedAt + * + * @return $this + */ + public function setDeletedAt(? \DateTime $deletedAt); + + /** + * Set deleted. + * + * @param bool $deleted + * + * @return $this + */ + public function setDeleted(bool $deleted); + + /** + * Get deleted. + * + * @return bool + */ + public function isDeleted() : bool; +} diff --git a/src/Model/LifecycleDateTimeInterface.php b/src/Model/LifecycleDateTimeInterface.php new file mode 100755 index 00000000..5f64764e --- /dev/null +++ b/src/Model/LifecycleDateTimeInterface.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Core23\DoctrineExtensions\Model; + +interface LifecycleDateTimeInterface +{ + /** + * Get createdAt. + * + * @return \DateTime|null + */ + public function getCreatedAt(): ? \DateTime; + + /** + * Get updatedAt. + * + * @return \DateTime|null + */ + public function getUpdatedAt() : ? \DateTime; + + /** + * Set createdAt. + * + * @param \DateTime|null $createdAt + * + * @return $this + */ + public function setCreatedAt(? \DateTime $createdAt); + + /** + * Set updatedAt. + * + * @param \DateTime|null $updatedAt + * + * @return $this + */ + public function setUpdatedAt(? \DateTime $updatedAt); +} diff --git a/src/Model/PositionAwareInterface.php b/src/Model/PositionAwareInterface.php new file mode 100644 index 00000000..2f38d0af --- /dev/null +++ b/src/Model/PositionAwareInterface.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Core23\DoctrineExtensions\Model; + +interface PositionAwareInterface +{ + /** + * Get position. + * + * @return int|null + */ + public function getPosition(): ? int; + + /** + * Set position. + * + * @param int|null $position + * + * @return $this + */ + public function setPosition(? int $position); + + /** + * Get list of position fields. + * + * @return string[] + */ + public function getPositionGroup() : array; +} diff --git a/src/Model/Traits/ConfirmableTrait.php b/src/Model/Traits/ConfirmableTrait.php new file mode 100755 index 00000000..7cffa079 --- /dev/null +++ b/src/Model/Traits/ConfirmableTrait.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Core23\DoctrineExtensions\Model\Traits; + +trait ConfirmableTrait +{ + /** + * @var \DateTime + */ + protected $confirmedAt; + + /** + * Get confirmedAt. + * + * @return \DateTime|null + */ + public function getConfirmedAt(): ? \DateTime + { + return $this->confirmedAt; + } + + /** + * Set confirmedAt. + * + * @param \DateTime|null $confirmedAt + * + * @return $this + */ + public function setConfirmedAt(? \DateTime $confirmedAt) + { + $this->confirmedAt = $confirmedAt; + + return $this; + } + + /** + * Set confirmed. + * + * @param bool $confirmed + * + * @return $this + */ + public function setConfirmed(bool $confirmed) + { + if ($confirmed) { + $this->setConfirmedAt(new \DateTime()); + } else { + $this->setConfirmedAt(null); + } + + return $this; + } + + /** + * Get confirmed. + * + * @return bool + */ + public function isConfirmed() : bool + { + return $this->confirmedAt !== null; + } +} diff --git a/src/Model/Traits/DeleteableTrait.php b/src/Model/Traits/DeleteableTrait.php new file mode 100755 index 00000000..3a4faea9 --- /dev/null +++ b/src/Model/Traits/DeleteableTrait.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Core23\DoctrineExtensions\Model\Traits; + +trait DeleteableTrait +{ + /** + * @var \DateTime + */ + protected $deletedAt; + + /** + * Get deletedAt. + * + * @return \DateTime|null + */ + public function getDeletedAt(): ? \DateTime + { + return $this->deletedAt; + } + + /** + * Set deletedAt. + * + * @param \DateTime|null $deletedAt + * + * @return $this + */ + public function setDeletedAt(? \DateTime $deletedAt) + { + $this->deletedAt = $deletedAt; + + return $this; + } + + /** + * Set deleted. + * + * @param bool $deleted + * + * @return $this + */ + public function setDeleted(bool $deleted) + { + if ($deleted) { + $this->setDeletedAt(new \DateTime()); + } else { + $this->setDeletedAt(null); + } + + return $this; + } + + /** + * Get deleted. + * + * @return bool + */ + public function isDeleted() : bool + { + return $this->deletedAt !== null; + } +} diff --git a/src/Model/Traits/LifecycleDateTimeTrait.php b/src/Model/Traits/LifecycleDateTimeTrait.php new file mode 100755 index 00000000..66648b93 --- /dev/null +++ b/src/Model/Traits/LifecycleDateTimeTrait.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Core23\DoctrineExtensions\Model\Traits; + +trait LifecycleDateTimeTrait +{ + /** + * @var \DateTime|null + */ + protected $createdAt; + + /** + * @var \DateTime|null + */ + protected $updatedAt; + + /** + * Set createdAt. + * + * @param \DateTime|null $createdAt + * + * @return $this + */ + public function setCreatedAt(? \DateTime $createdAt) + { + $this->createdAt = $createdAt; + + return $this; + } + + /** + * Get createdAt. + * + * @return \DateTime|null + */ + public function getCreatedAt() : ? \DateTime + { + return $this->createdAt; + } + + /** + * Set modifiedAt. + * + * @param \DateTime|null $updatedAt + * + * @return $this + */ + public function setUpdatedAt(? \DateTime $updatedAt) + { + $this->updatedAt = $updatedAt; + + return $this; + } + + /** + * Get modifiedAt. + * + * @return \DateTime|null + */ + public function getUpdatedAt() : ? \DateTime + { + return $this->updatedAt; + } +} diff --git a/src/Model/Traits/SortableTrait.php b/src/Model/Traits/SortableTrait.php new file mode 100644 index 00000000..ea61f728 --- /dev/null +++ b/src/Model/Traits/SortableTrait.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Core23\DoctrineExtensions\Model\Traits; + +trait SortableTrait +{ + /** + * @var int|null + */ + protected $position = 0; + + /** + * @param int|null $position + * + * @return $this + */ + public function setPosition(? int $position) + { + $this->position = $position; + + return $this; + } + + /** + * @return int|null + */ + public function getPosition() : int + { + return $this->position; + } + + /** + * @return array + */ + public function getPositionGroup() : array + { + return array(); + } +} diff --git a/src/Test/EntityManagerMockFactory.php b/src/Test/EntityManagerMockFactory.php new file mode 100644 index 00000000..e2645094 --- /dev/null +++ b/src/Test/EntityManagerMockFactory.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Core23\DoctrineExtensions\Test; + +use Doctrine\DBAL\Connection; +use Doctrine\ORM\AbstractQuery; +use Doctrine\ORM\EntityManager; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\EntityRepository; +use Doctrine\ORM\Mapping\ClassMetadataInfo; +use Doctrine\ORM\QueryBuilder; +use Doctrine\ORM\Version; + +final class EntityManagerMockFactory +{ + /** + * @param \PHPUnit_Framework_TestCase $test + * @param \Closure $qbCallback + * @param mixed $fields + * + * @return \PHPUnit_Framework_MockObject_MockObject|EntityManagerInterface + */ + public static function create(\PHPUnit_Framework_TestCase $test, \Closure $qbCallback, $fields): \PHPUnit_Framework_MockObject_MockObject + { + $query = $test->getMockBuilder(AbstractQuery::class) + ->disableOriginalConstructor()->getMock(); + $query->expects($test->any())->method('execute')->will($test->returnValue(true)); + + if (Version::compare('2.5.0') < 1) { + $entityManager = $test->getMockBuilder(EntityManagerInterface::class)->getMock(); + $qb = $test->getMockBuilder(QueryBuilder::class)->setConstructorArgs(array($entityManager))->getMock(); + } else { + $qb = $test->getMockBuilder(QueryBuilder::class)->disableOriginalConstructor()->getMock(); + } + + $qb->expects($test->any())->method('select')->will($test->returnValue($qb)); + $qb->expects($test->any())->method('getQuery')->will($test->returnValue($query)); + $qb->expects($test->any())->method('where')->will($test->returnValue($qb)); + $qb->expects($test->any())->method('orderBy')->will($test->returnValue($qb)); + $qb->expects($test->any())->method('andWhere')->will($test->returnValue($qb)); + $qb->expects($test->any())->method('leftJoin')->will($test->returnValue($qb)); + + $qbCallback($qb); + + $repository = $test->getMockBuilder(EntityRepository::class)->disableOriginalConstructor()->getMock(); + $repository->expects($test->any())->method('createQueryBuilder')->will($test->returnValue($qb)); + + $metadata = $test->getMockBuilder(ClassMetadataInfo::class)->disableOriginalConstructor()->getMock(); + $metadata->expects($test->any())->method('getFieldNames')->will($test->returnValue($fields)); + $metadata->expects($test->any())->method('getName')->will($test->returnValue('className')); + $metadata->expects($test->any())->method('getIdentifier')->will($test->returnValue('id')); + $metadata->expects($test->any())->method('getTableName')->will($test->returnValue('dummy')); + + $connection = $test->getMockBuilder(Connection::class)->disableOriginalConstructor()->getMock(); + + $em = $test->getMockBuilder(EntityManager::class)->disableOriginalConstructor()->getMock(); + $em->expects($test->any())->method('getRepository')->will($test->returnValue($repository)); + $em->expects($test->any())->method('getClassMetadata')->will($test->returnValue($metadata)); + $em->expects($test->any())->method('getConnection')->will($test->returnValue($connection)); + + return $em; + } +} diff --git a/tests/Bridge/Symfony/DependencyInjection/Core23DoctrineExtensionsExtensionTest.php b/tests/Bridge/Symfony/DependencyInjection/Core23DoctrineExtensionsExtensionTest.php new file mode 100644 index 00000000..507901a9 --- /dev/null +++ b/tests/Bridge/Symfony/DependencyInjection/Core23DoctrineExtensionsExtensionTest.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Core23\DoctrineExtensions\Tests\Bridge\Symfony\DependencyInjection; + +use Core23\DoctrineExtensions\Bridge\Symfony\DependencyInjection\Core23DoctrineExtensionsExtension; +use Matthias\SymfonyDependencyInjectionTest\PhpUnit\AbstractExtensionTestCase; + +class Core23DoctrineExtensionsExtensionTest extends AbstractExtensionTestCase +{ + public function testLoadDefault() + { + } + + protected function getContainerExtensions(): array + { + return array( + new Core23DoctrineExtensionsExtension(), + ); + } +} diff --git a/tests/autoload.php.dist b/tests/autoload.php.dist new file mode 100755 index 00000000..1389bb68 --- /dev/null +++ b/tests/autoload.php.dist @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +// if the bundle is within a symfony project, try to reuse the project's autoload + +$files = array( + __DIR__.'/../vendor/autoload.php', + __DIR__.'/../../vendor/autoload.php', +); + +$autoload = false; +foreach ($files as $file) { + if (is_file($file)) { + $autoload = include_once $file; + + break; + } +} + +if (!$autoload) { + die('Unable to find autoload.php file, please use composer to load dependencies: + +wget http://getcomposer.org/composer.phar +php composer.phar install + +Visit http://getcomposer.org/ for more information. + +'); +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100755 index 00000000..84c4c014 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +if (file_exists($file = __DIR__.'/autoload.php')) { + require_once $file; +} elseif (file_exists($file = __DIR__.'/autoload.php.dist')) { + require_once $file; +} + +// try to get Symfony's PHPunit Bridge +$files = array_filter(array( + __DIR__.'/../vendor/symfony/symfony/src/Symfony/Bridge/PhpUnit/bootstrap.php', + __DIR__.'/../vendor/symfony/phpunit-bridge/bootstrap.php', + __DIR__.'/../../../../vendor/symfony/symfony/src/Symfony/Bridge/PhpUnit/bootstrap.php', + __DIR__.'/../../../../vendor/symfony/phpunit-bridge/bootstrap.php', +), 'file_exists'); +if (count($files) > 0) { + require_once current($files); +}