diff --git a/.github/workflows/documentation.yaml b/.github/workflows/documentation.yaml index 2475d8f2..0cbb4b2c 100644 --- a/.github/workflows/documentation.yaml +++ b/.github/workflows/documentation.yaml @@ -1,3 +1,7 @@ +# DO NOT EDIT THIS FILE! +# +# It's auto-generated by sonata-project/dev-kit package. + on: push: paths: @@ -31,7 +35,7 @@ jobs: run: sudo apt-get install python-dev build-essential - name: "Cache pip" - uses: actions/cache@v1 + uses: actions/cache@v2 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('docs/requirements.txt') }} diff --git a/CHANGELOG.md b/CHANGELOG.md index a019c0f7..27dd56c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## [3.12.1](https://github.com/sonata-project/SonataClassificationBundle/compare/3.12.0...3.12.1) - 2020-06-21 +### Fixed +- [[#539](https://github.com/sonata-project/SonataClassificationBundle/pull/539)] + Fix mysql database schema ([@wbloszyk](https://github.com/wbloszyk)) + +### Removed +- [[#539](https://github.com/sonata-project/SonataClassificationBundle/pull/539)] + Remove support for mssql database ([@wbloszyk](https://github.com/wbloszyk)) + +## [3.12.0](https://github.com/sonata-project/SonataClassificationBundle/compare/3.11.1...3.12.0) - 2020-06-19 +### Added +- Added `CategoryFilter` for admin lists +- Added `CollectionFilter` for admin lists + +### Fixed +- fixed database schema to work with mssql + +### Changed +- Make admin bundle optional + +### Removed +- SonataCoreBundle dependencies +- Support for Symfony < 4.3 + ## [3.11.1](https://github.com/sonata-project/SonataClassificationBundle/compare/3.11.0...3.11.1) - 2020-03-24 ### Fixed - Fix Lexer query error in managers diff --git a/composer.json b/composer.json index 6a20f2c6..f54192d7 100644 --- a/composer.json +++ b/composer.json @@ -21,34 +21,39 @@ } ], "require": { - "php": "^7.1", + "php": "^7.2", "cocur/slugify": "^2.0 || ^3.0 || ^4.0", - "sonata-project/admin-bundle": "^3.31", - "sonata-project/core-bundle": "^3.14", - "sonata-project/datagrid-bundle": "^2.3", + "sonata-project/datagrid-bundle": "^2.3 || ^3.0", "sonata-project/doctrine-extensions": "^1.6.0", - "sonata-project/doctrine-orm-admin-bundle": "^3.4", "sonata-project/easy-extends-bundle": "^2.5", - "symfony/config": "^3.4 || ^4.2", - "symfony/console": "^3.4 || ^4.2", - "symfony/dependency-injection": "^3.4 || ^4.2", - "symfony/form": "^3.4 || ^4.2", - "symfony/http-foundation": "^3.4 || ^4.2", - "symfony/http-kernel": "^3.4 || ^4.2", - "symfony/options-resolver": "^3.4 || ^4.2", - "twig/twig": "^1.35 || ^2.0" + "sonata-project/form-extensions": "^0.1.1 || ^1.4", + "symfony/config": "^4.3", + "symfony/console": "^4.3", + "symfony/dependency-injection": "^4.3", + "symfony/form": "^4.3", + "symfony/http-foundation": "^4.3", + "symfony/http-kernel": "^4.3", + "symfony/options-resolver": "^4.3", + "twig/twig": "^2.12.1" }, "conflict": { "doctrine/mongodb-odm": "<2.0", "friendsofsymfony/rest-bundle": "<2.1 || >=3.0", "jms/serializer": "<0.13", - "sonata-project/block-bundle": "<3.14 || >=4.0" + "sonata-project/admin-bundle": "<3.59", + "sonata-project/block-bundle": "<3.14 || >=4.0", + "sonata-project/core-bundle": "<3.20", + "sonata-project/doctrine-orm-admin-bundle": "<3.4", + "sonata-project/media-bundle": "<3.17 || >=4.0" }, "require-dev": { "doctrine/mongodb-odm": "^2.0", - "friendsofsymfony/rest-bundle": "^2.1", - "jms/serializer-bundle": "^1.0 || ^2.0", - "sonata-project/block-bundle": "^3.16.1", + "friendsofsymfony/rest-bundle": "^2.3", + "jms/serializer-bundle": "^2.0 || ^3.0", + "sonata-project/admin-bundle": "^3.59", + "sonata-project/block-bundle": "^3.18", + "sonata-project/doctrine-orm-admin-bundle": "^3.4", + "sonata-project/media-bundle": "^3.20", "symfony/phpunit-bridge": "^5.0" }, "suggest": { diff --git a/docs/requirements.txt b/docs/requirements.txt index 96d62560..a86ef50a 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,6 @@ -Sphinx!=1.8.0 +# DO NOT EDIT THIS FILE! +# +# It's auto-generated by sonata-project/dev-kit package. +Sphinx==1.8.5 git+https://github.com/fabpot/sphinx-php.git sphinx_rtd_theme diff --git a/src/Admin/Filter/CategoryFilter.php b/src/Admin/Filter/CategoryFilter.php new file mode 100644 index 00000000..eae094bd --- /dev/null +++ b/src/Admin/Filter/CategoryFilter.php @@ -0,0 +1,125 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\ClassificationBundle\Admin\Filter; + +use Sonata\AdminBundle\Datagrid\ProxyQueryInterface; +use Sonata\AdminBundle\Form\Type\Filter\DefaultType; +use Sonata\ClassificationBundle\Model\CategoryInterface; +use Sonata\ClassificationBundle\Model\CategoryManagerInterface; +use Sonata\DoctrineORMAdminBundle\Filter\Filter; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; + +final class CategoryFilter extends Filter +{ + /** + * @var CategoryManagerInterface + */ + private $categoryManager; + + public function __construct(CategoryManagerInterface $categoryManager) + { + $this->categoryManager = $categoryManager; + } + + public function filter(ProxyQueryInterface $queryBuilder, $alias, $field, $data): void + { + if (null === $data || !\is_array($data) || !\array_key_exists('value', $data)) { + return; + } + + if (null !== $data['value']) { + $queryBuilder + ->andWhere(sprintf('%s.%s = :category', $alias, $field)) + ->setParameter('category', $data['value']) + ; + } + + $this->active = null !== $data['value']; + } + + public function getDefaultOptions(): array + { + return [ + 'context' => null, + ]; + } + + public function getFieldType(): string + { + return $this->getOption('field_type', ChoiceType::class); + } + + public function getFieldOptions(): array + { + return $this->getOption('choices', [ + 'choices' => $this->getChoices(), + 'choice_translation_domain' => false, + ]); + } + + public function getRenderSettings(): array + { + return [DefaultType::class, [ + 'field_type' => $this->getFieldType(), + 'field_options' => $this->getFieldOptions(), + 'label' => $this->getLabel(), + ]]; + } + + protected function association(ProxyQueryInterface $queryBuilder, $data): array + { + $alias = $queryBuilder->entityJoin($this->getParentAssociationMappings()); + $part = strrchr('.'.$this->getFieldName(), '.'); + $fieldName = substr(false === $part ? $this->getFieldType() : $part, 1); + + return [$alias, $fieldName]; + } + + /** + * @return array + */ + private function getChoices(): array + { + $context = $this->getOption('context'); + + if (null === $context) { + $categories = $this->categoryManager->getAllRootCategories(); + } else { + $categories = $this->categoryManager->getRootCategoriesForContext($context); + } + + $choices = []; + + foreach ($categories as $category) { + $choices[sprintf('%s (%s)', $category->getName(), $category->getContext()->getId())] = $category->getId(); + + $this->visitChild($category, $choices); + } + + return $choices; + } + + private function visitChild(CategoryInterface $category, array &$choices, int $level = 2): void + { + if (0 === \count($category->getChildren())) { + return; + } + + foreach ($category->getChildren() as $child) { + $choices[sprintf('%s %s', str_repeat('-', 1 * $level), (string) $child)] = $child->getId(); + + $this->visitChild($child, $choices, $level + 1); + } + } +} diff --git a/src/Admin/Filter/CollectionFilter.php b/src/Admin/Filter/CollectionFilter.php new file mode 100644 index 00000000..f1dad1ab --- /dev/null +++ b/src/Admin/Filter/CollectionFilter.php @@ -0,0 +1,109 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\ClassificationBundle\Admin\Filter; + +use Sonata\AdminBundle\Datagrid\ProxyQueryInterface; +use Sonata\AdminBundle\Form\Type\Filter\DefaultType; +use Sonata\ClassificationBundle\Model\CollectionManagerInterface; +use Sonata\DoctrineORMAdminBundle\Filter\Filter; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; + +final class CollectionFilter extends Filter +{ + /** + * @var CollectionManagerInterface + */ + private $collectionManager; + + public function __construct(CollectionManagerInterface $collectionManager) + { + $this->collectionManager = $collectionManager; + } + + public function filter(ProxyQueryInterface $queryBuilder, $alias, $field, $data): void + { + if (null === $data || !\is_array($data) || !\array_key_exists('value', $data)) { + return; + } + + if ($data['value']) { + $queryBuilder + ->andWhere(sprintf('%s.%s = :collection', $alias, $field)) + ->setParameter('collection', $data['value']) + ; + } + + $this->active = null !== $data['value']; + } + + public function getDefaultOptions(): array + { + return [ + 'context' => null, + ]; + } + + public function getFieldType(): string + { + return $this->getOption('field_type', ChoiceType::class); + } + + public function getFieldOptions(): array + { + return $this->getOption('choices', [ + 'choices' => $this->getChoices(), + 'choice_translation_domain' => false, + ]); + } + + public function getRenderSettings(): array + { + return [DefaultType::class, [ + 'field_type' => $this->getFieldType(), + 'field_options' => $this->getFieldOptions(), + 'label' => $this->getLabel(), + ]]; + } + + protected function association(ProxyQueryInterface $queryBuilder, $data): array + { + $alias = $queryBuilder->entityJoin($this->getParentAssociationMappings()); + $part = strrchr('.'.$this->getFieldName(), '.'); + $fieldName = substr(false === $part ? $this->getFieldType() : $part, 1); + + return [$alias, $fieldName]; + } + + /** + * @return array + */ + private function getChoices(): array + { + $context = $this->getOption('context'); + + if (null === $context) { + $collections = $this->collectionManager->findAll(); + } else { + $collections = $this->collectionManager->getByContext($context); + } + + $choices = []; + + foreach ($collections as $collection) { + $choices[(string) $collection] = $collection->getId(); + } + + return $choices; + } +} diff --git a/src/Controller/Api/CategoryController.php b/src/Controller/Api/CategoryController.php index 05decc50..78844618 100644 --- a/src/Controller/Api/CategoryController.php +++ b/src/Controller/Api/CategoryController.php @@ -19,9 +19,9 @@ use FOS\RestBundle\Request\ParamFetcherInterface; use FOS\RestBundle\View\View as FOSRestView; use Nelmio\ApiDocBundle\Annotation\ApiDoc; +use Sonata\ClassificationBundle\Form\FormHelper; use Sonata\ClassificationBundle\Model\CategoryInterface; use Sonata\ClassificationBundle\Model\CategoryManagerInterface; -use Sonata\CoreBundle\Form\FormHelper; use Sonata\DatagridBundle\Pager\PagerInterface; use Symfony\Component\Form\FormFactoryInterface; use Symfony\Component\Form\FormInterface; diff --git a/src/Controller/Api/CollectionController.php b/src/Controller/Api/CollectionController.php index c0858e94..5d8db79c 100644 --- a/src/Controller/Api/CollectionController.php +++ b/src/Controller/Api/CollectionController.php @@ -19,9 +19,9 @@ use FOS\RestBundle\Request\ParamFetcherInterface; use FOS\RestBundle\View\View as FOSRestView; use Nelmio\ApiDocBundle\Annotation\ApiDoc; +use Sonata\ClassificationBundle\Form\FormHelper; use Sonata\ClassificationBundle\Model\CollectionInterface; use Sonata\ClassificationBundle\Model\CollectionManagerInterface; -use Sonata\CoreBundle\Form\FormHelper; use Sonata\DatagridBundle\Pager\PagerInterface; use Symfony\Component\Form\FormFactoryInterface; use Symfony\Component\Form\FormInterface; diff --git a/src/Controller/Api/ContextController.php b/src/Controller/Api/ContextController.php index 776fed47..36a1622f 100644 --- a/src/Controller/Api/ContextController.php +++ b/src/Controller/Api/ContextController.php @@ -19,9 +19,9 @@ use FOS\RestBundle\Request\ParamFetcherInterface; use FOS\RestBundle\View\View as FOSRestView; use Nelmio\ApiDocBundle\Annotation\ApiDoc; +use Sonata\ClassificationBundle\Form\FormHelper; use Sonata\ClassificationBundle\Model\ContextInterface; use Sonata\ClassificationBundle\Model\ContextManagerInterface; -use Sonata\CoreBundle\Form\FormHelper; use Sonata\DatagridBundle\Pager\PagerInterface; use Symfony\Component\Form\FormFactoryInterface; use Symfony\Component\Form\FormInterface; diff --git a/src/Controller/Api/TagController.php b/src/Controller/Api/TagController.php index 20c2c45c..566f2646 100644 --- a/src/Controller/Api/TagController.php +++ b/src/Controller/Api/TagController.php @@ -19,9 +19,9 @@ use FOS\RestBundle\Request\ParamFetcherInterface; use FOS\RestBundle\View\View as FOSRestView; use Nelmio\ApiDocBundle\Annotation\ApiDoc; +use Sonata\ClassificationBundle\Form\FormHelper; use Sonata\ClassificationBundle\Model\TagInterface; use Sonata\ClassificationBundle\Model\TagManagerInterface; -use Sonata\CoreBundle\Form\FormHelper; use Sonata\DatagridBundle\Pager\PagerInterface; use Symfony\Component\Form\FormFactoryInterface; use Symfony\Component\Form\FormInterface; diff --git a/src/DependencyInjection/SonataClassificationExtension.php b/src/DependencyInjection/SonataClassificationExtension.php index 23d801ef..8915466c 100644 --- a/src/DependencyInjection/SonataClassificationExtension.php +++ b/src/DependencyInjection/SonataClassificationExtension.php @@ -128,7 +128,6 @@ public function registerDoctrineMapping(array $config): void 'refresh', 'merge', 'detach', - 'remove', ], 'mappedBy' => null, 'inversedBy' => 'children', @@ -136,6 +135,7 @@ public function registerDoctrineMapping(array $config): void [ 'name' => 'parent_id', 'referencedColumnName' => 'id', + 'onDelete' => 'CASCADE', ], ], 'orphanRemoval' => false, diff --git a/src/Form/FormHelper.php b/src/Form/FormHelper.php new file mode 100644 index 00000000..b9b51663 --- /dev/null +++ b/src/Form/FormHelper.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\ClassificationBundle\Form; + +use Symfony\Component\Form\Form; + +final class FormHelper +{ + /** + * This function remove fields available if there are not present in the $data array + * The data array might come from $request->request->all(). + * + * This can be usefull if you don't want to send all fields will building an api. As missing + * fields will be threated like null values. + */ + public static function removeFields(array $data, Form $form) + { + $diff = array_diff(array_keys($form->all()), array_keys($data)); + + foreach ($diff as $key) { + $form->remove($key); + } + + foreach ($data as $name => $value) { + if (!\is_array($value)) { + continue; + } + + self::removeFields($value, $form[$name]); + } + } +} diff --git a/src/Resources/config/admin.xml b/src/Resources/config/admin.xml index 6295d4af..d0434721 100644 --- a/src/Resources/config/admin.xml +++ b/src/Resources/config/admin.xml @@ -50,5 +50,13 @@ %sonata.classification.admin.context.translation_domain% + + + + + + + + diff --git a/src/Serializer/CategorySerializerHandler.php b/src/Serializer/CategorySerializerHandler.php index 3d6f2950..83d5420f 100644 --- a/src/Serializer/CategorySerializerHandler.php +++ b/src/Serializer/CategorySerializerHandler.php @@ -13,7 +13,7 @@ namespace Sonata\ClassificationBundle\Serializer; -use Sonata\Serializer\BaseSerializerHandler; +use Sonata\Form\Serializer\BaseSerializerHandler; /** * @author Sylvain Deloux diff --git a/src/Serializer/CollectionSerializerHandler.php b/src/Serializer/CollectionSerializerHandler.php index 0894b347..e10b4447 100644 --- a/src/Serializer/CollectionSerializerHandler.php +++ b/src/Serializer/CollectionSerializerHandler.php @@ -13,7 +13,7 @@ namespace Sonata\ClassificationBundle\Serializer; -use Sonata\Serializer\BaseSerializerHandler; +use Sonata\Form\Serializer\BaseSerializerHandler; /** * @author Sylvain Deloux diff --git a/src/Serializer/ContextSerializerHandler.php b/src/Serializer/ContextSerializerHandler.php index b2bd98e6..28a22328 100644 --- a/src/Serializer/ContextSerializerHandler.php +++ b/src/Serializer/ContextSerializerHandler.php @@ -15,7 +15,7 @@ use JMS\Serializer\Context; use JMS\Serializer\VisitorInterface; -use Sonata\Serializer\BaseSerializerHandler; +use Sonata\Form\Serializer\BaseSerializerHandler; /** * @author Thomas Rabaix diff --git a/src/Serializer/TagSerializerHandler.php b/src/Serializer/TagSerializerHandler.php index b4b001ef..0f72db5f 100644 --- a/src/Serializer/TagSerializerHandler.php +++ b/src/Serializer/TagSerializerHandler.php @@ -13,7 +13,7 @@ namespace Sonata\ClassificationBundle\Serializer; -use Sonata\Serializer\BaseSerializerHandler; +use Sonata\Form\Serializer\BaseSerializerHandler; /** * @author Sylvain Deloux diff --git a/src/SonataClassificationBundle.php b/src/SonataClassificationBundle.php index 9caceb10..b3f59da1 100644 --- a/src/SonataClassificationBundle.php +++ b/src/SonataClassificationBundle.php @@ -36,15 +36,19 @@ public function boot(): void /** * Register form mapping information. + * + * NEXT_MAJOR: remove this method */ public function registerFormMapping(): void { - FormHelper::registerFormTypeMapping([ - 'sonata_classification_api_form_category' => ApiCategoryType::class, - 'sonata_classification_api_form_collection' => ApiCollectionType::class, - 'sonata_classification_api_form_tag' => ApiTagType::class, - 'sonata_classification_api_form_context' => ApiContextType::class, - 'sonata_category_selector' => CategorySelectorType::class, - ]); + if (class_exists(FormHelper::class)) { + FormHelper::registerFormTypeMapping([ + 'sonata_classification_api_form_category' => ApiCategoryType::class, + 'sonata_classification_api_form_collection' => ApiCollectionType::class, + 'sonata_classification_api_form_tag' => ApiTagType::class, + 'sonata_classification_api_form_context' => ApiContextType::class, + 'sonata_category_selector' => CategorySelectorType::class, + ]); + } } } diff --git a/tests/Admin/Filter/CategoryFilterTest.php b/tests/Admin/Filter/CategoryFilterTest.php new file mode 100644 index 00000000..a4bccd3b --- /dev/null +++ b/tests/Admin/Filter/CategoryFilterTest.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 Sonata\ClassificationBundle\Tests\Admin\Filter; + +use PHPUnit\Framework\TestCase; +use Sonata\ClassificationBundle\Admin\Filter\CategoryFilter; +use Sonata\ClassificationBundle\Model\CategoryManagerInterface; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; + +class CategoryFilterTest extends TestCase +{ + /** + * @var MockObject&CategoryManagerInterface + */ + private $categoryManager; + + protected function setUp(): void + { + $this->categoryManager = $this->createStub(CategoryManagerInterface::class); + } + + public function testRenderSettings(): void + { + $this->categoryManager->method('getAllRootCategories')->willReturn([]); + + $filter = new CategoryFilter($this->categoryManager); + $filter->initialize('field_name', [ + 'field_options' => ['class' => 'FooBar'], + ]); + $options = $filter->getRenderSettings()[1]; + + $this->assertSame(ChoiceType::class, $options['field_type']); + $this->assertSame([], $options['field_options']['choices']); + } +} diff --git a/tests/Admin/Filter/CollectionFilterTest.php b/tests/Admin/Filter/CollectionFilterTest.php new file mode 100644 index 00000000..97abfdd4 --- /dev/null +++ b/tests/Admin/Filter/CollectionFilterTest.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\ClassificationBundle\Tests\Admin\Filter; + +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Sonata\ClassificationBundle\Admin\Filter\CollectionFilter; +use Sonata\ClassificationBundle\Model\CollectionManagerInterface; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; + +class CollectionFilterTest extends TestCase +{ + /** + * @var MockObject&CollectionManagerInterface + */ + private $collectionManager; + + protected function setUp(): void + { + $this->collectionManager = $this->createStub(CollectionManagerInterface::class); + } + + public function testRenderSettings(): void + { + $this->collectionManager->method('findAll')->willReturn([]); + + $filter = new CollectionFilter($this->collectionManager); + $filter->initialize('field_name', [ + 'field_options' => ['class' => 'FooBar'], + ]); + $options = $filter->getRenderSettings()[1]; + + $this->assertSame(ChoiceType::class, $options['field_type']); + $this->assertSame([], $options['field_options']['choices']); + } +} diff --git a/tests/Admin/Filter/QueryBuilder.php b/tests/Admin/Filter/QueryBuilder.php new file mode 100644 index 00000000..5fbc6d79 --- /dev/null +++ b/tests/Admin/Filter/QueryBuilder.php @@ -0,0 +1,122 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\ClassificationBundle\Tests\Admin\Filter; + +use Doctrine\ORM\Query\Expr\Andx; +use Doctrine\ORM\Query\Expr\Orx; + +final class QueryBuilder extends \Doctrine\ORM\QueryBuilder +{ + public $parameters = []; + + public $query = []; + + public function __construct() + { + } + + /** + * @param mixed $value + */ + public function setParameter(string $key, $value, $type = null) + { + $this->parameters[$key] = $value; + } + + public function andWhere() + { + $query = \func_get_args(); + + $this->query[] = $query; + } + + public function expr(): self + { + return $this; + } + + /** + * @param string|string[] $parameter + */ + public function in(string $alias, $parameter): string + { + if (\is_array($parameter)) { + return sprintf('%s IN ("%s")', $alias, implode(', ', $parameter)); + } + + return sprintf('%s IN %s', $alias, $parameter); + } + + public function getDQLPart($queryPart) + { + return []; + } + + public function getRootAlias(): string + { + return current(($this->getRootAliases())); + } + + public function leftJoin($join, $alias, $conditionType = null, $condition = null, $indexBy = null) + { + $this->query[] = $join; + } + + public function orX($x = null): Orx + { + return new Orx(\func_get_args()); + } + + public function andX($x = null): Andx + { + return new Andx(\func_get_args()); + } + + public function neq(string $alias, string $parameter): string + { + return sprintf('%s <> %s', $alias, $parameter); + } + + public function isNull(string $queryPart): string + { + return $queryPart.' IS NULL'; + } + + public function isNotNull(string $queryPart): string + { + return $queryPart.' IS NOT NULL'; + } + + /** + * @param string|string[] $parameter + */ + public function notIn(string $alias, $parameter): string + { + if (\is_array($parameter)) { + return sprintf('%s NOT IN ("%s")', $alias, implode(', ', $parameter)); + } + + return sprintf('%s NOT IN %s', $alias, $parameter); + } + + public function getAllAliases(): array + { + return $this->getRootAliases(); + } + + public function getRootAliases(): array + { + return ['o']; + } +}