From b9010411fd2b8de4fa0fcbb257796f19a99ba6f3 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 28 Apr 2026 16:44:28 +0200 Subject: [PATCH 01/71] Refactor: Introduce `SimpleEquationFilterType` and `AbstractFilterType` to modularize filter handling --- src/FilterElement/SimpleEquationElement.php | 49 +++----------- src/FilterType/AbstractFilterType.php | 23 +++++++ src/FilterType/FilterTypeInterface.php | 21 ++++++ src/FilterType/SimpleEquationFilterType.php | 75 +++++++++++++++++++++ 4 files changed, 129 insertions(+), 39 deletions(-) create mode 100644 src/FilterType/AbstractFilterType.php create mode 100644 src/FilterType/FilterTypeInterface.php create mode 100644 src/FilterType/SimpleEquationFilterType.php diff --git a/src/FilterElement/SimpleEquationElement.php b/src/FilterElement/SimpleEquationElement.php index 2dd16704..4197292a 100644 --- a/src/FilterElement/SimpleEquationElement.php +++ b/src/FilterElement/SimpleEquationElement.php @@ -11,10 +11,10 @@ use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\FilterInvocation; +use HeimrichHannot\FlareBundle\FilterType\SimpleEquationFilterType; use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; use HeimrichHannot\FlareBundle\Specification\FilterDefinition; use HeimrichHannot\FlareBundle\Util\DcaHelper; -use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE, isTargeted: true)] @@ -33,31 +33,16 @@ public function __invoke(FilterInvocation $inv, FilterQueryBuilder $qb): void throw new FilterException('Invalid filter configuration.'); } - $operand = $qb->column($operand); + $filter = new SimpleEquationFilterType(); + $resolver = new OptionsResolver(); + $filter->configureOptions($resolver); + $options = $resolver->resolve([ + 'operand_left' => $operand, + 'operator' => $op, + 'operand_right' => $inv->filter->equationRight, + ]); - $where = match ($op) { - SqlEquationOperator::EQUALS => $qb->expr()->eq($operand, ':eq_right'), - SqlEquationOperator::NOT_EQUALS => $qb->expr()->neq($operand, ':eq_right'), - SqlEquationOperator::GREATER_THAN => $qb->expr()->gt($operand, ':eq_right'), - SqlEquationOperator::GREATER_THAN_EQUALS => $qb->expr()->gte($operand, ':eq_right'), - SqlEquationOperator::LESS_THAN => $qb->expr()->lt($operand, ':eq_right'), - SqlEquationOperator::LESS_THAN_EQUALS => $qb->expr()->lte($operand, ':eq_right'), - SqlEquationOperator::LIKE => $qb->expr()->like($operand, ':eq_right'), - SqlEquationOperator::NOT_LIKE => $qb->expr()->notLike($operand, ':eq_right'), - SqlEquationOperator::IS_NULL => $qb->expr()->isNull($operand), - SqlEquationOperator::IS_NOT_NULL => $qb->expr()->isNotNull($operand), - default => null, - }; - - if (!$where) { - throw new FilterException('Invalid filter configuration: Operator not supported.'); - } - - $qb->where($where); - - if (!$op->isUnary()) { - $qb->setParameter(':eq_right', $inv->filter->equationRight ?: ''); - } + $filter->buildQuery($qb, $options); } #[AsFilterCallback(self::TYPE, 'fields.equationLeft.options')] @@ -66,20 +51,6 @@ public function getEquationLeftOptions(string $targetTable): array return DcaHelper::getFieldOptions($targetTable); } - public function configureOptions(OptionsResolver $resolver): void - { - $resolver->define('left')->required()->allowedTypes('string'); - - $resolver->define('operator') - ->required() - ->allowedTypes('string', SqlEquationOperator::class) - ->allowedValues(static fn (SqlEquationOperator|string $value): bool => (bool) SqlEquationOperator::match($value)) - ->normalize(static fn (Options $resolver, SqlEquationOperator|string $value): ?SqlEquationOperator => SqlEquationOperator::match($value)) - ; - - $resolver->define('right')->default(null)->allowedTypes('string', 'null'); - } - public function getPalette(PaletteConfig $config): ?string { $filterModel = $config->getFilterModel(); diff --git a/src/FilterType/AbstractFilterType.php b/src/FilterType/AbstractFilterType.php new file mode 100644 index 00000000..64e4aba2 --- /dev/null +++ b/src/FilterType/AbstractFilterType.php @@ -0,0 +1,23 @@ + $options + */ + public function buildQuery(FilterQueryBuilder $builder, array $options): void; +} \ No newline at end of file diff --git a/src/FilterType/SimpleEquationFilterType.php b/src/FilterType/SimpleEquationFilterType.php new file mode 100644 index 00000000..366604bc --- /dev/null +++ b/src/FilterType/SimpleEquationFilterType.php @@ -0,0 +1,75 @@ +define('operand_left') + ->info('The left operand of the equation filter') + ->required() + ->allowedTypes('string') + ; + + $resolver->define('operator') + ->info('The operator of the equation filter.') + ->required() + ->allowedTypes(SqlEquationOperator::class, 'string') + ->allowedValues(static fn (SqlEquationOperator|string $value): bool => (bool) SqlEquationOperator::match($value)) + ->normalize(static fn (Options $resolver, SqlEquationOperator|string $value): ?SqlEquationOperator => SqlEquationOperator::match($value)) + ; + + $resolver->define('operand_right') + ->info('The right operand of the equation filter (optional for unary operators).') + ->allowedTypes('string', 'int', 'null') + ->default('') + ; + } + + /** + * @throws FilterException + */ + public function buildQuery(FilterQueryBuilder $builder, array $options): void + { + $operandLeft = $options['operand_left']; + $operator = SqlEquationOperator::match($options['operator']); + + if (!$operandLeft || !$operator instanceof SqlEquationOperator) { + throw new FilterException('Invalid filter configuration.'); + } + + $operandLeft = $builder->column($operandLeft); + + $where = match ($operator) { + SqlEquationOperator::EQUALS => $builder->expr()->eq($operandLeft, ':eq_right'), + SqlEquationOperator::NOT_EQUALS => $builder->expr()->neq($operandLeft, ':eq_right'), + SqlEquationOperator::GREATER_THAN => $builder->expr()->gt($operandLeft, ':eq_right'), + SqlEquationOperator::GREATER_THAN_EQUALS => $builder->expr()->gte($operandLeft, ':eq_right'), + SqlEquationOperator::LESS_THAN => $builder->expr()->lt($operandLeft, ':eq_right'), + SqlEquationOperator::LESS_THAN_EQUALS => $builder->expr()->lte($operandLeft, ':eq_right'), + SqlEquationOperator::LIKE => $builder->expr()->like($operandLeft, ':eq_right'), + SqlEquationOperator::NOT_LIKE => $builder->expr()->notLike($operandLeft, ':eq_right'), + SqlEquationOperator::IS_NULL => $builder->expr()->isNull($operandLeft), + SqlEquationOperator::IS_NOT_NULL => $builder->expr()->isNotNull($operandLeft), + default => null, + }; + + if (!$where) { + throw new FilterException('Invalid filter configuration: Operator not supported.'); + } + + $builder->where($where); + + if (!$operator->isUnary()) { + $operandRight = $options['operand_right']; + $builder->setParameter(':eq_right', $operandRight); + } + } +} \ No newline at end of file From 83ab235f45cfa0113b18be98443366829d9a2dc9 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 28 Apr 2026 17:36:01 +0200 Subject: [PATCH 02/71] Chore: Enable strict types in FilterType classes --- src/FilterType/AbstractFilterType.php | 2 ++ src/FilterType/FilterTypeInterface.php | 2 ++ src/FilterType/SimpleEquationFilterType.php | 2 ++ 3 files changed, 6 insertions(+) diff --git a/src/FilterType/AbstractFilterType.php b/src/FilterType/AbstractFilterType.php index 64e4aba2..96293920 100644 --- a/src/FilterType/AbstractFilterType.php +++ b/src/FilterType/AbstractFilterType.php @@ -1,5 +1,7 @@ Date: Thu, 30 Apr 2026 09:32:41 +0200 Subject: [PATCH 03/71] Feat: Add initial filter builder and interfaces for FlareBundle --- src/Filter/FilterBuilder.php | 8 ++++++++ src/Filter/FilterBuilderInterface.php | 8 ++++++++ src/Filter/FilterFactoryInterface.php | 8 ++++++++ 3 files changed, 24 insertions(+) create mode 100644 src/Filter/FilterBuilder.php create mode 100644 src/Filter/FilterBuilderInterface.php create mode 100644 src/Filter/FilterFactoryInterface.php diff --git a/src/Filter/FilterBuilder.php b/src/Filter/FilterBuilder.php new file mode 100644 index 00000000..2a5f2487 --- /dev/null +++ b/src/Filter/FilterBuilder.php @@ -0,0 +1,8 @@ + Date: Thu, 30 Apr 2026 09:49:43 +0200 Subject: [PATCH 04/71] Feat: Introduce `FilterTypeInterface`, `AbstractFilterType`, and `SimpleEquationFilterType` for extensible filter handling in FlareBundle --- src/Filter/Type/AbstractFilterType.php | 34 +++++++++ src/Filter/Type/FilterTypeInterface.php | 30 ++++++++ src/Filter/Type/SimpleEquationFilterType.php | 77 ++++++++++++++++++++ 3 files changed, 141 insertions(+) create mode 100644 src/Filter/Type/AbstractFilterType.php create mode 100644 src/Filter/Type/FilterTypeInterface.php create mode 100644 src/Filter/Type/SimpleEquationFilterType.php diff --git a/src/Filter/Type/AbstractFilterType.php b/src/Filter/Type/AbstractFilterType.php new file mode 100644 index 00000000..46df6378 --- /dev/null +++ b/src/Filter/Type/AbstractFilterType.php @@ -0,0 +1,34 @@ + $options + */ + public function buildQuery(FilterQueryBuilder $builder, array $options): void; +} \ No newline at end of file diff --git a/src/Filter/Type/SimpleEquationFilterType.php b/src/Filter/Type/SimpleEquationFilterType.php new file mode 100644 index 00000000..b7735c69 --- /dev/null +++ b/src/Filter/Type/SimpleEquationFilterType.php @@ -0,0 +1,77 @@ +define('operand_left') + ->info('The left operand of the equation filter') + ->required() + ->allowedTypes('string') + ; + + $resolver->define('operator') + ->info('The operator of the equation filter.') + ->required() + ->allowedTypes(SqlEquationOperator::class, 'string') + ->allowedValues(static fn (SqlEquationOperator|string $value): bool => (bool) SqlEquationOperator::match($value)) + ->normalize(static fn (Options $resolver, SqlEquationOperator|string $value): ?SqlEquationOperator => SqlEquationOperator::match($value)) + ; + + $resolver->define('operand_right') + ->info('The right operand of the equation filter (optional for unary operators).') + ->allowedTypes('string', 'int', 'null') + ->default('') + ; + } + + /** + * @throws FilterException + */ + public function buildQuery(FilterQueryBuilder $builder, array $options): void + { + $operandLeft = $options['operand_left']; + $operator = SqlEquationOperator::match($options['operator']); + + if (!$operandLeft || !$operator instanceof SqlEquationOperator) { + throw new FilterException('Invalid filter configuration.'); + } + + $operandLeft = $builder->column($operandLeft); + + $where = match ($operator) { + SqlEquationOperator::EQUALS => $builder->expr()->eq($operandLeft, ':eq_right'), + SqlEquationOperator::NOT_EQUALS => $builder->expr()->neq($operandLeft, ':eq_right'), + SqlEquationOperator::GREATER_THAN => $builder->expr()->gt($operandLeft, ':eq_right'), + SqlEquationOperator::GREATER_THAN_EQUALS => $builder->expr()->gte($operandLeft, ':eq_right'), + SqlEquationOperator::LESS_THAN => $builder->expr()->lt($operandLeft, ':eq_right'), + SqlEquationOperator::LESS_THAN_EQUALS => $builder->expr()->lte($operandLeft, ':eq_right'), + SqlEquationOperator::LIKE => $builder->expr()->like($operandLeft, ':eq_right'), + SqlEquationOperator::NOT_LIKE => $builder->expr()->notLike($operandLeft, ':eq_right'), + SqlEquationOperator::IS_NULL => $builder->expr()->isNull($operandLeft), + SqlEquationOperator::IS_NOT_NULL => $builder->expr()->isNotNull($operandLeft), + default => null, + }; + + if (!$where) { + throw new FilterException('Invalid filter configuration: Operator not supported.'); + } + + $builder->where($where); + + if (!$operator->isUnary()) { + $operandRight = $options['operand_right']; + $builder->setParameter(':eq_right', $operandRight); + } + } +} \ No newline at end of file From 37df91397b9040dbc829babb12318883ef8a6496 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Fri, 1 May 2026 19:50:17 +0200 Subject: [PATCH 05/71] refactor: update filter handling and introduce new filter types and events --- config/services.yaml | 4 - ...ion.php => ConfiguredFilterCollection.php} | 56 +++------ .../FilterElement/HydrateFormContract.php | 4 +- .../FilterElement/IntrinsicValueContract.php | 4 +- .../FilterElement/RuntimeValueContract.php | 6 +- src/DataContainer/FilterContainer.php | 12 +- .../Attribute/AsFilterInvoker.php | 31 ----- .../Compiler/RegisterFilterInvokersPass.php | 114 ------------------ src/DependencyInjection/Configuration.php | 3 +- .../HeimrichHannotFlareExtension.php | 4 +- src/Engine/Factory/LoaderFactory.php | 7 +- src/Engine/Loader/ValidationLoader.php | 10 +- src/Engine/Projector/AbstractProjector.php | 12 -- src/Engine/Projector/AggregationProjector.php | 2 +- src/Engine/Projector/InteractiveProjector.php | 28 ++--- ...t.php => ConfiguredFilterCreatedEvent.php} | 6 +- src/Event/FilterElementBuildingEvent.php | 45 +++++++ ...dEvent.php => FilterElementBuiltEvent.php} | 16 +-- .../FilterElementFormTypeOptionsEvent.php | 4 +- src/Event/FilterElementInvokingEvent.php | 56 --------- src/Event/FilterFormChildOptionsEvent.php | 4 +- .../NamedDispatch/FilterElementListener.php | 16 +-- src/Filter/FilterBuilder.php | 53 +++++++- src/Filter/FilterBuilderInterface.php | 17 ++- src/Filter/FilterCall.php | 17 +++ src/Filter/FilterInvocation.php | 6 +- src/Filter/FilterInvokerInterface.php | 22 ---- src/Filter/Resolver/FilterInvokerResolver.php | 57 --------- src/Filter/Resolver/FilterValueResolver.php | 45 ------- src/Filter/ServiceMethodFilterInvoker.php | 29 ----- src/Filter/Type/AbstractFilterType.php | 9 -- src/Filter/Type/ArchiveFilterType.php | 31 +++++ .../Type/BelongsToRelationFilterType.php | 95 +++++++++++++++ src/Filter/Type/BooleanFilterType.php | 24 ++++ src/Filter/Type/CalendarCurrentFilterType.php | 50 ++++++++ src/Filter/Type/DateRangeFilterType.php | 33 +++++ src/Filter/Type/DcaSelectFilterType.php | 76 ++++++++++++ .../Type/FieldValueChoiceFilterType.php | 38 ++++++ src/Filter/Type/FilterTypeInterface.php | 11 +- src/Filter/Type/IntegerIdChoiceFilterType.php | 37 ++++++ src/Filter/Type/PublishedFilterType.php | 48 ++++++++ src/Filter/Type/SearchKeywordsFilterType.php | 65 ++++++++++ .../FilterCollectorInterface.php | 4 +- .../ListModelFilterCollector.php | 16 +-- src/FilterElement/AbstractFilterElement.php | 39 +++--- src/FilterElement/ArchiveElement.php | 45 ++++--- .../BelongsToRelationElement.php | 103 +++++++--------- src/FilterElement/BooleanElement.php | 34 +++--- src/FilterElement/CalendarCurrentElement.php | 73 ++++------- src/FilterElement/DateRangeElement.php | 30 ++--- src/FilterElement/DcaSelectFieldElement.php | 95 ++++----------- src/FilterElement/FieldValueChoiceElement.php | 44 ++++--- src/FilterElement/FilterElementContext.php | 20 +++ src/FilterElement/FilterElementInterface.php | 16 +++ src/FilterElement/PublishedElement.php | 59 +++------ src/FilterElement/SearchKeywordsElement.php | 68 +++-------- src/FilterElement/SimpleEquationElement.php | 28 ++--- src/FilterType/AbstractFilterType.php | 25 ---- src/FilterType/FilterTypeInterface.php | 23 ---- src/FilterType/SimpleEquationFilterType.php | 77 ------------ src/Form/Factory/FilterFormFactory.php | 97 ++++----------- src/Form/FilterFormBuilder.php | 94 +++++++++++++++ src/Form/FilterFormBuilderInterface.php | 15 +++ src/HeimrichHannotFlareBundle.php | 2 - .../CodefogTagsChoiceElement.php | 34 +++--- .../CodefogTagsSearchElement.php | 7 -- .../EventListener/ChangelanguageListener.php | 12 +- src/Query/Executor/FilterExecutor.php | 109 +++++++++++------ src/Registry/FilterInvokerRegistry.php | 35 ------ src/Registry/FilterTypeRegistry.php | 54 +++++++++ ...terDefinition.php => ConfiguredFilter.php} | 55 +++++++-- ...actory.php => ConfiguredFilterFactory.php} | 17 ++- .../Factory/ListSpecificationFactory.php | 8 +- src/Specification/ListSpecification.php | 10 +- tests/Filter/FilterBuilderTest.php | 94 +++++++++++++++ .../AbstractFilterElementTest.php | 89 ++++++++++++++ 76 files changed, 1515 insertions(+), 1223 deletions(-) rename src/Collection/{FilterDefinitionCollection.php => ConfiguredFilterCollection.php} (57%) delete mode 100644 src/DependencyInjection/Attribute/AsFilterInvoker.php delete mode 100644 src/DependencyInjection/Compiler/RegisterFilterInvokersPass.php rename src/Event/{FilterDefinitionCreatedEvent.php => ConfiguredFilterCreatedEvent.php} (50%) create mode 100644 src/Event/FilterElementBuildingEvent.php rename src/Event/{FilterElementInvokedEvent.php => FilterElementBuiltEvent.php} (51%) delete mode 100644 src/Event/FilterElementInvokingEvent.php create mode 100644 src/Filter/FilterCall.php delete mode 100644 src/Filter/FilterInvokerInterface.php delete mode 100644 src/Filter/Resolver/FilterInvokerResolver.php delete mode 100644 src/Filter/Resolver/FilterValueResolver.php delete mode 100644 src/Filter/ServiceMethodFilterInvoker.php create mode 100644 src/Filter/Type/ArchiveFilterType.php create mode 100644 src/Filter/Type/BelongsToRelationFilterType.php create mode 100644 src/Filter/Type/BooleanFilterType.php create mode 100644 src/Filter/Type/CalendarCurrentFilterType.php create mode 100644 src/Filter/Type/DateRangeFilterType.php create mode 100644 src/Filter/Type/DcaSelectFilterType.php create mode 100644 src/Filter/Type/FieldValueChoiceFilterType.php create mode 100644 src/Filter/Type/IntegerIdChoiceFilterType.php create mode 100644 src/Filter/Type/PublishedFilterType.php create mode 100644 src/Filter/Type/SearchKeywordsFilterType.php create mode 100644 src/FilterElement/FilterElementContext.php create mode 100644 src/FilterElement/FilterElementInterface.php delete mode 100644 src/FilterType/AbstractFilterType.php delete mode 100644 src/FilterType/FilterTypeInterface.php delete mode 100644 src/FilterType/SimpleEquationFilterType.php create mode 100644 src/Form/FilterFormBuilder.php create mode 100644 src/Form/FilterFormBuilderInterface.php delete mode 100644 src/Registry/FilterInvokerRegistry.php create mode 100644 src/Registry/FilterTypeRegistry.php rename src/Specification/{FilterDefinition.php => ConfiguredFilter.php} (74%) rename src/Specification/Factory/{FilterDefinitionFactory.php => ConfiguredFilterFactory.php} (61%) create mode 100644 tests/Filter/FilterBuilderTest.php create mode 100644 tests/FilterElement/AbstractFilterElementTest.php diff --git a/config/services.yaml b/config/services.yaml index b9a91c1e..9db2359b 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -21,10 +21,6 @@ services: - ../src/Engine/Loader - ../src/Engine/View - HeimrichHannot\FlareBundle\Filter\Resolver\FilterInvokerResolver: - arguments: - $invokerLocator: null # populated by compiler pass - # Util classes registered as twig globals must be defined as services HeimrichHannot\FlareBundle\Util\Env: ~ HeimrichHannot\FlareBundle\Util\Str: ~ diff --git a/src/Collection/FilterDefinitionCollection.php b/src/Collection/ConfiguredFilterCollection.php similarity index 57% rename from src/Collection/FilterDefinitionCollection.php rename to src/Collection/ConfiguredFilterCollection.php index 6534a35a..3f43fa17 100644 --- a/src/Collection/FilterDefinitionCollection.php +++ b/src/Collection/ConfiguredFilterCollection.php @@ -5,14 +5,14 @@ namespace HeimrichHannot\FlareBundle\Collection; use Contao\StringUtil; -use HeimrichHannot\FlareBundle\Specification\FilterDefinition; +use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; /** - * @method array all() Get the items of the collection. - * @method array values() Get the values of the collection. - * @method \Traversable getIterator() Iterator for the collection items. + * @method array all() Get the items of the collection. + * @method array values() Get the values of the collection. + * @method \Traversable getIterator() Iterator for the collection items. */ -class FilterDefinitionCollection extends AbstractCollection +class ConfiguredFilterCollection extends AbstractCollection { public function __construct( ?array $items = null, @@ -36,7 +36,7 @@ private function initItems(array $items): void } } - public function get(string $key): ?FilterDefinition + public function get(string $key): ?ConfiguredFilter { return $this->items[$key] ?? null; } @@ -50,12 +50,12 @@ public function hasType(string $type): bool { return \array_reduce( $this->items, - static fn (bool $carry, FilterDefinition $filter): bool => $carry || $filter->getType() === $type, + static fn (bool $carry, ConfiguredFilter $filter): bool => $carry || $filter->getElementType() === $type, false ); } - public function add(FilterDefinition ...$item): static + public function add(ConfiguredFilter ...$item): static { foreach ($item as $filter) { do { @@ -68,15 +68,15 @@ public function add(FilterDefinition ...$item): static return $this; } - public function set(string $key, FilterDefinition $filter): void + public function set(string $key, ConfiguredFilter $filter): void { $this->items[$key] = $filter; } /** - * @param FilterDefinition|string $item The item to remove or its key. + * @param ConfiguredFilter|string $item The item to remove or its key. */ - public function remove(FilterDefinition|string $item): bool + public function remove(ConfiguredFilter|string $item): bool { if (\is_string($item)) { if (!\array_key_exists($item, $this->items)) { @@ -90,7 +90,7 @@ public function remove(FilterDefinition|string $item): bool $filtered = \array_filter( $this->items, - static fn (FilterDefinition $filter): bool => $filter !== $item + static fn (ConfiguredFilter $filter): bool => $filter !== $item ); $this->items = $filtered; @@ -98,50 +98,28 @@ public function remove(FilterDefinition|string $item): bool return \count($this->items) < $beforeCount; } - /** - * Serialize the collection. - * - * @return string Serialized representation of the collection. - */ public function serialize(): string { return \serialize($this->items); } - /** - * Unserialize data into the collection. - * - * @param string $data The serialized data. - * @throws \UnexpectedValueException if the data is not an array of the expected type. - */ public function unserialize(string $data): void { $unserialized = StringUtil::deserialize($data); - if (!is_array($unserialized)) { - throw new \UnexpectedValueException("Invalid data: expected an array."); + if (!\is_array($unserialized)) { + throw new \UnexpectedValueException('Invalid data: expected an array.'); } $this->items = []; $this->initItems($unserialized); } - /** - * Magic method for serialization. - * - * @return array Data to serialize. - */ public function __serialize(): array { return $this->items; } - /** - * Magic method for unserialization. - * - * @param array $data Data array to restore into the object. - * @throws \UnexpectedValueException if any item is of an incorrect type. - */ public function __unserialize(array $data): void { $this->items = []; @@ -150,14 +128,14 @@ public function __unserialize(array $data): void public function __clone(): void { - $this->items = \array_map(static fn (FilterDefinition $item): FilterDefinition => clone $item, $this->items); + $this->items = \array_map(static fn (ConfiguredFilter $item): ConfiguredFilter => clone $item, $this->items); } public function hash(): string { return \sha1(\serialize(\array_map( - static fn (FilterDefinition $filter): string => $filter->hash(), + static fn (ConfiguredFilter $filter): string => $filter->hash(), $this->items ))); } -} \ No newline at end of file +} diff --git a/src/Contract/FilterElement/HydrateFormContract.php b/src/Contract/FilterElement/HydrateFormContract.php index fc2de3a6..1958aa72 100644 --- a/src/Contract/FilterElement/HydrateFormContract.php +++ b/src/Contract/FilterElement/HydrateFormContract.php @@ -4,11 +4,11 @@ namespace HeimrichHannot\FlareBundle\Contract\FilterElement; -use HeimrichHannot\FlareBundle\Specification\FilterDefinition; +use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; use HeimrichHannot\FlareBundle\Specification\ListSpecification; use Symfony\Component\Form\FormInterface; interface HydrateFormContract { - public function hydrateForm(FormInterface $field, ListSpecification $list, FilterDefinition $filter): void; + public function hydrateForm(FormInterface $field, ListSpecification $list, ConfiguredFilter $filter): void; } \ No newline at end of file diff --git a/src/Contract/FilterElement/IntrinsicValueContract.php b/src/Contract/FilterElement/IntrinsicValueContract.php index 12a71ea7..6188f53c 100644 --- a/src/Contract/FilterElement/IntrinsicValueContract.php +++ b/src/Contract/FilterElement/IntrinsicValueContract.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\Contract\FilterElement; -use HeimrichHannot\FlareBundle\Specification\FilterDefinition; +use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; use HeimrichHannot\FlareBundle\Specification\ListSpecification; interface IntrinsicValueContract @@ -19,5 +19,5 @@ interface IntrinsicValueContract * @return mixed Any intrinsic value of which the FilterElement's invokers know how to interpret. Will be accessible * through `$invocation->getValue()` from the invoker methods. */ - public function getIntrinsicValue(ListSpecification $list, FilterDefinition $filter): mixed; + public function getIntrinsicValue(ListSpecification $list, ConfiguredFilter $filter): mixed; } \ No newline at end of file diff --git a/src/Contract/FilterElement/RuntimeValueContract.php b/src/Contract/FilterElement/RuntimeValueContract.php index abab4d6b..0c080d43 100644 --- a/src/Contract/FilterElement/RuntimeValueContract.php +++ b/src/Contract/FilterElement/RuntimeValueContract.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\Contract\FilterElement; -use HeimrichHannot\FlareBundle\Specification\FilterDefinition; +use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; use HeimrichHannot\FlareBundle\Specification\ListSpecification; interface RuntimeValueContract @@ -19,5 +19,5 @@ interface RuntimeValueContract * @return mixed The processed value, which will be passed to the filter method upon invocation, where it can * be accessed through `$invocation->getValue()`. */ - public function processRuntimeValue(mixed $value, ListSpecification $list, FilterDefinition $filter): mixed; -} \ No newline at end of file + public function processRuntimeValue(mixed $value, ListSpecification $list, ConfiguredFilter $filter): mixed; +} diff --git a/src/DataContainer/FilterContainer.php b/src/DataContainer/FilterContainer.php index 9c75847a..ea8be118 100644 --- a/src/DataContainer/FilterContainer.php +++ b/src/DataContainer/FilterContainer.php @@ -11,9 +11,9 @@ use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Query\Factory\ListExecutionContextFactory; use HeimrichHannot\FlareBundle\Query\ListExecutionContext; -use HeimrichHannot\FlareBundle\Specification\Factory\FilterDefinitionFactory; +use HeimrichHannot\FlareBundle\Specification\Factory\ConfiguredFilterFactory; use HeimrichHannot\FlareBundle\Specification\Factory\ListSpecificationFactory; -use HeimrichHannot\FlareBundle\Specification\FilterDefinition; +use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; use HeimrichHannot\FlareBundle\Specification\ListSpecification; use HeimrichHannot\FlareBundle\Util\CallbackHelper; @@ -22,7 +22,7 @@ class FilterContainer implements FlareCallbackContainerInterface public const TABLE_NAME = 'tl_flare_filter'; public function __construct( - private readonly FilterDefinitionFactory $filterDefinitionFactory, + private readonly ConfiguredFilterFactory $configuredFilterFactory, private readonly FlareCallbackManager $callbacks, private readonly ListExecutionContextFactory $listExecutionContextFactory, private readonly ListSpecificationFactory $listSpecificationFactory, @@ -64,7 +64,7 @@ public function handleFieldOptions(?DataContainer $dc, string $target): array $callbacks = $this->callbacks->getFilterCallbacks($filterModel->type, $target); - $filterDefinition = $this->filterDefinitionFactory->create($filterModel); + $configuredFilter = $this->configuredFilterFactory->create($filterModel); $listSpecification = $this->listSpecificationFactory->create($listModel); $context = $this->listExecutionContextFactory->create($listSpecification); $tables = $context->tableAliasRegistry->getTables(); @@ -74,7 +74,7 @@ public function handleFieldOptions(?DataContainer $dc, string $target): array FilterModel::class => $filterModel, ListModel::class => $listModel, DataContainer::class => $dc, - FilterDefinition::class => $filterDefinition, + ConfiguredFilter::class => $configuredFilter, ListSpecification::class => $listSpecification, ListExecutionContext::class => $context, 'tables' => $tables, @@ -146,4 +146,4 @@ public function getModelsFromDataContainer(?DataContainer $dc, bool $ignoreType } // -} \ No newline at end of file +} diff --git a/src/DependencyInjection/Attribute/AsFilterInvoker.php b/src/DependencyInjection/Attribute/AsFilterInvoker.php deleted file mode 100644 index 3ec83e6e..00000000 --- a/src/DependencyInjection/Attribute/AsFilterInvoker.php +++ /dev/null @@ -1,31 +0,0 @@ -hasDefinition(FilterInvokerRegistry::class)) { - return; - } - - $registryDefinition = $container->getDefinition(FilterInvokerRegistry::class); - $invokerLocations = []; - - $taggedServices = $container->findTaggedServiceIds(AsFilterInvoker::TAG); - - foreach ($taggedServices as $serviceId => $tags) - { - $definition = $container->getDefinition($serviceId); - - if ($definition->isAbstract()) { - continue; - } - - foreach ($tags as $attributes) - { - $this->processAttribute( - attributes: $attributes, - serviceId: $serviceId, - definition: $definition, - registryDefinition: $registryDefinition - ); - $invokerLocations[$serviceId] = new Reference($serviceId); - } - } - - if ($container->hasDefinition(FilterInvokerResolver::class)) - { - $resolverDefinition = $container->getDefinition(FilterInvokerResolver::class); - $resolverDefinition->setArgument( - '$invokerLocator', - (new Definition(ServiceLocator::class, [$invokerLocations])) - ->addTag('container.service_locator') - ); - } - } - - private function processAttribute( - array $attributes, - string $serviceId, - Definition $definition, - Definition $registryDefinition - ): void { - $method = $attributes['method'] ?? '__invoke'; - $filterType = $attributes['filterType'] ?? null; - $context = $attributes['context'] ?? null; - $priority = $attributes['priority'] ?? 0; - - if (null !== $filterType) - { - if (!$filterType) { - throw new InvalidArgumentException(sprintf('The "filterType" property on the #[AsFilterInvoker] attribute on service "%s" MUST NOT be empty.', $serviceId)); - } - - $registryDefinition->addMethodCall('add', [ - $filterType, - $context, - $serviceId, - $method, - $priority, - ]); - - return; - } - - // If filterType is null, we check if the service is a filter element - $elementTags = $definition->getTag(AsFilterElement::TAG); - $isFilterElement = \count($elementTags) > 0; - - if (!$isFilterElement) { - throw new InvalidArgumentException(sprintf('Service "%s" is not a filter element, thus the "filterType" property on the #[AsFilterInvoker] attribute MUST be specified.', $serviceId)); - } - - foreach ($elementTags as $elementAttributes) - { - if (!$type = (string) ($elementAttributes['type'] ?? null)) { - $type = TypeNameFactory::createFilterElementType($definition->getClass()); - } - - $registryDefinition->addMethodCall('add', [ - $type, - $context, - $serviceId, - $method, - $priority, - ]); - } - } -} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 83557b9b..592ad2d7 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -14,7 +14,6 @@ public function getConfigTreeBuilder(): TreeBuilder $treeBuilder = new TreeBuilder('huh_flare'); $rootNode = $treeBuilder->getRootNode(); - // @phpstan-ignore class.notFound $rootNode ->children() ->arrayNode('format_label_defaults') @@ -60,4 +59,4 @@ public function getConfigTreeBuilder(): TreeBuilder return $treeBuilder; } -} \ No newline at end of file +} diff --git a/src/DependencyInjection/HeimrichHannotFlareExtension.php b/src/DependencyInjection/HeimrichHannotFlareExtension.php index ebb5d8f6..64e7b3e2 100644 --- a/src/DependencyInjection/HeimrichHannotFlareExtension.php +++ b/src/DependencyInjection/HeimrichHannotFlareExtension.php @@ -6,7 +6,6 @@ use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterCallback; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; -use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterInvoker; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFlareCallback; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListCallback; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListType; @@ -58,7 +57,6 @@ public function load(array $configs, ContainerBuilder $container): void $attributesForAutoconfiguration = [ AsListType::class => AsListType::TAG, AsFilterElement::class => AsFilterElement::TAG, - AsFilterInvoker::class => AsFilterInvoker::TAG, // todo(@ericges): remove callbacks in favor of events in v0.2.0 AsFlareCallback::class => FlareCallbackDescriptor::TAG, AsFilterCallback::class => FlareCallbackDescriptor::TAG_FILTER_CALLBACK, @@ -106,4 +104,4 @@ public function prepend(ContainerBuilder $container): void $loader = new YamlFileLoader($container, new FileLocator(\dirname(__DIR__) . '/../config')); $loader->load('config.yaml'); } -} \ No newline at end of file +} diff --git a/src/Engine/Factory/LoaderFactory.php b/src/Engine/Factory/LoaderFactory.php index e07c522f..3521b1a4 100644 --- a/src/Engine/Factory/LoaderFactory.php +++ b/src/Engine/Factory/LoaderFactory.php @@ -10,14 +10,12 @@ use HeimrichHannot\FlareBundle\Engine\Loader\InteractiveLoaderConfig; use HeimrichHannot\FlareBundle\Engine\Loader\ValidationLoader; use HeimrichHannot\FlareBundle\Engine\Loader\ValidationLoaderConfig; -use HeimrichHannot\FlareBundle\Filter\Resolver\FilterValueResolver; use HeimrichHannot\FlareBundle\Query\Executor\ListQueryDirector; final readonly class LoaderFactory { public function __construct( - private FilterValueResolver $filterValueResolver, - private ListQueryDirector $listQueryDirector, + private ListQueryDirector $listQueryDirector, ) {} public function createAggregationLoader(AggregationLoaderConfig $config): AggregationLoader @@ -39,9 +37,8 @@ public function createInteractiveLoader(InteractiveLoaderConfig $config): Intera public function createValidationLoader(ValidationLoaderConfig $config): ValidationLoader { return new ValidationLoader( - filterValueResolver: $this->filterValueResolver, - listQueryDirector: $this->listQueryDirector, config: $config, + listQueryDirector: $this->listQueryDirector, ); } } \ No newline at end of file diff --git a/src/Engine/Loader/ValidationLoader.php b/src/Engine/Loader/ValidationLoader.php index e405b335..014f891b 100644 --- a/src/Engine/Loader/ValidationLoader.php +++ b/src/Engine/Loader/ValidationLoader.php @@ -7,7 +7,6 @@ use HeimrichHannot\FlareBundle\Engine\Context\ValidationContext; use HeimrichHannot\FlareBundle\Enum\SqlEquationOperator; use HeimrichHannot\FlareBundle\Exception\FlareException; -use HeimrichHannot\FlareBundle\Filter\Resolver\FilterValueResolver; use HeimrichHannot\FlareBundle\FilterElement\SimpleEquationElement; use HeimrichHannot\FlareBundle\Query\Executor\ListQueryDirector; use HeimrichHannot\FlareBundle\Query\ListQueryConfig; @@ -16,9 +15,8 @@ readonly class ValidationLoader implements ValidationLoaderInterface { public function __construct( - private FilterValueResolver $filterValueResolver, - private ListQueryDirector $listQueryDirector, private ValidationLoaderConfig $config, + private ListQueryDirector $listQueryDirector, ) {} /** @@ -94,12 +92,12 @@ public function fetchEntryByAutoItem(string $autoItem): ?array /** * @throws \Exception */ - private function executeQuery(ListSpecification $spec, ValidationContext $config): ?array + private function executeQuery(ListSpecification $spec, ValidationContext $context): ?array { $qb = $this->listQueryDirector->createQueryBuilder(new ListQueryConfig( list: $spec, - context: $config, - filterValues: $this->filterValueResolver->resolve($spec, $config->getFilterValues()), + context: $context, + filterValues: $context->getFilterValues(), )); if (!$qb) { diff --git a/src/Engine/Projector/AbstractProjector.php b/src/Engine/Projector/AbstractProjector.php index 5388e1ec..8363ee60 100644 --- a/src/Engine/Projector/AbstractProjector.php +++ b/src/Engine/Projector/AbstractProjector.php @@ -9,7 +9,6 @@ use HeimrichHannot\FlareBundle\Engine\View\ViewInterface; use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Exception\FlareException; -use HeimrichHannot\FlareBundle\Filter\Resolver\FilterValueResolver; use HeimrichHannot\FlareBundle\Query\Executor\ListQueryDirector; use HeimrichHannot\FlareBundle\Query\ListQueryConfig; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; @@ -37,7 +36,6 @@ public static function getSubscribedServices(): array { return [ FilterElementRegistry::class, - FilterValueResolver::class, ListQueryDirector::class, ProjectorRegistry::class, RequestStack::class, @@ -66,21 +64,11 @@ public function priority(ListSpecification $list, ContextInterface $context): in */ abstract public function project(ListSpecification $list, ContextInterface $context): ViewInterface; - public function resolveFilterValues(ListSpecification $spec, array $runtimeValues): array - { - return $this->getFilterValueResolver()->resolve($spec, $runtimeValues); - } - protected function getFilterElementRegistry(): FilterElementRegistry { return $this->container->get(FilterElementRegistry::class); } - protected function getFilterValueResolver(): FilterValueResolver - { - return $this->container->get(FilterValueResolver::class); - } - protected function getListQueryDirector(): ListQueryDirector { return $this->container->get(ListQueryDirector::class); diff --git a/src/Engine/Projector/AggregationProjector.php b/src/Engine/Projector/AggregationProjector.php index dd9c2cc5..d75defd1 100644 --- a/src/Engine/Projector/AggregationProjector.php +++ b/src/Engine/Projector/AggregationProjector.php @@ -33,7 +33,7 @@ public function project(ListSpecification $list, ContextInterface $context): Agg $loader = $this->createLoader(new AggregationLoaderConfig( list: $list, context: $context, - filterValues: $this->resolveFilterValues($list, $context->getFilterValues()), + filterValues: $context->getFilterValues(), )); return $this->createView($loader); diff --git a/src/Engine/Projector/InteractiveProjector.php b/src/Engine/Projector/InteractiveProjector.php index 662ff34f..7f5c6963 100644 --- a/src/Engine/Projector/InteractiveProjector.php +++ b/src/Engine/Projector/InteractiveProjector.php @@ -50,8 +50,7 @@ public function project(ListSpecification $list, ContextInterface $context): Int // collect filter values from form data $form = $this->createForm($list, $context); - $runtimeValues = $this->mapFormDataToFilterKeys($list, $form); - $filterValues = $this->resolveFilterValues($list, $runtimeValues); + $filterValues = $this->mapFormDataToFilterKeys($list, $form); // pagination setup $totalItems = $this->createAggregationView($list, $context, $filterValues)->getCount(); @@ -135,9 +134,9 @@ private function hydrateForm(FormInterface $form, ListSpecification $list): void $filterElementRegistry = $this->getFilterElementRegistry(); $data = []; - foreach ($list->getFilters()->getIterator() as $filterDefinition) + foreach ($list->getFilters()->getIterator() as $configuredFilter) { - if (!$filterElement = $filterElementRegistry->get($filterDefinition->getType())?->getService()) { + if (!$filterElement = $filterElementRegistry->get($configuredFilter->getElementType())?->getService()) { continue; } @@ -145,15 +144,9 @@ private function hydrateForm(FormInterface $form, ListSpecification $list): void continue; } - if ($filterDefinition->isIntrinsic()) { - continue; - } - - if (!$filterName = $filterDefinition->getAlias()) { - throw new FlareException(message: 'Non-intrinsic filter must provide a form field name.'); - } + $filterName = $configuredFilter->getAlias(); - if (!$form->has($filterName)) { + if (!$filterName || !$form->has($filterName)) { continue; } @@ -163,7 +156,7 @@ private function hydrateForm(FormInterface $form, ListSpecification $list): void } catch (OutOfBoundsException $exception) { - $filterSourceId = $filterDefinition->getDataSource()->getFilterIdentifier(); + $filterSourceId = $configuredFilter->getDataSource()?->getFilterIdentifier(); throw new FlareException( message: 'Filter form does not contain field: ' . $filterName, @@ -173,12 +166,11 @@ private function hydrateForm(FormInterface $form, ListSpecification $list): void ); } - $filterElement->hydrateForm($field, $list, $filterDefinition); + $filterElement->hydrateForm($field, $list, $configuredFilter); $data[$filterName] = $field->getData(); } - // This might not be necessary, but $form->getData() should return all child data as well. $form->setData(\array_merge($form->getData() ?? [], $data)); } @@ -188,9 +180,9 @@ protected function mapFormDataToFilterKeys(ListSpecification $list, FormInterfac $filterElementRegistry = $this->getFilterElementRegistry(); - foreach ($list->getFilters()->all() as $key => $definition) + foreach ($list->getFilters()->all() as $key => $configuredFilter) { - $alias = $definition->getAlias(); + $alias = $configuredFilter->getAlias(); if (\is_null($alias)) { continue; @@ -201,7 +193,7 @@ protected function mapFormDataToFilterKeys(ListSpecification $list, FormInterfac } $field = $form->get($alias); - $filterElement = $filterElementRegistry->get($definition->getType())?->getService(); + $filterElement = $filterElementRegistry->get($configuredFilter->getElementType())?->getService(); $values[$key] = $filterElement instanceof FormDataContract ? $filterElement->extractFormData($field) diff --git a/src/Event/FilterDefinitionCreatedEvent.php b/src/Event/ConfiguredFilterCreatedEvent.php similarity index 50% rename from src/Event/FilterDefinitionCreatedEvent.php rename to src/Event/ConfiguredFilterCreatedEvent.php index bd73cfce..0705ce59 100644 --- a/src/Event/FilterDefinitionCreatedEvent.php +++ b/src/Event/ConfiguredFilterCreatedEvent.php @@ -4,12 +4,12 @@ namespace HeimrichHannot\FlareBundle\Event; -use HeimrichHannot\FlareBundle\Specification\FilterDefinition; +use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; use Symfony\Contracts\EventDispatcher\Event; -class FilterDefinitionCreatedEvent extends Event +class ConfiguredFilterCreatedEvent extends Event { public function __construct( - public FilterDefinition $filterDefinition, + public ConfiguredFilter $configuredFilter, ) {} } \ No newline at end of file diff --git a/src/Event/FilterElementBuildingEvent.php b/src/Event/FilterElementBuildingEvent.php new file mode 100644 index 00000000..abd79b35 --- /dev/null +++ b/src/Event/FilterElementBuildingEvent.php @@ -0,0 +1,45 @@ +invocation; + } + + public function getContext(): ContextInterface + { + return $this->context; + } + + public function getBuilder(): FilterBuilderInterface + { + return $this->builder; + } + + public function shouldBuild(): bool + { + return $this->shouldBuild; + } + + public function setShouldBuild(bool $shouldBuild): void + { + $this->shouldBuild = $shouldBuild; + } +} \ No newline at end of file diff --git a/src/Event/FilterElementInvokedEvent.php b/src/Event/FilterElementBuiltEvent.php similarity index 51% rename from src/Event/FilterElementInvokedEvent.php rename to src/Event/FilterElementBuiltEvent.php index 4a33b7db..0bcf9178 100644 --- a/src/Event/FilterElementInvokedEvent.php +++ b/src/Event/FilterElementBuiltEvent.php @@ -4,24 +4,24 @@ namespace HeimrichHannot\FlareBundle\Event; +use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterInvocation; -use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; use Symfony\Contracts\EventDispatcher\Event; -class FilterElementInvokedEvent extends Event +class FilterElementBuiltEvent extends Event { public function __construct( - private readonly FilterInvocation $invocation, - private readonly FilterQueryBuilder $queryBuilder, + private readonly FilterInvocation $invocation, + private readonly FilterBuilderInterface $builder, ) {} - public function getQueryBuilder(): FilterQueryBuilder + public function getInvocation(): FilterInvocation { - return $this->queryBuilder; + return $this->invocation; } - public function getInvocation(): FilterInvocation + public function getBuilder(): FilterBuilderInterface { - return $this->invocation; + return $this->builder; } } \ No newline at end of file diff --git a/src/Event/FilterElementFormTypeOptionsEvent.php b/src/Event/FilterElementFormTypeOptionsEvent.php index 8ca501a1..c18ba362 100644 --- a/src/Event/FilterElementFormTypeOptionsEvent.php +++ b/src/Event/FilterElementFormTypeOptionsEvent.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\Event; use HeimrichHannot\FlareBundle\Form\ChoicesBuilder; -use HeimrichHannot\FlareBundle\Specification\FilterDefinition; +use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; use HeimrichHannot\FlareBundle\Specification\ListSpecification; use Symfony\Contracts\EventDispatcher\Event; @@ -14,7 +14,7 @@ class FilterElementFormTypeOptionsEvent extends Event public function __construct( public readonly ChoicesBuilder $choicesBuilder, public readonly ListSpecification $list, - public readonly FilterDefinition $filter, + public readonly ConfiguredFilter $filter, public array $options, ) {} } \ No newline at end of file diff --git a/src/Event/FilterElementInvokingEvent.php b/src/Event/FilterElementInvokingEvent.php deleted file mode 100644 index 5430c310..00000000 --- a/src/Event/FilterElementInvokingEvent.php +++ /dev/null @@ -1,56 +0,0 @@ -invocation; - } - - public function getContext(): ContextInterface - { - return $this->context; - } - - public function getInvoker(): FilterInvokerInterface - { - return $this->invoker; - } - - public function setInvoker(FilterInvokerInterface $invoker): void - { - $this->invoker = $invoker; - } - - public function shouldInvoke(): bool - { - return $this->shouldInvoke; - } - - public function setShouldInvoke(bool $shouldInvoke): void - { - $this->shouldInvoke = $shouldInvoke; - } -} \ No newline at end of file diff --git a/src/Event/FilterFormChildOptionsEvent.php b/src/Event/FilterFormChildOptionsEvent.php index dceb6d8f..7b753510 100644 --- a/src/Event/FilterFormChildOptionsEvent.php +++ b/src/Event/FilterFormChildOptionsEvent.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\Event; -use HeimrichHannot\FlareBundle\Specification\FilterDefinition; +use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; use HeimrichHannot\FlareBundle\Specification\ListSpecification; use Symfony\Contracts\EventDispatcher\Event; @@ -12,7 +12,7 @@ class FilterFormChildOptionsEvent extends Event { public function __construct( public readonly ListSpecification $listSpecification, - public readonly FilterDefinition $filterDefinition, + public readonly ConfiguredFilter $configuredFilter, public readonly ?string $parentFormName, public readonly string $formName, public array $options, diff --git a/src/EventListener/NamedDispatch/FilterElementListener.php b/src/EventListener/NamedDispatch/FilterElementListener.php index 73bbde4e..c4724a98 100644 --- a/src/EventListener/NamedDispatch/FilterElementListener.php +++ b/src/EventListener/NamedDispatch/FilterElementListener.php @@ -4,8 +4,8 @@ namespace HeimrichHannot\FlareBundle\EventListener\NamedDispatch; -use HeimrichHannot\FlareBundle\Event\FilterElementInvokedEvent; -use HeimrichHannot\FlareBundle\Event\FilterElementInvokingEvent; +use HeimrichHannot\FlareBundle\Event\FilterElementBuiltEvent; +use HeimrichHannot\FlareBundle\Event\FilterElementBuildingEvent; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -16,19 +16,19 @@ public function __construct( ) {} #[AsEventListener(priority: -200)] - public function onFilterElementInvokedEvent(FilterElementInvokedEvent $event): void + public function onFilterElementBuiltEvent(FilterElementBuiltEvent $event): void { - $type = $event->getInvocation()->getFilterDefinition()->getType(); - $eventName = "flare.filter_element.{$type}.invoked"; + $type = $event->getInvocation()->getConfiguredFilter()->getElementType(); + $eventName = "flare.filter_element.{$type}.built"; $this->eventDispatcher->dispatch(event: $event, eventName: $eventName); } #[AsEventListener(priority: -200)] - public function onFilterElementInvokingEvent(FilterElementInvokingEvent $event): void + public function onFilterElementBuildingEvent(FilterElementBuildingEvent $event): void { - $type = $event->getInvocation()->getFilterDefinition()->getType(); - $eventName = "flare.filter_element.{$type}.invoking"; + $type = $event->getInvocation()->getConfiguredFilter()->getElementType(); + $eventName = "flare.filter_element.{$type}.building"; $this->eventDispatcher->dispatch(event: $event, eventName: $eventName); } diff --git a/src/Filter/FilterBuilder.php b/src/Filter/FilterBuilder.php index 2a5f2487..b1088e5d 100644 --- a/src/Filter/FilterBuilder.php +++ b/src/Filter/FilterBuilder.php @@ -1,8 +1,59 @@ $type + * @param array $options + * + * @throws FilterException + */ + public function add(string $type, array $options = [], ?string $targetAlias = null): static + { + if (!$filterType = $this->filterTypeRegistry->get($type)) { + throw new FilterException(\sprintf('No FLARE filter type service registered for "%s".', $type)); + } + + $resolver = new OptionsResolver(); + $filterType->configureOptions($resolver); + + $this->calls[] = new FilterCall( + type: $filterType, + typeClass: $type, + targetAlias: $targetAlias ?: $this->defaultTargetAlias, + options: $resolver->resolve($options), + ); + + return $this; + } + + public function all(): array + { + return $this->calls; + } + public function abort(): never + { + throw new AbortFilteringException(); + } } \ No newline at end of file diff --git a/src/Filter/FilterBuilderInterface.php b/src/Filter/FilterBuilderInterface.php index 547b2191..a3cb384f 100644 --- a/src/Filter/FilterBuilderInterface.php +++ b/src/Filter/FilterBuilderInterface.php @@ -1,8 +1,23 @@ $type + * @param array $options + */ + public function add(string $type, array $options = [], ?string $targetAlias = null): static; + + /** + * @return FilterCall[] + */ + public function all(): array; + + public function abort(): never; } \ No newline at end of file diff --git a/src/Filter/FilterCall.php b/src/Filter/FilterCall.php new file mode 100644 index 00000000..3f724cc6 --- /dev/null +++ b/src/Filter/FilterCall.php @@ -0,0 +1,17 @@ +filter; } diff --git a/src/Filter/FilterInvokerInterface.php b/src/Filter/FilterInvokerInterface.php deleted file mode 100644 index 6d503f53..00000000 --- a/src/Filter/FilterInvokerInterface.php +++ /dev/null @@ -1,22 +0,0 @@ -registry->find($filterType, $contextType)) - { - $service = $this->invokerLocator->get($invokerConfig['serviceId']); - return $this->resolveCallback($service, $invokerConfig['method']); - } - - // Fallback to the element itself - if ($elementDescriptor = $this->elementRegistry->get($filterType)) - { - $method = $elementDescriptor->getMethod() ?? '__invoke'; - $service = $elementDescriptor->getService(); - - if (\method_exists($service, $method)) { - return new ServiceMethodFilterInvoker($service, $method); - } - } - - // No invoker found - return null; - } - - private function resolveCallback(object $service, string $method): ?FilterInvokerInterface - { - if (!\method_exists($service, $method)) { - return null; - } - - if ($method === '__invoke' && $service instanceof FilterInvokerInterface) { - return $service; - } - - return new ServiceMethodFilterInvoker($service, $method); - } -} \ No newline at end of file diff --git a/src/Filter/Resolver/FilterValueResolver.php b/src/Filter/Resolver/FilterValueResolver.php deleted file mode 100644 index 6ae517eb..00000000 --- a/src/Filter/Resolver/FilterValueResolver.php +++ /dev/null @@ -1,45 +0,0 @@ -getFilters()->all() as $key => $filter) - { - $element = $this->filterElementRegistry->get($filter->getType())?->getService(); - - if (\array_key_exists($key, $runtimeValues)) - { - $value = $runtimeValues[$key]; - - if ($element instanceof RuntimeValueContract) { - $value = $element->processRuntimeValue($value, $spec, $filter); - } - - $values[$key] = $value; - continue; - } - - if ($element instanceof IntrinsicValueContract && $filter->isIntrinsic()) { - $values[$key] = $element->getIntrinsicValue($spec, $filter); - } - } - - return $values; - } -} diff --git a/src/Filter/ServiceMethodFilterInvoker.php b/src/Filter/ServiceMethodFilterInvoker.php deleted file mode 100644 index a7f68868..00000000 --- a/src/Filter/ServiceMethodFilterInvoker.php +++ /dev/null @@ -1,29 +0,0 @@ -service, $this->method)) - { - throw new \InvalidArgumentException(\sprintf( - 'Method "%s::%s" does not exist.', - $this->service::class, - $this->method, - )); - } - } - - public function __invoke(FilterInvocation $inv, FilterQueryBuilder $qb): void - { - $this->service->{$this->method}($inv, $qb); - } -} \ No newline at end of file diff --git a/src/Filter/Type/AbstractFilterType.php b/src/Filter/Type/AbstractFilterType.php index 46df6378..90f0edc8 100644 --- a/src/Filter/Type/AbstractFilterType.php +++ b/src/Filter/Type/AbstractFilterType.php @@ -4,8 +4,6 @@ namespace HeimrichHannot\FlareBundle\Filter\Type; -use HeimrichHannot\FlareBundle\Filter\FilterBuilder; -use HeimrichHannot\FlareBundle\Filter\FilterInvocation; use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -18,13 +16,6 @@ public function configureOptions(OptionsResolver $resolver): void { } - /** - * {@inheritDoc} - */ - public function buildFilter(FilterBuilder $builder, FilterInvocation $inv): void - { - } - /** * {@inheritDoc} */ diff --git a/src/Filter/Type/ArchiveFilterType.php b/src/Filter/Type/ArchiveFilterType.php new file mode 100644 index 00000000..88cfcc86 --- /dev/null +++ b/src/Filter/Type/ArchiveFilterType.php @@ -0,0 +1,31 @@ +define('field')->default('pid')->allowedTypes('string'); + $resolver->define('parent_ids')->required()->allowedTypes('array'); + } + + public function buildQuery(FilterQueryBuilder $builder, array $options): void + { + $ids = \array_values(\array_unique(\array_filter(\array_map('\intval', $options['parent_ids'])))); + + if (!$ids) { + throw new FilterException('No valid parent archive ids extracted.'); + } + + $builder->where($builder->expr()->in($builder->column($options['field']), ':pids')) + ->setParameter('pids', $ids, ArrayParameterType::INTEGER); + } +} \ No newline at end of file diff --git a/src/Filter/Type/BelongsToRelationFilterType.php b/src/Filter/Type/BelongsToRelationFilterType.php new file mode 100644 index 00000000..a433aed9 --- /dev/null +++ b/src/Filter/Type/BelongsToRelationFilterType.php @@ -0,0 +1,95 @@ +define('field_pid')->required()->allowedTypes('string'); + $resolver->define('field_dynamic_ptable')->default(null)->allowedTypes('null', 'string'); + $resolver->define('whitelist')->default([])->allowedTypes('array'); + $resolver->define('parent_groups')->default([])->allowedTypes('array'); + $resolver->define('submitted_data')->default(null)->allowedTypes('null', 'array'); + } + + public function buildQuery(FilterQueryBuilder $builder, array $options): void + { + if ($options['field_dynamic_ptable']) { + $this->buildDynamicQuery($builder, $options); + return; + } + + if (!$options['whitelist']) { + $builder::abort(); + } + + $builder->where($builder->expr()->in($builder->column($options['field_pid']), ':whitelist')) + ->setParameter('whitelist', $options['whitelist']); + } + + private function buildDynamicQuery(FilterQueryBuilder $builder, array $options): void + { + $ors = []; + $submittedData = $options['submitted_data']; + $fieldDynamicPtable = $options['field_dynamic_ptable']; + $fieldPid = $options['field_pid']; + + $colDynamicPtable = $builder->column($fieldDynamicPtable); + $colPid = $builder->column($fieldPid); + + foreach (\array_values($options['parent_groups']) as $i => $group) + { + $table = $group['table'] ?? null; + $parentIds = $group['ids'] ?? null; + + if (!$table || !\is_array($parentIds)) { + continue; + } + + if (\is_array($submittedData)) + { + $submittedWhitelist = $submittedData[$table] ?? null; + + if (!\is_array($submittedWhitelist)) { + continue; + } + + $parentIds = \array_intersect($parentIds, $submittedWhitelist); + } + + $parentIds = \array_values(\array_filter($parentIds)); + + if (!$parentIds) { + continue; + } + + $tableParam = \sprintf(':g%s_ptable', $i); + $idsParam = \sprintf(':g%s_whitelist', $i); + + $ors[] = $builder->expr()->and( + $builder->expr()->eq($colDynamicPtable, $tableParam), + $builder->expr()->in($colPid, $idsParam) + ); + + $builder->setParameter($tableParam, $table); + $builder->setParameter($idsParam, $parentIds); + } + + if (!$ors) { + $builder::abort(); + } + + if (\count($ors) === 1) { + $builder->where($ors[0]); + return; + } + + $builder->whereOr(...$ors); + } +} \ No newline at end of file diff --git a/src/Filter/Type/BooleanFilterType.php b/src/Filter/Type/BooleanFilterType.php new file mode 100644 index 00000000..d8c52206 --- /dev/null +++ b/src/Filter/Type/BooleanFilterType.php @@ -0,0 +1,24 @@ +define('field')->required()->allowedTypes('string'); + $resolver->define('value')->required()->allowedTypes('bool'); + } + + public function buildQuery(FilterQueryBuilder $builder, array $options): void + { + $builder->where($builder->expr()->eq($builder->column($options['field']), ':val')) + ->setParameter('val', $options['value'] ? '1' : '', ParameterType::STRING); + } +} \ No newline at end of file diff --git a/src/Filter/Type/CalendarCurrentFilterType.php b/src/Filter/Type/CalendarCurrentFilterType.php new file mode 100644 index 00000000..bf24abbc --- /dev/null +++ b/src/Filter/Type/CalendarCurrentFilterType.php @@ -0,0 +1,50 @@ +define('start')->required()->allowedTypes('int'); + $resolver->define('stop')->required()->allowedTypes('int'); + $resolver->define('has_extended_events')->default(false)->allowedTypes('bool'); + } + + public function buildQuery(FilterQueryBuilder $builder, array $options): void + { + $colStartTime = $builder->column('startTime'); + $colRepeatEnd = $builder->column('repeatEnd'); + $colRecurrences = $builder->column('recurrences'); + $colRecurring = $builder->column('recurring'); + + $or = [ + "{$colStartTime} >= :start AND {$colStartTime} <= :end", + $builder->expr()->and( + $builder->expr()->eq($colRecurring, '1'), + $builder->expr()->lte($colStartTime, ':end'), + $builder->expr()->or( + $builder->expr()->eq($colRecurrences, '0'), + $builder->expr()->gte($colRepeatEnd, ':start'), + ), + ), + ]; + + if ($options['has_extended_events']) + { + $colEndTime = $builder->column('endTime'); + + $or[] = "{$colEndTime} >= :start AND {$colEndTime} <= :end"; + $or[] = "{$colStartTime} <= :start AND {$colEndTime} >= :end"; + } + + $builder->whereOr(...$or); + $builder->setParameter('start', $options['start']); + $builder->setParameter('end', $options['stop']); + } +} \ No newline at end of file diff --git a/src/Filter/Type/DateRangeFilterType.php b/src/Filter/Type/DateRangeFilterType.php new file mode 100644 index 00000000..ad5a8414 --- /dev/null +++ b/src/Filter/Type/DateRangeFilterType.php @@ -0,0 +1,33 @@ +define('field')->required()->allowedTypes('string'); + $resolver->define('from')->default(null)->allowedTypes('null', \DateTimeInterface::class); + $resolver->define('to')->default(null)->allowedTypes('null', \DateTimeInterface::class); + } + + public function buildQuery(FilterQueryBuilder $builder, array $options): void + { + $field = $builder->column($options['field']); + + if ($options['from'] instanceof \DateTimeInterface) { + $builder->where($builder->expr()->gte($field, ':from')) + ->setParameter('from', $options['from']->getTimestamp()); + } + + if ($options['to'] instanceof \DateTimeInterface) { + $builder->where($builder->expr()->lte($field, ':to')) + ->setParameter('to', $options['to']->getTimestamp()); + } + } +} \ No newline at end of file diff --git a/src/Filter/Type/DcaSelectFilterType.php b/src/Filter/Type/DcaSelectFilterType.php new file mode 100644 index 00000000..12ace775 --- /dev/null +++ b/src/Filter/Type/DcaSelectFilterType.php @@ -0,0 +1,76 @@ +define('field')->required()->allowedTypes('string'); + $resolver->define('selected')->required()->allowedTypes('array'); + $resolver->define('valid_options')->required()->allowedTypes('array'); + $resolver->define('is_multiple_dca_field')->default(false)->allowedTypes('bool'); + } + + public function buildQuery(FilterQueryBuilder $builder, array $options): void + { + $selected = \array_values($options['selected']); + $validOptions = $options['valid_options']; + $field = $options['field']; + + if (!$selected) { + return; + } + + if (!$validOptions || !$field) { + $builder::abort(); + } + + if (\count($selected) === 1) + { + $value = \current($selected); + if (!\array_key_exists($value, $validOptions)) { + $builder::abort(); + } + + if ($options['is_multiple_dca_field']) { + $builder->whereInSerialized($value, $field); + return; + } + + $builder->where($builder->expr()->eq($builder->column($field), ':value')) + ->setParameter('value', $value); + return; + } + + if (\count(\array_unique($validOptions)) !== \count($validOptions)) { + throw new FilterException('The options for the DCA select field must be unique.'); + } + + $filtered = []; + foreach ($selected as $value) + { + if ($validOptions[$value] ?? null) { + $filtered[] = $value; + } + } + + if (!$filtered) { + $builder::abort(); + } + + if ($options['is_multiple_dca_field']) { + $builder->whereInSerialized($filtered, $field); + return; + } + + $builder->where($builder->expr()->in($builder->column($field), ':values')) + ->setParameter('values', $filtered); + } +} \ No newline at end of file diff --git a/src/Filter/Type/FieldValueChoiceFilterType.php b/src/Filter/Type/FieldValueChoiceFilterType.php new file mode 100644 index 00000000..1f02fd23 --- /dev/null +++ b/src/Filter/Type/FieldValueChoiceFilterType.php @@ -0,0 +1,38 @@ +define('field')->required()->allowedTypes('string'); + $resolver->define('values')->required()->allowedTypes('array'); + } + + public function buildQuery(FilterQueryBuilder $builder, array $options): void + { + $values = $options['values']; + + if (!$values) { + return; + } + + $field = $builder->column($options['field']); + + if (\count($values) < 2) + { + $builder->where("LOWER(TRIM({$field})) = :value") + ->setParameter('value', \reset($values)); + return; + } + + $builder->where("LOWER(TRIM({$field})) IN (:values)") + ->setParameter('values', $values); + } +} \ No newline at end of file diff --git a/src/Filter/Type/FilterTypeInterface.php b/src/Filter/Type/FilterTypeInterface.php index 4d3ddd7a..06192534 100644 --- a/src/Filter/Type/FilterTypeInterface.php +++ b/src/Filter/Type/FilterTypeInterface.php @@ -4,23 +4,20 @@ namespace HeimrichHannot\FlareBundle\Filter\Type; -use HeimrichHannot\FlareBundle\Filter\FilterBuilder; -use HeimrichHannot\FlareBundle\Filter\FilterInvocation; use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; +use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; use Symfony\Component\OptionsResolver\OptionsResolver; +#[AutoconfigureTag(self::TAG)] interface FilterTypeInterface { + public const TAG = 'huh.flare.filter_type'; + /** * Configures the options for this type. */ public function configureOptions(OptionsResolver $resolver): void; - /** - * Builds the filter. - */ - public function buildFilter(FilterBuilder $builder, FilterInvocation $inv): void; - /** * Builds the filter query. * diff --git a/src/Filter/Type/IntegerIdChoiceFilterType.php b/src/Filter/Type/IntegerIdChoiceFilterType.php new file mode 100644 index 00000000..49afee3c --- /dev/null +++ b/src/Filter/Type/IntegerIdChoiceFilterType.php @@ -0,0 +1,37 @@ +define('field')->default('id')->allowedTypes('string'); + $resolver->define('ids')->required()->allowedTypes('array'); + } + + public function buildQuery(FilterQueryBuilder $builder, array $options): void + { + $ids = \array_values(\array_unique(\array_filter(\array_map('\intval', $options['ids'])))); + + if (!$ids) { + return; + } + + if (\count($ids) === 1) { + $builder->where($builder->expr()->eq($builder->column($options['field']), ':id')) + ->setParameter('id', \reset($ids), ParameterType::INTEGER); + return; + } + + $builder->where($builder->expr()->in($builder->column($options['field']), ':ids')) + ->setParameter('ids', $ids, ArrayParameterType::INTEGER); + } +} \ No newline at end of file diff --git a/src/Filter/Type/PublishedFilterType.php b/src/Filter/Type/PublishedFilterType.php new file mode 100644 index 00000000..5d684dd9 --- /dev/null +++ b/src/Filter/Type/PublishedFilterType.php @@ -0,0 +1,48 @@ +define('published_field')->default(null)->allowedTypes('null', 'string'); + $resolver->define('start_field')->default(null)->allowedTypes('null', 'string'); + $resolver->define('stop_field')->default(null)->allowedTypes('null', 'string'); + $resolver->define('invert_published')->default(false)->allowedTypes('bool'); + $resolver->define('now')->required()->allowedTypes('int'); + } + + public function buildQuery(FilterQueryBuilder $builder, array $options): void + { + if ($options['published_field']) + { + $publishedField = $builder->column($options['published_field']); + $operator = $options['invert_published'] ? 'neq' : 'eq'; + + $builder->where($builder->expr()->{$operator}($publishedField, ':published')) + ->setParameter('published', '1'); + } + + if ($options['start_field']) + { + $startField = $builder->column($options['start_field']); + + $builder->where("{$startField} = '' OR {$startField} = '0' OR {$startField} <= :start") + ->setParameter('start', $options['now']); + } + + if ($options['stop_field']) + { + $stopField = $builder->column($options['stop_field']); + + $builder->where("{$stopField} = '' OR {$stopField} = '0' OR {$stopField} >= :stop") + ->setParameter('stop', $options['now']); + } + } +} \ No newline at end of file diff --git a/src/Filter/Type/SearchKeywordsFilterType.php b/src/Filter/Type/SearchKeywordsFilterType.php new file mode 100644 index 00000000..66332fe4 --- /dev/null +++ b/src/Filter/Type/SearchKeywordsFilterType.php @@ -0,0 +1,65 @@ +define('value')->required()->allowedTypes('string'); + $resolver->define('columns')->required()->allowedTypes('array'); + } + + public function buildQuery(FilterQueryBuilder $builder, array $options): void + { + $columns = \array_map($builder->column(...), $options['columns']); + $searchTermGroups = \array_values(\preg_split('/\s+OR\s+/i', $options['value'])); + $or = []; + + foreach ($searchTermGroups ?: [] as $i => $searchTermGroup) + { + if (!$searchTerms = $this->makeTerms($searchTermGroup)) { + return; + } + + $and = []; + + foreach (\array_values($searchTerms) as $j => $term) + { + $param = ':term_' . $i . '_' . $j; + + $and[] = $builder->expr()->or(...\array_map( + static fn (string $column): string => $builder->expr()->like($column, $param), + $columns + )); + + $builder->setParameter($param, '%' . $term . '%'); + } + + $or[] = $builder->expr()->and(...$and); + } + + $builder->where($builder->expr()->or(...$or)); + } + + private function makeTerms(string $text): array + { + $text = (string) \mb_strtolower($text); + $text = \preg_replace('/[^\p{L}\p{Nd}-]+/u', ' ', $text); + $text = \preg_replace('/\s+/', ' ', $text); + $terms = \array_unique(\array_filter(\array_map('\trim', \explode(' ', \trim($text))))); + $stopWords = $this->configProvider->getStopWords(); + + return $stopWords ? \array_diff($terms, $stopWords) : $terms; + } +} \ No newline at end of file diff --git a/src/FilterCollector/FilterCollectorInterface.php b/src/FilterCollector/FilterCollectorInterface.php index c9ee2ed6..2408359e 100644 --- a/src/FilterCollector/FilterCollectorInterface.php +++ b/src/FilterCollector/FilterCollectorInterface.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\FilterCollector; -use HeimrichHannot\FlareBundle\Collection\FilterDefinitionCollection; +use HeimrichHannot\FlareBundle\Collection\ConfiguredFilterCollection; use HeimrichHannot\FlareBundle\Specification\DataSource\ListDataSourceInterface; use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; @@ -13,5 +13,5 @@ interface FilterCollectorInterface { public function supports(ListDataSourceInterface $dataSource): bool; - public function collect(ListDataSourceInterface $dataSource): ?FilterDefinitionCollection; + public function collect(ListDataSourceInterface $dataSource): ?ConfiguredFilterCollection; } \ No newline at end of file diff --git a/src/FilterCollector/ListModelFilterCollector.php b/src/FilterCollector/ListModelFilterCollector.php index d24b472b..ea3d62e6 100644 --- a/src/FilterCollector/ListModelFilterCollector.php +++ b/src/FilterCollector/ListModelFilterCollector.php @@ -5,17 +5,17 @@ namespace HeimrichHannot\FlareBundle\FilterCollector; use Contao\Controller; -use HeimrichHannot\FlareBundle\Collection\FilterDefinitionCollection; +use HeimrichHannot\FlareBundle\Collection\ConfiguredFilterCollection; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Registry\ListTypeRegistry; use HeimrichHannot\FlareBundle\Specification\DataSource\ListDataSourceInterface; -use HeimrichHannot\FlareBundle\Specification\Factory\FilterDefinitionFactory; +use HeimrichHannot\FlareBundle\Specification\Factory\ConfiguredFilterFactory; readonly class ListModelFilterCollector implements FilterCollectorInterface { public function __construct( - private FilterDefinitionFactory $filterDefinitionFactory, + private ConfiguredFilterFactory $configuredFilterFactory, private ListTypeRegistry $listTypeRegistry, ) {} @@ -24,7 +24,7 @@ public function supports(ListDataSourceInterface $dataSource): bool return $dataSource instanceof ListModel; } - public function collect(ListDataSourceInterface $dataSource): ?FilterDefinitionCollection + public function collect(ListDataSourceInterface $dataSource): ?ConfiguredFilterCollection { if (!$dataSource instanceof ListModel) { throw new \InvalidArgumentException('The given data source is not a list model.'); @@ -42,7 +42,7 @@ public function collect(ListDataSourceInterface $dataSource): ?FilterDefinitionC /** @var \Traversable $filterModels */ $filterModels = FilterModel::findByPid($dataSource->id, published: true); - $collection = new FilterDefinitionCollection(); + $collection = new ConfiguredFilterCollection(); foreach ($filterModels as $filterModel) // Collect filters defined in the backend @@ -51,12 +51,12 @@ public function collect(ListDataSourceInterface $dataSource): ?FilterDefinitionC continue; } - $filterDefinition = $this->filterDefinitionFactory->create($filterModel); + $configuredFilter = $this->configuredFilterFactory->create($filterModel); - $key = $filterDefinition->getAlias() + $key = $configuredFilter->getAlias() ?: "_.{$filterModel::getTable()}.{$filterModel->id}"; - $collection->set($key, $filterDefinition); + $collection->set($key, $configuredFilter); } return $collection; diff --git a/src/FilterElement/AbstractFilterElement.php b/src/FilterElement/AbstractFilterElement.php index 4acad00f..d39d7ce9 100644 --- a/src/FilterElement/AbstractFilterElement.php +++ b/src/FilterElement/AbstractFilterElement.php @@ -9,16 +9,13 @@ use HeimrichHannot\FlareBundle\Contract\FilterElement\FormTypeOptionsContract; use HeimrichHannot\FlareBundle\Contract\FilterElement\RuntimeValueContract; use HeimrichHannot\FlareBundle\Contract\IsSupportedContract; -use HeimrichHannot\FlareBundle\Contract\OptionsInterface; use HeimrichHannot\FlareBundle\Contract\PaletteContract; use HeimrichHannot\FlareBundle\Event\FilterElementFormTypeOptionsEvent; +use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterInvocation; -use HeimrichHannot\FlareBundle\Filter\FilterInvokerInterface; -use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; -use HeimrichHannot\FlareBundle\Specification\FilterDefinition; +use HeimrichHannot\FlareBundle\Form\FilterFormBuilderInterface; +use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; use HeimrichHannot\FlareBundle\Specification\ListSpecification; -use Symfony\Component\Form\FormInterface; -use Symfony\Component\OptionsResolver\OptionsResolver; /** * @phpstan-template FormOptionsShape of array{ @@ -28,7 +25,7 @@ * placeholder?: string * } */ -abstract class AbstractFilterElement implements FilterInvokerInterface, OptionsInterface, +abstract class AbstractFilterElement implements FilterElementInterface, FormDataContract, FormTypeOptionsContract, IsSupportedContract, PaletteContract, RuntimeValueContract { /** @@ -43,17 +40,10 @@ abstract class AbstractFilterElement implements FilterInvokerInterface, OptionsI 'placeholder' => 'placeholder', ]; - /** - * The default filtering logic. - * - * {@inheritdoc} - */ - abstract public function __invoke(FilterInvocation $inv, FilterQueryBuilder $qb): void; - /** * Creates default form type options based on default filter model fields and the given config. * - * @param FilterDefinition $filter The filter definition. + * @param ConfiguredFilter $filter The filter definition. * @param array|array|array|FormOptionsShape|list> $config The config to use. * @@ -63,7 +53,7 @@ abstract public function __invoke(FilterInvocation $inv, FilterQueryBuilder $qb) * @example $config = ['label', 'multiple', 'placeholder' => 'Select a value'] */ public function defaultFormTypeOptions( - FilterDefinition $filter, + ConfiguredFilter $filter, array $config = [], ): array { $options = []; @@ -104,6 +94,17 @@ public function defaultFormTypeOptions( public function handleFormTypeOptions(FilterElementFormTypeOptionsEvent $event): void {} + public function buildForm(FilterFormBuilderInterface $builder, FilterElementContext $context): void + { + if ($context->filter->isIntrinsic()) { + return; + } + + $builder->add($context); + } + + public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $invocation): void {} + public function extractFormData(FormInterface $form): mixed { return $form->getData(); @@ -114,19 +115,17 @@ public function isSupported(): bool return true; } - public function configureOptions(OptionsResolver $resolver): void {} - public function getPalette(PaletteConfig $config): ?string { return null; } - public function processRuntimeValue(mixed $value, ListSpecification $list, FilterDefinition $filter): mixed + public function processRuntimeValue(mixed $value, ListSpecification $list, ConfiguredFilter $filter): mixed { return $value; } - public static function define(): FilterDefinition + public static function define(): ConfiguredFilter { throw new \LogicException('Not implemented.'); } diff --git a/src/FilterElement/ArchiveElement.php b/src/FilterElement/ArchiveElement.php index da176218..f8a24ae8 100644 --- a/src/FilterElement/ArchiveElement.php +++ b/src/FilterElement/ArchiveElement.php @@ -8,7 +8,6 @@ use Contao\Model; use Contao\Model\Collection; use Contao\StringUtil; -use Doctrine\DBAL\ArrayParameterType; use HeimrichHannot\FlareBundle\Contract\Config\PaletteConfig; use HeimrichHannot\FlareBundle\Contract\FilterElement\HydrateFormContract; use HeimrichHannot\FlareBundle\Contract\FilterElement\IntrinsicValueContract; @@ -16,7 +15,9 @@ use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Event\FilterElementFormTypeOptionsEvent; use HeimrichHannot\FlareBundle\Exception\FilterException; +use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterInvocation; +use HeimrichHannot\FlareBundle\Filter\Type\ArchiveFilterType; use HeimrichHannot\FlareBundle\Form\ChoicesBuilder; use HeimrichHannot\FlareBundle\Form\Factory\ChoicesBuilderFactory; use HeimrichHannot\FlareBundle\InferPtable\Factory\PtableInferrableFactory; @@ -24,8 +25,7 @@ use HeimrichHannot\FlareBundle\InferPtable\PtableInferrer; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Model\ListModel; -use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; -use HeimrichHannot\FlareBundle\Specification\FilterDefinition; +use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; use HeimrichHannot\FlareBundle\Specification\ListSpecification; use HeimrichHannot\FlareBundle\Util\Str; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; @@ -46,19 +46,24 @@ public function __construct( /** * @throws FilterException */ - public function __invoke(FilterInvocation $inv, FilterQueryBuilder $qb): void + public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $invocation): void { + $filter = $invocation->filter; + /** @var Model[] $selectedModels */ - $selectedModels = $inv->getValue() ?? []; - $inferrer = $this->getPtableInferrer($inv->list); + $selectedModels = $filter->isIntrinsic() + ? $this->getIntrinsicValue($invocation->list, $filter) + : $this->processRuntimeValue($invocation->getValue(), $invocation->list, $filter); + + $inferrer = $this->getPtableInferrer($invocation->list); if (!$selectedModels) { - if ($inv->filter->useWhitelistForOptionsOnly) { + if ($filter->useWhitelistForOptionsOnly) { return; } - $qb::abort(); + $builder->abort(); } if ($inferrer->getDcaMainPtable()) @@ -67,8 +72,10 @@ public function __invoke(FilterInvocation $inv, FilterQueryBuilder $qb): void throw new FilterException('No valid parent archive ids extracted.'); } - $qb->where($qb->expr()->in($qb->column('pid'), ':pids')) - ->setParameter('pids', $pids, ArrayParameterType::INTEGER); + $builder->add(ArchiveFilterType::class, [ + 'field' => 'pid', + 'parent_ids' => $pids, + ]); return; } @@ -93,16 +100,16 @@ public function __invoke(FilterInvocation $inv, FilterQueryBuilder $qb): void } } - $this->relationElement->filterDynamicPtableField( - qb: $qb, - filter: $inv->filter, + $this->relationElement->addDynamicPtableFilter( + builder: $builder, + filter: $filter, fieldDynamicPtable: 'ptable', fieldPid: 'pid', submittedData: $grouped, ); } - protected function getWhitelistedParentIds(ListSpecification $list, FilterDefinition $filter): ?array + protected function getWhitelistedParentIds(ListSpecification $list, ConfiguredFilter $filter): ?array { $inferrer = $this->getPtableInferrer($list); @@ -120,7 +127,7 @@ protected function getWhitelistedParentIds(ListSpecification $list, FilterDefini return $this->getParentIdsFromGroupWhitelistBlob($filter->groupWhitelistParents); } - protected function getWhitelistedParents(ListSpecification $list, FilterDefinition $filter): array + protected function getWhitelistedParents(ListSpecification $list, ConfiguredFilter $filter): array { $inferrer = $this->getPtableInferrer($list); @@ -142,7 +149,7 @@ protected function getWhitelistedParents(ListSpecification $list, FilterDefiniti /** * @return Model[] */ - public function getIntrinsicValue(ListSpecification $list, FilterDefinition $filter): array + public function getIntrinsicValue(ListSpecification $list, ConfiguredFilter $filter): array { return $this->getWhitelistedParents($list, $filter); } @@ -150,7 +157,7 @@ public function getIntrinsicValue(ListSpecification $list, FilterDefinition $fil /** * @return Model[] */ - public function processRuntimeValue(mixed $value, ListSpecification $list, FilterDefinition $filter): array + public function processRuntimeValue(mixed $value, ListSpecification $list, ConfiguredFilter $filter): array { $values = $this->normalizeFilterValue($value); @@ -552,7 +559,7 @@ protected function getParentsFromGroupWhitelistBlob(?string $blob): array return $allParents; } - public function hydrateForm(FormInterface $field, ListSpecification $list, FilterDefinition $filter): void + public function hydrateForm(FormInterface $field, ListSpecification $list, ConfiguredFilter $filter): void { if (!$preselect = StringUtil::deserialize($filter->preselect ?: null, true)) { @@ -633,4 +640,4 @@ public function hydrateForm(FormInterface $field, ListSpecification $list, Filte $field->setData($data); } -} \ No newline at end of file +} diff --git a/src/FilterElement/BelongsToRelationElement.php b/src/FilterElement/BelongsToRelationElement.php index 2ddd9083..bc233c95 100644 --- a/src/FilterElement/BelongsToRelationElement.php +++ b/src/FilterElement/BelongsToRelationElement.php @@ -10,11 +10,12 @@ use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Exception\InferenceException; +use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterInvocation; +use HeimrichHannot\FlareBundle\Filter\Type\BelongsToRelationFilterType; use HeimrichHannot\FlareBundle\InferPtable\Factory\PtableInferrableFactory; use HeimrichHannot\FlareBundle\InferPtable\PtableInferrer; -use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; -use HeimrichHannot\FlareBundle\Specification\FilterDefinition; +use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; use Symfony\Contracts\Translation\TranslatorInterface; #[AsFilterElement(type: self::TYPE)] @@ -29,15 +30,17 @@ public function __construct( /** * @throws FilterException */ - public function __invoke(FilterInvocation $inv, FilterQueryBuilder $qb): void + public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $invocation): void { - if (!$fieldPid = $inv->filter->fieldPid) + $filter = $invocation->filter; + + if (!$fieldPid = $filter->fieldPid) { throw new FilterException('No parent field defined.'); } - $inferrable = PtableInferrableFactory::createFromListModelLike($inv->list); - $inferrer = new PtableInferrer($inferrable, $inv->list->dc); + $inferrable = PtableInferrableFactory::createFromListModelLike($invocation->list); + $inferrer = new PtableInferrer($inferrable, $invocation->list->dc); try { @@ -46,21 +49,28 @@ public function __invoke(FilterInvocation $inv, FilterQueryBuilder $qb): void } catch (InferenceException) { - $qb->abort(); + $builder->abort(); } if (\is_string($fieldDynamicPtable)) { - $this->filterDynamicPtableField($qb, $inv->filter, $fieldDynamicPtable, $fieldPid); + $builder->add(BelongsToRelationFilterType::class, [ + 'field_pid' => $fieldPid, + 'field_dynamic_ptable' => $fieldDynamicPtable, + 'parent_groups' => $this->getDynamicParentGroups($filter), + ]); + return; } - if (!$ptable || !$whitelistParents = StringUtil::deserialize($inv->filter->whitelistParents)) { + if (!$ptable || !$whitelistParents = StringUtil::deserialize($filter->whitelistParents)) { throw new FilterException('No whitelisted parents.'); } - $qb->where($qb->expr()->in($qb->column($fieldPid), ":whitelist")) - ->setParameter('whitelist', $whitelistParents); + $builder->add(BelongsToRelationFilterType::class, [ + 'field_pid' => $fieldPid, + 'whitelist' => (array) $whitelistParents, + ]); } /** @@ -72,24 +82,31 @@ public function __invoke(FilterInvocation $inv, FilterQueryBuilder $qb): void * ]; * ``` */ - public function filterDynamicPtableField( - FilterQueryBuilder $qb, - FilterDefinition $filter, - string $fieldDynamicPtable, - string $fieldPid, - ?array $submittedData = null, + public function addDynamicPtableFilter( + FilterBuilderInterface $builder, + ConfiguredFilter $filter, + string $fieldDynamicPtable, + string $fieldPid, + ?array $submittedData = null, ): void { + $builder->add(BelongsToRelationFilterType::class, [ + 'field_pid' => $fieldPid, + 'field_dynamic_ptable' => $fieldDynamicPtable, + 'parent_groups' => $this->getDynamicParentGroups($filter), + 'submitted_data' => $submittedData, + ]); + } + + public function getDynamicParentGroups(ConfiguredFilter $filter): array + { if (!$parentGroups = StringUtil::deserialize($filter->groupWhitelistParents)) { - $qb->abort(); + return []; } - $ors = []; + $groups = []; - $colDynamicPtable = $qb->column($fieldDynamicPtable); - $colPid = $qb->column($fieldPid); - - foreach (\array_values($parentGroups) as $i => $group) + foreach (\array_values($parentGroups) as $group) { if (!($g_tablePtable = $group['tablePtable'] ?? null) || !($g_whitelistParents = $group['whitelistParents'] ?? null) @@ -98,47 +115,19 @@ public function filterDynamicPtableField( continue; } - if (isset($submittedData)) - { - $submittedWhitelist = $submittedData[$g_tablePtable] ?? null; - - if (!\is_array($submittedWhitelist)) { - continue; - } - - $g_whitelistParents = \array_intersect($g_whitelistParents, $submittedWhitelist); - } - $g_whitelistParents = \array_values(\array_filter($g_whitelistParents)); if (!$g_whitelistParents) { continue; } - $gKey_tablePtable = \sprintf(':g%s_ptable', $i); - $gKey_whitelistParents = \sprintf(':g%s_whitelist', $i); - - $ors[] = $qb->expr()->and( - $qb->expr()->eq($colDynamicPtable, $gKey_tablePtable), - $qb->expr()->in($colPid, $gKey_whitelistParents) - ); - - $qb->setParameter($gKey_tablePtable, $g_tablePtable); - $qb->setParameter($gKey_whitelistParents, $g_whitelistParents); - } - - if (\count($ors) < 1) - { - $qb->abort(); - } - - if (\count($ors) === 1) - { - $qb->where($ors[0]); - return; + $groups[] = [ + 'table' => $g_tablePtable, + 'ids' => $g_whitelistParents, + ]; } - $qb->whereOr(...$ors); + return $groups; } public function getPalette(PaletteConfig $config): ?string @@ -205,4 +194,4 @@ public function getPalette(PaletteConfig $config): ?string return $palette; } -} \ No newline at end of file +} diff --git a/src/FilterElement/BooleanElement.php b/src/FilterElement/BooleanElement.php index a1afc4c5..7bfc0b4b 100644 --- a/src/FilterElement/BooleanElement.php +++ b/src/FilterElement/BooleanElement.php @@ -6,7 +6,6 @@ use Contao\Controller; use Contao\Message; -use Doctrine\DBAL\ParameterType; use HeimrichHannot\FlareBundle\Contract\Config\PaletteConfig; use HeimrichHannot\FlareBundle\Contract\FilterElement\IntrinsicValueContract; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterCallback; @@ -15,10 +14,11 @@ use HeimrichHannot\FlareBundle\Enum\BoolMode; use HeimrichHannot\FlareBundle\Event\FilterElementFormTypeOptionsEvent; use HeimrichHannot\FlareBundle\Exception\FilterException; +use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterInvocation; +use HeimrichHannot\FlareBundle\Filter\Type\BooleanFilterType; use HeimrichHannot\FlareBundle\Model\FilterModel; -use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; -use HeimrichHannot\FlareBundle\Specification\FilterDefinition; +use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; use HeimrichHannot\FlareBundle\Specification\ListSpecification; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; @@ -35,28 +35,34 @@ class BooleanElement extends AbstractFilterElement implements IntrinsicValueCont /** * @throws FilterException */ - public function __invoke(FilterInvocation $inv, FilterQueryBuilder $qb): void + public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $invocation): void { - if (!$targetField = $inv->filter->fieldGeneric) { - $qb->abort(); + $filter = $invocation->filter; + + if (!$targetField = $filter->fieldGeneric) { + $builder->abort(); } - $value = $inv->getValue(); + $value = $filter->isIntrinsic() + ? $this->getIntrinsicValue($invocation->list, $filter) + : $this->processRuntimeValue($invocation->getValue(), $invocation->list, $filter); if ($value === null) { return; } - $qb->where($qb->expr()->eq($qb->column($targetField), ':val')) - ->setParameter('val', $value ? '1' : '', ParameterType::STRING); + $builder->add(BooleanFilterType::class, [ + 'field' => $targetField, + 'value' => $value, + ]); } - public function getIntrinsicValue(ListSpecification $list, FilterDefinition $filter): bool + public function getIntrinsicValue(ListSpecification $list, ConfiguredFilter $filter): bool { return (bool) $this->normalizeValue($filter->preselect); } - public function processRuntimeValue(mixed $value, ListSpecification $list, FilterDefinition $filter): ?bool + public function processRuntimeValue(mixed $value, ListSpecification $list, ConfiguredFilter $filter): ?bool { $mode = BoolMode::tryFrom($filter->boolMode ?: '') ?? BoolMode::BINARY; @@ -166,8 +172,8 @@ public function getPalette(PaletteConfig $config): ?string public static function define( ?string $targetField = null, ?bool $expectedValue = null, - ): FilterDefinition { - $definition = new FilterDefinition( + ): ConfiguredFilter { + $definition = new ConfiguredFilter( type: static::TYPE, intrinsic: true, ); @@ -177,4 +183,4 @@ public static function define( return $definition; } -} \ No newline at end of file +} diff --git a/src/FilterElement/CalendarCurrentElement.php b/src/FilterElement/CalendarCurrentElement.php index ff0ebd70..4e30b056 100644 --- a/src/FilterElement/CalendarCurrentElement.php +++ b/src/FilterElement/CalendarCurrentElement.php @@ -8,15 +8,14 @@ use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Engine\Context\ValidationContext; use HeimrichHannot\FlareBundle\Event\FilterElementFormTypeOptionsEvent; -use HeimrichHannot\FlareBundle\Event\FilterElementInvokingEvent; use HeimrichHannot\FlareBundle\Exception\FilterException; +use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterInvocation; +use HeimrichHannot\FlareBundle\Filter\Type\CalendarCurrentFilterType; use HeimrichHannot\FlareBundle\Form\Type\DateRangeFilterType; -use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; -use HeimrichHannot\FlareBundle\Specification\FilterDefinition; +use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; use HeimrichHannot\FlareBundle\Specification\ListSpecification; use HeimrichHannot\FlareBundle\Util\DateTimeHelper; -use Symfony\Component\EventDispatcher\Attribute\AsEventListener; #[AsFilterElement( type: self::TYPE, @@ -29,20 +28,26 @@ class CalendarCurrentElement extends AbstractFilterElement /** * @throws FilterException */ - public function __invoke(FilterInvocation $inv, FilterQueryBuilder $qb): void + public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $invocation): void { - $value = $inv->getValue(); + $filter = $invocation->filter; + + if (!$filter->isLimited && $invocation->context instanceof ValidationContext) { + return; + } + + $value = $this->processRuntimeValue($invocation->getValue(), $invocation->list, $filter) ?? []; $from = $value['from'] ?? null; $to = $value['to'] ?? null; - $start = \strtotime($inv->filter->startAt) ?: 0; - $stop = \strtotime($inv->filter->stopAt) ?: DateTimeHelper::maxTimestamp(); + $start = \strtotime($filter->startAt) ?: 0; + $stop = \strtotime($filter->stopAt) ?: DateTimeHelper::maxTimestamp(); if ($from instanceof \DateTimeInterface) { $from = $from->getTimestamp(); - if (!$inv->filter->isLimited || $from >= $start) { + if (!$filter->isLimited || $from >= $start) { $start = $from; } } @@ -51,43 +56,19 @@ public function __invoke(FilterInvocation $inv, FilterQueryBuilder $qb): void { $to = $to->getTimestamp(); - if (!$inv->filter->isLimited || $to <= $stop) { + if (!$filter->isLimited || $to <= $stop) { $stop = $to; } } - $colStartTime = $qb->column('startTime'); - $colRepeatEnd = $qb->column('repeatEnd'); - $colRecurrences = $qb->column('recurrences'); - $colRecurring = $qb->column('recurring'); - - $or = [ - "{$colStartTime} >= :start AND {$colStartTime} <= :end", // event starts in range - $qb->expr()->and( // event is recurring - $qb->expr()->eq($colRecurring, '1'), - $qb->expr()->lte($colStartTime, ':end'), // event starts before the end of the range - $qb->expr()->or( - $qb->expr()->eq($colRecurrences, '0'), // 0 = infinite recurrences - $qb->expr()->gte($colRepeatEnd, ':start'), - ), - ), - ]; - - if ($inv->filter->hasExtendedEvents) - { - $colEndTime = $qb->column('endTime'); - - $or[] = "{$colEndTime} >= :start AND {$colEndTime} <= :end"; // event ends in the range - $or[] = "{$colStartTime} <= :start AND {$colEndTime} >= :end"; // event is within the range - } - - $qb->whereOr(...$or); - - $qb->setParameter('start', $start); - $qb->setParameter('end', $stop); + $builder->add(CalendarCurrentFilterType::class, [ + 'start' => $start, + 'stop' => $stop, + 'has_extended_events' => (bool) $filter->hasExtendedEvents, + ]); } - public function processRuntimeValue(mixed $value, ListSpecification $list, FilterDefinition $filter): ?array + public function processRuntimeValue(mixed $value, ListSpecification $list, ConfiguredFilter $filter): ?array { if (!\is_array($value)) { return null; @@ -138,16 +119,6 @@ private function mixedToDateTime(mixed $input): ?\DateTimeInterface return null; } - #[AsEventListener('flare.filter_element.' . self::TYPE . '.invoking')] - public function onInvoking(FilterElementInvokingEvent $event): void - { - $filter = $event->getInvocation()->filter; - - if (!$filter->isLimited && $event->getContext() instanceof ValidationContext) { - $event->setShouldInvoke(false); - } - } - public function getPalette(PaletteConfig $config): ?string { $filterModel = $config->getFilterModel(); @@ -189,4 +160,4 @@ public function handleFormTypeOptions(FilterElementFormTypeOptionsEvent $event): $event->options['to_max'] = $stopAt; } } -} \ No newline at end of file +} diff --git a/src/FilterElement/DateRangeElement.php b/src/FilterElement/DateRangeElement.php index 0626b415..04627520 100644 --- a/src/FilterElement/DateRangeElement.php +++ b/src/FilterElement/DateRangeElement.php @@ -7,9 +7,10 @@ use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Event\FilterElementFormTypeOptionsEvent; use HeimrichHannot\FlareBundle\Exception\FilterException; +use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterInvocation; +use HeimrichHannot\FlareBundle\Filter\Type\DateRangeFilterType as DateRangeQueryFilterType; use HeimrichHannot\FlareBundle\Form\Type\DateRangeFilterType; -use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; #[AsFilterElement( type: self::TYPE, @@ -23,32 +24,23 @@ class DateRangeElement extends AbstractFilterElement /** * @throws FilterException */ - public function __invoke(FilterInvocation $inv, FilterQueryBuilder $qb): void + public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $invocation): void { - $value = $inv->getValue(); + $value = (array) ($invocation->getValue() ?: []); - if (!$field = $inv->filter->fieldGeneric) { + if (!$field = $invocation->filter->fieldGeneric) { throw new FilterException('Set fieldGeneric in filter model.'); } - $from = $value['from'] ?? null; - $to = $value['to'] ?? null; - - $colField = $qb->column($field); - - if ($from instanceof \DateTimeInterface) { - $qb->where($qb->expr()->gte($colField, ':from')) - ->setParameter('from', $from->getTimestamp()); - } - - if ($to instanceof \DateTimeInterface) { - $qb->where($qb->expr()->lte($colField, ':to')) - ->setParameter('to', $to->getTimestamp()); - } + $builder->add(DateRangeQueryFilterType::class, [ + 'field' => $field, + 'from' => $value['from'] ?? null, + 'to' => $value['to'] ?? null, + ]); } public function handleFormTypeOptions(FilterElementFormTypeOptionsEvent $event): void { $event->options['required'] = false; } -} \ No newline at end of file +} diff --git a/src/FilterElement/DcaSelectFieldElement.php b/src/FilterElement/DcaSelectFieldElement.php index d15fc350..253d01f7 100644 --- a/src/FilterElement/DcaSelectFieldElement.php +++ b/src/FilterElement/DcaSelectFieldElement.php @@ -15,11 +15,12 @@ use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Event\FilterElementFormTypeOptionsEvent; use HeimrichHannot\FlareBundle\Exception\FilterException; +use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterInvocation; +use HeimrichHannot\FlareBundle\Filter\Type\DcaSelectFilterType; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Model\ListModel; -use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; -use HeimrichHannot\FlareBundle\Specification\FilterDefinition; +use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; use HeimrichHannot\FlareBundle\Specification\ListSpecification; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\FormInterface; @@ -35,11 +36,16 @@ class DcaSelectFieldElement extends AbstractFilterElement implements HydrateForm /** * @throws FilterException */ - public function __invoke(FilterInvocation $inv, FilterQueryBuilder $qb): void + public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $invocation): void { - $options = $this->getOptions($inv->list, $inv->filter) ?? []; + $filter = $invocation->filter; + $options = $this->getOptions($invocation->list, $filter) ?? []; - if (!$selected = $inv->getValue()) { + $selected = $filter->isIntrinsic() + ? $this->getIntrinsicValue($invocation->list, $filter) + : $invocation->getValue(); + + if (!$selected) { return; } @@ -48,71 +54,22 @@ public function __invoke(FilterInvocation $inv, FilterQueryBuilder $qb): void } if (!$options) { - $qb->abort(); + $builder->abort(); } - if (!$targetField = $inv->filter->fieldGeneric) { - $qb->abort(); + if (!$targetField = $filter->fieldGeneric) { + $builder->abort(); } - $dcaOptionsField = $this->getOptionsField($inv->list, $inv->filter) ?? []; + $dcaOptionsField = $this->getOptionsField($invocation->list, $filter) ?? []; $isMultiple = $dcaOptionsField['eval']['multiple'] ?? false; - if (\count($selected) === 1) - { - $value = \current($selected); - if (!\array_key_exists($value, $options)) - { - $qb->abort(); - } - - if ($isMultiple) - { - $qb->whereInSerialized($value, $targetField); - - return; - } - - $qb->where($qb->expr()->eq($qb->column($targetField), ':value')) - ->setParameter('value', $value); - - return; - } - - if (\count(\array_unique($options)) !== \count($options)) - // options are not unique, cannot flip - { - throw new FilterException(\sprintf( - 'The options for the DCA select field %s.%s must be unique.', - $inv->list->dc, - $targetField, - )); - } - - $validOptions = []; - - foreach ($selected as $value) - { - if ($options[$value] ?? null) { - $validOptions[] = $value; - } - } - - if (!\count($validOptions)) - // of the submitted values, none are valid - { - $qb->abort(); - } - - if ($isMultiple) - { - $qb->whereInSerialized($validOptions, $targetField); - - return; - } - - $qb->where($qb->expr()->in($qb->column($targetField), ':values')) - ->setParameter('values', $validOptions); + $builder->add(DcaSelectFilterType::class, [ + 'field' => $targetField, + 'selected' => $selected, + 'valid_options' => $options, + 'is_multiple_dca_field' => (bool) $isMultiple, + ]); } public function getPalette(PaletteConfig $config): ?string @@ -126,12 +83,12 @@ public function getPalette(PaletteConfig $config): ?string return $palette; } - public function getIntrinsicValue(ListSpecification $list, FilterDefinition $filter): mixed + public function getIntrinsicValue(ListSpecification $list, ConfiguredFilter $filter): mixed { return $this->getPreselectValue($filter); } - public function getPreselectValue(FilterDefinition $filter): mixed + public function getPreselectValue(ConfiguredFilter $filter): mixed { return $filter->isMultiple ? StringUtil::deserialize($filter->preselect ?: null) @@ -143,7 +100,7 @@ public function extractFormData(FormInterface $form): mixed return $form->getViewData(); } - public function hydrateForm(FormInterface $field, ListSpecification $list, FilterDefinition $filter): void + public function hydrateForm(FormInterface $field, ListSpecification $list, ConfiguredFilter $filter): void { if ($field->isSubmitted()) { return; @@ -278,7 +235,7 @@ public function getPreselectOptions(ListModel $listModel, FilterModel $filterMod return $this->tryGetOptionsFromField($listModel, $field) ?? []; } - public function getOptions(ListSpecification $list, FilterDefinition $filter): ?array + public function getOptions(ListSpecification $list, ConfiguredFilter $filter): ?array { $optionsField = $this->getOptionsField($list, $filter) ?? []; $options = $this->tryGetOptionsFromField($list, $optionsField); @@ -304,7 +261,7 @@ public function getOptions(ListSpecification $list, FilterDefinition $filter): ? return $options; } - public function getOptionsField(ListModel|ListSpecification $list, FilterModel|FilterDefinition $filter): ?array + public function getOptionsField(ListModel|ListSpecification $list, FilterModel|ConfiguredFilter $filter): ?array { Controller::loadLanguageFile($list->dc); Controller::loadDataContainer($list->dc); diff --git a/src/FilterElement/FieldValueChoiceElement.php b/src/FilterElement/FieldValueChoiceElement.php index 529c3b50..23cfc6e0 100644 --- a/src/FilterElement/FieldValueChoiceElement.php +++ b/src/FilterElement/FieldValueChoiceElement.php @@ -15,13 +15,14 @@ use HeimrichHannot\FlareBundle\Engine\Context\ValidationContext; use HeimrichHannot\FlareBundle\Event\FilterElementFormTypeOptionsEvent; use HeimrichHannot\FlareBundle\Exception\FilterException; +use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterInvocation; +use HeimrichHannot\FlareBundle\Filter\Type\FieldValueChoiceFilterType; use HeimrichHannot\FlareBundle\Form\ChoicesBuilder; use HeimrichHannot\FlareBundle\Form\Factory\ChoicesBuilderFactory; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Model\ListModel; -use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; -use HeimrichHannot\FlareBundle\Specification\FilterDefinition; +use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; use HeimrichHannot\FlareBundle\Specification\ListSpecification; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\FormInterface; @@ -46,41 +47,38 @@ public function __construct( /** * @throws FilterException */ - public function __invoke(FilterInvocation $inv, FilterQueryBuilder $qb): void + public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $invocation): void { - if ($inv->context instanceof ValidationContext) { + if ($invocation->context instanceof ValidationContext) { return; } - if (!($field = $inv->filter->fieldGeneric)) { - return; - } + $filter = $invocation->filter; - if (!$value = $inv->getValue()) { + if (!($field = $filter->fieldGeneric)) { return; } - $colField = $qb->column($field); + $value = $filter->isIntrinsic() + ? $this->getIntrinsicValue($invocation->list, $filter) + : $this->processRuntimeValue($invocation->getValue(), $invocation->list, $filter); - if (\count($value) < 2) - { - $qb->where("LOWER(TRIM({$colField})) = :value") - ->setParameter('value', \reset($value)); - } - /** @mago-expect lint:no-else-clause This else clause is fine. */ - else - { - $qb->where("LOWER(TRIM({$colField})) IN (:values)") - ->setParameter('values', $value); + if (!$value) { + return; } + + $builder->add(FieldValueChoiceFilterType::class, [ + 'field' => $field, + 'values' => $value, + ]); } - public function processRuntimeValue(mixed $value, ListSpecification $list, FilterDefinition $filter): ?array + public function processRuntimeValue(mixed $value, ListSpecification $list, ConfiguredFilter $filter): ?array { return $this->extractSubmittedData((array) $value); } - public function getIntrinsicValue(ListSpecification $list, FilterDefinition $filter): ?array + public function getIntrinsicValue(ListSpecification $list, ConfiguredFilter $filter): ?array { return $this->extractPreselectData($filter); } @@ -90,7 +88,7 @@ public function extractFormData(FormInterface $form): mixed return $form->getViewData(); } - public function extractPreselectData(FilterDefinition $filter): ?array + public function extractPreselectData(ConfiguredFilter $filter): ?array { if (!$preselect = $filter->preselect) { return null; @@ -121,7 +119,7 @@ public function extractSubmittedData(array $submittedData): ?array return $submittedData ?: null; } - public function hydrateForm(FormInterface $field, ListSpecification $list, FilterDefinition $filter): void + public function hydrateForm(FormInterface $field, ListSpecification $list, ConfiguredFilter $filter): void { if ($field->isSubmitted()) { return; diff --git a/src/FilterElement/FilterElementContext.php b/src/FilterElement/FilterElementContext.php new file mode 100644 index 00000000..c542dd66 --- /dev/null +++ b/src/FilterElement/FilterElementContext.php @@ -0,0 +1,20 @@ +filter->usePublished ?? true) - { - $publishedField = $qb->column($inv->filter->fieldPublished ?: 'published'); - $invertPublished = $inv->filter->invertPublished ?? false; - $operator = $invertPublished ? 'neq' : 'eq'; - - // "published = '1'" or "published != '1'" - $qb->where($qb->expr()->{$operator}($publishedField, $this->connection->quote('1'))); - } - - $epsilon = $this->connection->quote(''); - $zero = $this->connection->quote('0'); - - if ($inv->filter->useStart ?? true) - { - $startField = $qb->column($inv->filter->fieldStart ?: 'start'); - - $qb->where("{$startField} = {$epsilon} OR {$startField} = {$zero} OR {$startField} <= :start") - ->setParameter('start', \time()); - } - - if ($inv->filter->useStop ?? true) - { - $stopField = $qb->column($inv->filter->fieldStop ?: 'stop'); - - $qb->where("{$stopField} = {$epsilon} OR {$stopField} = {$zero} OR {$stopField} >= :stop") - ->setParameter('stop', \time()); - } + $filter = $invocation->filter; + + $builder->add(PublishedFilterType::class, [ + 'published_field' => ($filter->usePublished ?? true) ? ($filter->fieldPublished ?: 'published') : null, + 'start_field' => ($filter->useStart ?? true) ? ($filter->fieldStart ?: 'start') : null, + 'stop_field' => ($filter->useStop ?? true) ? ($filter->fieldStop ?: 'stop') : null, + 'invert_published' => (bool) ($filter->invertPublished ?? false), + 'now' => \time(), + ]); } public static function define( @@ -63,13 +36,13 @@ public static function define( string|false|null $start = null, string|false|null $stop = null, bool|null $invertPublished = null, - ): FilterDefinition { + ): ConfiguredFilter { $published ??= 'published'; $start ??= 'start'; $stop ??= 'stop'; $invertPublished ??= false; - $definition = new FilterDefinition( + $definition = new ConfiguredFilter( type: static::TYPE, intrinsic: true, ); @@ -92,4 +65,4 @@ public static function define( return $definition; } -} \ No newline at end of file +} diff --git a/src/FilterElement/SearchKeywordsElement.php b/src/FilterElement/SearchKeywordsElement.php index 5231d58c..39365f79 100644 --- a/src/FilterElement/SearchKeywordsElement.php +++ b/src/FilterElement/SearchKeywordsElement.php @@ -5,14 +5,14 @@ namespace HeimrichHannot\FlareBundle\FilterElement; use Contao\StringUtil; -use HeimrichHannot\FlareBundle\ConfigProvider; use HeimrichHannot\FlareBundle\Contract\Config\PaletteConfig; use HeimrichHannot\FlareBundle\Contract\FilterElement\IntrinsicValueContract; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Event\FilterElementFormTypeOptionsEvent; +use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterInvocation; -use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; -use HeimrichHannot\FlareBundle\Specification\FilterDefinition; +use HeimrichHannot\FlareBundle\Filter\Type\SearchKeywordsFilterType; +use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; use HeimrichHannot\FlareBundle\Specification\ListSpecification; use Symfony\Component\Form\Extension\Core\Type\TextType; @@ -25,61 +25,25 @@ class SearchKeywordsElement extends AbstractFilterElement implements IntrinsicVa { public const TYPE = 'flare_search_keywords'; - public function __construct( - private readonly ConfigProvider $configProvider, - ) {} - - public function __invoke(FilterInvocation $inv, FilterQueryBuilder $qb): void + public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $invocation): void { - $value = $inv->getValue(); + $filter = $invocation->filter; + $value = $filter->isIntrinsic() + ? $this->getIntrinsicValue($invocation->list, $filter) + : $invocation->getValue(); + if (!$value || !\is_string($value)) { return; } - if (!$columns = StringUtil::deserialize($inv->filter->columnsGeneric, true)) { + if (!$columns = StringUtil::deserialize($filter->columnsGeneric, true)) { return; } - $columns = \array_map($qb->column(...), $columns); - - $searchTermGroups = \array_values(\preg_split('/\s+OR\s+/i', $value)); - - $or = []; - - foreach ($searchTermGroups ?: [] as $i => $searchTermGroup) - { - if (!$searchTerms = $this->makeTerms($searchTermGroup)) { - return; - } - - $and = []; - - foreach (\array_values($searchTerms) as $j => $term) - { - $param = ':term_' . $i . '_' . $j; - - $and[] = $qb->expr()->or(...\array_map( - static fn(string $column): string => $qb->expr()->like($column, $param), - $columns - )); - - $qb->setParameter($param, '%' . $term . '%'); - } - - $or[] = $qb->expr()->and(...$and); - } - - $qb->where($qb->expr()->or(...$or)); - } - - private function makeTerms(string $text): array - { - $text = (string) \mb_strtolower($text); - $text = \preg_replace('/[^\p{L}\p{Nd}-]+/u', ' ', $text); - $text = \preg_replace('/\s+/', ' ', $text); - $terms = \array_unique(\array_filter(\array_map('\trim', \explode(' ', \trim($text))))); - $stopWords = $this->configProvider->getStopWords(); - return $stopWords ? \array_diff($terms, $stopWords) : $terms; + $builder->add(SearchKeywordsFilterType::class, [ + 'value' => $value, + 'columns' => $columns, + ]); } public function handleFormTypeOptions(FilterElementFormTypeOptionsEvent $event): void @@ -96,7 +60,7 @@ public function handleFormTypeOptions(FilterElementFormTypeOptionsEvent $event): } } - public function getIntrinsicValue(ListSpecification $list, FilterDefinition $filter): ?string + public function getIntrinsicValue(ListSpecification $list, ConfiguredFilter $filter): ?string { return $filter->prefill ?: null; } @@ -111,4 +75,4 @@ public function getPalette(PaletteConfig $config): ?string return $palette . ';{form_legend},label,placeholder'; } -} \ No newline at end of file +} diff --git a/src/FilterElement/SimpleEquationElement.php b/src/FilterElement/SimpleEquationElement.php index 4197292a..67fa9a83 100644 --- a/src/FilterElement/SimpleEquationElement.php +++ b/src/FilterElement/SimpleEquationElement.php @@ -10,12 +10,11 @@ use HeimrichHannot\FlareBundle\Enum\SqlEquationOperator; use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Exception\FlareException; +use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterInvocation; -use HeimrichHannot\FlareBundle\FilterType\SimpleEquationFilterType; -use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; -use HeimrichHannot\FlareBundle\Specification\FilterDefinition; +use HeimrichHannot\FlareBundle\Filter\Type\SimpleEquationFilterType; +use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; use HeimrichHannot\FlareBundle\Util\DcaHelper; -use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE, isTargeted: true)] class SimpleEquationElement extends AbstractFilterElement @@ -25,24 +24,19 @@ class SimpleEquationElement extends AbstractFilterElement /** * @throws FilterException */ - public function __invoke(FilterInvocation $inv, FilterQueryBuilder $qb): void + public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $invocation): void { - if (!($operand = $inv->filter->equationLeft) - || !$op = SqlEquationOperator::match($inv->filter->equationOperator)) + if (!($operand = $invocation->filter->equationLeft) + || !$op = SqlEquationOperator::match($invocation->filter->equationOperator)) { throw new FilterException('Invalid filter configuration.'); } - $filter = new SimpleEquationFilterType(); - $resolver = new OptionsResolver(); - $filter->configureOptions($resolver); - $options = $resolver->resolve([ + $builder->add(SimpleEquationFilterType::class, [ 'operand_left' => $operand, 'operator' => $op, - 'operand_right' => $inv->filter->equationRight, + 'operand_right' => $invocation->filter->equationRight, ]); - - $filter->buildQuery($qb, $options); } #[AsFilterCallback(self::TYPE, 'fields.equationLeft.options')] @@ -66,8 +60,8 @@ public static function define( ?string $equationLeft = null, ?SqlEquationOperator $equationOperator = null, mixed $equationRight = null, - ): FilterDefinition { - $definition = new FilterDefinition( + ): ConfiguredFilter { + $definition = new ConfiguredFilter( type: static::TYPE, intrinsic: true, ); @@ -82,4 +76,4 @@ public static function define( return $definition; } -} \ No newline at end of file +} diff --git a/src/FilterType/AbstractFilterType.php b/src/FilterType/AbstractFilterType.php deleted file mode 100644 index 96293920..00000000 --- a/src/FilterType/AbstractFilterType.php +++ /dev/null @@ -1,25 +0,0 @@ - $options - */ - public function buildQuery(FilterQueryBuilder $builder, array $options): void; -} \ No newline at end of file diff --git a/src/FilterType/SimpleEquationFilterType.php b/src/FilterType/SimpleEquationFilterType.php deleted file mode 100644 index a858a492..00000000 --- a/src/FilterType/SimpleEquationFilterType.php +++ /dev/null @@ -1,77 +0,0 @@ -define('operand_left') - ->info('The left operand of the equation filter') - ->required() - ->allowedTypes('string') - ; - - $resolver->define('operator') - ->info('The operator of the equation filter.') - ->required() - ->allowedTypes(SqlEquationOperator::class, 'string') - ->allowedValues(static fn (SqlEquationOperator|string $value): bool => (bool) SqlEquationOperator::match($value)) - ->normalize(static fn (Options $resolver, SqlEquationOperator|string $value): ?SqlEquationOperator => SqlEquationOperator::match($value)) - ; - - $resolver->define('operand_right') - ->info('The right operand of the equation filter (optional for unary operators).') - ->allowedTypes('string', 'int', 'null') - ->default('') - ; - } - - /** - * @throws FilterException - */ - public function buildQuery(FilterQueryBuilder $builder, array $options): void - { - $operandLeft = $options['operand_left']; - $operator = SqlEquationOperator::match($options['operator']); - - if (!$operandLeft || !$operator instanceof SqlEquationOperator) { - throw new FilterException('Invalid filter configuration.'); - } - - $operandLeft = $builder->column($operandLeft); - - $where = match ($operator) { - SqlEquationOperator::EQUALS => $builder->expr()->eq($operandLeft, ':eq_right'), - SqlEquationOperator::NOT_EQUALS => $builder->expr()->neq($operandLeft, ':eq_right'), - SqlEquationOperator::GREATER_THAN => $builder->expr()->gt($operandLeft, ':eq_right'), - SqlEquationOperator::GREATER_THAN_EQUALS => $builder->expr()->gte($operandLeft, ':eq_right'), - SqlEquationOperator::LESS_THAN => $builder->expr()->lt($operandLeft, ':eq_right'), - SqlEquationOperator::LESS_THAN_EQUALS => $builder->expr()->lte($operandLeft, ':eq_right'), - SqlEquationOperator::LIKE => $builder->expr()->like($operandLeft, ':eq_right'), - SqlEquationOperator::NOT_LIKE => $builder->expr()->notLike($operandLeft, ':eq_right'), - SqlEquationOperator::IS_NULL => $builder->expr()->isNull($operandLeft), - SqlEquationOperator::IS_NOT_NULL => $builder->expr()->isNotNull($operandLeft), - default => null, - }; - - if (!$where) { - throw new FilterException('Invalid filter configuration: Operator not supported.'); - } - - $builder->where($where); - - if (!$operator->isUnary()) { - $operandRight = $options['operand_right']; - $builder->setParameter(':eq_right', $operandRight); - } - } -} \ No newline at end of file diff --git a/src/Form/Factory/FilterFormFactory.php b/src/Form/Factory/FilterFormFactory.php index af1965de..d70b430d 100644 --- a/src/Form/Factory/FilterFormFactory.php +++ b/src/Form/Factory/FilterFormFactory.php @@ -5,14 +5,14 @@ namespace HeimrichHannot\FlareBundle\Form\Factory; use Contao\PageModel; -use HeimrichHannot\FlareBundle\Contract\FilterElement\FormTypeOptionsContract; +use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; use HeimrichHannot\FlareBundle\Engine\Context\Interface\FormContextInterface; -use HeimrichHannot\FlareBundle\Event\FilterElementFormTypeOptionsEvent; use HeimrichHannot\FlareBundle\Event\FilterFormBuildEvent; -use HeimrichHannot\FlareBundle\Event\FilterFormChildOptionsEvent; use HeimrichHannot\FlareBundle\Exception\FlareException; +use HeimrichHannot\FlareBundle\FilterElement\FilterElementContext; +use HeimrichHannot\FlareBundle\FilterElement\FilterElementInterface; +use HeimrichHannot\FlareBundle\Form\FilterFormBuilder; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; -use HeimrichHannot\FlareBundle\Specification\FilterDefinition; use HeimrichHannot\FlareBundle\Specification\ListSpecification; use Symfony\Component\Form\Extension\Core\Type\FormType; use Symfony\Component\Form\FormFactoryInterface; @@ -33,6 +33,10 @@ public function __construct( */ public function create(ListSpecification $list, FormContextInterface $context): FormInterface { + if (!$context instanceof ContextInterface) { + throw new FlareException('Filter form context must implement ContextInterface.', method: __METHOD__); + } + $name = $context->getFormName(); $filters = $list->getFilters(); @@ -50,34 +54,32 @@ public function create(ListSpecification $list, FormContextInterface $context): } $builder = $this->formFactory->createNamedBuilder($name, FormType::class, null, $formOptions); + $filterFormBuilder = new FilterFormBuilder( + rootBuilder: $builder, + choicesBuilderFactory: $this->choicesBuilderFactory, + eventDispatcher: $this->eventDispatcher, + ); - foreach ($filters->getIterator() as $filterDefinition) - // Apply only non-intrinsic, published filters with a valid type + foreach ($filters->getIterator() as $configuredFilter) { - if (!$filterDefinition->getType() || $filterDefinition->isIntrinsic()) { + if (!$configuredFilter->getElementType()) { continue; } - if (!$formType = $this->filterElementRegistry->get($filterDefinition->getType())?->getFormType()) { + if (!$descriptor = $this->filterElementRegistry->get($configuredFilter->getElementType())) { continue; } - $options = $this->resolveFieldOptions($list, $filterDefinition); - - $childName = $filterDefinition->getAlias(); - - /** @var FilterFormChildOptionsEvent $childOptionsEvent */ - $childOptionsEvent = $this->eventDispatcher->dispatch(new FilterFormChildOptionsEvent( - listSpecification: $list, - filterDefinition: $filterDefinition, - parentFormName: $name, - formName: $childName, - options: $options, - )); - - $options = $childOptionsEvent->options; + $element = $descriptor->getService(); - $builder->add($childName, $formType, $options); + if ($element instanceof FilterElementInterface) { + $element->buildForm($filterFormBuilder, new FilterElementContext( + list: $list, + filter: $configuredFilter, + engineContext: $context, + descriptor: $descriptor, + )); + } } /* @@ -103,53 +105,6 @@ public function create(ListSpecification $list, FormContextInterface $context): return $builder->getForm(); } - /** - * @throws FlareException If form type options could not be retrieved from the filter element. - */ - private function resolveFieldOptions( - ListSpecification $list, - FilterDefinition $filter, - ): array { - $choicesBuilder = $this->choicesBuilderFactory->createChoicesBuilder(); - - $formTypeOptionsEvent = new FilterElementFormTypeOptionsEvent( - choicesBuilder: $choicesBuilder, - list: $list, - filter: $filter, - options: [], - ); - - $filterElement = $this->filterElementRegistry->get($filter->getType())?->getService(); - if ($filterElement instanceof FormTypeOptionsContract) - { - $filterElement->handleFormTypeOptions($formTypeOptionsEvent); - } - - /** @var FilterElementFormTypeOptionsEvent $formTypeOptionsEvent */ - $formTypeOptionsEvent = $this->eventDispatcher->dispatch($formTypeOptionsEvent); - - $choicesBuilder = $formTypeOptionsEvent->choicesBuilder; - if ($choicesBuilder->isEnabled()) - { - $choicesOptions = [ - 'choices' => $choicesBuilder->buildChoices(), - 'choice_label' => $choicesBuilder->buildChoiceLabelCallback(), - 'choice_value' => $choicesBuilder->buildChoiceValueCallback(), - ]; - } - - $defaultOptions = [ - 'inherit_data' => false, - 'label' => false, - ]; - - return \array_merge( - $defaultOptions, - $choicesOptions ?? [], - $formTypeOptionsEvent->options, - ); - } - private function resolveFormAction(FormContextInterface $config): ?string { if (!$jumpTo = $config->getFormActionPage()) { @@ -162,4 +117,4 @@ private function resolveFormAction(FormContextInterface $config): ?string return $pageModel->getAbsoluteUrl(); } -} \ No newline at end of file +} diff --git a/src/Form/FilterFormBuilder.php b/src/Form/FilterFormBuilder.php new file mode 100644 index 00000000..93f5404e --- /dev/null +++ b/src/Form/FilterFormBuilder.php @@ -0,0 +1,94 @@ +filter; + $formType ??= $context->descriptor->getFormType(); + + if (!$formType) { + return $this; + } + + $childName = $filter->getAlias(); + if (!$childName) { + throw new FlareException(message: 'Non-intrinsic filter must provide a form field name.'); + } + + $choicesBuilder = $this->choicesBuilderFactory->createChoicesBuilder(); + + $formTypeOptionsEvent = new FilterElementFormTypeOptionsEvent( + choicesBuilder: $choicesBuilder, + list: $context->list, + filter: $filter, + options: $options, + ); + + $element = $context->descriptor->getService(); + if ($element instanceof FormTypeOptionsContract) { + $element->handleFormTypeOptions($formTypeOptionsEvent); + } + + /** @var FilterElementFormTypeOptionsEvent $formTypeOptionsEvent */ + $formTypeOptionsEvent = $this->eventDispatcher->dispatch($formTypeOptionsEvent); + + $choicesBuilder = $formTypeOptionsEvent->choicesBuilder; + if ($choicesBuilder->isEnabled()) { + $choicesOptions = [ + 'choices' => $choicesBuilder->buildChoices(), + 'choice_label' => $choicesBuilder->buildChoiceLabelCallback(), + 'choice_value' => $choicesBuilder->buildChoiceValueCallback(), + ]; + } + + $resolvedOptions = \array_merge( + [ + 'inherit_data' => false, + 'label' => false, + ], + $choicesOptions ?? [], + $formTypeOptionsEvent->options, + ); + + /** @var FilterFormChildOptionsEvent $childOptionsEvent */ + $childOptionsEvent = $this->eventDispatcher->dispatch(new FilterFormChildOptionsEvent( + listSpecification: $context->list, + configuredFilter: $filter, + parentFormName: $this->rootBuilder->getName(), + formName: $childName, + options: $resolvedOptions, + )); + + $this->rootBuilder->add($childName, $formType, $childOptionsEvent->options); + + return $this; + } + + public function getRootBuilder(): FormBuilderInterface + { + return $this->rootBuilder; + } +} diff --git a/src/Form/FilterFormBuilderInterface.php b/src/Form/FilterFormBuilderInterface.php new file mode 100644 index 00000000..8f06810b --- /dev/null +++ b/src/Form/FilterFormBuilderInterface.php @@ -0,0 +1,15 @@ + Fill Registries ### $container->addCompilerPass(new DependencyInjection\Compiler\RegisterFlareCallbacksPass()); - $container->addCompilerPass(new DependencyInjection\Compiler\RegisterFilterInvokersPass()); - // RegisterFilterInvokersPass MUST be added before RegisterFilterElementsPass $container->addCompilerPass(new DependencyInjection\Compiler\RegisterFilterElementsPass()); $container->addCompilerPass(new DependencyInjection\Compiler\RegisterListTypesPass()); ###< Fill Registries ### diff --git a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceElement.php b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceElement.php index 35d1213d..ab57cd89 100644 --- a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceElement.php +++ b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceElement.php @@ -5,21 +5,20 @@ namespace HeimrichHannot\FlareBundle\Integration\CodefogTags\FilterElement; use Contao\StringUtil; -use Doctrine\DBAL\ArrayParameterType; -use Doctrine\DBAL\ParameterType; use HeimrichHannot\FlareBundle\Contract\FilterElement\HydrateFormContract; use HeimrichHannot\FlareBundle\Contract\FilterElement\IntrinsicValueContract; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterCallback; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Event\FilterElementFormTypeOptionsEvent; +use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterInvocation; +use HeimrichHannot\FlareBundle\Filter\Type\IntegerIdChoiceFilterType; use HeimrichHannot\FlareBundle\FilterElement\AbstractFilterElement; use HeimrichHannot\FlareBundle\Integration\CodefogTags\Registry\CfgTagsJoinsRegistry; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Query\Factory\ListExecutionContextFactory; -use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; use HeimrichHannot\FlareBundle\Query\ListExecutionContext; -use HeimrichHannot\FlareBundle\Specification\FilterDefinition; +use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; use HeimrichHannot\FlareBundle\Specification\ListSpecification; use Psr\Log\LoggerInterface; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; @@ -41,25 +40,24 @@ public function __construct( private readonly LoggerInterface $logger, ) {} - public function __invoke(FilterInvocation $inv, FilterQueryBuilder $qb): void + public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $invocation): void { /** @var ?array $tagIds */ - $tagIds = $inv->getValue(); - if (!$tagIds) { - return; - } + $tagIds = $invocation->filter->isIntrinsic() + ? $this->getIntrinsicValue($invocation->list, $invocation->filter) + : $this->processRuntimeValue($invocation->getValue(), $invocation->list, $invocation->filter); - if (\count($tagIds) === 1) { - $qb->where($qb->expr()->eq($qb->column('id'), ':cfg_tag_id')) - ->setParameter('cfg_tag_id', \reset($tagIds), ParameterType::INTEGER); + if (!$tagIds) { return; } - $qb->where($qb->expr()->in($qb->column('id'), ':cfg_tag_ids')) - ->setParameter('cfg_tag_ids', $tagIds, ArrayParameterType::INTEGER); + $builder->add(IntegerIdChoiceFilterType::class, [ + 'field' => 'id', + 'ids' => $tagIds, + ]); } - public function hydrateForm(FormInterface $field, ListSpecification $list, FilterDefinition $filter): void + public function hydrateForm(FormInterface $field, ListSpecification $list, ConfiguredFilter $filter): void { if ($field->isSubmitted()) { return; @@ -101,14 +99,14 @@ private function normalizeValueArray(array $values): array return \array_values(\array_unique(\array_filter(\array_map('\intval', $values)))); } - public function getIntrinsicValue(ListSpecification $list, FilterDefinition $filter): ?array + public function getIntrinsicValue(ListSpecification $list, ConfiguredFilter $filter): ?array { return $this->normalizeValueArray( StringUtil::deserialize($filter->preselect ?: null, true) ) ?: null; } - public function processRuntimeValue(mixed $value, ListSpecification $list, FilterDefinition $filter): ?array + public function processRuntimeValue(mixed $value, ListSpecification $list, ConfiguredFilter $filter): ?array { if (!$value = StringUtil::deserialize($value)) { return null; @@ -157,7 +155,7 @@ public function handleFormTypeOptions(FilterElementFormTypeOptionsEvent $event): } #[AsFilterCallback(self::TYPE, 'fields.preselect.options')] - public function getOptions(ListSpecification $list, FilterDefinition $filter, ListExecutionContext $context): ?array + public function getOptions(ListSpecification $list, ConfiguredFilter $filter, ListExecutionContext $context): ?array { $targetAlias = $filter->getTargetAlias(); diff --git a/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php b/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php index c62925bf..1e9d2f3a 100644 --- a/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php +++ b/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php @@ -5,9 +5,7 @@ namespace HeimrichHannot\FlareBundle\Integration\CodefogTags\FilterElement; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; -use HeimrichHannot\FlareBundle\Filter\FilterInvocation; use HeimrichHannot\FlareBundle\FilterElement\AbstractFilterElement; -use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; use Symfony\Component\Form\Extension\Core\Type\SearchType; #[AsFilterElement( @@ -20,11 +18,6 @@ class CodefogTagsSearchElement extends AbstractFilterElement { public const TYPE = 'cfg_tags_search'; - public function __invoke(FilterInvocation $inv, FilterQueryBuilder $qb): void - { - // TODO: Implement __invoke() method. - } - public function isSupported(): bool { return false; diff --git a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php index ffd7f95f..5feded86 100644 --- a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php +++ b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php @@ -126,27 +126,27 @@ public function listViewFetchCountEvent(FetchCountEvent $event): void $dcMultilingualDisplay = $event->getContentContext()->getContentModel()->flare_dcMultilingualDisplay ?: $filters->getListModel()->dcMultilingual_display; - $filterDefinition = null; + $configuredFilter = null; if ($lang !== $langFallback && $dcMultilingualDisplay === DcMultilingualHelper::DISPLAY_LOCALIZED) // localized list view { - $filterDefinition = SimpleEquationElement::define( + $configuredFilter = SimpleEquationElement::define( equationLeft: DcMultilingualHelper::getPidColumn($table), equationOperator: SqlEquationOperator::GREATER_THAN, equationRight: '0' ); - $filterDefinition->forceTargetAlias('translation'); + $configuredFilter->forceTargetAlias('translation'); } - $filterDefinition ??= SimpleEquationElement::define( + $configuredFilter ??= SimpleEquationElement::define( equationLeft: DcMultilingualHelper::getPidColumn($table), equationOperator: SqlEquationOperator::EQUALS, equationRight: '0' ); // $filters->add($this->filterContextManager->definitionToContext( - // definition: $filterDefinition, + // filter: $configuredFilter, // listModel: $filters->getListModel(), // contentContext: $contentContext, // )); @@ -325,4 +325,4 @@ private function createQueryBuilder(string $table, string $language): QueryBuild DcMultilingualHelper::getTranslatableFields($table) )->buildQueryBuilderForFind($language); } -} \ No newline at end of file +} diff --git a/src/Query/Executor/FilterExecutor.php b/src/Query/Executor/FilterExecutor.php index d3eaa197..6f0a5fdd 100644 --- a/src/Query/Executor/FilterExecutor.php +++ b/src/Query/Executor/FilterExecutor.php @@ -4,19 +4,22 @@ namespace HeimrichHannot\FlareBundle\Query\Executor; -use HeimrichHannot\FlareBundle\Event\FilterElementInvokedEvent; -use HeimrichHannot\FlareBundle\Event\FilterElementInvokingEvent; +use HeimrichHannot\FlareBundle\Event\FilterElementBuiltEvent; +use HeimrichHannot\FlareBundle\Event\FilterElementBuildingEvent; use HeimrichHannot\FlareBundle\Exception\AbortFilteringException; use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Exception\FlareException; +use HeimrichHannot\FlareBundle\Filter\FilterBuilder; +use HeimrichHannot\FlareBundle\Filter\FilterCall; use HeimrichHannot\FlareBundle\Filter\FilterInvocation; -use HeimrichHannot\FlareBundle\Filter\Resolver\FilterInvokerResolver; +use HeimrichHannot\FlareBundle\FilterElement\FilterElementInterface; use HeimrichHannot\FlareBundle\Query\Factory\FilterQueryBuilderFactory; use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; use HeimrichHannot\FlareBundle\Query\ListQueryConfig; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; -use HeimrichHannot\FlareBundle\Specification\FilterDefinition; +use HeimrichHannot\FlareBundle\Registry\FilterTypeRegistry; +use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; use HeimrichHannot\FlareBundle\Util\Str; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -25,8 +28,8 @@ public function __construct( private EventDispatcherInterface $eventDispatcher, private FilterElementRegistry $filterElementRegistry, - private FilterInvokerResolver $filterInvoker, private FilterQueryBuilderFactory $filterQueryBuilderFactory, + private FilterTypeRegistry $filterTypeRegistry, ) {} /** @@ -45,7 +48,7 @@ public function invokeFilters(ListQueryConfig $options): array /** * @var int|string $key - * @var FilterDefinition $filter + * @var ConfiguredFilter $filter */ foreach ($list->getFilters()->all() as $key => $filter) { @@ -56,11 +59,11 @@ public function invokeFilters(ListQueryConfig $options): array value: $options->filterValues[$key] ?? null, ); - if (!$filterQueryBuilder = $this->invokeFilter($invocation)) { + if (!$builders = $this->invokeFilter($invocation)) { continue; } - $filterQueryBuilders[] = $filterQueryBuilder; + \array_push($filterQueryBuilders, ...$builders); } return $filterQueryBuilders; @@ -71,7 +74,10 @@ public function invokeFilters(ListQueryConfig $options): array * @throws FilterException * @throws FlareException */ - public function invokeFilter(FilterInvocation $invocation): ?FilterQueryBuilder + /** + * @return FilterQueryBuilder[] + */ + public function invokeFilter(FilterInvocation $invocation): array { if (!Str::isValidSqlName($table = $invocation->list->dc)) { @@ -84,40 +90,36 @@ public function invokeFilter(FilterInvocation $invocation): ?FilterQueryBuilder $filter = $invocation->filter; $context = $invocation->context; - if (!$filterElementDescriptor = $this->filterElementRegistry->get($filter->getType())) { - return null; - } - - if (!$invoker = $this->filterInvoker->get( - filterType: $filter->getType(), - contextType: $context::getContextType() - )) { - return null; + if (!$filterElementDescriptor = $this->filterElementRegistry->get($filter->getElementType())) { + return []; } - $event = $this->eventDispatcher->dispatch(new FilterElementInvokingEvent( - invocation: $invocation, - context: $context, - invoker: $invoker, - shouldInvoke: true, - )); - - if (!$event->shouldInvoke()) { - return null; + $filterElement = $filterElementDescriptor->getService(); + if (!$filterElement instanceof FilterElementInterface) { + return []; } - $invoker = $event->getInvoker(); - $targetAlias = TableAliasRegistry::ALIAS_MAIN; if ($filterElementDescriptor->isTargeted() || $filter->isTargetingForced()) { $targetAlias = $filter->getTargetAlias() ?: TableAliasRegistry::ALIAS_MAIN; } - $filterQueryBuilder = $this->filterQueryBuilderFactory->create($targetAlias); + $builder = new FilterBuilder($this->filterTypeRegistry, $targetAlias); + + $event = $this->eventDispatcher->dispatch(new FilterElementBuildingEvent( + invocation: $invocation, + context: $context, + builder: $builder, + shouldBuild: true, + )); + + if (!$event->shouldBuild()) { + return []; + } try { - $invoker($invocation, $filterQueryBuilder); + $filterElement->buildFilter($builder, $invocation); } catch (AbortFilteringException $e) { @@ -125,21 +127,56 @@ public function invokeFilter(FilterInvocation $invocation): ?FilterQueryBuilder } catch (FilterException $e) { - throw $this->createCallbackException($e, $filter, $invoker); + throw $this->createCallbackException($e, $filter, $filterElement); } catch (\Throwable $e) { throw new FilterException($e->getMessage(), code: $e->getCode(), previous: $e, method: __METHOD__); } - $this->eventDispatcher->dispatch(new FilterElementInvokedEvent($invocation, $filterQueryBuilder)); + $this->eventDispatcher->dispatch(new FilterElementBuiltEvent($invocation, $builder)); + + return $this->buildQueryBuilders($builder->all(), $filter, $filterElement); + } + + /** + * @param FilterCall[] $calls + * @return FilterQueryBuilder[] + */ + private function buildQueryBuilders(array $calls, ConfiguredFilter $filter, object $filterElement): array + { + $filterQueryBuilders = []; + + foreach ($calls as $call) + { + $filterQueryBuilder = $this->filterQueryBuilderFactory->create($call->targetAlias); + + try + { + $call->type->buildQuery($filterQueryBuilder, $call->options); + } + catch (AbortFilteringException $e) + { + throw $e; + } + catch (FilterException $e) + { + throw $this->createCallbackException($e, $filter, $call->type); + } + catch (\Throwable $e) + { + throw new FilterException($e->getMessage(), code: $e->getCode(), previous: $e, method: $filterElement::class); + } - return $filterQueryBuilder; + $filterQueryBuilders[] = $filterQueryBuilder; + } + + return $filterQueryBuilders; } private function createCallbackException( FilterException $e, - FilterDefinition $filter, + ConfiguredFilter $filter, mixed $callback ): FilterException { if (!$errorMethod = $e->getMethod()) @@ -177,7 +214,7 @@ private function createCallbackException( return new FilterException( \sprintf('[FLARE] Query denied: %s / Callback: %s', $e->getMessage(), $errorMethod), code: $e->getCode(), previous: $e, method: $errorMethod, - source: \sprintf('tl_flare_filter.id=%s', $filter->getDataSource()->getFilterIdentifier() ?: '0'), + source: \sprintf('tl_flare_filter.id=%s', $filter->getDataSource()?->getFilterIdentifier() ?: '0'), ); } } \ No newline at end of file diff --git a/src/Registry/FilterInvokerRegistry.php b/src/Registry/FilterInvokerRegistry.php deleted file mode 100644 index e231ce6c..00000000 --- a/src/Registry/FilterInvokerRegistry.php +++ /dev/null @@ -1,35 +0,0 @@ -invokers[$filterType][$context ?? 'default'][$priority][] = [ - 'serviceId' => $serviceId, - 'method' => $method - ]; - } - - public function find(string $filterType, string $context): ?array - { - $invokers = $this->invokers[$filterType][$context] ?? null; - - if ($invokers === null && $context !== 'default') { - $invokers = $this->invokers[$filterType]['default'] ?? null; - } - - if ($invokers === null) { - return null; - } - - \krsort($invokers); - - return \current($invokers)[0] ?? null; - } -} \ No newline at end of file diff --git a/src/Registry/FilterTypeRegistry.php b/src/Registry/FilterTypeRegistry.php new file mode 100644 index 00000000..2b04ac2a --- /dev/null +++ b/src/Registry/FilterTypeRegistry.php @@ -0,0 +1,54 @@ +, FilterTypeInterface> + */ + private array $types; + + public function __construct( + #[TaggedIterator(FilterTypeInterface::TAG)] + private readonly iterable $filterTypes, + ) {} + + /** + * @param class-string $class + */ + public function get(string $class): ?FilterTypeInterface + { + return $this->resolve()[$class] ?? null; + } + + /** + * @return array, FilterTypeInterface> + */ + public function all(): array + { + return $this->resolve(); + } + + private function resolve(): array + { + if (!isset($this->types)) { + $this->types = []; + + foreach ($this->filterTypes as $filterType) { + if (!$filterType instanceof FilterTypeInterface) { + continue; + } + + $this->types[$filterType::class] = $filterType; + } + } + + return $this->types; + } +} \ No newline at end of file diff --git a/src/Specification/FilterDefinition.php b/src/Specification/ConfiguredFilter.php similarity index 74% rename from src/Specification/FilterDefinition.php rename to src/Specification/ConfiguredFilter.php index 6aa984e2..8fd47847 100644 --- a/src/Specification/FilterDefinition.php +++ b/src/Specification/ConfiguredFilter.php @@ -8,37 +8,66 @@ use HeimrichHannot\FlareBundle\Specification\DataSource\FilterDataSourceInterface; /** + * In-memory filter configuration used by the engine. + * + * The Contao row remains the storage format. This object keeps the stable runtime identity + * separate from the raw DCA configuration that filter elements interpret. + * * @property string $type + * @property string $elementType * @property bool $intrinsic */ #[\AllowDynamicProperties] -class FilterDefinition +class ConfiguredFilter { use DocumentsFilterModelTrait; use DynamicPropertiesTrait; + private string $elementType; + public function __construct( - private string $type, + string $type, private bool $intrinsic, private ?string $alias = null, private ?string $targetAlias = null, private bool $isTargetingForced = false, private ?FilterDataSourceInterface $dataSource = null, + array $rawData = [], ) { + $this->elementType = $type; + if (!\is_null($alias)) { $this->setAlias($alias); } + + $this->setProperties($rawData); + } + + public function getElementType(): string + { + return $this->elementType; + } + + public function setElementType(string $elementType): static + { + $this->elementType = $elementType; + return $this; } + /** + * @deprecated Use getElementType(). + */ public function getType(): string { - return $this->type; + return $this->getElementType(); } + /** + * @deprecated Use setElementType(). + */ public function setType(string $type): static { - $this->type = $type; - return $this; + return $this->setElementType($type); } public function getAlias(): ?string @@ -113,8 +142,8 @@ public function forceTargetAlias(string $targetAlias): static public function __isset(string $name): bool { return match ($name) { - 'type', 'intrinsic' => true, - 'alias', 'targetAlias', 'target_alias', 'sourceFilterModel' => $this->__get($name) !== null, + 'type', 'elementType', 'intrinsic' => true, + 'alias', 'targetAlias', 'target_alias', 'dataSource', 'sourceFilterModel' => $this->__get($name) !== null, default => $this->issetProperty($name), }; } @@ -122,7 +151,7 @@ public function __isset(string $name): bool public function __set(string $name, mixed $value): void { match ($name) { - 'type' => $this->setType($value), + 'type', 'elementType' => $this->setElementType($value), 'intrinsic' => $this->setIntrinsic($value), 'targetAlias', 'target_alias' => $this->setTargetAlias($value), 'dataSource', 'sourceFilterModel' => $this->setDataSource($value), @@ -133,7 +162,7 @@ public function __set(string $name, mixed $value): void public function __get(string $name): mixed { return match ($name) { - 'type' => $this->getType(), + 'type', 'elementType' => $this->getElementType(), 'intrinsic' => $this->isIntrinsic(), 'targetAlias', 'target_alias' => $this->getTargetAlias(), 'dataSource', 'sourceFilterModel' => $this->getDataSource(), @@ -141,10 +170,16 @@ public function __get(string $name): mixed }; } + public function getRawData(): array + { + return $this->getProperties(); + } + public function getRow(): array { return \array_merge($this->getProperties(), [ - 'type' => $this->type, + 'type' => $this->elementType, + 'elementType' => $this->elementType, 'intrinsic' => $this->intrinsic, 'targetAlias' => $this->targetAlias, ]); diff --git a/src/Specification/Factory/FilterDefinitionFactory.php b/src/Specification/Factory/ConfiguredFilterFactory.php similarity index 61% rename from src/Specification/Factory/FilterDefinitionFactory.php rename to src/Specification/Factory/ConfiguredFilterFactory.php index e4dca038..4e70abfa 100644 --- a/src/Specification/Factory/FilterDefinitionFactory.php +++ b/src/Specification/Factory/ConfiguredFilterFactory.php @@ -4,31 +4,30 @@ namespace HeimrichHannot\FlareBundle\Specification\Factory; -use HeimrichHannot\FlareBundle\Event\FilterDefinitionCreatedEvent; +use HeimrichHannot\FlareBundle\Event\ConfiguredFilterCreatedEvent; +use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; use HeimrichHannot\FlareBundle\Specification\DataSource\FilterDataSourceInterface; -use HeimrichHannot\FlareBundle\Specification\FilterDefinition; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; -final readonly class FilterDefinitionFactory +final readonly class ConfiguredFilterFactory { public function __construct( private EventDispatcherInterface $eventDispatcher, ) {} - public function create(FilterDataSourceInterface $dataSource): FilterDefinition + public function create(FilterDataSourceInterface $dataSource): ConfiguredFilter { - $definition = new FilterDefinition( + $filter = new ConfiguredFilter( type: $dataSource->getFilterType(), intrinsic: $dataSource->isFilterIntrinsic(), alias: $dataSource->getFilterFormName(), targetAlias: $dataSource->getFilterTargetAlias(), dataSource: $dataSource, + rawData: $dataSource->getFilterData(), ); - $definition->setProperties($dataSource->getFilterData()); + $event = $this->eventDispatcher->dispatch(new ConfiguredFilterCreatedEvent($filter)); - $event = $this->eventDispatcher->dispatch(new FilterDefinitionCreatedEvent($definition)); - - return $event->filterDefinition; + return $event->configuredFilter; } } \ No newline at end of file diff --git a/src/Specification/Factory/ListSpecificationFactory.php b/src/Specification/Factory/ListSpecificationFactory.php index 1ab56217..d501c0a4 100644 --- a/src/Specification/Factory/ListSpecificationFactory.php +++ b/src/Specification/Factory/ListSpecificationFactory.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\Specification\Factory; -use HeimrichHannot\FlareBundle\Collection\FilterDefinitionCollection; +use HeimrichHannot\FlareBundle\Collection\ConfiguredFilterCollection; use HeimrichHannot\FlareBundle\Event\ListSpecificationCreatedEvent; use HeimrichHannot\FlareBundle\Registry\FilterCollectorRegistry; use HeimrichHannot\FlareBundle\Specification\DataSource\ListDataSourceInterface; @@ -41,14 +41,14 @@ public function create(ListDataSourceInterface $dataSource): ListSpecification return $event->listSpecification; } - private function collectFilters(ListDataSourceInterface $dataSource): FilterDefinitionCollection + private function collectFilters(ListDataSourceInterface $dataSource): ConfiguredFilterCollection { $collector = $this->filterCollectors->match($dataSource); if (!$collector) { - return new FilterDefinitionCollection(); + return new ConfiguredFilterCollection(); } - return $collector->collect($dataSource) ?? new FilterDefinitionCollection(); + return $collector->collect($dataSource) ?? new ConfiguredFilterCollection(); } } \ No newline at end of file diff --git a/src/Specification/ListSpecification.php b/src/Specification/ListSpecification.php index ae0b06b9..29f1aecb 100644 --- a/src/Specification/ListSpecification.php +++ b/src/Specification/ListSpecification.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\Specification; -use HeimrichHannot\FlareBundle\Collection\FilterDefinitionCollection; +use HeimrichHannot\FlareBundle\Collection\ConfiguredFilterCollection; use HeimrichHannot\FlareBundle\Model\DocumentsListModelTrait; use HeimrichHannot\FlareBundle\Specification\DataSource\ListDataSourceInterface; @@ -19,9 +19,9 @@ public function __construct( public readonly string $type, public readonly string $dc, private ?ListDataSourceInterface $dataSource = null, - private ?FilterDefinitionCollection $filters = null, + private ?ConfiguredFilterCollection $filters = null, ) { - $this->filters ??= new FilterDefinitionCollection(); + $this->filters ??= new ConfiguredFilterCollection(); } public function getDataSource(): ?ListDataSourceInterface @@ -35,12 +35,12 @@ public function setDataSource(?ListDataSourceInterface $dataSource): static return $this; } - public function getFilters(): FilterDefinitionCollection + public function getFilters(): ConfiguredFilterCollection { return $this->filters; } - public function setFilters(FilterDefinitionCollection $filters): void + public function setFilters(ConfiguredFilterCollection $filters): void { $this->filters = $filters; } diff --git a/tests/Filter/FilterBuilderTest.php b/tests/Filter/FilterBuilderTest.php new file mode 100644 index 00000000..757cfeba --- /dev/null +++ b/tests/Filter/FilterBuilderTest.php @@ -0,0 +1,94 @@ +get(TestFilterType::class)); + self::assertSame([TestFilterType::class => $type], $registry->all()); + self::assertNull($registry->get(UnknownFilterType::class)); + } + + public function testBuilderResolvesOptionsAndRecordsTargetedCalls(): void + { + $builder = new FilterBuilder( + new FilterTypeRegistry([new TestFilterType()]), + 'main', + ); + + $builder + ->add(TestFilterType::class, ['value' => 'first']) + ->add(TestFilterType::class, ['value' => 'second', 'enabled' => true], 'translation'); + + $calls = $builder->all(); + + self::assertCount(2, $calls); + self::assertSame('main', $calls[0]->targetAlias); + self::assertSame('first', $calls[0]->options['value']); + self::assertFalse($calls[0]->options['enabled']); + self::assertSame('translation', $calls[1]->targetAlias); + self::assertSame('second', $calls[1]->options['value']); + self::assertTrue($calls[1]->options['enabled']); + } + + public function testBuilderRejectsUnknownFilterTypes(): void + { + $builder = new FilterBuilder(new FilterTypeRegistry([]), 'main'); + + $this->expectException(FilterException::class); + $builder->add(TestFilterType::class, ['value' => 'test']); + } + + public function testBuilderLetsOptionsResolverValidateRequiredOptions(): void + { + $builder = new FilterBuilder( + new FilterTypeRegistry([new TestFilterType()]), + 'main', + ); + + $this->expectException(MissingOptionsException::class); + $builder->add(TestFilterType::class); + } + + public function testBuilderAbortThrowsAbortFilteringException(): void + { + $builder = new FilterBuilder(new FilterTypeRegistry([]), 'main'); + + $this->expectException(AbortFilteringException::class); + $builder->abort(); + } +} + +final class TestFilterType extends AbstractFilterType +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->define('value')->required()->allowedTypes('string'); + $resolver->define('enabled')->default(false)->allowedTypes('bool'); + } + + public function buildQuery(FilterQueryBuilder $builder, array $options): void + { + } +} + +final class UnknownFilterType extends AbstractFilterType +{ +} diff --git a/tests/FilterElement/AbstractFilterElementTest.php b/tests/FilterElement/AbstractFilterElementTest.php new file mode 100644 index 00000000..8b1695b1 --- /dev/null +++ b/tests/FilterElement/AbstractFilterElementTest.php @@ -0,0 +1,89 @@ +buildForm($builder, $this->createContext(new ConfiguredFilter( + type: 'test', + intrinsic: true, + alias: 'field', + ))); + + self::assertSame([], $builder->added); + } + + public function testNonIntrinsicFiltersAttachFormFields(): void + { + $element = new TestFilterElement(); + $builder = new RecordingFilterFormBuilder(); + $filter = new ConfiguredFilter( + type: 'test', + intrinsic: false, + alias: 'field', + ); + + $element->buildForm($builder, $this->createContext($filter)); + + self::assertSame([$filter], $builder->added); + } + + private function createContext(ConfiguredFilter $filter): FilterElementContext + { + return new FilterElementContext( + list: new ListSpecification('test_list', 'tl_test'), + filter: $filter, + engineContext: new TestContext(), + descriptor: new FilterElementDescriptor(new TestFilterElement(), formType: 'test_form'), + ); + } +} + +final class TestFilterElement extends AbstractFilterElement +{ +} + +final class RecordingFilterFormBuilder implements FilterFormBuilderInterface +{ + /** + * @var ConfiguredFilter[] + */ + public array $added = []; + + public function add(FilterElementContext $context, ?string $formType = null, array $options = []): static + { + $this->added[] = $context->filter; + + return $this; + } + + public function getRootBuilder(): FormBuilderInterface + { + throw new \LogicException('Not used in this test.'); + } +} + +final class TestContext implements ContextInterface +{ + public static function getContextType(): string + { + return 'test'; + } +} From 8d1cd50179e3016e6969d037b43c5e78b6b2038f Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Fri, 1 May 2026 20:04:00 +0200 Subject: [PATCH 06/71] refactor: remove unused FilterFactoryInterface and update mago.toml with assertion style configuration --- mago.toml | 1 + src/Filter/FilterFactoryInterface.php | 8 -------- 2 files changed, 1 insertion(+), 8 deletions(-) delete mode 100644 src/Filter/FilterFactoryInterface.php diff --git a/mago.toml b/mago.toml index c089d01c..37caf0cb 100644 --- a/mago.toml +++ b/mago.toml @@ -29,6 +29,7 @@ no-isset = { enabled = false } tagged-todo = { enabled = false } too-many-methods = { threshold = 20 } prefer-early-continue = { enabled = false } +assertion-style = { style = "self" } cyclomatic-complexity = { enabled = false } kan-defect = { enabled = false } diff --git a/src/Filter/FilterFactoryInterface.php b/src/Filter/FilterFactoryInterface.php deleted file mode 100644 index 02b39ede..00000000 --- a/src/Filter/FilterFactoryInterface.php +++ /dev/null @@ -1,8 +0,0 @@ - Date: Fri, 1 May 2026 20:06:53 +0200 Subject: [PATCH 07/71] refactor: update mago.toml by refining paths and fix missing newline in configuration files --- mago.toml | 2 +- src/DependencyInjection/Configuration.php | 2 +- src/DependencyInjection/HeimrichHannotFlareExtension.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mago.toml b/mago.toml index 37caf0cb..4dedb4e4 100644 --- a/mago.toml +++ b/mago.toml @@ -3,7 +3,7 @@ php-version = "8.2" [source] -paths = ["src/", "tests/"] +paths = ["src/"] includes = ["vendor"] excludes = [] diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 592ad2d7..fe68e4e6 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -59,4 +59,4 @@ public function getConfigTreeBuilder(): TreeBuilder return $treeBuilder; } -} +} \ No newline at end of file diff --git a/src/DependencyInjection/HeimrichHannotFlareExtension.php b/src/DependencyInjection/HeimrichHannotFlareExtension.php index 64e7b3e2..68c0192f 100644 --- a/src/DependencyInjection/HeimrichHannotFlareExtension.php +++ b/src/DependencyInjection/HeimrichHannotFlareExtension.php @@ -104,4 +104,4 @@ public function prepend(ContainerBuilder $container): void $loader = new YamlFileLoader($container, new FileLocator(\dirname(__DIR__) . '/../config')); $loader->load('config.yaml'); } -} +} \ No newline at end of file From bb30f1fa50433776fecb22a6b575e44afa49c886 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Fri, 1 May 2026 22:55:31 +0200 Subject: [PATCH 08/71] refactor: enhance type handling in AbstractFilterElement and DcaHelper for improved clarity --- src/FilterElement/AbstractFilterElement.php | 1 + src/Util/DcaHelper.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/FilterElement/AbstractFilterElement.php b/src/FilterElement/AbstractFilterElement.php index d39d7ce9..4e064246 100644 --- a/src/FilterElement/AbstractFilterElement.php +++ b/src/FilterElement/AbstractFilterElement.php @@ -16,6 +16,7 @@ use HeimrichHannot\FlareBundle\Form\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use Symfony\Component\Form\FormInterface; /** * @phpstan-template FormOptionsShape of array{ diff --git a/src/Util/DcaHelper.php b/src/Util/DcaHelper.php index c904955a..f3b8f679 100644 --- a/src/Util/DcaHelper.php +++ b/src/Util/DcaHelper.php @@ -151,7 +151,7 @@ public static function testSQLType(array|string|null $sql, string $expectedType) } if ($regex = static::getSqlTypeRegex($expectedType)) { - return (bool)\preg_match($regex, $sql); + return (bool) \preg_match($regex, $sql); } return false; From 6dd852982b4d08037af046131dfdc86132f43b17 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Mon, 13 Jul 2026 13:55:30 +0200 Subject: [PATCH 09/71] Chore: Add PHPStan ignore for Symfony Config template defaults --- src/DependencyInjection/Configuration.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index fe68e4e6..6ab12a70 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -14,6 +14,7 @@ public function getConfigTreeBuilder(): TreeBuilder $treeBuilder = new TreeBuilder('huh_flare'); $rootNode = $treeBuilder->getRootNode(); + // @phpstan-ignore class.notFound (PHPStan 1.x cannot parse symfony/config 7.4 template defaults) $rootNode ->children() ->arrayNode('format_label_defaults') From 99462b67644547c78433b65761d39645e8618128 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Mon, 13 Jul 2026 17:13:28 +0200 Subject: [PATCH 10/71] refactor!: element-owned filter architecture Filter elements now own their entire lifecycle: form building on native Symfony FormBuilderInterface sub-builders, config schema + DCA-row translation via ConfigContract (OptionsResolver), data-to-options transformation in buildFilter(builder, context, data), and backend DCA configuration via DcaContract::configureDca() on both tl_flare_filter and tl_flare_list. - Replace ConfiguredFilter/ConfiguredFilterCollection with immutable Filter DTO held as plain keyed array on ListSpecification - Remove FilterInvocation, FilterFormBuilder, Hydrate/FormData/ Intrinsic/RuntimeValue/FormTypeOptions contracts, and the entire AsFlareCallback/palette machinery (PaletteContract, PaletteEvent, callback registry, MethodInjector) - Add standalone filter channels: Filter::fromCallback()/fromType() and flare_make_filter() Twig function - Nest filter GET params as ?list[alias][value]=x (BC break) - Add unit tests for Filter, FilterConfigResolver, ListSpecification --- config/services.yaml | 7 +- src/Collection/AbstractCollection.php | 79 --- src/Collection/ConfiguredFilterCollection.php | 141 ---- src/Contract/Config/PaletteConfig.php | 73 --- src/Contract/DcaContract.php | 19 + src/Contract/FilterElement/ConfigContract.php | 31 + .../FilterElement/FormDataContract.php | 12 - .../FilterElement/FormTypeOptionsContract.php | 16 - .../FilterElement/HydrateFormContract.php | 14 - .../FilterElement/IntrinsicValueContract.php | 23 - .../FilterElement/RuntimeValueContract.php | 23 - src/Contract/PaletteContract.php | 12 - src/DataContainer/Builder/DcaBuilder.php | 110 ++++ src/DataContainer/Builder/DcaContext.php | 57 ++ src/DataContainer/Builder/DcaFieldBuilder.php | 136 ++++ src/DataContainer/FilterContainer.php | 110 +--- .../FlareCallbackContainerInterface.php | 21 - src/DataContainer/ListContainer.php | 100 +-- .../Attribute/AsFilterCallback.php | 8 - .../Attribute/AsFilterElement.php | 16 +- .../Attribute/AsFlareCallback.php | 27 - .../Attribute/AsListCallback.php | 8 - .../Attribute/AsListType.php | 4 +- .../Compiler/RegisterFilterElementsPass.php | 4 +- .../Compiler/RegisterFlareCallbacksPass.php | 85 --- .../Compiler/RegisterListTypesPass.php | 2 - .../HeimrichHannotFlareExtension.php | 26 +- ...tractPriorityServiceDescriptorRegistry.php | 134 ---- .../Registry/ServiceDescriptorInterface.php | 4 +- src/Engine/Loader/ValidationLoader.php | 4 +- src/Engine/Mod/SimpleEquationMod.php | 9 +- src/Engine/Projector/InteractiveProjector.php | 84 +-- src/Event/ConfiguredFilterCreatedEvent.php | 15 - src/Event/ElementDcaEvent.php | 22 + src/Event/FilterCollectedEvent.php | 21 + src/Event/FilterElementBuildingEvent.php | 29 +- src/Event/FilterElementBuiltEvent.php | 22 +- src/Event/FilterElementFormBuiltEvent.php | 45 ++ .../FilterElementFormTypeOptionsEvent.php | 20 - src/Event/FilterFormChildOptionsEvent.php | 20 - src/Event/PaletteEvent.php | 47 -- .../Contao/ElementDcaListener.php | 123 ++++ .../Contao/LoadDataContainerListener.php | 121 ---- .../AutoTypePalettesCallback.php | 158 ----- .../FieldsLoadAndSaveCallbacks.php | 4 +- .../NamedDispatch/ElementDcaEventListener.php | 26 + .../NamedDispatch/FilterElementListener.php | 27 +- .../NamedDispatch/FilterFormListener.php | 33 - .../NamedDispatch/PaletteListener.php | 41 -- src/Filter/Filter.php | 134 ++++ src/Filter/FilterBuilder.php | 17 +- src/Filter/FilterConfigResolver.php | 55 ++ src/Filter/FilterContext.php | 33 + src/Filter/FilterInvocation.php | 39 -- src/Filter/Type/AbstractFilterType.php | 18 +- src/Filter/Type/FilterTypeInterface.php | 4 +- .../FilterCollectorInterface.php | 9 +- .../ListModelFilterCollector.php | 50 +- src/FilterElement/AbstractFilterElement.php | 123 +--- src/FilterElement/ArchiveElement.php | 620 +++++++++--------- .../BelongsToRelationElement.php | 90 ++- src/FilterElement/BooleanElement.php | 163 +++-- src/FilterElement/CalendarCurrentElement.php | 226 +++++-- src/FilterElement/CallbackFilterElement.php | 39 ++ src/FilterElement/DateRangeElement.php | 101 ++- src/FilterElement/DcaSelectFieldElement.php | 327 ++++----- src/FilterElement/FieldValueChoiceElement.php | 317 ++++----- src/FilterElement/FilterElementContext.php | 20 - src/FilterElement/FilterElementInterface.php | 24 +- src/FilterElement/PublishedElement.php | 89 ++- src/FilterElement/SearchKeywordsElement.php | 99 +-- src/FilterElement/SimpleEquationElement.php | 89 ++- src/Form/Factory/FilterFormFactory.php | 62 +- src/Form/FilterFormBuilder.php | 94 --- src/Form/FilterFormBuilderInterface.php | 15 - src/HeimrichHannotFlareBundle.php | 1 - .../FilterCallback/TargetAliasCallback.php | 42 +- .../CodefogTagsChoiceElement.php | 237 ++++--- .../CodefogTagsSearchElement.php | 20 +- .../ListType/EventsListType.php | 27 +- .../EventListener/ContaoCommentsListener.php | 10 +- src/ListType/AbstractListType.php | 12 +- src/ListType/GenericDataContainerListType.php | 19 +- src/ListType/NewsListType.php | 18 +- src/Manager/FlareCallbackManager.php | 46 -- src/Model/FilterModel.php | 3 +- src/Query/Executor/FilterExecutor.php | 118 ++-- .../Descriptor/FilterElementDescriptor.php | 74 +-- .../Descriptor/FlareCallbackDescriptor.php | 78 --- .../Descriptor/ListTypeDescriptor.php | 28 +- src/Registry/FilterElementResolver.php | 48 ++ src/Registry/FilterTypeRegistry.php | 9 +- src/Registry/FlareCallbackRegistry.php | 23 - src/Specification/ConfiguredFilter.php | 198 ------ .../DataSource/FilterDataSourceInterface.php | 22 - .../Factory/ConfiguredFilterFactory.php | 33 - .../Factory/ListSpecificationFactory.php | 25 +- src/Specification/ListSpecification.php | 66 +- src/Twig/Extension/FlareExtension.php | 1 + src/Twig/Runtime/FlareRuntime.php | 19 + src/Util/CallbackHelper.php | 82 +-- src/Util/MethodInjector.php | 89 --- src/Util/Str.php | 12 + tests/Filter/FilterBuilderTest.php | 3 + .../AbstractFilterElementTest.php | 89 --- 105 files changed, 2703 insertions(+), 3779 deletions(-) delete mode 100644 src/Collection/AbstractCollection.php delete mode 100644 src/Collection/ConfiguredFilterCollection.php delete mode 100644 src/Contract/Config/PaletteConfig.php create mode 100644 src/Contract/DcaContract.php create mode 100644 src/Contract/FilterElement/ConfigContract.php delete mode 100644 src/Contract/FilterElement/FormDataContract.php delete mode 100644 src/Contract/FilterElement/FormTypeOptionsContract.php delete mode 100644 src/Contract/FilterElement/HydrateFormContract.php delete mode 100644 src/Contract/FilterElement/IntrinsicValueContract.php delete mode 100644 src/Contract/FilterElement/RuntimeValueContract.php delete mode 100644 src/Contract/PaletteContract.php create mode 100644 src/DataContainer/Builder/DcaBuilder.php create mode 100644 src/DataContainer/Builder/DcaContext.php create mode 100644 src/DataContainer/Builder/DcaFieldBuilder.php delete mode 100644 src/DataContainer/FlareCallbackContainerInterface.php delete mode 100644 src/DependencyInjection/Attribute/AsFilterCallback.php delete mode 100644 src/DependencyInjection/Attribute/AsFlareCallback.php delete mode 100644 src/DependencyInjection/Attribute/AsListCallback.php delete mode 100644 src/DependencyInjection/Compiler/RegisterFlareCallbacksPass.php delete mode 100644 src/DependencyInjection/Registry/AbstractPriorityServiceDescriptorRegistry.php delete mode 100644 src/Event/ConfiguredFilterCreatedEvent.php create mode 100644 src/Event/ElementDcaEvent.php create mode 100644 src/Event/FilterCollectedEvent.php create mode 100644 src/Event/FilterElementFormBuiltEvent.php delete mode 100644 src/Event/FilterElementFormTypeOptionsEvent.php delete mode 100644 src/Event/FilterFormChildOptionsEvent.php delete mode 100644 src/Event/PaletteEvent.php create mode 100644 src/EventListener/Contao/ElementDcaListener.php delete mode 100644 src/EventListener/Contao/LoadDataContainerListener.php delete mode 100644 src/EventListener/DataContainer/AutoTypePalettesCallback.php create mode 100644 src/EventListener/NamedDispatch/ElementDcaEventListener.php delete mode 100644 src/EventListener/NamedDispatch/FilterFormListener.php delete mode 100644 src/EventListener/NamedDispatch/PaletteListener.php create mode 100644 src/Filter/Filter.php create mode 100644 src/Filter/FilterConfigResolver.php create mode 100644 src/Filter/FilterContext.php delete mode 100644 src/Filter/FilterInvocation.php create mode 100644 src/FilterElement/CallbackFilterElement.php delete mode 100644 src/FilterElement/FilterElementContext.php delete mode 100644 src/Form/FilterFormBuilder.php delete mode 100644 src/Form/FilterFormBuilderInterface.php delete mode 100644 src/Manager/FlareCallbackManager.php delete mode 100644 src/Registry/Descriptor/FlareCallbackDescriptor.php create mode 100644 src/Registry/FilterElementResolver.php delete mode 100644 src/Registry/FlareCallbackRegistry.php delete mode 100644 src/Specification/ConfiguredFilter.php delete mode 100644 src/Specification/DataSource/FilterDataSourceInterface.php delete mode 100644 src/Specification/Factory/ConfiguredFilterFactory.php delete mode 100644 src/Util/MethodInjector.php delete mode 100644 tests/FilterElement/AbstractFilterElementTest.php diff --git a/config/services.yaml b/config/services.yaml index 9db2359b..39efe127 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -9,10 +9,15 @@ services: HeimrichHannot\FlareBundle\: resource: ../src exclude: - - ../src/{Collection,Contao,ContaoManager,Contract,DependencyInjection,Dto,Engine,Event,Integration,Model,Trait,Util} + - ../src/{Contao,ContaoManager,Contract,DependencyInjection,Dto,Engine,Event,Integration,Model,Trait,Util} - ../src/{Filter,Form,InferPtable,List,Paginator,Query,Sort,Specification}/*.php + - ../src/DataContainer/Builder + - ../src/FilterElement/CallbackFilterElement.php - ../src/Registry/Descriptor + # Manually registered because top-level src/Filter/*.php files are excluded above + HeimrichHannot\FlareBundle\Filter\FilterConfigResolver: ~ + HeimrichHannot\FlareBundle\Engine\: resource: ../src/Engine exclude: diff --git a/src/Collection/AbstractCollection.php b/src/Collection/AbstractCollection.php deleted file mode 100644 index 0309f49c..00000000 --- a/src/Collection/AbstractCollection.php +++ /dev/null @@ -1,79 +0,0 @@ -items) < 1; - } - - /** - * Get all items in the collection. - * - * @return array - */ - public function all(): array - { - return $this->items; - } - - /** - * Get the values of the collection as an array. - * - * @return array The values of the collection. - */ - public function values(): array - { - return \array_values($this->items); - } - - /** - * Retrieve an iterator for the items. - * - * @return \Traversable Iterator for the collection items. - */ - public function getIterator(): \Traversable - { - return new \ArrayIterator($this->items); - } - - /** - * Get the number of items in the collection. - * - * @return int The count of items. - */ - public function count(): int - { - return \count($this->items); - } -} \ No newline at end of file diff --git a/src/Collection/ConfiguredFilterCollection.php b/src/Collection/ConfiguredFilterCollection.php deleted file mode 100644 index 3f43fa17..00000000 --- a/src/Collection/ConfiguredFilterCollection.php +++ /dev/null @@ -1,141 +0,0 @@ - all() Get the items of the collection. - * @method array values() Get the values of the collection. - * @method \Traversable getIterator() Iterator for the collection items. - */ -class ConfiguredFilterCollection extends AbstractCollection -{ - public function __construct( - ?array $items = null, - ) { - $this->initItems($items ?? []); - } - - private function initItems(array $items): void - { - if (!$items) { - return; - } - - if (\array_is_list($items)) { - $this->add(...$items); - return; - } - - foreach ($items as $key => $filter) { - $this->items[(string) $key] = $filter; - } - } - - public function get(string $key): ?ConfiguredFilter - { - return $this->items[$key] ?? null; - } - - public function has(string $key): bool - { - return \array_key_exists($key, $this->items); - } - - public function hasType(string $type): bool - { - return \array_reduce( - $this->items, - static fn (bool $carry, ConfiguredFilter $filter): bool => $carry || $filter->getElementType() === $type, - false - ); - } - - public function add(ConfiguredFilter ...$item): static - { - foreach ($item as $filter) { - do { - $randomKey = '_generated_' . \bin2hex(\random_bytes(4)); - } while (\array_key_exists($randomKey, $this->items)); - - $this->items[$randomKey] = $filter; - } - - return $this; - } - - public function set(string $key, ConfiguredFilter $filter): void - { - $this->items[$key] = $filter; - } - - /** - * @param ConfiguredFilter|string $item The item to remove or its key. - */ - public function remove(ConfiguredFilter|string $item): bool - { - if (\is_string($item)) { - if (!\array_key_exists($item, $this->items)) { - return false; - } - unset($this->items[$item]); - return true; - } - - $beforeCount = \count($this->items); - - $filtered = \array_filter( - $this->items, - static fn (ConfiguredFilter $filter): bool => $filter !== $item - ); - - $this->items = $filtered; - - return \count($this->items) < $beforeCount; - } - - public function serialize(): string - { - return \serialize($this->items); - } - - public function unserialize(string $data): void - { - $unserialized = StringUtil::deserialize($data); - - if (!\is_array($unserialized)) { - throw new \UnexpectedValueException('Invalid data: expected an array.'); - } - - $this->items = []; - $this->initItems($unserialized); - } - - public function __serialize(): array - { - return $this->items; - } - - public function __unserialize(array $data): void - { - $this->items = []; - $this->initItems($data); - } - - public function __clone(): void - { - $this->items = \array_map(static fn (ConfiguredFilter $item): ConfiguredFilter => clone $item, $this->items); - } - - public function hash(): string - { - return \sha1(\serialize(\array_map( - static fn (ConfiguredFilter $filter): string => $filter->hash(), - $this->items - ))); - } -} diff --git a/src/Contract/Config/PaletteConfig.php b/src/Contract/Config/PaletteConfig.php deleted file mode 100644 index 74f74358..00000000 --- a/src/Contract/Config/PaletteConfig.php +++ /dev/null @@ -1,73 +0,0 @@ -getType(); - } - - public function getType(): string - { - return $this->type; - } - - public function getDataContainer(): DataContainer - { - return $this->dataContainer; - } - - public function getPrefix(): string - { - return $this->prefix; - } - - public function setPrefix(string $prefix): static - { - $this->prefix = $prefix; - - return $this; - } - - public function getSuffix(): string - { - return $this->suffix; - } - - public function setSuffix(string $suffix): static - { - $this->suffix = $suffix; - - return $this; - } - - public function getListModel(): ListModel - { - return $this->listModel; - } - - public function getFilterModel(): ?FilterModel - { - return $this->filterModel; - } -} \ No newline at end of file diff --git a/src/Contract/DcaContract.php b/src/Contract/DcaContract.php new file mode 100644 index 00000000..e9575b94 --- /dev/null +++ b/src/Contract/DcaContract.php @@ -0,0 +1,19 @@ + $row + * + * @return array + */ + public function configFromRow(array $row): array; +} diff --git a/src/Contract/FilterElement/FormDataContract.php b/src/Contract/FilterElement/FormDataContract.php deleted file mode 100644 index 68a42290..00000000 --- a/src/Contract/FilterElement/FormDataContract.php +++ /dev/null @@ -1,12 +0,0 @@ -getValue()` from the invoker methods. - */ - public function getIntrinsicValue(ListSpecification $list, ConfiguredFilter $filter): mixed; -} \ No newline at end of file diff --git a/src/Contract/FilterElement/RuntimeValueContract.php b/src/Contract/FilterElement/RuntimeValueContract.php deleted file mode 100644 index 0c080d43..00000000 --- a/src/Contract/FilterElement/RuntimeValueContract.php +++ /dev/null @@ -1,23 +0,0 @@ -getValue()`. - */ - public function processRuntimeValue(mixed $value, ListSpecification $list, ConfiguredFilter $filter): mixed; -} diff --git a/src/Contract/PaletteContract.php b/src/Contract/PaletteContract.php deleted file mode 100644 index 5df9aded..00000000 --- a/src/Contract/PaletteContract.php +++ /dev/null @@ -1,12 +0,0 @@ - + */ + private array $fields = []; + + /** + * Sets the element's palette part. It is merged between the table's + * `__prefix__` and `__suffix__` palettes. Pass null for no own fields. + */ + public function palette(?string $palette): self + { + $this->palette = $palette; + return $this; + } + + public function getPalette(): ?string + { + return $this->palette; + } + + /** + * Overrides the palette prefix for this type: a string replaces the table's `__prefix__`, + * a callable `fn(string $current): string` transforms it, null keeps it. + */ + public function prefix(string|callable|null $prefix): self + { + $this->prefix = \is_callable($prefix) ? $prefix(...) : $prefix; + return $this; + } + + /** + * Overrides the palette suffix for this type: a string replaces the table's `__suffix__`, + * a callable `fn(string $current): string` transforms it, null keeps it. + */ + public function suffix(string|callable|null $suffix): self + { + $this->suffix = \is_callable($suffix) ? $suffix(...) : $suffix; + return $this; + } + + /** + * Returns the (shared) field builder for per-type tweaks of a DCA field definition. + */ + public function field(string $name): DcaFieldBuilder + { + return $this->fields[$name] ??= new DcaFieldBuilder(); + } + + /** + * Writes the collected configuration into `$GLOBALS['TL_DCA'][$table]`. + * + * @internal Called by the FLARE loadDataContainer listener only. + */ + public function apply(string $table, string $type, bool $applyPalette = true): void + { + if (!isset($GLOBALS['TL_DCA'][$table])) { + return; + } + + $dca = &$GLOBALS['TL_DCA'][$table]; + + if ($applyPalette) + { + $prefix = self::resolveAffix($this->prefix, (string) ($dca['palettes']['__prefix__'] ?? '')); + $suffix = self::resolveAffix($this->suffix, (string) ($dca['palettes']['__suffix__'] ?? '')); + + $dca['palettes'][$type] = Str::mergePalettes($prefix, $this->palette, $suffix); + } + + foreach ($this->fields as $name => $field) + { + if (!\is_array($dca['fields'][$name] ?? null)) { + $dca['fields'][$name] = []; + } + + $field->applyTo($dca['fields'][$name]); + } + } + + private static function resolveAffix(string|\Closure|null $override, string $current): string + { + return match (true) { + $override instanceof \Closure => (string) $override($current), + \is_string($override) => $override, + default => $current, + }; + } +} diff --git a/src/DataContainer/Builder/DcaContext.php b/src/DataContainer/Builder/DcaContext.php new file mode 100644 index 00000000..7f393adf --- /dev/null +++ b/src/DataContainer/Builder/DcaContext.php @@ -0,0 +1,57 @@ +executionContext === null) { + $this->executionContext = ($this->executionContextFactory)() ?? false; + } + + return $this->executionContext ?: null; + } + + /** + * @return array Table names by alias. + */ + public function getTables(): array + { + return $this->getExecutionContext()?->tableAliasRegistry->getTables() ?? []; + } + + /** + * The table the filter's conditions target: the configured target alias' table, + * falling back to the list's data container. + */ + public function getTargetTable(): string + { + $targetAlias = (string) ($this->filterModel->targetAlias ?? ''); + + return $this->getExecutionContext()?->tableAliasRegistry->getTable($targetAlias) ?: $this->listModel->dc; + } +} diff --git a/src/DataContainer/Builder/DcaFieldBuilder.php b/src/DataContainer/Builder/DcaFieldBuilder.php new file mode 100644 index 00000000..dff545d3 --- /dev/null +++ b/src/DataContainer/Builder/DcaFieldBuilder.php @@ -0,0 +1,136 @@ + + */ + private array $load = []; + + /** + * @var list + */ + private array $save = []; + + public function inputType(string $inputType): self + { + $this->definition['inputType'] = $inputType; + return $this; + } + + /** + * Merges values into the field's `eval` configuration. + */ + public function eval(array $eval): self + { + $this->definition['eval'] = \array_merge($this->definition['eval'] ?? [], $eval); + return $this; + } + + /** + * Deep-merges arbitrary keys (reference, default, sql, ...) into the field definition. + */ + public function merge(array $definition): self + { + $this->definition = self::deepMerge($this->definition, $definition); + return $this; + } + + /** + * Static options array or an options provider `fn(?DataContainer): array`. + * + * @param callable(?DataContainer): array|array $options + */ + public function options(callable|array $options): self + { + $this->options = $options; + return $this; + } + + /** + * Adds a load transform `fn(mixed $value, ?DataContainer $dc): mixed`. + */ + public function load(callable $fn): self + { + $this->load[] = $fn; + return $this; + } + + /** + * Adds a save transform `fn(mixed $value, ?DataContainer $dc): mixed`. + */ + public function save(callable $fn): self + { + $this->save[] = $fn; + return $this; + } + + /** + * @internal Called by {@see DcaBuilder::apply()} only. + */ + public function applyTo(array &$definition): void + { + $definition = self::deepMerge($definition, $this->definition); + + if (\is_array($this->options)) + { + $definition['options'] = $this->options; + unset($definition['options_callback']); + } + elseif (\is_callable($this->options)) + { + $options = $this->options; + $definition['options_callback'] = static fn (?DataContainer $dc = null): array => $options($dc); + unset($definition['options']); + } + + foreach ($this->load as $load) + { + $definition['load_callback'] ??= []; + $definition['load_callback'][] = static fn (mixed $value, ?DataContainer $dc = null): mixed => $load($value, $dc); + } + + foreach ($this->save as $save) + { + $definition['save_callback'] ??= []; + $definition['save_callback'][] = static fn (mixed $value, ?DataContainer $dc = null): mixed => $save($value, $dc); + } + } + + private static function deepMerge(array $base, array $overlay): array + { + foreach ($overlay as $key => $value) + { + if (\is_int($key)) { + $base[] = $value; + continue; + } + + if (\is_array($value) && \is_array($base[$key] ?? null)) { + $base[$key] = self::deepMerge($base[$key], $value); + continue; + } + + $base[$key] = $value; + } + + return $base; + } +} diff --git a/src/DataContainer/FilterContainer.php b/src/DataContainer/FilterContainer.php index ea8be118..f0e6ece7 100644 --- a/src/DataContainer/FilterContainer.php +++ b/src/DataContainer/FilterContainer.php @@ -5,119 +5,13 @@ namespace HeimrichHannot\FlareBundle\DataContainer; use Contao\DataContainer; -use HeimrichHannot\FlareBundle\Exception\FlareException; -use HeimrichHannot\FlareBundle\Manager\FlareCallbackManager; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Model\ListModel; -use HeimrichHannot\FlareBundle\Query\Factory\ListExecutionContextFactory; -use HeimrichHannot\FlareBundle\Query\ListExecutionContext; -use HeimrichHannot\FlareBundle\Specification\Factory\ConfiguredFilterFactory; -use HeimrichHannot\FlareBundle\Specification\Factory\ListSpecificationFactory; -use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; -use HeimrichHannot\FlareBundle\Util\CallbackHelper; -class FilterContainer implements FlareCallbackContainerInterface +class FilterContainer { public const TABLE_NAME = 'tl_flare_filter'; - public function __construct( - private readonly ConfiguredFilterFactory $configuredFilterFactory, - private readonly FlareCallbackManager $callbacks, - private readonly ListExecutionContextFactory $listExecutionContextFactory, - private readonly ListSpecificationFactory $listSpecificationFactory, - ) {} - - /* ============================= * - * CALLBACK HANDLING * - * ============================= */ - // - - public function handleConfigOnLoad(?DataContainer $dc, string $target): void - { - [$filterModel, $listModel] = $this->getModelsFromDataContainer($dc); - - if (!$filterModel || !$listModel) { - return; - } - - $callbacks = $this->callbacks->getFilterCallbacks($filterModel->type, $target, lowPrioFirst: true); - - CallbackHelper::call($callbacks, [], [ - FilterModel::class => $filterModel, - ListModel::class => $listModel, - DataContainer::class => $dc, - ]); - } - - /** - * @throws \RuntimeException - * @throws FlareException - */ - public function handleFieldOptions(?DataContainer $dc, string $target): array - { - [$filterModel, $listModel] = $this->getModelsFromDataContainer($dc); - - if (!$filterModel || !$listModel) { - return []; - } - - $callbacks = $this->callbacks->getFilterCallbacks($filterModel->type, $target); - - $configuredFilter = $this->configuredFilterFactory->create($filterModel); - $listSpecification = $this->listSpecificationFactory->create($listModel); - $context = $this->listExecutionContextFactory->create($listSpecification); - $tables = $context->tableAliasRegistry->getTables(); - $targetTable = $context->tableAliasRegistry->getTable($filterModel->targetAlias) ?: $listModel->dc; - - return CallbackHelper::firstReturn($callbacks, [], [ - FilterModel::class => $filterModel, - ListModel::class => $listModel, - DataContainer::class => $dc, - ConfiguredFilter::class => $configuredFilter, - ListSpecification::class => $listSpecification, - ListExecutionContext::class => $context, - 'tables' => $tables, - 'targetTable' => $targetTable, - ]) ?? []; - } - - /** - * @throws \RuntimeException - */ - public function handleLoadField(mixed $value, ?DataContainer $dc, string $target): mixed - { - return $this->handleValueCallback($value, $dc, $target); - } - - /** - * @throws \RuntimeException - */ - public function handleSaveField(mixed $value, ?DataContainer $dc, string $target): mixed - { - return $this->handleValueCallback($value, $dc, $target); - } - - /** - * @throws \RuntimeException - */ - public function handleValueCallback(mixed $value, ?DataContainer $dc, string $target): mixed - { - [$filterModel, $listModel] = $this->getModelsFromDataContainer($dc); - - if (!$filterModel || !$listModel) { - return $value; - } - - $callbacks = $this->callbacks->getFilterCallbacks($filterModel->type, $target); - - return CallbackHelper::firstReturn($callbacks, [$value], [ - FilterModel::class => $filterModel, - ListModel::class => $listModel, - DataContainer::class => $dc, - ]) ?? $value; - } - /** * @param DataContainer|null $dc * @param bool $ignoreType @@ -144,6 +38,4 @@ public function getModelsFromDataContainer(?DataContainer $dc, bool $ignoreType return [null, null]; } - - // } diff --git a/src/DataContainer/FlareCallbackContainerInterface.php b/src/DataContainer/FlareCallbackContainerInterface.php deleted file mode 100644 index f29f98b7..00000000 --- a/src/DataContainer/FlareCallbackContainerInterface.php +++ /dev/null @@ -1,21 +0,0 @@ - - - public function handleConfigOnLoad(?DataContainer $dc, string $target): void - { - if (!$listModel = $this->getListModelFromDataContainer($dc)) { - return; - } - - $namespace = static::CALLBACK_PREFIX . '.' . $listModel->type; - - $callbacks = $this->callbackRegistry->getSorted($namespace, $target) ?? []; - $callbacks = \array_reverse($callbacks); - - CallbackHelper::call($callbacks, [], [ - ListModel::class => $listModel, - DataContainer::class => $dc, - ]); - } - - /** - * @throws \RuntimeException - */ - public function handleFieldOptions(?DataContainer $dc, string $target): array - { - if (!$listModel = $this->getListModelFromDataContainer($dc)) { - return []; - } - - $namespace = static::CALLBACK_PREFIX . '.' . $listModel->type; - - $callbacks = $this->callbackRegistry->getSorted($namespace, $target) ?? []; - - return CallbackHelper::firstReturn($callbacks, [], [ - ListModel::class => $listModel, - DataContainer::class => $dc, - ]) ?? []; - } - - /** - * @throws \RuntimeException - */ - public function handleLoadField(mixed $value, ?DataContainer $dc, string $target): mixed - { - return $this->handleValueCallback($value, $dc, $target); - } - - /** - * @throws \RuntimeException - */ - public function handleSaveField(mixed $value, ?DataContainer $dc, string $target): mixed - { - return $this->handleValueCallback($value, $dc, $target); - } - - /** - * @throws \RuntimeException - */ - public function handleValueCallback(mixed $value, ?DataContainer $dc, string $target): mixed - { - if (!$listModel = $this->getListModelFromDataContainer($dc)) { - return $value; - } - - $namespace = static::CALLBACK_PREFIX . '.' . $listModel->type; - - $callbacks = $this->callbackRegistry->getSorted($namespace, $target) ?? []; - - return CallbackHelper::firstReturn($callbacks, [$value], [ - ListModel::class => $listModel, - DataContainer::class => $dc, - ]) ?? $value; - } - - public function getListModelFromDataContainer(?DataContainer $dc): ?ListModel - { - if (!$dc?->id) { - return null; - } - - return ListModel::findByPk($dc->id); - } - - // - /* ============================= * * CONFIG * * ============================= */ @@ -177,4 +85,4 @@ public function getListedTableName(DataContainer $dc): ?string { return ($row = DcaHelper::rowOf($dc)) ? ($row['dc'] ?? null) : null; } -} \ No newline at end of file +} diff --git a/src/DependencyInjection/Attribute/AsFilterCallback.php b/src/DependencyInjection/Attribute/AsFilterCallback.php deleted file mode 100644 index 1a706269..00000000 --- a/src/DependencyInjection/Attribute/AsFilterCallback.php +++ /dev/null @@ -1,8 +0,0 @@ - $formType - * @param ?string $method + * @param bool $intrinsicOnly Whether the element never renders a form control and must be configured intrinsically. * @param bool|null $isTargeted * @param mixed ...$attributes */ public function __construct( ?string $type = null, - ?string $palette = null, - ?string $formType = null, - ?string $method = null, + bool $intrinsicOnly = false, ?bool $isTargeted = null, mixed ...$attributes ) { $attributes['type'] = $type ?? $attributes['alias'] ?? null; - $attributes['palette'] = $palette; - $attributes['formType'] = $formType; - $attributes['method'] = $method; + $attributes['intrinsicOnly'] = $intrinsicOnly; $attributes['isTargeted'] = $isTargeted; $this->attributes = $attributes; } -} \ No newline at end of file +} diff --git a/src/DependencyInjection/Attribute/AsFlareCallback.php b/src/DependencyInjection/Attribute/AsFlareCallback.php deleted file mode 100644 index ba9e7a81..00000000 --- a/src/DependencyInjection/Attribute/AsFlareCallback.php +++ /dev/null @@ -1,27 +0,0 @@ -attributes = $attributes; } -} \ No newline at end of file +} diff --git a/src/DependencyInjection/Compiler/RegisterFilterElementsPass.php b/src/DependencyInjection/Compiler/RegisterFilterElementsPass.php index bf12ae31..892dceb7 100644 --- a/src/DependencyInjection/Compiler/RegisterFilterElementsPass.php +++ b/src/DependencyInjection/Compiler/RegisterFilterElementsPass.php @@ -68,10 +68,8 @@ protected function getFilterElementConfig( $definition = new Definition(FilterElementDescriptor::class, [ $reference, $attributes, - $attributes['palette'] ?? null, - $attributes['formType'] ?? null, - $attributes['method'] ?? null, $attributes['isTargeted'] ?? null, + (bool) ($attributes['intrinsicOnly'] ?? false), ]); $serviceId = 'huh.flare.filter_element._config_' . ContainerBuilder::hash($definition); diff --git a/src/DependencyInjection/Compiler/RegisterFlareCallbacksPass.php b/src/DependencyInjection/Compiler/RegisterFlareCallbacksPass.php deleted file mode 100644 index 1ee1547c..00000000 --- a/src/DependencyInjection/Compiler/RegisterFlareCallbacksPass.php +++ /dev/null @@ -1,85 +0,0 @@ -has(FlareCallbackRegistry::class)) { - return; - } - - $mapTagPrefix = [ - FlareCallbackDescriptor::TAG_FILTER_CALLBACK => 'filter', - FlareCallbackDescriptor::TAG_LIST_CALLBACK => 'list', - // Keep this tag on the bottom, so its "bare" callbacks are loaded after more specific ones - FlareCallbackDescriptor::TAG => null, - ]; - - $registry = $container->findDefinition(FlareCallbackRegistry::class); - - foreach ($mapTagPrefix as $tag => $prefix) - { - foreach ($this->findAndSortTaggedServices($tag, $container) as $reference) - { - if (\str_starts_with((string) $reference, 'huh.flare.flare_callback._')) { - continue; - } - - $definition = $container->findDefinition((string) $reference); - $definitionTag = $definition->getTag($tag); - $definition->clearTag($tag); - - foreach ($definitionTag as $attributes) - { - $namespace = $prefix ? $prefix . '.' : ''; - $namespace .= $attributes['element'] ?? null; - $target = $attributes['target'] ?? null; - - if (!$namespace || !$target) { - continue; - } - - $config = $this->getFilterCallbackConfig($container, $reference, $attributes); - - /** @see FlareCallbackRegistry::add() */ - $registry->addMethodCall('add', [$namespace, $target, (int) ($attributes['priority'] ?? 0), $config]); - } - } - } - } - - protected function getFilterCallbackConfig( - ContainerBuilder $container, - Reference $reference, - array $attributes, - ): Reference { - /** @see FlareCallbackDescriptor::__construct */ - $definition = new Definition(FlareCallbackDescriptor::class, [ - $reference, - $attributes, - $attributes['element'] ?? null, - $attributes['target'] ?? null, - $attributes['method'] ?? null, - $attributes['priority'] ?? 0, - ]); - - $serviceId = 'huh.flare.flare_callback._config_' . ContainerBuilder::hash($definition); - $container->setDefinition($serviceId, $definition); - - return new Reference($serviceId); - } -} \ No newline at end of file diff --git a/src/DependencyInjection/Compiler/RegisterListTypesPass.php b/src/DependencyInjection/Compiler/RegisterListTypesPass.php index 109c88c7..8dabfe7d 100644 --- a/src/DependencyInjection/Compiler/RegisterListTypesPass.php +++ b/src/DependencyInjection/Compiler/RegisterListTypesPass.php @@ -71,8 +71,6 @@ protected function getListTypeConfig( $reference, $attributes, $attributes['dataContainer'] ?? null, - $attributes['palette'] ?? null, - $attributes['method'] ?? null, ]); $serviceId = 'huh.flare.list_type._config_' . ContainerBuilder::hash($definition); diff --git a/src/DependencyInjection/HeimrichHannotFlareExtension.php b/src/DependencyInjection/HeimrichHannotFlareExtension.php index 68c0192f..5ff4c0de 100644 --- a/src/DependencyInjection/HeimrichHannotFlareExtension.php +++ b/src/DependencyInjection/HeimrichHannotFlareExtension.php @@ -4,12 +4,8 @@ namespace HeimrichHannot\FlareBundle\DependencyInjection; -use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterCallback; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; -use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFlareCallback; -use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListCallback; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListType; -use HeimrichHannot\FlareBundle\Registry\Descriptor\FlareCallbackDescriptor; use HeimrichHannot\FlareBundle\Util\Env; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ChildDefinition; @@ -57,37 +53,17 @@ public function load(array $configs, ContainerBuilder $container): void $attributesForAutoconfiguration = [ AsListType::class => AsListType::TAG, AsFilterElement::class => AsFilterElement::TAG, - // todo(@ericges): remove callbacks in favor of events in v0.2.0 - AsFlareCallback::class => FlareCallbackDescriptor::TAG, - AsFilterCallback::class => FlareCallbackDescriptor::TAG_FILTER_CALLBACK, - AsListCallback::class => FlareCallbackDescriptor::TAG_LIST_CALLBACK, ]; foreach ($attributesForAutoconfiguration as $attributeClass => $tag) { $container->registerAttributeForAutoconfiguration( $attributeClass, - static function (ChildDefinition $definition, object $attribute, \Reflector $reflector) use ($attributeClass, $tag): void { + static function (ChildDefinition $definition, object $attribute) use ($tag): void { $tagAttributes = \property_exists($attribute, 'attributes') ? $attribute->attributes : \get_object_vars($attribute); - if ($reflector instanceof \ReflectionMethod) - { - if (isset($tagAttributes['method'])) { - throw new \LogicException( - sprintf( - '%s attribute cannot declare a method on "%s::%s()".', - $attributeClass, - $reflector->getDeclaringClass()->getName(), - $reflector->getName() - ) - ); - } - - $tagAttributes['method'] = $reflector->getName(); - } - $definition->addTag($tag, $tagAttributes); } ); diff --git a/src/DependencyInjection/Registry/AbstractPriorityServiceDescriptorRegistry.php b/src/DependencyInjection/Registry/AbstractPriorityServiceDescriptorRegistry.php deleted file mode 100644 index 75f8ed3e..00000000 --- a/src/DependencyInjection/Registry/AbstractPriorityServiceDescriptorRegistry.php +++ /dev/null @@ -1,134 +0,0 @@ ->> - */ - private array $elements = []; - - /** - * Returns the class name of the config class. - * - * @return class-string - */ - abstract public function getDescriptorClass(): string; - - /** - * Registers a new service configuration under a TNamespace with a TKey and priority. - * - * @param TNamespace $namespace - * @param TKey $key - * @param TPrio $priority - * @param TDescriptor $descriptor - */ - public function add(string $namespace, string $key, int $priority, ServiceDescriptorInterface $descriptor): static - { - if (!\is_a($descriptor, $this->getDescriptorClass())) { - throw new \InvalidArgumentException('Config must be an instance of ' . $this->getDescriptorClass() . '.'); - } - - $this->elements[$namespace][$key][$priority][] = $descriptor; - - return $this; - } - - /** - * Removes a service configuration from the registry. - */ - public function remove(string $namespace, string $key): static - { - unset($this->elements[$namespace][$key]); - - return $this; - } - - /** - * Checks if a set of service configurations is registered. - * - * @param TNamespace $namespace - * @param ?TKey $key - */ - public function has(string $namespace, ?string $key = null): bool - { - if (\is_null($key)) - { - return isset($this->elements[$namespace]) - && \is_array($this->elements[$namespace]) - && \array_filter($this->elements[$namespace]); - } - - return isset($this->elements[$namespace][$key]) - && \is_array($this->elements[$namespace][$key]) - && \array_filter($this->elements[$namespace][$key]); - } - - /** - * Returns a specific set of service configurations by its TNamespace and TKey. - * - * @param TNamespace $namespace - * @param TKey $key - * @return array|null A priority-sorted array of service configurations. - */ - public function get(string $namespace, string $key): ?array - { - return $this->elements[$namespace][$key] ?? null; - } - - /** - * @param TNamespace $namespace - * @return array>|null - */ - public function getNamespace(string $namespace): ?array - { - return $this->elements[$namespace] ?? null; - } - - /** - * Returns a specific set of service configurations by its TNamespace and TKey. - * - * @return TDescriptor[]|null - */ - public function getSorted(string $namespace, string $key): ?array - { - if (!$prioSorted = $this->get($namespace, $key)) { - return null; - } - - \krsort($prioSorted); - - $return = []; - \array_walk_recursive( - $prioSorted, - static function (ServiceDescriptorInterface $element) use (&$return): void { - $return[] = $element; - } - ); - - return $return; - } - - /** - * Returns all registered service configurations. - * - * @return array>> - */ - public function all(): array - { - return $this->elements; - } -} \ No newline at end of file diff --git a/src/DependencyInjection/Registry/ServiceDescriptorInterface.php b/src/DependencyInjection/Registry/ServiceDescriptorInterface.php index 7146db32..f7449747 100644 --- a/src/DependencyInjection/Registry/ServiceDescriptorInterface.php +++ b/src/DependencyInjection/Registry/ServiceDescriptorInterface.php @@ -8,7 +8,5 @@ interface ServiceDescriptorInterface { public function getAttributes(): array; - public function getMethod(): ?string; - public function getService(): object; -} \ No newline at end of file +} diff --git a/src/Engine/Loader/ValidationLoader.php b/src/Engine/Loader/ValidationLoader.php index 014f891b..d5d02a7f 100644 --- a/src/Engine/Loader/ValidationLoader.php +++ b/src/Engine/Loader/ValidationLoader.php @@ -41,7 +41,7 @@ public function fetchEntryById(int $id): ?array equationRight: $id, ); - $list->getFilters()->add($idDefinition); + $list->addFilter($idDefinition); return $this->executeQuery($list, $this->config->context); } @@ -75,7 +75,7 @@ public function fetchEntryByAutoItem(string $autoItem): ?array equationRight: $autoItem, ); - $list->getFilters()->add($autoItemDefinition); + $list->addFilter($autoItemDefinition); return $this->executeQuery($list, $this->config->context); } diff --git a/src/Engine/Mod/SimpleEquationMod.php b/src/Engine/Mod/SimpleEquationMod.php index 176186f2..a3a2e267 100644 --- a/src/Engine/Mod/SimpleEquationMod.php +++ b/src/Engine/Mod/SimpleEquationMod.php @@ -24,14 +24,7 @@ public function __invoke(Engine $engine, array $options): void equationRight: $options['operand2'], ); - $filters = $engine->getList()->getFilters(); - - if ($name = $options['name']) { - $filters->set($name, $filter); - return; - } - - $filters->add($filter); + $engine->getList()->addFilter($filter, $options['name'] ?: null); } public function configureOptions(OptionsResolver $resolver): void diff --git a/src/Engine/Projector/InteractiveProjector.php b/src/Engine/Projector/InteractiveProjector.php index 7f5c6963..ba2e30e9 100644 --- a/src/Engine/Projector/InteractiveProjector.php +++ b/src/Engine/Projector/InteractiveProjector.php @@ -4,8 +4,6 @@ namespace HeimrichHannot\FlareBundle\Engine\Projector; -use HeimrichHannot\FlareBundle\Contract\FilterElement\FormDataContract; -use HeimrichHannot\FlareBundle\Contract\FilterElement\HydrateFormContract; use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; use HeimrichHannot\FlareBundle\Engine\Context\Factory\AggregationContextFactory; use HeimrichHannot\FlareBundle\Engine\Context\InteractiveContext; @@ -23,7 +21,6 @@ use HeimrichHannot\FlareBundle\Reader\Factory\ReaderUrlGeneratorFactory; use HeimrichHannot\FlareBundle\Reader\ReaderUrlGeneratorInterface; use HeimrichHannot\FlareBundle\Specification\ListSpecification; -use Symfony\Component\Form\Exception\OutOfBoundsException; use Symfony\Component\Form\FormInterface; /** @@ -50,7 +47,7 @@ public function project(ListSpecification $list, ContextInterface $context): Int // collect filter values from form data $form = $this->createForm($list, $context); - $filterValues = $this->mapFormDataToFilterKeys($list, $form); + $filterValues = $this->collectFilterData($list, $form); // pagination setup $totalItems = $this->createAggregationView($list, $context, $filterValues)->getCount(); @@ -117,90 +114,29 @@ public function createForm(ListSpecification $list, InteractiveContext $context) $form = $this->filterFormFactory->create($list, $context); $form->handleRequest($this->getCurrentRequest()); - $this->hydrateForm($form, $list); - return $form; } /** - * @throws FlareException If the form does not contain the filter field. + * Collects each filter's submitted form data (the compound child's data array), + * keyed by the filter's list-specification key. + * + * @return array> */ - private function hydrateForm(FormInterface $form, ListSpecification $list): void + protected function collectFilterData(ListSpecification $list, FormInterface $form): array { - if ($form->isSubmitted()) { - return; - } - - $filterElementRegistry = $this->getFilterElementRegistry(); - $data = []; - foreach ($list->getFilters()->getIterator() as $configuredFilter) - { - if (!$filterElement = $filterElementRegistry->get($configuredFilter->getElementType())?->getService()) { - continue; - } - - if (!$filterElement instanceof HydrateFormContract) { - continue; - } - - $filterName = $configuredFilter->getAlias(); - - if (!$filterName || !$form->has($filterName)) { - continue; - } - - try - { - $field = $form->get($filterName); - } - catch (OutOfBoundsException $exception) - { - $filterSourceId = $configuredFilter->getDataSource()?->getFilterIdentifier(); - - throw new FlareException( - message: 'Filter form does not contain field: ' . $filterName, - previous: $exception, - method: __METHOD__, - source: $filterSourceId ? \sprintf('tl_flare_filter.id=%s', $filterSourceId) : 'filter inlined' - ); - } - - $filterElement->hydrateForm($field, $list, $configuredFilter); - - $data[$filterName] = $field->getData(); - } - - $form->setData(\array_merge($form->getData() ?? [], $data)); - } - protected function mapFormDataToFilterKeys(ListSpecification $list, FormInterface $form): array - { - $values = []; - - $filterElementRegistry = $this->getFilterElementRegistry(); - - foreach ($list->getFilters()->all() as $key => $configuredFilter) + foreach ($list->getFilters() as $key => $filter) { - $alias = $configuredFilter->getAlias(); - - if (\is_null($alias)) { + if (!$filter->alias || !$form->has($filter->alias)) { continue; } - if (!$form->has($alias)) { - continue; - } - - $field = $form->get($alias); - $filterElement = $filterElementRegistry->get($configuredFilter->getElementType())?->getService(); - - $values[$key] = $filterElement instanceof FormDataContract - ? $filterElement->extractFormData($field) - : $field->getData(); + $data[$key] = (array) $form->get($filter->alias)->getData(); } - return $values; + return $data; } /** diff --git a/src/Event/ConfiguredFilterCreatedEvent.php b/src/Event/ConfiguredFilterCreatedEvent.php deleted file mode 100644 index 0705ce59..00000000 --- a/src/Event/ConfiguredFilterCreatedEvent.php +++ /dev/null @@ -1,15 +0,0 @@ - $data + */ public function __construct( - private readonly FilterInvocation $invocation, - private readonly ContextInterface $context, + private readonly FilterContext $context, private readonly FilterBuilderInterface $builder, - private bool $shouldBuild, + private readonly array $data = [], + private bool $shouldBuild = true, ) {} - public function getInvocation(): FilterInvocation - { - return $this->invocation; - } - - public function getContext(): ContextInterface + public function getContext(): FilterContext { return $this->context; } @@ -33,6 +30,14 @@ public function getBuilder(): FilterBuilderInterface return $this->builder; } + /** + * @return array + */ + public function getData(): array + { + return $this->data; + } + public function shouldBuild(): bool { return $this->shouldBuild; @@ -42,4 +47,4 @@ public function setShouldBuild(bool $shouldBuild): void { $this->shouldBuild = $shouldBuild; } -} \ No newline at end of file +} diff --git a/src/Event/FilterElementBuiltEvent.php b/src/Event/FilterElementBuiltEvent.php index 0bcf9178..5524fa8a 100644 --- a/src/Event/FilterElementBuiltEvent.php +++ b/src/Event/FilterElementBuiltEvent.php @@ -5,23 +5,35 @@ namespace HeimrichHannot\FlareBundle\Event; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\FilterInvocation; +use HeimrichHannot\FlareBundle\Filter\FilterContext; use Symfony\Contracts\EventDispatcher\Event; class FilterElementBuiltEvent extends Event { + /** + * @param array $data + */ public function __construct( - private readonly FilterInvocation $invocation, + private readonly FilterContext $context, private readonly FilterBuilderInterface $builder, + private readonly array $data = [], ) {} - public function getInvocation(): FilterInvocation + public function getContext(): FilterContext { - return $this->invocation; + return $this->context; } public function getBuilder(): FilterBuilderInterface { return $this->builder; } -} \ No newline at end of file + + /** + * @return array + */ + public function getData(): array + { + return $this->data; + } +} diff --git a/src/Event/FilterElementFormBuiltEvent.php b/src/Event/FilterElementFormBuiltEvent.php new file mode 100644 index 00000000..e6984c34 --- /dev/null +++ b/src/Event/FilterElementFormBuiltEvent.php @@ -0,0 +1,45 @@ +builder; + } + + public function getContext(): FilterContext + { + return $this->context; + } + + public function cancel(): void + { + $this->cancelled = true; + } + + public function isCancelled(): bool + { + return $this->cancelled; + } +} diff --git a/src/Event/FilterElementFormTypeOptionsEvent.php b/src/Event/FilterElementFormTypeOptionsEvent.php deleted file mode 100644 index c18ba362..00000000 --- a/src/Event/FilterElementFormTypeOptionsEvent.php +++ /dev/null @@ -1,20 +0,0 @@ -paletteContainer; - } - - public function getPaletteConfig(): PaletteConfig - { - return $this->paletteConfig; - } - - public function setPaletteConfig(PaletteConfig $paletteConfig): self - { - $this->paletteConfig = $paletteConfig; - - return $this; - } - - public function getPalette(): ?string - { - return $this->palette; - } - - public function setPalette(?string $palette): self - { - $this->palette = $palette; - - return $this; - } -} \ No newline at end of file diff --git a/src/EventListener/Contao/ElementDcaListener.php b/src/EventListener/Contao/ElementDcaListener.php new file mode 100644 index 00000000..a2368826 --- /dev/null +++ b/src/EventListener/Contao/ElementDcaListener.php @@ -0,0 +1,123 @@ +configure($table); + }; + } + + private function configure(string $table): void + { + if (!$id = Input::get('id')) { + return; + } + + if ($table === FilterModel::getTable()) + { + $filterModel = FilterModel::findByPk($id); + $listModel = $filterModel?->getRelated('pid'); + $type = (string) ($filterModel->type ?? ''); + $service = $this->filterElementRegistry->get($type)?->getService(); + } + else + { + $filterModel = null; + $listModel = ListModel::findByPk($id); + $type = (string) ($listModel->type ?? ''); + $service = $this->listTypeRegistry->get($type)?->getService(); + } + + if (!$listModel instanceof ListModel || !$type || $type === 'default' || \str_starts_with($type, '__')) { + return; + } + + $context = new DcaContext( + table: $table, + type: $type, + listModel: $listModel, + filterModel: $filterModel, + executionContextFactory: fn (): ?ListExecutionContext => $this->createExecutionContext($listModel), + ); + + $dca = new DcaBuilder(); + + if ($service instanceof DcaContract) { + $service->configureDca($dca, $context); + } + + $this->eventDispatcher->dispatch(new ElementDcaEvent($dca, $context)); + + $isEditAction = $this->requestStack->getCurrentRequest()?->query->get('act') === 'edit'; + + $dca->apply($table, $type, applyPalette: $isEditAction); + } + + /** + * @mago-expect lint:no-empty-catch-clause Backend configuration must not fail on broken list configs. + */ + private function createExecutionContext(ListModel $listModel): ?ListExecutionContext + { + try + { + $specification = $this->listSpecificationFactory->create($listModel); + + return $this->listExecutionContextFactory->create($specification); + } + catch (\Throwable) {} + + return null; + } +} diff --git a/src/EventListener/Contao/LoadDataContainerListener.php b/src/EventListener/Contao/LoadDataContainerListener.php deleted file mode 100644 index 5d663df0..00000000 --- a/src/EventListener/Contao/LoadDataContainerListener.php +++ /dev/null @@ -1,121 +0,0 @@ - [FilterModel::findByPk($id)?->type, 'filter.', $this->filterContainer], - $listTable => [ListModel::findByPk($id)?->type, 'list.', $this->listContainer], - default => [null, null, null], - }; - - if (!$modelType || !$prefix || !$container) { - return; - } - - if (!$callbacks = $this->registry->getNamespace($prefix . $modelType)) { - return; - } - - // @phpstan-ignore function.alreadyNarrowedType - if (!\is_subclass_of($container, FlareCallbackContainerInterface::class)) { - return; - } - - /** @mago-expect lint:no-empty This is the most straightforward way to check if the callback should be bound. */ - if (!empty($callbacks[$target = 'config.onload'])) - // bind onload callback - { - $GLOBALS['TL_DCA'][$table]['config']['onload_callback'][] = - static fn (DataContainer $dc): null => $container->handleConfigOnLoad($dc, $target); - } - - $exclude = \array_fill_keys(['id', 'pid', 'tstamp', 'sorting', 'type', 'published', 'intrinsic'], true); - - $refFields = &$GLOBALS['TL_DCA'][$table]['fields']; - - foreach ($refFields as $field => &$definition) - { - if ($exclude[$field] ?? false) { - continue; - } - - // Always pass the target to the handler method, - // to ensure that cloning of fields is possible - // without interfering with the callback execution. - // This is required for the group widget, for example. - - /** @mago-expect lint:no-empty This is the most straightforward way to check if the callback should be bound. */ - if (!empty($callbacks[$target = "fields.{$field}.options"])) - // bind options callback - { - $definition['options_callback'] = - static fn (?DataContainer $dc): array => $container->handleFieldOptions($dc, $target); - } - - /** @mago-expect lint:no-empty This is the most straightforward way to check if the callback should be bound. */ - if (!empty($callbacks[$target = "fields.{$field}.load"])) - // bind load callback - { - if (!\is_array($definition['load_callback'] ?? null)) { - $definition['load_callback'] = []; - } - - $definition['load_callback'][] = - static fn (mixed $value, ?DataContainer $dc): mixed => $container->handleLoadField($value, $dc, $target); - } - - /** @mago-expect lint:no-empty This is the most straightforward way to check if the callback should be bound. */ - if (!empty($callbacks[$target = "fields.{$field}.save"])) - // bind save callback - { - if (!\is_array($definition['save_callback'] ?? null)) { - $definition['save_callback'] = []; - } - - $definition['save_callback'][] = - static fn (mixed $value, ?DataContainer $dc): mixed => $container->handleSaveField($value, $dc, $target); - } - } - } -} \ No newline at end of file diff --git a/src/EventListener/DataContainer/AutoTypePalettesCallback.php b/src/EventListener/DataContainer/AutoTypePalettesCallback.php deleted file mode 100644 index fca6fbe6..00000000 --- a/src/EventListener/DataContainer/AutoTypePalettesCallback.php +++ /dev/null @@ -1,158 +0,0 @@ -onConfigLoad(PaletteContainer::FILTER, $dc); - } - - #[AsCallback(table: ListContainer::TABLE_NAME, target: 'config.onload', priority: 101)] - public function onListContainerConfigLoad(?DataContainer $dc = null): void - { - $this->onConfigLoad(PaletteContainer::LIST, $dc); - } - - public function onConfigLoad(PaletteContainer $container, ?DataContainer $dc = null): void - { - $request = $this->requestStack->getCurrentRequest(); - - if (!$dc || !$dc->id || $request?->query->get('act') !== 'edit') { - return; - } - - [$listModel, $filterModel] = $this->getModelsFromDC($container, $dc); - - if (!$listModel instanceof ListModel) { - return; - } - - $descriptor = match ($container) { - PaletteContainer::FILTER => $this->filterElementRegistry->get($type = $filterModel?->type), - PaletteContainer::LIST => $this->listTypeRegistry->get($type = $listModel->type), - }; - - if (!isset($type) || !$type || !($descriptor instanceof ServiceDescriptorInterface)) { - return; - } - - $this->applyPalette($container, $dc, $type, $descriptor, $listModel, $filterModel); - } - - protected function getModelsFromDC(PaletteContainer $container, DataContainer $dc): array - { - Controller::loadDataContainer(FilterContainer::TABLE_NAME); - Controller::loadDataContainer(ListContainer::TABLE_NAME); - - switch ($container) { - case PaletteContainer::FILTER: - $filterModel = FilterModel::findByPk($dc->id); - $listModel = ListModel::findByPk($filterModel?->pid ?: null); - break; - case PaletteContainer::LIST: - $listModel = ListModel::findByPk($dc->id); - break; - } - - return [$listModel ?? null, $filterModel ?? null]; - } - - protected function applyPalette( - PaletteContainer $container, - DataContainer $dc, - string $type, - ServiceDescriptorInterface $descriptor, - ListModel $listModel, - ?FilterModel $filterModel, - ): void { - if (!($table = $dc->table) || $type === 'default' || \str_starts_with($type, '__')) { - return; - } - - $paletteConfigFactory = static fn (string $prefix, string $suffix): PaletteConfig => new PaletteConfig( - type: $type, - dataContainer: $dc, - prefix: $prefix, - suffix: $suffix, - listModel: $listModel, - filterModel: $filterModel, - ); - - $dcaPalettes = &$GLOBALS['TL_DCA'][$table]['palettes']; - $prefix = $dcaPalettes['__prefix__'] ?? ''; - $suffix = $dcaPalettes['__suffix__'] ?? ''; - - $service = $descriptor->getService(); - - if ($service instanceof PaletteContract) - // If the service implements PaletteContract, use its getPalette method. - { - $paletteConfig = $paletteConfigFactory($prefix, $suffix); - - $palette = $service->getPalette($paletteConfig); - - $prefix = $paletteConfig->getPrefix(); - $suffix = $paletteConfig->getSuffix(); - } - - if (!isset($palette) && $descriptor instanceof PaletteContract) - // Grab the default palette specified in the AsListType or AsFilterElement attributes. - { - $paletteConfig = $paletteConfigFactory($prefix, $suffix); - - $palette = $descriptor->getPalette($paletteConfig); - - $prefix = $paletteConfig->getPrefix(); - $suffix = $paletteConfig->getSuffix(); - } - - ###> - - $palette ??= null; - - $event = $this->eventDispatcher->dispatch(new PaletteEvent( - paletteContainer: $container, - paletteConfig: $paletteConfigFactory($prefix, $suffix), - palette: $palette, - )); - - $palette = $event->getPalette(); - $paletteConfig = $event->getPaletteConfig(); - $prefix = $paletteConfig->getPrefix(); - $suffix = $paletteConfig->getSuffix(); - - ###< - - $dcaPalettes[$type] = Str::mergePalettes($prefix, $palette, $suffix); - } -} \ No newline at end of file diff --git a/src/EventListener/DataContainer/FlareFilter/FieldsLoadAndSaveCallbacks.php b/src/EventListener/DataContainer/FlareFilter/FieldsLoadAndSaveCallbacks.php index be134bf2..dee66f5a 100644 --- a/src/EventListener/DataContainer/FlareFilter/FieldsLoadAndSaveCallbacks.php +++ b/src/EventListener/DataContainer/FlareFilter/FieldsLoadAndSaveCallbacks.php @@ -79,7 +79,7 @@ public function onLoadField_intrinsic(mixed $value, DataContainer $dc): bool return $value; } - if ($this->filterElementRegistry->get($row['type'] ?? null)?->isIntrinsicRequired()) + if ($this->filterElementRegistry->get($row['type'] ?? null)?->isIntrinsicOnly()) { $eval = &$GLOBALS['TL_DCA'][self::TABLE_NAME]['fields']['intrinsic']['eval']; @@ -98,7 +98,7 @@ public function onSaveField_intrinsic(mixed $value, DataContainer $dc): mixed return $value; } - if ($this->filterElementRegistry->get($row['type'] ?? null)?->isIntrinsicRequired()) { + if ($this->filterElementRegistry->get($row['type'] ?? null)?->isIntrinsicOnly()) { return '1'; } diff --git a/src/EventListener/NamedDispatch/ElementDcaEventListener.php b/src/EventListener/NamedDispatch/ElementDcaEventListener.php new file mode 100644 index 00000000..5375740b --- /dev/null +++ b/src/EventListener/NamedDispatch/ElementDcaEventListener.php @@ -0,0 +1,26 @@ +context->table === FilterModel::getTable() ? 'filter_element' : 'list'; + $eventName = "flare.{$prefix}.{$event->context->type}.dca"; + + $this->eventDispatcher->dispatch(event: $event, eventName: $eventName); + } +} diff --git a/src/EventListener/NamedDispatch/FilterElementListener.php b/src/EventListener/NamedDispatch/FilterElementListener.php index c4724a98..3c6ed280 100644 --- a/src/EventListener/NamedDispatch/FilterElementListener.php +++ b/src/EventListener/NamedDispatch/FilterElementListener.php @@ -6,6 +6,7 @@ use HeimrichHannot\FlareBundle\Event\FilterElementBuiltEvent; use HeimrichHannot\FlareBundle\Event\FilterElementBuildingEvent; +use HeimrichHannot\FlareBundle\Event\FilterElementFormBuiltEvent; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -18,18 +19,30 @@ public function __construct( #[AsEventListener(priority: -200)] public function onFilterElementBuiltEvent(FilterElementBuiltEvent $event): void { - $type = $event->getInvocation()->getConfiguredFilter()->getElementType(); - $eventName = "flare.filter_element.{$type}.built"; + if (!$type = $event->getContext()->filter->getElementType()) { + return; + } - $this->eventDispatcher->dispatch(event: $event, eventName: $eventName); + $this->eventDispatcher->dispatch(event: $event, eventName: "flare.filter_element.{$type}.built"); } #[AsEventListener(priority: -200)] public function onFilterElementBuildingEvent(FilterElementBuildingEvent $event): void { - $type = $event->getInvocation()->getConfiguredFilter()->getElementType(); - $eventName = "flare.filter_element.{$type}.building"; + if (!$type = $event->getContext()->filter->getElementType()) { + return; + } - $this->eventDispatcher->dispatch(event: $event, eventName: $eventName); + $this->eventDispatcher->dispatch(event: $event, eventName: "flare.filter_element.{$type}.building"); } -} \ No newline at end of file + + #[AsEventListener(priority: -200)] + public function onFilterElementFormBuiltEvent(FilterElementFormBuiltEvent $event): void + { + if (!$type = $event->getContext()->filter->getElementType()) { + return; + } + + $this->eventDispatcher->dispatch(event: $event, eventName: "flare.filter_element.{$type}.form_built"); + } +} diff --git a/src/EventListener/NamedDispatch/FilterFormListener.php b/src/EventListener/NamedDispatch/FilterFormListener.php deleted file mode 100644 index e43fde7e..00000000 --- a/src/EventListener/NamedDispatch/FilterFormListener.php +++ /dev/null @@ -1,33 +0,0 @@ -formName}.build"; - - $this->eventDispatcher->dispatch(event: $event, eventName: $eventName); - } - - #[AsEventListener(priority: -200)] - public function onFilterFormChildOptionsEvent(FilterFormChildOptionsEvent $event): void - { - $eventName = "flare.form.{$event->parentFormName}.child.{$event->formName}.options"; - - $this->eventDispatcher->dispatch(event: $event, eventName: $eventName); - } -} \ No newline at end of file diff --git a/src/EventListener/NamedDispatch/PaletteListener.php b/src/EventListener/NamedDispatch/PaletteListener.php deleted file mode 100644 index df917a14..00000000 --- a/src/EventListener/NamedDispatch/PaletteListener.php +++ /dev/null @@ -1,41 +0,0 @@ -getPaletteContainer()) { - PaletteContainer::FILTER => $this->dispatchFilterPaletteEvent($event), - PaletteContainer::LIST => $this->dispatchListPaletteEvent($event), - }; - } - - private function dispatchFilterPaletteEvent(PaletteEvent $event): void - { - if ($filterElementAlias = $event->getPaletteConfig()->getFilterModel()?->type) - { - $eventName = "flare.filter_element.{$filterElementAlias}.palette"; - $this->eventDispatcher->dispatch(event: $event, eventName: $eventName); - } - } - - private function dispatchListPaletteEvent(PaletteEvent $event): void - { - $eventName = "flare.list.{$event->getPaletteConfig()->getListModel()->type}.palette"; - $this->eventDispatcher->dispatch(event: $event, eventName: $eventName); - } -} \ No newline at end of file diff --git a/src/Filter/Filter.php b/src/Filter/Filter.php new file mode 100644 index 00000000..a6bfad65 --- /dev/null +++ b/src/Filter/Filter.php @@ -0,0 +1,134 @@ + $config Canonical config (element-defined schema); scalars, arrays, and enums only. + * @param array|null $data Runtime data bag, same shape buildFilter() receives. + * Submitted form data takes precedence over this bag. + * @param string|null $alias Form name of the filter. An alias that is not a valid Symfony form + * name (e.g. the generated "_.{source}" fallback) never mounts form children. + * @param string|null $targetAlias Table alias the filter's conditions apply to. + * @param bool $targetingForced Whether the target alias applies even if the element is not marked as targeted. + * @param string|null $source Provenance for error messages, e.g. "tl_flare_filter.42". + */ + public function __construct( + public FilterElementInterface|string $element, + public array $config = [], + public ?array $data = null, + public ?string $alias = null, + public ?string $targetAlias = null, + public bool $targetingForced = false, + public ?string $source = null, + ) {} + + public function getElementType(): ?string + { + return \is_string($this->element) ? $this->element : null; + } + + public function getElementInstance(): ?FilterElementInterface + { + return $this->element instanceof FilterElementInterface ? $this->element : null; + } + + /** + * @param array $config + */ + public function withConfig(array $config): self + { + return new self($this->element, $config, $this->data, $this->alias, $this->targetAlias, $this->targetingForced, $this->source); + } + + /** + * @param array|null $data + */ + public function withData(?array $data): self + { + return new self($this->element, $this->config, $data, $this->alias, $this->targetAlias, $this->targetingForced, $this->source); + } + + public function withAlias(?string $alias): self + { + return new self($this->element, $this->config, $this->data, $alias, $this->targetAlias, $this->targetingForced, $this->source); + } + + public function withTargetAlias(?string $targetAlias, bool $forced = true): self + { + return new self($this->element, $this->config, $this->data, $this->alias, $targetAlias, !\is_null($targetAlias) && $forced, $this->source); + } + + public function withSource(?string $source): self + { + return new self($this->element, $this->config, $this->data, $this->alias, $this->targetAlias, $this->targetingForced, $source); + } + + /** + * Creates an inline filter from closures, without a registered element service. + * + * @param callable(FilterBuilderInterface, FilterContext, array): void $buildFilter + * @param (callable(\Symfony\Component\Form\FormBuilderInterface, FilterContext): void)|null $buildForm + */ + public static function fromCallback( + callable $buildFilter, + ?callable $buildForm = null, + ?string $alias = null, + ?string $targetAlias = null, + ): self { + return new self( + element: new CallbackFilterElement($buildFilter(...), $buildForm ? $buildForm(...) : null), + alias: $alias, + targetAlias: $targetAlias, + targetingForced: !\is_null($targetAlias), + ); + } + + /** + * Creates an inline filter that applies a single filter type with the given options — + * no registered element, no DB row. + * + * @param class-string $filterTypeClass + * @param array $options + */ + public static function fromType(string $filterTypeClass, array $options = [], ?string $targetAlias = null): self + { + return self::fromCallback( + static function (FilterBuilderInterface $builder) use ($filterTypeClass, $options): void { + $builder->add($filterTypeClass, $options); + }, + targetAlias: $targetAlias, + ); + } + + /** + * Stable representation for hashing/caching. Inline elements are represented by their + * class name, which makes hashes of anonymous elements request-local. + */ + public function fingerprint(): array + { + return [ + 'element' => $this->getElementType() ?? $this->element::class, + 'config' => $this->config, + 'data' => $this->data, + 'alias' => $this->alias, + 'targetAlias' => $this->targetAlias, + 'targetingForced' => $this->targetingForced, + ]; + } +} diff --git a/src/Filter/FilterBuilder.php b/src/Filter/FilterBuilder.php index b1088e5d..00aff703 100644 --- a/src/Filter/FilterBuilder.php +++ b/src/Filter/FilterBuilder.php @@ -12,6 +12,11 @@ class FilterBuilder implements FilterBuilderInterface { + /** + * @var array, OptionsResolver> + */ + private static array $resolvers = []; + /** * @var FilterCall[] */ @@ -34,14 +39,18 @@ public function add(string $type, array $options = [], ?string $targetAlias = nu throw new FilterException(\sprintf('No FLARE filter type service registered for "%s".', $type)); } - $resolver = new OptionsResolver(); - $filterType->configureOptions($resolver); + if (!isset(self::$resolvers[$type])) + { + $resolver = new OptionsResolver(); + $filterType->configureOptions($resolver); + self::$resolvers[$type] = $resolver; + } $this->calls[] = new FilterCall( type: $filterType, typeClass: $type, targetAlias: $targetAlias ?: $this->defaultTargetAlias, - options: $resolver->resolve($options), + options: self::$resolvers[$type]->resolve($options), ); return $this; @@ -56,4 +65,4 @@ public function abort(): never { throw new AbortFilteringException(); } -} \ No newline at end of file +} diff --git a/src/Filter/FilterConfigResolver.php b/src/Filter/FilterConfigResolver.php new file mode 100644 index 00000000..1cd74bf1 --- /dev/null +++ b/src/Filter/FilterConfigResolver.php @@ -0,0 +1,55 @@ + + */ + private array $resolvers = []; + + /** + * @return array + * + * @throws FilterException If the config does not satisfy the element's schema. + */ + public function resolve(Filter $filter, FilterElementInterface $element): array + { + if (!$element instanceof ConfigContract) { + return $filter->config; + } + + if (!isset($this->resolvers[$element::class])) + { + $resolver = new OptionsResolver(); + $element->configureConfig($resolver); + $this->resolvers[$element::class] = $resolver; + } + + try + { + return $this->resolvers[$element::class]->resolve($filter->config); + } + catch (\Throwable $e) + { + throw new FilterException( + \sprintf('[FLARE] Invalid filter config for element "%s": %s', $element::class, $e->getMessage()), + previous: $e, + method: $element::class . '::configureConfig', + source: $filter->source, + ); + } + } +} diff --git a/src/Filter/FilterContext.php b/src/Filter/FilterContext.php new file mode 100644 index 00000000..9878da1b --- /dev/null +++ b/src/Filter/FilterContext.php @@ -0,0 +1,33 @@ + $config Resolved canonical config of the filter. + * @param string|int|null $key Key of the filter within {@see ListSpecification::getFilters()}. + */ + public function __construct( + public ListSpecification $list, + public Filter $filter, + public array $config, + public ContextInterface $engineContext, + public string|int|null $key = null, + ) {} +} diff --git a/src/Filter/FilterInvocation.php b/src/Filter/FilterInvocation.php deleted file mode 100644 index 9850197b..00000000 --- a/src/Filter/FilterInvocation.php +++ /dev/null @@ -1,39 +0,0 @@ -filter; - } - - public function getListSpecification(): ListSpecification - { - return $this->list; - } - - public function getContextConfig(): ContextInterface - { - return $this->context; - } - - public function getValue(): mixed - { - return $this->value; - } -} \ No newline at end of file diff --git a/src/Filter/Type/AbstractFilterType.php b/src/Filter/Type/AbstractFilterType.php index 90f0edc8..2cf9eb3f 100644 --- a/src/Filter/Type/AbstractFilterType.php +++ b/src/Filter/Type/AbstractFilterType.php @@ -7,19 +7,9 @@ use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; use Symfony\Component\OptionsResolver\OptionsResolver; -class AbstractFilterType implements FilterTypeInterface +abstract class AbstractFilterType implements FilterTypeInterface { - /** - * {@inheritDoc} - */ - public function configureOptions(OptionsResolver $resolver): void - { - } + public function configureOptions(OptionsResolver $resolver): void {} - /** - * {@inheritDoc} - */ - public function buildQuery(FilterQueryBuilder $builder, array $options): void - { - } -} \ No newline at end of file + abstract public function buildQuery(FilterQueryBuilder $builder, array $options): void; +} diff --git a/src/Filter/Type/FilterTypeInterface.php b/src/Filter/Type/FilterTypeInterface.php index 06192534..9cf16fbe 100644 --- a/src/Filter/Type/FilterTypeInterface.php +++ b/src/Filter/Type/FilterTypeInterface.php @@ -8,10 +8,10 @@ use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; use Symfony\Component\OptionsResolver\OptionsResolver; -#[AutoconfigureTag(self::TAG)] +#[AutoconfigureTag(self::FLARE_FILTER_TYPE_TAG)] interface FilterTypeInterface { - public const TAG = 'huh.flare.filter_type'; + public const FLARE_FILTER_TYPE_TAG = 'huh.flare.filter_type'; /** * Configures the options for this type. diff --git a/src/FilterCollector/FilterCollectorInterface.php b/src/FilterCollector/FilterCollectorInterface.php index 2408359e..ff6c7239 100644 --- a/src/FilterCollector/FilterCollectorInterface.php +++ b/src/FilterCollector/FilterCollectorInterface.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\FilterCollector; -use HeimrichHannot\FlareBundle\Collection\ConfiguredFilterCollection; +use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Specification\DataSource\ListDataSourceInterface; use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; @@ -13,5 +13,8 @@ interface FilterCollectorInterface { public function supports(ListDataSourceInterface $dataSource): bool; - public function collect(ListDataSourceInterface $dataSource): ?ConfiguredFilterCollection; -} \ No newline at end of file + /** + * @return array|null Filters keyed by their list-specification key. + */ + public function collect(ListDataSourceInterface $dataSource): ?array; +} diff --git a/src/FilterCollector/ListModelFilterCollector.php b/src/FilterCollector/ListModelFilterCollector.php index ea3d62e6..7d780077 100644 --- a/src/FilterCollector/ListModelFilterCollector.php +++ b/src/FilterCollector/ListModelFilterCollector.php @@ -5,18 +5,22 @@ namespace HeimrichHannot\FlareBundle\FilterCollector; use Contao\Controller; -use HeimrichHannot\FlareBundle\Collection\ConfiguredFilterCollection; +use HeimrichHannot\FlareBundle\Contract\FilterElement\ConfigContract; +use HeimrichHannot\FlareBundle\Event\FilterCollectedEvent; +use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Model\ListModel; +use HeimrichHannot\FlareBundle\Registry\FilterElementResolver; use HeimrichHannot\FlareBundle\Registry\ListTypeRegistry; use HeimrichHannot\FlareBundle\Specification\DataSource\ListDataSourceInterface; -use HeimrichHannot\FlareBundle\Specification\Factory\ConfiguredFilterFactory; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; readonly class ListModelFilterCollector implements FilterCollectorInterface { public function __construct( - private ConfiguredFilterFactory $configuredFilterFactory, - private ListTypeRegistry $listTypeRegistry, + private EventDispatcherInterface $eventDispatcher, + private FilterElementResolver $filterElementResolver, + private ListTypeRegistry $listTypeRegistry, ) {} public function supports(ListDataSourceInterface $dataSource): bool @@ -24,7 +28,7 @@ public function supports(ListDataSourceInterface $dataSource): bool return $dataSource instanceof ListModel; } - public function collect(ListDataSourceInterface $dataSource): ?ConfiguredFilterCollection + public function collect(ListDataSourceInterface $dataSource): ?array { if (!$dataSource instanceof ListModel) { throw new \InvalidArgumentException('The given data source is not a list model.'); @@ -40,25 +44,39 @@ public function collect(ListDataSourceInterface $dataSource): ?ConfiguredFilterC Controller::loadDataContainer($table); - /** @var \Traversable $filterModels */ - $filterModels = FilterModel::findByPid($dataSource->id, published: true); - $collection = new ConfiguredFilterCollection(); + $filters = []; - foreach ($filterModels as $filterModel) + /** @var FilterModel $model */ + foreach (FilterModel::findByPid((int) $dataSource->id, published: true) as $model) // Collect filters defined in the backend { - if (!$filterModel->published) { + if (!$model->published) { continue; } - $configuredFilter = $this->configuredFilterFactory->create($filterModel); + $source = "{$model::getTable()}.{$model->id}"; - $key = $configuredFilter->getAlias() - ?: "_.{$filterModel::getTable()}.{$filterModel->id}"; + if (!$element = $this->filterElementResolver->resolveType($model->getFilterType(), $source)) { + continue; + } + + $config = $element instanceof ConfigContract + ? $element->configFromRow($model->row()) + : $model->row(); + + $filter = new Filter( + element: $model->getFilterType(), + config: $config, + alias: $model->getFilterFormName() ?: "_.{$source}", + targetAlias: $model->getFilterTargetAlias() ?: null, + source: $source, + ); + + $filter = $this->eventDispatcher->dispatch(new FilterCollectedEvent($filter, $model))->filter; - $collection->set($key, $configuredFilter); + $filters[$filter->alias] = $filter; } - return $collection; + return $filters; } -} \ No newline at end of file +} diff --git a/src/FilterElement/AbstractFilterElement.php b/src/FilterElement/AbstractFilterElement.php index 4e064246..7738ed30 100644 --- a/src/FilterElement/AbstractFilterElement.php +++ b/src/FilterElement/AbstractFilterElement.php @@ -4,130 +4,19 @@ namespace HeimrichHannot\FlareBundle\FilterElement; -use HeimrichHannot\FlareBundle\Contract\Config\PaletteConfig; -use HeimrichHannot\FlareBundle\Contract\FilterElement\FormDataContract; -use HeimrichHannot\FlareBundle\Contract\FilterElement\FormTypeOptionsContract; -use HeimrichHannot\FlareBundle\Contract\FilterElement\RuntimeValueContract; use HeimrichHannot\FlareBundle\Contract\IsSupportedContract; -use HeimrichHannot\FlareBundle\Contract\PaletteContract; -use HeimrichHannot\FlareBundle\Event\FilterElementFormTypeOptionsEvent; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\FilterInvocation; -use HeimrichHannot\FlareBundle\Form\FilterFormBuilderInterface; -use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; -use Symfony\Component\Form\FormInterface; +use HeimrichHannot\FlareBundle\Filter\FilterContext; +use Symfony\Component\Form\FormBuilderInterface; -/** - * @phpstan-template FormOptionsShape of array{ - * expanded?: bool, - * label?: string, - * multiple?: bool, - * placeholder?: string - * } - */ -abstract class AbstractFilterElement implements FilterElementInterface, - FormDataContract, FormTypeOptionsContract, IsSupportedContract, PaletteContract, RuntimeValueContract +abstract class AbstractFilterElement implements FilterElementInterface, IsSupportedContract { - /** - * @var FormOptionsShape|string[] Defines which filter-model fields to use for auto-generating form type options. - */ - public static array $autoFormOptionsMap = [ - 'multiple' => 'isMultiple', - 'expanded' => 'isExpanded', - 'required' => 'isMandatory', - 'mandatory' => 'isMandatory', - 'label' => 'label', - 'placeholder' => 'placeholder', - ]; + public function buildForm(FormBuilderInterface $builder, FilterContext $context): void {} - /** - * Creates default form type options based on default filter model fields and the given config. - * - * @param ConfiguredFilter $filter The filter definition. - * @param array|array|array|FormOptionsShape|list> $config The config to use. - * - * @return array - * - * @api Creates default form type options based on default filter model fields and the given config. - * @example $config = ['label', 'multiple', 'placeholder' => 'Select a value'] - */ - public function defaultFormTypeOptions( - ConfiguredFilter $filter, - array $config = [], - ): array { - $options = []; - - /** @var array $listPart */ - $listPart = \array_filter($config, '\is_int', \ARRAY_FILTER_USE_KEY); - - foreach (self::$autoFormOptionsMap as $optionName => $attribute) - { - // Associative branch - if (\array_key_exists($optionName, $config)) - { - $value = $filter->{$attribute}; - $default = $config[$optionName]; - - if ($value === '') { - $value = $default; - } - - $option = $value ?? $default; - - if (!\is_null($option)) { - $options[$optionName] = $option; - } - - continue; - } - - // List branch - if (\in_array($optionName, $listPart, true)) - { - $options[$optionName] = $filter->{$attribute}; - } - } - - return $options; - } - - public function handleFormTypeOptions(FilterElementFormTypeOptionsEvent $event): void {} - - public function buildForm(FilterFormBuilderInterface $builder, FilterElementContext $context): void - { - if ($context->filter->isIntrinsic()) { - return; - } - - $builder->add($context); - } - - public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $invocation): void {} - - public function extractFormData(FormInterface $form): mixed - { - return $form->getData(); - } + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void {} public function isSupported(): bool { return true; } - - public function getPalette(PaletteConfig $config): ?string - { - return null; - } - - public function processRuntimeValue(mixed $value, ListSpecification $list, ConfiguredFilter $filter): mixed - { - return $value; - } - - public static function define(): ConfiguredFilter - { - throw new \LogicException('Not implemented.'); - } -} \ No newline at end of file +} diff --git a/src/FilterElement/ArchiveElement.php b/src/FilterElement/ArchiveElement.php index f8a24ae8..69067a60 100644 --- a/src/FilterElement/ArchiveElement.php +++ b/src/FilterElement/ArchiveElement.php @@ -4,35 +4,32 @@ namespace HeimrichHannot\FlareBundle\FilterElement; -use Contao\DataContainer; use Contao\Model; use Contao\Model\Collection; use Contao\StringUtil; -use HeimrichHannot\FlareBundle\Contract\Config\PaletteConfig; -use HeimrichHannot\FlareBundle\Contract\FilterElement\HydrateFormContract; -use HeimrichHannot\FlareBundle\Contract\FilterElement\IntrinsicValueContract; -use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterCallback; +use HeimrichHannot\FlareBundle\Contract\DcaContract; +use HeimrichHannot\FlareBundle\Contract\FilterElement\ConfigContract; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; -use HeimrichHannot\FlareBundle\Event\FilterElementFormTypeOptionsEvent; use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\FilterInvocation; +use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\ArchiveFilterType; +use HeimrichHannot\FlareBundle\Filter\Type\BelongsToRelationFilterType; use HeimrichHannot\FlareBundle\Form\ChoicesBuilder; use HeimrichHannot\FlareBundle\Form\Factory\ChoicesBuilderFactory; use HeimrichHannot\FlareBundle\InferPtable\Factory\PtableInferrableFactory; -use HeimrichHannot\FlareBundle\InferPtable\PtableInferrableInterface; use HeimrichHannot\FlareBundle\InferPtable\PtableInferrer; -use HeimrichHannot\FlareBundle\Model\FilterModel; -use HeimrichHannot\FlareBundle\Model\ListModel; -use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; use HeimrichHannot\FlareBundle\Specification\ListSpecification; use HeimrichHannot\FlareBundle\Util\Str; +use Symfony\Component\Form\ChoiceList\Loader\CallbackChoiceLoader; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; -use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; -#[AsFilterElement(type: self::TYPE, formType: ChoiceType::class)] -class ArchiveElement extends AbstractFilterElement implements HydrateFormContract, IntrinsicValueContract +#[AsFilterElement(type: self::TYPE)] +class ArchiveElement extends AbstractFilterElement implements ConfigContract, DcaContract { public const TYPE = 'flare_archive'; @@ -40,26 +37,158 @@ class ArchiveElement extends AbstractFilterElement implements HydrateFormContrac public function __construct( private readonly ChoicesBuilderFactory $choicesBuilderFactory, - private readonly BelongsToRelationElement $relationElement, ) {} + public function configureConfig(OptionsResolver $resolver): void + { + $resolver->define('intrinsic')->default(false)->allowedTypes('bool'); + $resolver->define('whitelist_parents')->default([])->allowedTypes('int[]'); + $resolver->define('group_whitelist_parents')->default([])->allowedTypes('array'); + $resolver->define('use_whitelist_for_options_only')->default(false)->allowedTypes('bool'); + $resolver->define('format_label')->default(null)->allowedTypes('string', 'null'); + $resolver->define('has_empty_option')->default(false)->allowedTypes('bool'); + $resolver->define('format_empty_option')->default(null)->allowedTypes('string', 'null'); + $resolver->define('is_mandatory')->default(false)->allowedTypes('bool'); + $resolver->define('is_multiple')->default(false)->allowedTypes('bool'); + $resolver->define('is_expanded')->default(false)->allowedTypes('bool'); + $resolver->define('preselect')->default([])->allowedTypes('array'); + } + + public function configFromRow(array $row): array + { + $formatLabel = ($row['formatLabel'] ?? null) === 'custom' + ? ($row['formatLabelCustom'] ?? null) + : ($row['formatLabel'] ?? null); + + $formatEmptyOption = ($row['formatEmptyOption'] ?? null) === 'custom' + ? ($row['formatEmptyOptionCustom'] ?? null) + : ($row['formatEmptyOption'] ?? null); + + return [ + 'intrinsic' => (bool) ($row['intrinsic'] ?? false), + 'whitelist_parents' => $this->normalizeIds($row['whitelistParents'] ?? null), + 'group_whitelist_parents' => $this->normalizeGroups($row['groupWhitelistParents'] ?? null), + 'use_whitelist_for_options_only' => (bool) ($row['useWhitelistForOptionsOnly'] ?? false), + 'format_label' => $formatLabel ?: null, + 'has_empty_option' => (bool) ($row['hasEmptyOption'] ?? false), + 'format_empty_option' => $formatEmptyOption ?: null, + 'is_mandatory' => (bool) ($row['isMandatory'] ?? false), + 'is_multiple' => (bool) ($row['isMultiple'] ?? false), + 'is_expanded' => (bool) ($row['isExpanded'] ?? false), + 'preselect' => StringUtil::deserialize(($row['preselect'] ?? null) ?: null, true), + ]; + } + + /** + * @throws FilterException + */ + public function buildForm(FormBuilderInterface $builder, FilterContext $context): void + { + $config = $context->config; + + if ($config['intrinsic']) { + return; + } + + $inferrer = $this->getPtableInferrer($context->list); + + $choices = $this->choicesBuilderFactory->createChoicesBuilder()->enable(); + + if ($config['has_empty_option']) + { + $emptyOptionValue = ($config['is_expanded'] && $config['is_multiple']) + ? ChoicesBuilder::EMPTY_CHOICE_VALUE_ALTERNATIVE + : null; + + $choices->setEmptyOption($config['format_empty_option'] ?: true, $emptyOptionValue); + } + + if ($ptable = $inferrer->getDcaMainPtable()) + { + $choices->setLabel($config['format_label'] ?: null); + + $parents = $this->fetchParents($ptable, $config['whitelist_parents']); + + if (!$parents) { + throw new FilterException('No whitelisted parents defined or parent table class invalid.'); + } + + foreach ($parents as $parent) + { + $choices->add((string) $parent->id, $parent); + } + } + else + { + if (!$inferrer->isDcaDynamicPtable()) + // no valid ptable available + { + throw new FilterException('No valid ptable found.'); + } + + /** + * ## We are dealing with a _dynamic ptable_ henceforth. + */ + + if (!$groups = $config['group_whitelist_parents']) + { + throw new FilterException('No whitelisted parents defined.'); + } + + foreach ($groups as $group) + { + $table = $group['table']; + + foreach ($this->fetchParents($table, $group['ids']) ?? [] as $parent) + { + $choices->add(\sprintf('%s.%s', $table, $parent->id), $parent); + } + + $choices->setLabelForTable($group['label'], $table); + } + + if (!$choices->count()) { + throw new FilterException('No valid whitelisted parents defined.'); + } + + $choices->setModelSuffix('(%@name%)'); + } + + $formOptions = [ + 'label' => false, + 'required' => $config['is_mandatory'], + 'multiple' => $config['is_multiple'], + 'expanded' => $config['is_expanded'], + 'choice_loader' => new CallbackChoiceLoader(static fn (): array => $choices->buildChoices()), + 'choice_label' => $choices->buildChoiceLabelCallback(), + 'choice_value' => $choices->buildChoiceValueCallback(), + ]; + + if (null !== $data = $this->buildPreselectData($context->list, $config['preselect'])) { + $formOptions['data'] = $data; + } + + $builder->setAttribute('flare.choices_builder', $choices); + $builder->add(FilterContext::FIELD_VALUE, ChoiceType::class, $formOptions); + } + /** * @throws FilterException */ - public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $invocation): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void { - $filter = $invocation->filter; + $config = $context->config; /** @var Model[] $selectedModels */ - $selectedModels = $filter->isIntrinsic() - ? $this->getIntrinsicValue($invocation->list, $filter) - : $this->processRuntimeValue($invocation->getValue(), $invocation->list, $filter); + $selectedModels = $config['intrinsic'] + ? $this->getWhitelistedParents($context->list, $config) + : $this->processRuntimeValue($data[FilterContext::FIELD_VALUE] ?? null, $context->list, $config); - $inferrer = $this->getPtableInferrer($invocation->list); + $inferrer = $this->getPtableInferrer($context->list); if (!$selectedModels) { - if ($filter->useWhitelistForOptionsOnly) { + if ($config['use_whitelist_for_options_only']) { return; } @@ -100,22 +229,42 @@ public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $i } } - $this->relationElement->addDynamicPtableFilter( - builder: $builder, - filter: $filter, - fieldDynamicPtable: 'ptable', - fieldPid: 'pid', - submittedData: $grouped, - ); + $builder->add(BelongsToRelationFilterType::class, [ + 'field_pid' => 'pid', + 'field_dynamic_ptable' => 'ptable', + 'parent_groups' => $this->getDynamicParentGroups($config), + 'submitted_data' => $grouped, + ]); } - protected function getWhitelistedParentIds(ListSpecification $list, ConfiguredFilter $filter): ?array + /** + * @return array + */ + protected function getDynamicParentGroups(array $config): array + { + $groups = []; + + foreach ($config['group_whitelist_parents'] as $group) + { + $groups[] = [ + 'table' => $group['table'], + 'ids' => $group['ids'], + ]; + } + + return $groups; + } + + /** + * @return array|int[] Parent IDs, either flat (main ptable) or mapped by table (dynamic ptable). + */ + protected function getWhitelistedParentIds(ListSpecification $list, array $config): array { $inferrer = $this->getPtableInferrer($list); if ($inferrer->getDcaMainPtable()) { - return $this->getParentIdsFromWhitelistBlob($filter->whitelistParents); + return $config['whitelist_parents']; } if (!$inferrer->isDcaDynamicPtable()) @@ -124,16 +273,27 @@ protected function getWhitelistedParentIds(ListSpecification $list, ConfiguredFi return []; } - return $this->getParentIdsFromGroupWhitelistBlob($filter->groupWhitelistParents); + $tableToParentIds = []; + + foreach ($config['group_whitelist_parents'] as $group) + { + $tableToParentIds[$group['table']] ??= []; + \array_push($tableToParentIds[$group['table']], ...$group['ids']); + } + + return $tableToParentIds; } - protected function getWhitelistedParents(ListSpecification $list, ConfiguredFilter $filter): array + /** + * @return Model[] + */ + protected function getWhitelistedParents(ListSpecification $list, array $config): array { $inferrer = $this->getPtableInferrer($list); if ($ptable = $inferrer->getDcaMainPtable()) { - $parents = $this->getParentsFromWhitelistBlob($ptable, $filter->whitelistParents); + $parents = $this->fetchParents($ptable, $config['whitelist_parents']); return $parents?->getModels() ?? []; } @@ -143,37 +303,44 @@ protected function getWhitelistedParents(ListSpecification $list, ConfiguredFilt return []; } - return $this->getParentsFromGroupWhitelistBlob($filter->groupWhitelistParents); - } + $allParents = []; - /** - * @return Model[] - */ - public function getIntrinsicValue(ListSpecification $list, ConfiguredFilter $filter): array - { - return $this->getWhitelistedParents($list, $filter); + foreach ($this->getWhitelistedParentIds($list, $config) as $table => $parentIds) + { + if (!$parentIds = \array_unique($parentIds)) { + continue; + } + + if (!$coll = $this->fetchParents((string) $table, $parentIds)) { + continue; + } + + \array_push($allParents, ...$coll->getModels()); + } + + return $allParents; } /** * @return Model[] */ - public function processRuntimeValue(mixed $value, ListSpecification $list, ConfiguredFilter $filter): array + public function processRuntimeValue(mixed $value, ListSpecification $list, array $config): array { $values = $this->normalizeFilterValue($value); // If no value is selected, or the empty option is selected, and the filter // applies not only to form options, we must filter by all whitelisted archives. - $useFullWhitelist = (!$values || $values === true) && !$filter->useWhitelistForOptionsOnly; + $useFullWhitelist = (!$values || $values === true) && !$config['use_whitelist_for_options_only']; if ($useFullWhitelist) { - return $this->getWhitelistedParents($list, $filter); + return $this->getWhitelistedParents($list, $config); } if (!$values || $values === true) { return []; } - if (!$allowedParentIds = $this->getWhitelistedParentIds($list, $filter)) { + if (!$allowedParentIds = $this->getWhitelistedParentIds($list, $config)) { return []; } @@ -245,13 +412,13 @@ private function getPtableInferrer(ListSpecification $list): PtableInferrer return $this->_inferrer[$cacheKey] = new PtableInferrer($inferrable, $list->dc); } - public function getPalette(PaletteConfig $config): ?string + public function configureDca(DcaBuilder $dca, DcaContext $context): void { - if (!$filterModel = $config->getFilterModel()) { - return null; + if (!$filterModel = $context->filterModel) { + return; } - $inferrer = new PtableInferrer($filterModel, $config->getListModel()->dc); + $inferrer = new PtableInferrer($filterModel, $context->listModel->dc); $palettes = []; @@ -278,146 +445,34 @@ public function getPalette(PaletteConfig $config): ?string $palettes[] = $palette; } - if (!$palettes) { - return null; - } + $dca->palette($palettes ? Str::mergePalettes(...$palettes) : null); - return Str::mergePalettes(...$palettes); + $dca->field('preselect') + ->inputType('select') + ->eval([ + 'multiple' => (bool) $filterModel->isMultiple, + 'chosen' => true, + 'includeBlankOption' => true, + ]) + ->options(fn (): array => $this->getPreselectOptions($inferrer, $filterModel->row())); } /** - * @throws FilterException + * Builds the backend options for the preselect field from the whitelisted parents. + * + * @param array $row */ - public function handleFormTypeOptions(FilterElementFormTypeOptionsEvent $event): void + private function getPreselectOptions(PtableInferrer $inferrer, array $row): array { - $filter = $event->filter; - - $dataSource = $filter->getDataSource(); - if (!$dataSource instanceof PtableInferrableInterface) { - return; - } - - $inferrer = new PtableInferrer($dataSource, $event->list->dc); - - $choices = $event->choicesBuilder->enable(); - - $event->options['required'] = (bool) $filter->isMandatory; - $event->options['multiple'] = (bool) $filter->isMultiple; - $event->options['expanded'] = (bool) $filter->isExpanded; - - if ($filter->hasEmptyOption) - { - $emptyOptionLabel = ($filter->formatEmptyOption === 'custom') - ? $filter->formatEmptyOptionCustom - : $filter->formatEmptyOption; - - $emptyOptionValue = ($filter->isExpanded && $filter->isMultiple) - ? ChoicesBuilder::EMPTY_CHOICE_VALUE_ALTERNATIVE - : null; - - $choices->setEmptyOption($emptyOptionLabel ?: true, $emptyOptionValue); - } - - if ($ptable = $inferrer->getDcaMainPtable()) - { - $label = ($filter->formatLabel === 'custom') - ? $filter->formatLabelCustom - : $filter->formatLabel; - - $label = $label ?: null; - - $choices->setLabel($label); - - $parents = $this->getParentsFromWhitelistBlob($ptable, $filter->whitelistParents); - - if (!$parents) { - throw new FilterException('No whitelisted parents defined or parent table class invalid.'); - } - - foreach ($parents as $parent) - { - $choices->add((string) $parent->id, $parent); - } - - return; - } - - if (!$inferrer->isDcaDynamicPtable()) - // no valid ptable available - { - throw new FilterException('No valid ptable found.'); - } - - /** - * ## We are dealing with a _dynamic ptable_ henceforth. - */ - - if (!$groupWhitelist = StringUtil::deserialize($filter->groupWhitelistParents, true)) - { - throw new FilterException('No whitelisted parents defined.'); - } - - foreach ($groupWhitelist as $group) - { - $table = $group['tablePtable'] ?? null; - $whitelistParents = $group['whitelistParents'] ?? null; - - if (!$table || !$whitelistParents) { - continue; - } - - $parents = $this->getParentsFromWhitelistBlob($table, $whitelistParents); - - foreach ($parents as $parent) - { - $choices->add(\sprintf('%s.%s', $table, $parent->id), $parent); - } - - $formatLabel = $group['formatLabel'] ?? null; - $formatLabel = ($formatLabel === 'custom') - ? ($group['formatLabelCustom'] ?? null) - : $formatLabel; - $formatLabel = $formatLabel ?: null; - - $choices->setLabelForTable($formatLabel, $table); - } - - if (!$choices->count()) { - throw new FilterException('No valid whitelisted parents defined.'); - } - - $choices->setModelSuffix('(%@name%)'); - } - - #[AsFilterCallback(self::TYPE, 'fields.preselect.load')] - public function onLoad_preselect( - mixed $value, - ?DataContainer $dc, - FilterModel $filterModel, - ListModel $listModel - ): mixed { - if (!$dc) { - return []; - } - - $dca = &$GLOBALS['TL_DCA'][$dc->table]['fields'][$dc->field]; - - $inferrer = new PtableInferrer($filterModel, $listModel->dc); $choices = $this->choicesBuilderFactory ->createChoicesBuilder() ->setModelSuffix('[%id%]') ->enable(); - $dca['inputType'] = 'select'; - $dca['eval']['multiple'] = $filterModel->isMultiple; - $dca['eval']['chosen'] = true; - $dca['eval']['includeBlankOption'] = true; - $dca['options_callback'] = static fn (DataContainer $dc): array => $choices->buildOptions(); - if ($ptable = $inferrer->getDcaMainPtable()) { - if (!$parents = $this->getParentsFromWhitelistBlob($ptable, $filterModel->whitelistParents)) { - return $value; + if (!$parents = $this->fetchParents($ptable, $this->normalizeIds($row['whitelistParents'] ?? null))) { + return $choices->buildOptions(); } foreach ($parents as $parent) @@ -425,156 +480,49 @@ public function onLoad_preselect( $choices->add(\sprintf('%s.%s', $ptable, $parent->id), $parent); } - return $value; + return $choices->buildOptions(); } if ($inferrer->isDcaDynamicPtable()) { $choices->setModelSuffix('[%@table%.id=%id%]'); - if (!$groupWhitelist = StringUtil::deserialize($filterModel->groupWhitelistParents)) { - return $value; - } - - foreach ($groupWhitelist as $group) + foreach ($this->normalizeGroups($row['groupWhitelistParents'] ?? null) as $group) { - $parents = $this->getParentsFromWhitelistBlob( - table: $table = $group['tablePtable'] ?? null, - blob: $group['whitelistParents'] ?? null - ); - - if (!$parents) { + if (!$parents = $this->fetchParents($group['table'], $group['ids'])) { continue; } foreach ($parents as $parent) { - $choices->add(\sprintf('%s.%s', $table, $parent->id), $parent); + $choices->add(\sprintf('%s.%s', $group['table'], $parent->id), $parent); } } } - return $value; + return $choices->buildOptions(); } /** - * @return int[]|null + * Resolves the configured preselection (numeric IDs or "table.id" references) into models, + * to be used as the choice field's initial data. + * + * @return Model[]|null */ - protected function getParentIdsFromWhitelistBlob(?string $blob): ?array + private function buildPreselectData(ListSpecification $list, array $preselect): ?array { - if (!$whitelist = StringUtil::deserialize($blob, true)) { - return null; - } - - if (!$whitelist = \array_unique(\array_filter(\array_map('\intval', $whitelist)))) { - return null; - } - - return \array_values($whitelist); - } - - protected function getParentsFromWhitelistBlob(?string $table, ?string $blob): ?Collection - { - if (!$table || !$blob) { - return null; - } - - if (!$parentModelClass = Model::getClassFromTable($table)) { - return null; - } - - if (!\class_exists($parentModelClass)) { + if (!$preselect) { return null; } - $whitelist = $this->getParentIdsFromWhitelistBlob($blob); - - return $parentModelClass::findMultipleByIds($whitelist); - } - - /** - * @return array Returns an array mapping table names to parent IDs - */ - protected function getParentIdsFromGroupWhitelistBlob(?string $blob): array - { - $groupWhitelist = StringUtil::deserialize($blob, true); - - $tableToParentIds = []; - - foreach ($groupWhitelist as $group) - { - if (!\is_array($group)) { - continue; - } - - $table = $group['tablePtable'] ?? null; - $whitelistParentsBlob = $group['whitelistParents'] ?? null; - - if (!$table || !$whitelistParentsBlob) { - continue; - } - - if (!$parentIds = $this->getParentIdsFromWhitelistBlob($whitelistParentsBlob)) { - continue; - } - - $tableToParentIds[$table] ??= []; - \array_push($tableToParentIds[$table], ...$parentIds); - } - - return $tableToParentIds; - } - - /** - * @param string|null $blob - * @return Model[] - */ - protected function getParentsFromGroupWhitelistBlob(?string $blob): array - { - $tableToParentIds = $this->getParentIdsFromGroupWhitelistBlob($blob); - - $allParents = []; - - foreach ($tableToParentIds as $table => $parentIds) - { - if (!$parentModelClass = Model::getClassFromTable($table)) { - continue; - } - - if (!\class_exists($parentModelClass)) { - continue; - } - - if (!$parentIds = \array_unique($parentIds)) { - continue; - } - - if (!$coll = $parentModelClass::findMultipleByIds($parentIds)) { - continue; - } - - \array_push($allParents, ...$coll->getModels()); - } - - return $allParents; - } - - public function hydrateForm(FormInterface $field, ListSpecification $list, ConfiguredFilter $filter): void - { - if (!$preselect = StringUtil::deserialize($filter->preselect ?: null, true)) - { - return; - } - - $ptableInferrer = static function () use (&$ptableInferrer, $list): PtableInferrer { - $inferrable = PtableInferrableFactory::createFromListModelLike($list); - $inferrer = new PtableInferrer($inferrable, $list->dc); + $ptableInferrer = function () use (&$ptableInferrer, $list): PtableInferrer { + $inferrer = $this->getPtableInferrer($list); $ptableInferrer = static fn (): PtableInferrer => $inferrer; return $inferrer; }; $ptable = static function () use (&$ptable, $ptableInferrer): string { - $pt = $ptableInferrer()->getDcaMainPtable(); + $pt = (string) $ptableInferrer()->getDcaMainPtable(); $ptable = static fn (): string => $pt; return $pt; }; @@ -638,6 +586,80 @@ public function hydrateForm(FormInterface $field, ListSpecification $list, Confi \array_push($data, ...$models); } - $field->setData($data); + return $data; + } + + /** + * @param int[] $ids + */ + protected function fetchParents(?string $table, array $ids): ?Collection + { + if (!$table || !$ids) { + return null; + } + + if (!$parentModelClass = Model::getClassFromTable($table)) { + return null; + } + + if (!\class_exists($parentModelClass)) { + return null; + } + + return $parentModelClass::findMultipleByIds(\array_values($ids)); + } + + /** + * @return int[] + */ + private function normalizeIds(mixed $blob): array + { + if (!$whitelist = StringUtil::deserialize($blob, true)) { + return []; + } + + return \array_values(\array_unique(\array_filter(\array_map('\intval', $whitelist)))); + } + + /** + * Canonicalizes the serialized group widget blob into a list of + * `{table: string, ids: int[], label: ?string}` groups. + * + * @return array + */ + private function normalizeGroups(mixed $blob): array + { + $groups = []; + + foreach (StringUtil::deserialize($blob, true) as $group) + { + if (!\is_array($group)) { + continue; + } + + $table = $group['tablePtable'] ?? null; + $whitelistParentsBlob = $group['whitelistParents'] ?? null; + + if (!$table || !$whitelistParentsBlob) { + continue; + } + + if (!$ids = $this->normalizeIds($whitelistParentsBlob)) { + continue; + } + + $formatLabel = $group['formatLabel'] ?? null; + $formatLabel = ($formatLabel === 'custom') + ? ($group['formatLabelCustom'] ?? null) + : $formatLabel; + + $groups[] = [ + 'table' => (string) $table, + 'ids' => $ids, + 'label' => $formatLabel ?: null, + ]; + } + + return $groups; } } diff --git a/src/FilterElement/BelongsToRelationElement.php b/src/FilterElement/BelongsToRelationElement.php index bc233c95..bfa9cddd 100644 --- a/src/FilterElement/BelongsToRelationElement.php +++ b/src/FilterElement/BelongsToRelationElement.php @@ -6,20 +6,23 @@ use Contao\Message; use Contao\StringUtil; -use HeimrichHannot\FlareBundle\Contract\Config\PaletteConfig; +use HeimrichHannot\FlareBundle\Contract\DcaContract; +use HeimrichHannot\FlareBundle\Contract\FilterElement\ConfigContract; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Exception\InferenceException; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\FilterInvocation; +use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\BelongsToRelationFilterType; use HeimrichHannot\FlareBundle\InferPtable\Factory\PtableInferrableFactory; use HeimrichHannot\FlareBundle\InferPtable\PtableInferrer; -use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; +use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Contracts\Translation\TranslatorInterface; -#[AsFilterElement(type: self::TYPE)] -class BelongsToRelationElement extends AbstractFilterElement +#[AsFilterElement(type: self::TYPE, intrinsicOnly: true)] +class BelongsToRelationElement extends AbstractFilterElement implements ConfigContract, DcaContract { public const TYPE = 'flare_relation_belongsTo'; @@ -27,20 +30,43 @@ public function __construct( private readonly TranslatorInterface $trans, ) {} + public function configureConfig(OptionsResolver $resolver): void + { + $resolver->define('intrinsic')->default(false)->allowedTypes('bool'); + $resolver->define('field_pid')->default(null)->allowedTypes('string', 'null'); + $resolver->define('which_ptable')->default(null)->allowedTypes('string', 'null'); + $resolver->define('whitelist_parents')->default([])->allowedTypes('array'); + $resolver->define('group_whitelist_parents')->default([])->allowedTypes('array'); + } + + public function configFromRow(array $row): array + { + $whitelistParents = StringUtil::deserialize($row['whitelistParents'] ?? null); + $groupWhitelistParents = StringUtil::deserialize($row['groupWhitelistParents'] ?? null); + + return [ + 'intrinsic' => (bool) ($row['intrinsic'] ?? false), + 'field_pid' => ($row['fieldPid'] ?? null) ?: null, + 'which_ptable' => ($row['whichPtable'] ?? null) ?: null, + 'whitelist_parents' => $whitelistParents ? (array) $whitelistParents : [], + 'group_whitelist_parents' => \is_array($groupWhitelistParents) ? $groupWhitelistParents : [], + ]; + } + /** * @throws FilterException */ - public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $invocation): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void { - $filter = $invocation->filter; + $config = $context->config; - if (!$fieldPid = $filter->fieldPid) + if (!$fieldPid = $config['field_pid']) { throw new FilterException('No parent field defined.'); } - $inferrable = PtableInferrableFactory::createFromListModelLike($invocation->list); - $inferrer = new PtableInferrer($inferrable, $invocation->list->dc); + $inferrable = PtableInferrableFactory::createFromListModelLike($context->list); + $inferrer = new PtableInferrer($inferrable, $context->list->dc); try { @@ -57,19 +83,19 @@ public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $i $builder->add(BelongsToRelationFilterType::class, [ 'field_pid' => $fieldPid, 'field_dynamic_ptable' => $fieldDynamicPtable, - 'parent_groups' => $this->getDynamicParentGroups($filter), + 'parent_groups' => $this->getDynamicParentGroups($config['group_whitelist_parents']), ]); return; } - if (!$ptable || !$whitelistParents = StringUtil::deserialize($filter->whitelistParents)) { + if (!$ptable || !$whitelistParents = $config['whitelist_parents']) { throw new FilterException('No whitelisted parents.'); } $builder->add(BelongsToRelationFilterType::class, [ 'field_pid' => $fieldPid, - 'whitelist' => (array) $whitelistParents, + 'whitelist' => $whitelistParents, ]); } @@ -81,10 +107,13 @@ public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $i * 'tl_news' => [2, 3, 4, ...], * ]; * ``` + * + * @param array $groupWhitelistParents Deserialized group whitelist, as stored in the + * `group_whitelist_parents` config key. */ public function addDynamicPtableFilter( FilterBuilderInterface $builder, - ConfiguredFilter $filter, + array $groupWhitelistParents, string $fieldDynamicPtable, string $fieldPid, ?array $submittedData = null, @@ -92,18 +121,17 @@ public function addDynamicPtableFilter( $builder->add(BelongsToRelationFilterType::class, [ 'field_pid' => $fieldPid, 'field_dynamic_ptable' => $fieldDynamicPtable, - 'parent_groups' => $this->getDynamicParentGroups($filter), + 'parent_groups' => $this->getDynamicParentGroups($groupWhitelistParents), 'submitted_data' => $submittedData, ]); } - public function getDynamicParentGroups(ConfiguredFilter $filter): array + /** + * @param array $parentGroups Deserialized group whitelist, as stored in the + * `group_whitelist_parents` config key. + */ + public function getDynamicParentGroups(array $parentGroups): array { - if (!$parentGroups = StringUtil::deserialize($filter->groupWhitelistParents)) - { - return []; - } - $groups = []; foreach (\array_values($parentGroups) as $group) @@ -130,21 +158,25 @@ public function getDynamicParentGroups(ConfiguredFilter $filter): array return $groups; } - public function getPalette(PaletteConfig $config): ?string + public function configureDca(DcaBuilder $dca, DcaContext $context): void { - $listModel = $config->getListModel(); - $filterModel = $config->getFilterModel(); + $listModel = $context->listModel; + $filterModel = $context->filterModel; - if (!$filterModel) { + if (!$filterModel) + { Message::addError($this->trans->trans('errors.missing_model', [], 'flare')); - return ''; + $dca->palette(''); + return; } - if (!$listModel->dc) { + if (!$listModel->dc) + { Message::addError($this->trans->trans('errors.missing_datacontainer', [ '%id%' => $listModel->id, ], 'flare')); - return ''; + $dca->palette(''); + return; } $palette = '{filter_legend},fieldPid,whichPtable'; @@ -192,6 +224,6 @@ public function getPalette(PaletteConfig $config): ?string $palette .= ',whitelistParents'; } - return $palette; + $dca->palette($palette); } } diff --git a/src/FilterElement/BooleanElement.php b/src/FilterElement/BooleanElement.php index 7bfc0b4b..3d19d362 100644 --- a/src/FilterElement/BooleanElement.php +++ b/src/FilterElement/BooleanElement.php @@ -6,46 +6,73 @@ use Contao\Controller; use Contao\Message; -use HeimrichHannot\FlareBundle\Contract\Config\PaletteConfig; -use HeimrichHannot\FlareBundle\Contract\FilterElement\IntrinsicValueContract; -use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterCallback; +use HeimrichHannot\FlareBundle\Contract\DcaContract; +use HeimrichHannot\FlareBundle\Contract\FilterElement\ConfigContract; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Enum\BoolBinaryChoices; use HeimrichHannot\FlareBundle\Enum\BoolMode; -use HeimrichHannot\FlareBundle\Event\FilterElementFormTypeOptionsEvent; -use HeimrichHannot\FlareBundle\Exception\FilterException; +use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\FilterInvocation; +use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\BooleanFilterType; -use HeimrichHannot\FlareBundle\Model\FilterModel; -use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; -#[AsFilterElement( - type: self::TYPE, - palette: '{filter_legend},fieldGeneric,preselect', - formType: CheckboxType::class, - isTargeted: true, -)] -class BooleanElement extends AbstractFilterElement implements IntrinsicValueContract +#[AsFilterElement(type: self::TYPE, isTargeted: true)] +class BooleanElement extends AbstractFilterElement implements ConfigContract, DcaContract { public const TYPE = 'flare_bool'; - /** - * @throws FilterException - */ - public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $invocation): void + public function configureConfig(OptionsResolver $resolver): void { - $filter = $invocation->filter; + $resolver->define('intrinsic')->default(false)->allowedTypes('bool'); + $resolver->define('field')->default(null)->allowedTypes('string', 'null'); + $resolver->define('preselect')->default(null)->allowedTypes('bool', 'null'); + $resolver->define('mode')->default(BoolMode::BINARY)->allowedTypes(BoolMode::class); + $resolver->define('binary_choices')->default(BoolBinaryChoices::NULL_TRUE)->allowedTypes(BoolBinaryChoices::class); + $resolver->define('label')->default(null)->allowedTypes('string', 'null'); + } + + public function configFromRow(array $row): array + { + return [ + 'intrinsic' => (bool) ($row['intrinsic'] ?? false), + 'field' => ($row['fieldGeneric'] ?? null) ?: null, + 'preselect' => $this->normalizeValue($row['preselect'] ?? null), + 'mode' => BoolMode::tryFrom($row['boolMode'] ?? '') ?? BoolMode::BINARY, + 'binary_choices' => BoolBinaryChoices::tryFrom($row['boolBinaryChoices'] ?? '') ?? BoolBinaryChoices::NULL_TRUE, + 'label' => ($row['label'] ?? null) ?: (($row['title'] ?? null) ?: null), + ]; + } - if (!$targetField = $filter->fieldGeneric) { + public function buildForm(FormBuilderInterface $builder, FilterContext $context): void + { + $config = $context->config; + + if ($config['intrinsic']) { + return; + } + + $builder->add(FilterContext::FIELD_VALUE, CheckboxType::class, [ + 'label' => $config['label'] ?? 'CBX', + 'required' => false, + ]); + } + + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void + { + $config = $context->config; + + if (!$targetField = $config['field']) { $builder->abort(); } - $value = $filter->isIntrinsic() - ? $this->getIntrinsicValue($invocation->list, $filter) - : $this->processRuntimeValue($invocation->getValue(), $invocation->list, $filter); + $value = $config['intrinsic'] + ? $config['preselect'] + : $this->resolveRuntimeValue($data[FilterContext::FIELD_VALUE] ?? null, $config); if ($value === null) { return; @@ -57,24 +84,11 @@ public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $i ]); } - public function getIntrinsicValue(ListSpecification $list, ConfiguredFilter $filter): bool + private function resolveRuntimeValue(mixed $value, array $config): ?bool { - return (bool) $this->normalizeValue($filter->preselect); - } + $choices = $config['mode'] === BoolMode::BINARY ? $config['binary_choices'] : null; - public function processRuntimeValue(mixed $value, ListSpecification $list, ConfiguredFilter $filter): ?bool - { - $mode = BoolMode::tryFrom($filter->boolMode ?: '') ?? BoolMode::BINARY; - - $boolBinaryChoices = match ($mode) { - BoolMode::BINARY => - BoolBinaryChoices::tryFrom($filter->boolBinaryChoices ?: '') - ?? BoolBinaryChoices::NULL_TRUE, - default => null, - }; - - return $this->normalizeValue($value, $boolBinaryChoices) - ?? $this->normalizeValue($filter->preselect); + return $this->normalizeValue($value, $choices) ?? $config['preselect']; } public function normalizeValue(mixed $value, ?BoolBinaryChoices $choices = null): ?bool @@ -96,35 +110,37 @@ public function normalizeValue(mixed $value, ?BoolBinaryChoices $choices = null) return \filter_var($value, \FILTER_VALIDATE_BOOLEAN, \FILTER_NULL_ON_FAILURE); } - #[AsFilterCallback(self::TYPE, 'config.onload')] - public function onLoadConfig(FilterModel $filterModel): void + public function configureDca(DcaBuilder $dca, DcaContext $context): void { - $table = FilterModel::getTable(); - $fields = &$GLOBALS['TL_DCA'][$table]['fields']; - - ###> preselect - $field = &$fields['preselect']; - $field['inputType'] = 'select'; - $field['eval']['includeBlankOption'] = false; - $field['eval']['chosen'] = false; - $field['options'] = [ + $intrinsic = (bool) $context->filterModel?->intrinsic; + + $dca->palette($intrinsic + ? '{filter_legend},fieldGeneric,preselect' + : '{filter_legend},fieldGeneric,label,boolMode,preselect'); + + $preselectOptions = [ 'null' => 'flare.bool_preselect.null', 'true' => 'flare.bool_preselect.true', 'false' => 'flare.bool_preselect.false', ]; - if ($filterModel->intrinsic) { - unset($field['options']['null']); + if ($intrinsic) { + unset($preselectOptions['null']); } - ###< preselect + $dca->field('preselect') + ->inputType('select') + ->eval(['includeBlankOption' => false, 'chosen' => false]) + ->options($preselectOptions); - if ($filterModel->boolMode === BoolMode::TERNARY->value) { + $dca->field('fieldGeneric') + ->options(fn (): array => $this->getFieldGenericOptions($context->getTargetTable())); + + if ($context->filterModel?->boolMode === BoolMode::TERNARY->value) { Message::addError('The ternary mode is currently not supported by the boolean filter element. Please use the binary mode instead.'); } } - #[AsFilterCallback(self::TYPE, 'fields.fieldGeneric.options')] public function getFieldGenericOptions(string $targetTable): array { Controller::loadDataContainer($targetTable); @@ -153,34 +169,17 @@ public function getFieldGenericOptions(string $targetTable): array return $options; } - public function handleFormTypeOptions(FilterElementFormTypeOptionsEvent $event): void - { - $filter = $event->filter; - $event->options['label'] = $filter->label ?: $filter->title ?: 'CBX'; - $event->options['required'] = false; - } - - public function getPalette(PaletteConfig $config): ?string - { - if ($config->getFilterModel()->intrinsic) { - return null; - } - - return '{filter_legend},fieldGeneric,label,boolMode,preselect'; - } - public static function define( ?string $targetField = null, ?bool $expectedValue = null, - ): ConfiguredFilter { - $definition = new ConfiguredFilter( - type: static::TYPE, - intrinsic: true, + ): Filter { + return new Filter( + element: static::TYPE, + config: [ + 'intrinsic' => true, + 'field' => $targetField, + 'preselect' => (bool) $expectedValue, + ], ); - - $definition->fieldGeneric = $targetField; - $definition->preselect = (string) (bool) $expectedValue; - - return $definition; } } diff --git a/src/FilterElement/CalendarCurrentElement.php b/src/FilterElement/CalendarCurrentElement.php index 4e30b056..03d8f3c4 100644 --- a/src/FilterElement/CalendarCurrentElement.php +++ b/src/FilterElement/CalendarCurrentElement.php @@ -4,50 +4,118 @@ namespace HeimrichHannot\FlareBundle\FilterElement; -use HeimrichHannot\FlareBundle\Contract\Config\PaletteConfig; +use HeimrichHannot\FlareBundle\Contract\DcaContract; +use HeimrichHannot\FlareBundle\Contract\FilterElement\ConfigContract; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Engine\Context\ValidationContext; -use HeimrichHannot\FlareBundle\Event\FilterElementFormTypeOptionsEvent; -use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\FilterInvocation; +use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\CalendarCurrentFilterType; -use HeimrichHannot\FlareBundle\Form\Type\DateRangeFilterType; -use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; use HeimrichHannot\FlareBundle\Util\DateTimeHelper; - -#[AsFilterElement( - type: self::TYPE, - formType: DateRangeFilterType::class, -)] -class CalendarCurrentElement extends AbstractFilterElement +use Symfony\Component\Form\Extension\Core\Type\DateType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormError; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Contracts\Translation\TranslatorInterface; + +#[AsFilterElement(type: self::TYPE)] +class CalendarCurrentElement extends AbstractFilterElement implements ConfigContract, DcaContract { public const TYPE = 'flare_calendar_current'; - /** - * @throws FilterException - */ - public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $invocation): void + public function __construct( + private readonly TranslatorInterface $translator, + ) {} + + public function configureConfig(OptionsResolver $resolver): void + { + $resolver->define('intrinsic')->default(false)->allowedTypes('bool'); + $resolver->define('is_limited')->default(false)->allowedTypes('bool'); + $resolver->define('configure_start')->default(null)->allowedTypes('string', 'null'); + $resolver->define('configure_stop')->default(null)->allowedTypes('string', 'null'); + $resolver->define('start_at')->default(null)->allowedTypes('string', 'null'); + $resolver->define('stop_at')->default(null)->allowedTypes('string', 'null'); + $resolver->define('has_extended_events')->default(false)->allowedTypes('bool'); + } + + public function configFromRow(array $row): array { - $filter = $invocation->filter; + return [ + 'intrinsic' => (bool) ($row['intrinsic'] ?? false), + 'is_limited' => (bool) ($row['isLimited'] ?? false), + 'configure_start' => ($row['configureStart'] ?? null) ?: null, + 'configure_stop' => ($row['configureStop'] ?? null) ?: null, + 'start_at' => ($row['startAt'] ?? null) ?: null, + 'stop_at' => ($row['stopAt'] ?? null) ?: null, + 'has_extended_events' => (bool) ($row['hasExtendedEvents'] ?? false), + ]; + } - if (!$filter->isLimited && $invocation->context instanceof ValidationContext) { + public function buildForm(FormBuilderInterface $builder, FilterContext $context): void + { + $config = $context->config; + + if ($config['intrinsic']) { return; } - $value = $this->processRuntimeValue($invocation->getValue(), $invocation->list, $filter) ?? []; + [$min, $max] = $this->resolveFormLimits($config); + + $minAttr = $min?->format('Y-m-d'); + $maxAttr = null; + + if ($max !== null) { + $maxAttr = \DateTime::createFromInterface($max)->modify('-1 second')->format('Y-m-d'); + } + + $attr = \array_filter([ + 'min' => $minAttr, + 'max' => $maxAttr, + ]); + + $builder->add('from', DateType::class, [ + 'widget' => 'single_text', + 'label' => 'label.date_range.from', + 'html5' => true, + 'required' => false, + 'attr' => $attr, + ]); + + $builder->add('to', DateType::class, [ + 'widget' => 'single_text', + 'label' => 'label.date_range.to', + 'html5' => true, + 'required' => false, + 'attr' => $attr, + ]); + + $builder->addEventListener(FormEvents::POST_SUBMIT, $this->validateRange(...)); + } + + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void + { + $config = $context->config; + + if (!$config['is_limited'] && $context->engineContext instanceof ValidationContext) { + return; + } + + $value = $this->processRuntimeValue($data) ?? []; $from = $value['from'] ?? null; $to = $value['to'] ?? null; - $start = \strtotime($filter->startAt) ?: 0; - $stop = \strtotime($filter->stopAt) ?: DateTimeHelper::maxTimestamp(); + $start = \strtotime((string) $config['start_at']) ?: 0; + $stop = \strtotime((string) $config['stop_at']) ?: DateTimeHelper::maxTimestamp(); if ($from instanceof \DateTimeInterface) { $from = $from->getTimestamp(); - if (!$filter->isLimited || $from >= $start) { + if (!$config['is_limited'] || $from >= $start) { $start = $from; } } @@ -56,7 +124,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $i { $to = $to->getTimestamp(); - if (!$filter->isLimited || $to <= $stop) { + if (!$config['is_limited'] || $to <= $stop) { $stop = $to; } } @@ -64,16 +132,62 @@ public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $i $builder->add(CalendarCurrentFilterType::class, [ 'start' => $start, 'stop' => $stop, - 'has_extended_events' => (bool) $filter->hasExtendedEvents, + 'has_extended_events' => $config['has_extended_events'], ]); } - public function processRuntimeValue(mixed $value, ListSpecification $list, ConfiguredFilter $filter): ?array + public function configureDca(DcaBuilder $dca, DcaContext $context): void { - if (!\is_array($value)) { - return null; + $palette = '{date_start_legend},configureStart,hasExtendedEvents;{date_stop_legend},configureStop;'; + + if (!$context->filterModel?->intrinsic) { + $palette .= '{form_legend},isLimited;'; } + $dca->palette($palette); + } + + /** + * Resolves the form's lower and upper date limits from the canonical config, replicating + * the former handleFormTypeOptions() logic (from_min/to_min and from_max/to_max). + * + * @param array $config + * + * @return array{0: ?\DateTime, 1: ?\DateTime} + */ + private function resolveFormLimits(array $config): array + { + if (!$config['is_limited']) { + return [null, null]; + } + + $min = null; + $max = null; + + if ($config['configure_start'] + && $config['start_at'] + && ($startAt = \strtotime($config['start_at']))) + { + $min = DateTimeHelper::timestampToDateTime($startAt); + } + + if ($config['configure_stop'] + && $config['stop_at'] + && ($stopAt = \strtotime($config['stop_at']))) + { + $max = DateTimeHelper::timestampToDateTime($stopAt); + } + + return [$min, $max]; + } + + /** + * @param array $value + * + * @return array{from: ?\DateTimeInterface, to: ?\DateTimeInterface}|null + */ + private function processRuntimeValue(array $value): ?array + { if (!\array_key_exists('from', $value) && !\array_key_exists('to', $value)) { if (\count($value) !== 2) @@ -89,12 +203,9 @@ public function processRuntimeValue(mixed $value, ListSpecification $list, Confi ]; } - $from = $value['from'] ?? null; - $to = $value['to'] ?? null; - return [ - 'from' => $this->mixedToDateTime($from), - 'to' => $this->mixedToDateTime($to), + 'from' => $this->mixedToDateTime($value['from'] ?? null), + 'to' => $this->mixedToDateTime($value['to'] ?? null), ]; } @@ -109,7 +220,7 @@ private function mixedToDateTime(mixed $input): ?\DateTimeInterface } if (\is_numeric($input)) { - return \DateTimeImmutable::createFromFormat('U', $input); + return \DateTimeImmutable::createFromFormat('U', (string) $input) ?: null; } if (\is_string($input)) { @@ -119,45 +230,20 @@ private function mixedToDateTime(mixed $input): ?\DateTimeInterface return null; } - public function getPalette(PaletteConfig $config): ?string - { - $filterModel = $config->getFilterModel(); - - $palette = '{date_start_legend},configureStart,hasExtendedEvents;{date_stop_legend},configureStop;'; - - if (!$filterModel?->intrinsic) { - $palette .= '{form_legend},isLimited;'; - } - - return $palette; - } - - public function handleFormTypeOptions(FilterElementFormTypeOptionsEvent $event): void + /** + * Ensures `from` <= `to`, replicating the former compound form type's callback constraint. + */ + private function validateRange(FormEvent $event): void { - $event->options['required'] = false; - - $filter = $event->filter; + $form = $event->getForm(); - if (!$filter->isLimited) { - return; - } + $from = $form->has('from') ? $form->get('from')->getData() : null; + $to = $form->has('to') ? $form->get('to')->getData() : null; - if ($filter->configureStart - && $filter->startAt - && ($startAt = \strtotime($filter->startAt)) - && ($startAt = DateTimeHelper::timestampToDateTime($startAt))) - { - $event->options['from_min'] = $startAt; - $event->options['to_min'] = $startAt; - } - - if ($filter->configureStop - && $filter->stopAt - && ($stopAt = \strtotime($filter->stopAt)) - && ($stopAt = DateTimeHelper::timestampToDateTime($stopAt))) - { - $event->options['from_max'] = $stopAt; - $event->options['to_max'] = $stopAt; + if ($from instanceof \DateTimeInterface && $to instanceof \DateTimeInterface && $from > $to) { + $form->get('from')->addError(new FormError( + $this->translator->trans('flare.form.date_range.to_greater_than_from', [], 'validators'), + )); } } } diff --git a/src/FilterElement/CallbackFilterElement.php b/src/FilterElement/CallbackFilterElement.php new file mode 100644 index 00000000..cbea313f --- /dev/null +++ b/src/FilterElement/CallbackFilterElement.php @@ -0,0 +1,39 @@ +): void $buildFilter + * @param (\Closure(FormBuilderInterface, FilterContext): void)|null $buildForm + */ + public function __construct( + private \Closure $buildFilter, + private ?\Closure $buildForm = null, + ) {} + + public function buildForm(FormBuilderInterface $builder, FilterContext $context): void + { + if ($this->buildForm) { + ($this->buildForm)($builder, $context); + } + } + + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void + { + ($this->buildFilter)($builder, $context, $data); + } +} diff --git a/src/FilterElement/DateRangeElement.php b/src/FilterElement/DateRangeElement.php index 04627520..e5d4f1e3 100644 --- a/src/FilterElement/DateRangeElement.php +++ b/src/FilterElement/DateRangeElement.php @@ -4,43 +4,104 @@ namespace HeimrichHannot\FlareBundle\FilterElement; +use HeimrichHannot\FlareBundle\Contract\DcaContract; +use HeimrichHannot\FlareBundle\Contract\FilterElement\ConfigContract; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; -use HeimrichHannot\FlareBundle\Event\FilterElementFormTypeOptionsEvent; use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\FilterInvocation; -use HeimrichHannot\FlareBundle\Filter\Type\DateRangeFilterType as DateRangeQueryFilterType; -use HeimrichHannot\FlareBundle\Form\Type\DateRangeFilterType; - -#[AsFilterElement( - type: self::TYPE, - palette: 'fieldGeneric', - formType: DateRangeFilterType::class, -)] -class DateRangeElement extends AbstractFilterElement +use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\Type\DateRangeFilterType; +use Symfony\Component\Form\Extension\Core\Type\DateType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormError; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Contracts\Translation\TranslatorInterface; + +#[AsFilterElement(type: self::TYPE)] +class DateRangeElement extends AbstractFilterElement implements ConfigContract, DcaContract { public const TYPE = 'flare_dateRange'; + public function __construct( + private readonly TranslatorInterface $translator, + ) {} + + public function configureConfig(OptionsResolver $resolver): void + { + $resolver->define('intrinsic')->default(false)->allowedTypes('bool'); + $resolver->define('field')->default(null)->allowedTypes('string', 'null'); + } + + public function configFromRow(array $row): array + { + return [ + 'intrinsic' => (bool) ($row['intrinsic'] ?? false), + 'field' => ($row['fieldGeneric'] ?? null) ?: null, + ]; + } + + public function buildForm(FormBuilderInterface $builder, FilterContext $context): void + { + if ($context->config['intrinsic']) { + return; + } + + $builder->add('from', DateType::class, [ + 'widget' => 'single_text', + 'label' => 'label.date_range.from', + 'html5' => true, + 'required' => false, + ]); + + $builder->add('to', DateType::class, [ + 'widget' => 'single_text', + 'label' => 'label.date_range.to', + 'html5' => true, + 'required' => false, + ]); + + $builder->addEventListener(FormEvents::POST_SUBMIT, $this->validateRange(...)); + } + /** * @throws FilterException */ - public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $invocation): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void { - $value = (array) ($invocation->getValue() ?: []); - - if (!$field = $invocation->filter->fieldGeneric) { + if (!$field = $context->config['field']) { throw new FilterException('Set fieldGeneric in filter model.'); } - $builder->add(DateRangeQueryFilterType::class, [ + $builder->add(DateRangeFilterType::class, [ 'field' => $field, - 'from' => $value['from'] ?? null, - 'to' => $value['to'] ?? null, + 'from' => $data['from'] ?? null, + 'to' => $data['to'] ?? null, ]); } - public function handleFormTypeOptions(FilterElementFormTypeOptionsEvent $event): void + public function configureDca(DcaBuilder $dca, DcaContext $context): void { - $event->options['required'] = false; + $dca->palette('fieldGeneric'); + } + + /** + * Ensures `from` <= `to`, replicating the former compound form type's callback constraint. + */ + private function validateRange(FormEvent $event): void + { + $form = $event->getForm(); + + $from = $form->has('from') ? $form->get('from')->getData() : null; + $to = $form->has('to') ? $form->get('to')->getData() : null; + + if ($from instanceof \DateTimeInterface && $to instanceof \DateTimeInterface && $from > $to) { + $form->get('from')->addError(new FormError( + $this->translator->trans('flare.form.date_range.to_greater_than_from', [], 'validators'), + )); + } } } diff --git a/src/FilterElement/DcaSelectFieldElement.php b/src/FilterElement/DcaSelectFieldElement.php index 253d01f7..4de1a9e8 100644 --- a/src/FilterElement/DcaSelectFieldElement.php +++ b/src/FilterElement/DcaSelectFieldElement.php @@ -8,42 +8,108 @@ use Contao\DataContainer; use Contao\StringUtil; use Contao\System; -use HeimrichHannot\FlareBundle\Contract\Config\PaletteConfig; -use HeimrichHannot\FlareBundle\Contract\FilterElement\HydrateFormContract; -use HeimrichHannot\FlareBundle\Contract\FilterElement\IntrinsicValueContract; -use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterCallback; +use HeimrichHannot\FlareBundle\Contract\DcaContract; +use HeimrichHannot\FlareBundle\Contract\FilterElement\ConfigContract; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; -use HeimrichHannot\FlareBundle\Event\FilterElementFormTypeOptionsEvent; -use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\FilterInvocation; +use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\DcaSelectFilterType; -use HeimrichHannot\FlareBundle\Model\FilterModel; -use HeimrichHannot\FlareBundle\Model\ListModel; -use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Form\Factory\ChoicesBuilderFactory; +use Symfony\Component\Form\ChoiceList\Loader\CallbackChoiceLoader; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; -use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; -#[AsFilterElement( - type: self::TYPE, - formType: ChoiceType::class, -)] -class DcaSelectFieldElement extends AbstractFilterElement implements HydrateFormContract, IntrinsicValueContract +#[AsFilterElement(type: self::TYPE)] +class DcaSelectFieldElement extends AbstractFilterElement implements ConfigContract, DcaContract { public const TYPE = 'flare_dcaSelectField'; - /** - * @throws FilterException - */ - public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $invocation): void + public function __construct( + private readonly ChoicesBuilderFactory $choicesBuilderFactory, + ) {} + + public function configureConfig(OptionsResolver $resolver): void + { + $resolver->define('intrinsic')->default(false)->allowedTypes('bool'); + $resolver->define('field')->default(null)->allowedTypes('string', 'null'); + $resolver->define('is_multiple')->default(false)->allowedTypes('bool'); + $resolver->define('is_expanded')->default(false)->allowedTypes('bool'); + $resolver->define('is_mandatory')->default(false)->allowedTypes('bool'); + $resolver->define('label')->default(null)->allowedTypes('string', 'null'); + $resolver->define('placeholder')->default(null)->allowedTypes('string', 'null'); + $resolver->define('preselect')->default(null); + } + + public function configFromRow(array $row): array { - $filter = $invocation->filter; - $options = $this->getOptions($invocation->list, $filter) ?? []; + $isMultiple = (bool) ($row['isMultiple'] ?? false); + + return [ + 'intrinsic' => (bool) ($row['intrinsic'] ?? false), + 'field' => ($row['fieldGeneric'] ?? null) ?: null, + 'is_multiple' => $isMultiple, + 'is_expanded' => (bool) ($row['isExpanded'] ?? false), + 'is_mandatory' => (bool) ($row['isMandatory'] ?? false), + 'label' => ($row['label'] ?? null) ?: null, + 'placeholder' => ($row['placeholder'] ?? null) ?: null, + 'preselect' => $isMultiple + ? StringUtil::deserialize(($row['preselect'] ?? null) ?: null) + : (($row['preselect'] ?? null) ?: null), + ]; + } + + public function buildForm(FormBuilderInterface $builder, FilterContext $context): void + { + $config = $context->config; + + if ($config['intrinsic']) { + return; + } - $selected = $filter->isIntrinsic() - ? $this->getIntrinsicValue($invocation->list, $filter) - : $invocation->getValue(); + $options = $this->getOptions($context->list->dc, $config['field']); + + $formOptions = [ + 'label' => $config['label'] ?: false, + 'multiple' => $config['is_multiple'], + 'expanded' => $config['is_expanded'], + 'required' => $config['is_mandatory'], + 'placeholder' => $config['placeholder'] + ?: ($config['is_mandatory'] ? 'empty_option.prompt' : 'empty_option.no_selection'), + ]; + + if (!\is_null($options)) + { + $choicesBuilder = $this->choicesBuilderFactory->createChoicesBuilder()->enable(); + + foreach ($options as $value => $label) { + $choicesBuilder->add((string) $value, (string) $label); + } + + $formOptions['choice_loader'] = new CallbackChoiceLoader(static fn (): array => $choicesBuilder->buildChoices()); + $formOptions['choice_label'] = $choicesBuilder->buildChoiceLabelCallback(); + $formOptions['choice_value'] = $choicesBuilder->buildChoiceValueCallback(); + + $builder->setAttribute('flare.choices_builder', $choicesBuilder); + } + + if (null !== $data = $this->buildPreselectData($config['preselect'], $options ?? [])) { + $formOptions['data'] = $data; + } + + $builder->add(FilterContext::FIELD_VALUE, ChoiceType::class, $formOptions); + } + + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void + { + $config = $context->config; + $options = $this->getOptions($context->list->dc, $config['field']) ?? []; + + $selected = $config['intrinsic'] + ? $config['preselect'] + : $this->normalizeSubmittedValue($data[FilterContext::FIELD_VALUE] ?? null, $options); if (!$selected) { return; @@ -57,11 +123,11 @@ public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $i $builder->abort(); } - if (!$targetField = $filter->fieldGeneric) { + if (!$targetField = $config['field']) { $builder->abort(); } - $dcaOptionsField = $this->getOptionsField($invocation->list, $filter) ?? []; + $dcaOptionsField = $this->getOptionsField($context->list->dc, $config['field']) ?? []; $isMultiple = $dcaOptionsField['eval']['multiple'] ?? false; $builder->add(DcaSelectFilterType::class, [ @@ -72,59 +138,27 @@ public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $i ]); } - public function getPalette(PaletteConfig $config): ?string - { - $palette = '{filter_legend},fieldGeneric,isMultiple,preselect'; - - if (!$config->getFilterModel()->intrinsic) { - $palette .= ';{form_legend},isExpanded,isMandatory,label,placeholder'; - } - - return $palette; - } - - public function getIntrinsicValue(ListSpecification $list, ConfiguredFilter $filter): mixed - { - return $this->getPreselectValue($filter); - } - - public function getPreselectValue(ConfiguredFilter $filter): mixed - { - return $filter->isMultiple - ? StringUtil::deserialize($filter->preselect ?: null) - : $filter->preselect; - } - - public function extractFormData(FormInterface $form): mixed - { - return $form->getViewData(); - } - - public function hydrateForm(FormInterface $field, ListSpecification $list, ConfiguredFilter $filter): void + /** + * Computes the initial choice data from the configured preselection, mirroring how the + * form would present it: scalar preselect keys are mapped to their option labels. + */ + private function buildPreselectData(mixed $preselect, array $options): mixed { - if ($field->isSubmitted()) { - return; - } - - if (!$preselect = $this->getPreselectValue($filter)) { - return; + if (!$preselect) { + return null; } - $options = $this->getOptions($list, $filter) ?? []; - if (!\is_array($preselect)) { if (!\is_scalar($preselect)) { - $field->setData($preselect); - return; + return $preselect; } if (!$option = $options[$preselect] ?? null) { - return; + return null; } - $field->setData($option); - return; + return (string) $option; } $data = []; @@ -137,108 +171,105 @@ public function hydrateForm(FormInterface $field, ListSpecification $list, Confi } if ($option = $options[$value] ?? null) { - $data[] = $option; + $data[] = (string) $option; } } - $field->setData($data); + return $data; } - public function handleFormTypeOptions(FilterElementFormTypeOptionsEvent $event): void + /** + * Maps submitted choice data (option labels) back to the option keys the filter query + * expects, mirroring the choice value callback of the form's choices. + */ + private function normalizeSubmittedValue(mixed $value, array $options): mixed { - $list = $event->list; - $filter = $event->filter; - - $emptyPlaceholder = $filter->isMandatory ? 'empty_option.prompt' : 'empty_option.no_selection'; - - $event->options['multiple'] = (bool) $filter->isMultiple; - $event->options['expanded'] = (bool) $filter->isExpanded; - $event->options['required'] = (bool) $filter->isMandatory; - $event->options['placeholder'] = $filter->placeholder ?: $emptyPlaceholder; - - if ($filter->label) { - $event->options['label'] = $filter->label; + if (\is_null($value)) { + return null; } - if (\is_null($options = $this->getOptions($list, $filter))) { - return; + $choices = []; + foreach ($options as $key => $label) { + $choices[(string) $key] = (string) $label; } - $choices = $event->choicesBuilder->enable(); + $toKey = static function (mixed $choice) use ($choices): string { + $key = \array_search($choice, $choices, true); + return ($key === false) ? '' : (string) $key; + }; - foreach ($options as $value => $label) { - $choices->add((string) $value, (string) $label); + if (\is_array($value)) { + return \array_map($toKey, $value); } + + return $toKey($value); } - #[AsFilterCallback(self::TYPE, 'config.onload')] - public function onLoadConfig(FilterModel $filterModel): void + public function configureDca(DcaBuilder $dca, DcaContext $context): void { - $table = FilterModel::getTable(); - $fields = &$GLOBALS['TL_DCA'][$table]['fields']; - - ###> fieldGeneric - $field = &$fields['fieldGeneric']; - $field['eval']['alwaysSave'] = true; - $field['eval']['submitOnChange'] = true; - ###< fieldGeneric - - ###> isMultiple - $field = &$fields['isMultiple']; - $field['eval']['submitOnChange'] = true; - ###< isMultiple - - ###> preselect - $field = &$fields['preselect']; - $field['inputType'] = 'select'; - $field['eval']['includeBlankOption'] = true; - $field['eval']['multiple'] = $filterModel->isMultiple; - $field['eval']['chosen'] = true; - ###< preselect + $intrinsic = (bool) $context->filterModel?->intrinsic; + + $palette = '{filter_legend},fieldGeneric,isMultiple,preselect'; + + if (!$intrinsic) { + $palette .= ';{form_legend},isExpanded,isMandatory,label,placeholder'; + } + + $dca->palette($palette); + + $dca->field('fieldGeneric') + ->eval(['alwaysSave' => true, 'submitOnChange' => true]) + ->options(fn (): array => $this->getFieldGenericOptions($context->listModel->dc)); + + $dca->field('isMultiple') + ->eval(['submitOnChange' => true]); + + $preselect = $dca->field('preselect') + ->inputType('select') + ->eval([ + 'includeBlankOption' => true, + 'multiple' => (bool) $context->filterModel?->isMultiple, + 'chosen' => true, + ]); + + $table = $context->listModel->dc; + + if ($optionsField = $this->getOptionsField($table, (string) $context->filterModel?->fieldGeneric)) + { + $preselect + ->merge(['reference' => $optionsField['reference'] ?? []]) + ->options(fn (): array => $this->tryGetOptionsFromField($table, $optionsField) ?? []); + } + else + { + $preselect->options([]); + } } - #[AsFilterCallback(self::TYPE, 'fields.fieldGeneric.options')] - public function getFieldGenericOptions(ListModel $listModel): array + public function getFieldGenericOptions(string $table): array { - Controller::loadDataContainer($listModel->dc); + Controller::loadDataContainer($table); - if (!isset($GLOBALS['TL_DCA'][$listModel->dc]['fields'])) { + if (!isset($GLOBALS['TL_DCA'][$table]['fields'])) { return []; } // find all fields with a type of select $options = []; - foreach ($GLOBALS['TL_DCA'][$listModel->dc]['fields'] as $name => $field) + foreach ($GLOBALS['TL_DCA'][$table]['fields'] as $name => $field) { if ('select' === ($field['inputType'] ?? null)) { - $options[$name] = $listModel->dc . '.' . $name; + $options[$name] = $table . '.' . $name; } } return $options; } - #[AsFilterCallback(self::TYPE, 'fields.preselect.options')] - public function getPreselectOptions(ListModel $listModel, FilterModel $filterModel): array - { - if (!$field = $this->getOptionsField($listModel, $filterModel)) { - return []; - } - - if (!($preselectField = &$GLOBALS['TL_DCA'][FilterModel::getTable()]['fields']['preselect'])) { - return []; - } - - $preselectField['reference'] = $field['reference'] ?? []; - $preselectField['eval']['multiple'] = (bool) $filterModel->isMultiple; - - return $this->tryGetOptionsFromField($listModel, $field) ?? []; - } - - public function getOptions(ListSpecification $list, ConfiguredFilter $filter): ?array + public function getOptions(string $table, ?string $field): ?array { - $optionsField = $this->getOptionsField($list, $filter) ?? []; - $options = $this->tryGetOptionsFromField($list, $optionsField); + $optionsField = $this->getOptionsField($table, $field) ?? []; + $options = $this->tryGetOptionsFromField($table, $optionsField); if (!\is_array($options)) { @@ -261,15 +292,19 @@ public function getOptions(ListSpecification $list, ConfiguredFilter $filter): ? return $options; } - public function getOptionsField(ListModel|ListSpecification $list, FilterModel|ConfiguredFilter $filter): ?array + public function getOptionsField(string $table, ?string $field): ?array { - Controller::loadLanguageFile($list->dc); - Controller::loadDataContainer($list->dc); + if (!$table || !$field) { + return null; + } + + Controller::loadLanguageFile($table); + Controller::loadDataContainer($table); - return $GLOBALS['TL_DCA'][$list->dc]['fields'][$filter->fieldGeneric] ?? null; + return $GLOBALS['TL_DCA'][$table]['fields'][$field] ?? null; } - protected function tryGetOptionsFromField(ListModel|ListSpecification $list, array $optionsField): ?array + protected function tryGetOptionsFromField(string $table, array $optionsField): ?array { if (\is_array($options = $optionsField['options'] ?? null)) { @@ -278,7 +313,7 @@ protected function tryGetOptionsFromField(ListModel|ListSpecification $list, arr if ($optionsCallback = $optionsField['options_callback'] ?? null) { - $dataContainer = $this->mockDataContainerObject($list->dc); + $dataContainer = $this->mockDataContainerObject($table); if (\is_string($optionsCallback) && \str_contains($optionsCallback, '::')) { @@ -341,4 +376,4 @@ protected function save($varValue): void } }; } -} \ No newline at end of file +} diff --git a/src/FilterElement/FieldValueChoiceElement.php b/src/FilterElement/FieldValueChoiceElement.php index 23cfc6e0..b8f3128a 100644 --- a/src/FilterElement/FieldValueChoiceElement.php +++ b/src/FilterElement/FieldValueChoiceElement.php @@ -8,31 +8,24 @@ use Contao\DataContainer; use Contao\StringUtil; use Doctrine\DBAL\Connection; -use HeimrichHannot\FlareBundle\Contract\FilterElement\HydrateFormContract; -use HeimrichHannot\FlareBundle\Contract\FilterElement\IntrinsicValueContract; -use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterCallback; +use HeimrichHannot\FlareBundle\Contract\DcaContract; +use HeimrichHannot\FlareBundle\Contract\FilterElement\ConfigContract; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Engine\Context\ValidationContext; -use HeimrichHannot\FlareBundle\Event\FilterElementFormTypeOptionsEvent; -use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\FilterInvocation; +use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\FieldValueChoiceFilterType; use HeimrichHannot\FlareBundle\Form\ChoicesBuilder; use HeimrichHannot\FlareBundle\Form\Factory\ChoicesBuilderFactory; -use HeimrichHannot\FlareBundle\Model\FilterModel; -use HeimrichHannot\FlareBundle\Model\ListModel; -use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use Symfony\Component\Form\ChoiceList\Loader\CallbackChoiceLoader; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; -use Symfony\Component\Form\FormInterface; - -#[AsFilterElement( - type: self::TYPE, - palette: '{filter_legend},fieldGeneric,isMultiple,isExpanded,preselect', - formType: ChoiceType::class, -)] -class FieldValueChoiceElement extends AbstractFilterElement implements HydrateFormContract, IntrinsicValueContract +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +#[AsFilterElement(type: self::TYPE)] +class FieldValueChoiceElement extends AbstractFilterElement implements ConfigContract, DcaContract { public const TYPE = 'flare_fieldValueChoice'; @@ -44,115 +37,118 @@ public function __construct( private readonly ChoicesBuilderFactory $choicesBuilderFactory, ) {} - /** - * @throws FilterException - */ - public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $invocation): void + public function configureConfig(OptionsResolver $resolver): void { - if ($invocation->context instanceof ValidationContext) { - return; - } - - $filter = $invocation->filter; + $resolver->define('intrinsic')->default(false)->allowedTypes('bool'); + $resolver->define('field')->default(null)->allowedTypes('string', 'null'); + $resolver->define('multiple')->default(false)->allowedTypes('bool'); + $resolver->define('expanded')->default(false)->allowedTypes('bool'); + $resolver->define('preselect')->default(null)->allowedTypes('array', 'null'); + } - if (!($field = $filter->fieldGeneric)) { - return; - } + public function configFromRow(array $row): array + { + $multiple = (bool) ($row['isMultiple'] ?? false); + + return [ + 'intrinsic' => (bool) ($row['intrinsic'] ?? false), + 'field' => ($row['fieldGeneric'] ?? null) ?: null, + 'multiple' => $multiple, + 'expanded' => (bool) ($row['isExpanded'] ?? false), + 'preselect' => $this->normalizePreselect($row['preselect'] ?? null, $multiple), + ]; + } - $value = $filter->isIntrinsic() - ? $this->getIntrinsicValue($invocation->list, $filter) - : $this->processRuntimeValue($invocation->getValue(), $invocation->list, $filter); + public function buildForm(FormBuilderInterface $builder, FilterContext $context): void + { + $config = $context->config; - if (!$value) { + if ($config['intrinsic']) { return; } - $builder->add(FieldValueChoiceFilterType::class, [ - 'field' => $field, - 'values' => $value, + $choicesBuilder = $this->createChoices($context->list->dc, (string) ($config['field'] ?? '')) + ->setEmptyOption(!$config['multiple']); + + $builder->add(FilterContext::FIELD_VALUE, ChoiceType::class, [ + 'label' => false, + 'multiple' => $config['multiple'], + 'expanded' => $config['expanded'], + 'required' => false, + 'choice_loader' => new CallbackChoiceLoader(static fn (): array => $choicesBuilder->buildChoices()), + 'choice_label' => $choicesBuilder->buildChoiceLabelCallback(), + 'choice_value' => $choicesBuilder->buildChoiceValueCallback(), + 'data' => $this->buildPreselectData($choicesBuilder, $config), ]); - } - public function processRuntimeValue(mixed $value, ListSpecification $list, ConfiguredFilter $filter): ?array - { - return $this->extractSubmittedData((array) $value); + $builder->setAttribute('flare.choices_builder', $choicesBuilder); } - public function getIntrinsicValue(ListSpecification $list, ConfiguredFilter $filter): ?array + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void { - return $this->extractPreselectData($filter); - } + if ($context->engineContext instanceof ValidationContext) { + return; + } - public function extractFormData(FormInterface $form): mixed - { - return $form->getViewData(); - } + $config = $context->config; - public function extractPreselectData(ConfiguredFilter $filter): ?array - { - if (!$preselect = $filter->preselect) { - return null; + if (!$field = $config['field']) { + return; } - if (\is_array($preselect)) { - return $preselect; - } + $value = $config['intrinsic'] + ? $config['preselect'] + : $this->normalizeRuntimeValue($data[FilterContext::FIELD_VALUE] ?? null, $context); - if ($filter->isMultiple - || (\is_string($preselect) && \preg_match('/^a:\d+:\{.*}$/', $preselect))) - { - return StringUtil::deserialize($preselect, true); + if (!$value) { + return; } - return [$preselect]; + $builder->add(FieldValueChoiceFilterType::class, [ + 'field' => $field, + 'values' => $value, + ]); } - public function extractSubmittedData(array $submittedData): ?array + public function configureDca(DcaBuilder $dca, DcaContext $context): void { - $submittedData = \array_filter($submittedData); - $submittedData = \array_map('strtolower', \array_map('trim', $submittedData)); - $submittedData = \array_filter( - $submittedData, - static fn(string $value): bool => $value !== '' && $value !== ChoicesBuilder::EMPTY_CHOICE, - ); + $dca->palette('{filter_legend},fieldGeneric,isMultiple,isExpanded,preselect'); - return $submittedData ?: null; - } + $dca->field('isMultiple')->eval(['submitOnChange' => true, 'tl_class' => 'cbx m12 w25']); + $dca->field('isExpanded')->eval(['submitOnChange' => false, 'tl_class' => 'cbx m12 w25']); - public function hydrateForm(FormInterface $field, ListSpecification $list, ConfiguredFilter $filter): void - { - if ($field->isSubmitted()) { - return; - } + $table = $context->listModel->dc; + $valueField = $context->filterModel?->fieldGeneric; - if (!$preselect = $this->extractPreselectData($filter)) { + if (!$table || !$valueField) { return; } - $choices = $field->getConfig()->getOption('choices') ?? []; - - $data = []; - foreach ($preselect as $alias) { - if ($choice = $choices[$alias] ?? null) { - $data[] = $choice; - } - } - - if (!$filter->isMultiple) { - $data = \reset($data); - } - - $field->setData($data); + $dca->field('preselect') + ->inputType('select') + ->eval([ + 'multiple' => (bool) $context->filterModel?->isMultiple, + 'chosen' => true, + 'includeBlankOption' => true, + ]) + ->options(function (?DataContainer $dc) use ($table, $valueField): array { + Controller::loadDataContainer($table); + + return $this->createChoices($table, $valueField) + ->setModelSuffix('[%id%]') + ->buildOptions(); + }); } - public function handleFormTypeOptions(FilterElementFormTypeOptionsEvent $event): void + /** + * Builds the frontend/backend choices from the target field's values: foreign-key labels + * when the field has a foreignKey relation, distinct local column values otherwise. + */ + private function createChoices(string $table, string $field): ChoicesBuilder { - $choices = $event->choicesBuilder - ->enable() - ->setEmptyOption(!$event->filter->isMultiple); - - $table = $event->list->dc; - $field = $event->filter->fieldGeneric ?: ''; + $choices = $this->choicesBuilderFactory + ->createChoicesBuilder() + ->enable(); if (!\is_null($foreignValues = $this->getForeignValues($table, $field))) { @@ -169,76 +165,101 @@ public function handleFormTypeOptions(FilterElementFormTypeOptionsEvent $event): } } - $event->options['multiple'] = (bool) $event->filter->isMultiple; - $event->options['expanded'] = (bool) $event->filter->isExpanded; - $event->options['required'] = false; + return $choices; } - #[AsFilterCallback(self::TYPE, 'fields.isMultiple.load')] - #[AsFilterCallback(self::TYPE, 'fields.isExpanded.load')] - public function onLoad_isMultiple( - mixed $value, - ?DataContainer $dc, - FilterModel $filterModel, - ListModel $listModel - ): mixed { - if (!$dc || !($dcTable = $dc->table) || !($dcField = $dc->field)) { - return $value; + /** + * Computes the pre-fill data for the choice child from the preselect config, replicating + * the former HydrateFormContract::hydrateForm() logic. + * + * @param array $config + */ + private function buildPreselectData(ChoicesBuilder $choicesBuilder, array $config): mixed + { + if (!$preselect = $config['preselect']) { + return null; } - $dca = &$GLOBALS['TL_DCA'][$dcTable]['fields'][$dcField]; - $dca['eval']['submitOnChange'] = $dcField === 'isMultiple'; - $dca['eval']['tl_class'] = 'cbx m12 w25'; + $choices = $choicesBuilder->buildChoices(); - return $value; - } + $data = []; + foreach ($preselect as $alias) { + if ($choice = $choices[$alias] ?? null) { + $data[] = $choice; + } + } - #[AsFilterCallback(self::TYPE, 'fields.preselect.load')] - public function onLoad_preselect( - mixed $value, - ?DataContainer $dc, - FilterModel $filterModel, - ListModel $listModel - ): mixed { - if (!$dc - || !($dcTable = $dc->table) - || !($dcField = $dc->field) - || !($table = $listModel->dc) - || !($valueField = $filterModel->fieldGeneric)) - { - return $value; + if (!$config['multiple']) { + return \reset($data) ?: null; } - $flareDca = &$GLOBALS['TL_DCA'][$dcTable]['fields'][$dcField]; + return $data; + } - $choices = $this->choicesBuilderFactory - ->createChoicesBuilder() - ->setModelSuffix('[%id%]') - ->enable(); + /** + * Maps the submitted model data (choices) back to their scalar values — replicating the + * former view-data extraction — and applies the old submitted-data normalization. + */ + private function normalizeRuntimeValue(mixed $value, FilterContext $context): ?array + { + if (\is_null($value) || $value === '' || $value === []) { + return null; + } + + $choicesBuilder = $this->createChoices($context->list->dc, (string) ($context->config['field'] ?? '')); + $choices = $choicesBuilder->buildChoices(); + $toValue = $choicesBuilder->buildChoiceValueCallback(); - Controller::loadDataContainer($table); + $values = []; - if (!\is_null($foreignValues = $this->getForeignValues($table, $valueField))) + foreach ((array) $value as $choice) { - foreach ($foreignValues as $id => $label) { - $choices->add((string) $id, (string) $label, $id); + if ($choice === ChoicesBuilder::EMPTY_CHOICE || \in_array($choice, $choices, true)) + { + $values[] = (string) $toValue($choice); + continue; + } + + if (\is_scalar($choice) || $choice instanceof \Stringable) { + $values[] = (string) $choice; } } - /** @mago-expect lint:no-else-clause This else clause is fine. */ - else + + return $this->extractSubmittedData($values); + } + + private function normalizePreselect(mixed $preselect, bool $multiple): ?array + { + if (!$preselect) { + return null; + } + + if (\is_array($preselect)) { + return $preselect; + } + + if ($multiple + || (\is_string($preselect) && \preg_match('/^a:\d+:\{.*}$/', $preselect))) { - foreach ($this->getLocalValues($table, $valueField) as $option) { - $choices->add((string) $option, (string) $option, $option); - } + return StringUtil::deserialize($preselect, true); } - $flareDca['inputType'] = 'select'; - $flareDca['eval']['multiple'] = $filterModel->isMultiple; - $flareDca['eval']['chosen'] = true; - $flareDca['eval']['includeBlankOption'] = true; - $flareDca['options_callback'] = static fn (DataContainer $dc): array => $choices->buildOptions(); + return [$preselect]; + } - return $value; + /** + * @param list $submittedData + */ + private function extractSubmittedData(array $submittedData): ?array + { + $submittedData = \array_filter($submittedData); + $submittedData = \array_map('strtolower', \array_map('trim', $submittedData)); + $submittedData = \array_filter( + $submittedData, + static fn(string $value): bool => $value !== '' && $value !== ChoicesBuilder::EMPTY_CHOICE, + ); + + return $submittedData ?: null; } private function getForeignValues(string $table, string $field): ?array diff --git a/src/FilterElement/FilterElementContext.php b/src/FilterElement/FilterElementContext.php deleted file mode 100644 index c542dd66..00000000 --- a/src/FilterElement/FilterElementContext.php +++ /dev/null @@ -1,20 +0,0 @@ - $data Submitted form data of this filter's compound child (keyed by + * the local child names added in buildForm()) or a programmatically set data bag; empty array + * when neither exists (e.g. non-interactive contexts). + */ + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void; +} diff --git a/src/FilterElement/PublishedElement.php b/src/FilterElement/PublishedElement.php index 8b74a3bb..da0746f0 100644 --- a/src/FilterElement/PublishedElement.php +++ b/src/FilterElement/PublishedElement.php @@ -4,65 +4,84 @@ namespace HeimrichHannot\FlareBundle\FilterElement; +use HeimrichHannot\FlareBundle\Contract\DcaContract; +use HeimrichHannot\FlareBundle\Contract\FilterElement\ConfigContract; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; +use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\FilterInvocation; +use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\PublishedFilterType; -use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; +use Symfony\Component\OptionsResolver\OptionsResolver; -#[AsFilterElement( - type: self::TYPE, - palette: '{filter_legend},usePublished,useStart,useStop' -)] -class PublishedElement extends AbstractFilterElement +#[AsFilterElement(type: self::TYPE, intrinsicOnly: true)] +class PublishedElement extends AbstractFilterElement implements ConfigContract, DcaContract { public const TYPE = 'flare_published'; - public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $invocation): void + public function configureConfig(OptionsResolver $resolver): void { - $filter = $invocation->filter; + $resolver->define('intrinsic')->default(false)->allowedTypes('bool'); + $resolver->define('published_field')->default(null)->allowedTypes('string', 'null'); + $resolver->define('start_field')->default(null)->allowedTypes('string', 'null'); + $resolver->define('stop_field')->default(null)->allowedTypes('string', 'null'); + $resolver->define('invert')->default(false)->allowedTypes('bool'); + } + + public function configFromRow(array $row): array + { + $usePublished = $row['usePublished'] ?? true; + $useStart = $row['useStart'] ?? true; + $useStop = $row['useStop'] ?? true; + + return [ + 'intrinsic' => (bool) ($row['intrinsic'] ?? false), + 'published_field' => $usePublished ? (($row['fieldPublished'] ?? null) ?: 'published') : null, + 'start_field' => $useStart ? (($row['fieldStart'] ?? null) ?: 'start') : null, + 'stop_field' => $useStop ? (($row['fieldStop'] ?? null) ?: 'stop') : null, + 'invert' => (bool) ($row['invertPublished'] ?? false), + ]; + } + + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void + { + $config = $context->config; $builder->add(PublishedFilterType::class, [ - 'published_field' => ($filter->usePublished ?? true) ? ($filter->fieldPublished ?: 'published') : null, - 'start_field' => ($filter->useStart ?? true) ? ($filter->fieldStart ?: 'start') : null, - 'stop_field' => ($filter->useStop ?? true) ? ($filter->fieldStop ?: 'stop') : null, - 'invert_published' => (bool) ($filter->invertPublished ?? false), + 'published_field' => $config['published_field'], + 'start_field' => $config['start_field'], + 'stop_field' => $config['stop_field'], + 'invert_published' => $config['invert'], 'now' => \time(), ]); } + public function configureDca(DcaBuilder $dca, DcaContext $context): void + { + $dca->palette('{filter_legend},usePublished,useStart,useStop'); + } + public static function define( string|false|null $published = null, string|false|null $start = null, string|false|null $stop = null, bool|null $invertPublished = null, - ): ConfiguredFilter { + ): Filter { $published ??= 'published'; $start ??= 'start'; $stop ??= 'stop'; $invertPublished ??= false; - $definition = new ConfiguredFilter( - type: static::TYPE, - intrinsic: true, + return new Filter( + element: static::TYPE, + config: [ + 'intrinsic' => true, + 'published_field' => $published ?: null, + 'start_field' => $start ?: null, + 'stop_field' => $stop ?: null, + 'invert' => $published ? $invertPublished : false, + ], ); - - if ($published) { - $definition->usePublished = true; - $definition->fieldPublished = $published; - $definition->invertPublished = $invertPublished; - } - - if ($start) { - $definition->useStart = true; - $definition->fieldStart = $start; - } - - if ($stop) { - $definition->useStop = true; - $definition->fieldStop = $stop; - } - - return $definition; } } diff --git a/src/FilterElement/SearchKeywordsElement.php b/src/FilterElement/SearchKeywordsElement.php index 39365f79..ec5a8b7b 100644 --- a/src/FilterElement/SearchKeywordsElement.php +++ b/src/FilterElement/SearchKeywordsElement.php @@ -5,74 +5,91 @@ namespace HeimrichHannot\FlareBundle\FilterElement; use Contao\StringUtil; -use HeimrichHannot\FlareBundle\Contract\Config\PaletteConfig; -use HeimrichHannot\FlareBundle\Contract\FilterElement\IntrinsicValueContract; +use HeimrichHannot\FlareBundle\Contract\DcaContract; +use HeimrichHannot\FlareBundle\Contract\FilterElement\ConfigContract; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; -use HeimrichHannot\FlareBundle\Event\FilterElementFormTypeOptionsEvent; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\FilterInvocation; +use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\SearchKeywordsFilterType; -use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; -#[AsFilterElement( - type: self::TYPE, - formType: TextType::class, - isTargeted: true, -)] -class SearchKeywordsElement extends AbstractFilterElement implements IntrinsicValueContract +#[AsFilterElement(type: self::TYPE, isTargeted: true)] +class SearchKeywordsElement extends AbstractFilterElement implements ConfigContract, DcaContract { public const TYPE = 'flare_search_keywords'; - public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $invocation): void + public function configureConfig(OptionsResolver $resolver): void { - $filter = $invocation->filter; - $value = $filter->isIntrinsic() - ? $this->getIntrinsicValue($invocation->list, $filter) - : $invocation->getValue(); + $resolver->define('intrinsic')->default(false)->allowedTypes('bool'); + $resolver->define('columns')->default([])->allowedTypes('array'); + $resolver->define('prefill')->default(null)->allowedTypes('string', 'null'); + $resolver->define('label')->default(null)->allowedTypes('string', 'null'); + $resolver->define('placeholder')->default(null)->allowedTypes('string', 'null'); + } - if (!$value || !\is_string($value)) { + public function configFromRow(array $row): array + { + return [ + 'intrinsic' => (bool) ($row['intrinsic'] ?? false), + 'columns' => StringUtil::deserialize($row['columnsGeneric'] ?? null, true), + 'prefill' => ($row['prefill'] ?? null) ?: null, + 'label' => ($row['label'] ?? null) ?: null, + 'placeholder' => ($row['placeholder'] ?? null) ?: null, + ]; + } + + public function buildForm(FormBuilderInterface $builder, FilterContext $context): void + { + $config = $context->config; + + if ($config['intrinsic']) { return; } - if (!$columns = StringUtil::deserialize($filter->columnsGeneric, true)) { - return; + $options = [ + 'label' => $config['label'] ?? 'label.text', + 'required' => false, + ]; + + if ($config['placeholder']) { + $options['attr']['placeholder'] = $config['placeholder']; } - $builder->add(SearchKeywordsFilterType::class, [ - 'value' => $value, - 'columns' => $columns, - ]); + $builder->add(FilterContext::FIELD_VALUE, TextType::class, $options); } - public function handleFormTypeOptions(FilterElementFormTypeOptionsEvent $event): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void { - $event->options['label'] = 'label.text'; - $event->options['required'] = false; + $config = $context->config; - if ($label = $event->filter->label) { - $event->options['label'] = $label; + $value = $config['intrinsic'] + ? $config['prefill'] + : ($data[FilterContext::FIELD_VALUE] ?? null); + + if (!$value || !\is_string($value)) { + return; } - if ($placeholder = $event->filter->placeholder) { - $event->options['attr']['placeholder'] = $placeholder; + if (!$columns = $config['columns']) { + return; } - } - public function getIntrinsicValue(ListSpecification $list, ConfiguredFilter $filter): ?string - { - return $filter->prefill ?: null; + $builder->add(SearchKeywordsFilterType::class, [ + 'value' => $value, + 'columns' => $columns, + ]); } - public function getPalette(PaletteConfig $config): ?string + public function configureDca(DcaBuilder $dca, DcaContext $context): void { $palette = '{filter_legend},columnsGeneric'; - if ($config->getFilterModel()->intrinsic) { - return $palette . ',prefill'; - } - - return $palette . ';{form_legend},label,placeholder'; + $dca->palette($context->filterModel?->intrinsic + ? $palette . ',prefill' + : $palette . ';{form_legend},label,placeholder'); } } diff --git a/src/FilterElement/SimpleEquationElement.php b/src/FilterElement/SimpleEquationElement.php index 67fa9a83..aad1830f 100644 --- a/src/FilterElement/SimpleEquationElement.php +++ b/src/FilterElement/SimpleEquationElement.php @@ -4,76 +4,97 @@ namespace HeimrichHannot\FlareBundle\FilterElement; -use HeimrichHannot\FlareBundle\Contract\Config\PaletteConfig; -use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterCallback; +use HeimrichHannot\FlareBundle\Contract\DcaContract; +use HeimrichHannot\FlareBundle\Contract\FilterElement\ConfigContract; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Enum\SqlEquationOperator; use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Exception\FlareException; +use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\FilterInvocation; +use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\SimpleEquationFilterType; -use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; use HeimrichHannot\FlareBundle\Util\DcaHelper; +use Symfony\Component\OptionsResolver\OptionsResolver; -#[AsFilterElement(type: self::TYPE, isTargeted: true)] -class SimpleEquationElement extends AbstractFilterElement +#[AsFilterElement(type: self::TYPE, intrinsicOnly: true, isTargeted: true)] +class SimpleEquationElement extends AbstractFilterElement implements ConfigContract, DcaContract { public const TYPE = 'flare_equation_simple'; + public function configureConfig(OptionsResolver $resolver): void + { + $resolver->define('intrinsic')->default(false)->allowedTypes('bool'); + $resolver->define('left')->default(null)->allowedTypes('string', 'null'); + $resolver->define('operator')->default(null)->allowedTypes(SqlEquationOperator::class, 'null'); + $resolver->define('right')->default(null); + } + + public function configFromRow(array $row): array + { + $operator = $row['equationOperator'] ?? null; + + return [ + 'intrinsic' => (bool) ($row['intrinsic'] ?? false), + 'left' => ($row['equationLeft'] ?? null) ?: null, + 'operator' => $operator ? SqlEquationOperator::match($operator) : null, + 'right' => $row['equationRight'] ?? null, + ]; + } + /** * @throws FilterException */ - public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $invocation): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void { - if (!($operand = $invocation->filter->equationLeft) - || !$op = SqlEquationOperator::match($invocation->filter->equationOperator)) - { + $config = $context->config; + + if (!($operand = $config['left']) || !($op = $config['operator'])) { throw new FilterException('Invalid filter configuration.'); } $builder->add(SimpleEquationFilterType::class, [ 'operand_left' => $operand, 'operator' => $op, - 'operand_right' => $invocation->filter->equationRight, + 'operand_right' => $config['right'], ]); } - #[AsFilterCallback(self::TYPE, 'fields.equationLeft.options')] - public function getEquationLeftOptions(string $targetTable): array - { - return DcaHelper::getFieldOptions($targetTable); - } - - public function getPalette(PaletteConfig $config): ?string + public function configureDca(DcaBuilder $dca, DcaContext $context): void { - $filterModel = $config->getFilterModel(); + $operatorValue = $context->filterModel?->equationOperator; + $operator = $operatorValue ? SqlEquationOperator::match($operatorValue) : null; - if (SqlEquationOperator::match($filterModel?->equationOperator)?->isUnary()) { - return '{flare_simple_equation_legend},equationLeft,equationOperator'; - } + $dca->palette($operator?->isUnary() + ? '{flare_simple_equation_legend},equationLeft,equationOperator' + : '{flare_simple_equation_legend},equationLeft,equationOperator,equationRight'); - return '{flare_simple_equation_legend},equationLeft,equationOperator,equationRight'; + $dca->field('equationLeft') + ->options(fn (): array => DcaHelper::getFieldOptions($context->getTargetTable())); } + /** + * @throws FlareException + */ public static function define( ?string $equationLeft = null, ?SqlEquationOperator $equationOperator = null, mixed $equationRight = null, - ): ConfiguredFilter { - $definition = new ConfiguredFilter( - type: static::TYPE, - intrinsic: true, - ); - + ): Filter { if (!$equationLeft || !$equationOperator || (!$equationOperator->isUnary() && $equationRight === null)) { throw new FlareException('Invalid filter definition for SimpleEquationElement.'); } - $definition->equationLeft = $equationLeft; - $definition->equationOperator = $equationOperator->value; - $definition->equationRight = $equationRight; - - return $definition; + return new Filter( + element: static::TYPE, + config: [ + 'intrinsic' => true, + 'left' => $equationLeft, + 'operator' => $equationOperator, + 'right' => $equationRight, + ], + ); } } diff --git a/src/Form/Factory/FilterFormFactory.php b/src/Form/Factory/FilterFormFactory.php index d70b430d..2107c1b9 100644 --- a/src/Form/Factory/FilterFormFactory.php +++ b/src/Form/Factory/FilterFormFactory.php @@ -7,13 +7,14 @@ use Contao\PageModel; use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; use HeimrichHannot\FlareBundle\Engine\Context\Interface\FormContextInterface; +use HeimrichHannot\FlareBundle\Event\FilterElementFormBuiltEvent; use HeimrichHannot\FlareBundle\Event\FilterFormBuildEvent; use HeimrichHannot\FlareBundle\Exception\FlareException; -use HeimrichHannot\FlareBundle\FilterElement\FilterElementContext; -use HeimrichHannot\FlareBundle\FilterElement\FilterElementInterface; -use HeimrichHannot\FlareBundle\Form\FilterFormBuilder; -use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; +use HeimrichHannot\FlareBundle\Filter\FilterConfigResolver; +use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Registry\FilterElementResolver; use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Util\Str; use Symfony\Component\Form\Extension\Core\Type\FormType; use Symfony\Component\Form\FormFactoryInterface; use Symfony\Component\Form\FormInterface; @@ -22,9 +23,9 @@ readonly class FilterFormFactory { public function __construct( - private ChoicesBuilderFactory $choicesBuilderFactory, private EventDispatcherInterface $eventDispatcher, - private FilterElementRegistry $filterElementRegistry, + private FilterConfigResolver $filterConfigResolver, + private FilterElementResolver $filterElementResolver, private FormFactoryInterface $formFactory, ) {} @@ -38,7 +39,6 @@ public function create(ListSpecification $list, FormContextInterface $context): } $name = $context->getFormName(); - $filters = $list->getFilters(); $formOptions = [ 'method' => 'GET', @@ -54,32 +54,46 @@ public function create(ListSpecification $list, FormContextInterface $context): } $builder = $this->formFactory->createNamedBuilder($name, FormType::class, null, $formOptions); - $filterFormBuilder = new FilterFormBuilder( - rootBuilder: $builder, - choicesBuilderFactory: $this->choicesBuilderFactory, - eventDispatcher: $this->eventDispatcher, - ); + $builder->setAttribute('flare.list', $list); + $builder->setAttribute('flare.engine_context', $context); - foreach ($filters->getIterator() as $configuredFilter) + foreach ($list->getFilters() as $key => $filter) { - if (!$configuredFilter->getElementType()) { + if (!Str::isValidFormName($filter->alias)) { continue; } - if (!$descriptor = $this->filterElementRegistry->get($configuredFilter->getElementType())) { + if (!$element = $this->filterElementResolver->resolve($filter)) { continue; } - $element = $descriptor->getService(); - - if ($element instanceof FilterElementInterface) { - $element->buildForm($filterFormBuilder, new FilterElementContext( - list: $list, - filter: $configuredFilter, - engineContext: $context, - descriptor: $descriptor, - )); + $filterContext = new FilterContext( + list: $list, + filter: $filter, + config: $this->filterConfigResolver->resolve($filter, $element), + engineContext: $context, + key: $key, + ); + + $child = $builder->create($filter->alias, FormType::class, [ + 'inherit_data' => false, + 'label' => false, + 'required' => false, + ]); + $child->setAttribute(FilterContext::FORM_ATTRIBUTE, $filterContext); + + $element->buildForm($child, $filterContext); + + /** @var FilterElementFormBuiltEvent $event */ + $event = $this->eventDispatcher->dispatch(new FilterElementFormBuiltEvent($child, $filterContext)); + + if ($event->isCancelled() || $child->count() === 0) + // Empty compound children are never mounted. + { + continue; } + + $builder->add($child); } /* diff --git a/src/Form/FilterFormBuilder.php b/src/Form/FilterFormBuilder.php deleted file mode 100644 index 93f5404e..00000000 --- a/src/Form/FilterFormBuilder.php +++ /dev/null @@ -1,94 +0,0 @@ -filter; - $formType ??= $context->descriptor->getFormType(); - - if (!$formType) { - return $this; - } - - $childName = $filter->getAlias(); - if (!$childName) { - throw new FlareException(message: 'Non-intrinsic filter must provide a form field name.'); - } - - $choicesBuilder = $this->choicesBuilderFactory->createChoicesBuilder(); - - $formTypeOptionsEvent = new FilterElementFormTypeOptionsEvent( - choicesBuilder: $choicesBuilder, - list: $context->list, - filter: $filter, - options: $options, - ); - - $element = $context->descriptor->getService(); - if ($element instanceof FormTypeOptionsContract) { - $element->handleFormTypeOptions($formTypeOptionsEvent); - } - - /** @var FilterElementFormTypeOptionsEvent $formTypeOptionsEvent */ - $formTypeOptionsEvent = $this->eventDispatcher->dispatch($formTypeOptionsEvent); - - $choicesBuilder = $formTypeOptionsEvent->choicesBuilder; - if ($choicesBuilder->isEnabled()) { - $choicesOptions = [ - 'choices' => $choicesBuilder->buildChoices(), - 'choice_label' => $choicesBuilder->buildChoiceLabelCallback(), - 'choice_value' => $choicesBuilder->buildChoiceValueCallback(), - ]; - } - - $resolvedOptions = \array_merge( - [ - 'inherit_data' => false, - 'label' => false, - ], - $choicesOptions ?? [], - $formTypeOptionsEvent->options, - ); - - /** @var FilterFormChildOptionsEvent $childOptionsEvent */ - $childOptionsEvent = $this->eventDispatcher->dispatch(new FilterFormChildOptionsEvent( - listSpecification: $context->list, - configuredFilter: $filter, - parentFormName: $this->rootBuilder->getName(), - formName: $childName, - options: $resolvedOptions, - )); - - $this->rootBuilder->add($childName, $formType, $childOptionsEvent->options); - - return $this; - } - - public function getRootBuilder(): FormBuilderInterface - { - return $this->rootBuilder; - } -} diff --git a/src/Form/FilterFormBuilderInterface.php b/src/Form/FilterFormBuilderInterface.php deleted file mode 100644 index 8f06810b..00000000 --- a/src/Form/FilterFormBuilderInterface.php +++ /dev/null @@ -1,15 +0,0 @@ - Fill Registries ### - $container->addCompilerPass(new DependencyInjection\Compiler\RegisterFlareCallbacksPass()); $container->addCompilerPass(new DependencyInjection\Compiler\RegisterFilterElementsPass()); $container->addCompilerPass(new DependencyInjection\Compiler\RegisterListTypesPass()); ###< Fill Registries ### diff --git a/src/Integration/CodefogTags/FilterCallback/TargetAliasCallback.php b/src/Integration/CodefogTags/FilterCallback/TargetAliasCallback.php index 221e9d92..87b1c419 100644 --- a/src/Integration/CodefogTags/FilterCallback/TargetAliasCallback.php +++ b/src/Integration/CodefogTags/FilterCallback/TargetAliasCallback.php @@ -4,37 +4,43 @@ namespace HeimrichHannot\FlareBundle\Integration\CodefogTags\FilterCallback; -use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterCallback; +use HeimrichHannot\FlareBundle\Event\ElementDcaEvent; use HeimrichHannot\FlareBundle\Integration\CodefogTags\FilterElement\CodefogTagsChoiceElement; use HeimrichHannot\FlareBundle\Integration\CodefogTags\FilterElement\CodefogTagsSearchElement; use HeimrichHannot\FlareBundle\Integration\CodefogTags\Registry\CfgTagsJoinsRegistry; -use HeimrichHannot\FlareBundle\Query\ListExecutionContext; - +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; + +/** + * Restricts the targetAlias options of the Codefog tags filter elements to the + * active tags relations of the edited list. + */ +#[AsEventListener('flare.filter_element.' . CodefogTagsChoiceElement::TYPE . '.dca')] +#[AsEventListener('flare.filter_element.' . CodefogTagsSearchElement::TYPE . '.dca')] readonly class TargetAliasCallback { public function __construct( private CfgTagsJoinsRegistry $joinsRegistry, ) {} - #[AsFilterCallback(CodefogTagsChoiceElement::TYPE, 'fields.targetAlias.options', priority: 20)] - #[AsFilterCallback(CodefogTagsSearchElement::TYPE, 'fields.targetAlias.options', priority: 20)] - public function onTargetAliasOptions(ListExecutionContext $context): ?array + public function __invoke(ElementDcaEvent $event): void { - $activeTagsAliases = \array_intersect_key( - $this->joinsRegistry->all(), - \array_flip($context->tableAliasRegistry->getAliases()), - ); - - if (!$activeTagsAliases) { - return null; + if (!$context = $event->context->getExecutionContext()) { + return; } - $options = []; + $event->dca->field('targetAlias')->options(function () use ($context): array { + $activeTagsAliases = \array_intersect_key( + $this->joinsRegistry->all(), + \array_flip($context->tableAliasRegistry->getAliases()), + ); - foreach ($activeTagsAliases as $alias => $config) { - $options[$alias] = "{$alias} [tl_cfg_tag]"; - } + $options = []; + + foreach ($activeTagsAliases as $alias => $config) { + $options[$alias] = "{$alias} [tl_cfg_tag]"; + } - return $options; + return $options; + }); } } diff --git a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceElement.php b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceElement.php index ab57cd89..cc8fda53 100644 --- a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceElement.php +++ b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceElement.php @@ -5,108 +5,168 @@ namespace HeimrichHannot\FlareBundle\Integration\CodefogTags\FilterElement; use Contao\StringUtil; -use HeimrichHannot\FlareBundle\Contract\FilterElement\HydrateFormContract; -use HeimrichHannot\FlareBundle\Contract\FilterElement\IntrinsicValueContract; -use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterCallback; +use HeimrichHannot\FlareBundle\Contract\DcaContract; +use HeimrichHannot\FlareBundle\Contract\FilterElement\ConfigContract; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; -use HeimrichHannot\FlareBundle\Event\FilterElementFormTypeOptionsEvent; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\FilterInvocation; +use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\IntegerIdChoiceFilterType; use HeimrichHannot\FlareBundle\FilterElement\AbstractFilterElement; +use HeimrichHannot\FlareBundle\Form\Factory\ChoicesBuilderFactory; use HeimrichHannot\FlareBundle\Integration\CodefogTags\Registry\CfgTagsJoinsRegistry; -use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Query\Factory\ListExecutionContextFactory; use HeimrichHannot\FlareBundle\Query\ListExecutionContext; -use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; use Psr\Log\LoggerInterface; +use Symfony\Component\Form\ChoiceList\Loader\CallbackChoiceLoader; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; -use Symfony\Component\Form\FormInterface; - -#[AsFilterElement( - type: self::TYPE, - palette: '{form_legend},label,isMandatory,isMultiple,isExpanded;{filter_legend},preselect', - formType: ChoiceType::class, - isTargeted: true, -)] -class CodefogTagsChoiceElement extends AbstractFilterElement implements HydrateFormContract, IntrinsicValueContract +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +#[AsFilterElement(type: self::TYPE, isTargeted: true)] +class CodefogTagsChoiceElement extends AbstractFilterElement implements ConfigContract, DcaContract { public const TYPE = 'cfg_tags_choice'; public function __construct( + private readonly ChoicesBuilderFactory $choicesBuilderFactory, private readonly CfgTagsJoinsRegistry $joinsRegistry, private readonly ListExecutionContextFactory $listExecutionContextFactory, private readonly LoggerInterface $logger, ) {} - public function buildFilter(FilterBuilderInterface $builder, FilterInvocation $invocation): void + public function configureConfig(OptionsResolver $resolver): void { - /** @var ?array $tagIds */ - $tagIds = $invocation->filter->isIntrinsic() - ? $this->getIntrinsicValue($invocation->list, $invocation->filter) - : $this->processRuntimeValue($invocation->getValue(), $invocation->list, $invocation->filter); - - if (!$tagIds) { - return; - } + $resolver->define('intrinsic')->default(false)->allowedTypes('bool'); + $resolver->define('preselect')->default([])->allowedTypes('int[]'); + $resolver->define('is_mandatory')->default(false)->allowedTypes('bool'); + $resolver->define('is_multiple')->default(false)->allowedTypes('bool'); + $resolver->define('is_expanded')->default(false)->allowedTypes('bool'); + $resolver->define('label')->default(null)->allowedTypes('string', 'null'); + $resolver->define('placeholder')->default(null)->allowedTypes('string', 'null'); + } - $builder->add(IntegerIdChoiceFilterType::class, [ - 'field' => 'id', - 'ids' => $tagIds, - ]); + public function configFromRow(array $row): array + { + return [ + 'intrinsic' => (bool) ($row['intrinsic'] ?? false), + 'preselect' => $this->normalizeValueArray( + StringUtil::deserialize(($row['preselect'] ?? null) ?: null, true) + ), + 'is_mandatory' => (bool) ($row['isMandatory'] ?? false), + 'is_multiple' => (bool) ($row['isMultiple'] ?? false), + 'is_expanded' => (bool) ($row['isExpanded'] ?? false), + 'label' => ($row['label'] ?? null) ?: null, + 'placeholder' => ($row['placeholder'] ?? null) ?: null, + ]; } - public function hydrateForm(FormInterface $field, ListSpecification $list, ConfiguredFilter $filter): void + public function buildForm(FormBuilderInterface $builder, FilterContext $context): void { - if ($field->isSubmitted()) { + $config = $context->config; + + if ($config['intrinsic']) { return; } - if (!$preselect = $this->getIntrinsicValue($list, $filter)) { - return; + $formOptions = [ + 'label' => $config['label'] ?: false, + 'multiple' => $config['is_multiple'], + 'expanded' => $config['is_expanded'], + 'required' => $config['is_mandatory'], + 'placeholder' => $config['placeholder'] + ?: ($config['is_mandatory'] ? 'empty_option.prompt' : 'empty_option.no_selection'), + ]; + + if ($preselect = $config['preselect']) { + $formOptions['data'] = $config['is_multiple'] ? $preselect : \reset($preselect); } - if (!$filter->isMultiple) { - $preselect = \reset($preselect); + $executionContext = $this->listExecutionContextFactory->create($context->list); + + $optValues = $this->getOptions( + executionContext: $executionContext, + targetAlias: $context->filter->targetAlias, + listInfo: \sprintf( + '%s (ID %s)', + $context->list->type, + (string) ($context->list->getDataSource()?->getListProperty('id') ?? 'N/A'), + ), + filterInfo: \sprintf('%s (%s)', self::TYPE, $context->filter->source ?? 'inlined'), + ); + + if (!\is_null($optValues)) + { + $choicesBuilder = $this->choicesBuilderFactory->createChoicesBuilder()->enable(); + + foreach ($optValues as $value => $label) { + $choicesBuilder->add((string) $value, (string) $label, (int) $value); + } + + $formOptions['choice_loader'] = new CallbackChoiceLoader(static fn (): array => $choicesBuilder->buildChoices()); + $formOptions['choice_label'] = $choicesBuilder->buildChoiceLabelCallback(); + $formOptions['choice_value'] = $choicesBuilder->buildChoiceValueCallback(); + + $builder->setAttribute('flare.choices_builder', $choicesBuilder); } - $field->setData($preselect); + $builder->add(FilterContext::FIELD_VALUE, ChoiceType::class, $formOptions); } - #[AsFilterCallback(self::TYPE, 'config.onload')] - public function onLoadConfig(FilterModel $filterModel): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void { - $table = FilterModel::getTable(); - $fields = &$GLOBALS['TL_DCA'][$table]['fields']; - - ###> isMultiple - $field = &$fields['isMultiple']; - $field['eval']['submitOnChange'] = true; - ###< isMultiple - - ###> preselect - $field = &$fields['preselect']; - $field['inputType'] = 'select'; - $field['eval']['includeBlankOption'] = true; - $field['eval']['multiple'] = $filterModel->isMultiple; - $field['eval']['chosen'] = true; - ###< preselect + $config = $context->config; + + /** @var ?array $tagIds */ + $tagIds = $config['intrinsic'] + ? ($config['preselect'] ?: null) + : $this->processRuntimeValue($data[FilterContext::FIELD_VALUE] ?? null); + + if (!$tagIds) { + return; + } + + $builder->add(IntegerIdChoiceFilterType::class, [ + 'field' => 'id', + 'ids' => $tagIds, + ]); } - private function normalizeValueArray(array $values): array + public function configureDca(DcaBuilder $dca, DcaContext $context): void { - return \array_values(\array_unique(\array_filter(\array_map('\intval', $values)))); + $dca->palette('{form_legend},label,isMandatory,isMultiple,isExpanded;{filter_legend},preselect'); + + $dca->field('isMultiple') + ->eval(['submitOnChange' => true]); + + $dca->field('preselect') + ->inputType('select') + ->eval([ + 'includeBlankOption' => true, + 'multiple' => (bool) $context->filterModel?->isMultiple, + 'chosen' => true, + ]) + ->options(function () use ($context): array { + if (!$executionContext = $context->getExecutionContext()) { + return []; + } + + return $this->getOptions( + executionContext: $executionContext, + targetAlias: (string) ($context->filterModel->targetAlias ?? ''), + listInfo: \sprintf('%s (ID %s)', $context->listModel->type, $context->listModel->id), + filterInfo: \sprintf('%s (ID %s)', $context->type, (string) ($context->filterModel->id ?? 'N/A')), + ) ?? []; + }); } - public function getIntrinsicValue(ListSpecification $list, ConfiguredFilter $filter): ?array + private function normalizeValueArray(array $values): array { - return $this->normalizeValueArray( - StringUtil::deserialize($filter->preselect ?: null, true) - ) ?: null; + return \array_values(\array_unique(\array_filter(\array_map('\intval', $values)))); } - public function processRuntimeValue(mixed $value, ListSpecification $list, ConfiguredFilter $filter): ?array + public function processRuntimeValue(mixed $value): ?array { if (!$value = StringUtil::deserialize($value)) { return null; @@ -124,53 +184,26 @@ public function processRuntimeValue(mixed $value, ListSpecification $list, Confi return null; } - public function handleFormTypeOptions(FilterElementFormTypeOptionsEvent $event): void - { - $list = $event->list; - $filter = $event->filter; - - $emptyPlaceholder = $filter->isMandatory ? 'empty_option.prompt' : 'empty_option.no_selection'; - - $options = $this->defaultFormTypeOptions($filter, [ - 'multiple', - 'expanded', - 'required', - 'placeholder' => $emptyPlaceholder, - 'label' => null, - ]); - - $event->options = \array_merge($event->options, $options); - - $context = $this->listExecutionContextFactory->create($list); - - if (\is_null($optValues = $this->getOptions($list, $filter, $context))) { - return; - } - - $choices = $event->choicesBuilder->enable(); - - foreach ($optValues as $value => $label) { - $choices->add((string) $value, (string) $label, (int) $value); - } - } - - #[AsFilterCallback(self::TYPE, 'fields.preselect.options')] - public function getOptions(ListSpecification $list, ConfiguredFilter $filter, ListExecutionContext $context): ?array - { - $targetAlias = $filter->getTargetAlias(); - + /** + * Builds the tag options of the single active Codefog tags relation. Doubles as the + * backend options provider for the preselect field and the runtime choices source. + */ + public function getOptions( + ListExecutionContext $executionContext, + ?string $targetAlias, + string $listInfo = 'N/A', + string $filterInfo = 'N/A', + ): ?array { $activeTagsAliases = \array_intersect_key( $this->joinsRegistry->all(), - \array_flip($context->tableAliasRegistry->getAliases()), + \array_flip($executionContext->tableAliasRegistry->getAliases()), ); if (\count($activeTagsAliases) !== 1) { $this->logger->warning(\sprintf( '[FLARE] Cannot determine single target table for tags filter on ' - . 'list %s (ID %s), filter %s (ID %s), targetAlias %s', - $list->type, (string) ($list->getDataSource()?->getListProperty('id') ?? 'N/A'), - $filter->type, (string) ($filter->getDataSource()?->getFilterProperty('id') ?? 'N/A'), - $targetAlias, + . 'list %s, filter %s, targetAlias %s', + $listInfo, $filterInfo, $targetAlias, )); return null; } @@ -188,4 +221,4 @@ public function getOptions(ListSpecification $list, ConfiguredFilter $filter, Li return $options; } -} \ No newline at end of file +} diff --git a/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php b/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php index 1e9d2f3a..ff77abd3 100644 --- a/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php +++ b/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php @@ -4,17 +4,14 @@ namespace HeimrichHannot\FlareBundle\Integration\CodefogTags\FilterElement; +use HeimrichHannot\FlareBundle\Contract\DcaContract; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\FilterElement\AbstractFilterElement; -use Symfony\Component\Form\Extension\Core\Type\SearchType; -#[AsFilterElement( - type: self::TYPE, - palette: '{filter_legend},fieldGeneric,isMultiple,preselect', - formType: SearchType::class, - isTargeted: true, -)] -class CodefogTagsSearchElement extends AbstractFilterElement +#[AsFilterElement(type: self::TYPE, isTargeted: true)] +class CodefogTagsSearchElement extends AbstractFilterElement implements DcaContract { public const TYPE = 'cfg_tags_search'; @@ -22,4 +19,9 @@ public function isSupported(): bool { return false; } -} \ No newline at end of file + + public function configureDca(DcaBuilder $dca, DcaContext $context): void + { + $dca->palette('{filter_legend},fieldGeneric,isMultiple,preselect'); + } +} diff --git a/src/Integration/ContaoCalendar/ListType/EventsListType.php b/src/Integration/ContaoCalendar/ListType/EventsListType.php index 18a384b9..43b987be 100644 --- a/src/Integration/ContaoCalendar/ListType/EventsListType.php +++ b/src/Integration/ContaoCalendar/ListType/EventsListType.php @@ -4,7 +4,9 @@ namespace HeimrichHannot\FlareBundle\Integration\ContaoCalendar\ListType; -use HeimrichHannot\FlareBundle\Contract\Config\PaletteConfig; +use HeimrichHannot\FlareBundle\Contract\DcaContract; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListType; use HeimrichHannot\FlareBundle\Event\ListSpecificationCreatedEvent; use HeimrichHannot\FlareBundle\FilterElement\PublishedElement; @@ -15,24 +17,25 @@ use Symfony\Component\EventDispatcher\Attribute\AsEventListener; #[AsListType(type: self::TYPE, dataContainer: self::DATA_CONTAINER)] -class EventsListType extends AbstractListType +class EventsListType extends AbstractListType implements DcaContract { public const TYPE = 'flare_events'; public const DATA_CONTAINER = 'tl_calendar_events'; public const ALIAS_ARCHIVE = 'events_archive'; - public function getPalette(PaletteConfig $config): ?string + public function configureDca(DcaBuilder $dca, DcaContext $context): void { - if ($suffix = $config->getSuffix()) - { + $dca->suffix(static function (string $suffix): string { + if (!$suffix) { + return $suffix; + } + $suffix = \str_replace('sortSettings', '', $suffix); $suffix = \preg_replace('/(?:^|;)\{[^}]*},*(?:;|$)/', ';', $suffix); $suffix = \preg_replace('/;{2,}/', ';', $suffix); - $suffix = \trim($suffix, ';'); - $config->setSuffix($suffix); - } - return null; + return \trim($suffix, ';'); + }); } public function configureTableRegistry(TableAliasRegistry $registry): void @@ -55,10 +58,10 @@ public function onListSpecificationCreated(ListSpecificationCreatedEvent $config return; } - $filters = $config->listSpecification->getFilters(); + $spec = $config->listSpecification; - if (!$filters->hasType(PublishedElement::TYPE)) { - $filters->add(PublishedElement::define()); + if (!$spec->hasFilterOfType(PublishedElement::TYPE)) { + $spec->addFilter(PublishedElement::define()); } } } \ No newline at end of file diff --git a/src/Integration/ContaoComments/EventListener/ContaoCommentsListener.php b/src/Integration/ContaoComments/EventListener/ContaoCommentsListener.php index 159a4463..fbae51e7 100644 --- a/src/Integration/ContaoComments/EventListener/ContaoCommentsListener.php +++ b/src/Integration/ContaoComments/EventListener/ContaoCommentsListener.php @@ -13,7 +13,7 @@ use Contao\NewsModel; use Contao\UserModel; use HeimrichHannot\FlareBundle\DataContainer\ContentContainer; -use HeimrichHannot\FlareBundle\Event\PaletteEvent; +use HeimrichHannot\FlareBundle\Event\ElementDcaEvent; use HeimrichHannot\FlareBundle\Event\ReaderRenderEvent; use HeimrichHannot\FlareBundle\Model\ContentModel; use HeimrichHannot\FlareBundle\Model\ListModel; @@ -103,18 +103,18 @@ public function onReaderBuilt(ReaderRenderEvent $event): void /** * Attach the comments_enabled field to the flare_news palette. */ - #[AsEventListener('flare.list.flare_news.palette')] - public function onListPalette(PaletteEvent $event): void + #[AsEventListener('flare.list.flare_news.dca')] + public function onListDca(ElementDcaEvent $event): void { $pm = PaletteManipulator::create() ->addLegend('comments_legend') ->addField('comments_enabled', 'comments_legend', PaletteManipulator::POSITION_APPEND); - if ($event->getPaletteConfig()->getListModel()->comments_enabled) { + if ($event->context->listModel->comments_enabled) { $pm->addField('comments_sendNativeEmails', 'comments_legend', PaletteManipulator::POSITION_APPEND); } - $event->setPalette($pm->applyToString($event->getPalette())); + $event->dca->palette($pm->applyToString((string) $event->dca->getPalette())); } /** diff --git a/src/ListType/AbstractListType.php b/src/ListType/AbstractListType.php index 76cbef03..8d62551b 100644 --- a/src/ListType/AbstractListType.php +++ b/src/ListType/AbstractListType.php @@ -4,21 +4,13 @@ namespace HeimrichHannot\FlareBundle\ListType; -use HeimrichHannot\FlareBundle\Contract\Config\PaletteConfig; use HeimrichHannot\FlareBundle\Contract; use HeimrichHannot\FlareBundle\Query\SqlQueryStruct; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; -abstract class AbstractListType implements - Contract\PaletteContract, - Contract\ListType\ConfigureQueryContract +abstract class AbstractListType implements Contract\ListType\ConfigureQueryContract { - public function getPalette(PaletteConfig $config): ?string - { - return null; - } - public function configureTableRegistry(TableAliasRegistry $registry): void {} public function configureBaseQuery(SqlQueryStruct $struct): void {} -} \ No newline at end of file +} diff --git a/src/ListType/GenericDataContainerListType.php b/src/ListType/GenericDataContainerListType.php index f3f40128..307cf3c9 100644 --- a/src/ListType/GenericDataContainerListType.php +++ b/src/ListType/GenericDataContainerListType.php @@ -9,15 +9,17 @@ use Contao\CoreBundle\String\SimpleTokenParser; use Contao\DataContainer; use Contao\Message; -use HeimrichHannot\FlareBundle\Contract\Config\PaletteConfig; +use HeimrichHannot\FlareBundle\Contract\DcaContract; use HeimrichHannot\FlareBundle\Contract\ListType\DataContainerContract; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListType; use HeimrichHannot\FlareBundle\Exception\InferenceException; use HeimrichHannot\FlareBundle\InferPtable\PtableInferrer; use Symfony\Contracts\Translation\TranslatorInterface; -#[AsListType(type: self::TYPE, palette: self::DEFAULT_PALETTE)] -class GenericDataContainerListType extends AbstractListType implements DataContainerContract +#[AsListType(type: self::TYPE)] +class GenericDataContainerListType extends AbstractListType implements DataContainerContract, DcaContract { public const TYPE = 'flare_generic_dc'; public const DEFAULT_PALETTE = <<<'PALETTE' @@ -46,12 +48,13 @@ public function getDataContainerName(array $row, DataContainer $dc): string return $row['dc'] ?? ''; } - public function getPalette(PaletteConfig $config): ?string + public function configureDca(DcaBuilder $dca, DcaContext $context): void { - $listModel = $config->getListModel(); + $listModel = $context->listModel; if (!$listModel->hasParent) { - return null; + $dca->palette(self::DEFAULT_PALETTE); + return; } $pm = PaletteManipulator::create() @@ -92,6 +95,6 @@ public function getPalette(PaletteConfig $config): ?string $listModel->whichPtable_disableAutoOption(); } - return $pm->applyToString(self::DEFAULT_PALETTE); + $dca->palette($pm->applyToString(self::DEFAULT_PALETTE)); } -} \ No newline at end of file +} diff --git a/src/ListType/NewsListType.php b/src/ListType/NewsListType.php index e861f2ca..03f3bb07 100644 --- a/src/ListType/NewsListType.php +++ b/src/ListType/NewsListType.php @@ -4,6 +4,9 @@ namespace HeimrichHannot\FlareBundle\ListType; +use HeimrichHannot\FlareBundle\Contract\DcaContract; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListType; use HeimrichHannot\FlareBundle\Event\ListSpecificationCreatedEvent; use HeimrichHannot\FlareBundle\FilterElement\PublishedElement; @@ -12,12 +15,17 @@ use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; -#[AsListType(type: self::TYPE, dataContainer: 'tl_news', palette: '{filter_legend},')] -class NewsListType extends AbstractListType +#[AsListType(type: self::TYPE, dataContainer: 'tl_news')] +class NewsListType extends AbstractListType implements DcaContract { public const TYPE = 'flare_news'; public const ALIAS_ARCHIVE = 'news_archive'; + public function configureDca(DcaBuilder $dca, DcaContext $context): void + { + $dca->palette('{filter_legend},'); + } + public function configureTableRegistry(TableAliasRegistry $registry): void { $registry->registerJoin(new SqlJoinStruct( @@ -36,10 +44,10 @@ public function onListSpecificationCreated(ListSpecificationCreatedEvent $config return; } - $filters = $config->listSpecification->getFilters(); + $spec = $config->listSpecification; - if (!$filters->hasType(PublishedElement::TYPE)) { - $filters->add(PublishedElement::define()); + if (!$spec->hasFilterOfType(PublishedElement::TYPE)) { + $spec->addFilter(PublishedElement::define()); } } } \ No newline at end of file diff --git a/src/Manager/FlareCallbackManager.php b/src/Manager/FlareCallbackManager.php deleted file mode 100644 index 51cab607..00000000 --- a/src/Manager/FlareCallbackManager.php +++ /dev/null @@ -1,46 +0,0 @@ -getCallbacks($namespace, $what, $lowPrioFirst); - } - - public function getFilterCallbacks(string $who, string $what, bool $lowPrioFirst = false): array - { - $namespace = self::PREFIX_FILTER . $who; - - return $this->getCallbacks($namespace, $what, $lowPrioFirst); - } - - private function getCallbacks(string $namespace, string $target, bool $lowPrioFirst = false): array - { - if (!$namespace || !$target) { - return []; - } - - $callbacks = $this->registry->getSorted($namespace, $target) ?: []; - - if ($lowPrioFirst) { - $callbacks = \array_reverse($callbacks); - } - - return $callbacks; - } -} \ No newline at end of file diff --git a/src/Model/FilterModel.php b/src/Model/FilterModel.php index 38b59ecd..a042fdbc 100644 --- a/src/Model/FilterModel.php +++ b/src/Model/FilterModel.php @@ -8,13 +8,12 @@ use Contao\Model\Collection; use HeimrichHannot\FlareBundle\DataContainer\FilterContainer; use HeimrichHannot\FlareBundle\InferPtable\PtableInferrableInterface; -use HeimrichHannot\FlareBundle\Specification\DataSource\FilterDataSourceInterface; /** * Class FilterModel */ #[\AllowDynamicProperties] -class FilterModel extends Model implements FilterDataSourceInterface, PtableInferrableInterface +class FilterModel extends Model implements PtableInferrableInterface { use DocumentsFilterModelTrait, PtableInferrableTrait; diff --git a/src/Query/Executor/FilterExecutor.php b/src/Query/Executor/FilterExecutor.php index 6f0a5fdd..23def99e 100644 --- a/src/Query/Executor/FilterExecutor.php +++ b/src/Query/Executor/FilterExecutor.php @@ -9,17 +9,18 @@ use HeimrichHannot\FlareBundle\Exception\AbortFilteringException; use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Exception\FlareException; +use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\FilterBuilder; +use HeimrichHannot\FlareBundle\Filter\FilterConfigResolver; +use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterCall; -use HeimrichHannot\FlareBundle\Filter\FilterInvocation; -use HeimrichHannot\FlareBundle\FilterElement\FilterElementInterface; use HeimrichHannot\FlareBundle\Query\Factory\FilterQueryBuilderFactory; use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; use HeimrichHannot\FlareBundle\Query\ListQueryConfig; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; +use HeimrichHannot\FlareBundle\Registry\FilterElementResolver; use HeimrichHannot\FlareBundle\Registry\FilterTypeRegistry; -use HeimrichHannot\FlareBundle\Specification\ConfiguredFilter; use HeimrichHannot\FlareBundle\Util\Str; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -27,7 +28,9 @@ { public function __construct( private EventDispatcherInterface $eventDispatcher, + private FilterConfigResolver $filterConfigResolver, private FilterElementRegistry $filterElementRegistry, + private FilterElementResolver $filterElementResolver, private FilterQueryBuilderFactory $filterQueryBuilderFactory, private FilterTypeRegistry $filterTypeRegistry, ) {} @@ -42,24 +45,26 @@ public function __construct( public function invokeFilters(ListQueryConfig $options): array { $list = $options->list; - $context = $options->context; $filterQueryBuilders = []; - /** - * @var int|string $key - * @var ConfiguredFilter $filter - */ - foreach ($list->getFilters()->all() as $key => $filter) + foreach ($list->getFilters() as $key => $filter) { - $invocation = new FilterInvocation( - filter: $filter, + if (!$element = $this->filterElementResolver->resolve($filter)) { + continue; + } + + $context = new FilterContext( list: $list, - context: $context, - value: $options->filterValues[$key] ?? null, + filter: $filter, + config: $this->filterConfigResolver->resolve($filter, $element), + engineContext: $options->context, + key: $key, ); - if (!$builders = $this->invokeFilter($invocation)) { + $data = (array) ($options->filterValues[$key] ?? $filter->data ?? []); + + if (!$builders = $this->invokeFilter($filter, $context, $data)) { continue; } @@ -70,16 +75,17 @@ public function invokeFilters(ListQueryConfig $options): array } /** + * @param array $data + * + * @return FilterQueryBuilder[] + * * @throws AbortFilteringException * @throws FilterException * @throws FlareException */ - /** - * @return FilterQueryBuilder[] - */ - public function invokeFilter(FilterInvocation $invocation): array + public function invokeFilter(Filter $filter, FilterContext $context, array $data = []): array { - if (!Str::isValidSqlName($table = $invocation->list->dc)) + if (!Str::isValidSqlName($table = $context->list->dc)) { throw new FlareException(\sprintf( '[FLARE] ListSpecification data container cannot be used as SQL table identifier: "%s"', @@ -87,30 +93,23 @@ public function invokeFilter(FilterInvocation $invocation): array ), method: __METHOD__); } - $filter = $invocation->filter; - $context = $invocation->context; - - if (!$filterElementDescriptor = $this->filterElementRegistry->get($filter->getElementType())) { + if (!$element = $this->filterElementResolver->resolve($filter)) { return []; } - $filterElement = $filterElementDescriptor->getService(); - if (!$filterElement instanceof FilterElementInterface) { - return []; - } + $descriptor = ($type = $filter->getElementType()) ? $this->filterElementRegistry->get($type) : null; $targetAlias = TableAliasRegistry::ALIAS_MAIN; - if ($filterElementDescriptor->isTargeted() || $filter->isTargetingForced()) { - $targetAlias = $filter->getTargetAlias() ?: TableAliasRegistry::ALIAS_MAIN; + if ($descriptor?->isTargeted() || $filter->targetingForced) { + $targetAlias = $filter->targetAlias ?: TableAliasRegistry::ALIAS_MAIN; } $builder = new FilterBuilder($this->filterTypeRegistry, $targetAlias); $event = $this->eventDispatcher->dispatch(new FilterElementBuildingEvent( - invocation: $invocation, context: $context, builder: $builder, - shouldBuild: true, + data: $data, )); if (!$event->shouldBuild()) { @@ -119,7 +118,7 @@ public function invokeFilter(FilterInvocation $invocation): array try { - $filterElement->buildFilter($builder, $invocation); + $element->buildFilter($builder, $context, $data); } catch (AbortFilteringException $e) { @@ -127,23 +126,23 @@ public function invokeFilter(FilterInvocation $invocation): array } catch (FilterException $e) { - throw $this->createCallbackException($e, $filter, $filterElement); + throw $this->createFilterException($e, $filter, $element::class . '::buildFilter'); } catch (\Throwable $e) { throw new FilterException($e->getMessage(), code: $e->getCode(), previous: $e, method: __METHOD__); } - $this->eventDispatcher->dispatch(new FilterElementBuiltEvent($invocation, $builder)); + $this->eventDispatcher->dispatch(new FilterElementBuiltEvent($context, $builder, $data)); - return $this->buildQueryBuilders($builder->all(), $filter, $filterElement); + return $this->buildQueryBuilders($builder->all(), $filter); } /** * @param FilterCall[] $calls * @return FilterQueryBuilder[] */ - private function buildQueryBuilders(array $calls, ConfiguredFilter $filter, object $filterElement): array + private function buildQueryBuilders(array $calls, Filter $filter): array { $filterQueryBuilders = []; @@ -161,11 +160,11 @@ private function buildQueryBuilders(array $calls, ConfiguredFilter $filter, obje } catch (FilterException $e) { - throw $this->createCallbackException($e, $filter, $call->type); + throw $this->createFilterException($e, $filter, $call->typeClass . '::buildQuery'); } catch (\Throwable $e) { - throw new FilterException($e->getMessage(), code: $e->getCode(), previous: $e, method: $filterElement::class); + throw new FilterException($e->getMessage(), code: $e->getCode(), previous: $e, method: $call->typeClass); } $filterQueryBuilders[] = $filterQueryBuilder; @@ -174,47 +173,14 @@ private function buildQueryBuilders(array $calls, ConfiguredFilter $filter, obje return $filterQueryBuilders; } - private function createCallbackException( - FilterException $e, - ConfiguredFilter $filter, - mixed $callback - ): FilterException { - if (!$errorMethod = $e->getMethod()) - { - $serviceId = null; - $method = '___UNKNOWN___'; - - if (\is_object($callback)) - { - $serviceId = $callback::class; - $method = '::__invoke'; - } - - if (!$serviceId && \is_callable($callback)) - { - try - { - $reflection = new \ReflectionFunction($callback); - $serviceId = $reflection->getClosureScopeClass()?->getName() ?? 'Closure'; - $method = '::' . $reflection->getName(); - } - /** @mago-expect lint:no-empty-catch-clause ReflectionException is safely ignored here */ - catch (\ReflectionException) {} - } - - if (!$serviceId) - { - $serviceId = \gettype($callback); - $method = '()'; - } - - $errorMethod = $serviceId . $method; - } + private function createFilterException(FilterException $e, Filter $filter, string $fallbackMethod): FilterException + { + $errorMethod = $e->getMethod() ?: $fallbackMethod; return new FilterException( \sprintf('[FLARE] Query denied: %s / Callback: %s', $e->getMessage(), $errorMethod), code: $e->getCode(), previous: $e, method: $errorMethod, - source: \sprintf('tl_flare_filter.id=%s', $filter->getDataSource()?->getFilterIdentifier() ?: '0'), + source: $filter->source ?: 'filter inlined', ); } -} \ No newline at end of file +} diff --git a/src/Registry/Descriptor/FilterElementDescriptor.php b/src/Registry/Descriptor/FilterElementDescriptor.php index 5dc17d2c..58f1bd98 100644 --- a/src/Registry/Descriptor/FilterElementDescriptor.php +++ b/src/Registry/Descriptor/FilterElementDescriptor.php @@ -4,34 +4,26 @@ namespace HeimrichHannot\FlareBundle\Registry\Descriptor; -use HeimrichHannot\FlareBundle\Contract\Config\PaletteConfig; -use HeimrichHannot\FlareBundle\Contract\PaletteContract; use HeimrichHannot\FlareBundle\DependencyInjection\Compiler\RegisterFilterElementsPass; use HeimrichHannot\FlareBundle\DependencyInjection\Registry\ServiceDescriptorInterface; -use HeimrichHannot\FlareBundle\FilterElement\AbstractFilterElement; +use HeimrichHannot\FlareBundle\FilterElement\FilterElementInterface; -class FilterElementDescriptor implements ServiceDescriptorInterface, PaletteContract +class FilterElementDescriptor implements ServiceDescriptorInterface { /** @see RegisterFilterElementsPass::getFilterElementConfig */ public function __construct( - private object $service, - private array $attributes = [], - private ?string $palette = null, - private ?string $formType = null, - private ?string $method = null, - private ?bool $isTargeted = null, + private FilterElementInterface $service, + private array $attributes = [], + private ?bool $isTargeted = null, + private bool $intrinsicOnly = false, ) {} - /** - * @noinspection PhpDocSignatureInspection - * @return AbstractFilterElement|object - */ - public function getService(): object + public function getService(): FilterElementInterface { return $this->service; } - public function setService(object $service): void + public function setService(FilterElementInterface $service): void { $this->service = $service; } @@ -46,54 +38,16 @@ public function setAttributes(array $attributes): void $this->attributes = $attributes; } - public function getFormType(): ?string - { - return $this->formType; - } - - public function setFormType(?string $formType): void - { - $this->formType = $formType; - } - - public function getPalette(PaletteConfig $config): ?string - { - return $this->palette; - } - - public function setPalette(?string $palette): void - { - $this->palette = $palette; - } - - public function getMethod(): ?string - { - return $this->method; - } - - public function setMethod(?string $method): void - { - $this->method = $method; - } - public function isTargeted(): ?bool { return $this->isTargeted; } - public function setIsTargeted(?bool $isTargeted): void - { - $this->isTargeted = $isTargeted; - } - - public function hasFormType(): bool - { - $class = $this->getFormType(); - return $class !== null && \class_exists($class); - } - - public function isIntrinsicRequired(): bool + /** + * Whether the element never renders a form control and must be configured intrinsically. + */ + public function isIntrinsicOnly(): bool { - return !$this->hasFormType(); + return $this->intrinsicOnly; } -} \ No newline at end of file +} diff --git a/src/Registry/Descriptor/FlareCallbackDescriptor.php b/src/Registry/Descriptor/FlareCallbackDescriptor.php deleted file mode 100644 index c157e4ae..00000000 --- a/src/Registry/Descriptor/FlareCallbackDescriptor.php +++ /dev/null @@ -1,78 +0,0 @@ -service; - } - - public function getAttributes(): array - { - return $this->attributes; - } - - public function setAttributes(array $attributes): void - { - $this->attributes = $attributes; - } - - public function getFilterElementAlias(): ?string - { - return $this->filterElementAlias; - } - - public function setFilterElementAlias(?string $filterElementAlias): void - { - $this->filterElementAlias = $filterElementAlias; - } - - public function getTarget(): ?string - { - return $this->target; - } - - public function setTarget(?string $target): void - { - $this->target = $target; - } - - public function getMethod(): ?string - { - return $this->method; - } - - public function setMethod(?string $method): void - { - $this->method = $method; - } - - public function getPriority(): int - { - return $this->priority; - } - - public function setPriority(int $priority): void - { - $this->priority = $priority; - } -} \ No newline at end of file diff --git a/src/Registry/Descriptor/ListTypeDescriptor.php b/src/Registry/Descriptor/ListTypeDescriptor.php index ce8370e1..8c9cf4a7 100644 --- a/src/Registry/Descriptor/ListTypeDescriptor.php +++ b/src/Registry/Descriptor/ListTypeDescriptor.php @@ -4,19 +4,15 @@ namespace HeimrichHannot\FlareBundle\Registry\Descriptor; -use HeimrichHannot\FlareBundle\Contract\Config\PaletteConfig; -use HeimrichHannot\FlareBundle\Contract\PaletteContract; use HeimrichHannot\FlareBundle\DependencyInjection\Registry\ServiceDescriptorInterface; use HeimrichHannot\FlareBundle\ListType\AbstractListType; -class ListTypeDescriptor implements ServiceDescriptorInterface, PaletteContract +class ListTypeDescriptor implements ServiceDescriptorInterface { public function __construct( private object $service, private array $attributes = [], private ?string $dataContainer = null, - private ?string $palette = null, - private ?string $method = null ) {} /** @@ -52,24 +48,4 @@ public function setDataContainer(?string $dataContainer): void { $this->dataContainer = $dataContainer; } - - public function getPalette(PaletteConfig $config): ?string - { - return $this->palette; - } - - public function setPalette(?string $palette): void - { - $this->palette = $palette; - } - - public function getMethod(): ?string - { - return $this->method; - } - - public function setMethod(?string $method): void - { - $this->method = $method; - } -} \ No newline at end of file +} diff --git a/src/Registry/FilterElementResolver.php b/src/Registry/FilterElementResolver.php new file mode 100644 index 00000000..06068345 --- /dev/null +++ b/src/Registry/FilterElementResolver.php @@ -0,0 +1,48 @@ +getElementInstance()) { + return $instance; + } + + return $this->resolveType($filter->getElementType(), $filter->source); + } + + public function resolveType(?string $type, ?string $source = null): ?FilterElementInterface + { + $service = $this->filterElementRegistry->get((string) $type)?->getService(); + + if (!$service instanceof FilterElementInterface) + { + $this->logger->warning(\sprintf( + '[FLARE] No filter element registered for type "%s" — filter skipped. (%s)', + $type, + $source ?: 'filter inlined', + )); + + return null; + } + + return $service; + } +} diff --git a/src/Registry/FilterTypeRegistry.php b/src/Registry/FilterTypeRegistry.php index 2b04ac2a..0d288ce2 100644 --- a/src/Registry/FilterTypeRegistry.php +++ b/src/Registry/FilterTypeRegistry.php @@ -15,7 +15,7 @@ class FilterTypeRegistry private array $types; public function __construct( - #[TaggedIterator(FilterTypeInterface::TAG)] + #[TaggedIterator(FilterTypeInterface::FLARE_FILTER_TYPE_TAG)] private readonly iterable $filterTypes, ) {} @@ -42,7 +42,12 @@ private function resolve(): array foreach ($this->filterTypes as $filterType) { if (!$filterType instanceof FilterTypeInterface) { - continue; + throw new \LogicException(\sprintf( + 'Service "%s" is tagged "%s" but does not implement %s.', + $filterType::class, + FilterTypeInterface::FLARE_FILTER_TYPE_TAG, + FilterTypeInterface::class, + )); } $this->types[$filterType::class] = $filterType; diff --git a/src/Registry/FlareCallbackRegistry.php b/src/Registry/FlareCallbackRegistry.php deleted file mode 100644 index 84c92e78..00000000 --- a/src/Registry/FlareCallbackRegistry.php +++ /dev/null @@ -1,23 +0,0 @@ -elementType = $type; - - if (!\is_null($alias)) { - $this->setAlias($alias); - } - - $this->setProperties($rawData); - } - - public function getElementType(): string - { - return $this->elementType; - } - - public function setElementType(string $elementType): static - { - $this->elementType = $elementType; - return $this; - } - - /** - * @deprecated Use getElementType(). - */ - public function getType(): string - { - return $this->getElementType(); - } - - /** - * @deprecated Use setElementType(). - */ - public function setType(string $type): static - { - return $this->setElementType($type); - } - - public function getAlias(): ?string - { - return $this->alias; - } - - public function setAlias(?string $alias): static - { - if (!\is_null($alias) && !\preg_match('/^\w+$/', $alias)) { - throw new \InvalidArgumentException(\sprintf('Filter alias "%s" is invalid: must be alphanumeric and may only contain underscores.', $alias)); - } - $this->alias = $alias; - return $this; - } - - public function isIntrinsic(): bool - { - return $this->intrinsic; - } - - public function setIntrinsic(bool $intrinsic): static - { - $this->intrinsic = $intrinsic; - return $this; - } - - public function getDataSource(): ?FilterDataSourceInterface - { - return $this->dataSource; - } - - public function setDataSource(?FilterDataSourceInterface $dataSource): static - { - $this->dataSource = $dataSource; - return $this; - } - - public function setTargetAlias(?string $targetAlias): static - { - if (\is_null($targetAlias)) { - $this->setTargetingForced(false); - } - - $this->targetAlias = $targetAlias; - return $this; - } - - public function getTargetAlias(): ?string - { - return $this->targetAlias; - } - - public function setTargetingForced(bool $isTargetingForced): static - { - $this->isTargetingForced = $isTargetingForced; - return $this; - } - - public function isTargetingForced(): bool - { - return $this->isTargetingForced; - } - - public function forceTargetAlias(string $targetAlias): static - { - return $this - ->setTargetAlias($targetAlias) - ->setTargetingForced(true); - } - - public function __isset(string $name): bool - { - return match ($name) { - 'type', 'elementType', 'intrinsic' => true, - 'alias', 'targetAlias', 'target_alias', 'dataSource', 'sourceFilterModel' => $this->__get($name) !== null, - default => $this->issetProperty($name), - }; - } - - public function __set(string $name, mixed $value): void - { - match ($name) { - 'type', 'elementType' => $this->setElementType($value), - 'intrinsic' => $this->setIntrinsic($value), - 'targetAlias', 'target_alias' => $this->setTargetAlias($value), - 'dataSource', 'sourceFilterModel' => $this->setDataSource($value), - default => $this->setProperty($name, $value), - }; - } - - public function __get(string $name): mixed - { - return match ($name) { - 'type', 'elementType' => $this->getElementType(), - 'intrinsic' => $this->isIntrinsic(), - 'targetAlias', 'target_alias' => $this->getTargetAlias(), - 'dataSource', 'sourceFilterModel' => $this->getDataSource(), - default => $this->getProperty($name), - }; - } - - public function getRawData(): array - { - return $this->getProperties(); - } - - public function getRow(): array - { - return \array_merge($this->getProperties(), [ - 'type' => $this->elementType, - 'elementType' => $this->elementType, - 'intrinsic' => $this->intrinsic, - 'targetAlias' => $this->targetAlias, - ]); - } - - public function hash(): string - { - return \sha1(\serialize([ - 'row' => $this->getRow(), - 'filter' => $this->getDataSource() ? [ - 'id' => $this->getDataSource()->getFilterProperty('id'), - 'type' => $this->getDataSource()->getFilterProperty('type'), - ] : null, - ])); - } -} \ No newline at end of file diff --git a/src/Specification/DataSource/FilterDataSourceInterface.php b/src/Specification/DataSource/FilterDataSourceInterface.php deleted file mode 100644 index 9bdcf24b..00000000 --- a/src/Specification/DataSource/FilterDataSourceInterface.php +++ /dev/null @@ -1,22 +0,0 @@ -getFilterType(), - intrinsic: $dataSource->isFilterIntrinsic(), - alias: $dataSource->getFilterFormName(), - targetAlias: $dataSource->getFilterTargetAlias(), - dataSource: $dataSource, - rawData: $dataSource->getFilterData(), - ); - - $event = $this->eventDispatcher->dispatch(new ConfiguredFilterCreatedEvent($filter)); - - return $event->configuredFilter; - } -} \ No newline at end of file diff --git a/src/Specification/Factory/ListSpecificationFactory.php b/src/Specification/Factory/ListSpecificationFactory.php index d501c0a4..3514682b 100644 --- a/src/Specification/Factory/ListSpecificationFactory.php +++ b/src/Specification/Factory/ListSpecificationFactory.php @@ -4,7 +4,6 @@ namespace HeimrichHannot\FlareBundle\Specification\Factory; -use HeimrichHannot\FlareBundle\Collection\ConfiguredFilterCollection; use HeimrichHannot\FlareBundle\Event\ListSpecificationCreatedEvent; use HeimrichHannot\FlareBundle\Registry\FilterCollectorRegistry; use HeimrichHannot\FlareBundle\Specification\DataSource\ListDataSourceInterface; @@ -24,16 +23,17 @@ public function __construct( public function create(ListDataSourceInterface $dataSource): ListSpecification { - // Automatically collect filters (delegate to FilterCollectorRegistry) - $filterCollection = $this->collectFilters($dataSource); - $specification = new ListSpecification( type: $dataSource->getListType(), dc: $dataSource->getListTable(), dataSource: $dataSource, - filters: $filterCollection, ); + // Automatically collect filters (delegate to FilterCollectorRegistry) + foreach ($this->collectFilters($dataSource) as $key => $filter) { + $specification->addFilter($filter, $key); + } + $specification->setProperties($dataSource->getListData()); $event = $this->eventDispatcher->dispatch(new ListSpecificationCreatedEvent($specification)); @@ -41,14 +41,11 @@ public function create(ListDataSourceInterface $dataSource): ListSpecification return $event->listSpecification; } - private function collectFilters(ListDataSourceInterface $dataSource): ConfiguredFilterCollection + /** + * @return array + */ + private function collectFilters(ListDataSourceInterface $dataSource): array { - $collector = $this->filterCollectors->match($dataSource); - - if (!$collector) { - return new ConfiguredFilterCollection(); - } - - return $collector->collect($dataSource) ?? new ConfiguredFilterCollection(); + return $this->filterCollectors->match($dataSource)?->collect($dataSource) ?? []; } -} \ No newline at end of file +} diff --git a/src/Specification/ListSpecification.php b/src/Specification/ListSpecification.php index 29f1aecb..b9058e73 100644 --- a/src/Specification/ListSpecification.php +++ b/src/Specification/ListSpecification.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\Specification; -use HeimrichHannot\FlareBundle\Collection\ConfiguredFilterCollection; +use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Model\DocumentsListModelTrait; use HeimrichHannot\FlareBundle\Specification\DataSource\ListDataSourceInterface; @@ -15,14 +15,18 @@ class ListSpecification use DocumentsListModelTrait; use DynamicPropertiesTrait; + /** + * @var array + */ + private array $filters = []; + + private int $generatedFilterKeys = 0; + public function __construct( - public readonly string $type, - public readonly string $dc, - private ?ListDataSourceInterface $dataSource = null, - private ?ConfiguredFilterCollection $filters = null, - ) { - $this->filters ??= new ConfiguredFilterCollection(); - } + public readonly string $type, + public readonly string $dc, + private ?ListDataSourceInterface $dataSource = null, + ) {} public function getDataSource(): ?ListDataSourceInterface { @@ -35,14 +39,45 @@ public function setDataSource(?ListDataSourceInterface $dataSource): static return $this; } - public function getFilters(): ConfiguredFilterCollection + /** + * @return array + */ + public function getFilters(): array { return $this->filters; } - public function setFilters(ConfiguredFilterCollection $filters): void + public function getFilter(string $key): ?Filter + { + return $this->filters[$key] ?? null; + } + + /** + * Adds a filter. The key defaults to the filter's alias; alias-less filters receive a generated key. + */ + public function addFilter(Filter $filter, ?string $key = null): static + { + $key ??= $filter->alias ?? ('_generated_' . $this->generatedFilterKeys++); + $this->filters[$key] = $filter; + return $this; + } + + public function removeFilter(string $key): static { - $this->filters = $filters; + unset($this->filters[$key]); + return $this; + } + + public function hasFilterOfType(string $elementType): bool + { + foreach ($this->filters as $filter) + { + if ($filter->getElementType() === $elementType) { + return true; + } + } + + return false; } public function hash(): string @@ -50,7 +85,7 @@ public function hash(): string return \sha1(\serialize([ $this->type, $this->dc, - $this->filters->hash(), + \array_map(static fn (Filter $filter): array => $filter->fingerprint(), $this->filters), 'model' => $this->dataSource ? [ $this->dataSource->getListIdentifier(), $this->dataSource->getListType(), @@ -58,9 +93,4 @@ public function hash(): string ] : null, ])); } - - public function __clone(): void - { - $this->filters = clone $this->filters; - } -} \ No newline at end of file +} diff --git a/src/Twig/Extension/FlareExtension.php b/src/Twig/Extension/FlareExtension.php index 840950da..34b35dff 100644 --- a/src/Twig/Extension/FlareExtension.php +++ b/src/Twig/Extension/FlareExtension.php @@ -16,6 +16,7 @@ public function getFunctions(): array new TwigFunction('flare_content', [FlareRuntime::class, 'getTlContent'], ['is_safe' => ['html']]), new TwigFunction('flare_enclosure', [FlareRuntime::class, 'getEnclosure']), new TwigFunction('flare_enclosure_files', [FlareRuntime::class, 'getEnclosureFiles']), + new TwigFunction('flare_make_filter', [FlareRuntime::class, 'makeFilter']), new TwigFunction('flare_project', [FlareRuntime::class, 'project']), new TwigFunction('flare_schema_org', [FlareRuntime::class, 'getSchemaOrg'], ['needs_context'=> true]), ]; diff --git a/src/Twig/Runtime/FlareRuntime.php b/src/Twig/Runtime/FlareRuntime.php index b61c447b..f4b90210 100644 --- a/src/Twig/Runtime/FlareRuntime.php +++ b/src/Twig/Runtime/FlareRuntime.php @@ -14,6 +14,8 @@ use HeimrichHannot\FlareBundle\Engine\Engine; use HeimrichHannot\FlareBundle\Engine\View\ViewInterface; use HeimrichHannot\FlareBundle\Event\ReaderSchemaOrgEvent; +use HeimrichHannot\FlareBundle\Filter\Filter; +use HeimrichHannot\FlareBundle\Filter\Type\FilterTypeInterface; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Registry\ProjectorRegistry; use HeimrichHannot\FlareBundle\Specification\ListSpecification; @@ -33,6 +35,23 @@ public function project(ListSpecification $spec, ContextInterface $config): View return $this->projectorRegistry->getProjectorFor($spec, $config)->project($spec, $config); } + /** + * Creates a filter for programmatic use, e.g. `{% do flare.list.addFilter(flare_make_filter(...)) %}`. + * + * @param string $type A registered filter element type alias (config keys are the element's + * canonical config), or a filter type class-string (config keys are the type's options). + * @param array $config + * @param array|null $data Runtime data bag, as buildFilter() receives it. + */ + public function makeFilter(string $type, array $config = [], ?array $data = null, ?string $alias = null): Filter + { + if (\is_a($type, FilterTypeInterface::class, true)) { + return Filter::fromType($type, $config); + } + + return new Filter(element: $type, config: $config, data: $data, alias: $alias); + } + /** * @throws \InvalidArgumentException */ diff --git a/src/Util/CallbackHelper.php b/src/Util/CallbackHelper.php index 7261be3e..3e1a9787 100644 --- a/src/Util/CallbackHelper.php +++ b/src/Util/CallbackHelper.php @@ -4,88 +4,8 @@ namespace HeimrichHannot\FlareBundle\Util; -use HeimrichHannot\FlareBundle\DependencyInjection\Registry\ServiceDescriptorInterface; - -/** - * Class CallbackHelper - * - * @internal For internal use only. API might change without notice. - */ class CallbackHelper { - /** - * Invokes a set of callbacks with the given mandatory and optional parameters. - * - * @param ServiceDescriptorInterface[] $callbacks An array of callbacks or a single callable. - * @param array $mandatory Mandatory parameters to pass to the callbacks. - * @param array $parameters Optional parameters to pass to the callbacks. - * - * @throws \InvalidArgumentException if the callback is not callable. - * @throws \RuntimeException if an error occurs while invoking the callback. - */ - public static function call(array $callbacks, array $mandatory, array $parameters): void - { - foreach ($callbacks as $callbackConfig) - { - if (!$callbackConfig instanceof ServiceDescriptorInterface) { - throw new \InvalidArgumentException('Callback must be an instance of ServiceDescriptorInterface'); - } - - $method = $callbackConfig->getMethod(); - $service = $callbackConfig->getService(); - - if (!$method || !\method_exists($service, $method)) { - continue; - } - - try - { - MethodInjector::invoke($service, $method, $mandatory, $parameters); - } - catch (\Exception $e) - { - throw new \RuntimeException( - \sprintf('Error invoking callback: %s', $e->getMessage()), $e->getCode(), $e - ); - } - } - } - - /** - * @param ServiceDescriptorInterface[] $callbacks - * @throws \RuntimeException thrown if the callback method parameters cannot be auto-resolved - */ - public static function firstReturn(array $callbacks, array $mandatory, array $parameters): mixed - { - foreach ($callbacks as $callbackConfig) - { - if (!$callbackConfig instanceof ServiceDescriptorInterface) { - throw new \InvalidArgumentException('Callback must be an instance of ServiceDescriptorInterface'); - } - - $method = $callbackConfig->getMethod(); - $service = $callbackConfig->getService(); - - if (!$method || !\method_exists($service, $method)) { - continue; - } - - try { - $return = MethodInjector::invoke($service, $method, $mandatory, $parameters); - } catch (\Exception $e) { - throw new \RuntimeException( - \sprintf('Error invoking callback: %s', $e->getMessage()), $e->getCode(), $e - ); - } - - if (isset($return)) { - return $return; - } - } - - return null; - } - /** * Attempts to retrieve the value of a property from an object. If a getter method exists for the property, * it will be invoked. Otherwise, it will attempt to access the property directly or via magic methods. @@ -125,4 +45,4 @@ public static function tryGetProperty(object $obj, string $prop, mixed $default return $default; } -} \ No newline at end of file +} diff --git a/src/Util/MethodInjector.php b/src/Util/MethodInjector.php deleted file mode 100644 index df8dbb84..00000000 --- a/src/Util/MethodInjector.php +++ /dev/null @@ -1,89 +0,0 @@ -getParameters() as $parameter) - { - if ($skipped < \count($mandatoryParams)) - { - $skipped++; - continue; - } - - // @phpstan-ignore method.notFound - if ($parameter->getType() && !$parameter->getType()->isBuiltin()) - { - // @phpstan-ignore method.notFound - $typeName = $parameter->getType()->getName(); - - if (\array_key_exists($typeName, $optionalParams)) - { - $arguments[] = $optionalParams[$typeName]; - continue; - } - } - - if (\array_key_exists($parameter->getName(), $optionalParams)) - { - $arguments[] = $optionalParams[$parameter->getName()]; - continue; - } - - if ($parameter->isDefaultValueAvailable()) - { - $arguments[] = $parameter->getDefaultValue(); - continue; - } - - if (!$parameter->hasType() - || (($type = $parameter->getType()) instanceof \ReflectionNamedType - && ($type->allowsNull() || $type->getName() === 'mixed'))) - { - $arguments[] = null; - continue; - } - - throw new \RuntimeException(sprintf( - 'Unable to resolve parameter "%s" for method %s::%s', - $parameter->getName(), - get_class($service), - $method - )); - } - - return $reflectionMethod->invokeArgs($service, $arguments); - } -} \ No newline at end of file diff --git a/src/Util/Str.php b/src/Util/Str.php index 2db295b2..c8409ea5 100644 --- a/src/Util/Str.php +++ b/src/Util/Str.php @@ -104,6 +104,18 @@ public static function isValidSqlName(?string $db_or_col_name): bool return $db_or_col_name && \preg_match('/^[A-Za-z_]\w*$/', $db_or_col_name); } + /** + * Whether the given name is a valid, non-empty Symfony form name. + * Mirrors {@see \Symfony\Component\Form\FormConfigBuilder::isValidName()} except that + * empty names are rejected. Generated filter aliases like "_.tl_flare_filter.42" fail + * this check by design and therefore never mount form children. + */ + public static function isValidFormName(?string $name): bool + { + return $name !== null && $name !== '' + && \preg_match('/^[a-zA-Z0-9_][a-zA-Z0-9_\-:]*$/D', $name) === 1; + } + public static function wrap(mixed $value): string { if (\is_null($value)) { diff --git a/tests/Filter/FilterBuilderTest.php b/tests/Filter/FilterBuilderTest.php index 757cfeba..cabe4fca 100644 --- a/tests/Filter/FilterBuilderTest.php +++ b/tests/Filter/FilterBuilderTest.php @@ -91,4 +91,7 @@ public function buildQuery(FilterQueryBuilder $builder, array $options): void final class UnknownFilterType extends AbstractFilterType { + public function buildQuery(FilterQueryBuilder $builder, array $options): void + { + } } diff --git a/tests/FilterElement/AbstractFilterElementTest.php b/tests/FilterElement/AbstractFilterElementTest.php deleted file mode 100644 index 8b1695b1..00000000 --- a/tests/FilterElement/AbstractFilterElementTest.php +++ /dev/null @@ -1,89 +0,0 @@ -buildForm($builder, $this->createContext(new ConfiguredFilter( - type: 'test', - intrinsic: true, - alias: 'field', - ))); - - self::assertSame([], $builder->added); - } - - public function testNonIntrinsicFiltersAttachFormFields(): void - { - $element = new TestFilterElement(); - $builder = new RecordingFilterFormBuilder(); - $filter = new ConfiguredFilter( - type: 'test', - intrinsic: false, - alias: 'field', - ); - - $element->buildForm($builder, $this->createContext($filter)); - - self::assertSame([$filter], $builder->added); - } - - private function createContext(ConfiguredFilter $filter): FilterElementContext - { - return new FilterElementContext( - list: new ListSpecification('test_list', 'tl_test'), - filter: $filter, - engineContext: new TestContext(), - descriptor: new FilterElementDescriptor(new TestFilterElement(), formType: 'test_form'), - ); - } -} - -final class TestFilterElement extends AbstractFilterElement -{ -} - -final class RecordingFilterFormBuilder implements FilterFormBuilderInterface -{ - /** - * @var ConfiguredFilter[] - */ - public array $added = []; - - public function add(FilterElementContext $context, ?string $formType = null, array $options = []): static - { - $this->added[] = $context->filter; - - return $this; - } - - public function getRootBuilder(): FormBuilderInterface - { - throw new \LogicException('Not used in this test.'); - } -} - -final class TestContext implements ContextInterface -{ - public static function getContextType(): string - { - return 'test'; - } -} From 38fba4339d4c7dad724f23ff8009767aa0516664 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Mon, 13 Jul 2026 18:17:45 +0200 Subject: [PATCH 11/71] refactor: consolidate filter namespace and align naming conventions Restructure the filter subsystem under the Filter\ namespace and align class/interface names with their roles. No behavior changes. - Move filter elements to Filter\Element\ and collectors to Filter\Collector\; suffix element classes consistently (PublishedFilterElement, SearchKeywordsFilterElement, ...) - Rename ConfigContract -> FilterElementOptionsInterface (configureConfig -> configureOptions) and FilterConfigResolver -> Filter\OptionsResolver\FilterOptionsResolver - Rename DcaContract::configureDca -> buildDca; introduce DcaBuilderInterface and DcaFieldBuilderInterface - Rename Form\Type\DateRangeFilterType -> DateRangeFormType, resolving the name collision with the query-side filter type - Drop unused ListItemProviderConfig; update translations - Add unit tests for Filter, FilterOptionsResolver, and ListSpecification; restore FilterFormListener named dispatch --- config/services.yaml | 4 +-- .../Config/ListItemProviderConfig.php | 19 ----------- src/Contract/DcaContract.php | 2 +- src/DataContainer/Builder/DcaBuilder.php | 6 ++-- .../Builder/DcaBuilderInterface.php | 18 ++++++++++ src/DataContainer/Builder/DcaFieldBuilder.php | 4 +-- .../Builder/DcaFieldBuilderInterface.php | 20 +++++++++++ src/Engine/Loader/ValidationLoader.php | 8 ++--- src/Engine/Mod/SimpleEquationMod.php | 6 ++-- .../Contao/ElementDcaListener.php | 2 +- .../Collector}/FilterCollectorInterface.php | 2 +- .../Collector}/ListModelFilterCollector.php | 6 ++-- .../Element/AbstractFilterFilterElement.php | 33 +++++++++++++++++++ .../Element}/ArchiveElement.php | 10 +++--- .../Element}/BelongsToRelationElement.php | 10 +++--- .../Element}/BooleanElement.php | 23 +++++++------ .../Element/CalendarCurrentFilterElement.php} | 10 +++--- .../Element}/CallbackFilterElement.php | 2 +- .../Element}/DateRangeElement.php | 10 +++--- .../Element}/DcaSelectFieldElement.php | 22 ++++++------- .../Element}/FieldValueChoiceElement.php | 10 +++--- .../Element}/FilterElementInterface.php | 2 +- .../FilterElementOptionsInterface.php} | 6 ++-- .../Element/PublishedFilterElement.php} | 10 +++--- .../Element/SearchKeywordsFilterElement.php} | 10 +++--- .../Element/SimpleEquationFilterElement.php} | 10 +++--- src/Filter/Filter.php | 6 ++-- .../FilterOptionsResolver.php} | 15 +++++---- src/FilterElement/AbstractFilterElement.php | 22 ------------- src/Form/Factory/FilterFormFactory.php | 4 +-- ...geFilterType.php => DateRangeFormType.php} | 4 +-- .../FilterCallback/TargetAliasCallback.php | 4 +-- ...php => CodefogTagsChoiceFilterElement.php} | 19 ++++++----- .../CodefogTagsSearchElement.php | 19 ++++++++--- .../ListType/EventsListType.php | 10 +++--- .../EventListener/ChangelanguageListener.php | 16 ++++----- src/ListType/GenericDataContainerListType.php | 2 +- src/ListType/NewsListType.php | 10 +++--- src/Query/Executor/FilterExecutor.php | 8 ++--- .../Descriptor/FilterElementDescriptor.php | 2 +- src/Registry/FilterCollectorRegistry.php | 4 +-- src/Registry/FilterElementResolver.php | 2 +- translations/flare_filter.de.php | 24 +++++++------- translations/flare_filter.en.php | 25 +++++++------- 44 files changed, 243 insertions(+), 218 deletions(-) delete mode 100644 src/Contract/Config/ListItemProviderConfig.php create mode 100644 src/DataContainer/Builder/DcaBuilderInterface.php create mode 100644 src/DataContainer/Builder/DcaFieldBuilderInterface.php rename src/{FilterCollector => Filter/Collector}/FilterCollectorInterface.php (91%) rename src/{FilterCollector => Filter/Collector}/ListModelFilterCollector.php (92%) create mode 100644 src/Filter/Element/AbstractFilterFilterElement.php rename src/{FilterElement => Filter/Element}/ArchiveElement.php (98%) rename src/{FilterElement => Filter/Element}/BelongsToRelationElement.php (94%) rename src/{FilterElement => Filter/Element}/BooleanElement.php (89%) rename src/{FilterElement/CalendarCurrentElement.php => Filter/Element/CalendarCurrentFilterElement.php} (94%) rename src/{FilterElement => Filter/Element}/CallbackFilterElement.php (95%) rename src/{FilterElement => Filter/Element}/DateRangeElement.php (88%) rename src/{FilterElement => Filter/Element}/DcaSelectFieldElement.php (94%) rename src/{FilterElement => Filter/Element}/FieldValueChoiceElement.php (96%) rename src/{FilterElement => Filter/Element}/FilterElementInterface.php (95%) rename src/{Contract/FilterElement/ConfigContract.php => Filter/Element/FilterElementOptionsInterface.php} (82%) rename src/{FilterElement/PublishedElement.php => Filter/Element/PublishedFilterElement.php} (87%) rename src/{FilterElement/SearchKeywordsElement.php => Filter/Element/SearchKeywordsFilterElement.php} (87%) rename src/{FilterElement/SimpleEquationElement.php => Filter/Element/SimpleEquationFilterElement.php} (89%) rename src/Filter/{FilterConfigResolver.php => OptionsResolver/FilterOptionsResolver.php} (71%) delete mode 100644 src/FilterElement/AbstractFilterElement.php rename src/Form/Type/{DateRangeFilterType.php => DateRangeFormType.php} (98%) rename src/Integration/CodefogTags/FilterElement/{CodefogTagsChoiceElement.php => CodefogTagsChoiceFilterElement.php} (92%) diff --git a/config/services.yaml b/config/services.yaml index 39efe127..dcb8ec89 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -1,3 +1,4 @@ +# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json services: _defaults: autowire: true @@ -15,9 +16,6 @@ services: - ../src/FilterElement/CallbackFilterElement.php - ../src/Registry/Descriptor - # Manually registered because top-level src/Filter/*.php files are excluded above - HeimrichHannot\FlareBundle\Filter\FilterConfigResolver: ~ - HeimrichHannot\FlareBundle\Engine\: resource: ../src/Engine exclude: diff --git a/src/Contract/Config/ListItemProviderConfig.php b/src/Contract/Config/ListItemProviderConfig.php deleted file mode 100644 index e2767179..00000000 --- a/src/Contract/Config/ListItemProviderConfig.php +++ /dev/null @@ -1,19 +0,0 @@ -listSpecification; - } -} \ No newline at end of file diff --git a/src/Contract/DcaContract.php b/src/Contract/DcaContract.php index e9575b94..a9d26dc1 100644 --- a/src/Contract/DcaContract.php +++ b/src/Contract/DcaContract.php @@ -15,5 +15,5 @@ */ interface DcaContract { - public function configureDca(DcaBuilder $dca, DcaContext $context): void; + public function buildDca(DcaBuilder $dca, DcaContext $context): void; } diff --git a/src/DataContainer/Builder/DcaBuilder.php b/src/DataContainer/Builder/DcaBuilder.php index b8b44877..dc3ca3d5 100644 --- a/src/DataContainer/Builder/DcaBuilder.php +++ b/src/DataContainer/Builder/DcaBuilder.php @@ -1,4 +1,4 @@ -config->list; - $idDefinition = SimpleEquationElement::define( + $idDefinition = SimpleEquationFilterElement::define( equationLeft: 'id', equationOperator: SqlEquationOperator::EQUALS, equationRight: $id, @@ -69,7 +69,7 @@ public function fetchEntryByAutoItem(string $autoItem): ?array // IMPORTANT: clone the spec to not modify the original $list = clone $this->config->list; - $autoItemDefinition = SimpleEquationElement::define( + $autoItemDefinition = SimpleEquationFilterElement::define( equationLeft: $this->config->autoItemField, equationOperator: SqlEquationOperator::EQUALS, equationRight: $autoItem, @@ -112,4 +112,4 @@ private function executeQuery(ListSpecification $spec, ValidationContext $contex return $entry ?: null; } -} \ No newline at end of file +} diff --git a/src/Engine/Mod/SimpleEquationMod.php b/src/Engine/Mod/SimpleEquationMod.php index a3a2e267..1480f977 100644 --- a/src/Engine/Mod/SimpleEquationMod.php +++ b/src/Engine/Mod/SimpleEquationMod.php @@ -6,7 +6,7 @@ use HeimrichHannot\FlareBundle\Engine\Engine; use HeimrichHannot\FlareBundle\Enum\SqlEquationOperator; -use HeimrichHannot\FlareBundle\FilterElement\SimpleEquationElement; +use HeimrichHannot\FlareBundle\Filter\Element\SimpleEquationFilterElement; use Symfony\Component\OptionsResolver\OptionsResolver; class SimpleEquationMod extends AbstractMod @@ -18,7 +18,7 @@ public static function getType(): string public function __invoke(Engine $engine, array $options): void { - $filter = SimpleEquationElement::define( + $filter = SimpleEquationFilterElement::define( equationLeft: $options['operand1'], equationOperator: $options['operator'], equationRight: $options['operand2'], @@ -49,4 +49,4 @@ public function configureOptions(OptionsResolver $resolver): void ?? throw new \InvalidArgumentException('Invalid equation operator provided') ); } -} \ No newline at end of file +} diff --git a/src/EventListener/Contao/ElementDcaListener.php b/src/EventListener/Contao/ElementDcaListener.php index a2368826..6ccb9251 100644 --- a/src/EventListener/Contao/ElementDcaListener.php +++ b/src/EventListener/Contao/ElementDcaListener.php @@ -95,7 +95,7 @@ private function configure(string $table): void $dca = new DcaBuilder(); if ($service instanceof DcaContract) { - $service->configureDca($dca, $context); + $service->buildDca($dca, $context); } $this->eventDispatcher->dispatch(new ElementDcaEvent($dca, $context)); diff --git a/src/FilterCollector/FilterCollectorInterface.php b/src/Filter/Collector/FilterCollectorInterface.php similarity index 91% rename from src/FilterCollector/FilterCollectorInterface.php rename to src/Filter/Collector/FilterCollectorInterface.php index ff6c7239..05436c94 100644 --- a/src/FilterCollector/FilterCollectorInterface.php +++ b/src/Filter/Collector/FilterCollectorInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\FilterCollector; +namespace HeimrichHannot\FlareBundle\Filter\Collector; use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Specification\DataSource\ListDataSourceInterface; diff --git a/src/FilterCollector/ListModelFilterCollector.php b/src/Filter/Collector/ListModelFilterCollector.php similarity index 92% rename from src/FilterCollector/ListModelFilterCollector.php rename to src/Filter/Collector/ListModelFilterCollector.php index 7d780077..6f899c24 100644 --- a/src/FilterCollector/ListModelFilterCollector.php +++ b/src/Filter/Collector/ListModelFilterCollector.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\FilterCollector; +namespace HeimrichHannot\FlareBundle\Filter\Collector; use Contao\Controller; -use HeimrichHannot\FlareBundle\Contract\FilterElement\ConfigContract; use HeimrichHannot\FlareBundle\Event\FilterCollectedEvent; use HeimrichHannot\FlareBundle\Filter\Filter; +use HeimrichHannot\FlareBundle\Filter\Element\FilterElementOptionsInterface; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Registry\FilterElementResolver; @@ -60,7 +60,7 @@ public function collect(ListDataSourceInterface $dataSource): ?array continue; } - $config = $element instanceof ConfigContract + $config = $element instanceof FilterElementOptionsInterface ? $element->configFromRow($model->row()) : $model->row(); diff --git a/src/Filter/Element/AbstractFilterFilterElement.php b/src/Filter/Element/AbstractFilterFilterElement.php new file mode 100644 index 00000000..25575d7f --- /dev/null +++ b/src/Filter/Element/AbstractFilterFilterElement.php @@ -0,0 +1,33 @@ +define('intrinsic')->default(false)->allowedTypes('bool'); $resolver->define('whitelist_parents')->default([])->allowedTypes('int[]'); @@ -412,7 +410,7 @@ private function getPtableInferrer(ListSpecification $list): PtableInferrer return $this->_inferrer[$cacheKey] = new PtableInferrer($inferrable, $list->dc); } - public function configureDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilder $dca, DcaContext $context): void { if (!$filterModel = $context->filterModel) { return; diff --git a/src/FilterElement/BelongsToRelationElement.php b/src/Filter/Element/BelongsToRelationElement.php similarity index 94% rename from src/FilterElement/BelongsToRelationElement.php rename to src/Filter/Element/BelongsToRelationElement.php index bfa9cddd..43ba4b2a 100644 --- a/src/FilterElement/BelongsToRelationElement.php +++ b/src/Filter/Element/BelongsToRelationElement.php @@ -2,12 +2,10 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\FilterElement; +namespace HeimrichHannot\FlareBundle\Filter\Element; use Contao\Message; use Contao\StringUtil; -use HeimrichHannot\FlareBundle\Contract\DcaContract; -use HeimrichHannot\FlareBundle\Contract\FilterElement\ConfigContract; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; @@ -22,7 +20,7 @@ use Symfony\Contracts\Translation\TranslatorInterface; #[AsFilterElement(type: self::TYPE, intrinsicOnly: true)] -class BelongsToRelationElement extends AbstractFilterElement implements ConfigContract, DcaContract +class BelongsToRelationElement extends AbstractFilterFilterElement { public const TYPE = 'flare_relation_belongsTo'; @@ -30,7 +28,7 @@ public function __construct( private readonly TranslatorInterface $trans, ) {} - public function configureConfig(OptionsResolver $resolver): void + public function configureOptions(OptionsResolver $resolver): void { $resolver->define('intrinsic')->default(false)->allowedTypes('bool'); $resolver->define('field_pid')->default(null)->allowedTypes('string', 'null'); @@ -158,7 +156,7 @@ public function getDynamicParentGroups(array $parentGroups): array return $groups; } - public function configureDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilder $dca, DcaContext $context): void { $listModel = $context->listModel; $filterModel = $context->filterModel; diff --git a/src/FilterElement/BooleanElement.php b/src/Filter/Element/BooleanElement.php similarity index 89% rename from src/FilterElement/BooleanElement.php rename to src/Filter/Element/BooleanElement.php index 3d19d362..24a4bfa9 100644 --- a/src/FilterElement/BooleanElement.php +++ b/src/Filter/Element/BooleanElement.php @@ -2,12 +2,10 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\FilterElement; +namespace HeimrichHannot\FlareBundle\Filter\Element; use Contao\Controller; use Contao\Message; -use HeimrichHannot\FlareBundle\Contract\DcaContract; -use HeimrichHannot\FlareBundle\Contract\FilterElement\ConfigContract; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; @@ -22,11 +20,11 @@ use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE, isTargeted: true)] -class BooleanElement extends AbstractFilterElement implements ConfigContract, DcaContract +class BooleanElement extends AbstractFilterFilterElement { public const TYPE = 'flare_bool'; - public function configureConfig(OptionsResolver $resolver): void + public function configureOptions(OptionsResolver $resolver): void { $resolver->define('intrinsic')->default(false)->allowedTypes('bool'); $resolver->define('field')->default(null)->allowedTypes('string', 'null'); @@ -38,26 +36,27 @@ public function configureConfig(OptionsResolver $resolver): void public function configFromRow(array $row): array { + $label = $row['label'] ?? null; + $title = $row['title'] ?? null; + return [ 'intrinsic' => (bool) ($row['intrinsic'] ?? false), 'field' => ($row['fieldGeneric'] ?? null) ?: null, 'preselect' => $this->normalizeValue($row['preselect'] ?? null), 'mode' => BoolMode::tryFrom($row['boolMode'] ?? '') ?? BoolMode::BINARY, 'binary_choices' => BoolBinaryChoices::tryFrom($row['boolBinaryChoices'] ?? '') ?? BoolBinaryChoices::NULL_TRUE, - 'label' => ($row['label'] ?? null) ?: (($row['title'] ?? null) ?: null), + 'label' => $label ?: $title ?: null, ]; } public function buildForm(FormBuilderInterface $builder, FilterContext $context): void { - $config = $context->config; - - if ($config['intrinsic']) { + if ($context->config['intrinsic']) { return; } $builder->add(FilterContext::FIELD_VALUE, CheckboxType::class, [ - 'label' => $config['label'] ?? 'CBX', + 'label' => $context->config['label'] ?? 'CBX', 'required' => false, ]); } @@ -110,7 +109,7 @@ public function normalizeValue(mixed $value, ?BoolBinaryChoices $choices = null) return \filter_var($value, \FILTER_VALIDATE_BOOLEAN, \FILTER_NULL_ON_FAILURE); } - public function configureDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilder $dca, DcaContext $context): void { $intrinsic = (bool) $context->filterModel?->intrinsic; @@ -141,7 +140,7 @@ public function configureDca(DcaBuilder $dca, DcaContext $context): void } } - public function getFieldGenericOptions(string $targetTable): array + protected function getFieldGenericOptions(string $targetTable): array { Controller::loadDataContainer($targetTable); diff --git a/src/FilterElement/CalendarCurrentElement.php b/src/Filter/Element/CalendarCurrentFilterElement.php similarity index 94% rename from src/FilterElement/CalendarCurrentElement.php rename to src/Filter/Element/CalendarCurrentFilterElement.php index 03d8f3c4..f8c8aee6 100644 --- a/src/FilterElement/CalendarCurrentElement.php +++ b/src/Filter/Element/CalendarCurrentFilterElement.php @@ -2,10 +2,8 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\FilterElement; +namespace HeimrichHannot\FlareBundle\Filter\Element; -use HeimrichHannot\FlareBundle\Contract\DcaContract; -use HeimrichHannot\FlareBundle\Contract\FilterElement\ConfigContract; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; @@ -23,7 +21,7 @@ use Symfony\Contracts\Translation\TranslatorInterface; #[AsFilterElement(type: self::TYPE)] -class CalendarCurrentElement extends AbstractFilterElement implements ConfigContract, DcaContract +class CalendarCurrentFilterElement extends AbstractFilterFilterElement { public const TYPE = 'flare_calendar_current'; @@ -31,7 +29,7 @@ public function __construct( private readonly TranslatorInterface $translator, ) {} - public function configureConfig(OptionsResolver $resolver): void + public function configureOptions(OptionsResolver $resolver): void { $resolver->define('intrinsic')->default(false)->allowedTypes('bool'); $resolver->define('is_limited')->default(false)->allowedTypes('bool'); @@ -136,7 +134,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont ]); } - public function configureDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilder $dca, DcaContext $context): void { $palette = '{date_start_legend},configureStart,hasExtendedEvents;{date_stop_legend},configureStop;'; diff --git a/src/FilterElement/CallbackFilterElement.php b/src/Filter/Element/CallbackFilterElement.php similarity index 95% rename from src/FilterElement/CallbackFilterElement.php rename to src/Filter/Element/CallbackFilterElement.php index cbea313f..cc8cf440 100644 --- a/src/FilterElement/CallbackFilterElement.php +++ b/src/Filter/Element/CallbackFilterElement.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\FilterElement; +namespace HeimrichHannot\FlareBundle\Filter\Element; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; diff --git a/src/FilterElement/DateRangeElement.php b/src/Filter/Element/DateRangeElement.php similarity index 88% rename from src/FilterElement/DateRangeElement.php rename to src/Filter/Element/DateRangeElement.php index e5d4f1e3..b8fbd5c1 100644 --- a/src/FilterElement/DateRangeElement.php +++ b/src/Filter/Element/DateRangeElement.php @@ -2,10 +2,8 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\FilterElement; +namespace HeimrichHannot\FlareBundle\Filter\Element; -use HeimrichHannot\FlareBundle\Contract\DcaContract; -use HeimrichHannot\FlareBundle\Contract\FilterElement\ConfigContract; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; @@ -22,7 +20,7 @@ use Symfony\Contracts\Translation\TranslatorInterface; #[AsFilterElement(type: self::TYPE)] -class DateRangeElement extends AbstractFilterElement implements ConfigContract, DcaContract +class DateRangeElement extends AbstractFilterFilterElement { public const TYPE = 'flare_dateRange'; @@ -30,7 +28,7 @@ public function __construct( private readonly TranslatorInterface $translator, ) {} - public function configureConfig(OptionsResolver $resolver): void + public function configureOptions(OptionsResolver $resolver): void { $resolver->define('intrinsic')->default(false)->allowedTypes('bool'); $resolver->define('field')->default(null)->allowedTypes('string', 'null'); @@ -83,7 +81,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont ]); } - public function configureDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilder $dca, DcaContext $context): void { $dca->palette('fieldGeneric'); } diff --git a/src/FilterElement/DcaSelectFieldElement.php b/src/Filter/Element/DcaSelectFieldElement.php similarity index 94% rename from src/FilterElement/DcaSelectFieldElement.php rename to src/Filter/Element/DcaSelectFieldElement.php index 4de1a9e8..8f881e4d 100644 --- a/src/FilterElement/DcaSelectFieldElement.php +++ b/src/Filter/Element/DcaSelectFieldElement.php @@ -2,14 +2,12 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\FilterElement; +namespace HeimrichHannot\FlareBundle\Filter\Element; use Contao\Controller; use Contao\DataContainer; use Contao\StringUtil; use Contao\System; -use HeimrichHannot\FlareBundle\Contract\DcaContract; -use HeimrichHannot\FlareBundle\Contract\FilterElement\ConfigContract; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; @@ -23,7 +21,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE)] -class DcaSelectFieldElement extends AbstractFilterElement implements ConfigContract, DcaContract +class DcaSelectFieldElement extends AbstractFilterFilterElement { public const TYPE = 'flare_dcaSelectField'; @@ -31,7 +29,7 @@ public function __construct( private readonly ChoicesBuilderFactory $choicesBuilderFactory, ) {} - public function configureConfig(OptionsResolver $resolver): void + public function configureOptions(OptionsResolver $resolver): void { $resolver->define('intrinsic')->default(false)->allowedTypes('bool'); $resolver->define('field')->default(null)->allowedTypes('string', 'null'); @@ -46,6 +44,7 @@ public function configureConfig(OptionsResolver $resolver): void public function configFromRow(array $row): array { $isMultiple = (bool) ($row['isMultiple'] ?? false); + $preselect = ($row['preselect'] ?? null) ?: null; return [ 'intrinsic' => (bool) ($row['intrinsic'] ?? false), @@ -56,8 +55,8 @@ public function configFromRow(array $row): array 'label' => ($row['label'] ?? null) ?: null, 'placeholder' => ($row['placeholder'] ?? null) ?: null, 'preselect' => $isMultiple - ? StringUtil::deserialize(($row['preselect'] ?? null) ?: null) - : (($row['preselect'] ?? null) ?: null), + ? StringUtil::deserialize($preselect) + : $preselect, ]; } @@ -69,17 +68,18 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) return; } - $options = $this->getOptions($context->list->dc, $config['field']); + $defaultPlaceholder = $config['is_mandatory'] ? 'empty_option.prompt' : 'empty_option.no_selection'; $formOptions = [ 'label' => $config['label'] ?: false, 'multiple' => $config['is_multiple'], 'expanded' => $config['is_expanded'], 'required' => $config['is_mandatory'], - 'placeholder' => $config['placeholder'] - ?: ($config['is_mandatory'] ? 'empty_option.prompt' : 'empty_option.no_selection'), + 'placeholder' => $config['placeholder'] ?: $defaultPlaceholder, ]; + $options = $this->getOptions($context->list->dc, $config['field']); + if (!\is_null($options)) { $choicesBuilder = $this->choicesBuilderFactory->createChoicesBuilder()->enable(); @@ -205,7 +205,7 @@ private function normalizeSubmittedValue(mixed $value, array $options): mixed return $toKey($value); } - public function configureDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilder $dca, DcaContext $context): void { $intrinsic = (bool) $context->filterModel?->intrinsic; diff --git a/src/FilterElement/FieldValueChoiceElement.php b/src/Filter/Element/FieldValueChoiceElement.php similarity index 96% rename from src/FilterElement/FieldValueChoiceElement.php rename to src/Filter/Element/FieldValueChoiceElement.php index b8f3128a..a511f3fe 100644 --- a/src/FilterElement/FieldValueChoiceElement.php +++ b/src/Filter/Element/FieldValueChoiceElement.php @@ -2,14 +2,12 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\FilterElement; +namespace HeimrichHannot\FlareBundle\Filter\Element; use Contao\Controller; use Contao\DataContainer; use Contao\StringUtil; use Doctrine\DBAL\Connection; -use HeimrichHannot\FlareBundle\Contract\DcaContract; -use HeimrichHannot\FlareBundle\Contract\FilterElement\ConfigContract; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; @@ -25,7 +23,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE)] -class FieldValueChoiceElement extends AbstractFilterElement implements ConfigContract, DcaContract +class FieldValueChoiceElement extends AbstractFilterFilterElement { public const TYPE = 'flare_fieldValueChoice'; @@ -37,7 +35,7 @@ public function __construct( private readonly ChoicesBuilderFactory $choicesBuilderFactory, ) {} - public function configureConfig(OptionsResolver $resolver): void + public function configureOptions(OptionsResolver $resolver): void { $resolver->define('intrinsic')->default(false)->allowedTypes('bool'); $resolver->define('field')->default(null)->allowedTypes('string', 'null'); @@ -110,7 +108,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont ]); } - public function configureDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilder $dca, DcaContext $context): void { $dca->palette('{filter_legend},fieldGeneric,isMultiple,isExpanded,preselect'); diff --git a/src/FilterElement/FilterElementInterface.php b/src/Filter/Element/FilterElementInterface.php similarity index 95% rename from src/FilterElement/FilterElementInterface.php rename to src/Filter/Element/FilterElementInterface.php index 41f2b49f..a925d21d 100644 --- a/src/FilterElement/FilterElementInterface.php +++ b/src/Filter/Element/FilterElementInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\FilterElement; +namespace HeimrichHannot\FlareBundle\Filter\Element; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; diff --git a/src/Contract/FilterElement/ConfigContract.php b/src/Filter/Element/FilterElementOptionsInterface.php similarity index 82% rename from src/Contract/FilterElement/ConfigContract.php rename to src/Filter/Element/FilterElementOptionsInterface.php index 71672881..f03cd014 100644 --- a/src/Contract/FilterElement/ConfigContract.php +++ b/src/Filter/Element/FilterElementOptionsInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Contract\FilterElement; +namespace HeimrichHannot\FlareBundle\Filter\Element; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -12,12 +12,12 @@ * The element owns both the schema and the translation from the stored DCA row into * canonical config values, so its runtime methods never touch storage column names. */ -interface ConfigContract +interface FilterElementOptionsInterface { /** * Declares the canonical config schema, mirroring how filter types configure their options. */ - public function configureConfig(OptionsResolver $resolver): void; + public function configureOptions(OptionsResolver $resolver): void; /** * Translates a stored tl_flare_filter row into canonical config values (unresolved). diff --git a/src/FilterElement/PublishedElement.php b/src/Filter/Element/PublishedFilterElement.php similarity index 87% rename from src/FilterElement/PublishedElement.php rename to src/Filter/Element/PublishedFilterElement.php index da0746f0..e3bb91e4 100644 --- a/src/FilterElement/PublishedElement.php +++ b/src/Filter/Element/PublishedFilterElement.php @@ -2,10 +2,8 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\FilterElement; +namespace HeimrichHannot\FlareBundle\Filter\Element; -use HeimrichHannot\FlareBundle\Contract\DcaContract; -use HeimrichHannot\FlareBundle\Contract\FilterElement\ConfigContract; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; @@ -16,11 +14,11 @@ use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE, intrinsicOnly: true)] -class PublishedElement extends AbstractFilterElement implements ConfigContract, DcaContract +class PublishedFilterElement extends AbstractFilterFilterElement { public const TYPE = 'flare_published'; - public function configureConfig(OptionsResolver $resolver): void + public function configureOptions(OptionsResolver $resolver): void { $resolver->define('intrinsic')->default(false)->allowedTypes('bool'); $resolver->define('published_field')->default(null)->allowedTypes('string', 'null'); @@ -57,7 +55,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont ]); } - public function configureDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilder $dca, DcaContext $context): void { $dca->palette('{filter_legend},usePublished,useStart,useStop'); } diff --git a/src/FilterElement/SearchKeywordsElement.php b/src/Filter/Element/SearchKeywordsFilterElement.php similarity index 87% rename from src/FilterElement/SearchKeywordsElement.php rename to src/Filter/Element/SearchKeywordsFilterElement.php index ec5a8b7b..de08d971 100644 --- a/src/FilterElement/SearchKeywordsElement.php +++ b/src/Filter/Element/SearchKeywordsFilterElement.php @@ -2,11 +2,9 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\FilterElement; +namespace HeimrichHannot\FlareBundle\Filter\Element; use Contao\StringUtil; -use HeimrichHannot\FlareBundle\Contract\DcaContract; -use HeimrichHannot\FlareBundle\Contract\FilterElement\ConfigContract; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; @@ -18,11 +16,11 @@ use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE, isTargeted: true)] -class SearchKeywordsElement extends AbstractFilterElement implements ConfigContract, DcaContract +class SearchKeywordsFilterElement extends AbstractFilterFilterElement { public const TYPE = 'flare_search_keywords'; - public function configureConfig(OptionsResolver $resolver): void + public function configureOptions(OptionsResolver $resolver): void { $resolver->define('intrinsic')->default(false)->allowedTypes('bool'); $resolver->define('columns')->default([])->allowedTypes('array'); @@ -84,7 +82,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont ]); } - public function configureDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilder $dca, DcaContext $context): void { $palette = '{filter_legend},columnsGeneric'; diff --git a/src/FilterElement/SimpleEquationElement.php b/src/Filter/Element/SimpleEquationFilterElement.php similarity index 89% rename from src/FilterElement/SimpleEquationElement.php rename to src/Filter/Element/SimpleEquationFilterElement.php index aad1830f..5511eddd 100644 --- a/src/FilterElement/SimpleEquationElement.php +++ b/src/Filter/Element/SimpleEquationFilterElement.php @@ -2,10 +2,8 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\FilterElement; +namespace HeimrichHannot\FlareBundle\Filter\Element; -use HeimrichHannot\FlareBundle\Contract\DcaContract; -use HeimrichHannot\FlareBundle\Contract\FilterElement\ConfigContract; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; @@ -20,11 +18,11 @@ use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE, intrinsicOnly: true, isTargeted: true)] -class SimpleEquationElement extends AbstractFilterElement implements ConfigContract, DcaContract +class SimpleEquationFilterElement extends AbstractFilterFilterElement { public const TYPE = 'flare_equation_simple'; - public function configureConfig(OptionsResolver $resolver): void + public function configureOptions(OptionsResolver $resolver): void { $resolver->define('intrinsic')->default(false)->allowedTypes('bool'); $resolver->define('left')->default(null)->allowedTypes('string', 'null'); @@ -62,7 +60,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont ]); } - public function configureDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilder $dca, DcaContext $context): void { $operatorValue = $context->filterModel?->equationOperator; $operator = $operatorValue ? SqlEquationOperator::match($operatorValue) : null; diff --git a/src/Filter/Filter.php b/src/Filter/Filter.php index a6bfad65..6fe31d90 100644 --- a/src/Filter/Filter.php +++ b/src/Filter/Filter.php @@ -4,8 +4,8 @@ namespace HeimrichHannot\FlareBundle\Filter; -use HeimrichHannot\FlareBundle\FilterElement\CallbackFilterElement; -use HeimrichHannot\FlareBundle\FilterElement\FilterElementInterface; +use HeimrichHannot\FlareBundle\Filter\Element\CallbackFilterElement; +use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; /** * Immutable runtime representation of a single filter within a list. @@ -13,7 +13,7 @@ * Pairs a filter element (registered type string or inline instance) with its canonical, * element-defined configuration. Contains no DCA/storage specifics — translating a stored * row into config is the element's responsibility - * ({@see \HeimrichHannot\FlareBundle\Contract\FilterElement\ConfigContract}). + * ({@see \HeimrichHannot\FlareBundle\Filter\Element\FilterElementOptionsInterface}). */ final readonly class Filter { diff --git a/src/Filter/FilterConfigResolver.php b/src/Filter/OptionsResolver/FilterOptionsResolver.php similarity index 71% rename from src/Filter/FilterConfigResolver.php rename to src/Filter/OptionsResolver/FilterOptionsResolver.php index 1cd74bf1..e6222953 100644 --- a/src/Filter/FilterConfigResolver.php +++ b/src/Filter/OptionsResolver/FilterOptionsResolver.php @@ -2,18 +2,19 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Filter; +namespace HeimrichHannot\FlareBundle\Filter\OptionsResolver; -use HeimrichHannot\FlareBundle\Contract\FilterElement\ConfigContract; use HeimrichHannot\FlareBundle\Exception\FilterException; -use HeimrichHannot\FlareBundle\FilterElement\FilterElementInterface; +use HeimrichHannot\FlareBundle\Filter\Filter; +use HeimrichHannot\FlareBundle\Filter\Element\FilterElementOptionsInterface; +use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use Symfony\Component\OptionsResolver\OptionsResolver; /** * Resolves a filter's canonical config through the element's declared schema. - * Elements without a {@see ConfigContract} receive their config verbatim (unvalidated). + * Elements without a {@see FilterElementOptionsInterface} receive their config verbatim (unvalidated). */ -class FilterConfigResolver +class FilterOptionsResolver { /** * @var array @@ -27,14 +28,14 @@ class FilterConfigResolver */ public function resolve(Filter $filter, FilterElementInterface $element): array { - if (!$element instanceof ConfigContract) { + if (!$element instanceof FilterElementOptionsInterface) { return $filter->config; } if (!isset($this->resolvers[$element::class])) { $resolver = new OptionsResolver(); - $element->configureConfig($resolver); + $element->configureOptions($resolver); $this->resolvers[$element::class] = $resolver; } diff --git a/src/FilterElement/AbstractFilterElement.php b/src/FilterElement/AbstractFilterElement.php deleted file mode 100644 index 7738ed30..00000000 --- a/src/FilterElement/AbstractFilterElement.php +++ /dev/null @@ -1,22 +0,0 @@ -addViolation(); } } -} \ No newline at end of file +} diff --git a/src/Integration/CodefogTags/FilterCallback/TargetAliasCallback.php b/src/Integration/CodefogTags/FilterCallback/TargetAliasCallback.php index 87b1c419..8a275c5b 100644 --- a/src/Integration/CodefogTags/FilterCallback/TargetAliasCallback.php +++ b/src/Integration/CodefogTags/FilterCallback/TargetAliasCallback.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\Integration\CodefogTags\FilterCallback; use HeimrichHannot\FlareBundle\Event\ElementDcaEvent; -use HeimrichHannot\FlareBundle\Integration\CodefogTags\FilterElement\CodefogTagsChoiceElement; +use HeimrichHannot\FlareBundle\Integration\CodefogTags\FilterElement\CodefogTagsChoiceFilterElement; use HeimrichHannot\FlareBundle\Integration\CodefogTags\FilterElement\CodefogTagsSearchElement; use HeimrichHannot\FlareBundle\Integration\CodefogTags\Registry\CfgTagsJoinsRegistry; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; @@ -14,7 +14,7 @@ * Restricts the targetAlias options of the Codefog tags filter elements to the * active tags relations of the edited list. */ -#[AsEventListener('flare.filter_element.' . CodefogTagsChoiceElement::TYPE . '.dca')] +#[AsEventListener('flare.filter_element.' . CodefogTagsChoiceFilterElement::TYPE . '.dca')] #[AsEventListener('flare.filter_element.' . CodefogTagsSearchElement::TYPE . '.dca')] readonly class TargetAliasCallback { diff --git a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceElement.php b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php similarity index 92% rename from src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceElement.php rename to src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php index cc8fda53..30dfed1e 100644 --- a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceElement.php +++ b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php @@ -6,14 +6,14 @@ use Contao\StringUtil; use HeimrichHannot\FlareBundle\Contract\DcaContract; -use HeimrichHannot\FlareBundle\Contract\FilterElement\ConfigContract; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\Element\AbstractFilterFilterElement; +use HeimrichHannot\FlareBundle\Filter\Element\FilterElementOptionsInterface; use HeimrichHannot\FlareBundle\Filter\Type\IntegerIdChoiceFilterType; -use HeimrichHannot\FlareBundle\FilterElement\AbstractFilterElement; use HeimrichHannot\FlareBundle\Form\Factory\ChoicesBuilderFactory; use HeimrichHannot\FlareBundle\Integration\CodefogTags\Registry\CfgTagsJoinsRegistry; use HeimrichHannot\FlareBundle\Query\Factory\ListExecutionContextFactory; @@ -25,7 +25,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE, isTargeted: true)] -class CodefogTagsChoiceElement extends AbstractFilterElement implements ConfigContract, DcaContract +class CodefogTagsChoiceFilterElement extends AbstractFilterFilterElement implements FilterElementOptionsInterface, DcaContract { public const TYPE = 'cfg_tags_choice'; @@ -36,7 +36,7 @@ public function __construct( private readonly LoggerInterface $logger, ) {} - public function configureConfig(OptionsResolver $resolver): void + public function configureOptions(OptionsResolver $resolver): void { $resolver->define('intrinsic')->default(false)->allowedTypes('bool'); $resolver->define('preselect')->default([])->allowedTypes('int[]'); @@ -70,13 +70,14 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) return; } + $placeholderFallback = $config['is_mandatory'] ? 'empty_option.prompt' : 'empty_option.no_selection'; + $formOptions = [ 'label' => $config['label'] ?: false, 'multiple' => $config['is_multiple'], 'expanded' => $config['is_expanded'], 'required' => $config['is_mandatory'], - 'placeholder' => $config['placeholder'] - ?: ($config['is_mandatory'] ? 'empty_option.prompt' : 'empty_option.no_selection'), + 'placeholder' => $config['placeholder'] ?: $placeholderFallback, ]; if ($preselect = $config['preselect']) { @@ -118,9 +119,11 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont { $config = $context->config; + $preselect = $config['preselect'] ?: null; + /** @var ?array $tagIds */ $tagIds = $config['intrinsic'] - ? ($config['preselect'] ?: null) + ? $preselect : $this->processRuntimeValue($data[FilterContext::FIELD_VALUE] ?? null); if (!$tagIds) { @@ -133,7 +136,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont ]); } - public function configureDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilder $dca, DcaContext $context): void { $dca->palette('{form_legend},label,isMandatory,isMultiple,isExpanded;{filter_legend},preselect'); diff --git a/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php b/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php index ff77abd3..ff0fda7d 100644 --- a/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php +++ b/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php @@ -4,14 +4,14 @@ namespace HeimrichHannot\FlareBundle\Integration\CodefogTags\FilterElement; -use HeimrichHannot\FlareBundle\Contract\DcaContract; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; -use HeimrichHannot\FlareBundle\FilterElement\AbstractFilterElement; +use HeimrichHannot\FlareBundle\Filter\Element\AbstractFilterFilterElement; +use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE, isTargeted: true)] -class CodefogTagsSearchElement extends AbstractFilterElement implements DcaContract +class CodefogTagsSearchElement extends AbstractFilterFilterElement { public const TYPE = 'cfg_tags_search'; @@ -20,8 +20,19 @@ public function isSupported(): bool return false; } - public function configureDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilder $dca, DcaContext $context): void { $dca->palette('{filter_legend},fieldGeneric,isMultiple,preselect'); } + + public function configureOptions(OptionsResolver $resolver): void + { + // TODO: Implement configureOptions() method. + } + + public function configFromRow(array $row): array + { + // TODO: Implement configFromRow() method. + return []; + } } diff --git a/src/Integration/ContaoCalendar/ListType/EventsListType.php b/src/Integration/ContaoCalendar/ListType/EventsListType.php index 43b987be..880d06ee 100644 --- a/src/Integration/ContaoCalendar/ListType/EventsListType.php +++ b/src/Integration/ContaoCalendar/ListType/EventsListType.php @@ -9,7 +9,7 @@ use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListType; use HeimrichHannot\FlareBundle\Event\ListSpecificationCreatedEvent; -use HeimrichHannot\FlareBundle\FilterElement\PublishedElement; +use HeimrichHannot\FlareBundle\Filter\Element\PublishedFilterElement; use HeimrichHannot\FlareBundle\ListType\AbstractListType; use HeimrichHannot\FlareBundle\Query\JoinTypeEnum; use HeimrichHannot\FlareBundle\Query\SqlJoinStruct; @@ -23,7 +23,7 @@ class EventsListType extends AbstractListType implements DcaContract public const DATA_CONTAINER = 'tl_calendar_events'; public const ALIAS_ARCHIVE = 'events_archive'; - public function configureDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilder $dca, DcaContext $context): void { $dca->suffix(static function (string $suffix): string { if (!$suffix) { @@ -60,8 +60,8 @@ public function onListSpecificationCreated(ListSpecificationCreatedEvent $config $spec = $config->listSpecification; - if (!$spec->hasFilterOfType(PublishedElement::TYPE)) { - $spec->addFilter(PublishedElement::define()); + if (!$spec->hasFilterOfType(PublishedFilterElement::TYPE)) { + $spec->addFilter(PublishedFilterElement::define()); } } -} \ No newline at end of file +} diff --git a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php index 5feded86..7456a047 100644 --- a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php +++ b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php @@ -15,11 +15,11 @@ use HeimrichHannot\FlareBundle\Event\FetchAutoItemEvent; use HeimrichHannot\FlareBundle\Event\FetchCountEvent; use HeimrichHannot\FlareBundle\Event\FetchListEntriesEvent; -use HeimrichHannot\FlareBundle\FilterElement\SimpleEquationElement; +use HeimrichHannot\FlareBundle\Filter\Element\SimpleEquationFilterElement; use HeimrichHannot\FlareBundle\ListType\DcMultilingualListType; use HeimrichHannot\FlareBundle\Query\JoinTypeEnum; -use HeimrichHannot\FlareBundle\Reader\Resolver\ReaderRequestAttributeResolver; use HeimrichHannot\FlareBundle\Query\ListQueryBuilder; +use HeimrichHannot\FlareBundle\Reader\Resolver\ReaderRequestAttributeResolver; use HeimrichHannot\FlareBundle\Util\DcMultilingualHelper; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\HttpFoundation\RequestStack; @@ -110,7 +110,7 @@ private function isFallbackLanguage(AbstractFetchEvent $event): bool return $lang === $langFallback; } - + #[AsEventListener('flare.list.' . DcMultilingualListType::TYPE . '.fetch_count')] public function listViewFetchCountEvent(FetchCountEvent $event): void { @@ -122,16 +122,16 @@ public function listViewFetchCountEvent(FetchCountEvent $event): void $this->applyMlQueriesIfNecessary($event->getListQueryBuilder(), $filters, $lang); $contentContext = $event->getContentContext(); - + $dcMultilingualDisplay = $event->getContentContext()->getContentModel()->flare_dcMultilingualDisplay ?: $filters->getListModel()->dcMultilingual_display; $configuredFilter = null; - + if ($lang !== $langFallback && $dcMultilingualDisplay === DcMultilingualHelper::DISPLAY_LOCALIZED) // localized list view { - $configuredFilter = SimpleEquationElement::define( + $configuredFilter = SimpleEquationFilterElement::define( equationLeft: DcMultilingualHelper::getPidColumn($table), equationOperator: SqlEquationOperator::GREATER_THAN, equationRight: '0' @@ -139,7 +139,7 @@ public function listViewFetchCountEvent(FetchCountEvent $event): void $configuredFilter->forceTargetAlias('translation'); } - $configuredFilter ??= SimpleEquationElement::define( + $configuredFilter ??= SimpleEquationFilterElement::define( equationLeft: DcMultilingualHelper::getPidColumn($table), equationOperator: SqlEquationOperator::EQUALS, equationRight: '0' @@ -195,7 +195,7 @@ private function applyMlQueriesIfNecessary( $listQueryBuilder->setTableAliasMandatory('translation'); $listQueryBuilder->setGroupBy([]); } - + #[AsEventListener(priority: 220)] public function onListViewDetailsPageUrlGenerated(DetailsPageUrlGeneratedEvent $event): void { diff --git a/src/ListType/GenericDataContainerListType.php b/src/ListType/GenericDataContainerListType.php index 307cf3c9..c3bb8722 100644 --- a/src/ListType/GenericDataContainerListType.php +++ b/src/ListType/GenericDataContainerListType.php @@ -48,7 +48,7 @@ public function getDataContainerName(array $row, DataContainer $dc): string return $row['dc'] ?? ''; } - public function configureDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilder $dca, DcaContext $context): void { $listModel = $context->listModel; diff --git a/src/ListType/NewsListType.php b/src/ListType/NewsListType.php index 03f3bb07..5e14f379 100644 --- a/src/ListType/NewsListType.php +++ b/src/ListType/NewsListType.php @@ -9,7 +9,7 @@ use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListType; use HeimrichHannot\FlareBundle\Event\ListSpecificationCreatedEvent; -use HeimrichHannot\FlareBundle\FilterElement\PublishedElement; +use HeimrichHannot\FlareBundle\Filter\Element\PublishedFilterElement; use HeimrichHannot\FlareBundle\Query\JoinTypeEnum; use HeimrichHannot\FlareBundle\Query\SqlJoinStruct; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; @@ -21,7 +21,7 @@ class NewsListType extends AbstractListType implements DcaContract public const TYPE = 'flare_news'; public const ALIAS_ARCHIVE = 'news_archive'; - public function configureDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilder $dca, DcaContext $context): void { $dca->palette('{filter_legend},'); } @@ -46,8 +46,8 @@ public function onListSpecificationCreated(ListSpecificationCreatedEvent $config $spec = $config->listSpecification; - if (!$spec->hasFilterOfType(PublishedElement::TYPE)) { - $spec->addFilter(PublishedElement::define()); + if (!$spec->hasFilterOfType(PublishedFilterElement::TYPE)) { + $spec->addFilter(PublishedFilterElement::define()); } } -} \ No newline at end of file +} diff --git a/src/Query/Executor/FilterExecutor.php b/src/Query/Executor/FilterExecutor.php index 23def99e..101d5e38 100644 --- a/src/Query/Executor/FilterExecutor.php +++ b/src/Query/Executor/FilterExecutor.php @@ -4,16 +4,16 @@ namespace HeimrichHannot\FlareBundle\Query\Executor; -use HeimrichHannot\FlareBundle\Event\FilterElementBuiltEvent; use HeimrichHannot\FlareBundle\Event\FilterElementBuildingEvent; +use HeimrichHannot\FlareBundle\Event\FilterElementBuiltEvent; use HeimrichHannot\FlareBundle\Exception\AbortFilteringException; use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\FilterBuilder; -use HeimrichHannot\FlareBundle\Filter\FilterConfigResolver; -use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterCall; +use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\OptionsResolver\FilterOptionsResolver; use HeimrichHannot\FlareBundle\Query\Factory\FilterQueryBuilderFactory; use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; use HeimrichHannot\FlareBundle\Query\ListQueryConfig; @@ -28,7 +28,7 @@ { public function __construct( private EventDispatcherInterface $eventDispatcher, - private FilterConfigResolver $filterConfigResolver, + private FilterOptionsResolver $filterConfigResolver, private FilterElementRegistry $filterElementRegistry, private FilterElementResolver $filterElementResolver, private FilterQueryBuilderFactory $filterQueryBuilderFactory, diff --git a/src/Registry/Descriptor/FilterElementDescriptor.php b/src/Registry/Descriptor/FilterElementDescriptor.php index 58f1bd98..42993215 100644 --- a/src/Registry/Descriptor/FilterElementDescriptor.php +++ b/src/Registry/Descriptor/FilterElementDescriptor.php @@ -6,7 +6,7 @@ use HeimrichHannot\FlareBundle\DependencyInjection\Compiler\RegisterFilterElementsPass; use HeimrichHannot\FlareBundle\DependencyInjection\Registry\ServiceDescriptorInterface; -use HeimrichHannot\FlareBundle\FilterElement\FilterElementInterface; +use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; class FilterElementDescriptor implements ServiceDescriptorInterface { diff --git a/src/Registry/FilterCollectorRegistry.php b/src/Registry/FilterCollectorRegistry.php index 8de5c1d3..e1f367e6 100644 --- a/src/Registry/FilterCollectorRegistry.php +++ b/src/Registry/FilterCollectorRegistry.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\Registry; -use HeimrichHannot\FlareBundle\FilterCollector\FilterCollectorInterface; +use HeimrichHannot\FlareBundle\Filter\Collector\FilterCollectorInterface; use HeimrichHannot\FlareBundle\Specification\DataSource\ListDataSourceInterface; use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; @@ -48,4 +48,4 @@ public function match(ListDataSourceInterface $dataSource): ?FilterCollectorInte return null; } -} \ No newline at end of file +} diff --git a/src/Registry/FilterElementResolver.php b/src/Registry/FilterElementResolver.php index 06068345..a784a25d 100644 --- a/src/Registry/FilterElementResolver.php +++ b/src/Registry/FilterElementResolver.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\Registry; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\FilterElement\FilterElementInterface; +use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use Psr\Log\LoggerInterface; /** diff --git a/translations/flare_filter.de.php b/translations/flare_filter.de.php index d3ceb710..49e8ab17 100644 --- a/translations/flare_filter.de.php +++ b/translations/flare_filter.de.php @@ -1,20 +1,20 @@ 'Archiv', - FilterElement\BelongsToRelationElement::TYPE => 'Relation: Gehört zu', - FilterElement\BooleanElement::TYPE => 'Boolescher Eigenschaftswert', - FilterElement\CalendarCurrentElement::TYPE => 'Kalender-Zeitfenster', - FilterElement\DateRangeElement::TYPE => 'Datumsbereich', - FilterElement\DcaSelectFieldElement::TYPE => 'DCA-Feld Optionsauswahl', - FilterElement\FieldValueChoiceElement::TYPE => 'DCA-Feld Feldwerte-Auswahl (beta)', - FilterElement\PublishedElement::TYPE => 'Veröffentlicht', - FilterElement\SimpleEquationElement::TYPE => 'Einfache Gleichung', - FilterElement\SearchKeywordsElement::TYPE => 'Stichwortsuche', + Element\ArchiveElement::TYPE => 'Archiv', + Element\BelongsToRelationElement::TYPE => 'Relation: Gehört zu', + Element\BooleanElement::TYPE => 'Boolescher Eigenschaftswert', + Element\CalendarCurrentFilterElement::TYPE => 'Kalender-Zeitfenster', + Element\DateRangeElement::TYPE => 'Datumsbereich', + Element\DcaSelectFieldElement::TYPE => 'DCA-Feld Optionsauswahl', + Element\FieldValueChoiceElement::TYPE => 'DCA-Feld Feldwerte-Auswahl (beta)', + Element\PublishedFilterElement::TYPE => 'Veröffentlicht', + Element\SimpleEquationFilterElement::TYPE => 'Einfache Gleichung', + Element\SearchKeywordsFilterElement::TYPE => 'Stichwortsuche', - CodefogTagsElement\CodefogTagsChoiceElement::TYPE => 'Tag-Auswahl [codefog/tags-bundle]', + CodefogTagsElement\CodefogTagsChoiceFilterElement::TYPE => 'Tag-Auswahl [codefog/tags-bundle]', CodefogTagsElement\CodefogTagsSearchElement::TYPE => 'Tag-Suche [codefog/tags-bundle]', ]; diff --git a/translations/flare_filter.en.php b/translations/flare_filter.en.php index 0b95986a..b39a6a34 100644 --- a/translations/flare_filter.en.php +++ b/translations/flare_filter.en.php @@ -1,19 +1,18 @@ 'Archive', - FilterElement\BelongsToRelationElement::TYPE => 'Relation: Belongs to', - FilterElement\BooleanElement::TYPE => 'Boolean property value', - FilterElement\CalendarCurrentElement::TYPE => 'Calendar time window', - FilterElement\DateRangeElement::TYPE => 'Date range', - FilterElement\DcaSelectFieldElement::TYPE => 'DCA field options selection', - FilterElement\FieldValueChoiceElement::TYPE => 'DCA field value selection (beta)', - FilterElement\PublishedElement::TYPE => 'Published', - FilterElement\SimpleEquationElement::TYPE => 'Simple equation', - FilterElement\SearchKeywordsElement::TYPE => 'Keyword search', + \HeimrichHannot\FlareBundle\Filter\Element\ArchiveElement::TYPE => 'Archive', + \HeimrichHannot\FlareBundle\Filter\Element\BelongsToRelationElement::TYPE => 'Relation: Belongs to', + \HeimrichHannot\FlareBundle\Filter\Element\BooleanElement::TYPE => 'Boolean property value', + \HeimrichHannot\FlareBundle\Filter\Element\CalendarCurrentFilterElement::TYPE => 'Calendar time window', + \HeimrichHannot\FlareBundle\Filter\Element\DateRangeElement::TYPE => 'Date range', + \HeimrichHannot\FlareBundle\Filter\Element\DcaSelectFieldElement::TYPE => 'DCA field options selection', + \HeimrichHannot\FlareBundle\Filter\Element\FieldValueChoiceElement::TYPE => 'DCA field value selection (beta)', + \HeimrichHannot\FlareBundle\Filter\Element\PublishedFilterElement::TYPE => 'Published', + \HeimrichHannot\FlareBundle\Filter\Element\SimpleEquationFilterElement::TYPE => 'Simple equation', + \HeimrichHannot\FlareBundle\Filter\Element\SearchKeywordsFilterElement::TYPE => 'Keyword search', - CodefogTagsChoiceElement::TYPE => 'Tags [codefog/tags-bundle]', + CodefogTagsChoiceFilterElement::TYPE => 'Tags [codefog/tags-bundle]', ]; From f81392278f8fe16992d3ddea69a55bbccd5b87b5 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Mon, 13 Jul 2026 19:20:41 +0200 Subject: [PATCH 12/71] test: add comprehensive unit tests for Filter, FilterOptionsResolver, and ListSpecification classes --- tests/Filter/FilterConfigResolverTest.php | 91 ++++++++++++++ tests/Filter/FilterTest.php | 113 ++++++++++++++++++ tests/Specification/ListSpecificationTest.php | 69 +++++++++++ 3 files changed, 273 insertions(+) create mode 100644 tests/Filter/FilterConfigResolverTest.php create mode 100644 tests/Filter/FilterTest.php create mode 100644 tests/Specification/ListSpecificationTest.php diff --git a/tests/Filter/FilterConfigResolverTest.php b/tests/Filter/FilterConfigResolverTest.php new file mode 100644 index 00000000..2efbba6a --- /dev/null +++ b/tests/Filter/FilterConfigResolverTest.php @@ -0,0 +1,91 @@ +resolve(new Filter(element: 'test', config: ['field' => 'title']), $element); + + self::assertSame('title', $config['field']); + self::assertFalse($config['intrinsic']); + } + + public function testReturnsConfigVerbatimWithoutConfigContract(): void + { + $resolver = new FilterOptionsResolver(); + $element = new PlainElement(); + + $config = ['anything' => 'goes', 'unvalidated' => true]; + + self::assertSame($config, $resolver->resolve(new Filter(element: 'test', config: $config), $element)); + } + + public function testWrapsSchemaViolationsInFilterException(): void + { + $resolver = new FilterOptionsResolver(); + $element = new ElementConfigAwareElement(); + $filter = new Filter(element: 'test', config: ['unknown_key' => 1], source: 'tl_flare_filter.42'); + + try + { + $resolver->resolve($filter, $element); + self::fail('Expected FilterException.'); + } + catch (FilterException $e) + { + self::assertStringContainsString(ElementConfigAwareElement::class, $e->getMessage()); + self::assertSame('tl_flare_filter.42', $e->getSource()); + } + } +} + +final class ElementConfigAwareElement implements FilterElementInterface, FilterElementOptionsInterface +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->define('intrinsic')->default(false)->allowedTypes('bool'); + $resolver->define('field')->default(null)->allowedTypes('string', 'null'); + } + + public function configFromRow(array $row): array + { + return ['field' => $row['fieldGeneric'] ?? null]; + } + + public function buildForm(FormBuilderInterface $builder, FilterContext $context): void + { + } + + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void + { + } +} + +final class PlainElement implements FilterElementInterface +{ + public function buildForm(FormBuilderInterface $builder, FilterContext $context): void + { + } + + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void + { + } +} diff --git a/tests/Filter/FilterTest.php b/tests/Filter/FilterTest.php new file mode 100644 index 00000000..8b451fba --- /dev/null +++ b/tests/Filter/FilterTest.php @@ -0,0 +1,113 @@ +getElementType()); + self::assertNull($typed->getElementInstance()); + + $instance = new CallbackFilterElement(static function (): void {}); + $inline = new Filter(element: $instance); + + self::assertNull($inline->getElementType()); + self::assertSame($instance, $inline->getElementInstance()); + } + + public function testWithersPreserveOtherFields(): void + { + $filter = new Filter(element: 'test', config: ['a' => 1], alias: 'foo', source: 'tl_flare_filter.1'); + + $withData = $filter->withData(['value' => 42]); + + self::assertNull($filter->data); + self::assertSame(['value' => 42], $withData->data); + self::assertSame('foo', $withData->alias); + self::assertSame(['a' => 1], $withData->config); + self::assertSame('tl_flare_filter.1', $withData->source); + + $targeted = $filter->withTargetAlias('translation'); + + self::assertSame('translation', $targeted->targetAlias); + self::assertTrue($targeted->targetingForced); + self::assertFalse($filter->targetingForced); + } + + public function testFromTypeBuildsSingleFilterCall(): void + { + $filter = Filter::fromType(RecordingFilterType::class, ['value' => 'x']); + + $element = $filter->getElementInstance(); + self::assertNotNull($element); + + $builder = new FilterBuilder(new FilterTypeRegistry([new RecordingFilterType()]), 'main'); + $context = $this->createContext($filter); + + $element->buildFilter($builder, $context, []); + + $calls = $builder->all(); + self::assertCount(1, $calls); + self::assertSame(RecordingFilterType::class, $calls[0]->typeClass); + self::assertSame('x', $calls[0]->options['value']); + } + + public function testFromCallbackForcesTargetAlias(): void + { + $filter = Filter::fromCallback(static function (): void {}, targetAlias: 'translation'); + + self::assertSame('translation', $filter->targetAlias); + self::assertTrue($filter->targetingForced); + } + + public function testFingerprintRepresentsInlineElementsByClass(): void + { + $filter = Filter::fromCallback(static function (): void {}); + + self::assertSame(CallbackFilterElement::class, $filter->fingerprint()['element']); + } + + private function createContext(Filter $filter): FilterContext + { + return new FilterContext( + list: new ListSpecification('test_list', 'tl_test'), + filter: $filter, + config: $filter->config, + engineContext: new class implements ContextInterface { + public static function getContextType(): string + { + return 'test'; + } + }, + ); + } +} + +final class RecordingFilterType extends AbstractFilterType +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->define('value')->required()->allowedTypes('string'); + } + + public function buildQuery(FilterQueryBuilder $builder, array $options): void + { + } +} diff --git a/tests/Specification/ListSpecificationTest.php b/tests/Specification/ListSpecificationTest.php new file mode 100644 index 00000000..306cc29a --- /dev/null +++ b/tests/Specification/ListSpecificationTest.php @@ -0,0 +1,69 @@ +addFilter($filter); + + self::assertSame($filter, $spec->getFilter('color')); + self::assertSame(['color'], \array_keys($spec->getFilters())); + } + + public function testAddFilterWithExplicitKeyAndGeneratedKeys(): void + { + $spec = new ListSpecification('test', 'tl_test'); + + $spec->addFilter(new Filter(element: 'a'), 'custom'); + $spec->addFilter(new Filter(element: 'b')); + $spec->addFilter(new Filter(element: 'c')); + + $keys = \array_keys($spec->getFilters()); + + self::assertSame('custom', $keys[0]); + self::assertCount(3, $keys); + self::assertSame(\count($keys), \count(\array_unique($keys))); + } + + public function testHasFilterOfType(): void + { + $spec = new ListSpecification('test', 'tl_test'); + $spec->addFilter(new Filter(element: 'flare_published')); + + self::assertTrue($spec->hasFilterOfType('flare_published')); + self::assertFalse($spec->hasFilterOfType('flare_bool')); + } + + public function testHashReflectsFilterChanges(): void + { + $spec = new ListSpecification('test', 'tl_test'); + $before = $spec->hash(); + + $spec->addFilter(new Filter(element: 'flare_bool', config: ['field' => 'published']), 'x'); + $after = $spec->hash(); + + self::assertNotSame($before, $after); + self::assertSame($after, $spec->hash()); + } + + public function testRemoveFilter(): void + { + $spec = new ListSpecification('test', 'tl_test'); + $spec->addFilter(new Filter(element: 'a'), 'x'); + $spec->removeFilter('x'); + + self::assertNull($spec->getFilter('x')); + self::assertSame([], $spec->getFilters()); + } +} From ef7d61207e239d0dc9b616a08d9c46151ad7d73f Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Mon, 13 Jul 2026 19:20:56 +0200 Subject: [PATCH 13/71] Enable strict types, implement `CallbackChoiceLoader`, and improve filter lifecycle and element handling. --- .../Builder/DcaBuilderInterface.php | 2 + src/DataContainer/Builder/DcaFieldBuilder.php | 1 + .../Builder/DcaFieldBuilderInterface.php | 2 + .../Contao/ElementDcaListener.php | 1 + .../NamedDispatch/FilterFormListener.php | 24 ++++++ .../Collector/FilterCollectorInterface.php | 2 +- src/Filter/Element/ArchiveElement.php | 83 ++++++++++--------- .../Element/BelongsToRelationElement.php | 6 +- src/Filter/Element/DcaSelectFieldElement.php | 4 +- .../Element/FieldValueChoiceElement.php | 3 +- .../Element/SimpleEquationFilterElement.php | 2 +- src/Filter/FilterContext.php | 2 +- src/Form/ChoicesBuilder.php | 7 ++ src/InferPtable/PtableInferrer.php | 4 +- .../CodefogTagsChoiceFilterElement.php | 2 +- .../Factory/ListSpecificationFactory.php | 4 +- 16 files changed, 92 insertions(+), 57 deletions(-) create mode 100644 src/EventListener/NamedDispatch/FilterFormListener.php diff --git a/src/DataContainer/Builder/DcaBuilderInterface.php b/src/DataContainer/Builder/DcaBuilderInterface.php index 22b978c0..11933fee 100644 --- a/src/DataContainer/Builder/DcaBuilderInterface.php +++ b/src/DataContainer/Builder/DcaBuilderInterface.php @@ -1,5 +1,7 @@ options; unset($definition['options_callback']); } + /** @mago-expect lint:no-else-clause This else clause is fine. */ elseif (\is_callable($this->options)) { $options = $this->options; diff --git a/src/DataContainer/Builder/DcaFieldBuilderInterface.php b/src/DataContainer/Builder/DcaFieldBuilderInterface.php index 2e80f5e2..72708138 100644 --- a/src/DataContainer/Builder/DcaFieldBuilderInterface.php +++ b/src/DataContainer/Builder/DcaFieldBuilderInterface.php @@ -1,5 +1,7 @@ type ?? ''); $service = $this->filterElementRegistry->get($type)?->getService(); } + /** @mago-expect lint:no-else-clause This else clause is fine. */ else { $filterModel = null; diff --git a/src/EventListener/NamedDispatch/FilterFormListener.php b/src/EventListener/NamedDispatch/FilterFormListener.php new file mode 100644 index 00000000..85c12989 --- /dev/null +++ b/src/EventListener/NamedDispatch/FilterFormListener.php @@ -0,0 +1,24 @@ +formName}.build"; + + $this->eventDispatcher->dispatch(event: $event, eventName: $eventName); + } +} diff --git a/src/Filter/Collector/FilterCollectorInterface.php b/src/Filter/Collector/FilterCollectorInterface.php index 05436c94..66d66ab8 100644 --- a/src/Filter/Collector/FilterCollectorInterface.php +++ b/src/Filter/Collector/FilterCollectorInterface.php @@ -14,7 +14,7 @@ interface FilterCollectorInterface public function supports(ListDataSourceInterface $dataSource): bool; /** - * @return array|null Filters keyed by their list-specification key. + * @return array|null Filters keyed by their list-specification key. */ public function collect(ListDataSourceInterface $dataSource): ?array; } diff --git a/src/Filter/Element/ArchiveElement.php b/src/Filter/Element/ArchiveElement.php index aa647fbe..9e1bd800 100644 --- a/src/Filter/Element/ArchiveElement.php +++ b/src/Filter/Element/ArchiveElement.php @@ -21,7 +21,6 @@ use HeimrichHannot\FlareBundle\InferPtable\PtableInferrer; use HeimrichHannot\FlareBundle\Specification\ListSpecification; use HeimrichHannot\FlareBundle\Util\Str; -use Symfony\Component\Form\ChoiceList\Loader\CallbackChoiceLoader; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -91,6 +90,24 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) $inferrer = $this->getPtableInferrer($context->list); $choices = $this->choicesBuilderFactory->createChoicesBuilder()->enable(); + $builder->setAttribute('flare.choices_builder', $choices); + + $formOptions = [ + 'label' => false, + 'required' => $config['is_mandatory'], + 'multiple' => $config['is_multiple'], + 'expanded' => $config['is_expanded'], + 'choice_loader' => $choices->buildCallbackChoiceLoader(), + 'choice_label' => $choices->buildChoiceLabelCallback(), + 'choice_value' => $choices->buildChoiceValueCallback(), + ]; + + $data = $this->buildPreselectData($context->list, $config['preselect']); + if (!\is_null($data) && \count($data)) { + $formOptions['data'] = $data; + } + + $builder->add(FilterContext::FIELD_VALUE, ChoiceType::class, $formOptions); if ($config['has_empty_option']) { @@ -115,59 +132,43 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) { $choices->add((string) $parent->id, $parent); } + + return; } - else - { - if (!$inferrer->isDcaDynamicPtable()) - // no valid ptable available - { - throw new FilterException('No valid ptable found.'); - } - /** - * ## We are dealing with a _dynamic ptable_ henceforth. - */ + if (!$inferrer->isDcaDynamicPtable()) + // no valid ptable available + { + throw new FilterException('No valid ptable found.'); + } - if (!$groups = $config['group_whitelist_parents']) - { - throw new FilterException('No whitelisted parents defined.'); - } + /** + * ## We are dealing with a _dynamic ptable_ henceforth. + */ - foreach ($groups as $group) - { - $table = $group['table']; + if (!$groups = $config['group_whitelist_parents']) + { + throw new FilterException('No whitelisted parents defined.'); + } - foreach ($this->fetchParents($table, $group['ids']) ?? [] as $parent) - { - $choices->add(\sprintf('%s.%s', $table, $parent->id), $parent); - } + foreach ($groups as $group) + { + $table = $group['table']; - $choices->setLabelForTable($group['label'], $table); - } + $parents = $this->fetchParents($table, $group['ids'])?->getModels() ?? []; - if (!$choices->count()) { - throw new FilterException('No valid whitelisted parents defined.'); + foreach ($parents as $parent) { + $choices->add(\sprintf('%s.%s', $table, $parent->id), $parent); } - $choices->setModelSuffix('(%@name%)'); + $choices->setLabelForTable($group['label'], $table); } - $formOptions = [ - 'label' => false, - 'required' => $config['is_mandatory'], - 'multiple' => $config['is_multiple'], - 'expanded' => $config['is_expanded'], - 'choice_loader' => new CallbackChoiceLoader(static fn (): array => $choices->buildChoices()), - 'choice_label' => $choices->buildChoiceLabelCallback(), - 'choice_value' => $choices->buildChoiceValueCallback(), - ]; - - if (null !== $data = $this->buildPreselectData($context->list, $config['preselect'])) { - $formOptions['data'] = $data; + if (!$choices->count()) { + throw new FilterException('No valid whitelisted parents defined.'); } - $builder->setAttribute('flare.choices_builder', $choices); - $builder->add(FilterContext::FIELD_VALUE, ChoiceType::class, $formOptions); + $choices->setModelSuffix('(%@name%)'); } /** diff --git a/src/Filter/Element/BelongsToRelationElement.php b/src/Filter/Element/BelongsToRelationElement.php index 43ba4b2a..3e7de32d 100644 --- a/src/Filter/Element/BelongsToRelationElement.php +++ b/src/Filter/Element/BelongsToRelationElement.php @@ -132,7 +132,7 @@ public function getDynamicParentGroups(array $parentGroups): array { $groups = []; - foreach (\array_values($parentGroups) as $group) + foreach ($parentGroups as $group) { if (!($g_tablePtable = $group['tablePtable'] ?? null) || !($g_whitelistParents = $group['whitelistParents'] ?? null) @@ -170,9 +170,7 @@ public function buildDca(DcaBuilder $dca, DcaContext $context): void if (!$listModel->dc) { - Message::addError($this->trans->trans('errors.missing_datacontainer', [ - '%id%' => $listModel->id, - ], 'flare')); + Message::addError($this->trans->trans('errors.missing_datacontainer', ['%id%' => $listModel->id], 'flare')); $dca->palette(''); return; } diff --git a/src/Filter/Element/DcaSelectFieldElement.php b/src/Filter/Element/DcaSelectFieldElement.php index 8f881e4d..e464d419 100644 --- a/src/Filter/Element/DcaSelectFieldElement.php +++ b/src/Filter/Element/DcaSelectFieldElement.php @@ -15,7 +15,6 @@ use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\DcaSelectFilterType; use HeimrichHannot\FlareBundle\Form\Factory\ChoicesBuilderFactory; -use Symfony\Component\Form\ChoiceList\Loader\CallbackChoiceLoader; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -88,7 +87,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) $choicesBuilder->add((string) $value, (string) $label); } - $formOptions['choice_loader'] = new CallbackChoiceLoader(static fn (): array => $choicesBuilder->buildChoices()); + $formOptions['choice_loader'] = $choicesBuilder->buildCallbackChoiceLoader(); $formOptions['choice_label'] = $choicesBuilder->buildChoiceLabelCallback(); $formOptions['choice_value'] = $choicesBuilder->buildChoiceValueCallback(); @@ -240,6 +239,7 @@ public function buildDca(DcaBuilder $dca, DcaContext $context): void ->merge(['reference' => $optionsField['reference'] ?? []]) ->options(fn (): array => $this->tryGetOptionsFromField($table, $optionsField) ?? []); } + /** @mago-expect lint:no-else-clause This else clause is fine. */ else { $preselect->options([]); diff --git a/src/Filter/Element/FieldValueChoiceElement.php b/src/Filter/Element/FieldValueChoiceElement.php index a511f3fe..60711848 100644 --- a/src/Filter/Element/FieldValueChoiceElement.php +++ b/src/Filter/Element/FieldValueChoiceElement.php @@ -17,7 +17,6 @@ use HeimrichHannot\FlareBundle\Filter\Type\FieldValueChoiceFilterType; use HeimrichHannot\FlareBundle\Form\ChoicesBuilder; use HeimrichHannot\FlareBundle\Form\Factory\ChoicesBuilderFactory; -use Symfony\Component\Form\ChoiceList\Loader\CallbackChoiceLoader; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -73,7 +72,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) 'multiple' => $config['multiple'], 'expanded' => $config['expanded'], 'required' => false, - 'choice_loader' => new CallbackChoiceLoader(static fn (): array => $choicesBuilder->buildChoices()), + 'choice_loader' => $choicesBuilder->buildCallbackChoiceLoader(), 'choice_label' => $choicesBuilder->buildChoiceLabelCallback(), 'choice_value' => $choicesBuilder->buildChoiceValueCallback(), 'data' => $this->buildPreselectData($choicesBuilder, $config), diff --git a/src/Filter/Element/SimpleEquationFilterElement.php b/src/Filter/Element/SimpleEquationFilterElement.php index 5511eddd..7c813b04 100644 --- a/src/Filter/Element/SimpleEquationFilterElement.php +++ b/src/Filter/Element/SimpleEquationFilterElement.php @@ -70,7 +70,7 @@ public function buildDca(DcaBuilder $dca, DcaContext $context): void : '{flare_simple_equation_legend},equationLeft,equationOperator,equationRight'); $dca->field('equationLeft') - ->options(fn (): array => DcaHelper::getFieldOptions($context->getTargetTable())); + ->options(static fn (): array => DcaHelper::getFieldOptions($context->getTargetTable())); } /** diff --git a/src/Filter/FilterContext.php b/src/Filter/FilterContext.php index 9878da1b..d7e5c3a4 100644 --- a/src/Filter/FilterContext.php +++ b/src/Filter/FilterContext.php @@ -17,7 +17,7 @@ public const FORM_ATTRIBUTE = 'flare.filter_context'; /** Conventional local child name for single-field filter elements. */ - public const FIELD_VALUE = 'value'; + public const FIELD_VALUE = 'v'; /** * @param array $config Resolved canonical config of the filter. diff --git a/src/Form/ChoicesBuilder.php b/src/Form/ChoicesBuilder.php index 2a0b5da8..c919237c 100644 --- a/src/Form/ChoicesBuilder.php +++ b/src/Form/ChoicesBuilder.php @@ -8,6 +8,7 @@ use HeimrichHannot\FlareBundle\Contract\LabelableInterface; use HeimrichHannot\FlareBundle\Util\Str; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; +use Symfony\Component\Form\ChoiceList\Loader\CallbackChoiceLoader; use Symfony\Component\Translation\TranslatableMessage; use Symfony\Contracts\Translation\TranslatorInterface; @@ -45,6 +46,7 @@ * Group support ({@see addGroup()}, {@see removeGroup()}) is reserved and not yet implemented. * * @mago-expect lint:too-many-properties + * @mago-expect lint:too-many-methods */ class ChoicesBuilder { @@ -272,6 +274,11 @@ public function buildChoices(): array return $choices; } + public function buildCallbackChoiceLoader(): CallbackChoiceLoader + { + return new CallbackChoiceLoader($this->buildChoices(...)); + } + /** @api */ public function buildChoiceValueCallback(): callable { diff --git a/src/InferPtable/PtableInferrer.php b/src/InferPtable/PtableInferrer.php index 376211d2..4312ec50 100644 --- a/src/InferPtable/PtableInferrer.php +++ b/src/InferPtable/PtableInferrer.php @@ -129,7 +129,7 @@ public function isDcaDynamicPtable(): bool /** * @throws InferenceException * @deprecated Use {@see self::getInferredPtable()} instead. Return type will change to void. Visibility will - * change to private. + * change to private. Changes pending for v0.2. */ #[\ReturnTypeWillChange] public function infer(): ?string @@ -233,4 +233,4 @@ public function tryGetDynamicPtableField(): ?string return null; } -} \ No newline at end of file +} diff --git a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php index 30dfed1e..49cb0534 100644 --- a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php +++ b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php @@ -105,7 +105,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) $choicesBuilder->add((string) $value, (string) $label, (int) $value); } - $formOptions['choice_loader'] = new CallbackChoiceLoader(static fn (): array => $choicesBuilder->buildChoices()); + $formOptions['choice_loader'] = $choicesBuilder->buildCallbackChoiceLoader(); $formOptions['choice_label'] = $choicesBuilder->buildChoiceLabelCallback(); $formOptions['choice_value'] = $choicesBuilder->buildChoiceValueCallback(); diff --git a/src/Specification/Factory/ListSpecificationFactory.php b/src/Specification/Factory/ListSpecificationFactory.php index 3514682b..999ae751 100644 --- a/src/Specification/Factory/ListSpecificationFactory.php +++ b/src/Specification/Factory/ListSpecificationFactory.php @@ -31,7 +31,7 @@ public function create(ListDataSourceInterface $dataSource): ListSpecification // Automatically collect filters (delegate to FilterCollectorRegistry) foreach ($this->collectFilters($dataSource) as $key => $filter) { - $specification->addFilter($filter, $key); + $specification->addFilter($filter, (string) $key); } $specification->setProperties($dataSource->getListData()); @@ -42,7 +42,7 @@ public function create(ListDataSourceInterface $dataSource): ListSpecification } /** - * @return array + * @return array */ private function collectFilters(ListDataSourceInterface $dataSource): array { From cee10faffe04646b9fe2dfa5ebb60bc81cfa5624 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Mon, 13 Jul 2026 19:36:22 +0200 Subject: [PATCH 14/71] refactor: remove unused `DcaContract` implementation from `CodefogTagsChoiceFilterElement` --- .../FilterElement/CodefogTagsChoiceFilterElement.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php index 49cb0534..8594f3fe 100644 --- a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php +++ b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php @@ -19,13 +19,12 @@ use HeimrichHannot\FlareBundle\Query\Factory\ListExecutionContextFactory; use HeimrichHannot\FlareBundle\Query\ListExecutionContext; use Psr\Log\LoggerInterface; -use Symfony\Component\Form\ChoiceList\Loader\CallbackChoiceLoader; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE, isTargeted: true)] -class CodefogTagsChoiceFilterElement extends AbstractFilterFilterElement implements FilterElementOptionsInterface, DcaContract +class CodefogTagsChoiceFilterElement extends AbstractFilterFilterElement { public const TYPE = 'cfg_tags_choice'; From 9eb6e180cdad8d98a1d467e0a540c354fd4a897f Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Mon, 13 Jul 2026 19:54:09 +0200 Subject: [PATCH 15/71] refactor: rename `AbstractFilterFilterElement` to `AbstractFilterElement` and update references Revised all occurrences of `AbstractFilterFilterElement` to streamline naming conventions and align with the filter subsystem structure. --- ...rElement.php => AbstractFilterElement.php} | 2 +- src/Filter/Element/ArchiveElement.php | 2 +- .../Element/BelongsToRelationElement.php | 2 +- src/Filter/Element/BooleanElement.php | 2 +- .../Element/CalendarCurrentFilterElement.php | 2 +- src/Filter/Element/DateRangeElement.php | 2 +- src/Filter/Element/DcaSelectFieldElement.php | 2 +- .../Element/FieldValueChoiceElement.php | 2 +- src/Filter/Element/PublishedFilterElement.php | 2 +- .../Element/SearchKeywordsFilterElement.php | 2 +- .../Element/SimpleEquationFilterElement.php | 2 +- src/Filter/Filter.php | 50 +++++++++++++++++-- .../CodefogTagsChoiceFilterElement.php | 6 +-- .../CodefogTagsSearchElement.php | 4 +- 14 files changed, 60 insertions(+), 22 deletions(-) rename src/Filter/Element/{AbstractFilterFilterElement.php => AbstractFilterElement.php} (95%) diff --git a/src/Filter/Element/AbstractFilterFilterElement.php b/src/Filter/Element/AbstractFilterElement.php similarity index 95% rename from src/Filter/Element/AbstractFilterFilterElement.php rename to src/Filter/Element/AbstractFilterElement.php index 25575d7f..53db15a9 100644 --- a/src/Filter/Element/AbstractFilterFilterElement.php +++ b/src/Filter/Element/AbstractFilterElement.php @@ -13,7 +13,7 @@ use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; -abstract class AbstractFilterFilterElement implements +abstract class AbstractFilterElement implements FilterElementInterface, FilterElementOptionsInterface, IsSupportedContract, DcaContract { abstract public function configureOptions(OptionsResolver $resolver): void; diff --git a/src/Filter/Element/ArchiveElement.php b/src/Filter/Element/ArchiveElement.php index 9e1bd800..9f4abc53 100644 --- a/src/Filter/Element/ArchiveElement.php +++ b/src/Filter/Element/ArchiveElement.php @@ -26,7 +26,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE)] -class ArchiveElement extends AbstractFilterFilterElement +class ArchiveElement extends AbstractFilterElement { public const TYPE = 'flare_archive'; diff --git a/src/Filter/Element/BelongsToRelationElement.php b/src/Filter/Element/BelongsToRelationElement.php index 3e7de32d..2ded8816 100644 --- a/src/Filter/Element/BelongsToRelationElement.php +++ b/src/Filter/Element/BelongsToRelationElement.php @@ -20,7 +20,7 @@ use Symfony\Contracts\Translation\TranslatorInterface; #[AsFilterElement(type: self::TYPE, intrinsicOnly: true)] -class BelongsToRelationElement extends AbstractFilterFilterElement +class BelongsToRelationElement extends AbstractFilterElement { public const TYPE = 'flare_relation_belongsTo'; diff --git a/src/Filter/Element/BooleanElement.php b/src/Filter/Element/BooleanElement.php index 24a4bfa9..35980e7f 100644 --- a/src/Filter/Element/BooleanElement.php +++ b/src/Filter/Element/BooleanElement.php @@ -20,7 +20,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE, isTargeted: true)] -class BooleanElement extends AbstractFilterFilterElement +class BooleanElement extends AbstractFilterElement { public const TYPE = 'flare_bool'; diff --git a/src/Filter/Element/CalendarCurrentFilterElement.php b/src/Filter/Element/CalendarCurrentFilterElement.php index f8c8aee6..46093836 100644 --- a/src/Filter/Element/CalendarCurrentFilterElement.php +++ b/src/Filter/Element/CalendarCurrentFilterElement.php @@ -21,7 +21,7 @@ use Symfony\Contracts\Translation\TranslatorInterface; #[AsFilterElement(type: self::TYPE)] -class CalendarCurrentFilterElement extends AbstractFilterFilterElement +class CalendarCurrentFilterElement extends AbstractFilterElement { public const TYPE = 'flare_calendar_current'; diff --git a/src/Filter/Element/DateRangeElement.php b/src/Filter/Element/DateRangeElement.php index b8fbd5c1..a96d5978 100644 --- a/src/Filter/Element/DateRangeElement.php +++ b/src/Filter/Element/DateRangeElement.php @@ -20,7 +20,7 @@ use Symfony\Contracts\Translation\TranslatorInterface; #[AsFilterElement(type: self::TYPE)] -class DateRangeElement extends AbstractFilterFilterElement +class DateRangeElement extends AbstractFilterElement { public const TYPE = 'flare_dateRange'; diff --git a/src/Filter/Element/DcaSelectFieldElement.php b/src/Filter/Element/DcaSelectFieldElement.php index e464d419..c37c2480 100644 --- a/src/Filter/Element/DcaSelectFieldElement.php +++ b/src/Filter/Element/DcaSelectFieldElement.php @@ -20,7 +20,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE)] -class DcaSelectFieldElement extends AbstractFilterFilterElement +class DcaSelectFieldElement extends AbstractFilterElement { public const TYPE = 'flare_dcaSelectField'; diff --git a/src/Filter/Element/FieldValueChoiceElement.php b/src/Filter/Element/FieldValueChoiceElement.php index 60711848..536286e4 100644 --- a/src/Filter/Element/FieldValueChoiceElement.php +++ b/src/Filter/Element/FieldValueChoiceElement.php @@ -22,7 +22,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE)] -class FieldValueChoiceElement extends AbstractFilterFilterElement +class FieldValueChoiceElement extends AbstractFilterElement { public const TYPE = 'flare_fieldValueChoice'; diff --git a/src/Filter/Element/PublishedFilterElement.php b/src/Filter/Element/PublishedFilterElement.php index e3bb91e4..f2438ec7 100644 --- a/src/Filter/Element/PublishedFilterElement.php +++ b/src/Filter/Element/PublishedFilterElement.php @@ -14,7 +14,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE, intrinsicOnly: true)] -class PublishedFilterElement extends AbstractFilterFilterElement +class PublishedFilterElement extends AbstractFilterElement { public const TYPE = 'flare_published'; diff --git a/src/Filter/Element/SearchKeywordsFilterElement.php b/src/Filter/Element/SearchKeywordsFilterElement.php index de08d971..07e47207 100644 --- a/src/Filter/Element/SearchKeywordsFilterElement.php +++ b/src/Filter/Element/SearchKeywordsFilterElement.php @@ -16,7 +16,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE, isTargeted: true)] -class SearchKeywordsFilterElement extends AbstractFilterFilterElement +class SearchKeywordsFilterElement extends AbstractFilterElement { public const TYPE = 'flare_search_keywords'; diff --git a/src/Filter/Element/SimpleEquationFilterElement.php b/src/Filter/Element/SimpleEquationFilterElement.php index 7c813b04..72c4d80d 100644 --- a/src/Filter/Element/SimpleEquationFilterElement.php +++ b/src/Filter/Element/SimpleEquationFilterElement.php @@ -18,7 +18,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE, intrinsicOnly: true, isTargeted: true)] -class SimpleEquationFilterElement extends AbstractFilterFilterElement +class SimpleEquationFilterElement extends AbstractFilterElement { public const TYPE = 'flare_equation_simple'; diff --git a/src/Filter/Filter.php b/src/Filter/Filter.php index 6fe31d90..c1a11c8a 100644 --- a/src/Filter/Filter.php +++ b/src/Filter/Filter.php @@ -53,7 +53,15 @@ public function getElementInstance(): ?FilterElementInterface */ public function withConfig(array $config): self { - return new self($this->element, $config, $this->data, $this->alias, $this->targetAlias, $this->targetingForced, $this->source); + return new self( + element: $this->element, + config: $config, + data: $this->data, + alias: $this->alias, + targetAlias: $this->targetAlias, + targetingForced: $this->targetingForced, + source: $this->source, + ); } /** @@ -61,22 +69,54 @@ public function withConfig(array $config): self */ public function withData(?array $data): self { - return new self($this->element, $this->config, $data, $this->alias, $this->targetAlias, $this->targetingForced, $this->source); + return new self( + element: $this->element, + config: $this->config, + data: $data, + alias: $this->alias, + targetAlias: $this->targetAlias, + targetingForced: $this->targetingForced, + source: $this->source, + ); } public function withAlias(?string $alias): self { - return new self($this->element, $this->config, $this->data, $alias, $this->targetAlias, $this->targetingForced, $this->source); + return new self( + element: $this->element, + config: $this->config, + data: $this->data, + alias: $alias, + targetAlias: $this->targetAlias, + targetingForced: $this->targetingForced, + source: $this->source, + ); } public function withTargetAlias(?string $targetAlias, bool $forced = true): self { - return new self($this->element, $this->config, $this->data, $this->alias, $targetAlias, !\is_null($targetAlias) && $forced, $this->source); + return new self( + element: $this->element, + config: $this->config, + data: $this->data, + alias: $this->alias, + targetAlias: $targetAlias, + targetingForced: !\is_null($targetAlias) && $forced, + source: $this->source, + ); } public function withSource(?string $source): self { - return new self($this->element, $this->config, $this->data, $this->alias, $this->targetAlias, $this->targetingForced, $source); + return new self( + element: $this->element, + config: $this->config, + data: $this->data, + alias: $this->alias, + targetAlias: $this->targetAlias, + targetingForced: $this->targetingForced, + source: $source + ); } /** diff --git a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php index 8594f3fe..b40407b9 100644 --- a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php +++ b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php @@ -5,14 +5,12 @@ namespace HeimrichHannot\FlareBundle\Integration\CodefogTags\FilterElement; use Contao\StringUtil; -use HeimrichHannot\FlareBundle\Contract\DcaContract; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; -use HeimrichHannot\FlareBundle\Filter\Element\AbstractFilterFilterElement; -use HeimrichHannot\FlareBundle\Filter\Element\FilterElementOptionsInterface; +use HeimrichHannot\FlareBundle\Filter\Element\AbstractFilterElement; use HeimrichHannot\FlareBundle\Filter\Type\IntegerIdChoiceFilterType; use HeimrichHannot\FlareBundle\Form\Factory\ChoicesBuilderFactory; use HeimrichHannot\FlareBundle\Integration\CodefogTags\Registry\CfgTagsJoinsRegistry; @@ -24,7 +22,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE, isTargeted: true)] -class CodefogTagsChoiceFilterElement extends AbstractFilterFilterElement +class CodefogTagsChoiceFilterElement extends AbstractFilterElement { public const TYPE = 'cfg_tags_choice'; diff --git a/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php b/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php index ff0fda7d..6e2135e8 100644 --- a/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php +++ b/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php @@ -7,11 +7,11 @@ use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; -use HeimrichHannot\FlareBundle\Filter\Element\AbstractFilterFilterElement; +use HeimrichHannot\FlareBundle\Filter\Element\AbstractFilterElement; use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE, isTargeted: true)] -class CodefogTagsSearchElement extends AbstractFilterFilterElement +class CodefogTagsSearchElement extends AbstractFilterElement { public const TYPE = 'cfg_tags_search'; From e9cd7542886a3dc17f9b492c3c33a44ce2346dd5 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Mon, 13 Jul 2026 20:02:09 +0200 Subject: [PATCH 16/71] refactor: rename filter element classes to include `Filter` suffix and update references Aligned filter element naming with established conventions (`ArchiveElement` -> `ArchiveFilterElement`, etc.) and adjusted translations accordingly. --- ...veElement.php => ArchiveFilterElement.php} | 2 +- ...php => BelongsToRelationFilterElement.php} | 2 +- ...anElement.php => BooleanFilterElement.php} | 2 +- ...Element.php => DateRangeFilterElement.php} | 2 +- ...nt.php => DcaSelectFieldFilterElement.php} | 2 +- ....php => FieldValueChoiceFilterElement.php} | 2 +- translations/flare_filter.de.php | 12 +++++------ translations/flare_filter.en.php | 21 ++++++++++--------- 8 files changed, 23 insertions(+), 22 deletions(-) rename src/Filter/Element/{ArchiveElement.php => ArchiveFilterElement.php} (99%) rename src/Filter/Element/{BelongsToRelationElement.php => BelongsToRelationFilterElement.php} (99%) rename src/Filter/Element/{BooleanElement.php => BooleanFilterElement.php} (99%) rename src/Filter/Element/{DateRangeElement.php => DateRangeFilterElement.php} (98%) rename src/Filter/Element/{DcaSelectFieldElement.php => DcaSelectFieldFilterElement.php} (99%) rename src/Filter/Element/{FieldValueChoiceElement.php => FieldValueChoiceFilterElement.php} (99%) diff --git a/src/Filter/Element/ArchiveElement.php b/src/Filter/Element/ArchiveFilterElement.php similarity index 99% rename from src/Filter/Element/ArchiveElement.php rename to src/Filter/Element/ArchiveFilterElement.php index 9f4abc53..1d42844f 100644 --- a/src/Filter/Element/ArchiveElement.php +++ b/src/Filter/Element/ArchiveFilterElement.php @@ -26,7 +26,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE)] -class ArchiveElement extends AbstractFilterElement +class ArchiveFilterElement extends AbstractFilterElement { public const TYPE = 'flare_archive'; diff --git a/src/Filter/Element/BelongsToRelationElement.php b/src/Filter/Element/BelongsToRelationFilterElement.php similarity index 99% rename from src/Filter/Element/BelongsToRelationElement.php rename to src/Filter/Element/BelongsToRelationFilterElement.php index 2ded8816..5b8c1b69 100644 --- a/src/Filter/Element/BelongsToRelationElement.php +++ b/src/Filter/Element/BelongsToRelationFilterElement.php @@ -20,7 +20,7 @@ use Symfony\Contracts\Translation\TranslatorInterface; #[AsFilterElement(type: self::TYPE, intrinsicOnly: true)] -class BelongsToRelationElement extends AbstractFilterElement +class BelongsToRelationFilterElement extends AbstractFilterElement { public const TYPE = 'flare_relation_belongsTo'; diff --git a/src/Filter/Element/BooleanElement.php b/src/Filter/Element/BooleanFilterElement.php similarity index 99% rename from src/Filter/Element/BooleanElement.php rename to src/Filter/Element/BooleanFilterElement.php index 35980e7f..f8141ab6 100644 --- a/src/Filter/Element/BooleanElement.php +++ b/src/Filter/Element/BooleanFilterElement.php @@ -20,7 +20,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE, isTargeted: true)] -class BooleanElement extends AbstractFilterElement +class BooleanFilterElement extends AbstractFilterElement { public const TYPE = 'flare_bool'; diff --git a/src/Filter/Element/DateRangeElement.php b/src/Filter/Element/DateRangeFilterElement.php similarity index 98% rename from src/Filter/Element/DateRangeElement.php rename to src/Filter/Element/DateRangeFilterElement.php index a96d5978..a8d77468 100644 --- a/src/Filter/Element/DateRangeElement.php +++ b/src/Filter/Element/DateRangeFilterElement.php @@ -20,7 +20,7 @@ use Symfony\Contracts\Translation\TranslatorInterface; #[AsFilterElement(type: self::TYPE)] -class DateRangeElement extends AbstractFilterElement +class DateRangeFilterElement extends AbstractFilterElement { public const TYPE = 'flare_dateRange'; diff --git a/src/Filter/Element/DcaSelectFieldElement.php b/src/Filter/Element/DcaSelectFieldFilterElement.php similarity index 99% rename from src/Filter/Element/DcaSelectFieldElement.php rename to src/Filter/Element/DcaSelectFieldFilterElement.php index c37c2480..2764832e 100644 --- a/src/Filter/Element/DcaSelectFieldElement.php +++ b/src/Filter/Element/DcaSelectFieldFilterElement.php @@ -20,7 +20,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE)] -class DcaSelectFieldElement extends AbstractFilterElement +class DcaSelectFieldFilterElement extends AbstractFilterElement { public const TYPE = 'flare_dcaSelectField'; diff --git a/src/Filter/Element/FieldValueChoiceElement.php b/src/Filter/Element/FieldValueChoiceFilterElement.php similarity index 99% rename from src/Filter/Element/FieldValueChoiceElement.php rename to src/Filter/Element/FieldValueChoiceFilterElement.php index 536286e4..ba5884a1 100644 --- a/src/Filter/Element/FieldValueChoiceElement.php +++ b/src/Filter/Element/FieldValueChoiceFilterElement.php @@ -22,7 +22,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE)] -class FieldValueChoiceElement extends AbstractFilterElement +class FieldValueChoiceFilterElement extends AbstractFilterElement { public const TYPE = 'flare_fieldValueChoice'; diff --git a/translations/flare_filter.de.php b/translations/flare_filter.de.php index 49e8ab17..7fdd9485 100644 --- a/translations/flare_filter.de.php +++ b/translations/flare_filter.de.php @@ -4,13 +4,13 @@ use HeimrichHannot\FlareBundle\Integration\CodefogTags\FilterElement as CodefogTagsElement; return [ - Element\ArchiveElement::TYPE => 'Archiv', - Element\BelongsToRelationElement::TYPE => 'Relation: Gehört zu', - Element\BooleanElement::TYPE => 'Boolescher Eigenschaftswert', + Element\ArchiveFilterElement::TYPE => 'Archiv', + Element\BelongsToRelationFilterElement::TYPE => 'Relation: Gehört zu', + Element\BooleanFilterElement::TYPE => 'Boolescher Eigenschaftswert', Element\CalendarCurrentFilterElement::TYPE => 'Kalender-Zeitfenster', - Element\DateRangeElement::TYPE => 'Datumsbereich', - Element\DcaSelectFieldElement::TYPE => 'DCA-Feld Optionsauswahl', - Element\FieldValueChoiceElement::TYPE => 'DCA-Feld Feldwerte-Auswahl (beta)', + Element\DateRangeFilterElement::TYPE => 'Datumsbereich', + Element\DcaSelectFieldFilterElement::TYPE => 'DCA-Feld Optionsauswahl', + Element\FieldValueChoiceFilterElement::TYPE => 'DCA-Feld Feldwerte-Auswahl (beta)', Element\PublishedFilterElement::TYPE => 'Veröffentlicht', Element\SimpleEquationFilterElement::TYPE => 'Einfache Gleichung', Element\SearchKeywordsFilterElement::TYPE => 'Stichwortsuche', diff --git a/translations/flare_filter.en.php b/translations/flare_filter.en.php index b39a6a34..e024b645 100644 --- a/translations/flare_filter.en.php +++ b/translations/flare_filter.en.php @@ -1,18 +1,19 @@ 'Archive', - \HeimrichHannot\FlareBundle\Filter\Element\BelongsToRelationElement::TYPE => 'Relation: Belongs to', - \HeimrichHannot\FlareBundle\Filter\Element\BooleanElement::TYPE => 'Boolean property value', - \HeimrichHannot\FlareBundle\Filter\Element\CalendarCurrentFilterElement::TYPE => 'Calendar time window', - \HeimrichHannot\FlareBundle\Filter\Element\DateRangeElement::TYPE => 'Date range', - \HeimrichHannot\FlareBundle\Filter\Element\DcaSelectFieldElement::TYPE => 'DCA field options selection', - \HeimrichHannot\FlareBundle\Filter\Element\FieldValueChoiceElement::TYPE => 'DCA field value selection (beta)', - \HeimrichHannot\FlareBundle\Filter\Element\PublishedFilterElement::TYPE => 'Published', - \HeimrichHannot\FlareBundle\Filter\Element\SimpleEquationFilterElement::TYPE => 'Simple equation', - \HeimrichHannot\FlareBundle\Filter\Element\SearchKeywordsFilterElement::TYPE => 'Keyword search', + Element\ArchiveFilterElement::TYPE => 'Archive', + Element\BelongsToRelationFilterElement::TYPE => 'Relation: Belongs to', + Element\BooleanFilterElement::TYPE => 'Boolean property value', + Element\CalendarCurrentFilterElement::TYPE => 'Calendar time window', + Element\DateRangeFilterElement::TYPE => 'Date range', + Element\DcaSelectFieldFilterElement::TYPE => 'DCA field options selection', + Element\FieldValueChoiceFilterElement::TYPE => 'DCA field value selection (beta)', + Element\PublishedFilterElement::TYPE => 'Published', + Element\SimpleEquationFilterElement::TYPE => 'Simple equation', + Element\SearchKeywordsFilterElement::TYPE => 'Keyword search', CodefogTagsChoiceFilterElement::TYPE => 'Tags [codefog/tags-bundle]', ]; From 31235dab4a1f46af24cf93ce03682e7cfe5fa70b Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Mon, 13 Jul 2026 20:13:01 +0200 Subject: [PATCH 17/71] refactor: rename `FilterConfigResolverTest` to `FilterOptionsResolverTest` and update method/exception references --- src/Filter/OptionsResolver/FilterOptionsResolver.php | 2 +- ...ConfigResolverTest.php => FilterOptionsResolverTest.php} | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) rename tests/Filter/{FilterConfigResolverTest.php => FilterOptionsResolverTest.php} (93%) diff --git a/src/Filter/OptionsResolver/FilterOptionsResolver.php b/src/Filter/OptionsResolver/FilterOptionsResolver.php index e6222953..bcff3bb2 100644 --- a/src/Filter/OptionsResolver/FilterOptionsResolver.php +++ b/src/Filter/OptionsResolver/FilterOptionsResolver.php @@ -48,7 +48,7 @@ public function resolve(Filter $filter, FilterElementInterface $element): array throw new FilterException( \sprintf('[FLARE] Invalid filter config for element "%s": %s', $element::class, $e->getMessage()), previous: $e, - method: $element::class . '::configureConfig', + method: $element::class . '::configureOptions', source: $filter->source, ); } diff --git a/tests/Filter/FilterConfigResolverTest.php b/tests/Filter/FilterOptionsResolverTest.php similarity index 93% rename from tests/Filter/FilterConfigResolverTest.php rename to tests/Filter/FilterOptionsResolverTest.php index 2efbba6a..39e92fe3 100644 --- a/tests/Filter/FilterConfigResolverTest.php +++ b/tests/Filter/FilterOptionsResolverTest.php @@ -15,9 +15,9 @@ use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; -final class FilterConfigResolverTest extends TestCase +final class FilterOptionsResolverTest extends TestCase { - public function testResolvesConfigThroughElementSchema(): void + public function testResolvesOptionsThroughElementSchema(): void { $resolver = new FilterOptionsResolver(); $element = new ElementConfigAwareElement(); @@ -28,7 +28,7 @@ public function testResolvesConfigThroughElementSchema(): void self::assertFalse($config['intrinsic']); } - public function testReturnsConfigVerbatimWithoutConfigContract(): void + public function testReturnsOptionsVerbatimWithoutOptionsContract(): void { $resolver = new FilterOptionsResolver(); $element = new PlainElement(); From 289a360615723351611aa41964cedf022f0362be Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 12:21:30 +0200 Subject: [PATCH 18/71] refactor: restructure filter resolver namespace and update references Moved `FilterOptionsResolver` and `FilterElementResolver` to `Filter\Resolver\` namespace, adjusted imports and references accordingly. --- src/Filter/Collector/ListModelFilterCollector.php | 4 ++-- src/{Registry => Filter/Resolver}/FilterElementResolver.php | 5 +++-- .../{OptionsResolver => Resolver}/FilterOptionsResolver.php | 2 +- src/Form/Factory/FilterFormFactory.php | 4 ++-- src/Query/Executor/FilterExecutor.php | 4 ++-- tests/Filter/FilterOptionsResolverTest.php | 2 +- 6 files changed, 11 insertions(+), 10 deletions(-) rename src/{Registry => Filter/Resolver}/FilterElementResolver.php (91%) rename src/Filter/{OptionsResolver => Resolver}/FilterOptionsResolver.php (96%) diff --git a/src/Filter/Collector/ListModelFilterCollector.php b/src/Filter/Collector/ListModelFilterCollector.php index 6f899c24..236c5abc 100644 --- a/src/Filter/Collector/ListModelFilterCollector.php +++ b/src/Filter/Collector/ListModelFilterCollector.php @@ -6,11 +6,11 @@ use Contao\Controller; use HeimrichHannot\FlareBundle\Event\FilterCollectedEvent; -use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementOptionsInterface; +use HeimrichHannot\FlareBundle\Filter\Filter; +use HeimrichHannot\FlareBundle\Filter\Resolver\FilterElementResolver; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Model\ListModel; -use HeimrichHannot\FlareBundle\Registry\FilterElementResolver; use HeimrichHannot\FlareBundle\Registry\ListTypeRegistry; use HeimrichHannot\FlareBundle\Specification\DataSource\ListDataSourceInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; diff --git a/src/Registry/FilterElementResolver.php b/src/Filter/Resolver/FilterElementResolver.php similarity index 91% rename from src/Registry/FilterElementResolver.php rename to src/Filter/Resolver/FilterElementResolver.php index a784a25d..196f1b88 100644 --- a/src/Registry/FilterElementResolver.php +++ b/src/Filter/Resolver/FilterElementResolver.php @@ -2,10 +2,11 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Registry; +namespace HeimrichHannot\FlareBundle\Filter\Resolver; -use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; +use HeimrichHannot\FlareBundle\Filter\Filter; +use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; use Psr\Log\LoggerInterface; /** diff --git a/src/Filter/OptionsResolver/FilterOptionsResolver.php b/src/Filter/Resolver/FilterOptionsResolver.php similarity index 96% rename from src/Filter/OptionsResolver/FilterOptionsResolver.php rename to src/Filter/Resolver/FilterOptionsResolver.php index bcff3bb2..41b0dcac 100644 --- a/src/Filter/OptionsResolver/FilterOptionsResolver.php +++ b/src/Filter/Resolver/FilterOptionsResolver.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Filter\OptionsResolver; +namespace HeimrichHannot\FlareBundle\Filter\Resolver; use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Filter\Filter; diff --git a/src/Form/Factory/FilterFormFactory.php b/src/Form/Factory/FilterFormFactory.php index 45f3136f..73097c95 100644 --- a/src/Form/Factory/FilterFormFactory.php +++ b/src/Form/Factory/FilterFormFactory.php @@ -11,8 +11,8 @@ use HeimrichHannot\FlareBundle\Event\FilterFormBuildEvent; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\FilterContext; -use HeimrichHannot\FlareBundle\Filter\OptionsResolver\FilterOptionsResolver; -use HeimrichHannot\FlareBundle\Registry\FilterElementResolver; +use HeimrichHannot\FlareBundle\Filter\Resolver\FilterElementResolver; +use HeimrichHannot\FlareBundle\Filter\Resolver\FilterOptionsResolver; use HeimrichHannot\FlareBundle\Specification\ListSpecification; use HeimrichHannot\FlareBundle\Util\Str; use Symfony\Component\Form\Extension\Core\Type\FormType; diff --git a/src/Query/Executor/FilterExecutor.php b/src/Query/Executor/FilterExecutor.php index 101d5e38..1fb9809a 100644 --- a/src/Query/Executor/FilterExecutor.php +++ b/src/Query/Executor/FilterExecutor.php @@ -13,13 +13,13 @@ use HeimrichHannot\FlareBundle\Filter\FilterBuilder; use HeimrichHannot\FlareBundle\Filter\FilterCall; use HeimrichHannot\FlareBundle\Filter\FilterContext; -use HeimrichHannot\FlareBundle\Filter\OptionsResolver\FilterOptionsResolver; +use HeimrichHannot\FlareBundle\Filter\Resolver\FilterElementResolver; +use HeimrichHannot\FlareBundle\Filter\Resolver\FilterOptionsResolver; use HeimrichHannot\FlareBundle\Query\Factory\FilterQueryBuilderFactory; use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; use HeimrichHannot\FlareBundle\Query\ListQueryConfig; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; -use HeimrichHannot\FlareBundle\Registry\FilterElementResolver; use HeimrichHannot\FlareBundle\Registry\FilterTypeRegistry; use HeimrichHannot\FlareBundle\Util\Str; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; diff --git a/tests/Filter/FilterOptionsResolverTest.php b/tests/Filter/FilterOptionsResolverTest.php index 39e92fe3..710dfef9 100644 --- a/tests/Filter/FilterOptionsResolverTest.php +++ b/tests/Filter/FilterOptionsResolverTest.php @@ -10,7 +10,7 @@ use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementOptionsInterface; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; -use HeimrichHannot\FlareBundle\Filter\OptionsResolver\FilterOptionsResolver; +use HeimrichHannot\FlareBundle\Filter\Resolver\FilterOptionsResolver; use PHPUnit\Framework\TestCase; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; From ed767a8a7d137c54dfbdf1179af323e223ef441b Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 12:28:48 +0200 Subject: [PATCH 19/71] feat: add transformer machinery for canonical config translation Introduce `ConfigBuilder` (fluent canonical-config accumulator), `TransformerBuilder` (source-class to transformer map), the `TransformerContract` (`configureTransformers()`), and `FilterTransformerResolver` (per-element-class memoized transformer execution). Transformer maps are extensible via `FilterTransformerEvent`, re-dispatched as `flare.filter_element.{type}.transformers`. --- src/Config/ConfigBuilder.php | 37 ++++++++++++ src/Config/TransformerBuilder.php | 49 ++++++++++++++++ src/Contract/TransformerContract.php | 23 ++++++++ src/Event/FilterTransformerEvent.php | 23 ++++++++ .../FilterTransformerListener.php | 26 +++++++++ .../Resolver/FilterTransformerResolver.php | 56 +++++++++++++++++++ 6 files changed, 214 insertions(+) create mode 100644 src/Config/ConfigBuilder.php create mode 100644 src/Config/TransformerBuilder.php create mode 100644 src/Contract/TransformerContract.php create mode 100644 src/Event/FilterTransformerEvent.php create mode 100644 src/EventListener/NamedDispatch/FilterTransformerListener.php create mode 100644 src/Filter/Resolver/FilterTransformerResolver.php diff --git a/src/Config/ConfigBuilder.php b/src/Config/ConfigBuilder.php new file mode 100644 index 00000000..019c844a --- /dev/null +++ b/src/Config/ConfigBuilder.php @@ -0,0 +1,37 @@ + + */ + private array $config = []; + + public function set(string $key, mixed $value): self + { + $this->config[$key] = $value; + + return $this; + } + + /** + * Returns the accumulated canonical config. + * + * @return array + * + * @internal Drained by the framework (transformer resolver, list builder) only. + */ + public function all(): array + { + return $this->config; + } +} diff --git a/src/Config/TransformerBuilder.php b/src/Config/TransformerBuilder.php new file mode 100644 index 00000000..56a414c4 --- /dev/null +++ b/src/Config/TransformerBuilder.php @@ -0,0 +1,49 @@ + + */ + private array $transformers = []; + + /** + * Registers a transformer for a source class. Registering the same class again replaces + * the previous transformer, so event listeners can override element defaults. + * + * @param class-string $sourceClass + * @param callable(object $source, ConfigBuilder $config): void $transformer + */ + public function for(string $sourceClass, callable $transformer): self + { + $this->transformers[$sourceClass] = $transformer; + + return $this; + } + + /** + * Returns the first registered transformer whose source class matches the given source, + * or null if none matches. + * + * @return (callable(object, ConfigBuilder): void)|null + */ + public function resolve(object $source): ?callable + { + foreach ($this->transformers as $sourceClass => $transformer) + { + if ($source instanceof $sourceClass) { + return $transformer; + } + } + + return null; + } +} diff --git a/src/Contract/TransformerContract.php b/src/Contract/TransformerContract.php new file mode 100644 index 00000000..40dea4d9 --- /dev/null +++ b/src/Contract/TransformerContract.php @@ -0,0 +1,23 @@ +type) { + return; + } + + $this->eventDispatcher->dispatch(event: $event, eventName: "flare.filter_element.{$event->type}.transformers"); + } +} diff --git a/src/Filter/Resolver/FilterTransformerResolver.php b/src/Filter/Resolver/FilterTransformerResolver.php new file mode 100644 index 00000000..82365940 --- /dev/null +++ b/src/Filter/Resolver/FilterTransformerResolver.php @@ -0,0 +1,56 @@ + + */ + private array $builders = []; + + public function __construct( + private readonly EventDispatcherInterface $eventDispatcher, + ) {} + + /** + * @return array|null Canonical config values, or null when no transformer matches the source. + */ + public function transform(FilterElementInterface $element, ?string $elementType, object $source): ?array + { + if (!isset($this->builders[$element::class])) + { + $transformers = new TransformerBuilder(); + + if ($element instanceof TransformerContract) { + $element->configureTransformers($transformers); + } + + $this->eventDispatcher->dispatch(new FilterTransformerEvent($transformers, $element, $elementType)); + + $this->builders[$element::class] = $transformers; + } + + if (!$transformer = $this->builders[$element::class]->resolve($source)) { + return null; + } + + $transformer($source, $config = new ConfigBuilder()); + + return $config->all(); + } +} From 695004384e8f03f79a56b93c15d04c97959fc81d Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 12:41:31 +0200 Subject: [PATCH 20/71] refactor: replace `configFromRow` with transformer cycle and remove programmatic sugar - Elements now implement `configureTransformers()` (via `AbstractFilterElement`, which registers `transformFilterModel(FilterModel, ConfigBuilder)` for the FilterModel source); `FilterElementOptionsInterface` and `configFromRow()` are gone. `FilterOptionsResolver` checks the generic `OptionsInterface`. - `ListModelFilterCollector` translates via `FilterTransformerResolver`; elements without a matching transformer keep the raw-row passthrough. - New `FilterContextFactory` dedupes the identical `FilterContext` construction in `FilterFormFactory` and `FilterExecutor`. - Removed programmatic sugar: all static `define()` factories, `Filter::fromType()`, `Filter::fromCallback()`, `CallbackFilterElement`, and the `flare_make_filter` Twig function. Internal call sites construct `new Filter(element:, config:)` directly. A proper engine-extending API is a follow-up. --- config/services.yaml | 3 +- .../content_element/flare_listview.html.twig | 16 ---- src/Engine/Loader/ValidationLoader.php | 25 ++++-- src/Engine/Mod/SimpleEquationMod.php | 13 +++- .../Collector/ListModelFilterCollector.php | 14 ++-- src/Filter/Element/AbstractFilterElement.php | 18 ++++- src/Filter/Element/ArchiveFilterElement.php | 45 +++++------ .../BelongsToRelationFilterElement.php | 23 +++--- src/Filter/Element/BooleanFilterElement.php | 36 +++------ .../Element/CalendarCurrentFilterElement.php | 21 ++--- src/Filter/Element/CallbackFilterElement.php | 39 ---------- src/Filter/Element/DateRangeFilterElement.php | 11 +-- .../Element/DcaSelectFieldFilterElement.php | 31 ++++---- .../Element/FieldValueChoiceFilterElement.php | 21 ++--- .../Element/FilterElementOptionsInterface.php | 31 -------- src/Filter/Element/PublishedFilterElement.php | 48 ++++-------- .../Element/SearchKeywordsFilterElement.php | 17 ++-- .../Element/SimpleEquationFilterElement.php | 41 ++-------- src/Filter/Factory/FilterContextFactory.php | 43 ++++++++++ src/Filter/Filter.php | 42 +--------- src/Filter/Resolver/FilterOptionsResolver.php | 6 +- src/Form/Factory/FilterFormFactory.php | 12 +-- .../CodefogTagsChoiceFilterElement.php | 25 +++--- .../CodefogTagsSearchElement.php | 7 +- .../ListType/EventsListType.php | 12 ++- .../EventListener/ChangelanguageListener.php | 27 ++++--- src/ListType/NewsListType.php | 12 ++- src/Query/Executor/FilterExecutor.php | 12 +-- src/Twig/Extension/FlareExtension.php | 1 - src/Twig/Runtime/FlareRuntime.php | 19 ----- tests/Filter/FilterOptionsResolverTest.php | 9 +-- tests/Filter/FilterTest.php | 78 ++++--------------- 32 files changed, 299 insertions(+), 459 deletions(-) delete mode 100644 src/Filter/Element/CallbackFilterElement.php delete mode 100644 src/Filter/Element/FilterElementOptionsInterface.php create mode 100644 src/Filter/Factory/FilterContextFactory.php diff --git a/config/services.yaml b/config/services.yaml index dcb8ec89..a083118e 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -10,10 +10,9 @@ services: HeimrichHannot\FlareBundle\: resource: ../src exclude: - - ../src/{Contao,ContaoManager,Contract,DependencyInjection,Dto,Engine,Event,Integration,Model,Trait,Util} + - ../src/{Config,Contao,ContaoManager,Contract,DependencyInjection,Dto,Engine,Event,Integration,Model,Trait,Util} - ../src/{Filter,Form,InferPtable,List,Paginator,Query,Sort,Specification}/*.php - ../src/DataContainer/Builder - - ../src/FilterElement/CallbackFilterElement.php - ../src/Registry/Descriptor HeimrichHannot\FlareBundle\Engine\: diff --git a/contao/templates/content_element/flare_listview.html.twig b/contao/templates/content_element/flare_listview.html.twig index a0793c3c..ee8739a0 100644 --- a/contao/templates/content_element/flare_listview.html.twig +++ b/contao/templates/content_element/flare_listview.html.twig @@ -8,22 +8,6 @@ {% block content %} - {# - set my_list_spec = flare_make_list({ - dc: 'tl_news', - form_name: 'lieselotte', - items_per_page: 10, - }) - - my_list_spec.filters.add(flare_make_filter('eq', { id: 20 })) - - set my_view = flare_project('interactive', my_list_spec, {}); - - my_view.form.createView - my_view.entries - my_view.paginator - #} - {% block content_start %}{% endblock %} {% block filter %} diff --git a/src/Engine/Loader/ValidationLoader.php b/src/Engine/Loader/ValidationLoader.php index 1a9d5708..f55a0317 100644 --- a/src/Engine/Loader/ValidationLoader.php +++ b/src/Engine/Loader/ValidationLoader.php @@ -8,6 +8,7 @@ use HeimrichHannot\FlareBundle\Enum\SqlEquationOperator; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\Element\SimpleEquationFilterElement; +use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Query\Executor\ListQueryDirector; use HeimrichHannot\FlareBundle\Query\ListQueryConfig; use HeimrichHannot\FlareBundle\Specification\ListSpecification; @@ -35,10 +36,14 @@ public function fetchEntryById(int $id): ?array // IMPORTANT: clone the spec to not modify the original, i.e., when adding the id filter $list = clone $this->config->list; - $idDefinition = SimpleEquationFilterElement::define( - equationLeft: 'id', - equationOperator: SqlEquationOperator::EQUALS, - equationRight: $id, + $idDefinition = new Filter( + element: SimpleEquationFilterElement::TYPE, + config: [ + 'intrinsic' => true, + 'left' => 'id', + 'operator' => SqlEquationOperator::EQUALS, + 'right' => $id, + ], ); $list->addFilter($idDefinition); @@ -69,10 +74,14 @@ public function fetchEntryByAutoItem(string $autoItem): ?array // IMPORTANT: clone the spec to not modify the original $list = clone $this->config->list; - $autoItemDefinition = SimpleEquationFilterElement::define( - equationLeft: $this->config->autoItemField, - equationOperator: SqlEquationOperator::EQUALS, - equationRight: $autoItem, + $autoItemDefinition = new Filter( + element: SimpleEquationFilterElement::TYPE, + config: [ + 'intrinsic' => true, + 'left' => $this->config->autoItemField, + 'operator' => SqlEquationOperator::EQUALS, + 'right' => $autoItem, + ], ); $list->addFilter($autoItemDefinition); diff --git a/src/Engine/Mod/SimpleEquationMod.php b/src/Engine/Mod/SimpleEquationMod.php index 1480f977..5986149c 100644 --- a/src/Engine/Mod/SimpleEquationMod.php +++ b/src/Engine/Mod/SimpleEquationMod.php @@ -7,6 +7,7 @@ use HeimrichHannot\FlareBundle\Engine\Engine; use HeimrichHannot\FlareBundle\Enum\SqlEquationOperator; use HeimrichHannot\FlareBundle\Filter\Element\SimpleEquationFilterElement; +use HeimrichHannot\FlareBundle\Filter\Filter; use Symfony\Component\OptionsResolver\OptionsResolver; class SimpleEquationMod extends AbstractMod @@ -18,10 +19,14 @@ public static function getType(): string public function __invoke(Engine $engine, array $options): void { - $filter = SimpleEquationFilterElement::define( - equationLeft: $options['operand1'], - equationOperator: $options['operator'], - equationRight: $options['operand2'], + $filter = new Filter( + element: SimpleEquationFilterElement::TYPE, + config: [ + 'intrinsic' => true, + 'left' => $options['operand1'], + 'operator' => $options['operator'], + 'right' => $options['operand2'], + ], ); $engine->getList()->addFilter($filter, $options['name'] ?: null); diff --git a/src/Filter/Collector/ListModelFilterCollector.php b/src/Filter/Collector/ListModelFilterCollector.php index 236c5abc..f72d43c6 100644 --- a/src/Filter/Collector/ListModelFilterCollector.php +++ b/src/Filter/Collector/ListModelFilterCollector.php @@ -6,9 +6,9 @@ use Contao\Controller; use HeimrichHannot\FlareBundle\Event\FilterCollectedEvent; -use HeimrichHannot\FlareBundle\Filter\Element\FilterElementOptionsInterface; use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterElementResolver; +use HeimrichHannot\FlareBundle\Filter\Resolver\FilterTransformerResolver; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Registry\ListTypeRegistry; @@ -18,9 +18,10 @@ readonly class ListModelFilterCollector implements FilterCollectorInterface { public function __construct( - private EventDispatcherInterface $eventDispatcher, - private FilterElementResolver $filterElementResolver, - private ListTypeRegistry $listTypeRegistry, + private EventDispatcherInterface $eventDispatcher, + private FilterElementResolver $filterElementResolver, + private FilterTransformerResolver $filterTransformerResolver, + private ListTypeRegistry $listTypeRegistry, ) {} public function supports(ListDataSourceInterface $dataSource): bool @@ -60,9 +61,8 @@ public function collect(ListDataSourceInterface $dataSource): ?array continue; } - $config = $element instanceof FilterElementOptionsInterface - ? $element->configFromRow($model->row()) - : $model->row(); + $config = $this->filterTransformerResolver->transform($element, $model->getFilterType(), $model) + ?? $model->row(); $filter = new Filter( element: $model->getFilterType(), diff --git a/src/Filter/Element/AbstractFilterElement.php b/src/Filter/Element/AbstractFilterElement.php index 53db15a9..4c5739ee 100644 --- a/src/Filter/Element/AbstractFilterElement.php +++ b/src/Filter/Element/AbstractFilterElement.php @@ -4,21 +4,35 @@ namespace HeimrichHannot\FlareBundle\Filter\Element; +use HeimrichHannot\FlareBundle\Config\ConfigBuilder; +use HeimrichHannot\FlareBundle\Config\TransformerBuilder; use HeimrichHannot\FlareBundle\Contract\DcaContract; use HeimrichHannot\FlareBundle\Contract\IsSupportedContract; +use HeimrichHannot\FlareBundle\Contract\OptionsInterface; +use HeimrichHannot\FlareBundle\Contract\TransformerContract; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; abstract class AbstractFilterElement implements - FilterElementInterface, FilterElementOptionsInterface, IsSupportedContract, DcaContract + FilterElementInterface, OptionsInterface, TransformerContract, IsSupportedContract, DcaContract { abstract public function configureOptions(OptionsResolver $resolver): void; - abstract public function configFromRow(array $row): array; + public function configureTransformers(TransformerBuilder $transformers): void + { + $transformers->for(FilterModel::class, $this->transformFilterModel(...)); + } + + /** + * Translates a stored tl_flare_filter model into canonical config values (unresolved). + * All deserialization, casting, and enum parsing belongs here. + */ + abstract protected function transformFilterModel(FilterModel $model, ConfigBuilder $config): void; public function buildDca(DcaBuilder $dca, DcaContext $context): void {} diff --git a/src/Filter/Element/ArchiveFilterElement.php b/src/Filter/Element/ArchiveFilterElement.php index 1d42844f..08f54721 100644 --- a/src/Filter/Element/ArchiveFilterElement.php +++ b/src/Filter/Element/ArchiveFilterElement.php @@ -7,6 +7,7 @@ use Contao\Model; use Contao\Model\Collection; use Contao\StringUtil; +use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; @@ -19,6 +20,7 @@ use HeimrichHannot\FlareBundle\Form\Factory\ChoicesBuilderFactory; use HeimrichHannot\FlareBundle\InferPtable\Factory\PtableInferrableFactory; use HeimrichHannot\FlareBundle\InferPtable\PtableInferrer; +use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Specification\ListSpecification; use HeimrichHannot\FlareBundle\Util\Str; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; @@ -51,29 +53,28 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('preselect')->default([])->allowedTypes('array'); } - public function configFromRow(array $row): array + protected function transformFilterModel(FilterModel $model, ConfigBuilder $config): void { - $formatLabel = ($row['formatLabel'] ?? null) === 'custom' - ? ($row['formatLabelCustom'] ?? null) - : ($row['formatLabel'] ?? null); - - $formatEmptyOption = ($row['formatEmptyOption'] ?? null) === 'custom' - ? ($row['formatEmptyOptionCustom'] ?? null) - : ($row['formatEmptyOption'] ?? null); - - return [ - 'intrinsic' => (bool) ($row['intrinsic'] ?? false), - 'whitelist_parents' => $this->normalizeIds($row['whitelistParents'] ?? null), - 'group_whitelist_parents' => $this->normalizeGroups($row['groupWhitelistParents'] ?? null), - 'use_whitelist_for_options_only' => (bool) ($row['useWhitelistForOptionsOnly'] ?? false), - 'format_label' => $formatLabel ?: null, - 'has_empty_option' => (bool) ($row['hasEmptyOption'] ?? false), - 'format_empty_option' => $formatEmptyOption ?: null, - 'is_mandatory' => (bool) ($row['isMandatory'] ?? false), - 'is_multiple' => (bool) ($row['isMultiple'] ?? false), - 'is_expanded' => (bool) ($row['isExpanded'] ?? false), - 'preselect' => StringUtil::deserialize(($row['preselect'] ?? null) ?: null, true), - ]; + $formatLabel = $model->formatLabel === 'custom' + ? $model->formatLabelCustom + : $model->formatLabel; + + $formatEmptyOption = $model->formatEmptyOption === 'custom' + ? $model->formatEmptyOptionCustom + : $model->formatEmptyOption; + + $config + ->set('intrinsic', (bool) $model->intrinsic) + ->set('whitelist_parents', $this->normalizeIds($model->whitelistParents)) + ->set('group_whitelist_parents', $this->normalizeGroups($model->groupWhitelistParents)) + ->set('use_whitelist_for_options_only', (bool) $model->useWhitelistForOptionsOnly) + ->set('format_label', $formatLabel ?: null) + ->set('has_empty_option', (bool) $model->hasEmptyOption) + ->set('format_empty_option', $formatEmptyOption ?: null) + ->set('is_mandatory', (bool) $model->isMandatory) + ->set('is_multiple', (bool) $model->isMultiple) + ->set('is_expanded', (bool) $model->isExpanded) + ->set('preselect', StringUtil::deserialize($model->preselect ?: null, true)); } /** diff --git a/src/Filter/Element/BelongsToRelationFilterElement.php b/src/Filter/Element/BelongsToRelationFilterElement.php index 5b8c1b69..544459c3 100644 --- a/src/Filter/Element/BelongsToRelationFilterElement.php +++ b/src/Filter/Element/BelongsToRelationFilterElement.php @@ -6,6 +6,7 @@ use Contao\Message; use Contao\StringUtil; +use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; @@ -16,6 +17,7 @@ use HeimrichHannot\FlareBundle\Filter\Type\BelongsToRelationFilterType; use HeimrichHannot\FlareBundle\InferPtable\Factory\PtableInferrableFactory; use HeimrichHannot\FlareBundle\InferPtable\PtableInferrer; +use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Contracts\Translation\TranslatorInterface; @@ -37,18 +39,17 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('group_whitelist_parents')->default([])->allowedTypes('array'); } - public function configFromRow(array $row): array + protected function transformFilterModel(FilterModel $model, ConfigBuilder $config): void { - $whitelistParents = StringUtil::deserialize($row['whitelistParents'] ?? null); - $groupWhitelistParents = StringUtil::deserialize($row['groupWhitelistParents'] ?? null); - - return [ - 'intrinsic' => (bool) ($row['intrinsic'] ?? false), - 'field_pid' => ($row['fieldPid'] ?? null) ?: null, - 'which_ptable' => ($row['whichPtable'] ?? null) ?: null, - 'whitelist_parents' => $whitelistParents ? (array) $whitelistParents : [], - 'group_whitelist_parents' => \is_array($groupWhitelistParents) ? $groupWhitelistParents : [], - ]; + $whitelistParents = StringUtil::deserialize($model->whitelistParents); + $groupWhitelistParents = StringUtil::deserialize($model->groupWhitelistParents); + + $config + ->set('intrinsic', (bool) $model->intrinsic) + ->set('field_pid', $model->fieldPid ?: null) + ->set('which_ptable', $model->whichPtable ?: null) + ->set('whitelist_parents', $whitelistParents ? (array) $whitelistParents : []) + ->set('group_whitelist_parents', \is_array($groupWhitelistParents) ? $groupWhitelistParents : []); } /** diff --git a/src/Filter/Element/BooleanFilterElement.php b/src/Filter/Element/BooleanFilterElement.php index f8141ab6..2d412390 100644 --- a/src/Filter/Element/BooleanFilterElement.php +++ b/src/Filter/Element/BooleanFilterElement.php @@ -6,13 +6,14 @@ use Contao\Controller; use Contao\Message; +use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Enum\BoolBinaryChoices; use HeimrichHannot\FlareBundle\Enum\BoolMode; -use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; +use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\BooleanFilterType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; @@ -34,19 +35,15 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('label')->default(null)->allowedTypes('string', 'null'); } - public function configFromRow(array $row): array + protected function transformFilterModel(FilterModel $model, ConfigBuilder $config): void { - $label = $row['label'] ?? null; - $title = $row['title'] ?? null; - - return [ - 'intrinsic' => (bool) ($row['intrinsic'] ?? false), - 'field' => ($row['fieldGeneric'] ?? null) ?: null, - 'preselect' => $this->normalizeValue($row['preselect'] ?? null), - 'mode' => BoolMode::tryFrom($row['boolMode'] ?? '') ?? BoolMode::BINARY, - 'binary_choices' => BoolBinaryChoices::tryFrom($row['boolBinaryChoices'] ?? '') ?? BoolBinaryChoices::NULL_TRUE, - 'label' => $label ?: $title ?: null, - ]; + $config + ->set('intrinsic', (bool) $model->intrinsic) + ->set('field', $model->fieldGeneric ?: null) + ->set('preselect', $this->normalizeValue($model->preselect)) + ->set('mode', BoolMode::tryFrom((string) $model->boolMode) ?? BoolMode::BINARY) + ->set('binary_choices', BoolBinaryChoices::tryFrom((string) $model->boolBinaryChoices) ?? BoolBinaryChoices::NULL_TRUE) + ->set('label', $model->label ?: $model->title ?: null); } public function buildForm(FormBuilderInterface $builder, FilterContext $context): void @@ -168,17 +165,4 @@ protected function getFieldGenericOptions(string $targetTable): array return $options; } - public static function define( - ?string $targetField = null, - ?bool $expectedValue = null, - ): Filter { - return new Filter( - element: static::TYPE, - config: [ - 'intrinsic' => true, - 'field' => $targetField, - 'preselect' => (bool) $expectedValue, - ], - ); - } } diff --git a/src/Filter/Element/CalendarCurrentFilterElement.php b/src/Filter/Element/CalendarCurrentFilterElement.php index 46093836..5ef7f9a3 100644 --- a/src/Filter/Element/CalendarCurrentFilterElement.php +++ b/src/Filter/Element/CalendarCurrentFilterElement.php @@ -4,10 +4,12 @@ namespace HeimrichHannot\FlareBundle\Filter\Element; +use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Engine\Context\ValidationContext; +use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\CalendarCurrentFilterType; @@ -40,17 +42,16 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('has_extended_events')->default(false)->allowedTypes('bool'); } - public function configFromRow(array $row): array + protected function transformFilterModel(FilterModel $model, ConfigBuilder $config): void { - return [ - 'intrinsic' => (bool) ($row['intrinsic'] ?? false), - 'is_limited' => (bool) ($row['isLimited'] ?? false), - 'configure_start' => ($row['configureStart'] ?? null) ?: null, - 'configure_stop' => ($row['configureStop'] ?? null) ?: null, - 'start_at' => ($row['startAt'] ?? null) ?: null, - 'stop_at' => ($row['stopAt'] ?? null) ?: null, - 'has_extended_events' => (bool) ($row['hasExtendedEvents'] ?? false), - ]; + $config + ->set('intrinsic', (bool) $model->intrinsic) + ->set('is_limited', (bool) $model->isLimited) + ->set('configure_start', $model->configureStart ?: null) + ->set('configure_stop', $model->configureStop ?: null) + ->set('start_at', $model->startAt ?: null) + ->set('stop_at', $model->stopAt ?: null) + ->set('has_extended_events', (bool) $model->hasExtendedEvents); } public function buildForm(FormBuilderInterface $builder, FilterContext $context): void diff --git a/src/Filter/Element/CallbackFilterElement.php b/src/Filter/Element/CallbackFilterElement.php deleted file mode 100644 index cc8cf440..00000000 --- a/src/Filter/Element/CallbackFilterElement.php +++ /dev/null @@ -1,39 +0,0 @@ -): void $buildFilter - * @param (\Closure(FormBuilderInterface, FilterContext): void)|null $buildForm - */ - public function __construct( - private \Closure $buildFilter, - private ?\Closure $buildForm = null, - ) {} - - public function buildForm(FormBuilderInterface $builder, FilterContext $context): void - { - if ($this->buildForm) { - ($this->buildForm)($builder, $context); - } - } - - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void - { - ($this->buildFilter)($builder, $context, $data); - } -} diff --git a/src/Filter/Element/DateRangeFilterElement.php b/src/Filter/Element/DateRangeFilterElement.php index a8d77468..76ca969b 100644 --- a/src/Filter/Element/DateRangeFilterElement.php +++ b/src/Filter/Element/DateRangeFilterElement.php @@ -4,10 +4,12 @@ namespace HeimrichHannot\FlareBundle\Filter\Element; +use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Exception\FilterException; +use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\DateRangeFilterType; @@ -34,12 +36,11 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('field')->default(null)->allowedTypes('string', 'null'); } - public function configFromRow(array $row): array + protected function transformFilterModel(FilterModel $model, ConfigBuilder $config): void { - return [ - 'intrinsic' => (bool) ($row['intrinsic'] ?? false), - 'field' => ($row['fieldGeneric'] ?? null) ?: null, - ]; + $config + ->set('intrinsic', (bool) $model->intrinsic) + ->set('field', $model->fieldGeneric ?: null); } public function buildForm(FormBuilderInterface $builder, FilterContext $context): void diff --git a/src/Filter/Element/DcaSelectFieldFilterElement.php b/src/Filter/Element/DcaSelectFieldFilterElement.php index 2764832e..46c1e2ab 100644 --- a/src/Filter/Element/DcaSelectFieldFilterElement.php +++ b/src/Filter/Element/DcaSelectFieldFilterElement.php @@ -8,6 +8,7 @@ use Contao\DataContainer; use Contao\StringUtil; use Contao\System; +use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; @@ -15,6 +16,7 @@ use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\DcaSelectFilterType; use HeimrichHannot\FlareBundle\Form\Factory\ChoicesBuilderFactory; +use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -40,23 +42,22 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('preselect')->default(null); } - public function configFromRow(array $row): array + protected function transformFilterModel(FilterModel $model, ConfigBuilder $config): void { - $isMultiple = (bool) ($row['isMultiple'] ?? false); - $preselect = ($row['preselect'] ?? null) ?: null; - - return [ - 'intrinsic' => (bool) ($row['intrinsic'] ?? false), - 'field' => ($row['fieldGeneric'] ?? null) ?: null, - 'is_multiple' => $isMultiple, - 'is_expanded' => (bool) ($row['isExpanded'] ?? false), - 'is_mandatory' => (bool) ($row['isMandatory'] ?? false), - 'label' => ($row['label'] ?? null) ?: null, - 'placeholder' => ($row['placeholder'] ?? null) ?: null, - 'preselect' => $isMultiple + $isMultiple = (bool) $model->isMultiple; + $preselect = $model->preselect ?: null; + + $config + ->set('intrinsic', (bool) $model->intrinsic) + ->set('field', $model->fieldGeneric ?: null) + ->set('is_multiple', $isMultiple) + ->set('is_expanded', (bool) $model->isExpanded) + ->set('is_mandatory', (bool) $model->isMandatory) + ->set('label', $model->label ?: null) + ->set('placeholder', $model->placeholder ?: null) + ->set('preselect', $isMultiple ? StringUtil::deserialize($preselect) - : $preselect, - ]; + : $preselect); } public function buildForm(FormBuilderInterface $builder, FilterContext $context): void diff --git a/src/Filter/Element/FieldValueChoiceFilterElement.php b/src/Filter/Element/FieldValueChoiceFilterElement.php index ba5884a1..3cb65bf2 100644 --- a/src/Filter/Element/FieldValueChoiceFilterElement.php +++ b/src/Filter/Element/FieldValueChoiceFilterElement.php @@ -8,6 +8,7 @@ use Contao\DataContainer; use Contao\StringUtil; use Doctrine\DBAL\Connection; +use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; @@ -17,6 +18,7 @@ use HeimrichHannot\FlareBundle\Filter\Type\FieldValueChoiceFilterType; use HeimrichHannot\FlareBundle\Form\ChoicesBuilder; use HeimrichHannot\FlareBundle\Form\Factory\ChoicesBuilderFactory; +use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -43,17 +45,16 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('preselect')->default(null)->allowedTypes('array', 'null'); } - public function configFromRow(array $row): array + protected function transformFilterModel(FilterModel $model, ConfigBuilder $config): void { - $multiple = (bool) ($row['isMultiple'] ?? false); - - return [ - 'intrinsic' => (bool) ($row['intrinsic'] ?? false), - 'field' => ($row['fieldGeneric'] ?? null) ?: null, - 'multiple' => $multiple, - 'expanded' => (bool) ($row['isExpanded'] ?? false), - 'preselect' => $this->normalizePreselect($row['preselect'] ?? null, $multiple), - ]; + $multiple = (bool) $model->isMultiple; + + $config + ->set('intrinsic', (bool) $model->intrinsic) + ->set('field', $model->fieldGeneric ?: null) + ->set('multiple', $multiple) + ->set('expanded', (bool) $model->isExpanded) + ->set('preselect', $this->normalizePreselect($model->preselect, $multiple)); } public function buildForm(FormBuilderInterface $builder, FilterContext $context): void diff --git a/src/Filter/Element/FilterElementOptionsInterface.php b/src/Filter/Element/FilterElementOptionsInterface.php deleted file mode 100644 index f03cd014..00000000 --- a/src/Filter/Element/FilterElementOptionsInterface.php +++ /dev/null @@ -1,31 +0,0 @@ - $row - * - * @return array - */ - public function configFromRow(array $row): array; -} diff --git a/src/Filter/Element/PublishedFilterElement.php b/src/Filter/Element/PublishedFilterElement.php index f2438ec7..2fe57379 100644 --- a/src/Filter/Element/PublishedFilterElement.php +++ b/src/Filter/Element/PublishedFilterElement.php @@ -4,11 +4,12 @@ namespace HeimrichHannot\FlareBundle\Filter\Element; +use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; -use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; +use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\PublishedFilterType; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -27,19 +28,18 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('invert')->default(false)->allowedTypes('bool'); } - public function configFromRow(array $row): array + protected function transformFilterModel(FilterModel $model, ConfigBuilder $config): void { - $usePublished = $row['usePublished'] ?? true; - $useStart = $row['useStart'] ?? true; - $useStop = $row['useStop'] ?? true; - - return [ - 'intrinsic' => (bool) ($row['intrinsic'] ?? false), - 'published_field' => $usePublished ? (($row['fieldPublished'] ?? null) ?: 'published') : null, - 'start_field' => $useStart ? (($row['fieldStart'] ?? null) ?: 'start') : null, - 'stop_field' => $useStop ? (($row['fieldStop'] ?? null) ?: 'stop') : null, - 'invert' => (bool) ($row['invertPublished'] ?? false), - ]; + $usePublished = (bool) ($model->usePublished ?? true); + $useStart = (bool) ($model->useStart ?? true); + $useStop = (bool) ($model->useStop ?? true); + + $config + ->set('intrinsic', (bool) $model->intrinsic) + ->set('published_field', $usePublished ? ($model->fieldPublished ?: 'published') : null) + ->set('start_field', $useStart ? ($model->fieldStart ?: 'start') : null) + ->set('stop_field', $useStop ? ($model->fieldStop ?: 'stop') : null) + ->set('invert', (bool) $model->invertPublished); } public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void @@ -60,26 +60,4 @@ public function buildDca(DcaBuilder $dca, DcaContext $context): void $dca->palette('{filter_legend},usePublished,useStart,useStop'); } - public static function define( - string|false|null $published = null, - string|false|null $start = null, - string|false|null $stop = null, - bool|null $invertPublished = null, - ): Filter { - $published ??= 'published'; - $start ??= 'start'; - $stop ??= 'stop'; - $invertPublished ??= false; - - return new Filter( - element: static::TYPE, - config: [ - 'intrinsic' => true, - 'published_field' => $published ?: null, - 'start_field' => $start ?: null, - 'stop_field' => $stop ?: null, - 'invert' => $published ? $invertPublished : false, - ], - ); - } } diff --git a/src/Filter/Element/SearchKeywordsFilterElement.php b/src/Filter/Element/SearchKeywordsFilterElement.php index 07e47207..9dc8ef93 100644 --- a/src/Filter/Element/SearchKeywordsFilterElement.php +++ b/src/Filter/Element/SearchKeywordsFilterElement.php @@ -5,10 +5,12 @@ namespace HeimrichHannot\FlareBundle\Filter\Element; use Contao\StringUtil; +use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; +use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\SearchKeywordsFilterType; use Symfony\Component\Form\Extension\Core\Type\TextType; @@ -29,15 +31,14 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('placeholder')->default(null)->allowedTypes('string', 'null'); } - public function configFromRow(array $row): array + protected function transformFilterModel(FilterModel $model, ConfigBuilder $config): void { - return [ - 'intrinsic' => (bool) ($row['intrinsic'] ?? false), - 'columns' => StringUtil::deserialize($row['columnsGeneric'] ?? null, true), - 'prefill' => ($row['prefill'] ?? null) ?: null, - 'label' => ($row['label'] ?? null) ?: null, - 'placeholder' => ($row['placeholder'] ?? null) ?: null, - ]; + $config + ->set('intrinsic', (bool) $model->intrinsic) + ->set('columns', StringUtil::deserialize($model->columnsGeneric, true)) + ->set('prefill', $model->prefill ?: null) + ->set('label', $model->label ?: null) + ->set('placeholder', $model->placeholder ?: null); } public function buildForm(FormBuilderInterface $builder, FilterContext $context): void diff --git a/src/Filter/Element/SimpleEquationFilterElement.php b/src/Filter/Element/SimpleEquationFilterElement.php index 72c4d80d..adf99948 100644 --- a/src/Filter/Element/SimpleEquationFilterElement.php +++ b/src/Filter/Element/SimpleEquationFilterElement.php @@ -4,16 +4,16 @@ namespace HeimrichHannot\FlareBundle\Filter\Element; +use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Enum\SqlEquationOperator; use HeimrichHannot\FlareBundle\Exception\FilterException; -use HeimrichHannot\FlareBundle\Exception\FlareException; -use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\SimpleEquationFilterType; +use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Util\DcaHelper; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -30,16 +30,13 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('right')->default(null); } - public function configFromRow(array $row): array + protected function transformFilterModel(FilterModel $model, ConfigBuilder $config): void { - $operator = $row['equationOperator'] ?? null; - - return [ - 'intrinsic' => (bool) ($row['intrinsic'] ?? false), - 'left' => ($row['equationLeft'] ?? null) ?: null, - 'operator' => $operator ? SqlEquationOperator::match($operator) : null, - 'right' => $row['equationRight'] ?? null, - ]; + $config + ->set('intrinsic', (bool) $model->intrinsic) + ->set('left', $model->equationLeft ?: null) + ->set('operator', $model->equationOperator ? SqlEquationOperator::match($model->equationOperator) : null) + ->set('right', $model->equationRight); } /** @@ -73,26 +70,4 @@ public function buildDca(DcaBuilder $dca, DcaContext $context): void ->options(static fn (): array => DcaHelper::getFieldOptions($context->getTargetTable())); } - /** - * @throws FlareException - */ - public static function define( - ?string $equationLeft = null, - ?SqlEquationOperator $equationOperator = null, - mixed $equationRight = null, - ): Filter { - if (!$equationLeft || !$equationOperator || (!$equationOperator->isUnary() && $equationRight === null)) { - throw new FlareException('Invalid filter definition for SimpleEquationElement.'); - } - - return new Filter( - element: static::TYPE, - config: [ - 'intrinsic' => true, - 'left' => $equationLeft, - 'operator' => $equationOperator, - 'right' => $equationRight, - ], - ); - } } diff --git a/src/Filter/Factory/FilterContextFactory.php b/src/Filter/Factory/FilterContextFactory.php new file mode 100644 index 00000000..af158f95 --- /dev/null +++ b/src/Filter/Factory/FilterContextFactory.php @@ -0,0 +1,43 @@ +filterOptionsResolver->resolve($filter, $element), + engineContext: $engineContext, + key: $key, + ); + } +} diff --git a/src/Filter/Filter.php b/src/Filter/Filter.php index c1a11c8a..67ff3c62 100644 --- a/src/Filter/Filter.php +++ b/src/Filter/Filter.php @@ -4,7 +4,6 @@ namespace HeimrichHannot\FlareBundle\Filter; -use HeimrichHannot\FlareBundle\Filter\Element\CallbackFilterElement; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; /** @@ -12,8 +11,8 @@ * * Pairs a filter element (registered type string or inline instance) with its canonical, * element-defined configuration. Contains no DCA/storage specifics — translating a stored - * row into config is the element's responsibility - * ({@see \HeimrichHannot\FlareBundle\Filter\Element\FilterElementOptionsInterface}). + * source into config is the element's transformer responsibility + * ({@see \HeimrichHannot\FlareBundle\Contract\TransformerContract}). */ final readonly class Filter { @@ -119,43 +118,6 @@ public function withSource(?string $source): self ); } - /** - * Creates an inline filter from closures, without a registered element service. - * - * @param callable(FilterBuilderInterface, FilterContext, array): void $buildFilter - * @param (callable(\Symfony\Component\Form\FormBuilderInterface, FilterContext): void)|null $buildForm - */ - public static function fromCallback( - callable $buildFilter, - ?callable $buildForm = null, - ?string $alias = null, - ?string $targetAlias = null, - ): self { - return new self( - element: new CallbackFilterElement($buildFilter(...), $buildForm ? $buildForm(...) : null), - alias: $alias, - targetAlias: $targetAlias, - targetingForced: !\is_null($targetAlias), - ); - } - - /** - * Creates an inline filter that applies a single filter type with the given options — - * no registered element, no DB row. - * - * @param class-string $filterTypeClass - * @param array $options - */ - public static function fromType(string $filterTypeClass, array $options = [], ?string $targetAlias = null): self - { - return self::fromCallback( - static function (FilterBuilderInterface $builder) use ($filterTypeClass, $options): void { - $builder->add($filterTypeClass, $options); - }, - targetAlias: $targetAlias, - ); - } - /** * Stable representation for hashing/caching. Inline elements are represented by their * class name, which makes hashes of anonymous elements request-local. diff --git a/src/Filter/Resolver/FilterOptionsResolver.php b/src/Filter/Resolver/FilterOptionsResolver.php index 41b0dcac..d3fa137b 100644 --- a/src/Filter/Resolver/FilterOptionsResolver.php +++ b/src/Filter/Resolver/FilterOptionsResolver.php @@ -4,15 +4,15 @@ namespace HeimrichHannot\FlareBundle\Filter\Resolver; +use HeimrichHannot\FlareBundle\Contract\OptionsInterface; use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\Filter\Element\FilterElementOptionsInterface; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use Symfony\Component\OptionsResolver\OptionsResolver; /** * Resolves a filter's canonical config through the element's declared schema. - * Elements without a {@see FilterElementOptionsInterface} receive their config verbatim (unvalidated). + * Elements without an {@see OptionsInterface} receive their config verbatim (unvalidated). */ class FilterOptionsResolver { @@ -28,7 +28,7 @@ class FilterOptionsResolver */ public function resolve(Filter $filter, FilterElementInterface $element): array { - if (!$element instanceof FilterElementOptionsInterface) { + if (!$element instanceof OptionsInterface) { return $filter->config; } diff --git a/src/Form/Factory/FilterFormFactory.php b/src/Form/Factory/FilterFormFactory.php index 73097c95..0a914de0 100644 --- a/src/Form/Factory/FilterFormFactory.php +++ b/src/Form/Factory/FilterFormFactory.php @@ -10,9 +10,9 @@ use HeimrichHannot\FlareBundle\Event\FilterElementFormBuiltEvent; use HeimrichHannot\FlareBundle\Event\FilterFormBuildEvent; use HeimrichHannot\FlareBundle\Exception\FlareException; +use HeimrichHannot\FlareBundle\Filter\Factory\FilterContextFactory; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterElementResolver; -use HeimrichHannot\FlareBundle\Filter\Resolver\FilterOptionsResolver; use HeimrichHannot\FlareBundle\Specification\ListSpecification; use HeimrichHannot\FlareBundle\Util\Str; use Symfony\Component\Form\Extension\Core\Type\FormType; @@ -24,7 +24,7 @@ { public function __construct( private EventDispatcherInterface $eventDispatcher, - private FilterOptionsResolver $filterConfigResolver, + private FilterContextFactory $filterContextFactory, private FilterElementResolver $filterElementResolver, private FormFactoryInterface $formFactory, ) {} @@ -67,13 +67,7 @@ public function create(ListSpecification $list, FormContextInterface $context): continue; } - $filterContext = new FilterContext( - list: $list, - filter: $filter, - config: $this->filterConfigResolver->resolve($filter, $element), - engineContext: $context, - key: $key, - ); + $filterContext = $this->filterContextFactory->create($list, $filter, $element, $context, $key); $child = $builder->create($filter->alias, FormType::class, [ 'inherit_data' => false, diff --git a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php index b40407b9..cff019a9 100644 --- a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php +++ b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php @@ -5,6 +5,7 @@ namespace HeimrichHannot\FlareBundle\Integration\CodefogTags\FilterElement; use Contao\StringUtil; +use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; @@ -14,6 +15,7 @@ use HeimrichHannot\FlareBundle\Filter\Type\IntegerIdChoiceFilterType; use HeimrichHannot\FlareBundle\Form\Factory\ChoicesBuilderFactory; use HeimrichHannot\FlareBundle\Integration\CodefogTags\Registry\CfgTagsJoinsRegistry; +use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Query\Factory\ListExecutionContextFactory; use HeimrichHannot\FlareBundle\Query\ListExecutionContext; use Psr\Log\LoggerInterface; @@ -44,19 +46,18 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('placeholder')->default(null)->allowedTypes('string', 'null'); } - public function configFromRow(array $row): array + protected function transformFilterModel(FilterModel $model, ConfigBuilder $config): void { - return [ - 'intrinsic' => (bool) ($row['intrinsic'] ?? false), - 'preselect' => $this->normalizeValueArray( - StringUtil::deserialize(($row['preselect'] ?? null) ?: null, true) - ), - 'is_mandatory' => (bool) ($row['isMandatory'] ?? false), - 'is_multiple' => (bool) ($row['isMultiple'] ?? false), - 'is_expanded' => (bool) ($row['isExpanded'] ?? false), - 'label' => ($row['label'] ?? null) ?: null, - 'placeholder' => ($row['placeholder'] ?? null) ?: null, - ]; + $config + ->set('intrinsic', (bool) $model->intrinsic) + ->set('preselect', $this->normalizeValueArray( + StringUtil::deserialize($model->preselect ?: null, true) + )) + ->set('is_mandatory', (bool) $model->isMandatory) + ->set('is_multiple', (bool) $model->isMultiple) + ->set('is_expanded', (bool) $model->isExpanded) + ->set('label', $model->label ?: null) + ->set('placeholder', $model->placeholder ?: null); } public function buildForm(FormBuilderInterface $builder, FilterContext $context): void diff --git a/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php b/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php index 6e2135e8..b8637cbd 100644 --- a/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php +++ b/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php @@ -4,10 +4,12 @@ namespace HeimrichHannot\FlareBundle\Integration\CodefogTags\FilterElement; +use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Filter\Element\AbstractFilterElement; +use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE, isTargeted: true)] @@ -30,9 +32,8 @@ public function configureOptions(OptionsResolver $resolver): void // TODO: Implement configureOptions() method. } - public function configFromRow(array $row): array + protected function transformFilterModel(FilterModel $model, ConfigBuilder $config): void { - // TODO: Implement configFromRow() method. - return []; + // TODO: Implement transformFilterModel() method. } } diff --git a/src/Integration/ContaoCalendar/ListType/EventsListType.php b/src/Integration/ContaoCalendar/ListType/EventsListType.php index 880d06ee..320c42e5 100644 --- a/src/Integration/ContaoCalendar/ListType/EventsListType.php +++ b/src/Integration/ContaoCalendar/ListType/EventsListType.php @@ -10,6 +10,7 @@ use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListType; use HeimrichHannot\FlareBundle\Event\ListSpecificationCreatedEvent; use HeimrichHannot\FlareBundle\Filter\Element\PublishedFilterElement; +use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\ListType\AbstractListType; use HeimrichHannot\FlareBundle\Query\JoinTypeEnum; use HeimrichHannot\FlareBundle\Query\SqlJoinStruct; @@ -61,7 +62,16 @@ public function onListSpecificationCreated(ListSpecificationCreatedEvent $config $spec = $config->listSpecification; if (!$spec->hasFilterOfType(PublishedFilterElement::TYPE)) { - $spec->addFilter(PublishedFilterElement::define()); + $spec->addFilter(new Filter( + element: PublishedFilterElement::TYPE, + config: [ + 'intrinsic' => true, + 'published_field' => 'published', + 'start_field' => 'start', + 'stop_field' => 'stop', + 'invert' => false, + ], + )); } } } diff --git a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php index 7456a047..e149aac2 100644 --- a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php +++ b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php @@ -16,6 +16,7 @@ use HeimrichHannot\FlareBundle\Event\FetchCountEvent; use HeimrichHannot\FlareBundle\Event\FetchListEntriesEvent; use HeimrichHannot\FlareBundle\Filter\Element\SimpleEquationFilterElement; +use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\ListType\DcMultilingualListType; use HeimrichHannot\FlareBundle\Query\JoinTypeEnum; use HeimrichHannot\FlareBundle\Query\ListQueryBuilder; @@ -131,18 +132,26 @@ public function listViewFetchCountEvent(FetchCountEvent $event): void if ($lang !== $langFallback && $dcMultilingualDisplay === DcMultilingualHelper::DISPLAY_LOCALIZED) // localized list view { - $configuredFilter = SimpleEquationFilterElement::define( - equationLeft: DcMultilingualHelper::getPidColumn($table), - equationOperator: SqlEquationOperator::GREATER_THAN, - equationRight: '0' + $configuredFilter = new Filter( + element: SimpleEquationFilterElement::TYPE, + config: [ + 'intrinsic' => true, + 'left' => DcMultilingualHelper::getPidColumn($table), + 'operator' => SqlEquationOperator::GREATER_THAN, + 'right' => '0', + ], ); - $configuredFilter->forceTargetAlias('translation'); + $configuredFilter = $configuredFilter->withTargetAlias('translation'); } - $configuredFilter ??= SimpleEquationFilterElement::define( - equationLeft: DcMultilingualHelper::getPidColumn($table), - equationOperator: SqlEquationOperator::EQUALS, - equationRight: '0' + $configuredFilter ??= new Filter( + element: SimpleEquationFilterElement::TYPE, + config: [ + 'intrinsic' => true, + 'left' => DcMultilingualHelper::getPidColumn($table), + 'operator' => SqlEquationOperator::EQUALS, + 'right' => '0', + ], ); // $filters->add($this->filterContextManager->definitionToContext( diff --git a/src/ListType/NewsListType.php b/src/ListType/NewsListType.php index 5e14f379..ebea247b 100644 --- a/src/ListType/NewsListType.php +++ b/src/ListType/NewsListType.php @@ -10,6 +10,7 @@ use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListType; use HeimrichHannot\FlareBundle\Event\ListSpecificationCreatedEvent; use HeimrichHannot\FlareBundle\Filter\Element\PublishedFilterElement; +use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Query\JoinTypeEnum; use HeimrichHannot\FlareBundle\Query\SqlJoinStruct; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; @@ -47,7 +48,16 @@ public function onListSpecificationCreated(ListSpecificationCreatedEvent $config $spec = $config->listSpecification; if (!$spec->hasFilterOfType(PublishedFilterElement::TYPE)) { - $spec->addFilter(PublishedFilterElement::define()); + $spec->addFilter(new Filter( + element: PublishedFilterElement::TYPE, + config: [ + 'intrinsic' => true, + 'published_field' => 'published', + 'start_field' => 'start', + 'stop_field' => 'stop', + 'invert' => false, + ], + )); } } } diff --git a/src/Query/Executor/FilterExecutor.php b/src/Query/Executor/FilterExecutor.php index 1fb9809a..d32842cf 100644 --- a/src/Query/Executor/FilterExecutor.php +++ b/src/Query/Executor/FilterExecutor.php @@ -12,9 +12,9 @@ use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\FilterBuilder; use HeimrichHannot\FlareBundle\Filter\FilterCall; +use HeimrichHannot\FlareBundle\Filter\Factory\FilterContextFactory; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterElementResolver; -use HeimrichHannot\FlareBundle\Filter\Resolver\FilterOptionsResolver; use HeimrichHannot\FlareBundle\Query\Factory\FilterQueryBuilderFactory; use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; use HeimrichHannot\FlareBundle\Query\ListQueryConfig; @@ -28,7 +28,7 @@ { public function __construct( private EventDispatcherInterface $eventDispatcher, - private FilterOptionsResolver $filterConfigResolver, + private FilterContextFactory $filterContextFactory, private FilterElementRegistry $filterElementRegistry, private FilterElementResolver $filterElementResolver, private FilterQueryBuilderFactory $filterQueryBuilderFactory, @@ -54,13 +54,7 @@ public function invokeFilters(ListQueryConfig $options): array continue; } - $context = new FilterContext( - list: $list, - filter: $filter, - config: $this->filterConfigResolver->resolve($filter, $element), - engineContext: $options->context, - key: $key, - ); + $context = $this->filterContextFactory->create($list, $filter, $element, $options->context, $key); $data = (array) ($options->filterValues[$key] ?? $filter->data ?? []); diff --git a/src/Twig/Extension/FlareExtension.php b/src/Twig/Extension/FlareExtension.php index 34b35dff..840950da 100644 --- a/src/Twig/Extension/FlareExtension.php +++ b/src/Twig/Extension/FlareExtension.php @@ -16,7 +16,6 @@ public function getFunctions(): array new TwigFunction('flare_content', [FlareRuntime::class, 'getTlContent'], ['is_safe' => ['html']]), new TwigFunction('flare_enclosure', [FlareRuntime::class, 'getEnclosure']), new TwigFunction('flare_enclosure_files', [FlareRuntime::class, 'getEnclosureFiles']), - new TwigFunction('flare_make_filter', [FlareRuntime::class, 'makeFilter']), new TwigFunction('flare_project', [FlareRuntime::class, 'project']), new TwigFunction('flare_schema_org', [FlareRuntime::class, 'getSchemaOrg'], ['needs_context'=> true]), ]; diff --git a/src/Twig/Runtime/FlareRuntime.php b/src/Twig/Runtime/FlareRuntime.php index f4b90210..b61c447b 100644 --- a/src/Twig/Runtime/FlareRuntime.php +++ b/src/Twig/Runtime/FlareRuntime.php @@ -14,8 +14,6 @@ use HeimrichHannot\FlareBundle\Engine\Engine; use HeimrichHannot\FlareBundle\Engine\View\ViewInterface; use HeimrichHannot\FlareBundle\Event\ReaderSchemaOrgEvent; -use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\Filter\Type\FilterTypeInterface; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Registry\ProjectorRegistry; use HeimrichHannot\FlareBundle\Specification\ListSpecification; @@ -35,23 +33,6 @@ public function project(ListSpecification $spec, ContextInterface $config): View return $this->projectorRegistry->getProjectorFor($spec, $config)->project($spec, $config); } - /** - * Creates a filter for programmatic use, e.g. `{% do flare.list.addFilter(flare_make_filter(...)) %}`. - * - * @param string $type A registered filter element type alias (config keys are the element's - * canonical config), or a filter type class-string (config keys are the type's options). - * @param array $config - * @param array|null $data Runtime data bag, as buildFilter() receives it. - */ - public function makeFilter(string $type, array $config = [], ?array $data = null, ?string $alias = null): Filter - { - if (\is_a($type, FilterTypeInterface::class, true)) { - return Filter::fromType($type, $config); - } - - return new Filter(element: $type, config: $config, data: $data, alias: $alias); - } - /** * @throws \InvalidArgumentException */ diff --git a/tests/Filter/FilterOptionsResolverTest.php b/tests/Filter/FilterOptionsResolverTest.php index 710dfef9..57ae7f06 100644 --- a/tests/Filter/FilterOptionsResolverTest.php +++ b/tests/Filter/FilterOptionsResolverTest.php @@ -4,11 +4,11 @@ namespace HeimrichHannot\FlareBundle\Tests\Filter; +use HeimrichHannot\FlareBundle\Contract\OptionsInterface; use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; -use HeimrichHannot\FlareBundle\Filter\Element\FilterElementOptionsInterface; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterOptionsResolver; use PHPUnit\Framework\TestCase; @@ -57,7 +57,7 @@ public function testWrapsSchemaViolationsInFilterException(): void } } -final class ElementConfigAwareElement implements FilterElementInterface, FilterElementOptionsInterface +final class ElementConfigAwareElement implements FilterElementInterface, OptionsInterface { public function configureOptions(OptionsResolver $resolver): void { @@ -65,11 +65,6 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('field')->default(null)->allowedTypes('string', 'null'); } - public function configFromRow(array $row): array - { - return ['field' => $row['fieldGeneric'] ?? null]; - } - public function buildForm(FormBuilderInterface $builder, FilterContext $context): void { } diff --git a/tests/Filter/FilterTest.php b/tests/Filter/FilterTest.php index 8b451fba..67d25f75 100644 --- a/tests/Filter/FilterTest.php +++ b/tests/Filter/FilterTest.php @@ -4,17 +4,12 @@ namespace HeimrichHannot\FlareBundle\Tests\Filter; -use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\Filter\FilterBuilder; +use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; -use HeimrichHannot\FlareBundle\Filter\Element\CallbackFilterElement; -use HeimrichHannot\FlareBundle\Filter\Type\AbstractFilterType; -use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; -use HeimrichHannot\FlareBundle\Registry\FilterTypeRegistry; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use PHPUnit\Framework\TestCase; -use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Form\FormBuilderInterface; final class FilterTest extends TestCase { @@ -25,7 +20,7 @@ public function testElementUnionAccessors(): void self::assertSame('flare_bool', $typed->getElementType()); self::assertNull($typed->getElementInstance()); - $instance = new CallbackFilterElement(static function (): void {}); + $instance = $this->createInlineElement(); $inline = new Filter(element: $instance); self::assertNull($inline->getElementType()); @@ -51,63 +46,24 @@ public function testWithersPreserveOtherFields(): void self::assertFalse($filter->targetingForced); } - public function testFromTypeBuildsSingleFilterCall(): void - { - $filter = Filter::fromType(RecordingFilterType::class, ['value' => 'x']); - - $element = $filter->getElementInstance(); - self::assertNotNull($element); - - $builder = new FilterBuilder(new FilterTypeRegistry([new RecordingFilterType()]), 'main'); - $context = $this->createContext($filter); - - $element->buildFilter($builder, $context, []); - - $calls = $builder->all(); - self::assertCount(1, $calls); - self::assertSame(RecordingFilterType::class, $calls[0]->typeClass); - self::assertSame('x', $calls[0]->options['value']); - } - - public function testFromCallbackForcesTargetAlias(): void - { - $filter = Filter::fromCallback(static function (): void {}, targetAlias: 'translation'); - - self::assertSame('translation', $filter->targetAlias); - self::assertTrue($filter->targetingForced); - } - public function testFingerprintRepresentsInlineElementsByClass(): void { - $filter = Filter::fromCallback(static function (): void {}); - - self::assertSame(CallbackFilterElement::class, $filter->fingerprint()['element']); - } + $instance = $this->createInlineElement(); + $filter = new Filter(element: $instance); - private function createContext(Filter $filter): FilterContext - { - return new FilterContext( - list: new ListSpecification('test_list', 'tl_test'), - filter: $filter, - config: $filter->config, - engineContext: new class implements ContextInterface { - public static function getContextType(): string - { - return 'test'; - } - }, - ); - } -} - -final class RecordingFilterType extends AbstractFilterType -{ - public function configureOptions(OptionsResolver $resolver): void - { - $resolver->define('value')->required()->allowedTypes('string'); + self::assertSame($instance::class, $filter->fingerprint()['element']); } - public function buildQuery(FilterQueryBuilder $builder, array $options): void + private function createInlineElement(): FilterElementInterface { + return new class implements FilterElementInterface { + public function buildForm(FormBuilderInterface $builder, FilterContext $context): void + { + } + + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void + { + } + }; } } From 8c7a6584316df573bad2ee9fd225727fe004baa9 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 12:44:04 +0200 Subject: [PATCH 21/71] =?UTF-8?q?feat:=20add=20Lists=20domain=20=E2=80=94?= =?UTF-8?q?=20`ListSpec`=20DTO,=20`ListBuilder`,=20per-type=20list=20optio?= =?UTF-8?q?ns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `Lists\ListSpec`: immutable list DTO (type, dc, filters, canonical config, source) with `with*()` modifiers, `getAutoItemField()` (validated), and a config-based `hash()`. - `ListBuilder` owns the list build lifecycle: type's `BuildListContract::buildList()` hook, `ListBuildEvent` (named dispatch `flare.list.{type}.build`), base + type transformer config assembly, schema resolution via `ListOptionsResolver`. - `BaseListOptions`: framework-owned base schema/translation for tl_flare_list columns, applied unconditionally; `genericPageMeta` replaces the dynamic `eval_generic_page_meta`. - `AbstractListType` now implements `ListTypeInterface`, `OptionsInterface`, and `TransformerContract` (`transformListModel()` override point). - `ListBuilderFactory` replaces `ListSpecificationFactory` (old path still in place until the consumer sweep). --- config/services.yaml | 2 +- src/Contract/ListType/BuildListContract.php | 16 ++ src/Event/ListBuildEvent.php | 20 +++ .../NamedDispatch/ListBuildListener.php | 26 +++ src/ListType/AbstractListType.php | 25 ++- src/ListType/ListTypeInterface.php | 10 ++ src/Lists/BaseListOptions.php | 67 +++++++ src/Lists/Factory/ListBuilderFactory.php | 64 +++++++ src/Lists/ListBuilder.php | 169 ++++++++++++++++++ src/Lists/ListSpec.php | 133 ++++++++++++++ src/Lists/Resolver/ListOptionsResolver.php | 65 +++++++ 11 files changed, 595 insertions(+), 2 deletions(-) create mode 100644 src/Contract/ListType/BuildListContract.php create mode 100644 src/Event/ListBuildEvent.php create mode 100644 src/EventListener/NamedDispatch/ListBuildListener.php create mode 100644 src/ListType/ListTypeInterface.php create mode 100644 src/Lists/BaseListOptions.php create mode 100644 src/Lists/Factory/ListBuilderFactory.php create mode 100644 src/Lists/ListBuilder.php create mode 100644 src/Lists/ListSpec.php create mode 100644 src/Lists/Resolver/ListOptionsResolver.php diff --git a/config/services.yaml b/config/services.yaml index a083118e..d5fe6366 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -11,7 +11,7 @@ services: resource: ../src exclude: - ../src/{Config,Contao,ContaoManager,Contract,DependencyInjection,Dto,Engine,Event,Integration,Model,Trait,Util} - - ../src/{Filter,Form,InferPtable,List,Paginator,Query,Sort,Specification}/*.php + - ../src/{Filter,Form,InferPtable,List,Lists,Paginator,Query,Sort,Specification}/*.php - ../src/DataContainer/Builder - ../src/Registry/Descriptor diff --git a/src/Contract/ListType/BuildListContract.php b/src/Contract/ListType/BuildListContract.php new file mode 100644 index 00000000..c4ecc915 --- /dev/null +++ b/src/Contract/ListType/BuildListContract.php @@ -0,0 +1,16 @@ +builder->getTypeAlias()) { + return; + } + + $this->eventDispatcher->dispatch(event: $event, eventName: "flare.list.{$type}.build"); + } +} diff --git a/src/ListType/AbstractListType.php b/src/ListType/AbstractListType.php index 8d62551b..4267fb81 100644 --- a/src/ListType/AbstractListType.php +++ b/src/ListType/AbstractListType.php @@ -4,12 +4,35 @@ namespace HeimrichHannot\FlareBundle\ListType; +use HeimrichHannot\FlareBundle\Config\ConfigBuilder; +use HeimrichHannot\FlareBundle\Config\TransformerBuilder; use HeimrichHannot\FlareBundle\Contract; +use HeimrichHannot\FlareBundle\Contract\OptionsInterface; +use HeimrichHannot\FlareBundle\Contract\TransformerContract; +use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Query\SqlQueryStruct; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; +use Symfony\Component\OptionsResolver\OptionsResolver; -abstract class AbstractListType implements Contract\ListType\ConfigureQueryContract +abstract class AbstractListType implements + ListTypeInterface, OptionsInterface, TransformerContract, Contract\ListType\ConfigureQueryContract { + /** + * Declares the type's config schema on top of {@see \HeimrichHannot\FlareBundle\Lists\BaseListOptions}. + */ + public function configureOptions(OptionsResolver $resolver): void {} + + public function configureTransformers(TransformerBuilder $transformers): void + { + $transformers->for(ListModel::class, $this->transformListModel(...)); + } + + /** + * Translates a stored tl_flare_list model into the type's canonical config values (unresolved). + * Base columns are already translated by {@see \HeimrichHannot\FlareBundle\Lists\BaseListOptions}. + */ + protected function transformListModel(ListModel $model, ConfigBuilder $config): void {} + public function configureTableRegistry(TableAliasRegistry $registry): void {} public function configureBaseQuery(SqlQueryStruct $struct): void {} diff --git a/src/ListType/ListTypeInterface.php b/src/ListType/ListTypeInterface.php new file mode 100644 index 00000000..57670bc3 --- /dev/null +++ b/src/ListType/ListTypeInterface.php @@ -0,0 +1,10 @@ +define('id')->default(null)->allowedTypes('int', 'null'); + $resolver->define('title')->default('')->allowedTypes('string'); + $resolver->define('published')->default(false)->allowedTypes('bool'); + $resolver->define('jumpToListView')->default(null)->allowedTypes('int', 'null'); + $resolver->define('jumpToReader')->default(null)->allowedTypes('int', 'null'); + $resolver->define('sortSettings')->default([])->allowedTypes('array'); + $resolver->define('metaTitleFormat')->default(null)->allowedTypes('string', 'null'); + $resolver->define('metaDescriptionFormat')->default(null)->allowedTypes('string', 'null'); + $resolver->define('metaRobotsFormat')->default(null)->allowedTypes('string', 'null'); + $resolver->define('fieldAutoItem')->default(null)->allowedTypes('string', 'null'); + $resolver->define('hasParent')->default(false)->allowedTypes('bool'); + $resolver->define('fieldPid')->default(null)->allowedTypes('string', 'null'); + $resolver->define('fieldPtable')->default(null)->allowedTypes('string', 'null'); + $resolver->define('tablePtable')->default(null)->allowedTypes('string', 'null'); + $resolver->define('whichPtable')->default('')->allowedTypes('string'); + $resolver->define('comments_enabled')->default(false)->allowedTypes('bool'); + $resolver->define('comments_sendNativeEmails')->default(false)->allowedTypes('bool'); + $resolver->define('dcMultilingual_display')->default(null)->allowedTypes('string', 'null'); + $resolver->define('genericPageMeta')->default(false)->allowedTypes('bool'); + } + + public static function transform(ListModel $model, ConfigBuilder $config): void + { + $config + ->set('id', $model->id ? (int) $model->id : null) + ->set('title', (string) $model->title) + ->set('published', (bool) $model->published) + ->set('jumpToListView', $model->jumpToListView ? (int) $model->jumpToListView : null) + ->set('jumpToReader', $model->jumpToReader ? (int) $model->jumpToReader : null) + ->set('sortSettings', StringUtil::deserialize($model->sortSettings, true)) + ->set('metaTitleFormat', $model->metaTitleFormat ?: null) + ->set('metaDescriptionFormat', $model->metaDescriptionFormat ?: null) + ->set('metaRobotsFormat', $model->metaRobotsFormat ?: null) + ->set('fieldAutoItem', $model->fieldAutoItem ?: null) + ->set('hasParent', (bool) $model->hasParent) + ->set('fieldPid', $model->fieldPid ?: null) + ->set('fieldPtable', $model->fieldPtable ?: null) + ->set('tablePtable', $model->tablePtable ?: null) + ->set('whichPtable', (string) $model->whichPtable) + ->set('comments_enabled', (bool) $model->comments_enabled) + ->set('comments_sendNativeEmails', (bool) $model->comments_sendNativeEmails) + ->set('dcMultilingual_display', $model->dcMultilingual_display ?: null); + } +} diff --git a/src/Lists/Factory/ListBuilderFactory.php b/src/Lists/Factory/ListBuilderFactory.php new file mode 100644 index 00000000..863c1982 --- /dev/null +++ b/src/Lists/Factory/ListBuilderFactory.php @@ -0,0 +1,64 @@ +listTypeRegistry->get($type)?->getService(); + + return new ListBuilder( + optionsResolver: $this->listOptionsResolver, + eventDispatcher: $this->eventDispatcher, + type: $type, + typeService: $typeService, + dc: $dc, + model: $model, + source: $source, + ); + } + + public function createFromListModel(ListModel $listModel): ListBuilder + { + $builder = $this->create( + type: (string) $listModel->type, + dc: (string) $listModel->dc, + model: $listModel, + source: $listModel::getTable() . '.' . $listModel->id, + ); + + foreach ($this->filterCollector->collect($listModel) ?? [] as $key => $filter) { + $builder->addFilter($filter, (string) $key); + } + + return $builder; + } +} diff --git a/src/Lists/ListBuilder.php b/src/Lists/ListBuilder.php new file mode 100644 index 00000000..15864004 --- /dev/null +++ b/src/Lists/ListBuilder.php @@ -0,0 +1,169 @@ + + */ + private array $filters = []; + + /** + * @var array + */ + private array $overrides = []; + + private int $generatedFilterKeys = 0; + + public function __construct( + private readonly ListOptionsResolver $optionsResolver, + private readonly EventDispatcherInterface $eventDispatcher, + private readonly ListTypeInterface|string $type, + private readonly ?object $typeService, + private readonly string $dc, + private readonly ?ListModel $model = null, + private readonly ?string $source = null, + ) {} + + public function getType(): ListTypeInterface|string + { + return $this->type; + } + + public function getTypeAlias(): ?string + { + return \is_string($this->type) ? $this->type : null; + } + + public function getTypeService(): ?object + { + return $this->typeService; + } + + public function getDc(): string + { + return $this->dc; + } + + public function getModel(): ?ListModel + { + return $this->model; + } + + public function getSource(): ?string + { + return $this->source; + } + + /** + * Sets a canonical config value, overriding base translation and type transformers. + */ + public function set(string $key, mixed $value): self + { + $this->overrides[$key] = $value; + + return $this; + } + + /** + * Adds a filter. The key defaults to the filter's alias; alias-less filters receive a generated key. + */ + public function addFilter(Filter $filter, ?string $key = null): self + { + $key ??= $filter->alias ?? ('_generated_' . $this->generatedFilterKeys++); + $this->filters[$key] = $filter; + + return $this; + } + + public function removeFilter(string $key): self + { + unset($this->filters[$key]); + + return $this; + } + + /** + * @return array + */ + public function getFilters(): array + { + return $this->filters; + } + + public function hasFilterOfType(string $elementType): bool + { + foreach ($this->filters as $filter) + { + if ($filter->getElementType() === $elementType) { + return true; + } + } + + return false; + } + + /** + * @throws FlareException If the resulting config does not satisfy the schema. + */ + public function build(): ListSpec + { + if ($this->typeService instanceof BuildListContract) { + $this->typeService->buildList($this); + } + + $this->eventDispatcher->dispatch(new ListBuildEvent($this)); + + $config = new ConfigBuilder(); + + if ($this->model) + { + BaseListOptions::transform($this->model, $config); + + if ($this->typeService instanceof TransformerContract) + { + $transformers = new TransformerBuilder(); + $this->typeService->configureTransformers($transformers); + + if ($transformer = $transformers->resolve($this->model)) { + $transformer($this->model, $config); + } + } + } + + foreach ($this->overrides as $key => $value) { + $config->set($key, $value); + } + + return new ListSpec( + type: $this->type, + dc: $this->dc, + filters: $this->filters, + config: $this->optionsResolver->resolve($this->typeService, $config->all(), $this->source), + source: $this->source, + ); + } +} diff --git a/src/Lists/ListSpec.php b/src/Lists/ListSpec.php new file mode 100644 index 00000000..8db6b425 --- /dev/null +++ b/src/Lists/ListSpec.php @@ -0,0 +1,133 @@ + $filters + * @param array $config Canonical config, resolved through the base and type schemas. + * @param string|null $source Provenance for error messages, e.g. "tl_flare_list.5". + */ + public function __construct( + public ListTypeInterface|string $type, + public string $dc, + public array $filters = [], + public array $config = [], + public ?string $source = null, + ) {} + + public function getTypeAlias(): ?string + { + return \is_string($this->type) ? $this->type : null; + } + + public function getTypeInstance(): ?ListTypeInterface + { + return $this->type instanceof ListTypeInterface ? $this->type : null; + } + + /** + * Adds a filter. The key defaults to the filter's alias; alias-less filters receive a generated key. + */ + public function withFilter(Filter $filter, ?string $key = null): self + { + if (null === ($key ??= $filter->alias)) + { + $index = 0; + + while (isset($this->filters["_generated_{$index}"])) { + $index++; + } + + $key = "_generated_{$index}"; + } + + return $this->withFilters([...$this->filters, $key => $filter]); + } + + public function withoutFilter(string $key): self + { + $filters = $this->filters; + unset($filters[$key]); + + return $this->withFilters($filters); + } + + /** + * @param array $filters + */ + public function withFilters(array $filters): self + { + return new self( + type: $this->type, + dc: $this->dc, + filters: $filters, + config: $this->config, + source: $this->source, + ); + } + + /** + * @param array $config + */ + public function withConfig(array $config): self + { + return new self( + type: $this->type, + dc: $this->dc, + filters: $this->filters, + config: $config, + source: $this->source, + ); + } + + public function hasFilterOfType(string $elementType): bool + { + foreach ($this->filters as $filter) + { + if ($filter->getElementType() === $elementType) { + return true; + } + } + + return false; + } + + public function getAutoItemField(): string + { + return DcaHelper::tryGetColumnName( + $this->dc, + (string) ($this->config['fieldAutoItem'] ?? ''), + DcaHelper::tryGetColumnName($this->dc, 'alias', 'id'), + ); + } + + public function hash(): string + { + return \sha1(\serialize([ + $this->getTypeAlias() ?? $this->type::class, + $this->dc, + $this->source, + $this->config, + \array_map(static fn (Filter $filter): array => $filter->fingerprint(), $this->filters), + ])); + } +} diff --git a/src/Lists/Resolver/ListOptionsResolver.php b/src/Lists/Resolver/ListOptionsResolver.php new file mode 100644 index 00000000..a40970e9 --- /dev/null +++ b/src/Lists/Resolver/ListOptionsResolver.php @@ -0,0 +1,65 @@ + Keyed by type class; '' for type-less lists. + */ + private array $resolvers = []; + + /** + * @param array $config + * + * @return array + * + * @throws FlareException If the config does not satisfy the schema. + */ + public function resolve(?object $typeService, array $config, ?string $source = null): array + { + $key = $typeService ? $typeService::class : ''; + + if (!isset($this->resolvers[$key])) + { + $resolver = new OptionsResolver(); + BaseListOptions::configureOptions($resolver); + + if ($typeService instanceof OptionsInterface) { + $typeService->configureOptions($resolver); + } + + $this->resolvers[$key] = $resolver; + } + + try + { + return $this->resolvers[$key]->resolve($config); + } + catch (\Throwable $e) + { + throw new FlareException( + \sprintf( + '[FLARE] Invalid list config%s: %s', + $typeService ? ' for list type "' . $typeService::class . '"' : '', + $e->getMessage(), + ), + previous: $e, + method: ($typeService ? $typeService::class : BaseListOptions::class) . '::configureOptions', + source: $source, + ); + } + } +} From b3e6f413d0cb5c06e5f5f7fb78c8df4b60519d59 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 12:55:56 +0200 Subject: [PATCH 22/71] refactor: replace `ListSpecification` with immutable `ListSpec` built by `ListBuilder` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - All consumers now use `Lists\ListSpec`; construction goes through `ListBuilderFactory::createFromListModel()->build()` (controllers, reader attribute factory, backend DCA path, breadcrumb listener, filter field options callbacks). - Dynamic properties are gone: `src/Specification/` deleted entirely (row-dump bag, `#[\AllowDynamicProperties]`, `AutoItemFieldGetterTrait`, `ListDataSourceInterface`, `ListSpecificationFactory`). Consumers read the canonical `ListSpec::$config` (page meta, comments, ptable inference, context factories, sort factory, reader attribute marshalling). - List types own their lifecycle: News/Events add their published filter in `buildList()` instead of `ListSpecificationCreatedEvent` listeners (event deleted, `ListBuildEvent` + `flare.list.{type}.build` replace it); Generic/DcMultilingual set `genericPageMeta` in `transformListModel()` (replaces `EnableGenericPageMetaListener` and the DcMultilingual listener, whose `isPageMetaGeneric` key was never read — this fixes DcMultilingual generic page meta). - `ListSpec` is fully immutable: `Engine::setList()` added, `Engine::__clone` no longer clones the list, `ValidationLoader`/`SimpleEquationMod` use `withFilter()`. - `FilterCollectorInterface`/`FilterCollectorRegistry` deleted; `ListModelFilterCollector` stays as the concrete collector consumed by `ListBuilderFactory`. - `ListExecutionContextFactory` supports inline list type instances. - `getAutoItemField()` now always validates against the DCA (was unvalidated in the trait variant used by ChangelanguageListener). --- config/services.yaml | 2 +- .../ContentElement/ListViewController.php | 10 +- .../ContentElement/ReaderController.php | 12 +-- .../Factory/InteractiveContextFactory.php | 19 ++-- .../Factory/ValidationContextFactory.php | 17 ++-- src/Engine/Engine.php | 18 ++-- src/Engine/Factory/EngineFactory.php | 6 +- src/Engine/Loader/AggregationLoaderConfig.php | 4 +- src/Engine/Loader/InteractiveLoaderConfig.php | 4 +- src/Engine/Loader/ValidationLoader.php | 14 +-- src/Engine/Loader/ValidationLoaderConfig.php | 4 +- src/Engine/Mod/SimpleEquationMod.php | 2 +- src/Engine/Projector/AbstractProjector.php | 10 +- src/Engine/Projector/AggregationProjector.php | 6 +- src/Engine/Projector/ExportProjector.php | 6 +- src/Engine/Projector/InteractiveProjector.php | 14 +-- src/Engine/Projector/ProjectorInterface.php | 10 +- src/Engine/Projector/ValidationProjector.php | 6 +- src/Event/FilterFormBuildEvent.php | 4 +- src/Event/ListSpecificationCreatedEvent.php | 15 --- src/Event/QueryBaseInitializedEvent.php | 4 +- src/Event/ReaderPageMetaEvent.php | 8 +- src/Event/ReaderRenderEvent.php | 8 +- src/Event/ReaderSchemaOrgEvent.php | 4 +- .../Contao/BreadcrumbListener.php | 10 +- .../Contao/ElementDcaListener.php | 6 +- .../FlareFilter/FieldsOptionsCallbacks.php | 12 +-- .../ListSpecificationListener.php | 24 ----- .../Reader/EnableGenericPageMetaListener.php | 23 ----- .../Reader/GenericReaderPageMetaListener.php | 28 ++++-- .../Collector/FilterCollectorInterface.php | 20 ---- .../Collector/ListModelFilterCollector.php | 27 +++--- src/Filter/Element/ArchiveFilterElement.php | 14 +-- .../BelongsToRelationFilterElement.php | 2 +- src/Filter/Factory/FilterContextFactory.php | 4 +- src/Filter/FilterContext.php | 14 +-- src/Form/Factory/FilterFormFactory.php | 8 +- .../Factory/PtableInferrableFactory.php | 50 +++------- .../RegisterTagsTablesListener.php | 2 +- .../CodefogTagsChoiceFilterElement.php | 6 +- .../ListType/EventsListType.php | 19 ++-- .../Projector/EventsAggregationProjector.php | 8 +- .../Projector/EventsInteractiveProjector.php | 8 +- .../EventListener/ContaoCommentsListener.php | 6 +- .../EventListener/ChangelanguageListener.php | 4 +- ...ingualListSpecificationCreatedListener.php | 22 ----- .../ListType/DcMultilingualListType.php | 9 +- src/ListType/GenericDataContainerListType.php | 7 ++ src/ListType/NewsListType.php | 19 ++-- src/Model/DocumentsListModelTrait.php | 2 +- src/Model/ListModel.php | 32 +------ src/Query/Executor/FilterExecutor.php | 4 +- .../Factory/ListExecutionContextFactory.php | 27 ++++-- src/Query/ListQueryConfig.php | 4 +- .../Factory/ReaderRequestAttributeFactory.php | 6 +- src/Reader/ReaderRequestAttribute.php | 15 ++- src/Registry/FilterCollectorRegistry.php | 51 ---------- src/Registry/ProjectorRegistry.php | 4 +- src/Sort/Factory/SortOrderSequenceFactory.php | 11 +-- .../AutoItemFieldGetterTrait.php | 15 --- .../DataSource/ListDataSourceInterface.php | 18 ---- src/Specification/DynamicPropertiesTrait.php | 55 ----------- .../Factory/ListSpecificationFactory.php | 51 ---------- src/Specification/ListSpecification.php | 96 ------------------- src/Twig/Runtime/FlareRuntime.php | 6 +- tests/Specification/ListSpecificationTest.php | 69 ------------- 66 files changed, 261 insertions(+), 764 deletions(-) delete mode 100644 src/Event/ListSpecificationCreatedEvent.php delete mode 100644 src/EventListener/NamedDispatch/ListSpecificationListener.php delete mode 100644 src/EventListener/Reader/EnableGenericPageMetaListener.php delete mode 100644 src/Filter/Collector/FilterCollectorInterface.php delete mode 100644 src/Integration/Terminal42Languages/EventListener/DcMultilingualListSpecificationCreatedListener.php delete mode 100644 src/Registry/FilterCollectorRegistry.php delete mode 100644 src/Specification/AutoItemFieldGetterTrait.php delete mode 100644 src/Specification/DataSource/ListDataSourceInterface.php delete mode 100644 src/Specification/DynamicPropertiesTrait.php delete mode 100644 src/Specification/Factory/ListSpecificationFactory.php delete mode 100644 src/Specification/ListSpecification.php delete mode 100644 tests/Specification/ListSpecificationTest.php diff --git a/config/services.yaml b/config/services.yaml index d5fe6366..01ada175 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -11,7 +11,7 @@ services: resource: ../src exclude: - ../src/{Config,Contao,ContaoManager,Contract,DependencyInjection,Dto,Engine,Event,Integration,Model,Trait,Util} - - ../src/{Filter,Form,InferPtable,List,Lists,Paginator,Query,Sort,Specification}/*.php + - ../src/{Filter,Form,InferPtable,List,Lists,Paginator,Query,Sort}/*.php - ../src/DataContainer/Builder - ../src/Registry/Descriptor diff --git a/src/Controller/ContentElement/ListViewController.php b/src/Controller/ContentElement/ListViewController.php index 319deb4f..356d4753 100644 --- a/src/Controller/ContentElement/ListViewController.php +++ b/src/Controller/ContentElement/ListViewController.php @@ -20,7 +20,7 @@ use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Model\ListModel; -use HeimrichHannot\FlareBundle\Specification\Factory\ListSpecificationFactory; +use HeimrichHannot\FlareBundle\Lists\Factory\ListBuilderFactory; use HeimrichHannot\FlareBundle\Util\Str; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; @@ -41,7 +41,7 @@ public function __construct( private readonly EventDispatcherInterface $eventDispatcher, private readonly InteractiveContextFactory $interactiveConfigFactory, private readonly KernelInterface $kernel, - private readonly ListSpecificationFactory $listSpecificationFactory, + private readonly ListBuilderFactory $listFactory, private readonly LoggerInterface $logger, private readonly ScopeMatcher $scopeMatcher, private readonly SymfonyResponseTagger $responseTagger, @@ -95,13 +95,13 @@ protected function getFrontendResponse(Template $template, ContentModel $content try { + $listSpec = $this->listFactory->createFromListModel($listModel)->build(); + $interactiveConfig = $this->interactiveConfigFactory->createFromContent( contentModel: $contentModel, - listModel: $listModel, + list: $listSpec, ); - $listSpec = $this->listSpecificationFactory->create(dataSource: $listModel); - $engine = $this->engineFactory->createEngine($interactiveConfig, $listSpec); } catch (ValidationFailedException $e) diff --git a/src/Controller/ContentElement/ReaderController.php b/src/Controller/ContentElement/ReaderController.php index ebf4e9d5..abf9d99e 100644 --- a/src/Controller/ContentElement/ReaderController.php +++ b/src/Controller/ContentElement/ReaderController.php @@ -28,7 +28,7 @@ use HeimrichHannot\FlareBundle\Reader\Resolver\ReaderRequestAttributeResolver; use HeimrichHannot\FlareBundle\Reader\ReaderPageMeta; use HeimrichHannot\FlareBundle\Reader\ReaderRequestAttribute; -use HeimrichHannot\FlareBundle\Specification\Factory\ListSpecificationFactory; +use HeimrichHannot\FlareBundle\Lists\Factory\ListBuilderFactory; use HeimrichHannot\FlareBundle\Util\Str; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; @@ -48,7 +48,7 @@ public function __construct( private readonly EngineFactory $engineFactory, private readonly EntityCacheTags $entityCacheTags, private readonly KernelInterface $kernel, - private readonly ListSpecificationFactory $listSpecificationFactory, + private readonly ListBuilderFactory $listFactory, private readonly LoggerInterface $logger, private readonly ReaderRequestAttributeResolver $attributeResolver, private readonly ResponseContextAccessor $responseContextAccessor, @@ -112,11 +112,11 @@ protected function getFrontendResponse(Template $template, ContentModel $content try { - $listSpec = $this->listSpecificationFactory->create(dataSource: $listModel); + $listSpec = $this->listFactory->createFromListModel($listModel)->build(); $validationContext = $this->validationContextFactory->createFromContent( contentModel: $contentModel, - listModel: $listModel + list: $listSpec, ); $engine = $this->engineFactory->createEngine($validationContext, $listSpec); @@ -140,7 +140,7 @@ protected function getFrontendResponse(Template $template, ContentModel $content $pageMetaEvent = $this->eventDispatcher->dispatch(new ReaderPageMetaEvent( contentModel: $contentModel, displayModel: $autoItemModel, - listSpecification: $listSpec, + list: $listSpec, )); $pageMeta = $pageMetaEvent->getPageMeta(); } @@ -157,7 +157,7 @@ protected function getFrontendResponse(Template $template, ContentModel $content contentModel: $contentModel, context: $validationContext, displayModel: $autoItemModel, - listSpecification: $listSpec, + list: $listSpec, pageMeta: $pageMeta, template: $template, ) diff --git a/src/Engine/Context/Factory/InteractiveContextFactory.php b/src/Engine/Context/Factory/InteractiveContextFactory.php index a4238815..74d459ea 100644 --- a/src/Engine/Context/Factory/InteractiveContextFactory.php +++ b/src/Engine/Context/Factory/InteractiveContextFactory.php @@ -7,10 +7,9 @@ use Contao\ContentModel; use HeimrichHannot\FlareBundle\DataContainer\ContentContainer; use HeimrichHannot\FlareBundle\Engine\Context\InteractiveContext; -use HeimrichHannot\FlareBundle\Model\ListModel; +use HeimrichHannot\FlareBundle\Lists\ListSpec; use HeimrichHannot\FlareBundle\Paginator\PaginatorConfig; use HeimrichHannot\FlareBundle\Sort\Factory\SortOrderSequenceFactory; -use HeimrichHannot\FlareBundle\Util\DcaHelper; use Symfony\Component\Validator\Exception\ValidationFailedException; use Symfony\Component\Validator\Validator\ValidatorInterface; @@ -21,23 +20,21 @@ public function __construct( private ValidatorInterface $validator, ) {} - public function createFromContent(ContentModel $contentModel, ListModel $listModel): InteractiveContext + public function createFromContent(ContentModel $contentModel, ListSpec $list): InteractiveContext { - $filterFormName = $contentModel->{ContentContainer::FIELD_FORM_NAME} ?: ('fl' . $listModel->id); + $filterFormName = $contentModel->{ContentContainer::FIELD_FORM_NAME} + ?: ('fl' . ($list->config['id'] ?? '')); $paginatorConfig = new PaginatorConfig( itemsPerPage: (int) ($contentModel->{ContentContainer::FIELD_ITEMS_PER_PAGE} ?: 0), ); - $sortOrderSequence = $this->sortOrderSequenceFactory->createFromListModel($listModel); + $sortOrderSequence = $this->sortOrderSequenceFactory->createFromList($list); - $jumpToReaderPageId = (int) ($contentModel->{ContentContainer::FIELD_JUMP_TO_READER} ?: $listModel->jumpToReader); + $jumpToReaderPageId = (int) ($contentModel->{ContentContainer::FIELD_JUMP_TO_READER} + ?: ($list->config['jumpToReader'] ?? 0)); - $fieldAutoItem = DcaHelper::tryGetColumnName( - $listModel->dc, - $listModel->fieldAutoItem, - DcaHelper::tryGetColumnName($listModel->dc, 'alias', 'id') - ); + $fieldAutoItem = $list->getAutoItemField(); $config = new InteractiveContext( paginatorConfig: $paginatorConfig, diff --git a/src/Engine/Context/Factory/ValidationContextFactory.php b/src/Engine/Context/Factory/ValidationContextFactory.php index b5dda3b4..ec018942 100644 --- a/src/Engine/Context/Factory/ValidationContextFactory.php +++ b/src/Engine/Context/Factory/ValidationContextFactory.php @@ -8,8 +8,7 @@ use HeimrichHannot\FlareBundle\DataContainer\ContentContainer; use HeimrichHannot\FlareBundle\Engine\Context\ValidationContext; use HeimrichHannot\FlareBundle\Engine\View\InteractiveView; -use HeimrichHannot\FlareBundle\Model\ListModel; -use HeimrichHannot\FlareBundle\Util\DcaHelper; +use HeimrichHannot\FlareBundle\Lists\ListSpec; use Symfony\Component\Validator\Exception\ValidationFailedException; use Symfony\Component\Validator\Validator\ValidatorInterface; @@ -19,16 +18,14 @@ public function __construct( private ValidatorInterface $validator, ) {} - public function createFromContent(ContentModel $contentModel, ListModel $listModel): ValidationContext + public function createFromContent(ContentModel $contentModel, ListSpec $list): ValidationContext { - $jumpToReaderPageId = (int) ($contentModel->{ContentContainer::FIELD_JUMP_TO_READER} ?: $listModel->jumpToReader); - $jumpToListViewPageId = (int) ($contentModel->{ContentContainer::FIELD_JUMP_TO_LISTVIEW} ?: $listModel->jumpToListView); + $jumpToReaderPageId = (int) ($contentModel->{ContentContainer::FIELD_JUMP_TO_READER} + ?: ($list->config['jumpToReader'] ?? 0)); + $jumpToListViewPageId = (int) ($contentModel->{ContentContainer::FIELD_JUMP_TO_LISTVIEW} + ?: ($list->config['jumpToListView'] ?? 0)); - $fieldAutoItem = DcaHelper::tryGetColumnName( - $listModel->dc, - $listModel->fieldAutoItem, - DcaHelper::tryGetColumnName($listModel->dc, 'alias', 'id') - ); + $fieldAutoItem = $list->getAutoItemField(); $config = new ValidationContext( jumpToReaderPageId: $jumpToReaderPageId, diff --git a/src/Engine/Engine.php b/src/Engine/Engine.php index 9641b6d1..41e2c6a0 100644 --- a/src/Engine/Engine.php +++ b/src/Engine/Engine.php @@ -9,7 +9,7 @@ use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Registry\EngineModRegistry; use HeimrichHannot\FlareBundle\Registry\ProjectorRegistry; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Lists\ListSpec; final class Engine { @@ -17,7 +17,7 @@ public function __construct( private readonly EngineModRegistry $engineModRegistry, private readonly ProjectorRegistry $projectorRegistry, private ContextInterface $context, - private ListSpecification $list, + private ListSpec $list, private array $mods = [], ) {} @@ -26,11 +26,18 @@ public function getContext(): ContextInterface return $this->context; } - public function getList(): ListSpecification + public function getList(): ListSpec { return $this->list; } + public function setList(ListSpec $list): self + { + $this->list = $list; + + return $this; + } + /** * @throws FlareException */ @@ -94,13 +101,13 @@ public function clearMods(): self return $this; } - public function with(?ContextInterface $context = null, ?ListSpecification $list = null, ?array $mods = null): self + public function with(?ContextInterface $context = null, ?ListSpec $list = null, ?array $mods = null): self { return new self( engineModRegistry: $this->engineModRegistry, projectorRegistry: $this->projectorRegistry, context: $context ?? clone $this->context, - list: $list ?? clone $this->list, + list: $list ?? $this->list, mods: $mods ?? $this->mods, ); } @@ -113,6 +120,5 @@ public function clone(): self public function __clone(): void { $this->context = clone $this->context; - $this->list = clone $this->list; } } \ No newline at end of file diff --git a/src/Engine/Factory/EngineFactory.php b/src/Engine/Factory/EngineFactory.php index 9bc586d2..6cec495f 100644 --- a/src/Engine/Factory/EngineFactory.php +++ b/src/Engine/Factory/EngineFactory.php @@ -8,7 +8,7 @@ use HeimrichHannot\FlareBundle\Engine\Engine; use HeimrichHannot\FlareBundle\Registry\EngineModRegistry; use HeimrichHannot\FlareBundle\Registry\ProjectorRegistry; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Lists\ListSpec; final readonly class EngineFactory { @@ -19,14 +19,14 @@ public function __construct( public function createEngine( ContextInterface $context, - ListSpecification $listSpecification, + ListSpec $list, array $mods = [], ): Engine { return new Engine( engineModRegistry: $this->engineModRegistry, projectorRegistry: $this->projectorRegistry, context: $context, - list: $listSpecification, + list: $list, mods: $mods, ); } diff --git a/src/Engine/Loader/AggregationLoaderConfig.php b/src/Engine/Loader/AggregationLoaderConfig.php index ad884679..b490c6b0 100644 --- a/src/Engine/Loader/AggregationLoaderConfig.php +++ b/src/Engine/Loader/AggregationLoaderConfig.php @@ -5,12 +5,12 @@ namespace HeimrichHannot\FlareBundle\Engine\Loader; use HeimrichHannot\FlareBundle\Engine\Context\AggregationContext; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Lists\ListSpec; readonly class AggregationLoaderConfig { public function __construct( - public ListSpecification $list, + public ListSpec $list, public AggregationContext $context, public array $filterValues, ) {} diff --git a/src/Engine/Loader/InteractiveLoaderConfig.php b/src/Engine/Loader/InteractiveLoaderConfig.php index 81eb5306..e40fc3b3 100644 --- a/src/Engine/Loader/InteractiveLoaderConfig.php +++ b/src/Engine/Loader/InteractiveLoaderConfig.php @@ -5,12 +5,12 @@ namespace HeimrichHannot\FlareBundle\Engine\Loader; use HeimrichHannot\FlareBundle\Engine\Context\InteractiveContext; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Lists\ListSpec; readonly class InteractiveLoaderConfig { public function __construct( - public ListSpecification $list, + public ListSpec $list, public InteractiveContext $context, public array $filterValues, ) {} diff --git a/src/Engine/Loader/ValidationLoader.php b/src/Engine/Loader/ValidationLoader.php index f55a0317..32305c1c 100644 --- a/src/Engine/Loader/ValidationLoader.php +++ b/src/Engine/Loader/ValidationLoader.php @@ -11,7 +11,7 @@ use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Query\Executor\ListQueryDirector; use HeimrichHannot\FlareBundle\Query\ListQueryConfig; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Lists\ListSpec; readonly class ValidationLoader implements ValidationLoaderInterface { @@ -33,9 +33,6 @@ public function fetchEntryById(int $id): ?array try { - // IMPORTANT: clone the spec to not modify the original, i.e., when adding the id filter - $list = clone $this->config->list; - $idDefinition = new Filter( element: SimpleEquationFilterElement::TYPE, config: [ @@ -46,7 +43,7 @@ public function fetchEntryById(int $id): ?array ], ); - $list->addFilter($idDefinition); + $list = $this->config->list->withFilter($idDefinition); return $this->executeQuery($list, $this->config->context); } @@ -71,9 +68,6 @@ public function fetchEntryByAutoItem(string $autoItem): ?array try { - // IMPORTANT: clone the spec to not modify the original - $list = clone $this->config->list; - $autoItemDefinition = new Filter( element: SimpleEquationFilterElement::TYPE, config: [ @@ -84,7 +78,7 @@ public function fetchEntryByAutoItem(string $autoItem): ?array ], ); - $list->addFilter($autoItemDefinition); + $list = $this->config->list->withFilter($autoItemDefinition); return $this->executeQuery($list, $this->config->context); } @@ -101,7 +95,7 @@ public function fetchEntryByAutoItem(string $autoItem): ?array /** * @throws \Exception */ - private function executeQuery(ListSpecification $spec, ValidationContext $context): ?array + private function executeQuery(ListSpec $spec, ValidationContext $context): ?array { $qb = $this->listQueryDirector->createQueryBuilder(new ListQueryConfig( list: $spec, diff --git a/src/Engine/Loader/ValidationLoaderConfig.php b/src/Engine/Loader/ValidationLoaderConfig.php index 62cc9085..b79138d2 100644 --- a/src/Engine/Loader/ValidationLoaderConfig.php +++ b/src/Engine/Loader/ValidationLoaderConfig.php @@ -5,12 +5,12 @@ namespace HeimrichHannot\FlareBundle\Engine\Loader; use HeimrichHannot\FlareBundle\Engine\Context\ValidationContext; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Lists\ListSpec; readonly class ValidationLoaderConfig { public function __construct( - public ListSpecification $list, + public ListSpec $list, public ValidationContext $context, public string $autoItemField, ) {} diff --git a/src/Engine/Mod/SimpleEquationMod.php b/src/Engine/Mod/SimpleEquationMod.php index 5986149c..cd664802 100644 --- a/src/Engine/Mod/SimpleEquationMod.php +++ b/src/Engine/Mod/SimpleEquationMod.php @@ -29,7 +29,7 @@ public function __invoke(Engine $engine, array $options): void ], ); - $engine->getList()->addFilter($filter, $options['name'] ?: null); + $engine->setList($engine->getList()->withFilter($filter, $options['name'] ?: null)); } public function configureOptions(OptionsResolver $resolver): void diff --git a/src/Engine/Projector/AbstractProjector.php b/src/Engine/Projector/AbstractProjector.php index 8363ee60..04a465cc 100644 --- a/src/Engine/Projector/AbstractProjector.php +++ b/src/Engine/Projector/AbstractProjector.php @@ -13,7 +13,7 @@ use HeimrichHannot\FlareBundle\Query\ListQueryConfig; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; use HeimrichHannot\FlareBundle\Registry\ProjectorRegistry; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Lists\ListSpec; use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; @@ -45,14 +45,14 @@ public static function getSubscribedServices(): array /** * {@inheritdoc} */ - abstract public function supports(ListSpecification $list, ContextInterface $context): bool; + abstract public function supports(ListSpec $list, ContextInterface $context): bool; /** * {@inheritdoc} * * The default priority is 0, but can be overriden by subclasses. */ - public function priority(ListSpecification $list, ContextInterface $context): int + public function priority(ListSpec $list, ContextInterface $context): int { return 0; } @@ -62,7 +62,7 @@ public function priority(ListSpecification $list, ContextInterface $context): in * * @throws FlareException Thrown if the projector does not support the provided list context and configuration. */ - abstract public function project(ListSpecification $list, ContextInterface $context): ViewInterface; + abstract public function project(ListSpec $list, ContextInterface $context): ViewInterface; protected function getFilterElementRegistry(): FilterElementRegistry { @@ -78,7 +78,7 @@ protected function getListQueryDirector(): ListQueryDirector * @throws FlareException */ protected function getProjectorFor( - ListSpecification $spec, + ListSpec $spec, ContextInterface $config, ?array $exclude = null, ): ProjectorInterface { diff --git a/src/Engine/Projector/AggregationProjector.php b/src/Engine/Projector/AggregationProjector.php index d75defd1..887bd4cb 100644 --- a/src/Engine/Projector/AggregationProjector.php +++ b/src/Engine/Projector/AggregationProjector.php @@ -10,7 +10,7 @@ use HeimrichHannot\FlareBundle\Engine\Loader\AggregationLoaderConfig; use HeimrichHannot\FlareBundle\Engine\Loader\AggregationLoaderInterface; use HeimrichHannot\FlareBundle\Engine\View\AggregationView; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Lists\ListSpec; /** * @implements ProjectorInterface @@ -21,12 +21,12 @@ public function __construct( private readonly LoaderFactory $loaderFactory, ) {} - public function supports(ListSpecification $list, ContextInterface $context): bool + public function supports(ListSpec $list, ContextInterface $context): bool { return $context instanceof AggregationContext; } - public function project(ListSpecification $list, ContextInterface $context): AggregationView + public function project(ListSpec $list, ContextInterface $context): AggregationView { \assert($context instanceof AggregationContext, '$config must be an instance of AggregationConfig'); diff --git a/src/Engine/Projector/ExportProjector.php b/src/Engine/Projector/ExportProjector.php index b4ae7b64..def93cf5 100644 --- a/src/Engine/Projector/ExportProjector.php +++ b/src/Engine/Projector/ExportProjector.php @@ -7,19 +7,19 @@ use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; use HeimrichHannot\FlareBundle\Engine\View\ExportView; use HeimrichHannot\FlareBundle\Engine\View\ViewInterface; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Lists\ListSpec; /** * @implements ProjectorInterface */ class ExportProjector extends AbstractProjector { - public function supports(ListSpecification $list, ContextInterface $context): bool + public function supports(ListSpec $list, ContextInterface $context): bool { return false; } - public function project(ListSpecification $list, ContextInterface $context): ViewInterface + public function project(ListSpec $list, ContextInterface $context): ViewInterface { throw new \RuntimeException('Not implemented.'); } diff --git a/src/Engine/Projector/InteractiveProjector.php b/src/Engine/Projector/InteractiveProjector.php index ba2e30e9..1efd5d78 100644 --- a/src/Engine/Projector/InteractiveProjector.php +++ b/src/Engine/Projector/InteractiveProjector.php @@ -20,7 +20,7 @@ use HeimrichHannot\FlareBundle\Paginator\Paginator; use HeimrichHannot\FlareBundle\Reader\Factory\ReaderUrlGeneratorFactory; use HeimrichHannot\FlareBundle\Reader\ReaderUrlGeneratorInterface; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Lists\ListSpec; use Symfony\Component\Form\FormInterface; /** @@ -36,12 +36,12 @@ public function __construct( private readonly ReaderUrlGeneratorFactory $readerUrlGeneratorFactory, ) {} - public function supports(ListSpecification $list, ContextInterface $context): bool + public function supports(ListSpec $list, ContextInterface $context): bool { return $context instanceof InteractiveContext; } - public function project(ListSpecification $list, ContextInterface $context): InteractiveView + public function project(ListSpec $list, ContextInterface $context): InteractiveView { \assert($context instanceof InteractiveContext, '$config must be an instance of InteractiveConfig'); @@ -109,7 +109,7 @@ protected function createView( /** * @throws FlareException */ - public function createForm(ListSpecification $list, InteractiveContext $context): FormInterface + public function createForm(ListSpec $list, InteractiveContext $context): FormInterface { $form = $this->filterFormFactory->create($list, $context); $form->handleRequest($this->getCurrentRequest()); @@ -123,11 +123,11 @@ public function createForm(ListSpecification $list, InteractiveContext $context) * * @return array> */ - protected function collectFilterData(ListSpecification $list, FormInterface $form): array + protected function collectFilterData(ListSpec $list, FormInterface $form): array { $data = []; - foreach ($list->getFilters() as $key => $filter) + foreach ($list->filters as $key => $filter) { if (!$filter->alias || !$form->has($filter->alias)) { continue; @@ -143,7 +143,7 @@ protected function collectFilterData(ListSpecification $list, FormInterface $for * @throws FlareException */ protected function createAggregationView( - ListSpecification $spec, + ListSpec $spec, InteractiveContext $interactiveConfig, array $filterValues, ): AggregationView { diff --git a/src/Engine/Projector/ProjectorInterface.php b/src/Engine/Projector/ProjectorInterface.php index 6ac2b8e1..8e1fd45e 100644 --- a/src/Engine/Projector/ProjectorInterface.php +++ b/src/Engine/Projector/ProjectorInterface.php @@ -6,7 +6,7 @@ use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; use HeimrichHannot\FlareBundle\Engine\View\ViewInterface; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Lists\ListSpec; use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; /** @@ -19,19 +19,19 @@ interface ProjectorInterface /** * Checks if this projector supports the given context configuration. */ - public function supports(ListSpecification $list, ContextInterface $context): bool; + public function supports(ListSpec $list, ContextInterface $context): bool; /** * Calculates the priority of the projector when supported, considering the given specification. */ - public function priority(ListSpecification $list, ContextInterface $context): int; + public function priority(ListSpec $list, ContextInterface $context): int; /** * Projects a list specification into a result based on the context config. * - * @param ListSpecification $list + * @param ListSpec $list * @param ContextInterface $context * @return ViewInterface */ - public function project(ListSpecification $list, ContextInterface $context): ViewInterface; + public function project(ListSpec $list, ContextInterface $context): ViewInterface; } \ No newline at end of file diff --git a/src/Engine/Projector/ValidationProjector.php b/src/Engine/Projector/ValidationProjector.php index 50f72808..db1e967d 100644 --- a/src/Engine/Projector/ValidationProjector.php +++ b/src/Engine/Projector/ValidationProjector.php @@ -13,7 +13,7 @@ use HeimrichHannot\FlareBundle\Reader\BackLink; use HeimrichHannot\FlareBundle\Reader\Factory\ReaderUrlGeneratorFactory; use HeimrichHannot\FlareBundle\Reader\ReaderUrlGeneratorInterface; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Lists\ListSpec; /** * @implements ProjectorInterface @@ -25,12 +25,12 @@ public function __construct( private readonly ReaderUrlGeneratorFactory $readerUrlGeneratorFactory, ) {} - public function supports(ListSpecification $list, ContextInterface $context): bool + public function supports(ListSpec $list, ContextInterface $context): bool { return $context instanceof ValidationContext; } - public function project(ListSpecification $list, ContextInterface $context): ValidationView + public function project(ListSpec $list, ContextInterface $context): ValidationView { \assert($context instanceof ValidationContext, '$config must be an instance of ValidationConfig'); diff --git a/src/Event/FilterFormBuildEvent.php b/src/Event/FilterFormBuildEvent.php index 0c13d470..33e1ec8f 100644 --- a/src/Event/FilterFormBuildEvent.php +++ b/src/Event/FilterFormBuildEvent.php @@ -4,14 +4,14 @@ namespace HeimrichHannot\FlareBundle\Event; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Lists\ListSpec; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Contracts\EventDispatcher\Event; class FilterFormBuildEvent extends Event { public function __construct( - public readonly ListSpecification $listSpecification, + public readonly ListSpec $list, public readonly string $formName, public FormBuilderInterface $formBuilder, ) {} diff --git a/src/Event/ListSpecificationCreatedEvent.php b/src/Event/ListSpecificationCreatedEvent.php deleted file mode 100644 index 7438fde5..00000000 --- a/src/Event/ListSpecificationCreatedEvent.php +++ /dev/null @@ -1,15 +0,0 @@ -pageMeta = $pageMeta ?? new ReaderPageMeta(); @@ -32,9 +32,9 @@ public function getDisplayModel(): Model return $this->displayModel; } - public function getListSpecification(): ListSpecification + public function getList(): ListSpec { - return $this->listSpecification; + return $this->list; } public function getPageMeta(): ReaderPageMeta diff --git a/src/Event/ReaderRenderEvent.php b/src/Event/ReaderRenderEvent.php index 1513d5a5..0ddcfb65 100644 --- a/src/Event/ReaderRenderEvent.php +++ b/src/Event/ReaderRenderEvent.php @@ -9,7 +9,7 @@ use Contao\Template; use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; use HeimrichHannot\FlareBundle\Reader\ReaderPageMeta; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Lists\ListSpec; use Symfony\Contracts\EventDispatcher\Event; class ReaderRenderEvent extends Event @@ -20,7 +20,7 @@ public function __construct( private readonly ContentModel $contentModel, private readonly ContextInterface $context, private readonly Model $displayModel, - private readonly ListSpecification $listSpecification, + private readonly ListSpec $list, private ReaderPageMeta $pageMeta, private Template $template, ) {} @@ -40,9 +40,9 @@ public function getDisplayModel(): Model return $this->displayModel; } - public function getListSpecification(): ListSpecification + public function getList(): ListSpec { - return $this->listSpecification; + return $this->list; } public function getPageMeta(): ReaderPageMeta diff --git a/src/Event/ReaderSchemaOrgEvent.php b/src/Event/ReaderSchemaOrgEvent.php index 9a2a8b98..41975099 100644 --- a/src/Event/ReaderSchemaOrgEvent.php +++ b/src/Event/ReaderSchemaOrgEvent.php @@ -5,13 +5,13 @@ namespace HeimrichHannot\FlareBundle\Event; use Contao\Model; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Lists\ListSpec; use Symfony\Contracts\EventDispatcher\Event; class ReaderSchemaOrgEvent extends Event { public function __construct( - public readonly ListSpecification $listSpecification, + public readonly ListSpec $list, public readonly Model $model, public array $data = [], ) {} diff --git a/src/EventListener/Contao/BreadcrumbListener.php b/src/EventListener/Contao/BreadcrumbListener.php index 1b32a9bc..ca69f05e 100644 --- a/src/EventListener/Contao/BreadcrumbListener.php +++ b/src/EventListener/Contao/BreadcrumbListener.php @@ -18,7 +18,7 @@ use HeimrichHannot\FlareBundle\Exception\ViewException; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Registry\ProjectorRegistry; -use HeimrichHannot\FlareBundle\Specification\Factory\ListSpecificationFactory; +use HeimrichHannot\FlareBundle\Lists\Factory\ListBuilderFactory; use HeimrichHannot\FlareBundle\Util\Env; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -28,7 +28,7 @@ public function __construct( private Connection $connection, private EventDispatcherInterface $eventDispatcher, - private ListSpecificationFactory $listSpecificationFactory, + private ListBuilderFactory $listFactory, private ProjectorRegistry $projectorRegistry, private ValidationContextFactory $validationContextFactory, ) {} @@ -93,11 +93,11 @@ public function __invoke(array $items, Module $module): array return $items; } - $listSpec = $this->listSpecificationFactory->create(dataSource: $listModel); + $listSpec = $this->listFactory->createFromListModel($listModel)->build(); $validationContext = $this->validationContextFactory->createFromContent( contentModel: $contentModel, - listModel: $listModel + list: $listSpec, ); $validationProjector = $this->projectorRegistry->getProjectorFor($listSpec, $validationContext); @@ -115,7 +115,7 @@ public function __invoke(array $items, Module $module): array $pageMetaEvent = $this->eventDispatcher->dispatch(new ReaderPageMetaEvent( contentModel: $contentModel, displayModel: $autoItemModel, - listSpecification: $listSpec, + list: $listSpec, )); $title = $pageMetaEvent->getPageMeta()->getTitle(); diff --git a/src/EventListener/Contao/ElementDcaListener.php b/src/EventListener/Contao/ElementDcaListener.php index 4852868d..16e1f7b3 100644 --- a/src/EventListener/Contao/ElementDcaListener.php +++ b/src/EventListener/Contao/ElementDcaListener.php @@ -16,7 +16,7 @@ use HeimrichHannot\FlareBundle\Query\ListExecutionContext; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; use HeimrichHannot\FlareBundle\Registry\ListTypeRegistry; -use HeimrichHannot\FlareBundle\Specification\Factory\ListSpecificationFactory; +use HeimrichHannot\FlareBundle\Lists\Factory\ListBuilderFactory; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -35,7 +35,7 @@ public function __construct( private EventDispatcherInterface $eventDispatcher, private FilterElementRegistry $filterElementRegistry, private ListExecutionContextFactory $listExecutionContextFactory, - private ListSpecificationFactory $listSpecificationFactory, + private ListBuilderFactory $listFactory, private ListTypeRegistry $listTypeRegistry, private RequestStack $requestStack, ) {} @@ -113,7 +113,7 @@ private function createExecutionContext(ListModel $listModel): ?ListExecutionCon { try { - $specification = $this->listSpecificationFactory->create($listModel); + $specification = $this->listFactory->createFromListModel($listModel)->build(); return $this->listExecutionContextFactory->create($specification); } diff --git a/src/EventListener/DataContainer/FlareFilter/FieldsOptionsCallbacks.php b/src/EventListener/DataContainer/FlareFilter/FieldsOptionsCallbacks.php index c66caa56..6367079f 100644 --- a/src/EventListener/DataContainer/FlareFilter/FieldsOptionsCallbacks.php +++ b/src/EventListener/DataContainer/FlareFilter/FieldsOptionsCallbacks.php @@ -17,7 +17,7 @@ use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; use HeimrichHannot\FlareBundle\Query\Factory\ListExecutionContextFactory; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; -use HeimrichHannot\FlareBundle\Specification\Factory\ListSpecificationFactory; +use HeimrichHannot\FlareBundle\Lists\Factory\ListBuilderFactory; use HeimrichHannot\FlareBundle\Util\DateTimeHelper; use HeimrichHannot\FlareBundle\Util\DcaFieldFilter; use HeimrichHannot\FlareBundle\Util\DcaHelper; @@ -36,7 +36,7 @@ public function __construct( private FilterContainer $filterContainer, private FilterElementRegistry $filterElementRegistry, private TranslatorInterface $translator, - private ListSpecificationFactory $listSpecificationFactory, + private ListBuilderFactory $listFactory, private ListExecutionContextFactory $listExecutionContextFactory, ) {} @@ -118,8 +118,8 @@ public function getFieldOptions_fieldGeneric(DataContainer $dc): array return []; } - $listSpecification = $this->listSpecificationFactory->create($listModel); - $listExecutionContext = $this->listExecutionContextFactory->create($listSpecification); + $list = $this->listFactory->createFromListModel($listModel)->build(); + $listExecutionContext = $this->listExecutionContextFactory->create($list); $table = $listExecutionContext->tableAliasRegistry ->getTable($filterModel->targetAlias ?: TableAliasRegistry::ALIAS_MAIN); @@ -224,9 +224,9 @@ public function getOptions_targetAlias(?DataContainer $dc): array return []; } - $listSpecification = $this->listSpecificationFactory->create($listModel); + $list = $this->listFactory->createFromListModel($listModel)->build(); - $context = $this->listExecutionContextFactory->create($listSpecification); + $context = $this->listExecutionContextFactory->create($list); $tables = $context->tableAliasRegistry->getTables(); $options = []; diff --git a/src/EventListener/NamedDispatch/ListSpecificationListener.php b/src/EventListener/NamedDispatch/ListSpecificationListener.php deleted file mode 100644 index 706cb8c1..00000000 --- a/src/EventListener/NamedDispatch/ListSpecificationListener.php +++ /dev/null @@ -1,24 +0,0 @@ -listSpecification->type}.list_specification_created"; - - $this->eventDispatcher->dispatch(event: $event, eventName: $eventName); - } -} \ No newline at end of file diff --git a/src/EventListener/Reader/EnableGenericPageMetaListener.php b/src/EventListener/Reader/EnableGenericPageMetaListener.php deleted file mode 100644 index 5a80886d..00000000 --- a/src/EventListener/Reader/EnableGenericPageMetaListener.php +++ /dev/null @@ -1,23 +0,0 @@ -listSpecification; - - if ($list->type === GenericDataContainerListType::TYPE) { - // @todo (@ericges): Overhaul this mechanic - $list->setProperty('eval_generic_page_meta', true); - } - } -} \ No newline at end of file diff --git a/src/EventListener/Reader/GenericReaderPageMetaListener.php b/src/EventListener/Reader/GenericReaderPageMetaListener.php index bc8c37a0..2ac7b512 100644 --- a/src/EventListener/Reader/GenericReaderPageMetaListener.php +++ b/src/EventListener/Reader/GenericReaderPageMetaListener.php @@ -22,19 +22,19 @@ public function __construct( public function __invoke(ReaderPageMetaEvent $event): void { - $list = $event->getListSpecification(); + $list = $event->getList(); $contentModel = $event->getContentModel(); $model = $event->getDisplayModel(); - if (!$list->getProperty('eval_generic_page_meta')) { + if (!($list->config['genericPageMeta'] ?? false)) { return; } $pageMeta = $event->getPageMeta(); - $titleFormat = $pageMeta->getTitle() ? null : $list->metaTitleFormat; - $descriptionFormat = $pageMeta->getDescription() ? null : $list->metaDescriptionFormat; - $robotsFormat = $pageMeta->getRobots() ? null : $list->metaRobotsFormat; + $titleFormat = $pageMeta->getTitle() ? null : $list->config['metaTitleFormat']; + $descriptionFormat = $pageMeta->getDescription() ? null : $list->config['metaDescriptionFormat']; + $robotsFormat = $pageMeta->getRobots() ? null : $list->config['metaRobotsFormat']; if (\is_null($titleFormat) && \is_null($descriptionFormat) && \is_null($robotsFormat)) { // skip if no data formats are available for the page @@ -42,11 +42,11 @@ public function __invoke(ReaderPageMetaEvent $event): void } $tokens = [ - 'list.type' => $list->type, + 'list.type' => $list->getTypeAlias() ?? $list->type::class, 'list.dc' => $list->dc, ]; - $this->addTokensFromProperties($tokens, $list->getProperties(), prefix: 'list'); + $this->addTokensFromProperties($tokens, $list->config, prefix: 'list'); $this->addTokensFromProperties($tokens, $contentModel->row(), prefix: 'ce'); $this->addTokensFromProperties($tokens, $model->row()); @@ -78,11 +78,21 @@ private function addTokensFromProperties(array &$tokens, array $properties, ?str { foreach ($properties as $key => $value) { - if (!\is_scalar($value)) { + $path = \is_null($prefix) ? $key : "{$prefix}.{$key}"; + + if (\is_array($value)) + // canonical config values are already deserialized + { + foreach (Arr::flatten($value, prefix: $path) as $flatKey => $flatValue) { + $tokens[$flatKey] = $flatValue; + } + continue; } - $path = \is_null($prefix) ? $key : "{$prefix}.{$key}"; + if (!\is_scalar($value)) { + continue; + } $tokens[$path] = $value; diff --git a/src/Filter/Collector/FilterCollectorInterface.php b/src/Filter/Collector/FilterCollectorInterface.php deleted file mode 100644 index 66d66ab8..00000000 --- a/src/Filter/Collector/FilterCollectorInterface.php +++ /dev/null @@ -1,20 +0,0 @@ -|null Filters keyed by their list-specification key. - */ - public function collect(ListDataSourceInterface $dataSource): ?array; -} diff --git a/src/Filter/Collector/ListModelFilterCollector.php b/src/Filter/Collector/ListModelFilterCollector.php index f72d43c6..dca1baaf 100644 --- a/src/Filter/Collector/ListModelFilterCollector.php +++ b/src/Filter/Collector/ListModelFilterCollector.php @@ -12,10 +12,13 @@ use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Registry\ListTypeRegistry; -use HeimrichHannot\FlareBundle\Specification\DataSource\ListDataSourceInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; -readonly class ListModelFilterCollector implements FilterCollectorInterface +/** + * Collects the published tl_flare_filter rows of a list model as Filter DTOs, + * translating each row through its element's transformers. + */ +readonly class ListModelFilterCollector { public function __construct( private EventDispatcherInterface $eventDispatcher, @@ -24,22 +27,16 @@ public function __construct( private ListTypeRegistry $listTypeRegistry, ) {} - public function supports(ListDataSourceInterface $dataSource): bool + /** + * @return array|null + */ + public function collect(ListModel $listModel): ?array { - return $dataSource instanceof ListModel; - } - - public function collect(ListDataSourceInterface $dataSource): ?array - { - if (!$dataSource instanceof ListModel) { - throw new \InvalidArgumentException('The given data source is not a list model.'); - } - - if (!$dataSource->id || !$table = $dataSource->getTable()) { + if (!$listModel->id || !$table = $listModel::getTable()) { return null; } - if (!$this->listTypeRegistry->get($dataSource->getListType())?->getService()) { + if (!$this->listTypeRegistry->get((string) $listModel->type)?->getService()) { return null; } @@ -48,7 +45,7 @@ public function collect(ListDataSourceInterface $dataSource): ?array $filters = []; /** @var FilterModel $model */ - foreach (FilterModel::findByPid((int) $dataSource->id, published: true) as $model) + foreach (FilterModel::findByPid((int) $listModel->id, published: true) as $model) // Collect filters defined in the backend { if (!$model->published) { diff --git a/src/Filter/Element/ArchiveFilterElement.php b/src/Filter/Element/ArchiveFilterElement.php index 08f54721..a2ec72a8 100644 --- a/src/Filter/Element/ArchiveFilterElement.php +++ b/src/Filter/Element/ArchiveFilterElement.php @@ -21,7 +21,7 @@ use HeimrichHannot\FlareBundle\InferPtable\Factory\PtableInferrableFactory; use HeimrichHannot\FlareBundle\InferPtable\PtableInferrer; use HeimrichHannot\FlareBundle\Model\FilterModel; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Lists\ListSpec; use HeimrichHannot\FlareBundle\Util\Str; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\FormBuilderInterface; @@ -258,7 +258,7 @@ protected function getDynamicParentGroups(array $config): array /** * @return array|int[] Parent IDs, either flat (main ptable) or mapped by table (dynamic ptable). */ - protected function getWhitelistedParentIds(ListSpecification $list, array $config): array + protected function getWhitelistedParentIds(ListSpec $list, array $config): array { $inferrer = $this->getPtableInferrer($list); @@ -287,7 +287,7 @@ protected function getWhitelistedParentIds(ListSpecification $list, array $confi /** * @return Model[] */ - protected function getWhitelistedParents(ListSpecification $list, array $config): array + protected function getWhitelistedParents(ListSpec $list, array $config): array { $inferrer = $this->getPtableInferrer($list); @@ -324,7 +324,7 @@ protected function getWhitelistedParents(ListSpecification $list, array $config) /** * @return Model[] */ - public function processRuntimeValue(mixed $value, ListSpecification $list, array $config): array + public function processRuntimeValue(mixed $value, ListSpec $list, array $config): array { $values = $this->normalizeFilterValue($value); @@ -400,7 +400,7 @@ protected function normalizeFilterValue(mixed $value): array|true|null return $arr; } - private function getPtableInferrer(ListSpecification $list): PtableInferrer + private function getPtableInferrer(ListSpec $list): PtableInferrer { $cacheKey = $list->hash(); @@ -408,7 +408,7 @@ private function getPtableInferrer(ListSpecification $list): PtableInferrer return $this->_inferrer[$cacheKey]; } - $inferrable = PtableInferrableFactory::createFromListModelLike($list); + $inferrable = PtableInferrableFactory::createFromConfig($list->config); return $this->_inferrer[$cacheKey] = new PtableInferrer($inferrable, $list->dc); } @@ -509,7 +509,7 @@ private function getPreselectOptions(PtableInferrer $inferrer, array $row): arra * * @return Model[]|null */ - private function buildPreselectData(ListSpecification $list, array $preselect): ?array + private function buildPreselectData(ListSpec $list, array $preselect): ?array { if (!$preselect) { return null; diff --git a/src/Filter/Element/BelongsToRelationFilterElement.php b/src/Filter/Element/BelongsToRelationFilterElement.php index 544459c3..7604cc37 100644 --- a/src/Filter/Element/BelongsToRelationFilterElement.php +++ b/src/Filter/Element/BelongsToRelationFilterElement.php @@ -64,7 +64,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont throw new FilterException('No parent field defined.'); } - $inferrable = PtableInferrableFactory::createFromListModelLike($context->list); + $inferrable = PtableInferrableFactory::createFromConfig($context->list->config); $inferrer = new PtableInferrer($inferrable, $context->list->dc); try diff --git a/src/Filter/Factory/FilterContextFactory.php b/src/Filter/Factory/FilterContextFactory.php index af158f95..3bf97509 100644 --- a/src/Filter/Factory/FilterContextFactory.php +++ b/src/Filter/Factory/FilterContextFactory.php @@ -10,7 +10,7 @@ use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterOptionsResolver; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Lists\ListSpec; /** * Builds the invocation context handed to filter elements, resolving the filter's @@ -26,7 +26,7 @@ public function __construct( * @throws FilterException If the filter's config violates the element's schema */ public function create( - ListSpecification $list, + ListSpec $list, Filter $filter, FilterElementInterface $element, ContextInterface $engineContext, diff --git a/src/Filter/FilterContext.php b/src/Filter/FilterContext.php index d7e5c3a4..500f431a 100644 --- a/src/Filter/FilterContext.php +++ b/src/Filter/FilterContext.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\Filter; use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Lists\ListSpec; /** * Invocation context handed to filter elements, both when building the form @@ -21,13 +21,13 @@ /** * @param array $config Resolved canonical config of the filter. - * @param string|int|null $key Key of the filter within {@see ListSpecification::getFilters()}. + * @param string|int|null $key Key of the filter within {@see ListSpec::$filters}. */ public function __construct( - public ListSpecification $list, - public Filter $filter, - public array $config, - public ContextInterface $engineContext, - public string|int|null $key = null, + public ListSpec $list, + public Filter $filter, + public array $config, + public ContextInterface $engineContext, + public string|int|null $key = null, ) {} } diff --git a/src/Form/Factory/FilterFormFactory.php b/src/Form/Factory/FilterFormFactory.php index 0a914de0..1444bcbf 100644 --- a/src/Form/Factory/FilterFormFactory.php +++ b/src/Form/Factory/FilterFormFactory.php @@ -13,7 +13,7 @@ use HeimrichHannot\FlareBundle\Filter\Factory\FilterContextFactory; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterElementResolver; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Lists\ListSpec; use HeimrichHannot\FlareBundle\Util\Str; use Symfony\Component\Form\Extension\Core\Type\FormType; use Symfony\Component\Form\FormFactoryInterface; @@ -32,7 +32,7 @@ public function __construct( /** * @throws FlareException If the form could not be built */ - public function create(ListSpecification $list, FormContextInterface $context): FormInterface + public function create(ListSpec $list, FormContextInterface $context): FormInterface { if (!$context instanceof ContextInterface) { throw new FlareException('Filter form context must implement ContextInterface.', method: __METHOD__); @@ -57,7 +57,7 @@ public function create(ListSpecification $list, FormContextInterface $context): $builder->setAttribute('flare.list', $list); $builder->setAttribute('flare.engine_context', $context); - foreach ($list->getFilters() as $key => $filter) + foreach ($list->filters as $key => $filter) { if (!Str::isValidFormName($filter->alias)) { continue; @@ -103,7 +103,7 @@ public function create(ListSpecification $list, FormContextInterface $context): /** @var FilterFormBuildEvent $formBuildEvent */ $formBuildEvent = $this->eventDispatcher->dispatch(new FilterFormBuildEvent( - listSpecification: $list, + list: $list, formName: $name, formBuilder: $builder, )); diff --git a/src/InferPtable/Factory/PtableInferrableFactory.php b/src/InferPtable/Factory/PtableInferrableFactory.php index f81386f9..eb6f2b3b 100644 --- a/src/InferPtable/Factory/PtableInferrableFactory.php +++ b/src/InferPtable/Factory/PtableInferrableFactory.php @@ -5,46 +5,22 @@ namespace HeimrichHannot\FlareBundle\InferPtable\Factory; use HeimrichHannot\FlareBundle\InferPtable\PtableInferrable; -use HeimrichHannot\FlareBundle\InferPtable\PtableInferrableInterface; class PtableInferrableFactory { - public static function createFromListModelLike(object $list): ?PtableInferrable + /** + * Creates an inferrable from a list's canonical config + * ({@see \HeimrichHannot\FlareBundle\Lists\ListSpec::$config}). + * + * @param array $config + */ + public static function createFromConfig(array $config): PtableInferrable { - if ($list instanceof PtableInferrableInterface) { - return new PtableInferrable( - fieldPid: $list->getInferFieldPid(), - whichPtable: $list->getInferWhichPtable(), - fieldPtable: $list->getInferFieldPtable(), - tablePtable: $list->getInferTablePtable(), - ); - } - - $properties = ['fieldPid', 'whichPtable', 'fieldPtable', 'tablePtable']; - $arguments = []; - - foreach ($properties as $property) - { - $ucFirstProperty = \ucfirst($property); - - if (\method_exists($list, $method = 'getInfer' . $ucFirstProperty)) { - $arguments[$property] = $list->{$method}(); - continue; - } - - if (\method_exists($list, $method = 'get' . $ucFirstProperty)) { - $arguments[$property] = $list->{$method}(); - continue; - } - - if (\property_exists($list, $property) || \method_exists($list, '__get')) { - $arguments[$property] = $list->{$property}; - continue; - } - - return null; - } - - return new PtableInferrable(...$arguments); + return new PtableInferrable( + fieldPid: (string) ($config['fieldPid'] ?? ''), + whichPtable: (string) ($config['whichPtable'] ?? ''), + fieldPtable: (string) ($config['fieldPtable'] ?? ''), + tablePtable: (string) ($config['tablePtable'] ?? ''), + ); } } \ No newline at end of file diff --git a/src/Integration/CodefogTags/EventListener/RegisterTagsTablesListener.php b/src/Integration/CodefogTags/EventListener/RegisterTagsTablesListener.php index ba1fa389..ff3fd5e7 100644 --- a/src/Integration/CodefogTags/EventListener/RegisterTagsTablesListener.php +++ b/src/Integration/CodefogTags/EventListener/RegisterTagsTablesListener.php @@ -28,7 +28,7 @@ public function __construct( public function __invoke(QueryBaseInitializedEvent $event): void { - $table = $event->listSpecification->dc; + $table = $event->list->dc; if (!$columns = $this->managersRegistry->fieldsOf($table)) { return; } diff --git a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php index cff019a9..6f62d9ff 100644 --- a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php +++ b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php @@ -88,9 +88,9 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) executionContext: $executionContext, targetAlias: $context->filter->targetAlias, listInfo: \sprintf( - '%s (ID %s)', - $context->list->type, - (string) ($context->list->getDataSource()?->getListProperty('id') ?? 'N/A'), + '%s (%s)', + $context->list->getTypeAlias() ?? 'inline', + (string) ($context->list->source ?? 'N/A'), ), filterInfo: \sprintf('%s (%s)', self::TYPE, $context->filter->source ?? 'inlined'), ); diff --git a/src/Integration/ContaoCalendar/ListType/EventsListType.php b/src/Integration/ContaoCalendar/ListType/EventsListType.php index 320c42e5..98a15482 100644 --- a/src/Integration/ContaoCalendar/ListType/EventsListType.php +++ b/src/Integration/ContaoCalendar/ListType/EventsListType.php @@ -5,20 +5,20 @@ namespace HeimrichHannot\FlareBundle\Integration\ContaoCalendar\ListType; use HeimrichHannot\FlareBundle\Contract\DcaContract; +use HeimrichHannot\FlareBundle\Contract\ListType\BuildListContract; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListType; -use HeimrichHannot\FlareBundle\Event\ListSpecificationCreatedEvent; use HeimrichHannot\FlareBundle\Filter\Element\PublishedFilterElement; use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\ListType\AbstractListType; +use HeimrichHannot\FlareBundle\Lists\ListBuilder; use HeimrichHannot\FlareBundle\Query\JoinTypeEnum; use HeimrichHannot\FlareBundle\Query\SqlJoinStruct; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; -use Symfony\Component\EventDispatcher\Attribute\AsEventListener; #[AsListType(type: self::TYPE, dataContainer: self::DATA_CONTAINER)] -class EventsListType extends AbstractListType implements DcaContract +class EventsListType extends AbstractListType implements BuildListContract, DcaContract { public const TYPE = 'flare_events'; public const DATA_CONTAINER = 'tl_calendar_events'; @@ -52,17 +52,10 @@ public function configureTableRegistry(TableAliasRegistry $registry): void )); } - #[AsEventListener(priority: 200)] - public function onListSpecificationCreated(ListSpecificationCreatedEvent $config): void + public function buildList(ListBuilder $builder): void { - if ($config->listSpecification->type !== self::TYPE) { - return; - } - - $spec = $config->listSpecification; - - if (!$spec->hasFilterOfType(PublishedFilterElement::TYPE)) { - $spec->addFilter(new Filter( + if (!$builder->hasFilterOfType(PublishedFilterElement::TYPE)) { + $builder->addFilter(new Filter( element: PublishedFilterElement::TYPE, config: [ 'intrinsic' => true, diff --git a/src/Integration/ContaoCalendar/Projector/EventsAggregationProjector.php b/src/Integration/ContaoCalendar/Projector/EventsAggregationProjector.php index c89599ab..9945b517 100644 --- a/src/Integration/ContaoCalendar/Projector/EventsAggregationProjector.php +++ b/src/Integration/ContaoCalendar/Projector/EventsAggregationProjector.php @@ -12,18 +12,18 @@ use HeimrichHannot\FlareBundle\Integration\ContaoCalendar\GroupsEntriesTrait; use HeimrichHannot\FlareBundle\Integration\ContaoCalendar\ListType\EventsListType; use HeimrichHannot\FlareBundle\Integration\ContaoCalendar\Loader\EventsAggregationLoader; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Lists\ListSpec; class EventsAggregationProjector extends AggregationProjector { use GroupsEntriesTrait; - public function supports(ListSpecification $list, ContextInterface $context): bool + public function supports(ListSpec $list, ContextInterface $context): bool { - return $list->type === EventsListType::TYPE && $context instanceof AggregationContext; + return $list->getTypeAlias() === EventsListType::TYPE && $context instanceof AggregationContext; } - public function priority(ListSpecification $list, ContextInterface $context): int + public function priority(ListSpec $list, ContextInterface $context): int { return 100; } diff --git a/src/Integration/ContaoCalendar/Projector/EventsInteractiveProjector.php b/src/Integration/ContaoCalendar/Projector/EventsInteractiveProjector.php index 4ffae0c3..dbda8ddd 100644 --- a/src/Integration/ContaoCalendar/Projector/EventsInteractiveProjector.php +++ b/src/Integration/ContaoCalendar/Projector/EventsInteractiveProjector.php @@ -15,19 +15,19 @@ use HeimrichHannot\FlareBundle\Integration\ContaoCalendar\View\InteractiveEventsView; use HeimrichHannot\FlareBundle\Paginator\Paginator; use HeimrichHannot\FlareBundle\Reader\ReaderUrlGeneratorInterface; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Lists\ListSpec; use Symfony\Component\Form\FormInterface; class EventsInteractiveProjector extends InteractiveProjector { use GroupsEntriesTrait; - public function supports(ListSpecification $list, ContextInterface $context): bool + public function supports(ListSpec $list, ContextInterface $context): bool { - return $list->type === EventsListType::TYPE && $context instanceof InteractiveContext; + return $list->getTypeAlias() === EventsListType::TYPE && $context instanceof InteractiveContext; } - public function priority(ListSpecification $list, ContextInterface $context): int + public function priority(ListSpec $list, ContextInterface $context): int { return 100; } diff --git a/src/Integration/ContaoComments/EventListener/ContaoCommentsListener.php b/src/Integration/ContaoComments/EventListener/ContaoCommentsListener.php index fbae51e7..ad36716e 100644 --- a/src/Integration/ContaoComments/EventListener/ContaoCommentsListener.php +++ b/src/Integration/ContaoComments/EventListener/ContaoCommentsListener.php @@ -32,8 +32,8 @@ public function __construct( #[AsEventListener] public function onReaderBuilt(ReaderRenderEvent $event): void { - $list = $event->getListSpecification(); - if (!$list->comments_enabled) { + $list = $event->getList(); + if (!($list->config['comments_enabled'] ?? false)) { return; } @@ -60,7 +60,7 @@ public function onReaderBuilt(ReaderRenderEvent $event): void $notifies = []; - if ($list->comments_sendNativeEmails) + if ($list->config['comments_sendNativeEmails'] ?? false) { if ($archiveModel->notify !== 'notify_author' && isset($GLOBALS['TL_ADMIN_EMAIL'])) diff --git a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php index e149aac2..5b47f854 100644 --- a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php +++ b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php @@ -53,9 +53,9 @@ public function setMultilingualQueryBuilderFactory( #[AsEventListener] public function fetchAutoItem(FetchAutoItemEvent $event): void { - $list = $event->getListSpecification(); + $list = $event->getList(); - if ($list->type !== DcMultilingualListType::TYPE) { + if ($list->getTypeAlias() !== DcMultilingualListType::TYPE) { return; } diff --git a/src/Integration/Terminal42Languages/EventListener/DcMultilingualListSpecificationCreatedListener.php b/src/Integration/Terminal42Languages/EventListener/DcMultilingualListSpecificationCreatedListener.php deleted file mode 100644 index 8c099d1e..00000000 --- a/src/Integration/Terminal42Languages/EventListener/DcMultilingualListSpecificationCreatedListener.php +++ /dev/null @@ -1,22 +0,0 @@ -listSpecification; - - if ($list->type === DcMultilingualListType::TYPE) { - $list->isPageMetaGeneric = true; - } - } -} \ No newline at end of file diff --git a/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php b/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php index 297071e5..0e5c2307 100644 --- a/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php +++ b/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php @@ -7,11 +7,13 @@ use Contao\CoreBundle\String\HtmlDecoder; use Contao\CoreBundle\String\SimpleTokenParser; use Contao\DataContainer; +use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\Contract\ListType\DataContainerContract; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListType; use HeimrichHannot\FlareBundle\ListType\AbstractListType; +use HeimrichHannot\FlareBundle\Model\ListModel; -#[AsListType(type: self::TYPE, palette: self::DEFAULT_PALETTE)] +#[AsListType(type: self::TYPE)] class DcMultilingualListType extends AbstractListType implements DataContainerContract { public const TYPE = 'flare_generic_dc_multilingual'; @@ -39,4 +41,9 @@ public function getDataContainerName(array $row, DataContainer $dc): string { return $row['dc'] ?? ''; } + + protected function transformListModel(ListModel $model, ConfigBuilder $config): void + { + $config->set('genericPageMeta', true); + } } \ No newline at end of file diff --git a/src/ListType/GenericDataContainerListType.php b/src/ListType/GenericDataContainerListType.php index c3bb8722..1258fd5c 100644 --- a/src/ListType/GenericDataContainerListType.php +++ b/src/ListType/GenericDataContainerListType.php @@ -9,6 +9,7 @@ use Contao\CoreBundle\String\SimpleTokenParser; use Contao\DataContainer; use Contao\Message; +use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\Contract\DcaContract; use HeimrichHannot\FlareBundle\Contract\ListType\DataContainerContract; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; @@ -16,6 +17,7 @@ use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListType; use HeimrichHannot\FlareBundle\Exception\InferenceException; use HeimrichHannot\FlareBundle\InferPtable\PtableInferrer; +use HeimrichHannot\FlareBundle\Model\ListModel; use Symfony\Contracts\Translation\TranslatorInterface; #[AsListType(type: self::TYPE)] @@ -48,6 +50,11 @@ public function getDataContainerName(array $row, DataContainer $dc): string return $row['dc'] ?? ''; } + protected function transformListModel(ListModel $model, ConfigBuilder $config): void + { + $config->set('genericPageMeta', true); + } + public function buildDca(DcaBuilder $dca, DcaContext $context): void { $listModel = $context->listModel; diff --git a/src/ListType/NewsListType.php b/src/ListType/NewsListType.php index ebea247b..b97723c3 100644 --- a/src/ListType/NewsListType.php +++ b/src/ListType/NewsListType.php @@ -5,19 +5,19 @@ namespace HeimrichHannot\FlareBundle\ListType; use HeimrichHannot\FlareBundle\Contract\DcaContract; +use HeimrichHannot\FlareBundle\Contract\ListType\BuildListContract; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListType; -use HeimrichHannot\FlareBundle\Event\ListSpecificationCreatedEvent; use HeimrichHannot\FlareBundle\Filter\Element\PublishedFilterElement; use HeimrichHannot\FlareBundle\Filter\Filter; +use HeimrichHannot\FlareBundle\Lists\ListBuilder; use HeimrichHannot\FlareBundle\Query\JoinTypeEnum; use HeimrichHannot\FlareBundle\Query\SqlJoinStruct; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; -use Symfony\Component\EventDispatcher\Attribute\AsEventListener; #[AsListType(type: self::TYPE, dataContainer: 'tl_news')] -class NewsListType extends AbstractListType implements DcaContract +class NewsListType extends AbstractListType implements BuildListContract, DcaContract { public const TYPE = 'flare_news'; public const ALIAS_ARCHIVE = 'news_archive'; @@ -38,17 +38,10 @@ public function configureTableRegistry(TableAliasRegistry $registry): void )); } - #[AsEventListener(priority: 200)] - public function onListSpecificationCreated(ListSpecificationCreatedEvent $config): void + public function buildList(ListBuilder $builder): void { - if ($config->listSpecification->type !== self::TYPE) { - return; - } - - $spec = $config->listSpecification; - - if (!$spec->hasFilterOfType(PublishedFilterElement::TYPE)) { - $spec->addFilter(new Filter( + if (!$builder->hasFilterOfType(PublishedFilterElement::TYPE)) { + $builder->addFilter(new Filter( element: PublishedFilterElement::TYPE, config: [ 'intrinsic' => true, diff --git a/src/Model/DocumentsListModelTrait.php b/src/Model/DocumentsListModelTrait.php index c4f74f9f..90eae391 100644 --- a/src/Model/DocumentsListModelTrait.php +++ b/src/Model/DocumentsListModelTrait.php @@ -24,7 +24,7 @@ * @property string $fieldPtable * @property string $tablePtable * @property string $whichPtable - * @property string dcMultilingual_display + * @property string $dcMultilingual_display */ trait DocumentsListModelTrait { diff --git a/src/Model/ListModel.php b/src/Model/ListModel.php index 979a8f8b..d0cc62d5 100644 --- a/src/Model/ListModel.php +++ b/src/Model/ListModel.php @@ -7,42 +7,20 @@ use Contao\Model; use HeimrichHannot\FlareBundle\DataContainer\ListContainer; use HeimrichHannot\FlareBundle\InferPtable\PtableInferrableInterface; -use HeimrichHannot\FlareBundle\Specification\DataSource\ListDataSourceInterface; -use HeimrichHannot\FlareBundle\Specification\AutoItemFieldGetterTrait; +use HeimrichHannot\FlareBundle\Util\DcaHelper; /** * Class ListModel */ -class ListModel extends Model implements PtableInferrableInterface, ListDataSourceInterface +class ListModel extends Model implements PtableInferrableInterface { - use AutoItemFieldGetterTrait; use DocumentsListModelTrait; use PtableInferrableTrait; protected static $strTable = ListContainer::TABLE_NAME; - public function getListIdentifier(): string + public function getAutoItemField(): string { - return (string) $this->id; + return $this->fieldAutoItem ?: DcaHelper::tryGetColumnName($this->dc, 'alias', 'id'); } - - public function getListType(): string - { - return $this->type; - } - - public function getListTable(): string - { - return $this->dc; - } - - public function getListData(): array - { - return $this->arrData; - } - - public function getListProperty(string $name): mixed - { - return $this->{$name}; - } -} \ No newline at end of file +} diff --git a/src/Query/Executor/FilterExecutor.php b/src/Query/Executor/FilterExecutor.php index d32842cf..c23fd946 100644 --- a/src/Query/Executor/FilterExecutor.php +++ b/src/Query/Executor/FilterExecutor.php @@ -48,7 +48,7 @@ public function invokeFilters(ListQueryConfig $options): array $filterQueryBuilders = []; - foreach ($list->getFilters() as $key => $filter) + foreach ($list->filters as $key => $filter) { if (!$element = $this->filterElementResolver->resolve($filter)) { continue; @@ -82,7 +82,7 @@ public function invokeFilter(Filter $filter, FilterContext $context, array $data if (!Str::isValidSqlName($table = $context->list->dc)) { throw new FlareException(\sprintf( - '[FLARE] ListSpecification data container cannot be used as SQL table identifier: "%s"', + '[FLARE] ListSpec data container cannot be used as SQL table identifier: "%s"', $table ), method: __METHOD__); } diff --git a/src/Query/Factory/ListExecutionContextFactory.php b/src/Query/Factory/ListExecutionContextFactory.php index 5d86277c..39e3277b 100644 --- a/src/Query/Factory/ListExecutionContextFactory.php +++ b/src/Query/Factory/ListExecutionContextFactory.php @@ -12,7 +12,7 @@ use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; use HeimrichHannot\FlareBundle\Registry\Descriptor\ListTypeDescriptor; use HeimrichHannot\FlareBundle\Registry\ListTypeRegistry; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Lists\ListSpec; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; readonly class ListExecutionContextFactory @@ -25,15 +25,25 @@ public function __construct( /** * @throws FlareException */ - public function create(ListSpecification $list): ListExecutionContext + public function create(ListSpec $list): ListExecutionContext { - /** @var ListTypeDescriptor $listTypeDescriptor */ - $listTypeDescriptor = $this->listTypeRegistry->get($list->type); - if (!$listTypeDescriptor instanceof ListTypeDescriptor) { - throw new FlareException(\sprintf('No list type registered for type "%s".', $list->type), method: __METHOD__); + $listTypeDescriptor = null; + $listType = $list->getTypeInstance(); + + if (!$listType) + { + $listTypeDescriptor = $this->listTypeRegistry->get($list->getTypeAlias()); + if (!$listTypeDescriptor instanceof ListTypeDescriptor) { + throw new FlareException( + \sprintf('No list type registered for type "%s".', $list->getTypeAlias() ?? ''), + method: __METHOD__, + ); + } + + $listType = $listTypeDescriptor->getService(); } - if (!$mainTable = $list->dc ?? $listTypeDescriptor->getDataContainer()) { + if (!$mainTable = $list->dc ?: $listTypeDescriptor?->getDataContainer()) { throw new FlareException('No data container table set.', method: __METHOD__); } @@ -46,14 +56,13 @@ public function create(ListSpecification $list): ListExecutionContext ->setSelect([TableAliasRegistry::ALIAS_MAIN . '.*']) ->setGroupBy([TableAliasRegistry::ALIAS_MAIN . '.id']); - $listType = $listTypeDescriptor->getService(); if ($listType instanceof ConfigureQueryContract) { $listType->configureTableRegistry($registry); $listType->configureBaseQuery($struct); } $this->eventDispatcher->dispatch(new QueryBaseInitializedEvent( - listSpecification: $list, + list: $list, registry: $registry, struct: $struct, )); diff --git a/src/Query/ListQueryConfig.php b/src/Query/ListQueryConfig.php index fd8a7dea..cbdb5226 100644 --- a/src/Query/ListQueryConfig.php +++ b/src/Query/ListQueryConfig.php @@ -5,12 +5,12 @@ namespace HeimrichHannot\FlareBundle\Query; use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Lists\ListSpec; readonly class ListQueryConfig { public function __construct( - public ListSpecification $list, + public ListSpec $list, public ContextInterface $context, public array $filterValues, public bool $isCounting = false, diff --git a/src/Reader/Factory/ReaderRequestAttributeFactory.php b/src/Reader/Factory/ReaderRequestAttributeFactory.php index d4cfb379..6281f1dd 100644 --- a/src/Reader/Factory/ReaderRequestAttributeFactory.php +++ b/src/Reader/Factory/ReaderRequestAttributeFactory.php @@ -7,12 +7,12 @@ use Contao\Model; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Reader\ReaderRequestAttribute; -use HeimrichHannot\FlareBundle\Specification\Factory\ListSpecificationFactory; +use HeimrichHannot\FlareBundle\Lists\Factory\ListBuilderFactory; final readonly class ReaderRequestAttributeFactory { public function __construct( - private ListSpecificationFactory $listSpecificationFactory, + private ListBuilderFactory $listFactory, ) {} public function createFromData(array $data): ?ReaderRequestAttribute @@ -38,7 +38,7 @@ public function createFromData(array $data): ?ReaderRequestAttribute throw new \InvalidArgumentException('Invalid data for ReaderRequestAttribute unmarshalling.'); } - $spec = $this->listSpecificationFactory->create($listModel); + $spec = $this->listFactory->createFromListModel($listModel)->build(); return new ReaderRequestAttribute($model, $spec); } diff --git a/src/Reader/ReaderRequestAttribute.php b/src/Reader/ReaderRequestAttribute.php index 5e9c72bf..e54e7c3b 100644 --- a/src/Reader/ReaderRequestAttribute.php +++ b/src/Reader/ReaderRequestAttribute.php @@ -5,14 +5,13 @@ namespace HeimrichHannot\FlareBundle\Reader; use Contao\Model; -use HeimrichHannot\FlareBundle\Model\ListModel; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Lists\ListSpec; readonly class ReaderRequestAttribute { public function __construct( - private Model $model, - private ListSpecification $listSpecification, + private Model $model, + private ListSpec $list, ) {} public function getModel(): Model @@ -20,20 +19,18 @@ public function getModel(): Model return $this->model; } - public function getListSpecification(): ListSpecification + public function getList(): ListSpec { - return $this->listSpecification; + return $this->list; } public function marshal(): array { - $dataSource = $this->listSpecification->getDataSource(); - return [ 'model_class' => $this->model::class, 'model_table' => $this->model::getTable(), 'model_id' => $this->model->id, - 'list_id' => $dataSource instanceof ListModel ? $dataSource->id : null, + 'list_id' => $this->list->config['id'] ?? null, ]; } } \ No newline at end of file diff --git a/src/Registry/FilterCollectorRegistry.php b/src/Registry/FilterCollectorRegistry.php deleted file mode 100644 index e1f367e6..00000000 --- a/src/Registry/FilterCollectorRegistry.php +++ /dev/null @@ -1,51 +0,0 @@ - $collectorsIterable - */ - public function __construct( - #[TaggedIterator('flare.filter_collector')] - private readonly iterable $collectorsIterable, - ) {} - - private function resolve(): array - { - if (!isset($this->collectors)) - { - $this->collectors = \iterator_to_array($this->collectorsIterable); - } - - return $this->collectors; - } - - public function all(): iterable - { - return $this->resolve(); - } - - public function match(ListDataSourceInterface $dataSource): ?FilterCollectorInterface - { - /** @var FilterCollectorInterface $collector */ - foreach ($this->resolve() as $collector) - { - if ($collector->supports($dataSource)) - { - return $collector; - } - } - - return null; - } -} diff --git a/src/Registry/ProjectorRegistry.php b/src/Registry/ProjectorRegistry.php index 2f21bdc1..f03cc1e7 100644 --- a/src/Registry/ProjectorRegistry.php +++ b/src/Registry/ProjectorRegistry.php @@ -7,7 +7,7 @@ use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; use HeimrichHannot\FlareBundle\Engine\Projector\ProjectorInterface; use HeimrichHannot\FlareBundle\Exception\FlareException; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Lists\ListSpec; use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; readonly class ProjectorRegistry @@ -26,7 +26,7 @@ public function __construct( * @throws FlareException If no projector is found. */ public function getProjectorFor( - ListSpecification $spec, + ListSpec $spec, ContextInterface $config, ?array $exclude = null ): ProjectorInterface { diff --git a/src/Sort/Factory/SortOrderSequenceFactory.php b/src/Sort/Factory/SortOrderSequenceFactory.php index 83d2185a..24c301cf 100644 --- a/src/Sort/Factory/SortOrderSequenceFactory.php +++ b/src/Sort/Factory/SortOrderSequenceFactory.php @@ -4,22 +4,17 @@ namespace HeimrichHannot\FlareBundle\Sort\Factory; -use Contao\StringUtil; use HeimrichHannot\FlareBundle\Exception\FlareException; -use HeimrichHannot\FlareBundle\Model\ListModel; +use HeimrichHannot\FlareBundle\Lists\ListSpec; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; use HeimrichHannot\FlareBundle\Sort\SortOrder; use HeimrichHannot\FlareBundle\Sort\SortOrderSequence; final readonly class SortOrderSequenceFactory { - public function createFromListModel(ListModel $listModel): ?SortOrderSequence + public function createFromList(ListSpec $list): ?SortOrderSequence { - if (!$listModel->sortSettings) { - return null; - } - - if (!$sortSettings = StringUtil::deserialize($listModel->sortSettings, true)) { + if (!$sortSettings = ($list->config['sortSettings'] ?? [])) { return null; } diff --git a/src/Specification/AutoItemFieldGetterTrait.php b/src/Specification/AutoItemFieldGetterTrait.php deleted file mode 100644 index 5600c029..00000000 --- a/src/Specification/AutoItemFieldGetterTrait.php +++ /dev/null @@ -1,15 +0,0 @@ -fieldAutoItem ?: DcaHelper::tryGetColumnName($this->dc, 'alias', 'id'); - } -} \ No newline at end of file diff --git a/src/Specification/DataSource/ListDataSourceInterface.php b/src/Specification/DataSource/ListDataSourceInterface.php deleted file mode 100644 index 842155ac..00000000 --- a/src/Specification/DataSource/ListDataSourceInterface.php +++ /dev/null @@ -1,18 +0,0 @@ -properties; - } - - public function getProperty(string $name, mixed $default = null): mixed - { - return $this->properties[$name] ?? $default; - } - - public function hasProperty(string $name): bool - { - return \array_key_exists($name, $this->properties); - } - - public function setProperties(array $properties): void - { - $this->properties = $properties; - } - - public function setProperty(string $name, mixed $value): void - { - $this->properties[$name] = $value; - } - - public function issetProperty(string $name): bool - { - return $this->hasProperty($name) && $this->getProperty($name) !== null; - } - - public function __isset(string $name): bool - { - return $this->issetProperty($name); - } - - public function __set(string $name, mixed $value): void - { - $this->setProperty($name, $value); - } - - public function __get(string $name): mixed - { - return $this->getProperty($name); - } -} \ No newline at end of file diff --git a/src/Specification/Factory/ListSpecificationFactory.php b/src/Specification/Factory/ListSpecificationFactory.php deleted file mode 100644 index 999ae751..00000000 --- a/src/Specification/Factory/ListSpecificationFactory.php +++ /dev/null @@ -1,51 +0,0 @@ -getListType(), - dc: $dataSource->getListTable(), - dataSource: $dataSource, - ); - - // Automatically collect filters (delegate to FilterCollectorRegistry) - foreach ($this->collectFilters($dataSource) as $key => $filter) { - $specification->addFilter($filter, (string) $key); - } - - $specification->setProperties($dataSource->getListData()); - - $event = $this->eventDispatcher->dispatch(new ListSpecificationCreatedEvent($specification)); - - return $event->listSpecification; - } - - /** - * @return array - */ - private function collectFilters(ListDataSourceInterface $dataSource): array - { - return $this->filterCollectors->match($dataSource)?->collect($dataSource) ?? []; - } -} diff --git a/src/Specification/ListSpecification.php b/src/Specification/ListSpecification.php deleted file mode 100644 index b9058e73..00000000 --- a/src/Specification/ListSpecification.php +++ /dev/null @@ -1,96 +0,0 @@ - - */ - private array $filters = []; - - private int $generatedFilterKeys = 0; - - public function __construct( - public readonly string $type, - public readonly string $dc, - private ?ListDataSourceInterface $dataSource = null, - ) {} - - public function getDataSource(): ?ListDataSourceInterface - { - return $this->dataSource; - } - - public function setDataSource(?ListDataSourceInterface $dataSource): static - { - $this->dataSource = $dataSource; - return $this; - } - - /** - * @return array - */ - public function getFilters(): array - { - return $this->filters; - } - - public function getFilter(string $key): ?Filter - { - return $this->filters[$key] ?? null; - } - - /** - * Adds a filter. The key defaults to the filter's alias; alias-less filters receive a generated key. - */ - public function addFilter(Filter $filter, ?string $key = null): static - { - $key ??= $filter->alias ?? ('_generated_' . $this->generatedFilterKeys++); - $this->filters[$key] = $filter; - return $this; - } - - public function removeFilter(string $key): static - { - unset($this->filters[$key]); - return $this; - } - - public function hasFilterOfType(string $elementType): bool - { - foreach ($this->filters as $filter) - { - if ($filter->getElementType() === $elementType) { - return true; - } - } - - return false; - } - - public function hash(): string - { - return \sha1(\serialize([ - $this->type, - $this->dc, - \array_map(static fn (Filter $filter): array => $filter->fingerprint(), $this->filters), - 'model' => $this->dataSource ? [ - $this->dataSource->getListIdentifier(), - $this->dataSource->getListType(), - $this->dataSource->getListTable(), - ] : null, - ])); - } -} diff --git a/src/Twig/Runtime/FlareRuntime.php b/src/Twig/Runtime/FlareRuntime.php index b61c447b..0dd3896e 100644 --- a/src/Twig/Runtime/FlareRuntime.php +++ b/src/Twig/Runtime/FlareRuntime.php @@ -16,7 +16,7 @@ use HeimrichHannot\FlareBundle\Event\ReaderSchemaOrgEvent; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Registry\ProjectorRegistry; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Lists\ListSpec; use HeimrichHannot\FlareBundle\Util\CallableWrapper; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Twig\Extension\RuntimeExtensionInterface; @@ -28,7 +28,7 @@ public function __construct( private ProjectorRegistry $projectorRegistry, ) {} - public function project(ListSpecification $spec, ContextInterface $config): ViewInterface + public function project(ListSpec $spec, ContextInterface $config): ViewInterface { return $this->projectorRegistry->getProjectorFor($spec, $config)->project($spec, $config); } @@ -121,7 +121,7 @@ public function getEnclosureFiles(Model|array|string|null $enclosed): array return []; } - public function getSchemaOrg(array $context, ?Model $model = null, ?ListSpecification $list = null): ?array + public function getSchemaOrg(array $context, ?Model $model = null, ?ListSpec $list = null): ?array { $model ??= $context['model'] ?? null; if (!$model instanceof Model) { diff --git a/tests/Specification/ListSpecificationTest.php b/tests/Specification/ListSpecificationTest.php deleted file mode 100644 index 306cc29a..00000000 --- a/tests/Specification/ListSpecificationTest.php +++ /dev/null @@ -1,69 +0,0 @@ -addFilter($filter); - - self::assertSame($filter, $spec->getFilter('color')); - self::assertSame(['color'], \array_keys($spec->getFilters())); - } - - public function testAddFilterWithExplicitKeyAndGeneratedKeys(): void - { - $spec = new ListSpecification('test', 'tl_test'); - - $spec->addFilter(new Filter(element: 'a'), 'custom'); - $spec->addFilter(new Filter(element: 'b')); - $spec->addFilter(new Filter(element: 'c')); - - $keys = \array_keys($spec->getFilters()); - - self::assertSame('custom', $keys[0]); - self::assertCount(3, $keys); - self::assertSame(\count($keys), \count(\array_unique($keys))); - } - - public function testHasFilterOfType(): void - { - $spec = new ListSpecification('test', 'tl_test'); - $spec->addFilter(new Filter(element: 'flare_published')); - - self::assertTrue($spec->hasFilterOfType('flare_published')); - self::assertFalse($spec->hasFilterOfType('flare_bool')); - } - - public function testHashReflectsFilterChanges(): void - { - $spec = new ListSpecification('test', 'tl_test'); - $before = $spec->hash(); - - $spec->addFilter(new Filter(element: 'flare_bool', config: ['field' => 'published']), 'x'); - $after = $spec->hash(); - - self::assertNotSame($before, $after); - self::assertSame($after, $spec->hash()); - } - - public function testRemoveFilter(): void - { - $spec = new ListSpecification('test', 'tl_test'); - $spec->addFilter(new Filter(element: 'a'), 'x'); - $spec->removeFilter('x'); - - self::assertNull($spec->getFilter('x')); - self::assertSame([], $spec->getFilters()); - } -} From 22b55117a5fd40461547925b8393fab6ce5fdf49 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 12:56:41 +0200 Subject: [PATCH 23/71] refactor: rename `ConfigureQueryContract` to `BuildQueryContract` `configureTableRegistry()` / `configureBaseQuery()` become `buildTableRegistry()` / `buildBaseQuery()`, aligning the list-type query hooks with the build* lifecycle family (configure* = declarative setup, build* = per-invocation construction). --- ...{ConfigureQueryContract.php => BuildQueryContract.php} | 6 +++--- .../ContaoCalendar/ListType/EventsListType.php | 2 +- src/ListType/AbstractListType.php | 6 +++--- src/ListType/NewsListType.php | 2 +- src/Query/Factory/ListExecutionContextFactory.php | 8 ++++---- 5 files changed, 12 insertions(+), 12 deletions(-) rename src/Contract/ListType/{ConfigureQueryContract.php => BuildQueryContract.php} (52%) diff --git a/src/Contract/ListType/ConfigureQueryContract.php b/src/Contract/ListType/BuildQueryContract.php similarity index 52% rename from src/Contract/ListType/ConfigureQueryContract.php rename to src/Contract/ListType/BuildQueryContract.php index 20fc0d2a..1930eecb 100644 --- a/src/Contract/ListType/ConfigureQueryContract.php +++ b/src/Contract/ListType/BuildQueryContract.php @@ -7,9 +7,9 @@ use HeimrichHannot\FlareBundle\Query\SqlQueryStruct; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; -interface ConfigureQueryContract +interface BuildQueryContract { - public function configureTableRegistry(TableAliasRegistry $registry): void; + public function buildTableRegistry(TableAliasRegistry $registry): void; - public function configureBaseQuery(SqlQueryStruct $struct): void; + public function buildBaseQuery(SqlQueryStruct $struct): void; } \ No newline at end of file diff --git a/src/Integration/ContaoCalendar/ListType/EventsListType.php b/src/Integration/ContaoCalendar/ListType/EventsListType.php index 98a15482..918712c6 100644 --- a/src/Integration/ContaoCalendar/ListType/EventsListType.php +++ b/src/Integration/ContaoCalendar/ListType/EventsListType.php @@ -39,7 +39,7 @@ public function buildDca(DcaBuilder $dca, DcaContext $context): void }); } - public function configureTableRegistry(TableAliasRegistry $registry): void + public function buildTableRegistry(TableAliasRegistry $registry): void { $fromAlias = TableAliasRegistry::ALIAS_MAIN; diff --git a/src/ListType/AbstractListType.php b/src/ListType/AbstractListType.php index 4267fb81..b770b1ae 100644 --- a/src/ListType/AbstractListType.php +++ b/src/ListType/AbstractListType.php @@ -15,7 +15,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; abstract class AbstractListType implements - ListTypeInterface, OptionsInterface, TransformerContract, Contract\ListType\ConfigureQueryContract + ListTypeInterface, OptionsInterface, TransformerContract, Contract\ListType\BuildQueryContract { /** * Declares the type's config schema on top of {@see \HeimrichHannot\FlareBundle\Lists\BaseListOptions}. @@ -33,7 +33,7 @@ public function configureTransformers(TransformerBuilder $transformers): void */ protected function transformListModel(ListModel $model, ConfigBuilder $config): void {} - public function configureTableRegistry(TableAliasRegistry $registry): void {} + public function buildTableRegistry(TableAliasRegistry $registry): void {} - public function configureBaseQuery(SqlQueryStruct $struct): void {} + public function buildBaseQuery(SqlQueryStruct $struct): void {} } diff --git a/src/ListType/NewsListType.php b/src/ListType/NewsListType.php index b97723c3..47c500e3 100644 --- a/src/ListType/NewsListType.php +++ b/src/ListType/NewsListType.php @@ -27,7 +27,7 @@ public function buildDca(DcaBuilder $dca, DcaContext $context): void $dca->palette('{filter_legend},'); } - public function configureTableRegistry(TableAliasRegistry $registry): void + public function buildTableRegistry(TableAliasRegistry $registry): void { $registry->registerJoin(new SqlJoinStruct( fromAlias: TableAliasRegistry::ALIAS_MAIN, diff --git a/src/Query/Factory/ListExecutionContextFactory.php b/src/Query/Factory/ListExecutionContextFactory.php index 39e3277b..940ea83d 100644 --- a/src/Query/Factory/ListExecutionContextFactory.php +++ b/src/Query/Factory/ListExecutionContextFactory.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\Query\Factory; -use HeimrichHannot\FlareBundle\Contract\ListType\ConfigureQueryContract; +use HeimrichHannot\FlareBundle\Contract\ListType\BuildQueryContract; use HeimrichHannot\FlareBundle\Event\QueryBaseInitializedEvent; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Query\ListExecutionContext; @@ -56,9 +56,9 @@ public function create(ListSpec $list): ListExecutionContext ->setSelect([TableAliasRegistry::ALIAS_MAIN . '.*']) ->setGroupBy([TableAliasRegistry::ALIAS_MAIN . '.id']); - if ($listType instanceof ConfigureQueryContract) { - $listType->configureTableRegistry($registry); - $listType->configureBaseQuery($struct); + if ($listType instanceof BuildQueryContract) { + $listType->buildTableRegistry($registry); + $listType->buildBaseQuery($struct); } $this->eventDispatcher->dispatch(new QueryBaseInitializedEvent( From 4f8a93a9977dbacd34ef2d558f195e6da821a838 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 13:02:46 +0200 Subject: [PATCH 24/71] test: cover transformer machinery and Lists domain; update AGENTS.md New tests: ConfigBuilder, TransformerBuilder, FilterTransformerResolver (memoization + event extensibility), SimpleEquation/Archive `transformFilterModel()` round-trips through their own schemas, ListSpec (immutability, filter keying, hash), ListBuilder (hook, event, override precedence, source provenance), BaseListOptions. AGENTS.md architecture section now describes the Lists domain, the transformer cycle, and the current attribute/event surface; the stale no-test-suite claim is corrected. --- AGENTS.md | 38 ++++-- tests/Config/ConfigBuilderTest.php | 38 ++++++ tests/Config/TransformerBuilderTest.php | 65 +++++++++ .../Element/ArchiveFilterElementTest.php | 102 ++++++++++++++ .../SimpleEquationFilterElementTest.php | 88 ++++++++++++ .../Filter/FilterTransformerResolverTest.php | 114 ++++++++++++++++ tests/Lists/BaseListOptionsTest.php | 80 +++++++++++ tests/Lists/ListBuilderTest.php | 126 ++++++++++++++++++ tests/Lists/ListSpecTest.php | 83 ++++++++++++ 9 files changed, 720 insertions(+), 14 deletions(-) create mode 100644 tests/Config/ConfigBuilderTest.php create mode 100644 tests/Config/TransformerBuilderTest.php create mode 100644 tests/Filter/Element/ArchiveFilterElementTest.php create mode 100644 tests/Filter/Element/SimpleEquationFilterElementTest.php create mode 100644 tests/Filter/FilterTransformerResolverTest.php create mode 100644 tests/Lists/BaseListOptionsTest.php create mode 100644 tests/Lists/ListBuilderTest.php create mode 100644 tests/Lists/ListSpecTest.php diff --git a/AGENTS.md b/AGENTS.md index 230ae474..c12b6013 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,8 +18,8 @@ The core execution flow is: ``` ContentElement Controller - → EngineFactory (consumes a ListSpecification from src/Specification/) - → Engine + → ListBuilderFactory::createFromListModel(...)->build() — builds the immutable ListSpec (src/Lists/) + → EngineFactory → Engine → Context (Interactive / Validation / Aggregation) — src/Engine/Context/ → Loader (src/Engine/Loader/) + Mods (src/Engine/Mod/) → Projector (Interactive / Validation / Aggregation / Export) — orchestrates query + filter execution @@ -30,9 +30,19 @@ Note: Contexts and Projectors/Views are separate axes — (Export Context/Projec The bundle follows standard Symfony Bundle architecture with deep Contao integration. +**Lifecycle taxonomy** — elements and list types own their lifecycle through two method families: +`configure*` methods are declarative, memoizable setup (`configureOptions` = OptionsResolver schema, +`configureTransformers` = source→canonical-config mappings); `build*` methods are per-invocation construction +(`buildDca`, `buildForm`, `buildFilter`, `buildList`, `buildTableRegistry`/`buildBaseQuery`). + **Notable subsystems** (beyond the flow above): -- `src/Specification/` — `ListSpecification` / `FilterDefinition`, the declarative input to the engine -- `src/Filter/`, `src/FilterElement/`, `src/FilterCollector/` — filter definition and execution +- `src/Lists/` — `ListSpec` (immutable list DTO: type, dc, filters, canonical config, source), `ListBuilder` + (build lifecycle: type's `buildList()` hook → `ListBuildEvent` → config assembly → schema resolution), + `BaseListOptions` (framework-owned base schema for tl_flare_list columns) +- `src/Filter/` — `Filter` DTO, elements (`Element/`), types (`Type/`), collector, resolvers + (`FilterOptionsResolver`, `FilterTransformerResolver`, `FilterElementResolver`), `FilterContextFactory` +- `src/Config/` — `ConfigBuilder` (fluent canonical-config accumulator; no cast helpers — transformers cast + declaratively off the typed model) and `TransformerBuilder` (source class → transformer map) - `src/Form/` — filter form building (FilterFormFactory etc.) - `src/Reader/` — reader/detail-page URL generation (`ReaderUrlGenerator`) - `src/InferPtable/` — parent-table inference for DCAs @@ -46,19 +56,19 @@ The bundle follows standard Symfony Bundle architecture with deep Contao integra - `src/Controller/ContentElement/ListViewController.php` / `ReaderController.php` — frontend controllers **Extensibility via PHP 8 attributes** (compiler passes auto-register tagged services): -- `#[AsFilterElement(type: '...', palette: '...', formType: ...)]` — register a filter element -- `#[AsListType(type: '...', dataContainer: '...', palette: '...')]` — register a list type -- `#[AsFilterCallback(type, 'path.to.callback')]` — register a Contao DCA callback on a filter type -- `#[AsListCallback(type, 'path.to.callback')]` — register a Contao DCA callback on a list type -- `#[AsFilterInvoker]` — register a custom filter invocation handler - -(`AsFilterCallback` and `AsListCallback` both extend the `@internal` base attribute `AsFlareCallback`.) +- `#[AsFilterElement(type: '...', intrinsicOnly: ..., isTargeted: ...)]` — register a filter element +- `#[AsListType(type: '...', dataContainer: '...')]` — register a list type Attributes are in `src/DependencyInjection/Attribute/`, compiler passes in `src/DependencyInjection/Compiler/`. +Backend palettes/fields are declared in code via `DcaContract::buildDca(DcaBuilder, DcaContext)` (both +tl_flare_filter and tl_flare_list). -**Event system** — Events, some with aliased dispatch for targeted listening (`flare.form.{name}.build`, etc., implemented by the listeners in `src/EventListener/NamedDispatch/`). All events are in `src/Event/`. Prefer events over overriding services for customization. +**Event system** — Events, some with aliased dispatch for targeted listening (`flare.form.{name}.build`, +`flare.list.{type}.build`, `flare.filter_element.{type}.transformers`, `flare.filter_element.{type}.dca` / +`flare.list.{type}.dca`, etc., implemented by the listeners in `src/EventListener/NamedDispatch/`). All events +are in `src/Event/`. Prefer events over overriding services for customization. -**Registry pattern** — Registries in `src/Registry/` map type names to implementations: `FilterElementRegistry`, `ListTypeRegistry`, `FilterInvokerRegistry`, `ProjectorRegistry`, `FilterCollectorRegistry`, `FlareCallbackRegistry`, `EngineModRegistry`. +**Registry pattern** — Registries in `src/Registry/` map type names to implementations: `FilterElementRegistry`, `ListTypeRegistry`, `FilterTypeRegistry`, `ProjectorRegistry`, `EngineModRegistry`. **Query safety** — `FilterQueryBuilder` (`src/Query/FilterQueryBuilder.php`) enforces parameterized queries. `TableAliasRegistry` (`src/Query/TableAliasRegistry.php`) manages table aliases and JOINs safely. @@ -91,7 +101,7 @@ Attributes are in `src/DependencyInjection/Attribute/`, compiler passes in `src/ ## Testing & CI -* **There is currently no test suite**: no `tests/` directory, no `phpunit.xml`, no test CI workflow — even though PHPUnit is in `require-dev` and `tests/` is referenced in `autoload-dev` and `mago.toml`. Don't look for tests or invent a `make test` target. +* **Unit tests** live in `tests/` (PHPUnit 9); run them with `make php vendor/bin/phpunit tests`. There is no `phpunit.xml` and no test CI workflow yet, and no `make test` target. * CI workflows in `.github/workflows/`: * `phpstan.yaml` — PHPStan analysis * `mago.yaml` — Mago lint (`--minimum-fail-level note`, PHP 8.2–8.5) diff --git a/tests/Config/ConfigBuilderTest.php b/tests/Config/ConfigBuilderTest.php new file mode 100644 index 00000000..1eb50dbb --- /dev/null +++ b/tests/Config/ConfigBuilderTest.php @@ -0,0 +1,38 @@ +all()); + } + + public function testSetIsFluentAndAccumulates(): void + { + $config = new ConfigBuilder(); + + $result = $config + ->set('intrinsic', true) + ->set('left', 'id') + ->set('right', null); + + self::assertSame($config, $result); + self::assertSame(['intrinsic' => true, 'left' => 'id', 'right' => null], $config->all()); + } + + public function testSetOverwritesSameKey(): void + { + $config = new ConfigBuilder(); + + $config->set('field', 'a')->set('field', 'b'); + + self::assertSame(['field' => 'b'], $config->all()); + } +} diff --git a/tests/Config/TransformerBuilderTest.php b/tests/Config/TransformerBuilderTest.php new file mode 100644 index 00000000..85e0f818 --- /dev/null +++ b/tests/Config/TransformerBuilderTest.php @@ -0,0 +1,65 @@ +for(SourceA::class, $transformer); + + self::assertSame($transformers, $result); + self::assertSame($transformer, $transformers->resolve(new SourceA())); + } + + public function testResolvesSubclassSources(): void + { + $transformers = new TransformerBuilder(); + $transformer = static function (object $source, ConfigBuilder $config): void {}; + + $transformers->for(SourceA::class, $transformer); + + self::assertSame($transformer, $transformers->resolve(new SourceASub())); + } + + public function testReRegistrationOverrides(): void + { + $transformers = new TransformerBuilder(); + $first = static function (object $source, ConfigBuilder $config): void {}; + $second = static function (object $source, ConfigBuilder $config): void {}; + + $transformers->for(SourceA::class, $first); + $transformers->for(SourceA::class, $second); + + self::assertSame($second, $transformers->resolve(new SourceA())); + } + + public function testReturnsNullWithoutMatch(): void + { + $transformers = new TransformerBuilder(); + $transformers->for(SourceA::class, static function (object $source, ConfigBuilder $config): void {}); + + self::assertNull($transformers->resolve(new SourceB())); + } +} + +class SourceA +{ +} + +final class SourceASub extends SourceA +{ +} + +final class SourceB +{ +} diff --git a/tests/Filter/Element/ArchiveFilterElementTest.php b/tests/Filter/Element/ArchiveFilterElementTest.php new file mode 100644 index 00000000..16263701 --- /dev/null +++ b/tests/Filter/Element/ArchiveFilterElementTest.php @@ -0,0 +1,102 @@ +transform([ + 'intrinsic' => '1', + 'whitelistParents' => \serialize(['3', '5', '5', '0']), + 'groupWhitelistParents' => \serialize([['table' => 'tl_news_archive', 'ids' => ['1'], 'label' => 'A']]), + 'useWhitelistForOptionsOnly' => '1', + 'formatLabel' => '%title%', + 'hasEmptyOption' => '1', + 'formatEmptyOption' => '', + 'isMandatory' => '', + 'isMultiple' => '1', + 'isExpanded' => '', + 'preselect' => \serialize(['7']), + ]); + + self::assertTrue($config['intrinsic']); + self::assertSame([3, 5], $config['whitelist_parents']); + self::assertTrue($config['use_whitelist_for_options_only']); + self::assertSame('%title%', $config['format_label']); + self::assertTrue($config['has_empty_option']); + self::assertNull($config['format_empty_option']); + self::assertFalse($config['is_mandatory']); + self::assertTrue($config['is_multiple']); + self::assertFalse($config['is_expanded']); + self::assertSame(['7'], $config['preselect']); + } + + public function testCollapsesCustomFormats(): void + { + $config = $this->transform([ + 'formatLabel' => 'custom', + 'formatLabelCustom' => '%title% (%year%)', + 'formatEmptyOption' => 'custom', + 'formatEmptyOptionCustom' => '', + ]); + + self::assertSame('%title% (%year%)', $config['format_label']); + self::assertNull($config['format_empty_option']); + } + + public function testTransformSatisfiesTheElementSchema(): void + { + $element = $this->createElement(); + + $resolver = new OptionsResolver(); + $element->configureOptions($resolver); + + $resolved = $resolver->resolve($this->transform([ + 'whitelistParents' => \serialize(['2']), + 'preselect' => '', + ])); + + self::assertSame([2], $resolved['whitelist_parents']); + self::assertSame([], $resolved['preselect']); + self::assertFalse($resolved['intrinsic']); + } + + private function createElement(): ArchiveFilterElement + { + // ChoicesBuilderFactory is readonly (not doublable); transformFilterModel() never touches it. + return new ArchiveFilterElement(new ChoicesBuilderFactory( + $this->createMock(TranslatorInterface::class), + $this->createMock(ParameterBagInterface::class), + )); + } + + /** + * @return array + */ + private function transform(array $row): array + { + $element = $this->createElement(); + + $transformers = new TransformerBuilder(); + $element->configureTransformers($transformers); + + $transformer = $transformers->resolve($model = new FilterModelStub($row)); + self::assertNotNull($transformer); + + $transformer($model, $config = new ConfigBuilder()); + + return $config->all(); + } +} diff --git a/tests/Filter/Element/SimpleEquationFilterElementTest.php b/tests/Filter/Element/SimpleEquationFilterElementTest.php new file mode 100644 index 00000000..573209f8 --- /dev/null +++ b/tests/Filter/Element/SimpleEquationFilterElementTest.php @@ -0,0 +1,88 @@ +transform([ + 'intrinsic' => '1', + 'equationLeft' => 'pid', + 'equationOperator' => '=', + 'equationRight' => '42', + ]); + + self::assertTrue($config['intrinsic']); + self::assertSame('pid', $config['left']); + self::assertSame(SqlEquationOperator::EQUALS, $config['operator']); + self::assertSame('42', $config['right']); + } + + public function testTransformsEmptyModelToDefaults(): void + { + $config = $this->transform([ + 'intrinsic' => '', + 'equationLeft' => '', + 'equationOperator' => '', + 'equationRight' => null, + ]); + + self::assertFalse($config['intrinsic']); + self::assertNull($config['left']); + self::assertNull($config['operator']); + self::assertNull($config['right']); + } + + public function testTransformSatisfiesTheElementSchema(): void + { + $element = new SimpleEquationFilterElement(); + + $resolver = new OptionsResolver(); + $element->configureOptions($resolver); + + $resolved = $resolver->resolve($this->transform([ + 'equationLeft' => 'id', + 'equationOperator' => '>', + ])); + + self::assertSame('id', $resolved['left']); + self::assertSame(SqlEquationOperator::GREATER_THAN, $resolved['operator']); + } + + /** + * @return array + */ + private function transform(array $row): array + { + $element = new SimpleEquationFilterElement(); + + $transformers = new TransformerBuilder(); + $element->configureTransformers($transformers); + + $transformer = $transformers->resolve($model = new FilterModelStub($row)); + self::assertNotNull($transformer); + + $transformer($model, $config = new ConfigBuilder()); + + return $config->all(); + } +} + +final class FilterModelStub extends FilterModel +{ + public function __construct(array $row = []) + { + $this->arrData = $row; + } +} diff --git a/tests/Filter/FilterTransformerResolverTest.php b/tests/Filter/FilterTransformerResolverTest.php new file mode 100644 index 00000000..fa357aab --- /dev/null +++ b/tests/Filter/FilterTransformerResolverTest.php @@ -0,0 +1,114 @@ +transform($element, 'test', new RowSource(['value' => 'x'])); + + self::assertSame(['value' => 'x'], $config); + } + + public function testReturnsNullWithoutMatchingTransformer(): void + { + $resolver = new FilterTransformerResolver(new EventDispatcher()); + + self::assertNull($resolver->transform(new TransformingElement(), 'test', new \stdClass())); + self::assertNull($resolver->transform(new PlainTransformerlessElement(), 'test', new RowSource([]))); + } + + public function testMemoizesBuilderAndDispatchesEventOncePerElementClass(): void + { + $dispatched = 0; + + $dispatcher = new EventDispatcher(); + $dispatcher->addListener(FilterTransformerEvent::class, static function () use (&$dispatched): void { + $dispatched++; + }); + + $resolver = new FilterTransformerResolver($dispatcher); + $element = new TransformingElement(); + + $resolver->transform($element, 'test', new RowSource([])); + $resolver->transform($element, 'test', new RowSource([])); + + self::assertSame(1, $dispatched); + } + + public function testEventListenersCanAddSourceCapabilities(): void + { + $dispatcher = new EventDispatcher(); + $dispatcher->addListener( + FilterTransformerEvent::class, + static function (FilterTransformerEvent $event): void { + $event->transformers->for( + \stdClass::class, + static fn (object $source, ConfigBuilder $config) => $config->set('external', true), + ); + }, + ); + + $resolver = new FilterTransformerResolver($dispatcher); + + $config = $resolver->transform(new PlainTransformerlessElement(), 'test', new \stdClass()); + + self::assertSame(['external' => true], $config); + } +} + +final class RowSource +{ + public function __construct( + public array $row = [], + ) {} +} + +final class TransformingElement implements FilterElementInterface, TransformerContract +{ + public function configureTransformers(TransformerBuilder $transformers): void + { + $transformers->for(RowSource::class, static function (RowSource $source, ConfigBuilder $config): void { + foreach ($source->row as $key => $value) { + $config->set($key, $value); + } + }); + } + + public function buildForm(FormBuilderInterface $builder, FilterContext $context): void + { + } + + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void + { + } +} + +final class PlainTransformerlessElement implements FilterElementInterface +{ + public function buildForm(FormBuilderInterface $builder, FilterContext $context): void + { + } + + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void + { + } +} diff --git a/tests/Lists/BaseListOptionsTest.php b/tests/Lists/BaseListOptionsTest.php new file mode 100644 index 00000000..7fb42064 --- /dev/null +++ b/tests/Lists/BaseListOptionsTest.php @@ -0,0 +1,80 @@ + '5', + 'title' => 'My List', + 'published' => '1', + 'jumpToListView' => '', + 'jumpToReader' => '12', + 'sortSettings' => \serialize([['column' => 'title', 'direction' => 'ASC']]), + 'metaTitleFormat' => '', + 'fieldAutoItem' => 'alias', + 'hasParent' => '1', + 'fieldPid' => 'pid', + 'whichPtable' => 'auto', + ]); + + BaseListOptions::transform($model, $config = new ConfigBuilder()); + $all = $config->all(); + + self::assertSame(5, $all['id']); + self::assertSame('My List', $all['title']); + self::assertTrue($all['published']); + self::assertNull($all['jumpToListView']); + self::assertSame(12, $all['jumpToReader']); + self::assertSame([['column' => 'title', 'direction' => 'ASC']], $all['sortSettings']); + self::assertNull($all['metaTitleFormat']); + self::assertSame('alias', $all['fieldAutoItem']); + self::assertTrue($all['hasParent']); + self::assertSame('pid', $all['fieldPid']); + self::assertSame('auto', $all['whichPtable']); + self::assertFalse($all['comments_enabled']); + } + + public function testSchemaProvidesDefaultsForEmptyConfig(): void + { + $resolved = (new ListOptionsResolver())->resolve(null, []); + + self::assertNull($resolved['id']); + self::assertSame('', $resolved['title']); + self::assertFalse($resolved['published']); + self::assertSame([], $resolved['sortSettings']); + self::assertNull($resolved['metaTitleFormat']); + self::assertSame('', $resolved['whichPtable']); + self::assertFalse($resolved['genericPageMeta']); + } + + public function testTransformedRowSatisfiesTheSchema(): void + { + $model = new ListModelStub(['id' => '3', 'title' => 'x', 'sortSettings' => '']); + + BaseListOptions::transform($model, $config = new ConfigBuilder()); + + $resolved = (new ListOptionsResolver())->resolve(null, $config->all()); + + self::assertSame(3, $resolved['id']); + self::assertSame([], $resolved['sortSettings']); + } +} + +class ListModelStub extends ListModel +{ + public function __construct(array $row = []) + { + $this->arrData = $row; + } +} diff --git a/tests/Lists/ListBuilderTest.php b/tests/Lists/ListBuilderTest.php new file mode 100644 index 00000000..130562ed --- /dev/null +++ b/tests/Lists/ListBuilderTest.php @@ -0,0 +1,126 @@ +addListener(ListBuildEvent::class, static function (ListBuildEvent $event) use (&$dispatchedWith): void { + $dispatchedWith = $event->builder; + $event->builder->addFilter(new Filter(element: 'from_event', alias: 'via_event')); + }); + + $type = new class extends AbstractListType implements BuildListContract { + public int $buildListCalls = 0; + + public function buildList(ListBuilder $builder): void + { + $this->buildListCalls++; + $builder->addFilter(new Filter(element: 'from_hook', alias: 'via_hook')); + } + }; + + $builder = $this->createBuilder($dispatcher, typeService: $type); + $spec = $builder->build(); + + self::assertSame(1, $type->buildListCalls); + self::assertSame($builder, $dispatchedWith); + self::assertArrayHasKey('via_hook', $spec->filters); + self::assertArrayHasKey('via_event', $spec->filters); + } + + public function testFiltersAndTypeCarryOverToTheSpec(): void + { + $builder = $this->createBuilder(new EventDispatcher()); + + $builder->addFilter(new Filter(element: 'a', alias: 'x')); + $builder->addFilter(new Filter(element: 'b')); + $builder->removeFilter('x'); + + self::assertTrue($builder->hasFilterOfType('b')); + self::assertFalse($builder->hasFilterOfType('a')); + + $spec = $builder->build(); + + self::assertSame('test_type', $spec->type); + self::assertSame('tl_test', $spec->dc); + self::assertSame('tl_flare_list.9', $spec->source); + self::assertArrayHasKey('_generated_0', $spec->filters); + self::assertArrayNotHasKey('x', $spec->filters); + } + + public function testModelTransformationAndOverridePrecedence(): void + { + $type = new class extends AbstractListType { + protected function transformListModel(ListModel $model, ConfigBuilder $config): void + { + $config->set('genericPageMeta', true); + $config->set('title', 'from-transformer'); + } + }; + + $builder = $this->createBuilder( + new EventDispatcher(), + typeService: $type, + model: new ListModelStub(['id' => '9', 'title' => 'from-model']), + ); + + $builder->set('title', 'from-override'); + + $config = $builder->build()->config; + + self::assertSame(9, $config['id']); // base transformation + self::assertTrue($config['genericPageMeta']); // type transformer over base + self::assertSame('from-override', $config['title']); // explicit override wins + } + + public function testInvalidConfigThrowsWithSourceProvenance(): void + { + $builder = $this->createBuilder(new EventDispatcher()); + $builder->set('unknown_key', 1); + + try + { + $builder->build(); + self::fail('Expected FlareException.'); + } + catch (FlareException $e) + { + self::assertSame('tl_flare_list.9', $e->getSource()); + } + } + + private function createBuilder( + EventDispatcher $dispatcher, + ?object $typeService = null, + ?ListModel $model = null, + ): ListBuilder { + return new ListBuilder( + optionsResolver: new ListOptionsResolver(), + eventDispatcher: $dispatcher, + type: 'test_type', + typeService: $typeService, + dc: 'tl_test', + model: $model, + source: 'tl_flare_list.9', + ); + } +} diff --git a/tests/Lists/ListSpecTest.php b/tests/Lists/ListSpecTest.php new file mode 100644 index 00000000..004d32d5 --- /dev/null +++ b/tests/Lists/ListSpecTest.php @@ -0,0 +1,83 @@ +withFilter(new Filter(element: 'flare_bool', alias: 'foo')); + + self::assertArrayHasKey('foo', $spec->filters); + } + + public function testWithFilterAcceptsExplicitKey(): void + { + $spec = (new ListSpec(type: 'test', dc: 'tl_test')) + ->withFilter(new Filter(element: 'flare_bool', alias: 'foo'), 'custom'); + + self::assertArrayHasKey('custom', $spec->filters); + self::assertArrayNotHasKey('foo', $spec->filters); + } + + public function testWithFilterGeneratesCollisionFreeKeysForAliasLessFilters(): void + { + $spec = (new ListSpec(type: 'test', dc: 'tl_test')) + ->withFilter(new Filter(element: 'a')) + ->withFilter(new Filter(element: 'b')); + + self::assertArrayHasKey('_generated_0', $spec->filters); + self::assertArrayHasKey('_generated_1', $spec->filters); + + $spec = $spec->withoutFilter('_generated_0')->withFilter(new Filter(element: 'c')); + + self::assertSame('c', $spec->filters['_generated_0']->element); + self::assertSame('b', $spec->filters['_generated_1']->element); + } + + public function testModifiersAreImmutable(): void + { + $original = new ListSpec(type: 'test', dc: 'tl_test', config: ['id' => 1]); + + $modified = $original + ->withFilter(new Filter(element: 'a', alias: 'x')) + ->withConfig(['id' => 2]); + + self::assertSame([], $original->filters); + self::assertSame(['id' => 1], $original->config); + self::assertNotSame($original, $modified); + self::assertSame(['id' => 2], $modified->config); + self::assertArrayHasKey('x', $modified->filters); + } + + public function testHasFilterOfType(): void + { + $spec = (new ListSpec(type: 'test', dc: 'tl_test')) + ->withFilter(new Filter(element: 'flare_published', alias: 'p')); + + self::assertTrue($spec->hasFilterOfType('flare_published')); + self::assertFalse($spec->hasFilterOfType('flare_bool')); + } + + public function testHashIsStableAndChangesWithContent(): void + { + $make = static fn (array $config = [], ?string $source = null): ListSpec => + new ListSpec(type: 'test', dc: 'tl_test', config: $config, source: $source); + + self::assertSame($make()->hash(), $make()->hash()); + self::assertNotSame($make()->hash(), $make(config: ['id' => 1])->hash()); + self::assertNotSame($make()->hash(), $make(source: 'tl_flare_list.5')->hash()); + self::assertNotSame( + $make()->hash(), + $make()->withFilter(new Filter(element: 'a', alias: 'x'))->hash(), + ); + } +} From 8270fd233b1254a86c7987802ff02fed607c9c13 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 14:01:33 +0200 Subject: [PATCH 25/71] refactor: exact-class precedence in `TransformerBuilder::resolve()`; finish `OptionsContract` rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve() now checks the source's exact class first (O(1), most-specific registration wins) before falling back to the instanceof scan that covers subclass/interface registrations — previously an earlier base-class registration shadowed a later, more specific one. Also completes the `OptionsInterface` → `OptionsContract` rename (`AbstractFilterElement` still implemented the old name) and fixes a stale docblock. --- src/Config/TransformerBuilder.php | 11 +++++++++-- .../{OptionsInterface.php => OptionsContract.php} | 4 ++-- src/Contract/TransformerContract.php | 2 +- .../ContentElement/ListViewController.php | 4 ++-- src/Controller/ContentElement/ReaderController.php | 14 +++++++------- src/Engine/Factory/EngineFactory.php | 9 +++------ src/Engine/Loader/AggregationLoaderConfig.php | 4 ++-- src/Engine/Loader/ValidationLoader.php | 4 ++-- src/Filter/Element/AbstractFilterElement.php | 4 ++-- src/Filter/Resolver/FilterOptionsResolver.php | 6 +++--- src/ListType/AbstractListType.php | 4 ++-- src/Lists/Resolver/ListOptionsResolver.php | 6 +++--- tests/Config/TransformerBuilderTest.php | 13 +++++++++++++ tests/Filter/FilterOptionsResolverTest.php | 4 ++-- 14 files changed, 53 insertions(+), 36 deletions(-) rename src/Contract/{OptionsInterface.php => OptionsContract.php} (88%) diff --git a/src/Config/TransformerBuilder.php b/src/Config/TransformerBuilder.php index 56a414c4..f309a5ee 100644 --- a/src/Config/TransformerBuilder.php +++ b/src/Config/TransformerBuilder.php @@ -30,13 +30,20 @@ public function for(string $sourceClass, callable $transformer): self } /** - * Returns the first registered transformer whose source class matches the given source, - * or null if none matches. + * Returns the transformer registered for the source's exact class, falling back to the + * first registration matching by inheritance (subclasses, interfaces); null if none matches. + * The exact-class fast path lets a specific registration win over an earlier base-class one. + * + * @param object $source The stored source object to be transformed. * * @return (callable(object, ConfigBuilder): void)|null */ public function resolve(object $source): ?callable { + if ($transformer = $this->transformers[$source::class] ?? null) { + return $transformer; + } + foreach ($this->transformers as $sourceClass => $transformer) { if ($source instanceof $sourceClass) { diff --git a/src/Contract/OptionsInterface.php b/src/Contract/OptionsContract.php similarity index 88% rename from src/Contract/OptionsInterface.php rename to src/Contract/OptionsContract.php index 4c324604..3a9d21d1 100644 --- a/src/Contract/OptionsInterface.php +++ b/src/Contract/OptionsContract.php @@ -6,7 +6,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; -interface OptionsInterface +interface OptionsContract { public function configureOptions(OptionsResolver $resolver): void; -} \ No newline at end of file +} diff --git a/src/Contract/TransformerContract.php b/src/Contract/TransformerContract.php index 40dea4d9..4051860d 100644 --- a/src/Contract/TransformerContract.php +++ b/src/Contract/TransformerContract.php @@ -10,7 +10,7 @@ * Implemented by filter elements and list types that own the translation from stored * sources (e.g. a DCA model) into their canonical config values. * - * Like {@see OptionsInterface::configureOptions()}, this is declarative, memoizable setup — + * Like {@see OptionsContract::configureOptions()}, this is declarative, memoizable setup — * the configured transformers are cached per class and run by the framework whenever a * source needs translating. */ diff --git a/src/Controller/ContentElement/ListViewController.php b/src/Controller/ContentElement/ListViewController.php index 356d4753..7373a106 100644 --- a/src/Controller/ContentElement/ListViewController.php +++ b/src/Controller/ContentElement/ListViewController.php @@ -41,7 +41,7 @@ public function __construct( private readonly EventDispatcherInterface $eventDispatcher, private readonly InteractiveContextFactory $interactiveConfigFactory, private readonly KernelInterface $kernel, - private readonly ListBuilderFactory $listFactory, + private readonly ListBuilderFactory $listFactory, private readonly LoggerInterface $logger, private readonly ScopeMatcher $scopeMatcher, private readonly SymfonyResponseTagger $responseTagger, @@ -174,4 +174,4 @@ protected function getBackendResponse(Template $template, ContentModel $model, R $listModel->dc )); } -} \ No newline at end of file +} diff --git a/src/Controller/ContentElement/ReaderController.php b/src/Controller/ContentElement/ReaderController.php index abf9d99e..4b0ae9aa 100644 --- a/src/Controller/ContentElement/ReaderController.php +++ b/src/Controller/ContentElement/ReaderController.php @@ -112,14 +112,14 @@ protected function getFrontendResponse(Template $template, ContentModel $content try { - $listSpec = $this->listFactory->createFromListModel($listModel)->build(); + $list = $this->listFactory->createFromListModel($listModel)->build(); $validationContext = $this->validationContextFactory->createFromContent( contentModel: $contentModel, - list: $listSpec, + list: $list, ); - $engine = $this->engineFactory->createEngine($validationContext, $listSpec); + $engine = $this->engineFactory->createEngine($validationContext, $list); $validationView = $engine->createView(); @@ -133,14 +133,14 @@ protected function getFrontendResponse(Template $template, ContentModel $content $errData[] = "{$autoItemModel::getTable()}.id={$autoItemModel->id}"; - $this->attributeResolver->store(new ReaderRequestAttribute($autoItemModel, $listSpec), $request); + $this->attributeResolver->store(new ReaderRequestAttribute($autoItemModel, $list), $request); $this->entityCacheTags->tagWith($autoItemModel); /** @var ReaderPageMetaEvent $pageMetaEvent $pageMetaEvent */ $pageMetaEvent = $this->eventDispatcher->dispatch(new ReaderPageMetaEvent( contentModel: $contentModel, displayModel: $autoItemModel, - list: $listSpec, + list: $list, )); $pageMeta = $pageMetaEvent->getPageMeta(); } @@ -157,7 +157,7 @@ protected function getFrontendResponse(Template $template, ContentModel $content contentModel: $contentModel, context: $validationContext, displayModel: $autoItemModel, - list: $listSpec, + list: $list, pageMeta: $pageMeta, template: $template, ) @@ -232,4 +232,4 @@ protected function getBackendResponse(Template $template, ContentModel $model, R $listModel->dc, )); } -} \ No newline at end of file +} diff --git a/src/Engine/Factory/EngineFactory.php b/src/Engine/Factory/EngineFactory.php index 6cec495f..3f2d1f58 100644 --- a/src/Engine/Factory/EngineFactory.php +++ b/src/Engine/Factory/EngineFactory.php @@ -17,11 +17,8 @@ public function __construct( private ProjectorRegistry $projectorRegistry, ) {} - public function createEngine( - ContextInterface $context, - ListSpec $list, - array $mods = [], - ): Engine { + public function createEngine(ContextInterface $context, ListSpec $list, array $mods = []): Engine + { return new Engine( engineModRegistry: $this->engineModRegistry, projectorRegistry: $this->projectorRegistry, @@ -30,4 +27,4 @@ public function createEngine( mods: $mods, ); } -} \ No newline at end of file +} diff --git a/src/Engine/Loader/AggregationLoaderConfig.php b/src/Engine/Loader/AggregationLoaderConfig.php index b490c6b0..32f3a8e7 100644 --- a/src/Engine/Loader/AggregationLoaderConfig.php +++ b/src/Engine/Loader/AggregationLoaderConfig.php @@ -10,8 +10,8 @@ readonly class AggregationLoaderConfig { public function __construct( - public ListSpec $list, + public ListSpec $list, public AggregationContext $context, public array $filterValues, ) {} -} \ No newline at end of file +} diff --git a/src/Engine/Loader/ValidationLoader.php b/src/Engine/Loader/ValidationLoader.php index 32305c1c..90dbd724 100644 --- a/src/Engine/Loader/ValidationLoader.php +++ b/src/Engine/Loader/ValidationLoader.php @@ -95,10 +95,10 @@ public function fetchEntryByAutoItem(string $autoItem): ?array /** * @throws \Exception */ - private function executeQuery(ListSpec $spec, ValidationContext $context): ?array + private function executeQuery(ListSpec $list, ValidationContext $context): ?array { $qb = $this->listQueryDirector->createQueryBuilder(new ListQueryConfig( - list: $spec, + list: $list, context: $context, filterValues: $context->getFilterValues(), )); diff --git a/src/Filter/Element/AbstractFilterElement.php b/src/Filter/Element/AbstractFilterElement.php index 4c5739ee..6153799d 100644 --- a/src/Filter/Element/AbstractFilterElement.php +++ b/src/Filter/Element/AbstractFilterElement.php @@ -8,7 +8,7 @@ use HeimrichHannot\FlareBundle\Config\TransformerBuilder; use HeimrichHannot\FlareBundle\Contract\DcaContract; use HeimrichHannot\FlareBundle\Contract\IsSupportedContract; -use HeimrichHannot\FlareBundle\Contract\OptionsInterface; +use HeimrichHannot\FlareBundle\Contract\OptionsContract; use HeimrichHannot\FlareBundle\Contract\TransformerContract; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; @@ -19,7 +19,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; abstract class AbstractFilterElement implements - FilterElementInterface, OptionsInterface, TransformerContract, IsSupportedContract, DcaContract + FilterElementInterface, OptionsContract, TransformerContract, IsSupportedContract, DcaContract { abstract public function configureOptions(OptionsResolver $resolver): void; diff --git a/src/Filter/Resolver/FilterOptionsResolver.php b/src/Filter/Resolver/FilterOptionsResolver.php index d3fa137b..3465764b 100644 --- a/src/Filter/Resolver/FilterOptionsResolver.php +++ b/src/Filter/Resolver/FilterOptionsResolver.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\Filter\Resolver; -use HeimrichHannot\FlareBundle\Contract\OptionsInterface; +use HeimrichHannot\FlareBundle\Contract\OptionsContract; use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; @@ -12,7 +12,7 @@ /** * Resolves a filter's canonical config through the element's declared schema. - * Elements without an {@see OptionsInterface} receive their config verbatim (unvalidated). + * Elements without an {@see OptionsContract} receive their config verbatim (unvalidated). */ class FilterOptionsResolver { @@ -28,7 +28,7 @@ class FilterOptionsResolver */ public function resolve(Filter $filter, FilterElementInterface $element): array { - if (!$element instanceof OptionsInterface) { + if (!$element instanceof OptionsContract) { return $filter->config; } diff --git a/src/ListType/AbstractListType.php b/src/ListType/AbstractListType.php index b770b1ae..9871c7ed 100644 --- a/src/ListType/AbstractListType.php +++ b/src/ListType/AbstractListType.php @@ -7,7 +7,7 @@ use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\Config\TransformerBuilder; use HeimrichHannot\FlareBundle\Contract; -use HeimrichHannot\FlareBundle\Contract\OptionsInterface; +use HeimrichHannot\FlareBundle\Contract\OptionsContract; use HeimrichHannot\FlareBundle\Contract\TransformerContract; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Query\SqlQueryStruct; @@ -15,7 +15,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; abstract class AbstractListType implements - ListTypeInterface, OptionsInterface, TransformerContract, Contract\ListType\BuildQueryContract + ListTypeInterface, OptionsContract, TransformerContract, Contract\ListType\BuildQueryContract { /** * Declares the type's config schema on top of {@see \HeimrichHannot\FlareBundle\Lists\BaseListOptions}. diff --git a/src/Lists/Resolver/ListOptionsResolver.php b/src/Lists/Resolver/ListOptionsResolver.php index a40970e9..3f575d08 100644 --- a/src/Lists/Resolver/ListOptionsResolver.php +++ b/src/Lists/Resolver/ListOptionsResolver.php @@ -4,14 +4,14 @@ namespace HeimrichHannot\FlareBundle\Lists\Resolver; -use HeimrichHannot\FlareBundle\Contract\OptionsInterface; +use HeimrichHannot\FlareBundle\Contract\OptionsContract; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Lists\BaseListOptions; use Symfony\Component\OptionsResolver\OptionsResolver; /** * Resolves a list's canonical config through the framework's base schema plus the list - * type's declared schema ({@see OptionsInterface}). The combined resolver is memoized + * type's declared schema ({@see OptionsContract}). The combined resolver is memoized * per type class. */ class ListOptionsResolver @@ -37,7 +37,7 @@ public function resolve(?object $typeService, array $config, ?string $source = n $resolver = new OptionsResolver(); BaseListOptions::configureOptions($resolver); - if ($typeService instanceof OptionsInterface) { + if ($typeService instanceof OptionsContract) { $typeService->configureOptions($resolver); } diff --git a/tests/Config/TransformerBuilderTest.php b/tests/Config/TransformerBuilderTest.php index 85e0f818..5bbb1eff 100644 --- a/tests/Config/TransformerBuilderTest.php +++ b/tests/Config/TransformerBuilderTest.php @@ -50,6 +50,19 @@ public function testReturnsNullWithoutMatch(): void self::assertNull($transformers->resolve(new SourceB())); } + + public function testExactClassMatchWinsOverEarlierBaseClassRegistration(): void + { + $transformers = new TransformerBuilder(); + $base = static function (object $source, ConfigBuilder $config): void {}; + $specific = static function (object $source, ConfigBuilder $config): void {}; + + $transformers->for(SourceA::class, $base); + $transformers->for(SourceASub::class, $specific); + + self::assertSame($specific, $transformers->resolve(new SourceASub())); + self::assertSame($base, $transformers->resolve(new SourceA())); + } } class SourceA diff --git a/tests/Filter/FilterOptionsResolverTest.php b/tests/Filter/FilterOptionsResolverTest.php index 57ae7f06..76a40e8c 100644 --- a/tests/Filter/FilterOptionsResolverTest.php +++ b/tests/Filter/FilterOptionsResolverTest.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\Tests\Filter; -use HeimrichHannot\FlareBundle\Contract\OptionsInterface; +use HeimrichHannot\FlareBundle\Contract\OptionsContract; use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; @@ -57,7 +57,7 @@ public function testWrapsSchemaViolationsInFilterException(): void } } -final class ElementConfigAwareElement implements FilterElementInterface, OptionsInterface +final class ElementConfigAwareElement implements FilterElementInterface, OptionsContract { public function configureOptions(OptionsResolver $resolver): void { From 8afb1e7b86ee20ef4cc740c8e85661c26fdbd7f8 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 14:05:02 +0200 Subject: [PATCH 26/71] refactor: rename `Lists` namespace to singular `List` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `HeimrichHannot\FlareBundle\Lists` → `HeimrichHannot\FlareBundle\List` (src/List/, tests/List/). `List` is a valid namespace segment on PHP >= 8.0 (the bundle requires ^8.2); only a bare `class List` would be reserved. --- AGENTS.md | 4 ++-- config/services.yaml | 2 +- src/Contract/ListType/BuildListContract.php | 2 +- src/Controller/ContentElement/ListViewController.php | 2 +- src/Controller/ContentElement/ReaderController.php | 2 +- src/Engine/Context/Factory/InteractiveContextFactory.php | 2 +- src/Engine/Context/Factory/ValidationContextFactory.php | 2 +- src/Engine/Engine.php | 2 +- src/Engine/Factory/EngineFactory.php | 2 +- src/Engine/Loader/AggregationLoaderConfig.php | 2 +- src/Engine/Loader/InteractiveLoaderConfig.php | 2 +- src/Engine/Loader/ValidationLoader.php | 2 +- src/Engine/Loader/ValidationLoaderConfig.php | 2 +- src/Engine/Projector/AbstractProjector.php | 2 +- src/Engine/Projector/AggregationProjector.php | 2 +- src/Engine/Projector/ExportProjector.php | 2 +- src/Engine/Projector/InteractiveProjector.php | 2 +- src/Engine/Projector/ProjectorInterface.php | 2 +- src/Engine/Projector/ValidationProjector.php | 2 +- src/Event/FilterFormBuildEvent.php | 2 +- src/Event/ListBuildEvent.php | 2 +- src/Event/QueryBaseInitializedEvent.php | 2 +- src/Event/ReaderPageMetaEvent.php | 2 +- src/Event/ReaderRenderEvent.php | 2 +- src/Event/ReaderSchemaOrgEvent.php | 2 +- src/EventListener/Contao/BreadcrumbListener.php | 2 +- src/EventListener/Contao/ElementDcaListener.php | 2 +- .../DataContainer/FlareFilter/FieldsOptionsCallbacks.php | 2 +- src/Filter/Element/ArchiveFilterElement.php | 2 +- src/Filter/Factory/FilterContextFactory.php | 2 +- src/Filter/FilterContext.php | 2 +- src/Form/Factory/FilterFormFactory.php | 2 +- src/InferPtable/Factory/PtableInferrableFactory.php | 2 +- src/Integration/ContaoCalendar/ListType/EventsListType.php | 2 +- .../ContaoCalendar/Projector/EventsAggregationProjector.php | 2 +- .../ContaoCalendar/Projector/EventsInteractiveProjector.php | 2 +- src/{Lists => List}/BaseListOptions.php | 2 +- src/{Lists => List}/Factory/ListBuilderFactory.php | 6 +++--- src/{Lists => List}/ListBuilder.php | 4 ++-- src/{Lists => List}/ListSpec.php | 2 +- src/{Lists => List}/Resolver/ListOptionsResolver.php | 4 ++-- src/ListType/AbstractListType.php | 4 ++-- src/ListType/NewsListType.php | 2 +- src/Query/Factory/ListExecutionContextFactory.php | 2 +- src/Query/ListQueryConfig.php | 2 +- src/Reader/Factory/ReaderRequestAttributeFactory.php | 2 +- src/Reader/ReaderRequestAttribute.php | 2 +- src/Registry/ProjectorRegistry.php | 2 +- src/Sort/Factory/SortOrderSequenceFactory.php | 2 +- src/Twig/Runtime/FlareRuntime.php | 2 +- tests/{Lists => List}/BaseListOptionsTest.php | 6 +++--- tests/{Lists => List}/ListBuilderTest.php | 6 +++--- tests/{Lists => List}/ListSpecTest.php | 4 ++-- 53 files changed, 64 insertions(+), 64 deletions(-) rename src/{Lists => List}/BaseListOptions.php (98%) rename src/{Lists => List}/Factory/ListBuilderFactory.php (92%) rename src/{Lists => List}/ListBuilder.php (97%) rename src/{Lists => List}/ListSpec.php (98%) rename src/{Lists => List}/Resolver/ListOptionsResolver.php (94%) rename tests/{Lists => List}/BaseListOptionsTest.php (93%) rename tests/{Lists => List}/ListBuilderTest.php (96%) rename tests/{Lists => List}/ListSpecTest.php (96%) diff --git a/AGENTS.md b/AGENTS.md index c12b6013..ba07ae6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ The core execution flow is: ``` ContentElement Controller - → ListBuilderFactory::createFromListModel(...)->build() — builds the immutable ListSpec (src/Lists/) + → ListBuilderFactory::createFromListModel(...)->build() — builds the immutable ListSpec (src/List/) → EngineFactory → Engine → Context (Interactive / Validation / Aggregation) — src/Engine/Context/ → Loader (src/Engine/Loader/) + Mods (src/Engine/Mod/) @@ -36,7 +36,7 @@ The bundle follows standard Symfony Bundle architecture with deep Contao integra (`buildDca`, `buildForm`, `buildFilter`, `buildList`, `buildTableRegistry`/`buildBaseQuery`). **Notable subsystems** (beyond the flow above): -- `src/Lists/` — `ListSpec` (immutable list DTO: type, dc, filters, canonical config, source), `ListBuilder` +- `src/List/` — `ListSpec` (immutable list DTO: type, dc, filters, canonical config, source), `ListBuilder` (build lifecycle: type's `buildList()` hook → `ListBuildEvent` → config assembly → schema resolution), `BaseListOptions` (framework-owned base schema for tl_flare_list columns) - `src/Filter/` — `Filter` DTO, elements (`Element/`), types (`Type/`), collector, resolvers diff --git a/config/services.yaml b/config/services.yaml index 01ada175..9d70fa42 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -11,7 +11,7 @@ services: resource: ../src exclude: - ../src/{Config,Contao,ContaoManager,Contract,DependencyInjection,Dto,Engine,Event,Integration,Model,Trait,Util} - - ../src/{Filter,Form,InferPtable,List,Lists,Paginator,Query,Sort}/*.php + - ../src/{Filter,Form,InferPtable,List,Paginator,Query,Sort}/*.php - ../src/DataContainer/Builder - ../src/Registry/Descriptor diff --git a/src/Contract/ListType/BuildListContract.php b/src/Contract/ListType/BuildListContract.php index c4ecc915..c2991c0f 100644 --- a/src/Contract/ListType/BuildListContract.php +++ b/src/Contract/ListType/BuildListContract.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\Contract\ListType; -use HeimrichHannot\FlareBundle\Lists\ListBuilder; +use HeimrichHannot\FlareBundle\List\ListBuilder; /** * Implemented by list types that take part in their list's build lifecycle — diff --git a/src/Controller/ContentElement/ListViewController.php b/src/Controller/ContentElement/ListViewController.php index 7373a106..683af70f 100644 --- a/src/Controller/ContentElement/ListViewController.php +++ b/src/Controller/ContentElement/ListViewController.php @@ -20,7 +20,7 @@ use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Model\ListModel; -use HeimrichHannot\FlareBundle\Lists\Factory\ListBuilderFactory; +use HeimrichHannot\FlareBundle\List\Factory\ListBuilderFactory; use HeimrichHannot\FlareBundle\Util\Str; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; diff --git a/src/Controller/ContentElement/ReaderController.php b/src/Controller/ContentElement/ReaderController.php index 4b0ae9aa..37a985ac 100644 --- a/src/Controller/ContentElement/ReaderController.php +++ b/src/Controller/ContentElement/ReaderController.php @@ -28,7 +28,7 @@ use HeimrichHannot\FlareBundle\Reader\Resolver\ReaderRequestAttributeResolver; use HeimrichHannot\FlareBundle\Reader\ReaderPageMeta; use HeimrichHannot\FlareBundle\Reader\ReaderRequestAttribute; -use HeimrichHannot\FlareBundle\Lists\Factory\ListBuilderFactory; +use HeimrichHannot\FlareBundle\List\Factory\ListBuilderFactory; use HeimrichHannot\FlareBundle\Util\Str; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; diff --git a/src/Engine/Context/Factory/InteractiveContextFactory.php b/src/Engine/Context/Factory/InteractiveContextFactory.php index 74d459ea..7827633d 100644 --- a/src/Engine/Context/Factory/InteractiveContextFactory.php +++ b/src/Engine/Context/Factory/InteractiveContextFactory.php @@ -7,7 +7,7 @@ use Contao\ContentModel; use HeimrichHannot\FlareBundle\DataContainer\ContentContainer; use HeimrichHannot\FlareBundle\Engine\Context\InteractiveContext; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Paginator\PaginatorConfig; use HeimrichHannot\FlareBundle\Sort\Factory\SortOrderSequenceFactory; use Symfony\Component\Validator\Exception\ValidationFailedException; diff --git a/src/Engine/Context/Factory/ValidationContextFactory.php b/src/Engine/Context/Factory/ValidationContextFactory.php index ec018942..1694d51a 100644 --- a/src/Engine/Context/Factory/ValidationContextFactory.php +++ b/src/Engine/Context/Factory/ValidationContextFactory.php @@ -8,7 +8,7 @@ use HeimrichHannot\FlareBundle\DataContainer\ContentContainer; use HeimrichHannot\FlareBundle\Engine\Context\ValidationContext; use HeimrichHannot\FlareBundle\Engine\View\InteractiveView; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; use Symfony\Component\Validator\Exception\ValidationFailedException; use Symfony\Component\Validator\Validator\ValidatorInterface; diff --git a/src/Engine/Engine.php b/src/Engine/Engine.php index 41e2c6a0..65286947 100644 --- a/src/Engine/Engine.php +++ b/src/Engine/Engine.php @@ -9,7 +9,7 @@ use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Registry\EngineModRegistry; use HeimrichHannot\FlareBundle\Registry\ProjectorRegistry; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; final class Engine { diff --git a/src/Engine/Factory/EngineFactory.php b/src/Engine/Factory/EngineFactory.php index 3f2d1f58..e91507e4 100644 --- a/src/Engine/Factory/EngineFactory.php +++ b/src/Engine/Factory/EngineFactory.php @@ -8,7 +8,7 @@ use HeimrichHannot\FlareBundle\Engine\Engine; use HeimrichHannot\FlareBundle\Registry\EngineModRegistry; use HeimrichHannot\FlareBundle\Registry\ProjectorRegistry; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; final readonly class EngineFactory { diff --git a/src/Engine/Loader/AggregationLoaderConfig.php b/src/Engine/Loader/AggregationLoaderConfig.php index 32f3a8e7..4dfa9611 100644 --- a/src/Engine/Loader/AggregationLoaderConfig.php +++ b/src/Engine/Loader/AggregationLoaderConfig.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\Engine\Loader; use HeimrichHannot\FlareBundle\Engine\Context\AggregationContext; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; readonly class AggregationLoaderConfig { diff --git a/src/Engine/Loader/InteractiveLoaderConfig.php b/src/Engine/Loader/InteractiveLoaderConfig.php index e40fc3b3..53eb1c51 100644 --- a/src/Engine/Loader/InteractiveLoaderConfig.php +++ b/src/Engine/Loader/InteractiveLoaderConfig.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\Engine\Loader; use HeimrichHannot\FlareBundle\Engine\Context\InteractiveContext; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; readonly class InteractiveLoaderConfig { diff --git a/src/Engine/Loader/ValidationLoader.php b/src/Engine/Loader/ValidationLoader.php index 90dbd724..0e085f4f 100644 --- a/src/Engine/Loader/ValidationLoader.php +++ b/src/Engine/Loader/ValidationLoader.php @@ -11,7 +11,7 @@ use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Query\Executor\ListQueryDirector; use HeimrichHannot\FlareBundle\Query\ListQueryConfig; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; readonly class ValidationLoader implements ValidationLoaderInterface { diff --git a/src/Engine/Loader/ValidationLoaderConfig.php b/src/Engine/Loader/ValidationLoaderConfig.php index b79138d2..70740f4b 100644 --- a/src/Engine/Loader/ValidationLoaderConfig.php +++ b/src/Engine/Loader/ValidationLoaderConfig.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\Engine\Loader; use HeimrichHannot\FlareBundle\Engine\Context\ValidationContext; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; readonly class ValidationLoaderConfig { diff --git a/src/Engine/Projector/AbstractProjector.php b/src/Engine/Projector/AbstractProjector.php index 04a465cc..fbae3ee1 100644 --- a/src/Engine/Projector/AbstractProjector.php +++ b/src/Engine/Projector/AbstractProjector.php @@ -13,7 +13,7 @@ use HeimrichHannot\FlareBundle\Query\ListQueryConfig; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; use HeimrichHannot\FlareBundle\Registry\ProjectorRegistry; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; diff --git a/src/Engine/Projector/AggregationProjector.php b/src/Engine/Projector/AggregationProjector.php index 887bd4cb..5ccb7e6c 100644 --- a/src/Engine/Projector/AggregationProjector.php +++ b/src/Engine/Projector/AggregationProjector.php @@ -10,7 +10,7 @@ use HeimrichHannot\FlareBundle\Engine\Loader\AggregationLoaderConfig; use HeimrichHannot\FlareBundle\Engine\Loader\AggregationLoaderInterface; use HeimrichHannot\FlareBundle\Engine\View\AggregationView; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; /** * @implements ProjectorInterface diff --git a/src/Engine/Projector/ExportProjector.php b/src/Engine/Projector/ExportProjector.php index def93cf5..eb2056c1 100644 --- a/src/Engine/Projector/ExportProjector.php +++ b/src/Engine/Projector/ExportProjector.php @@ -7,7 +7,7 @@ use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; use HeimrichHannot\FlareBundle\Engine\View\ExportView; use HeimrichHannot\FlareBundle\Engine\View\ViewInterface; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; /** * @implements ProjectorInterface diff --git a/src/Engine/Projector/InteractiveProjector.php b/src/Engine/Projector/InteractiveProjector.php index 1efd5d78..c3efba0a 100644 --- a/src/Engine/Projector/InteractiveProjector.php +++ b/src/Engine/Projector/InteractiveProjector.php @@ -20,7 +20,7 @@ use HeimrichHannot\FlareBundle\Paginator\Paginator; use HeimrichHannot\FlareBundle\Reader\Factory\ReaderUrlGeneratorFactory; use HeimrichHannot\FlareBundle\Reader\ReaderUrlGeneratorInterface; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; use Symfony\Component\Form\FormInterface; /** diff --git a/src/Engine/Projector/ProjectorInterface.php b/src/Engine/Projector/ProjectorInterface.php index 8e1fd45e..2e9fd724 100644 --- a/src/Engine/Projector/ProjectorInterface.php +++ b/src/Engine/Projector/ProjectorInterface.php @@ -6,7 +6,7 @@ use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; use HeimrichHannot\FlareBundle\Engine\View\ViewInterface; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; /** diff --git a/src/Engine/Projector/ValidationProjector.php b/src/Engine/Projector/ValidationProjector.php index db1e967d..4ad6172a 100644 --- a/src/Engine/Projector/ValidationProjector.php +++ b/src/Engine/Projector/ValidationProjector.php @@ -13,7 +13,7 @@ use HeimrichHannot\FlareBundle\Reader\BackLink; use HeimrichHannot\FlareBundle\Reader\Factory\ReaderUrlGeneratorFactory; use HeimrichHannot\FlareBundle\Reader\ReaderUrlGeneratorInterface; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; /** * @implements ProjectorInterface diff --git a/src/Event/FilterFormBuildEvent.php b/src/Event/FilterFormBuildEvent.php index 33e1ec8f..9d849928 100644 --- a/src/Event/FilterFormBuildEvent.php +++ b/src/Event/FilterFormBuildEvent.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\Event; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Contracts\EventDispatcher\Event; diff --git a/src/Event/ListBuildEvent.php b/src/Event/ListBuildEvent.php index 04680e88..ff927731 100644 --- a/src/Event/ListBuildEvent.php +++ b/src/Event/ListBuildEvent.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\Event; -use HeimrichHannot\FlareBundle\Lists\ListBuilder; +use HeimrichHannot\FlareBundle\List\ListBuilder; use Symfony\Contracts\EventDispatcher\Event; /** diff --git a/src/Event/QueryBaseInitializedEvent.php b/src/Event/QueryBaseInitializedEvent.php index 040781c1..61e21491 100644 --- a/src/Event/QueryBaseInitializedEvent.php +++ b/src/Event/QueryBaseInitializedEvent.php @@ -6,7 +6,7 @@ use HeimrichHannot\FlareBundle\Query\SqlQueryStruct; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; use Symfony\Contracts\EventDispatcher\Event; class QueryBaseInitializedEvent extends Event diff --git a/src/Event/ReaderPageMetaEvent.php b/src/Event/ReaderPageMetaEvent.php index a470b49b..d123c898 100644 --- a/src/Event/ReaderPageMetaEvent.php +++ b/src/Event/ReaderPageMetaEvent.php @@ -7,7 +7,7 @@ use Contao\ContentModel; use Contao\Model; use HeimrichHannot\FlareBundle\Reader\ReaderPageMeta; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; class ReaderPageMetaEvent { diff --git a/src/Event/ReaderRenderEvent.php b/src/Event/ReaderRenderEvent.php index 0ddcfb65..864fd872 100644 --- a/src/Event/ReaderRenderEvent.php +++ b/src/Event/ReaderRenderEvent.php @@ -9,7 +9,7 @@ use Contao\Template; use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; use HeimrichHannot\FlareBundle\Reader\ReaderPageMeta; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; use Symfony\Contracts\EventDispatcher\Event; class ReaderRenderEvent extends Event diff --git a/src/Event/ReaderSchemaOrgEvent.php b/src/Event/ReaderSchemaOrgEvent.php index 41975099..cd74300c 100644 --- a/src/Event/ReaderSchemaOrgEvent.php +++ b/src/Event/ReaderSchemaOrgEvent.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\Event; use Contao\Model; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; use Symfony\Contracts\EventDispatcher\Event; class ReaderSchemaOrgEvent extends Event diff --git a/src/EventListener/Contao/BreadcrumbListener.php b/src/EventListener/Contao/BreadcrumbListener.php index ca69f05e..37dc4a20 100644 --- a/src/EventListener/Contao/BreadcrumbListener.php +++ b/src/EventListener/Contao/BreadcrumbListener.php @@ -18,7 +18,7 @@ use HeimrichHannot\FlareBundle\Exception\ViewException; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Registry\ProjectorRegistry; -use HeimrichHannot\FlareBundle\Lists\Factory\ListBuilderFactory; +use HeimrichHannot\FlareBundle\List\Factory\ListBuilderFactory; use HeimrichHannot\FlareBundle\Util\Env; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; diff --git a/src/EventListener/Contao/ElementDcaListener.php b/src/EventListener/Contao/ElementDcaListener.php index 16e1f7b3..3cfa822e 100644 --- a/src/EventListener/Contao/ElementDcaListener.php +++ b/src/EventListener/Contao/ElementDcaListener.php @@ -16,7 +16,7 @@ use HeimrichHannot\FlareBundle\Query\ListExecutionContext; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; use HeimrichHannot\FlareBundle\Registry\ListTypeRegistry; -use HeimrichHannot\FlareBundle\Lists\Factory\ListBuilderFactory; +use HeimrichHannot\FlareBundle\List\Factory\ListBuilderFactory; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; diff --git a/src/EventListener/DataContainer/FlareFilter/FieldsOptionsCallbacks.php b/src/EventListener/DataContainer/FlareFilter/FieldsOptionsCallbacks.php index 6367079f..d6d4a8d8 100644 --- a/src/EventListener/DataContainer/FlareFilter/FieldsOptionsCallbacks.php +++ b/src/EventListener/DataContainer/FlareFilter/FieldsOptionsCallbacks.php @@ -17,7 +17,7 @@ use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; use HeimrichHannot\FlareBundle\Query\Factory\ListExecutionContextFactory; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; -use HeimrichHannot\FlareBundle\Lists\Factory\ListBuilderFactory; +use HeimrichHannot\FlareBundle\List\Factory\ListBuilderFactory; use HeimrichHannot\FlareBundle\Util\DateTimeHelper; use HeimrichHannot\FlareBundle\Util\DcaFieldFilter; use HeimrichHannot\FlareBundle\Util\DcaHelper; diff --git a/src/Filter/Element/ArchiveFilterElement.php b/src/Filter/Element/ArchiveFilterElement.php index a2ec72a8..161ed6a5 100644 --- a/src/Filter/Element/ArchiveFilterElement.php +++ b/src/Filter/Element/ArchiveFilterElement.php @@ -21,7 +21,7 @@ use HeimrichHannot\FlareBundle\InferPtable\Factory\PtableInferrableFactory; use HeimrichHannot\FlareBundle\InferPtable\PtableInferrer; use HeimrichHannot\FlareBundle\Model\FilterModel; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Util\Str; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\FormBuilderInterface; diff --git a/src/Filter/Factory/FilterContextFactory.php b/src/Filter/Factory/FilterContextFactory.php index 3bf97509..1400d60d 100644 --- a/src/Filter/Factory/FilterContextFactory.php +++ b/src/Filter/Factory/FilterContextFactory.php @@ -10,7 +10,7 @@ use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterOptionsResolver; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; /** * Builds the invocation context handed to filter elements, resolving the filter's diff --git a/src/Filter/FilterContext.php b/src/Filter/FilterContext.php index 500f431a..d6861c69 100644 --- a/src/Filter/FilterContext.php +++ b/src/Filter/FilterContext.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\Filter; use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; /** * Invocation context handed to filter elements, both when building the form diff --git a/src/Form/Factory/FilterFormFactory.php b/src/Form/Factory/FilterFormFactory.php index 1444bcbf..3f0605fe 100644 --- a/src/Form/Factory/FilterFormFactory.php +++ b/src/Form/Factory/FilterFormFactory.php @@ -13,7 +13,7 @@ use HeimrichHannot\FlareBundle\Filter\Factory\FilterContextFactory; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterElementResolver; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Util\Str; use Symfony\Component\Form\Extension\Core\Type\FormType; use Symfony\Component\Form\FormFactoryInterface; diff --git a/src/InferPtable/Factory/PtableInferrableFactory.php b/src/InferPtable/Factory/PtableInferrableFactory.php index eb6f2b3b..14fd66fc 100644 --- a/src/InferPtable/Factory/PtableInferrableFactory.php +++ b/src/InferPtable/Factory/PtableInferrableFactory.php @@ -10,7 +10,7 @@ class PtableInferrableFactory { /** * Creates an inferrable from a list's canonical config - * ({@see \HeimrichHannot\FlareBundle\Lists\ListSpec::$config}). + * ({@see \HeimrichHannot\FlareBundle\List\ListSpec::$config}). * * @param array $config */ diff --git a/src/Integration/ContaoCalendar/ListType/EventsListType.php b/src/Integration/ContaoCalendar/ListType/EventsListType.php index 918712c6..de66f770 100644 --- a/src/Integration/ContaoCalendar/ListType/EventsListType.php +++ b/src/Integration/ContaoCalendar/ListType/EventsListType.php @@ -12,7 +12,7 @@ use HeimrichHannot\FlareBundle\Filter\Element\PublishedFilterElement; use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\ListType\AbstractListType; -use HeimrichHannot\FlareBundle\Lists\ListBuilder; +use HeimrichHannot\FlareBundle\List\ListBuilder; use HeimrichHannot\FlareBundle\Query\JoinTypeEnum; use HeimrichHannot\FlareBundle\Query\SqlJoinStruct; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; diff --git a/src/Integration/ContaoCalendar/Projector/EventsAggregationProjector.php b/src/Integration/ContaoCalendar/Projector/EventsAggregationProjector.php index 9945b517..b11cfb35 100644 --- a/src/Integration/ContaoCalendar/Projector/EventsAggregationProjector.php +++ b/src/Integration/ContaoCalendar/Projector/EventsAggregationProjector.php @@ -12,7 +12,7 @@ use HeimrichHannot\FlareBundle\Integration\ContaoCalendar\GroupsEntriesTrait; use HeimrichHannot\FlareBundle\Integration\ContaoCalendar\ListType\EventsListType; use HeimrichHannot\FlareBundle\Integration\ContaoCalendar\Loader\EventsAggregationLoader; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; class EventsAggregationProjector extends AggregationProjector { diff --git a/src/Integration/ContaoCalendar/Projector/EventsInteractiveProjector.php b/src/Integration/ContaoCalendar/Projector/EventsInteractiveProjector.php index dbda8ddd..c3e3541b 100644 --- a/src/Integration/ContaoCalendar/Projector/EventsInteractiveProjector.php +++ b/src/Integration/ContaoCalendar/Projector/EventsInteractiveProjector.php @@ -15,7 +15,7 @@ use HeimrichHannot\FlareBundle\Integration\ContaoCalendar\View\InteractiveEventsView; use HeimrichHannot\FlareBundle\Paginator\Paginator; use HeimrichHannot\FlareBundle\Reader\ReaderUrlGeneratorInterface; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; use Symfony\Component\Form\FormInterface; class EventsInteractiveProjector extends InteractiveProjector diff --git a/src/Lists/BaseListOptions.php b/src/List/BaseListOptions.php similarity index 98% rename from src/Lists/BaseListOptions.php rename to src/List/BaseListOptions.php index 8479e251..52c5dab8 100644 --- a/src/Lists/BaseListOptions.php +++ b/src/List/BaseListOptions.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Lists; +namespace HeimrichHannot\FlareBundle\List; use Contao\StringUtil; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; diff --git a/src/Lists/Factory/ListBuilderFactory.php b/src/List/Factory/ListBuilderFactory.php similarity index 92% rename from src/Lists/Factory/ListBuilderFactory.php rename to src/List/Factory/ListBuilderFactory.php index 863c1982..c16a47bb 100644 --- a/src/Lists/Factory/ListBuilderFactory.php +++ b/src/List/Factory/ListBuilderFactory.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Lists\Factory; +namespace HeimrichHannot\FlareBundle\List\Factory; use HeimrichHannot\FlareBundle\Filter\Collector\ListModelFilterCollector; use HeimrichHannot\FlareBundle\ListType\ListTypeInterface; -use HeimrichHannot\FlareBundle\Lists\ListBuilder; -use HeimrichHannot\FlareBundle\Lists\Resolver\ListOptionsResolver; +use HeimrichHannot\FlareBundle\List\ListBuilder; +use HeimrichHannot\FlareBundle\List\Resolver\ListOptionsResolver; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Registry\ListTypeRegistry; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; diff --git a/src/Lists/ListBuilder.php b/src/List/ListBuilder.php similarity index 97% rename from src/Lists/ListBuilder.php rename to src/List/ListBuilder.php index 15864004..a71d0165 100644 --- a/src/Lists/ListBuilder.php +++ b/src/List/ListBuilder.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Lists; +namespace HeimrichHannot\FlareBundle\List; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\Config\TransformerBuilder; @@ -12,7 +12,7 @@ use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\ListType\ListTypeInterface; -use HeimrichHannot\FlareBundle\Lists\Resolver\ListOptionsResolver; +use HeimrichHannot\FlareBundle\List\Resolver\ListOptionsResolver; use HeimrichHannot\FlareBundle\Model\ListModel; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; diff --git a/src/Lists/ListSpec.php b/src/List/ListSpec.php similarity index 98% rename from src/Lists/ListSpec.php rename to src/List/ListSpec.php index 8db6b425..585d0d45 100644 --- a/src/Lists/ListSpec.php +++ b/src/List/ListSpec.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Lists; +namespace HeimrichHannot\FlareBundle\List; use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\ListType\ListTypeInterface; diff --git a/src/Lists/Resolver/ListOptionsResolver.php b/src/List/Resolver/ListOptionsResolver.php similarity index 94% rename from src/Lists/Resolver/ListOptionsResolver.php rename to src/List/Resolver/ListOptionsResolver.php index 3f575d08..84a40a61 100644 --- a/src/Lists/Resolver/ListOptionsResolver.php +++ b/src/List/Resolver/ListOptionsResolver.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Lists\Resolver; +namespace HeimrichHannot\FlareBundle\List\Resolver; use HeimrichHannot\FlareBundle\Contract\OptionsContract; use HeimrichHannot\FlareBundle\Exception\FlareException; -use HeimrichHannot\FlareBundle\Lists\BaseListOptions; +use HeimrichHannot\FlareBundle\List\BaseListOptions; use Symfony\Component\OptionsResolver\OptionsResolver; /** diff --git a/src/ListType/AbstractListType.php b/src/ListType/AbstractListType.php index 9871c7ed..80e25a0f 100644 --- a/src/ListType/AbstractListType.php +++ b/src/ListType/AbstractListType.php @@ -18,7 +18,7 @@ abstract class AbstractListType implements ListTypeInterface, OptionsContract, TransformerContract, Contract\ListType\BuildQueryContract { /** - * Declares the type's config schema on top of {@see \HeimrichHannot\FlareBundle\Lists\BaseListOptions}. + * Declares the type's config schema on top of {@see \HeimrichHannot\FlareBundle\List\BaseListOptions}. */ public function configureOptions(OptionsResolver $resolver): void {} @@ -29,7 +29,7 @@ public function configureTransformers(TransformerBuilder $transformers): void /** * Translates a stored tl_flare_list model into the type's canonical config values (unresolved). - * Base columns are already translated by {@see \HeimrichHannot\FlareBundle\Lists\BaseListOptions}. + * Base columns are already translated by {@see \HeimrichHannot\FlareBundle\List\BaseListOptions}. */ protected function transformListModel(ListModel $model, ConfigBuilder $config): void {} diff --git a/src/ListType/NewsListType.php b/src/ListType/NewsListType.php index 47c500e3..d0336b1e 100644 --- a/src/ListType/NewsListType.php +++ b/src/ListType/NewsListType.php @@ -11,7 +11,7 @@ use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListType; use HeimrichHannot\FlareBundle\Filter\Element\PublishedFilterElement; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\Lists\ListBuilder; +use HeimrichHannot\FlareBundle\List\ListBuilder; use HeimrichHannot\FlareBundle\Query\JoinTypeEnum; use HeimrichHannot\FlareBundle\Query\SqlJoinStruct; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; diff --git a/src/Query/Factory/ListExecutionContextFactory.php b/src/Query/Factory/ListExecutionContextFactory.php index 940ea83d..050b4b65 100644 --- a/src/Query/Factory/ListExecutionContextFactory.php +++ b/src/Query/Factory/ListExecutionContextFactory.php @@ -12,7 +12,7 @@ use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; use HeimrichHannot\FlareBundle\Registry\Descriptor\ListTypeDescriptor; use HeimrichHannot\FlareBundle\Registry\ListTypeRegistry; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; readonly class ListExecutionContextFactory diff --git a/src/Query/ListQueryConfig.php b/src/Query/ListQueryConfig.php index cbdb5226..d27c876f 100644 --- a/src/Query/ListQueryConfig.php +++ b/src/Query/ListQueryConfig.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\Query; use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; readonly class ListQueryConfig { diff --git a/src/Reader/Factory/ReaderRequestAttributeFactory.php b/src/Reader/Factory/ReaderRequestAttributeFactory.php index 6281f1dd..8a10921a 100644 --- a/src/Reader/Factory/ReaderRequestAttributeFactory.php +++ b/src/Reader/Factory/ReaderRequestAttributeFactory.php @@ -7,7 +7,7 @@ use Contao\Model; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Reader\ReaderRequestAttribute; -use HeimrichHannot\FlareBundle\Lists\Factory\ListBuilderFactory; +use HeimrichHannot\FlareBundle\List\Factory\ListBuilderFactory; final readonly class ReaderRequestAttributeFactory { diff --git a/src/Reader/ReaderRequestAttribute.php b/src/Reader/ReaderRequestAttribute.php index e54e7c3b..b5131fb6 100644 --- a/src/Reader/ReaderRequestAttribute.php +++ b/src/Reader/ReaderRequestAttribute.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\Reader; use Contao\Model; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; readonly class ReaderRequestAttribute { diff --git a/src/Registry/ProjectorRegistry.php b/src/Registry/ProjectorRegistry.php index f03cc1e7..a5d35e22 100644 --- a/src/Registry/ProjectorRegistry.php +++ b/src/Registry/ProjectorRegistry.php @@ -7,7 +7,7 @@ use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; use HeimrichHannot\FlareBundle\Engine\Projector\ProjectorInterface; use HeimrichHannot\FlareBundle\Exception\FlareException; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; readonly class ProjectorRegistry diff --git a/src/Sort/Factory/SortOrderSequenceFactory.php b/src/Sort/Factory/SortOrderSequenceFactory.php index 24c301cf..b3101d16 100644 --- a/src/Sort/Factory/SortOrderSequenceFactory.php +++ b/src/Sort/Factory/SortOrderSequenceFactory.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\Sort\Factory; use HeimrichHannot\FlareBundle\Exception\FlareException; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; use HeimrichHannot\FlareBundle\Sort\SortOrder; use HeimrichHannot\FlareBundle\Sort\SortOrderSequence; diff --git a/src/Twig/Runtime/FlareRuntime.php b/src/Twig/Runtime/FlareRuntime.php index 0dd3896e..254c9833 100644 --- a/src/Twig/Runtime/FlareRuntime.php +++ b/src/Twig/Runtime/FlareRuntime.php @@ -16,7 +16,7 @@ use HeimrichHannot\FlareBundle\Event\ReaderSchemaOrgEvent; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Registry\ProjectorRegistry; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Util\CallableWrapper; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Twig\Extension\RuntimeExtensionInterface; diff --git a/tests/Lists/BaseListOptionsTest.php b/tests/List/BaseListOptionsTest.php similarity index 93% rename from tests/Lists/BaseListOptionsTest.php rename to tests/List/BaseListOptionsTest.php index 7fb42064..7c6b9765 100644 --- a/tests/Lists/BaseListOptionsTest.php +++ b/tests/List/BaseListOptionsTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Tests\Lists; +namespace HeimrichHannot\FlareBundle\Tests\List; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; -use HeimrichHannot\FlareBundle\Lists\BaseListOptions; -use HeimrichHannot\FlareBundle\Lists\Resolver\ListOptionsResolver; +use HeimrichHannot\FlareBundle\List\BaseListOptions; +use HeimrichHannot\FlareBundle\List\Resolver\ListOptionsResolver; use HeimrichHannot\FlareBundle\Model\ListModel; use PHPUnit\Framework\TestCase; diff --git a/tests/Lists/ListBuilderTest.php b/tests/List/ListBuilderTest.php similarity index 96% rename from tests/Lists/ListBuilderTest.php rename to tests/List/ListBuilderTest.php index 130562ed..2aecc664 100644 --- a/tests/Lists/ListBuilderTest.php +++ b/tests/List/ListBuilderTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Tests\Lists; +namespace HeimrichHannot\FlareBundle\Tests\List; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\Contract\ListType\BuildListContract; @@ -10,8 +10,8 @@ use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\ListType\AbstractListType; -use HeimrichHannot\FlareBundle\Lists\ListBuilder; -use HeimrichHannot\FlareBundle\Lists\Resolver\ListOptionsResolver; +use HeimrichHannot\FlareBundle\List\ListBuilder; +use HeimrichHannot\FlareBundle\List\Resolver\ListOptionsResolver; use HeimrichHannot\FlareBundle\Model\ListModel; use PHPUnit\Framework\TestCase; use Symfony\Component\EventDispatcher\EventDispatcher; diff --git a/tests/Lists/ListSpecTest.php b/tests/List/ListSpecTest.php similarity index 96% rename from tests/Lists/ListSpecTest.php rename to tests/List/ListSpecTest.php index 004d32d5..fe47bf26 100644 --- a/tests/Lists/ListSpecTest.php +++ b/tests/List/ListSpecTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Tests\Lists; +namespace HeimrichHannot\FlareBundle\Tests\List; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\Lists\ListSpec; +use HeimrichHannot\FlareBundle\List\ListSpec; use PHPUnit\Framework\TestCase; final class ListSpecTest extends TestCase From bdb40763ac6dea8734989e0a254b8f124af83793 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 14:46:11 +0200 Subject: [PATCH 27/71] refactor: add missing imports, normalize namespaces, fix type names and List API usage --- src/Contract/ListType/BuildQueryContract.php | 2 +- .../ContentElement/ListViewController.php | 4 +-- .../ContentElement/ReaderController.php | 4 +-- src/Engine/Engine.php | 4 +-- src/Engine/Factory/EngineFactory.php | 2 +- src/Engine/Loader/ValidationLoader.php | 2 +- src/Engine/Projector/AbstractProjector.php | 4 +-- src/Engine/Projector/InteractiveProjector.php | 2 +- src/Engine/Projector/ValidationProjector.php | 4 +-- src/Event/FilterFormBuildEvent.php | 8 +++--- src/Event/QueryBaseInitializedEvent.php | 8 +++--- src/Event/ReaderPageMetaEvent.php | 12 ++++---- src/Event/ReaderRenderEvent.php | 4 +-- .../Contao/BreadcrumbListener.php | 4 +-- .../Contao/ElementDcaListener.php | 4 +-- .../FlareFilter/FieldsOptionsCallbacks.php | 8 +++--- .../NamedDispatch/FilterElementListener.php | 2 +- src/Filter/Element/ArchiveFilterElement.php | 2 +- src/Filter/Element/BooleanFilterElement.php | 2 +- .../Element/CalendarCurrentFilterElement.php | 2 +- src/Filter/Element/DateRangeFilterElement.php | 2 +- src/Filter/Element/PublishedFilterElement.php | 3 +- .../Element/SearchKeywordsFilterElement.php | 2 +- src/Filter/Resolver/FilterOptionsResolver.php | 2 +- .../CodefogTagsChoiceFilterElement.php | 2 +- .../ListType/EventsListType.php | 28 ++++++++++--------- .../Projector/EventsInteractiveProjector.php | 4 +-- .../EventListener/ChangelanguageListener.php | 2 +- .../ListType/DcMultilingualListType.php | 4 +-- src/List/BaseListOptions.php | 2 +- src/List/Factory/ListBuilderFactory.php | 2 +- src/List/ListBuilder.php | 4 +-- src/List/ListSpec.php | 2 +- .../Type}/AbstractListType.php | 2 +- .../Type}/GenericDataContainerListType.php | 2 +- .../Type}/ListTypeInterface.php | 2 +- src/{ListType => List/Type}/NewsListType.php | 2 +- src/Query/Executor/FilterExecutor.php | 2 +- .../Factory/ListExecutionContextFactory.php | 4 +-- .../Factory/ReaderRequestAttributeFactory.php | 4 +-- .../Descriptor/ListTypeDescriptor.php | 2 +- src/Twig/Extension/FlareExtension.php | 2 +- src/Twig/Runtime/FlareRuntime.php | 23 ++------------- tests/Filter/FilterOptionsResolverTest.php | 2 +- tests/Filter/FilterTest.php | 2 +- .../Filter/FilterTransformerResolverTest.php | 2 +- tests/List/BaseListOptionsTest.php | 5 ++-- tests/List/ListBuilderTest.php | 2 +- translations/flare_list.de.php | 6 ++-- translations/flare_list.en.php | 6 ++-- 50 files changed, 98 insertions(+), 115 deletions(-) rename src/{ListType => List/Type}/AbstractListType.php (96%) rename src/{ListType => List/Type}/GenericDataContainerListType.php (98%) rename src/{ListType => List/Type}/ListTypeInterface.php (77%) rename src/{ListType => List/Type}/NewsListType.php (97%) diff --git a/src/Contract/ListType/BuildQueryContract.php b/src/Contract/ListType/BuildQueryContract.php index 1930eecb..dc969a6d 100644 --- a/src/Contract/ListType/BuildQueryContract.php +++ b/src/Contract/ListType/BuildQueryContract.php @@ -12,4 +12,4 @@ interface BuildQueryContract public function buildTableRegistry(TableAliasRegistry $registry): void; public function buildBaseQuery(SqlQueryStruct $struct): void; -} \ No newline at end of file +} diff --git a/src/Controller/ContentElement/ListViewController.php b/src/Controller/ContentElement/ListViewController.php index 683af70f..feefea90 100644 --- a/src/Controller/ContentElement/ListViewController.php +++ b/src/Controller/ContentElement/ListViewController.php @@ -13,14 +13,14 @@ use Contao\StringUtil; use Contao\Template; use FOS\HttpCacheBundle\Http\SymfonyResponseTagger; +use HeimrichHannot\FlareBundle\DataContainer\ContentContainer; use HeimrichHannot\FlareBundle\Engine\Context\Factory\InteractiveContextFactory; use HeimrichHannot\FlareBundle\Engine\Factory\EngineFactory; -use HeimrichHannot\FlareBundle\DataContainer\ContentContainer; use HeimrichHannot\FlareBundle\Event\ListViewRenderEvent; use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Exception\FlareException; -use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\List\Factory\ListBuilderFactory; +use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Util\Str; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; diff --git a/src/Controller/ContentElement/ReaderController.php b/src/Controller/ContentElement/ReaderController.php index 37a985ac..ada1ad11 100644 --- a/src/Controller/ContentElement/ReaderController.php +++ b/src/Controller/ContentElement/ReaderController.php @@ -24,11 +24,11 @@ use HeimrichHannot\FlareBundle\Event\ReaderRenderEvent; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Exception\ViewException; +use HeimrichHannot\FlareBundle\List\Factory\ListBuilderFactory; use HeimrichHannot\FlareBundle\Model\ListModel; -use HeimrichHannot\FlareBundle\Reader\Resolver\ReaderRequestAttributeResolver; use HeimrichHannot\FlareBundle\Reader\ReaderPageMeta; use HeimrichHannot\FlareBundle\Reader\ReaderRequestAttribute; -use HeimrichHannot\FlareBundle\List\Factory\ListBuilderFactory; +use HeimrichHannot\FlareBundle\Reader\Resolver\ReaderRequestAttributeResolver; use HeimrichHannot\FlareBundle\Util\Str; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; diff --git a/src/Engine/Engine.php b/src/Engine/Engine.php index 65286947..7efb845f 100644 --- a/src/Engine/Engine.php +++ b/src/Engine/Engine.php @@ -7,9 +7,9 @@ use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; use HeimrichHannot\FlareBundle\Engine\View\ViewInterface; use HeimrichHannot\FlareBundle\Exception\FlareException; +use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Registry\EngineModRegistry; use HeimrichHannot\FlareBundle\Registry\ProjectorRegistry; -use HeimrichHannot\FlareBundle\List\ListSpec; final class Engine { @@ -121,4 +121,4 @@ public function __clone(): void { $this->context = clone $this->context; } -} \ No newline at end of file +} diff --git a/src/Engine/Factory/EngineFactory.php b/src/Engine/Factory/EngineFactory.php index e91507e4..b2742b7f 100644 --- a/src/Engine/Factory/EngineFactory.php +++ b/src/Engine/Factory/EngineFactory.php @@ -6,9 +6,9 @@ use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; use HeimrichHannot\FlareBundle\Engine\Engine; +use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Registry\EngineModRegistry; use HeimrichHannot\FlareBundle\Registry\ProjectorRegistry; -use HeimrichHannot\FlareBundle\List\ListSpec; final readonly class EngineFactory { diff --git a/src/Engine/Loader/ValidationLoader.php b/src/Engine/Loader/ValidationLoader.php index 0e085f4f..1dbef173 100644 --- a/src/Engine/Loader/ValidationLoader.php +++ b/src/Engine/Loader/ValidationLoader.php @@ -9,9 +9,9 @@ use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\Element\SimpleEquationFilterElement; use HeimrichHannot\FlareBundle\Filter\Filter; +use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Query\Executor\ListQueryDirector; use HeimrichHannot\FlareBundle\Query\ListQueryConfig; -use HeimrichHannot\FlareBundle\List\ListSpec; readonly class ValidationLoader implements ValidationLoaderInterface { diff --git a/src/Engine/Projector/AbstractProjector.php b/src/Engine/Projector/AbstractProjector.php index fbae3ee1..fbbca4ec 100644 --- a/src/Engine/Projector/AbstractProjector.php +++ b/src/Engine/Projector/AbstractProjector.php @@ -9,11 +9,11 @@ use HeimrichHannot\FlareBundle\Engine\View\ViewInterface; use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Exception\FlareException; +use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Query\Executor\ListQueryDirector; use HeimrichHannot\FlareBundle\Query\ListQueryConfig; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; use HeimrichHannot\FlareBundle\Registry\ProjectorRegistry; -use HeimrichHannot\FlareBundle\List\ListSpec; use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; @@ -119,4 +119,4 @@ protected function getCurrentRequest(): Request return $request; } -} \ No newline at end of file +} diff --git a/src/Engine/Projector/InteractiveProjector.php b/src/Engine/Projector/InteractiveProjector.php index c3efba0a..4a33ba51 100644 --- a/src/Engine/Projector/InteractiveProjector.php +++ b/src/Engine/Projector/InteractiveProjector.php @@ -16,11 +16,11 @@ use HeimrichHannot\FlareBundle\Engine\View\InteractiveView; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Form\Factory\FilterFormFactory; +use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Paginator\Factory\PaginatorFactory; use HeimrichHannot\FlareBundle\Paginator\Paginator; use HeimrichHannot\FlareBundle\Reader\Factory\ReaderUrlGeneratorFactory; use HeimrichHannot\FlareBundle\Reader\ReaderUrlGeneratorInterface; -use HeimrichHannot\FlareBundle\List\ListSpec; use Symfony\Component\Form\FormInterface; /** diff --git a/src/Engine/Projector/ValidationProjector.php b/src/Engine/Projector/ValidationProjector.php index 4ad6172a..97c40b3e 100644 --- a/src/Engine/Projector/ValidationProjector.php +++ b/src/Engine/Projector/ValidationProjector.php @@ -10,10 +10,10 @@ use HeimrichHannot\FlareBundle\Engine\Loader\ValidationLoaderConfig; use HeimrichHannot\FlareBundle\Engine\Loader\ValidationLoaderInterface; use HeimrichHannot\FlareBundle\Engine\View\ValidationView; +use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Reader\BackLink; use HeimrichHannot\FlareBundle\Reader\Factory\ReaderUrlGeneratorFactory; use HeimrichHannot\FlareBundle\Reader\ReaderUrlGeneratorInterface; -use HeimrichHannot\FlareBundle\List\ListSpec; /** * @implements ProjectorInterface @@ -74,4 +74,4 @@ protected function createView( backLink: $backLink, ); } -} \ No newline at end of file +} diff --git a/src/Event/FilterFormBuildEvent.php b/src/Event/FilterFormBuildEvent.php index 9d849928..aef09e0c 100644 --- a/src/Event/FilterFormBuildEvent.php +++ b/src/Event/FilterFormBuildEvent.php @@ -11,8 +11,8 @@ class FilterFormBuildEvent extends Event { public function __construct( - public readonly ListSpec $list, - public readonly string $formName, - public FormBuilderInterface $formBuilder, + public readonly ListSpec $list, + public readonly string $formName, + public FormBuilderInterface $formBuilder, ) {} -} \ No newline at end of file +} diff --git a/src/Event/QueryBaseInitializedEvent.php b/src/Event/QueryBaseInitializedEvent.php index 61e21491..cebf609d 100644 --- a/src/Event/QueryBaseInitializedEvent.php +++ b/src/Event/QueryBaseInitializedEvent.php @@ -4,16 +4,16 @@ namespace HeimrichHannot\FlareBundle\Event; +use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Query\SqlQueryStruct; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; -use HeimrichHannot\FlareBundle\List\ListSpec; use Symfony\Contracts\EventDispatcher\Event; class QueryBaseInitializedEvent extends Event { public function __construct( - public readonly ListSpec $list, + public readonly ListSpec $list, public readonly TableAliasRegistry $registry, - public readonly SqlQueryStruct $struct, + public readonly SqlQueryStruct $struct, ) {} -} \ No newline at end of file +} diff --git a/src/Event/ReaderPageMetaEvent.php b/src/Event/ReaderPageMetaEvent.php index d123c898..cd27ebe4 100644 --- a/src/Event/ReaderPageMetaEvent.php +++ b/src/Event/ReaderPageMetaEvent.php @@ -6,18 +6,18 @@ use Contao\ContentModel; use Contao\Model; -use HeimrichHannot\FlareBundle\Reader\ReaderPageMeta; use HeimrichHannot\FlareBundle\List\ListSpec; +use HeimrichHannot\FlareBundle\Reader\ReaderPageMeta; class ReaderPageMetaEvent { private ReaderPageMeta $pageMeta; public function __construct( - private readonly ContentModel $contentModel, - private readonly Model $displayModel, - private readonly ListSpec $list, - ?ReaderPageMeta $pageMeta = null, + private readonly ContentModel $contentModel, + private readonly Model $displayModel, + private readonly ListSpec $list, + ?ReaderPageMeta $pageMeta = null, ) { $this->pageMeta = $pageMeta ?? new ReaderPageMeta(); } @@ -46,4 +46,4 @@ public function setPageMeta(ReaderPageMeta $pageMeta): void { $this->pageMeta = $pageMeta; } -} \ No newline at end of file +} diff --git a/src/Event/ReaderRenderEvent.php b/src/Event/ReaderRenderEvent.php index 864fd872..6d7a369c 100644 --- a/src/Event/ReaderRenderEvent.php +++ b/src/Event/ReaderRenderEvent.php @@ -8,8 +8,8 @@ use Contao\Model; use Contao\Template; use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; -use HeimrichHannot\FlareBundle\Reader\ReaderPageMeta; use HeimrichHannot\FlareBundle\List\ListSpec; +use HeimrichHannot\FlareBundle\Reader\ReaderPageMeta; use Symfony\Contracts\EventDispatcher\Event; class ReaderRenderEvent extends Event @@ -68,4 +68,4 @@ public function setTemplate(Template $template): self return $this; } -} \ No newline at end of file +} diff --git a/src/EventListener/Contao/BreadcrumbListener.php b/src/EventListener/Contao/BreadcrumbListener.php index 37dc4a20..8c23d35a 100644 --- a/src/EventListener/Contao/BreadcrumbListener.php +++ b/src/EventListener/Contao/BreadcrumbListener.php @@ -16,9 +16,9 @@ use HeimrichHannot\FlareBundle\Engine\View\ValidationView; use HeimrichHannot\FlareBundle\Event\ReaderPageMetaEvent; use HeimrichHannot\FlareBundle\Exception\ViewException; +use HeimrichHannot\FlareBundle\List\Factory\ListBuilderFactory; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Registry\ProjectorRegistry; -use HeimrichHannot\FlareBundle\List\Factory\ListBuilderFactory; use HeimrichHannot\FlareBundle\Util\Env; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -166,4 +166,4 @@ public function tryGetReaderPageId(array $items): ?int return $lastPageId; } -} \ No newline at end of file +} diff --git a/src/EventListener/Contao/ElementDcaListener.php b/src/EventListener/Contao/ElementDcaListener.php index 3cfa822e..5903ca9c 100644 --- a/src/EventListener/Contao/ElementDcaListener.php +++ b/src/EventListener/Contao/ElementDcaListener.php @@ -10,13 +10,13 @@ use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\Event\ElementDcaEvent; +use HeimrichHannot\FlareBundle\List\Factory\ListBuilderFactory; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Query\Factory\ListExecutionContextFactory; use HeimrichHannot\FlareBundle\Query\ListExecutionContext; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; use HeimrichHannot\FlareBundle\Registry\ListTypeRegistry; -use HeimrichHannot\FlareBundle\List\Factory\ListBuilderFactory; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -35,7 +35,7 @@ public function __construct( private EventDispatcherInterface $eventDispatcher, private FilterElementRegistry $filterElementRegistry, private ListExecutionContextFactory $listExecutionContextFactory, - private ListBuilderFactory $listFactory, + private ListBuilderFactory $listFactory, private ListTypeRegistry $listTypeRegistry, private RequestStack $requestStack, ) {} diff --git a/src/EventListener/DataContainer/FlareFilter/FieldsOptionsCallbacks.php b/src/EventListener/DataContainer/FlareFilter/FieldsOptionsCallbacks.php index d6d4a8d8..4efdd456 100644 --- a/src/EventListener/DataContainer/FlareFilter/FieldsOptionsCallbacks.php +++ b/src/EventListener/DataContainer/FlareFilter/FieldsOptionsCallbacks.php @@ -12,12 +12,12 @@ use HeimrichHannot\FlareBundle\Contract\IsSupportedContract; use HeimrichHannot\FlareBundle\DataContainer\FilterContainer; use HeimrichHannot\FlareBundle\InferPtable\PtableInferrer; +use HeimrichHannot\FlareBundle\List\Factory\ListBuilderFactory; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Model\ListModel; -use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; use HeimrichHannot\FlareBundle\Query\Factory\ListExecutionContextFactory; +use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; -use HeimrichHannot\FlareBundle\List\Factory\ListBuilderFactory; use HeimrichHannot\FlareBundle\Util\DateTimeHelper; use HeimrichHannot\FlareBundle\Util\DcaFieldFilter; use HeimrichHannot\FlareBundle\Util\DcaHelper; @@ -36,7 +36,7 @@ public function __construct( private FilterContainer $filterContainer, private FilterElementRegistry $filterElementRegistry, private TranslatorInterface $translator, - private ListBuilderFactory $listFactory, + private ListBuilderFactory $listFactory, private ListExecutionContextFactory $listExecutionContextFactory, ) {} @@ -275,4 +275,4 @@ public function getFormatOptions(string $field, ?string $prefix = null): array /** @noinspection PhpTranslationDomainInspection */ return ['custom' => $this->translator->trans("tl_flare_filter.{$field}_custom", [], 'contao_tl_flare_filter')] + $options; } -} \ No newline at end of file +} diff --git a/src/EventListener/NamedDispatch/FilterElementListener.php b/src/EventListener/NamedDispatch/FilterElementListener.php index 3c6ed280..74f29219 100644 --- a/src/EventListener/NamedDispatch/FilterElementListener.php +++ b/src/EventListener/NamedDispatch/FilterElementListener.php @@ -4,8 +4,8 @@ namespace HeimrichHannot\FlareBundle\EventListener\NamedDispatch; -use HeimrichHannot\FlareBundle\Event\FilterElementBuiltEvent; use HeimrichHannot\FlareBundle\Event\FilterElementBuildingEvent; +use HeimrichHannot\FlareBundle\Event\FilterElementBuiltEvent; use HeimrichHannot\FlareBundle\Event\FilterElementFormBuiltEvent; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; diff --git a/src/Filter/Element/ArchiveFilterElement.php b/src/Filter/Element/ArchiveFilterElement.php index 161ed6a5..d94b15f6 100644 --- a/src/Filter/Element/ArchiveFilterElement.php +++ b/src/Filter/Element/ArchiveFilterElement.php @@ -20,8 +20,8 @@ use HeimrichHannot\FlareBundle\Form\Factory\ChoicesBuilderFactory; use HeimrichHannot\FlareBundle\InferPtable\Factory\PtableInferrableFactory; use HeimrichHannot\FlareBundle\InferPtable\PtableInferrer; -use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\List\ListSpec; +use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Util\Str; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\FormBuilderInterface; diff --git a/src/Filter/Element/BooleanFilterElement.php b/src/Filter/Element/BooleanFilterElement.php index 2d412390..c915581c 100644 --- a/src/Filter/Element/BooleanFilterElement.php +++ b/src/Filter/Element/BooleanFilterElement.php @@ -13,9 +13,9 @@ use HeimrichHannot\FlareBundle\Enum\BoolBinaryChoices; use HeimrichHannot\FlareBundle\Enum\BoolMode; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; -use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\BooleanFilterType; +use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; diff --git a/src/Filter/Element/CalendarCurrentFilterElement.php b/src/Filter/Element/CalendarCurrentFilterElement.php index 5ef7f9a3..4743da05 100644 --- a/src/Filter/Element/CalendarCurrentFilterElement.php +++ b/src/Filter/Element/CalendarCurrentFilterElement.php @@ -9,10 +9,10 @@ use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Engine\Context\ValidationContext; -use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\CalendarCurrentFilterType; +use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Util\DateTimeHelper; use Symfony\Component\Form\Extension\Core\Type\DateType; use Symfony\Component\Form\FormBuilderInterface; diff --git a/src/Filter/Element/DateRangeFilterElement.php b/src/Filter/Element/DateRangeFilterElement.php index 76ca969b..10c6de4b 100644 --- a/src/Filter/Element/DateRangeFilterElement.php +++ b/src/Filter/Element/DateRangeFilterElement.php @@ -9,10 +9,10 @@ use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Exception\FilterException; -use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\DateRangeFilterType; +use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\Form\Extension\Core\Type\DateType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormError; diff --git a/src/Filter/Element/PublishedFilterElement.php b/src/Filter/Element/PublishedFilterElement.php index 2fe57379..16bff5c4 100644 --- a/src/Filter/Element/PublishedFilterElement.php +++ b/src/Filter/Element/PublishedFilterElement.php @@ -9,9 +9,9 @@ use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; -use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\PublishedFilterType; +use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE, intrinsicOnly: true)] @@ -59,5 +59,4 @@ public function buildDca(DcaBuilder $dca, DcaContext $context): void { $dca->palette('{filter_legend},usePublished,useStart,useStop'); } - } diff --git a/src/Filter/Element/SearchKeywordsFilterElement.php b/src/Filter/Element/SearchKeywordsFilterElement.php index 9dc8ef93..a0c3941e 100644 --- a/src/Filter/Element/SearchKeywordsFilterElement.php +++ b/src/Filter/Element/SearchKeywordsFilterElement.php @@ -10,9 +10,9 @@ use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; -use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\SearchKeywordsFilterType; +use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; diff --git a/src/Filter/Resolver/FilterOptionsResolver.php b/src/Filter/Resolver/FilterOptionsResolver.php index 3465764b..f49d0d30 100644 --- a/src/Filter/Resolver/FilterOptionsResolver.php +++ b/src/Filter/Resolver/FilterOptionsResolver.php @@ -6,8 +6,8 @@ use HeimrichHannot\FlareBundle\Contract\OptionsContract; use HeimrichHannot\FlareBundle\Exception\FilterException; -use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; +use HeimrichHannot\FlareBundle\Filter\Filter; use Symfony\Component\OptionsResolver\OptionsResolver; /** diff --git a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php index 6f62d9ff..0a268560 100644 --- a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php +++ b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php @@ -9,9 +9,9 @@ use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; +use HeimrichHannot\FlareBundle\Filter\Element\AbstractFilterElement; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; -use HeimrichHannot\FlareBundle\Filter\Element\AbstractFilterElement; use HeimrichHannot\FlareBundle\Filter\Type\IntegerIdChoiceFilterType; use HeimrichHannot\FlareBundle\Form\Factory\ChoicesBuilderFactory; use HeimrichHannot\FlareBundle\Integration\CodefogTags\Registry\CfgTagsJoinsRegistry; diff --git a/src/Integration/ContaoCalendar/ListType/EventsListType.php b/src/Integration/ContaoCalendar/ListType/EventsListType.php index de66f770..5c178b0a 100644 --- a/src/Integration/ContaoCalendar/ListType/EventsListType.php +++ b/src/Integration/ContaoCalendar/ListType/EventsListType.php @@ -11,8 +11,8 @@ use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListType; use HeimrichHannot\FlareBundle\Filter\Element\PublishedFilterElement; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\ListType\AbstractListType; use HeimrichHannot\FlareBundle\List\ListBuilder; +use HeimrichHannot\FlareBundle\List\Type\AbstractListType; use HeimrichHannot\FlareBundle\Query\JoinTypeEnum; use HeimrichHannot\FlareBundle\Query\SqlJoinStruct; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; @@ -31,7 +31,7 @@ public function buildDca(DcaBuilder $dca, DcaContext $context): void return $suffix; } - $suffix = \str_replace('sortSettings', '', $suffix); + $suffix = (string) \str_replace('sortSettings', '', $suffix); $suffix = \preg_replace('/(?:^|;)\{[^}]*},*(?:;|$)/', ';', $suffix); $suffix = \preg_replace('/;{2,}/', ';', $suffix); @@ -54,17 +54,19 @@ public function buildTableRegistry(TableAliasRegistry $registry): void public function buildList(ListBuilder $builder): void { - if (!$builder->hasFilterOfType(PublishedFilterElement::TYPE)) { - $builder->addFilter(new Filter( - element: PublishedFilterElement::TYPE, - config: [ - 'intrinsic' => true, - 'published_field' => 'published', - 'start_field' => 'start', - 'stop_field' => 'stop', - 'invert' => false, - ], - )); + if ($builder->hasFilterOfType(PublishedFilterElement::TYPE)) { + return; } + + $builder->addFilter(new Filter( + element: PublishedFilterElement::TYPE, + config: [ + 'intrinsic' => true, + 'published_field' => 'published', + 'start_field' => 'start', + 'stop_field' => 'stop', + 'invert' => false, + ], + )); } } diff --git a/src/Integration/ContaoCalendar/Projector/EventsInteractiveProjector.php b/src/Integration/ContaoCalendar/Projector/EventsInteractiveProjector.php index c3e3541b..d0247e68 100644 --- a/src/Integration/ContaoCalendar/Projector/EventsInteractiveProjector.php +++ b/src/Integration/ContaoCalendar/Projector/EventsInteractiveProjector.php @@ -13,9 +13,9 @@ use HeimrichHannot\FlareBundle\Integration\ContaoCalendar\ListType\EventsListType; use HeimrichHannot\FlareBundle\Integration\ContaoCalendar\Loader\EventsInteractiveLoader; use HeimrichHannot\FlareBundle\Integration\ContaoCalendar\View\InteractiveEventsView; +use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Paginator\Paginator; use HeimrichHannot\FlareBundle\Reader\ReaderUrlGeneratorInterface; -use HeimrichHannot\FlareBundle\List\ListSpec; use Symfony\Component\Form\FormInterface; class EventsInteractiveProjector extends InteractiveProjector @@ -57,4 +57,4 @@ protected function createView( totalItems: $totalItems, ); } -} \ No newline at end of file +} diff --git a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php index 5b47f854..3445ae87 100644 --- a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php +++ b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php @@ -17,7 +17,7 @@ use HeimrichHannot\FlareBundle\Event\FetchListEntriesEvent; use HeimrichHannot\FlareBundle\Filter\Element\SimpleEquationFilterElement; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\ListType\DcMultilingualListType; +use HeimrichHannot\FlareBundle\Integration\Terminal42Languages\ListType\DcMultilingualListType; use HeimrichHannot\FlareBundle\Query\JoinTypeEnum; use HeimrichHannot\FlareBundle\Query\ListQueryBuilder; use HeimrichHannot\FlareBundle\Reader\Resolver\ReaderRequestAttributeResolver; diff --git a/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php b/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php index 0e5c2307..c3483e3c 100644 --- a/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php +++ b/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php @@ -10,7 +10,7 @@ use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\Contract\ListType\DataContainerContract; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListType; -use HeimrichHannot\FlareBundle\ListType\AbstractListType; +use HeimrichHannot\FlareBundle\List\Type\AbstractListType; use HeimrichHannot\FlareBundle\Model\ListModel; #[AsListType(type: self::TYPE)] @@ -46,4 +46,4 @@ protected function transformListModel(ListModel $model, ConfigBuilder $config): { $config->set('genericPageMeta', true); } -} \ No newline at end of file +} diff --git a/src/List/BaseListOptions.php b/src/List/BaseListOptions.php index 52c5dab8..d2330cda 100644 --- a/src/List/BaseListOptions.php +++ b/src/List/BaseListOptions.php @@ -42,7 +42,7 @@ public static function configureOptions(OptionsResolver $resolver): void $resolver->define('genericPageMeta')->default(false)->allowedTypes('bool'); } - public static function transform(ListModel $model, ConfigBuilder $config): void + public static function transform(ConfigBuilder $config, ListModel $model): void { $config ->set('id', $model->id ? (int) $model->id : null) diff --git a/src/List/Factory/ListBuilderFactory.php b/src/List/Factory/ListBuilderFactory.php index c16a47bb..946d9c94 100644 --- a/src/List/Factory/ListBuilderFactory.php +++ b/src/List/Factory/ListBuilderFactory.php @@ -5,9 +5,9 @@ namespace HeimrichHannot\FlareBundle\List\Factory; use HeimrichHannot\FlareBundle\Filter\Collector\ListModelFilterCollector; -use HeimrichHannot\FlareBundle\ListType\ListTypeInterface; use HeimrichHannot\FlareBundle\List\ListBuilder; use HeimrichHannot\FlareBundle\List\Resolver\ListOptionsResolver; +use HeimrichHannot\FlareBundle\List\Type\ListTypeInterface; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Registry\ListTypeRegistry; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; diff --git a/src/List/ListBuilder.php b/src/List/ListBuilder.php index a71d0165..1078a855 100644 --- a/src/List/ListBuilder.php +++ b/src/List/ListBuilder.php @@ -11,8 +11,8 @@ use HeimrichHannot\FlareBundle\Event\ListBuildEvent; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\ListType\ListTypeInterface; use HeimrichHannot\FlareBundle\List\Resolver\ListOptionsResolver; +use HeimrichHannot\FlareBundle\List\Type\ListTypeInterface; use HeimrichHannot\FlareBundle\Model\ListModel; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -141,7 +141,7 @@ public function build(): ListSpec if ($this->model) { - BaseListOptions::transform($this->model, $config); + BaseListOptions::transform($config, $this->model); if ($this->typeService instanceof TransformerContract) { diff --git a/src/List/ListSpec.php b/src/List/ListSpec.php index 585d0d45..7a107cca 100644 --- a/src/List/ListSpec.php +++ b/src/List/ListSpec.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\List; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\ListType\ListTypeInterface; +use HeimrichHannot\FlareBundle\List\Type\ListTypeInterface; use HeimrichHannot\FlareBundle\Util\DcaHelper; /** diff --git a/src/ListType/AbstractListType.php b/src/List/Type/AbstractListType.php similarity index 96% rename from src/ListType/AbstractListType.php rename to src/List/Type/AbstractListType.php index 80e25a0f..a2098668 100644 --- a/src/ListType/AbstractListType.php +++ b/src/List/Type/AbstractListType.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\ListType; +namespace HeimrichHannot\FlareBundle\List\Type; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\Config\TransformerBuilder; diff --git a/src/ListType/GenericDataContainerListType.php b/src/List/Type/GenericDataContainerListType.php similarity index 98% rename from src/ListType/GenericDataContainerListType.php rename to src/List/Type/GenericDataContainerListType.php index 1258fd5c..fa7df7fb 100644 --- a/src/ListType/GenericDataContainerListType.php +++ b/src/List/Type/GenericDataContainerListType.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\ListType; +namespace HeimrichHannot\FlareBundle\List\Type; use Contao\CoreBundle\DataContainer\PaletteManipulator; use Contao\CoreBundle\String\HtmlDecoder; diff --git a/src/ListType/ListTypeInterface.php b/src/List/Type/ListTypeInterface.php similarity index 77% rename from src/ListType/ListTypeInterface.php rename to src/List/Type/ListTypeInterface.php index 57670bc3..a6151165 100644 --- a/src/ListType/ListTypeInterface.php +++ b/src/List/Type/ListTypeInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\ListType; +namespace HeimrichHannot\FlareBundle\List\Type; /** * Marker for FLARE list types — registered via #[AsListType] or used inline on a ListSpec. diff --git a/src/ListType/NewsListType.php b/src/List/Type/NewsListType.php similarity index 97% rename from src/ListType/NewsListType.php rename to src/List/Type/NewsListType.php index d0336b1e..6407b460 100644 --- a/src/ListType/NewsListType.php +++ b/src/List/Type/NewsListType.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\ListType; +namespace HeimrichHannot\FlareBundle\List\Type; use HeimrichHannot\FlareBundle\Contract\DcaContract; use HeimrichHannot\FlareBundle\Contract\ListType\BuildListContract; diff --git a/src/Query/Executor/FilterExecutor.php b/src/Query/Executor/FilterExecutor.php index c23fd946..a32ccd5a 100644 --- a/src/Query/Executor/FilterExecutor.php +++ b/src/Query/Executor/FilterExecutor.php @@ -9,10 +9,10 @@ use HeimrichHannot\FlareBundle\Exception\AbortFilteringException; use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Exception\FlareException; +use HeimrichHannot\FlareBundle\Filter\Factory\FilterContextFactory; use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\FilterBuilder; use HeimrichHannot\FlareBundle\Filter\FilterCall; -use HeimrichHannot\FlareBundle\Filter\Factory\FilterContextFactory; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterElementResolver; use HeimrichHannot\FlareBundle\Query\Factory\FilterQueryBuilderFactory; diff --git a/src/Query/Factory/ListExecutionContextFactory.php b/src/Query/Factory/ListExecutionContextFactory.php index 050b4b65..d52f2bb1 100644 --- a/src/Query/Factory/ListExecutionContextFactory.php +++ b/src/Query/Factory/ListExecutionContextFactory.php @@ -7,12 +7,12 @@ use HeimrichHannot\FlareBundle\Contract\ListType\BuildQueryContract; use HeimrichHannot\FlareBundle\Event\QueryBaseInitializedEvent; use HeimrichHannot\FlareBundle\Exception\FlareException; +use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Query\ListExecutionContext; use HeimrichHannot\FlareBundle\Query\SqlQueryStruct; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; use HeimrichHannot\FlareBundle\Registry\Descriptor\ListTypeDescriptor; use HeimrichHannot\FlareBundle\Registry\ListTypeRegistry; -use HeimrichHannot\FlareBundle\List\ListSpec; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; readonly class ListExecutionContextFactory @@ -74,4 +74,4 @@ public function create(ListSpec $list): ListExecutionContext return new ListExecutionContext($registry, $struct); } -} \ No newline at end of file +} diff --git a/src/Reader/Factory/ReaderRequestAttributeFactory.php b/src/Reader/Factory/ReaderRequestAttributeFactory.php index 8a10921a..f5dd40b1 100644 --- a/src/Reader/Factory/ReaderRequestAttributeFactory.php +++ b/src/Reader/Factory/ReaderRequestAttributeFactory.php @@ -5,9 +5,9 @@ namespace HeimrichHannot\FlareBundle\Reader\Factory; use Contao\Model; +use HeimrichHannot\FlareBundle\List\Factory\ListBuilderFactory; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Reader\ReaderRequestAttribute; -use HeimrichHannot\FlareBundle\List\Factory\ListBuilderFactory; final readonly class ReaderRequestAttributeFactory { @@ -42,4 +42,4 @@ public function createFromData(array $data): ?ReaderRequestAttribute return new ReaderRequestAttribute($model, $spec); } -} \ No newline at end of file +} diff --git a/src/Registry/Descriptor/ListTypeDescriptor.php b/src/Registry/Descriptor/ListTypeDescriptor.php index 8c9cf4a7..737a1555 100644 --- a/src/Registry/Descriptor/ListTypeDescriptor.php +++ b/src/Registry/Descriptor/ListTypeDescriptor.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\Registry\Descriptor; use HeimrichHannot\FlareBundle\DependencyInjection\Registry\ServiceDescriptorInterface; -use HeimrichHannot\FlareBundle\ListType\AbstractListType; +use HeimrichHannot\FlareBundle\List\Type\AbstractListType; class ListTypeDescriptor implements ServiceDescriptorInterface { diff --git a/src/Twig/Extension/FlareExtension.php b/src/Twig/Extension/FlareExtension.php index 840950da..338c39cf 100644 --- a/src/Twig/Extension/FlareExtension.php +++ b/src/Twig/Extension/FlareExtension.php @@ -20,4 +20,4 @@ public function getFunctions(): array new TwigFunction('flare_schema_org', [FlareRuntime::class, 'getSchemaOrg'], ['needs_context'=> true]), ]; } -} \ No newline at end of file +} diff --git a/src/Twig/Runtime/FlareRuntime.php b/src/Twig/Runtime/FlareRuntime.php index 254c9833..770ad1f7 100644 --- a/src/Twig/Runtime/FlareRuntime.php +++ b/src/Twig/Runtime/FlareRuntime.php @@ -14,9 +14,8 @@ use HeimrichHannot\FlareBundle\Engine\Engine; use HeimrichHannot\FlareBundle\Engine\View\ViewInterface; use HeimrichHannot\FlareBundle\Event\ReaderSchemaOrgEvent; -use HeimrichHannot\FlareBundle\Model\ListModel; -use HeimrichHannot\FlareBundle\Registry\ProjectorRegistry; use HeimrichHannot\FlareBundle\List\ListSpec; +use HeimrichHannot\FlareBundle\Registry\ProjectorRegistry; use HeimrichHannot\FlareBundle\Util\CallableWrapper; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Twig\Extension\RuntimeExtensionInterface; @@ -33,24 +32,6 @@ public function project(ListSpec $spec, ContextInterface $config): ViewInterface return $this->projectorRegistry->getProjectorFor($spec, $config)->project($spec, $config); } - /** - * @throws \InvalidArgumentException - */ - public function getListModel(ListModel|string|int $listModel): ?ListModel - { - if ($listModel instanceof ListModel) { - return $listModel; - } - - $listModel = ListModel::findByPk((int) $listModel); - - if ($listModel instanceof ListModel) { - return $listModel; - } - - throw new \InvalidArgumentException('Invalid list model'); - } - public function getTlContent(Model $model): ?callable { $table = $model->getTable(); @@ -157,4 +138,4 @@ private static function once(callable $callback): callable return $result; }; } -} \ No newline at end of file +} diff --git a/tests/Filter/FilterOptionsResolverTest.php b/tests/Filter/FilterOptionsResolverTest.php index 76a40e8c..1699f04c 100644 --- a/tests/Filter/FilterOptionsResolverTest.php +++ b/tests/Filter/FilterOptionsResolverTest.php @@ -6,10 +6,10 @@ use HeimrichHannot\FlareBundle\Contract\OptionsContract; use HeimrichHannot\FlareBundle\Exception\FilterException; +use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; -use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterOptionsResolver; use PHPUnit\Framework\TestCase; use Symfony\Component\Form\FormBuilderInterface; diff --git a/tests/Filter/FilterTest.php b/tests/Filter/FilterTest.php index 67d25f75..4822f1d4 100644 --- a/tests/Filter/FilterTest.php +++ b/tests/Filter/FilterTest.php @@ -4,10 +4,10 @@ namespace HeimrichHannot\FlareBundle\Tests\Filter; +use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; -use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use PHPUnit\Framework\TestCase; use Symfony\Component\Form\FormBuilderInterface; diff --git a/tests/Filter/FilterTransformerResolverTest.php b/tests/Filter/FilterTransformerResolverTest.php index fa357aab..555a86f9 100644 --- a/tests/Filter/FilterTransformerResolverTest.php +++ b/tests/Filter/FilterTransformerResolverTest.php @@ -8,9 +8,9 @@ use HeimrichHannot\FlareBundle\Config\TransformerBuilder; use HeimrichHannot\FlareBundle\Contract\TransformerContract; use HeimrichHannot\FlareBundle\Event\FilterTransformerEvent; +use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; -use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterTransformerResolver; use PHPUnit\Framework\TestCase; use Symfony\Component\EventDispatcher\EventDispatcher; diff --git a/tests/List/BaseListOptionsTest.php b/tests/List/BaseListOptionsTest.php index 7c6b9765..407c2880 100644 --- a/tests/List/BaseListOptionsTest.php +++ b/tests/List/BaseListOptionsTest.php @@ -28,7 +28,7 @@ public function testTransformsStoredRowToCanonicalValues(): void 'whichPtable' => 'auto', ]); - BaseListOptions::transform($model, $config = new ConfigBuilder()); + BaseListOptions::transform($config = new ConfigBuilder(), $model); $all = $config->all(); self::assertSame(5, $all['id']); @@ -62,7 +62,7 @@ public function testTransformedRowSatisfiesTheSchema(): void { $model = new ListModelStub(['id' => '3', 'title' => 'x', 'sortSettings' => '']); - BaseListOptions::transform($model, $config = new ConfigBuilder()); + BaseListOptions::transform($config = new ConfigBuilder(), $model); $resolved = (new ListOptionsResolver())->resolve(null, $config->all()); @@ -73,6 +73,7 @@ public function testTransformedRowSatisfiesTheSchema(): void class ListModelStub extends ListModel { + /** @noinspection PhpMissingParentConstructorInspection */ public function __construct(array $row = []) { $this->arrData = $row; diff --git a/tests/List/ListBuilderTest.php b/tests/List/ListBuilderTest.php index 2aecc664..927a0678 100644 --- a/tests/List/ListBuilderTest.php +++ b/tests/List/ListBuilderTest.php @@ -9,9 +9,9 @@ use HeimrichHannot\FlareBundle\Event\ListBuildEvent; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\ListType\AbstractListType; use HeimrichHannot\FlareBundle\List\ListBuilder; use HeimrichHannot\FlareBundle\List\Resolver\ListOptionsResolver; +use HeimrichHannot\FlareBundle\List\Type\AbstractListType; use HeimrichHannot\FlareBundle\Model\ListModel; use PHPUnit\Framework\TestCase; use Symfony\Component\EventDispatcher\EventDispatcher; diff --git a/translations/flare_list.de.php b/translations/flare_list.de.php index cae22909..6d7176f0 100644 --- a/translations/flare_list.de.php +++ b/translations/flare_list.de.php @@ -1,11 +1,11 @@ 'Data-Container', - ListType\NewsListType::TYPE => 'Nachrichten', + Type\GenericDataContainerListType::TYPE => 'Data-Container', + Type\NewsListType::TYPE => 'Nachrichten', EventsListType::TYPE => 'Events', ]; diff --git a/translations/flare_list.en.php b/translations/flare_list.en.php index 8e13e1d5..0e09f98e 100644 --- a/translations/flare_list.en.php +++ b/translations/flare_list.en.php @@ -1,11 +1,11 @@ 'Data Container', - ListType\NewsListType::TYPE => 'News', + Type\GenericDataContainerListType::TYPE => 'Data Container', + Type\NewsListType::TYPE => 'News', EventsListType::TYPE => 'Events', ]; From 0a45ce0f4021c5a216ef249ff2fc5dd97a17e6c9 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 16:15:06 +0200 Subject: [PATCH 28/71] refactor: replace `TransformerBuilder` with `TransformerResolver`, introduce `CallbackListModelTransformer` and `CallbackFilterModelTransformer` Updated all references to `TransformerBuilder` with `TransformerResolver`. Introduced `CallbackListModelTransformer` and `CallbackFilterModelTransformer` to encapsulate transformation logic. Refactored method signatures for consistency (`ConfigBuilder` precedes the source). Expanded `ConfigBuilder` and `ListBuilderInterface` for additional functionality and clarity. --- src/Config/ConfigBuilder.php | 7 +++- src/Config/ConfigBuilderInterface.php | 12 +++++++ src/Config/TransformerInterface.php | 8 +++++ ...merBuilder.php => TransformerResolver.php} | 12 +++---- src/Contract/TransformerContract.php | 4 +-- src/Event/FilterTransformerEvent.php | 9 ++--- src/Filter/CallbackFilterModelTransformer.php | 26 ++++++++++++++ src/Filter/Element/AbstractFilterElement.php | 12 ++++--- src/Filter/Element/ArchiveFilterElement.php | 3 +- .../BelongsToRelationFilterElement.php | 2 +- .../Resolver/FilterTransformerResolver.php | 7 ++-- .../ListType/DcMultilingualListType.php | 2 +- src/List/CallbackListModelTransformer.php | 26 ++++++++++++++ src/List/ListBuilder.php | 13 ++++--- src/List/ListBuilderInterface.php | 36 +++++++++++++++++++ src/List/Type/AbstractListType.php | 12 ++++--- .../Type/GenericDataContainerListType.php | 2 +- src/List/Type/NewsListType.php | 24 +++++++------ tests/Config/TransformerBuilderTest.php | 12 +++---- .../Element/ArchiveFilterElementTest.php | 4 +-- .../SimpleEquationFilterElementTest.php | 4 +-- .../Filter/FilterTransformerResolverTest.php | 6 ++-- 22 files changed, 187 insertions(+), 56 deletions(-) create mode 100644 src/Config/ConfigBuilderInterface.php create mode 100644 src/Config/TransformerInterface.php rename src/Config/{TransformerBuilder.php => TransformerResolver.php} (73%) create mode 100644 src/Filter/CallbackFilterModelTransformer.php create mode 100644 src/List/CallbackListModelTransformer.php create mode 100644 src/List/ListBuilderInterface.php diff --git a/src/Config/ConfigBuilder.php b/src/Config/ConfigBuilder.php index 019c844a..b659af38 100644 --- a/src/Config/ConfigBuilder.php +++ b/src/Config/ConfigBuilder.php @@ -9,7 +9,7 @@ * ({@see \HeimrichHannot\FlareBundle\Contract\TransformerContract}). Casting, deserialization, * and enum parsing happen declaratively at the call site — this builder only collects. */ -final class ConfigBuilder +final class ConfigBuilder implements ConfigBuilderInterface { /** * @var array @@ -23,6 +23,11 @@ public function set(string $key, mixed $value): self return $this; } + public function get(string $key): mixed + { + return $this->config[$key] ?? null; + } + /** * Returns the accumulated canonical config. * diff --git a/src/Config/ConfigBuilderInterface.php b/src/Config/ConfigBuilderInterface.php new file mode 100644 index 00000000..a2a736fd --- /dev/null +++ b/src/Config/ConfigBuilderInterface.php @@ -0,0 +1,12 @@ + + * @var array */ private array $transformers = []; @@ -20,9 +20,9 @@ final class TransformerBuilder * the previous transformer, so event listeners can override element defaults. * * @param class-string $sourceClass - * @param callable(object $source, ConfigBuilder $config): void $transformer + * @param TransformerInterface|callable(ConfigBuilder $config, object $source): void $transformer */ - public function for(string $sourceClass, callable $transformer): self + public function for(string $sourceClass, TransformerInterface|callable $transformer): self { $this->transformers[$sourceClass] = $transformer; @@ -36,9 +36,9 @@ public function for(string $sourceClass, callable $transformer): self * * @param object $source The stored source object to be transformed. * - * @return (callable(object, ConfigBuilder): void)|null + * @return TransformerInterface|(callable(ConfigBuilder $config, object $source): void)|null */ - public function resolve(object $source): ?callable + public function resolve(object $source): TransformerInterface|callable|null { if ($transformer = $this->transformers[$source::class] ?? null) { return $transformer; diff --git a/src/Contract/TransformerContract.php b/src/Contract/TransformerContract.php index 4051860d..cb9f1ab5 100644 --- a/src/Contract/TransformerContract.php +++ b/src/Contract/TransformerContract.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\Contract; -use HeimrichHannot\FlareBundle\Config\TransformerBuilder; +use HeimrichHannot\FlareBundle\Config\TransformerResolver; /** * Implemented by filter elements and list types that own the translation from stored @@ -19,5 +19,5 @@ interface TransformerContract /** * Declares per-source transformers translating a stored source into canonical config values. */ - public function configureTransformers(TransformerBuilder $transformers): void; + public function configureTransformers(TransformerResolver $resolver): void; } diff --git a/src/Event/FilterTransformerEvent.php b/src/Event/FilterTransformerEvent.php index 71b7f5fe..ef808543 100644 --- a/src/Event/FilterTransformerEvent.php +++ b/src/Event/FilterTransformerEvent.php @@ -4,7 +4,8 @@ namespace HeimrichHannot\FlareBundle\Event; -use HeimrichHannot\FlareBundle\Config\TransformerBuilder; +use HeimrichHannot\FlareBundle\Config\TransformerResolver; +use HeimrichHannot\FlareBundle\Config\TransformerResolver; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use Symfony\Contracts\EventDispatcher\Event; @@ -16,8 +17,8 @@ class FilterTransformerEvent extends Event { public function __construct( - public readonly TransformerBuilder $transformers, - public readonly FilterElementInterface $element, - public readonly ?string $type, + public readonly TransformerResolver $transformers, + public readonly FilterElementInterface $element, + public readonly ?string $type, ) {} } diff --git a/src/Filter/CallbackFilterModelTransformer.php b/src/Filter/CallbackFilterModelTransformer.php new file mode 100644 index 00000000..b77233e7 --- /dev/null +++ b/src/Filter/CallbackFilterModelTransformer.php @@ -0,0 +1,26 @@ +transform)($config, $source); + } +} diff --git a/src/Filter/Element/AbstractFilterElement.php b/src/Filter/Element/AbstractFilterElement.php index 6153799d..68ecb414 100644 --- a/src/Filter/Element/AbstractFilterElement.php +++ b/src/Filter/Element/AbstractFilterElement.php @@ -5,13 +5,14 @@ namespace HeimrichHannot\FlareBundle\Filter\Element; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; -use HeimrichHannot\FlareBundle\Config\TransformerBuilder; +use HeimrichHannot\FlareBundle\Config\TransformerResolver; use HeimrichHannot\FlareBundle\Contract\DcaContract; use HeimrichHannot\FlareBundle\Contract\IsSupportedContract; use HeimrichHannot\FlareBundle\Contract\OptionsContract; use HeimrichHannot\FlareBundle\Contract\TransformerContract; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; +use HeimrichHannot\FlareBundle\Filter\CallbackFilterModelTransformer; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Model\FilterModel; @@ -23,16 +24,19 @@ abstract class AbstractFilterElement implements { abstract public function configureOptions(OptionsResolver $resolver): void; - public function configureTransformers(TransformerBuilder $transformers): void + public function configureTransformers(TransformerResolver $resolver): void { - $transformers->for(FilterModel::class, $this->transformFilterModel(...)); + $resolver->for( + sourceClass: FilterModel::class, + transformer: new CallbackFilterModelTransformer($this->transformFilterModel(...)), + ); } /** * Translates a stored tl_flare_filter model into canonical config values (unresolved). * All deserialization, casting, and enum parsing belongs here. */ - abstract protected function transformFilterModel(FilterModel $model, ConfigBuilder $config): void; + abstract protected function transformFilterModel(ConfigBuilder $config, FilterModel $model): void; public function buildDca(DcaBuilder $dca, DcaContext $context): void {} diff --git a/src/Filter/Element/ArchiveFilterElement.php b/src/Filter/Element/ArchiveFilterElement.php index d94b15f6..261f77b0 100644 --- a/src/Filter/Element/ArchiveFilterElement.php +++ b/src/Filter/Element/ArchiveFilterElement.php @@ -8,6 +8,7 @@ use Contao\Model\Collection; use Contao\StringUtil; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; +use HeimrichHannot\FlareBundle\Config\TransformerResolver; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; @@ -53,7 +54,7 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('preselect')->default([])->allowedTypes('array'); } - protected function transformFilterModel(FilterModel $model, ConfigBuilder $config): void + protected function transformFilterModel(ConfigBuilder $config, FilterModel $model): void { $formatLabel = $model->formatLabel === 'custom' ? $model->formatLabelCustom diff --git a/src/Filter/Element/BelongsToRelationFilterElement.php b/src/Filter/Element/BelongsToRelationFilterElement.php index 7604cc37..cfaf29da 100644 --- a/src/Filter/Element/BelongsToRelationFilterElement.php +++ b/src/Filter/Element/BelongsToRelationFilterElement.php @@ -39,7 +39,7 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('group_whitelist_parents')->default([])->allowedTypes('array'); } - protected function transformFilterModel(FilterModel $model, ConfigBuilder $config): void + protected function transformFilterModel(ConfigBuilder $config, FilterModel $model): void { $whitelistParents = StringUtil::deserialize($model->whitelistParents); $groupWhitelistParents = StringUtil::deserialize($model->groupWhitelistParents); diff --git a/src/Filter/Resolver/FilterTransformerResolver.php b/src/Filter/Resolver/FilterTransformerResolver.php index 82365940..610c907f 100644 --- a/src/Filter/Resolver/FilterTransformerResolver.php +++ b/src/Filter/Resolver/FilterTransformerResolver.php @@ -5,7 +5,8 @@ namespace HeimrichHannot\FlareBundle\Filter\Resolver; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; -use HeimrichHannot\FlareBundle\Config\TransformerBuilder; +use HeimrichHannot\FlareBundle\Config\TransformerResolver; +use HeimrichHannot\FlareBundle\Config\TransformerResolver; use HeimrichHannot\FlareBundle\Contract\TransformerContract; use HeimrichHannot\FlareBundle\Event\FilterTransformerEvent; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; @@ -19,7 +20,7 @@ class FilterTransformerResolver { /** - * @var array + * @var array */ private array $builders = []; @@ -49,7 +50,7 @@ public function transform(FilterElementInterface $element, ?string $elementType, return null; } - $transformer($source, $config = new ConfigBuilder()); + $transformer($config = new ConfigBuilder(), $source); return $config->all(); } diff --git a/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php b/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php index c3483e3c..4d8c07dd 100644 --- a/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php +++ b/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php @@ -42,7 +42,7 @@ public function getDataContainerName(array $row, DataContainer $dc): string return $row['dc'] ?? ''; } - protected function transformListModel(ListModel $model, ConfigBuilder $config): void + protected function transformListModel(ConfigBuilder $config, ListModel $model): void { $config->set('genericPageMeta', true); } diff --git a/src/List/CallbackListModelTransformer.php b/src/List/CallbackListModelTransformer.php new file mode 100644 index 00000000..62d64090 --- /dev/null +++ b/src/List/CallbackListModelTransformer.php @@ -0,0 +1,26 @@ +transform)($config, $source); + } +} diff --git a/src/List/ListBuilder.php b/src/List/ListBuilder.php index 1078a855..55079d63 100644 --- a/src/List/ListBuilder.php +++ b/src/List/ListBuilder.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\List; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; -use HeimrichHannot\FlareBundle\Config\TransformerBuilder; +use HeimrichHannot\FlareBundle\Config\TransformerResolver; use HeimrichHannot\FlareBundle\Contract\ListType\BuildListContract; use HeimrichHannot\FlareBundle\Contract\TransformerContract; use HeimrichHannot\FlareBundle\Event\ListBuildEvent; @@ -24,7 +24,7 @@ * base translation, the type's model transformers, and explicit {@see set()} overrides — * resolved through the base and type schemas. */ -final class ListBuilder +final class ListBuilder implements ListBuilderInterface { /** * @var array @@ -114,6 +114,11 @@ public function getFilters(): array return $this->filters; } + public function getFilter(string $key): ?Filter + { + return $this->filters[$key] ?? null; + } + public function hasFilterOfType(string $elementType): bool { foreach ($this->filters as $filter) @@ -145,11 +150,11 @@ public function build(): ListSpec if ($this->typeService instanceof TransformerContract) { - $transformers = new TransformerBuilder(); + $transformers = new TransformerResolver(); $this->typeService->configureTransformers($transformers); if ($transformer = $transformers->resolve($this->model)) { - $transformer($this->model, $config); + $transformer($config, $this->model); } } } diff --git a/src/List/ListBuilderInterface.php b/src/List/ListBuilderInterface.php new file mode 100644 index 00000000..e1aa45f3 --- /dev/null +++ b/src/List/ListBuilderInterface.php @@ -0,0 +1,36 @@ +for(ListModel::class, $this->transformListModel(...)); + $resolver->for( + sourceClass: ListModel::class, + transformer: new CallbackListModelTransformer($this->transformListModel(...)), + ); } /** * Translates a stored tl_flare_list model into the type's canonical config values (unresolved). * Base columns are already translated by {@see \HeimrichHannot\FlareBundle\List\BaseListOptions}. */ - protected function transformListModel(ListModel $model, ConfigBuilder $config): void {} + protected function transformListModel(ConfigBuilder $config, ListModel $model): void {} public function buildTableRegistry(TableAliasRegistry $registry): void {} diff --git a/src/List/Type/GenericDataContainerListType.php b/src/List/Type/GenericDataContainerListType.php index fa7df7fb..5db176b5 100644 --- a/src/List/Type/GenericDataContainerListType.php +++ b/src/List/Type/GenericDataContainerListType.php @@ -50,7 +50,7 @@ public function getDataContainerName(array $row, DataContainer $dc): string return $row['dc'] ?? ''; } - protected function transformListModel(ListModel $model, ConfigBuilder $config): void + protected function transformListModel(ConfigBuilder $config, ListModel $model): void { $config->set('genericPageMeta', true); } diff --git a/src/List/Type/NewsListType.php b/src/List/Type/NewsListType.php index 6407b460..fcb8bb99 100644 --- a/src/List/Type/NewsListType.php +++ b/src/List/Type/NewsListType.php @@ -40,17 +40,19 @@ public function buildTableRegistry(TableAliasRegistry $registry): void public function buildList(ListBuilder $builder): void { - if (!$builder->hasFilterOfType(PublishedFilterElement::TYPE)) { - $builder->addFilter(new Filter( - element: PublishedFilterElement::TYPE, - config: [ - 'intrinsic' => true, - 'published_field' => 'published', - 'start_field' => 'start', - 'stop_field' => 'stop', - 'invert' => false, - ], - )); + if ($builder->hasFilterOfType(PublishedFilterElement::TYPE)) { + return; } + + $builder->addFilter(new Filter( + element: PublishedFilterElement::TYPE, + config: [ + 'intrinsic' => true, + 'published_field' => 'published', + 'start_field' => 'start', + 'stop_field' => 'stop', + 'invert' => false, + ], + )); } } diff --git a/tests/Config/TransformerBuilderTest.php b/tests/Config/TransformerBuilderTest.php index 5bbb1eff..ca72aed5 100644 --- a/tests/Config/TransformerBuilderTest.php +++ b/tests/Config/TransformerBuilderTest.php @@ -5,14 +5,14 @@ namespace HeimrichHannot\FlareBundle\Tests\Config; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; -use HeimrichHannot\FlareBundle\Config\TransformerBuilder; +use HeimrichHannot\FlareBundle\Config\TransformerResolver; use PHPUnit\Framework\TestCase; final class TransformerBuilderTest extends TestCase { public function testResolvesRegisteredSourceClass(): void { - $transformers = new TransformerBuilder(); + $transformers = new TransformerResolver(); $transformer = static function (object $source, ConfigBuilder $config): void {}; $result = $transformers->for(SourceA::class, $transformer); @@ -23,7 +23,7 @@ public function testResolvesRegisteredSourceClass(): void public function testResolvesSubclassSources(): void { - $transformers = new TransformerBuilder(); + $transformers = new TransformerResolver(); $transformer = static function (object $source, ConfigBuilder $config): void {}; $transformers->for(SourceA::class, $transformer); @@ -33,7 +33,7 @@ public function testResolvesSubclassSources(): void public function testReRegistrationOverrides(): void { - $transformers = new TransformerBuilder(); + $transformers = new TransformerResolver(); $first = static function (object $source, ConfigBuilder $config): void {}; $second = static function (object $source, ConfigBuilder $config): void {}; @@ -45,7 +45,7 @@ public function testReRegistrationOverrides(): void public function testReturnsNullWithoutMatch(): void { - $transformers = new TransformerBuilder(); + $transformers = new TransformerResolver(); $transformers->for(SourceA::class, static function (object $source, ConfigBuilder $config): void {}); self::assertNull($transformers->resolve(new SourceB())); @@ -53,7 +53,7 @@ public function testReturnsNullWithoutMatch(): void public function testExactClassMatchWinsOverEarlierBaseClassRegistration(): void { - $transformers = new TransformerBuilder(); + $transformers = new TransformerResolver(); $base = static function (object $source, ConfigBuilder $config): void {}; $specific = static function (object $source, ConfigBuilder $config): void {}; diff --git a/tests/Filter/Element/ArchiveFilterElementTest.php b/tests/Filter/Element/ArchiveFilterElementTest.php index 16263701..d90e274e 100644 --- a/tests/Filter/Element/ArchiveFilterElementTest.php +++ b/tests/Filter/Element/ArchiveFilterElementTest.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\Tests\Filter\Element; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; -use HeimrichHannot\FlareBundle\Config\TransformerBuilder; +use HeimrichHannot\FlareBundle\Config\TransformerResolver; use HeimrichHannot\FlareBundle\Filter\Element\ArchiveFilterElement; use HeimrichHannot\FlareBundle\Form\Factory\ChoicesBuilderFactory; use PHPUnit\Framework\TestCase; @@ -89,7 +89,7 @@ private function transform(array $row): array { $element = $this->createElement(); - $transformers = new TransformerBuilder(); + $transformers = new TransformerResolver(); $element->configureTransformers($transformers); $transformer = $transformers->resolve($model = new FilterModelStub($row)); diff --git a/tests/Filter/Element/SimpleEquationFilterElementTest.php b/tests/Filter/Element/SimpleEquationFilterElementTest.php index 573209f8..edb14035 100644 --- a/tests/Filter/Element/SimpleEquationFilterElementTest.php +++ b/tests/Filter/Element/SimpleEquationFilterElementTest.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\Tests\Filter\Element; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; -use HeimrichHannot\FlareBundle\Config\TransformerBuilder; +use HeimrichHannot\FlareBundle\Config\TransformerResolver; use HeimrichHannot\FlareBundle\Enum\SqlEquationOperator; use HeimrichHannot\FlareBundle\Filter\Element\SimpleEquationFilterElement; use HeimrichHannot\FlareBundle\Model\FilterModel; @@ -67,7 +67,7 @@ private function transform(array $row): array { $element = new SimpleEquationFilterElement(); - $transformers = new TransformerBuilder(); + $transformers = new TransformerResolver(); $element->configureTransformers($transformers); $transformer = $transformers->resolve($model = new FilterModelStub($row)); diff --git a/tests/Filter/FilterTransformerResolverTest.php b/tests/Filter/FilterTransformerResolverTest.php index 555a86f9..069dcdf6 100644 --- a/tests/Filter/FilterTransformerResolverTest.php +++ b/tests/Filter/FilterTransformerResolverTest.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\Tests\Filter; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; -use HeimrichHannot\FlareBundle\Config\TransformerBuilder; +use HeimrichHannot\FlareBundle\Config\TransformerResolver; use HeimrichHannot\FlareBundle\Contract\TransformerContract; use HeimrichHannot\FlareBundle\Event\FilterTransformerEvent; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; @@ -84,9 +84,9 @@ public function __construct( final class TransformingElement implements FilterElementInterface, TransformerContract { - public function configureTransformers(TransformerBuilder $transformers): void + public function configureTransformers(TransformerResolver $resolver): void { - $transformers->for(RowSource::class, static function (RowSource $source, ConfigBuilder $config): void { + $resolver->for(RowSource::class, static function (RowSource $source, ConfigBuilder $config): void { foreach ($source->row as $key => $value) { $config->set($key, $value); } From 0d642bca0b37a74e08f344c3abbffa9888677811 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 16:38:31 +0200 Subject: [PATCH 29/71] refactor: replace `TransformerBuilder` with `TransformerResolver` and normalize method signatures Replaced all instances of `TransformerBuilder` with `TransformerResolver`. Updated method signatures across filter elements to place `ConfigBuilder` before the source model. Adjusted tests, imports, and documentation to align with these changes. --- AGENTS.md | 2 +- src/Event/FilterTransformerEvent.php | 7 +++---- src/Event/ReaderRenderEvent.php | 12 ++++++------ src/Event/ReaderSchemaOrgEvent.php | 6 +++--- src/Filter/Element/BooleanFilterElement.php | 2 +- src/Filter/Element/CalendarCurrentFilterElement.php | 2 +- src/Filter/Element/DateRangeFilterElement.php | 2 +- src/Filter/Element/DcaSelectFieldFilterElement.php | 2 +- src/Filter/Element/FieldValueChoiceFilterElement.php | 2 +- src/Filter/Element/PublishedFilterElement.php | 2 +- src/Filter/Element/SearchKeywordsFilterElement.php | 2 +- src/Filter/Element/SimpleEquationFilterElement.php | 2 +- src/Filter/Resolver/FilterTransformerResolver.php | 3 +-- .../FilterElement/CodefogTagsChoiceFilterElement.php | 2 +- .../FilterElement/CodefogTagsSearchElement.php | 2 +- src/List/Type/AbstractListType.php | 4 ++-- tests/Config/TransformerBuilderTest.php | 2 +- 17 files changed, 27 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ba07ae6c..c9ddb4ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,7 @@ The bundle follows standard Symfony Bundle architecture with deep Contao integra - `src/Filter/` — `Filter` DTO, elements (`Element/`), types (`Type/`), collector, resolvers (`FilterOptionsResolver`, `FilterTransformerResolver`, `FilterElementResolver`), `FilterContextFactory` - `src/Config/` — `ConfigBuilder` (fluent canonical-config accumulator; no cast helpers — transformers cast - declaratively off the typed model) and `TransformerBuilder` (source class → transformer map) + declaratively off the typed model) and `TransformerResolver` (source class → transformer map) - `src/Form/` — filter form building (FilterFormFactory etc.) - `src/Reader/` — reader/detail-page URL generation (`ReaderUrlGenerator`) - `src/InferPtable/` — parent-table inference for DCAs diff --git a/src/Event/FilterTransformerEvent.php b/src/Event/FilterTransformerEvent.php index ef808543..b7f4325b 100644 --- a/src/Event/FilterTransformerEvent.php +++ b/src/Event/FilterTransformerEvent.php @@ -4,7 +4,6 @@ namespace HeimrichHannot\FlareBundle\Event; -use HeimrichHannot\FlareBundle\Config\TransformerResolver; use HeimrichHannot\FlareBundle\Config\TransformerResolver; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use Symfony\Contracts\EventDispatcher\Event; @@ -17,8 +16,8 @@ class FilterTransformerEvent extends Event { public function __construct( - public readonly TransformerResolver $transformers, - public readonly FilterElementInterface $element, - public readonly ?string $type, + public readonly TransformerResolver $transformers, + public readonly FilterElementInterface $element, + public readonly ?string $type, ) {} } diff --git a/src/Event/ReaderRenderEvent.php b/src/Event/ReaderRenderEvent.php index 6d7a369c..a066fcf7 100644 --- a/src/Event/ReaderRenderEvent.php +++ b/src/Event/ReaderRenderEvent.php @@ -17,12 +17,12 @@ class ReaderRenderEvent extends Event use ModifiesTemplateTrait; public function __construct( - private readonly ContentModel $contentModel, - private readonly ContextInterface $context, - private readonly Model $displayModel, - private readonly ListSpec $list, - private ReaderPageMeta $pageMeta, - private Template $template, + private readonly ContentModel $contentModel, + private readonly ContextInterface $context, + private readonly Model $displayModel, + private readonly ListSpec $list, + private ReaderPageMeta $pageMeta, + private Template $template, ) {} public function getContentModel(): ContentModel diff --git a/src/Event/ReaderSchemaOrgEvent.php b/src/Event/ReaderSchemaOrgEvent.php index cd74300c..4340a015 100644 --- a/src/Event/ReaderSchemaOrgEvent.php +++ b/src/Event/ReaderSchemaOrgEvent.php @@ -12,7 +12,7 @@ class ReaderSchemaOrgEvent extends Event { public function __construct( public readonly ListSpec $list, - public readonly Model $model, - public array $data = [], + public readonly Model $model, + public array $data = [], ) {} -} \ No newline at end of file +} diff --git a/src/Filter/Element/BooleanFilterElement.php b/src/Filter/Element/BooleanFilterElement.php index c915581c..16248ce1 100644 --- a/src/Filter/Element/BooleanFilterElement.php +++ b/src/Filter/Element/BooleanFilterElement.php @@ -35,7 +35,7 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('label')->default(null)->allowedTypes('string', 'null'); } - protected function transformFilterModel(FilterModel $model, ConfigBuilder $config): void + protected function transformFilterModel(ConfigBuilder $config, FilterModel $model): void { $config ->set('intrinsic', (bool) $model->intrinsic) diff --git a/src/Filter/Element/CalendarCurrentFilterElement.php b/src/Filter/Element/CalendarCurrentFilterElement.php index 4743da05..91003efd 100644 --- a/src/Filter/Element/CalendarCurrentFilterElement.php +++ b/src/Filter/Element/CalendarCurrentFilterElement.php @@ -42,7 +42,7 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('has_extended_events')->default(false)->allowedTypes('bool'); } - protected function transformFilterModel(FilterModel $model, ConfigBuilder $config): void + protected function transformFilterModel(ConfigBuilder $config, FilterModel $model): void { $config ->set('intrinsic', (bool) $model->intrinsic) diff --git a/src/Filter/Element/DateRangeFilterElement.php b/src/Filter/Element/DateRangeFilterElement.php index 10c6de4b..8790ebf0 100644 --- a/src/Filter/Element/DateRangeFilterElement.php +++ b/src/Filter/Element/DateRangeFilterElement.php @@ -36,7 +36,7 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('field')->default(null)->allowedTypes('string', 'null'); } - protected function transformFilterModel(FilterModel $model, ConfigBuilder $config): void + protected function transformFilterModel(ConfigBuilder $config, FilterModel $model): void { $config ->set('intrinsic', (bool) $model->intrinsic) diff --git a/src/Filter/Element/DcaSelectFieldFilterElement.php b/src/Filter/Element/DcaSelectFieldFilterElement.php index 46c1e2ab..6a0a66d7 100644 --- a/src/Filter/Element/DcaSelectFieldFilterElement.php +++ b/src/Filter/Element/DcaSelectFieldFilterElement.php @@ -42,7 +42,7 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('preselect')->default(null); } - protected function transformFilterModel(FilterModel $model, ConfigBuilder $config): void + protected function transformFilterModel(ConfigBuilder $config, FilterModel $model): void { $isMultiple = (bool) $model->isMultiple; $preselect = $model->preselect ?: null; diff --git a/src/Filter/Element/FieldValueChoiceFilterElement.php b/src/Filter/Element/FieldValueChoiceFilterElement.php index 3cb65bf2..8739c120 100644 --- a/src/Filter/Element/FieldValueChoiceFilterElement.php +++ b/src/Filter/Element/FieldValueChoiceFilterElement.php @@ -45,7 +45,7 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('preselect')->default(null)->allowedTypes('array', 'null'); } - protected function transformFilterModel(FilterModel $model, ConfigBuilder $config): void + protected function transformFilterModel(ConfigBuilder $config, FilterModel $model): void { $multiple = (bool) $model->isMultiple; diff --git a/src/Filter/Element/PublishedFilterElement.php b/src/Filter/Element/PublishedFilterElement.php index 16bff5c4..08c10014 100644 --- a/src/Filter/Element/PublishedFilterElement.php +++ b/src/Filter/Element/PublishedFilterElement.php @@ -28,7 +28,7 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('invert')->default(false)->allowedTypes('bool'); } - protected function transformFilterModel(FilterModel $model, ConfigBuilder $config): void + protected function transformFilterModel(ConfigBuilder $config, FilterModel $model): void { $usePublished = (bool) ($model->usePublished ?? true); $useStart = (bool) ($model->useStart ?? true); diff --git a/src/Filter/Element/SearchKeywordsFilterElement.php b/src/Filter/Element/SearchKeywordsFilterElement.php index a0c3941e..df2ceadc 100644 --- a/src/Filter/Element/SearchKeywordsFilterElement.php +++ b/src/Filter/Element/SearchKeywordsFilterElement.php @@ -31,7 +31,7 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('placeholder')->default(null)->allowedTypes('string', 'null'); } - protected function transformFilterModel(FilterModel $model, ConfigBuilder $config): void + protected function transformFilterModel(ConfigBuilder $config, FilterModel $model): void { $config ->set('intrinsic', (bool) $model->intrinsic) diff --git a/src/Filter/Element/SimpleEquationFilterElement.php b/src/Filter/Element/SimpleEquationFilterElement.php index adf99948..d1734777 100644 --- a/src/Filter/Element/SimpleEquationFilterElement.php +++ b/src/Filter/Element/SimpleEquationFilterElement.php @@ -30,7 +30,7 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('right')->default(null); } - protected function transformFilterModel(FilterModel $model, ConfigBuilder $config): void + protected function transformFilterModel(ConfigBuilder $config, FilterModel $model): void { $config ->set('intrinsic', (bool) $model->intrinsic) diff --git a/src/Filter/Resolver/FilterTransformerResolver.php b/src/Filter/Resolver/FilterTransformerResolver.php index 610c907f..c68f1a11 100644 --- a/src/Filter/Resolver/FilterTransformerResolver.php +++ b/src/Filter/Resolver/FilterTransformerResolver.php @@ -6,7 +6,6 @@ use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\Config\TransformerResolver; -use HeimrichHannot\FlareBundle\Config\TransformerResolver; use HeimrichHannot\FlareBundle\Contract\TransformerContract; use HeimrichHannot\FlareBundle\Event\FilterTransformerEvent; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; @@ -35,7 +34,7 @@ public function transform(FilterElementInterface $element, ?string $elementType, { if (!isset($this->builders[$element::class])) { - $transformers = new TransformerBuilder(); + $transformers = new TransformerResolver(); if ($element instanceof TransformerContract) { $element->configureTransformers($transformers); diff --git a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php index 0a268560..afe5993c 100644 --- a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php +++ b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php @@ -46,7 +46,7 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('placeholder')->default(null)->allowedTypes('string', 'null'); } - protected function transformFilterModel(FilterModel $model, ConfigBuilder $config): void + protected function transformFilterModel(ConfigBuilder $config, FilterModel $model): void { $config ->set('intrinsic', (bool) $model->intrinsic) diff --git a/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php b/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php index b8637cbd..ef1598c0 100644 --- a/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php +++ b/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php @@ -32,7 +32,7 @@ public function configureOptions(OptionsResolver $resolver): void // TODO: Implement configureOptions() method. } - protected function transformFilterModel(FilterModel $model, ConfigBuilder $config): void + protected function transformFilterModel(ConfigBuilder $config, FilterModel $model): void { // TODO: Implement transformFilterModel() method. } diff --git a/src/List/Type/AbstractListType.php b/src/List/Type/AbstractListType.php index 55e90d52..a7fe4fbe 100644 --- a/src/List/Type/AbstractListType.php +++ b/src/List/Type/AbstractListType.php @@ -6,7 +6,7 @@ use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\Config\TransformerResolver; -use HeimrichHannot\FlareBundle\Contract; +use HeimrichHannot\FlareBundle\Contract\ListType\BuildQueryContract; use HeimrichHannot\FlareBundle\Contract\OptionsContract; use HeimrichHannot\FlareBundle\Contract\TransformerContract; use HeimrichHannot\FlareBundle\List\CallbackListModelTransformer; @@ -16,7 +16,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; abstract class AbstractListType implements - ListTypeInterface, OptionsContract, TransformerContract, Contract\ListType\BuildQueryContract + ListTypeInterface, OptionsContract, TransformerContract, BuildQueryContract { /** * Declares the type's config schema on top of {@see \HeimrichHannot\FlareBundle\List\BaseListOptions}. diff --git a/tests/Config/TransformerBuilderTest.php b/tests/Config/TransformerBuilderTest.php index ca72aed5..0da0d929 100644 --- a/tests/Config/TransformerBuilderTest.php +++ b/tests/Config/TransformerBuilderTest.php @@ -8,7 +8,7 @@ use HeimrichHannot\FlareBundle\Config\TransformerResolver; use PHPUnit\Framework\TestCase; -final class TransformerBuilderTest extends TestCase +final class TransformerResolverTest extends TestCase { public function testResolvesRegisteredSourceClass(): void { From c12297359b0d179be647d38b5287e94fe75a801b Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 16:45:15 +0200 Subject: [PATCH 30/71] test: align tests with `TransformerResolver` API and `(ConfigBuilder, model)` signature order Rename `TransformerBuilderTest` file to match its class, swap transformer callable and `transformListModel()`/invocation argument order to the new `(ConfigBuilder $config, object $source)` convention. --- ...BuilderTest.php => TransformerResolverTest.php} | 14 +++++++------- tests/Filter/Element/ArchiveFilterElementTest.php | 2 +- .../Element/SimpleEquationFilterElementTest.php | 2 +- tests/Filter/FilterTransformerResolverTest.php | 4 ++-- tests/List/ListBuilderTest.php | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) rename tests/Config/{TransformerBuilderTest.php => TransformerResolverTest.php} (74%) diff --git a/tests/Config/TransformerBuilderTest.php b/tests/Config/TransformerResolverTest.php similarity index 74% rename from tests/Config/TransformerBuilderTest.php rename to tests/Config/TransformerResolverTest.php index 0da0d929..32d22c19 100644 --- a/tests/Config/TransformerBuilderTest.php +++ b/tests/Config/TransformerResolverTest.php @@ -13,7 +13,7 @@ final class TransformerResolverTest extends TestCase public function testResolvesRegisteredSourceClass(): void { $transformers = new TransformerResolver(); - $transformer = static function (object $source, ConfigBuilder $config): void {}; + $transformer = static function (ConfigBuilder $config, object $source): void {}; $result = $transformers->for(SourceA::class, $transformer); @@ -24,7 +24,7 @@ public function testResolvesRegisteredSourceClass(): void public function testResolvesSubclassSources(): void { $transformers = new TransformerResolver(); - $transformer = static function (object $source, ConfigBuilder $config): void {}; + $transformer = static function (ConfigBuilder $config, object $source): void {}; $transformers->for(SourceA::class, $transformer); @@ -34,8 +34,8 @@ public function testResolvesSubclassSources(): void public function testReRegistrationOverrides(): void { $transformers = new TransformerResolver(); - $first = static function (object $source, ConfigBuilder $config): void {}; - $second = static function (object $source, ConfigBuilder $config): void {}; + $first = static function (ConfigBuilder $config, object $source): void {}; + $second = static function (ConfigBuilder $config, object $source): void {}; $transformers->for(SourceA::class, $first); $transformers->for(SourceA::class, $second); @@ -46,7 +46,7 @@ public function testReRegistrationOverrides(): void public function testReturnsNullWithoutMatch(): void { $transformers = new TransformerResolver(); - $transformers->for(SourceA::class, static function (object $source, ConfigBuilder $config): void {}); + $transformers->for(SourceA::class, static function (ConfigBuilder $config, object $source): void {}); self::assertNull($transformers->resolve(new SourceB())); } @@ -54,8 +54,8 @@ public function testReturnsNullWithoutMatch(): void public function testExactClassMatchWinsOverEarlierBaseClassRegistration(): void { $transformers = new TransformerResolver(); - $base = static function (object $source, ConfigBuilder $config): void {}; - $specific = static function (object $source, ConfigBuilder $config): void {}; + $base = static function (ConfigBuilder $config, object $source): void {}; + $specific = static function (ConfigBuilder $config, object $source): void {}; $transformers->for(SourceA::class, $base); $transformers->for(SourceASub::class, $specific); diff --git a/tests/Filter/Element/ArchiveFilterElementTest.php b/tests/Filter/Element/ArchiveFilterElementTest.php index d90e274e..7d708eb9 100644 --- a/tests/Filter/Element/ArchiveFilterElementTest.php +++ b/tests/Filter/Element/ArchiveFilterElementTest.php @@ -95,7 +95,7 @@ private function transform(array $row): array $transformer = $transformers->resolve($model = new FilterModelStub($row)); self::assertNotNull($transformer); - $transformer($model, $config = new ConfigBuilder()); + $transformer($config = new ConfigBuilder(), $model); return $config->all(); } diff --git a/tests/Filter/Element/SimpleEquationFilterElementTest.php b/tests/Filter/Element/SimpleEquationFilterElementTest.php index edb14035..09fbfa5b 100644 --- a/tests/Filter/Element/SimpleEquationFilterElementTest.php +++ b/tests/Filter/Element/SimpleEquationFilterElementTest.php @@ -73,7 +73,7 @@ private function transform(array $row): array $transformer = $transformers->resolve($model = new FilterModelStub($row)); self::assertNotNull($transformer); - $transformer($model, $config = new ConfigBuilder()); + $transformer($config = new ConfigBuilder(), $model); return $config->all(); } diff --git a/tests/Filter/FilterTransformerResolverTest.php b/tests/Filter/FilterTransformerResolverTest.php index 069dcdf6..a52376e5 100644 --- a/tests/Filter/FilterTransformerResolverTest.php +++ b/tests/Filter/FilterTransformerResolverTest.php @@ -62,7 +62,7 @@ public function testEventListenersCanAddSourceCapabilities(): void static function (FilterTransformerEvent $event): void { $event->transformers->for( \stdClass::class, - static fn (object $source, ConfigBuilder $config) => $config->set('external', true), + static fn (ConfigBuilder $config, object $source) => $config->set('external', true), ); }, ); @@ -86,7 +86,7 @@ final class TransformingElement implements FilterElementInterface, TransformerCo { public function configureTransformers(TransformerResolver $resolver): void { - $resolver->for(RowSource::class, static function (RowSource $source, ConfigBuilder $config): void { + $resolver->for(RowSource::class, static function (ConfigBuilder $config, RowSource $source): void { foreach ($source->row as $key => $value) { $config->set($key, $value); } diff --git a/tests/List/ListBuilderTest.php b/tests/List/ListBuilderTest.php index 927a0678..42a306bd 100644 --- a/tests/List/ListBuilderTest.php +++ b/tests/List/ListBuilderTest.php @@ -70,7 +70,7 @@ public function testFiltersAndTypeCarryOverToTheSpec(): void public function testModelTransformationAndOverridePrecedence(): void { $type = new class extends AbstractListType { - protected function transformListModel(ListModel $model, ConfigBuilder $config): void + protected function transformListModel(ConfigBuilder $config, ListModel $model): void { $config->set('genericPageMeta', true); $config->set('title', 'from-transformer'); From 501b76e711fd2fe361b39a1620d7bf37628288f1 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 16:50:52 +0200 Subject: [PATCH 31/71] fix mago lint findings --- src/Config/ConfigBuilderInterface.php | 2 ++ src/Config/TransformerInterface.php | 2 ++ src/Filter/CallbackFilterModelTransformer.php | 2 ++ src/Filter/Element/ArchiveFilterElement.php | 1 - src/List/CallbackListModelTransformer.php | 2 ++ src/List/ListBuilderInterface.php | 2 ++ 6 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Config/ConfigBuilderInterface.php b/src/Config/ConfigBuilderInterface.php index a2a736fd..1e5a7524 100644 --- a/src/Config/ConfigBuilderInterface.php +++ b/src/Config/ConfigBuilderInterface.php @@ -1,5 +1,7 @@ Date: Tue, 14 Jul 2026 17:01:55 +0200 Subject: [PATCH 32/71] add clarification comment for PHPStan ignore annotation --- src/DependencyInjection/Configuration.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 6ab12a70..997d7286 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -14,7 +14,9 @@ public function getConfigTreeBuilder(): TreeBuilder $treeBuilder = new TreeBuilder('huh_flare'); $rootNode = $treeBuilder->getRootNode(); - // @phpstan-ignore class.notFound (PHPStan 1.x cannot parse symfony/config 7.4 template defaults) + // PHPStan 1.x cannot parse symfony/config 7.4 template defaults. + // This phpstan-ignore annotation is only required when using symfony/config >= 7: + // @ ### phpstan-ignore class.notFound $rootNode ->children() ->arrayNode('format_label_defaults') @@ -60,4 +62,4 @@ public function getConfigTreeBuilder(): TreeBuilder return $treeBuilder; } -} \ No newline at end of file +} From da4450438be90c33fee4e7d51e4e73fcda14b11e Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 17:55:17 +0200 Subject: [PATCH 33/71] refactor: add `from_enabled`/`to_enabled` options to `DateRangeFilterElement` and rename `$data` to `$values` in `buildFilter` signatures Added optional boolean options `from_enabled` and `to_enabled` to control the inclusion of `from` and `to` fields in `DateRangeFilterElement`. Updated parameter name in `buildFilter` methods across filter elements from `$data` to `$values` for improved clarity. Adjusted tests and related documentation accordingly. --- src/Filter/Element/AbstractFilterElement.php | 2 +- src/Filter/Element/ArchiveFilterElement.php | 4 +-- .../BelongsToRelationFilterElement.php | 2 +- src/Filter/Element/BooleanFilterElement.php | 4 +-- .../Element/CalendarCurrentFilterElement.php | 4 +-- src/Filter/Element/DateRangeFilterElement.php | 36 +++++++++++-------- .../Element/DcaSelectFieldFilterElement.php | 4 +-- .../Element/FieldValueChoiceFilterElement.php | 4 +-- src/Filter/Element/FilterElementInterface.php | 4 +-- src/Filter/Element/PublishedFilterElement.php | 2 +- .../Element/SearchKeywordsFilterElement.php | 4 +-- .../Element/SimpleEquationFilterElement.php | 2 +- .../CodefogTagsChoiceFilterElement.php | 4 +-- tests/Filter/FilterOptionsResolverTest.php | 4 +-- tests/Filter/FilterTest.php | 2 +- .../Filter/FilterTransformerResolverTest.php | 4 +-- 16 files changed, 46 insertions(+), 40 deletions(-) diff --git a/src/Filter/Element/AbstractFilterElement.php b/src/Filter/Element/AbstractFilterElement.php index 68ecb414..68719b16 100644 --- a/src/Filter/Element/AbstractFilterElement.php +++ b/src/Filter/Element/AbstractFilterElement.php @@ -42,7 +42,7 @@ public function buildDca(DcaBuilder $dca, DcaContext $context): void {} public function buildForm(FormBuilderInterface $builder, FilterContext $context): void {} - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void {} + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void {} public function isSupported(): bool { diff --git a/src/Filter/Element/ArchiveFilterElement.php b/src/Filter/Element/ArchiveFilterElement.php index 26f32e90..450f2dee 100644 --- a/src/Filter/Element/ArchiveFilterElement.php +++ b/src/Filter/Element/ArchiveFilterElement.php @@ -175,14 +175,14 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) /** * @throws FilterException */ - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void { $config = $context->config; /** @var Model[] $selectedModels */ $selectedModels = $config['intrinsic'] ? $this->getWhitelistedParents($context->list, $config) - : $this->processRuntimeValue($data[FilterContext::FIELD_VALUE] ?? null, $context->list, $config); + : $this->processRuntimeValue($values[FilterContext::FIELD_VALUE] ?? null, $context->list, $config); $inferrer = $this->getPtableInferrer($context->list); diff --git a/src/Filter/Element/BelongsToRelationFilterElement.php b/src/Filter/Element/BelongsToRelationFilterElement.php index cfaf29da..df87e001 100644 --- a/src/Filter/Element/BelongsToRelationFilterElement.php +++ b/src/Filter/Element/BelongsToRelationFilterElement.php @@ -55,7 +55,7 @@ protected function transformFilterModel(ConfigBuilder $config, FilterModel $mode /** * @throws FilterException */ - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void { $config = $context->config; diff --git a/src/Filter/Element/BooleanFilterElement.php b/src/Filter/Element/BooleanFilterElement.php index 16248ce1..2b92127a 100644 --- a/src/Filter/Element/BooleanFilterElement.php +++ b/src/Filter/Element/BooleanFilterElement.php @@ -58,7 +58,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) ]); } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void { $config = $context->config; @@ -68,7 +68,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont $value = $config['intrinsic'] ? $config['preselect'] - : $this->resolveRuntimeValue($data[FilterContext::FIELD_VALUE] ?? null, $config); + : $this->resolveRuntimeValue($values[FilterContext::FIELD_VALUE] ?? null, $config); if ($value === null) { return; diff --git a/src/Filter/Element/CalendarCurrentFilterElement.php b/src/Filter/Element/CalendarCurrentFilterElement.php index 91003efd..401cfac0 100644 --- a/src/Filter/Element/CalendarCurrentFilterElement.php +++ b/src/Filter/Element/CalendarCurrentFilterElement.php @@ -95,7 +95,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) $builder->addEventListener(FormEvents::POST_SUBMIT, $this->validateRange(...)); } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void { $config = $context->config; @@ -103,7 +103,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont return; } - $value = $this->processRuntimeValue($data) ?? []; + $value = $this->processRuntimeValue($values) ?? []; $from = $value['from'] ?? null; $to = $value['to'] ?? null; diff --git a/src/Filter/Element/DateRangeFilterElement.php b/src/Filter/Element/DateRangeFilterElement.php index 8790ebf0..efd6a398 100644 --- a/src/Filter/Element/DateRangeFilterElement.php +++ b/src/Filter/Element/DateRangeFilterElement.php @@ -34,6 +34,8 @@ public function configureOptions(OptionsResolver $resolver): void { $resolver->define('intrinsic')->default(false)->allowedTypes('bool'); $resolver->define('field')->default(null)->allowedTypes('string', 'null'); + $resolver->define('from_enabled')->default(true)->allowedTypes('bool'); + $resolver->define('to_enabled')->default(true)->allowedTypes('bool'); } protected function transformFilterModel(ConfigBuilder $config, FilterModel $model): void @@ -49,19 +51,23 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) return; } - $builder->add('from', DateType::class, [ - 'widget' => 'single_text', - 'label' => 'label.date_range.from', - 'html5' => true, - 'required' => false, - ]); + if ($context->config['from_enabled']) { + $builder->add('from', DateType::class, [ + 'widget' => 'single_text', + 'label' => 'label.date_range.from', + 'html5' => true, + 'required' => false, + ]); + } - $builder->add('to', DateType::class, [ - 'widget' => 'single_text', - 'label' => 'label.date_range.to', - 'html5' => true, - 'required' => false, - ]); + if ($context->config['to_enabled']) { + $builder->add('to', DateType::class, [ + 'widget' => 'single_text', + 'label' => 'label.date_range.to', + 'html5' => true, + 'required' => false, + ]); + } $builder->addEventListener(FormEvents::POST_SUBMIT, $this->validateRange(...)); } @@ -69,7 +75,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) /** * @throws FilterException */ - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void { if (!$field = $context->config['field']) { throw new FilterException('Set fieldGeneric in filter model.'); @@ -77,8 +83,8 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont $builder->add(DateRangeFilterType::class, [ 'field' => $field, - 'from' => $data['from'] ?? null, - 'to' => $data['to'] ?? null, + 'from' => $values['from'] ?? null, + 'to' => $values['to'] ?? null, ]); } diff --git a/src/Filter/Element/DcaSelectFieldFilterElement.php b/src/Filter/Element/DcaSelectFieldFilterElement.php index 6a0a66d7..61a49906 100644 --- a/src/Filter/Element/DcaSelectFieldFilterElement.php +++ b/src/Filter/Element/DcaSelectFieldFilterElement.php @@ -102,14 +102,14 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) $builder->add(FilterContext::FIELD_VALUE, ChoiceType::class, $formOptions); } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void { $config = $context->config; $options = $this->getOptions($context->list->dc, $config['field']) ?? []; $selected = $config['intrinsic'] ? $config['preselect'] - : $this->normalizeSubmittedValue($data[FilterContext::FIELD_VALUE] ?? null, $options); + : $this->normalizeSubmittedValue($values[FilterContext::FIELD_VALUE] ?? null, $options); if (!$selected) { return; diff --git a/src/Filter/Element/FieldValueChoiceFilterElement.php b/src/Filter/Element/FieldValueChoiceFilterElement.php index 8739c120..9298f068 100644 --- a/src/Filter/Element/FieldValueChoiceFilterElement.php +++ b/src/Filter/Element/FieldValueChoiceFilterElement.php @@ -82,7 +82,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) $builder->setAttribute('flare.choices_builder', $choicesBuilder); } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void { if ($context->engineContext instanceof ValidationContext) { return; @@ -96,7 +96,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont $value = $config['intrinsic'] ? $config['preselect'] - : $this->normalizeRuntimeValue($data[FilterContext::FIELD_VALUE] ?? null, $context); + : $this->normalizeRuntimeValue($values[FilterContext::FIELD_VALUE] ?? null, $context); if (!$value) { return; diff --git a/src/Filter/Element/FilterElementInterface.php b/src/Filter/Element/FilterElementInterface.php index a925d21d..fbf8331a 100644 --- a/src/Filter/Element/FilterElementInterface.php +++ b/src/Filter/Element/FilterElementInterface.php @@ -22,9 +22,9 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) /** * Translates canonical config and runtime data into filter type calls. * - * @param array $data Submitted form data of this filter's compound child (keyed by + * @param array $values Submitted form data of this filter's compound child (keyed by * the local child names added in buildForm()) or a programmatically set data bag; empty array * when neither exists (e.g. non-interactive contexts). */ - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void; + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void; } diff --git a/src/Filter/Element/PublishedFilterElement.php b/src/Filter/Element/PublishedFilterElement.php index 08c10014..0859aecc 100644 --- a/src/Filter/Element/PublishedFilterElement.php +++ b/src/Filter/Element/PublishedFilterElement.php @@ -42,7 +42,7 @@ protected function transformFilterModel(ConfigBuilder $config, FilterModel $mode ->set('invert', (bool) $model->invertPublished); } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void { $config = $context->config; diff --git a/src/Filter/Element/SearchKeywordsFilterElement.php b/src/Filter/Element/SearchKeywordsFilterElement.php index df2ceadc..77eb6763 100644 --- a/src/Filter/Element/SearchKeywordsFilterElement.php +++ b/src/Filter/Element/SearchKeywordsFilterElement.php @@ -61,13 +61,13 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) $builder->add(FilterContext::FIELD_VALUE, TextType::class, $options); } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void { $config = $context->config; $value = $config['intrinsic'] ? $config['prefill'] - : ($data[FilterContext::FIELD_VALUE] ?? null); + : ($values[FilterContext::FIELD_VALUE] ?? null); if (!$value || !\is_string($value)) { return; diff --git a/src/Filter/Element/SimpleEquationFilterElement.php b/src/Filter/Element/SimpleEquationFilterElement.php index d1734777..dd3f1fb4 100644 --- a/src/Filter/Element/SimpleEquationFilterElement.php +++ b/src/Filter/Element/SimpleEquationFilterElement.php @@ -42,7 +42,7 @@ protected function transformFilterModel(ConfigBuilder $config, FilterModel $mode /** * @throws FilterException */ - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void { $config = $context->config; diff --git a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php index afe5993c..0967a54d 100644 --- a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php +++ b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php @@ -113,7 +113,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) $builder->add(FilterContext::FIELD_VALUE, ChoiceType::class, $formOptions); } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void { $config = $context->config; @@ -122,7 +122,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont /** @var ?array $tagIds */ $tagIds = $config['intrinsic'] ? $preselect - : $this->processRuntimeValue($data[FilterContext::FIELD_VALUE] ?? null); + : $this->processRuntimeValue($values[FilterContext::FIELD_VALUE] ?? null); if (!$tagIds) { return; diff --git a/tests/Filter/FilterOptionsResolverTest.php b/tests/Filter/FilterOptionsResolverTest.php index 1699f04c..36e8c561 100644 --- a/tests/Filter/FilterOptionsResolverTest.php +++ b/tests/Filter/FilterOptionsResolverTest.php @@ -69,7 +69,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) { } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void { } } @@ -80,7 +80,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) { } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void { } } diff --git a/tests/Filter/FilterTest.php b/tests/Filter/FilterTest.php index 4822f1d4..835c8e0b 100644 --- a/tests/Filter/FilterTest.php +++ b/tests/Filter/FilterTest.php @@ -61,7 +61,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) { } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void { } }; diff --git a/tests/Filter/FilterTransformerResolverTest.php b/tests/Filter/FilterTransformerResolverTest.php index a52376e5..43baf8f3 100644 --- a/tests/Filter/FilterTransformerResolverTest.php +++ b/tests/Filter/FilterTransformerResolverTest.php @@ -97,7 +97,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) { } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void { } } @@ -108,7 +108,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) { } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $data): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void { } } From edb1dd7cfaa2b5ed0b9271f386cd75e2f737bd98 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 18:55:22 +0200 Subject: [PATCH 34/71] refactor: replace `intrinsicOnly` attribute with `IntrinsicContract` for filter elements Removed the `intrinsicOnly` attribute from filter elements and introduced the `IntrinsicContract` interface to determine intrinsic-only behavior. Updated relevant filter elements, descriptors, and registry logic. Adjusted documentation and tests accordingly. --- AGENTS.md | 2 +- src/Contract/FilterElement/IntrinsicContract.php | 8 ++++++++ .../Attribute/AsFilterElement.php | 3 --- .../Compiler/RegisterFilterElementsPass.php | 3 +-- .../FlareFilter/FieldsLoadAndSaveCallbacks.php | 11 ++++++++--- src/Filter/Element/AbstractFilterElement.php | 8 +++++++- .../Element/BelongsToRelationFilterElement.php | 7 ++++++- src/Filter/Element/PublishedFilterElement.php | 16 ++++++++++++---- .../Element/SimpleEquationFilterElement.php | 7 ++++++- .../Descriptor/FilterElementDescriptor.php | 9 --------- 10 files changed, 49 insertions(+), 25 deletions(-) create mode 100644 src/Contract/FilterElement/IntrinsicContract.php diff --git a/AGENTS.md b/AGENTS.md index c9ddb4ab..4f668d91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,7 @@ The bundle follows standard Symfony Bundle architecture with deep Contao integra - `src/Controller/ContentElement/ListViewController.php` / `ReaderController.php` — frontend controllers **Extensibility via PHP 8 attributes** (compiler passes auto-register tagged services): -- `#[AsFilterElement(type: '...', intrinsicOnly: ..., isTargeted: ...)]` — register a filter element +- `#[AsFilterElement(type: '...', isTargeted: ...)]` — register a filter element - `#[AsListType(type: '...', dataContainer: '...')]` — register a list type Attributes are in `src/DependencyInjection/Attribute/`, compiler passes in `src/DependencyInjection/Compiler/`. diff --git a/src/Contract/FilterElement/IntrinsicContract.php b/src/Contract/FilterElement/IntrinsicContract.php new file mode 100644 index 00000000..04da0443 --- /dev/null +++ b/src/Contract/FilterElement/IntrinsicContract.php @@ -0,0 +1,8 @@ +attributes = $attributes; diff --git a/src/DependencyInjection/Compiler/RegisterFilterElementsPass.php b/src/DependencyInjection/Compiler/RegisterFilterElementsPass.php index 892dceb7..0e36945b 100644 --- a/src/DependencyInjection/Compiler/RegisterFilterElementsPass.php +++ b/src/DependencyInjection/Compiler/RegisterFilterElementsPass.php @@ -69,7 +69,6 @@ protected function getFilterElementConfig( $reference, $attributes, $attributes['isTargeted'] ?? null, - (bool) ($attributes['intrinsicOnly'] ?? false), ]); $serviceId = 'huh.flare.filter_element._config_' . ContainerBuilder::hash($definition); @@ -91,4 +90,4 @@ protected function getFilterElementType(Definition $definition, array $attribute return TypeNameFactory::createFilterElementType($definition->getClass()); } -} \ No newline at end of file +} diff --git a/src/EventListener/DataContainer/FlareFilter/FieldsLoadAndSaveCallbacks.php b/src/EventListener/DataContainer/FlareFilter/FieldsLoadAndSaveCallbacks.php index dee66f5a..1ba9f50d 100644 --- a/src/EventListener/DataContainer/FlareFilter/FieldsLoadAndSaveCallbacks.php +++ b/src/EventListener/DataContainer/FlareFilter/FieldsLoadAndSaveCallbacks.php @@ -6,6 +6,7 @@ use Contao\CoreBundle\DependencyInjection\Attribute\AsCallback; use Contao\DataContainer; +use HeimrichHannot\FlareBundle\Contract\FilterElement\IntrinsicContract; use HeimrichHannot\FlareBundle\DataContainer\FilterContainer; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; use HeimrichHannot\FlareBundle\Util\DateTimeHelper; @@ -79,7 +80,9 @@ public function onLoadField_intrinsic(mixed $value, DataContainer $dc): bool return $value; } - if ($this->filterElementRegistry->get($row['type'] ?? null)?->isIntrinsicOnly()) + $filterElement = $this->filterElementRegistry->get($row['type'] ?? null)?->getService(); + + if ($filterElement instanceof IntrinsicContract && $filterElement->isOnlyIntrinsic()) { $eval = &$GLOBALS['TL_DCA'][self::TABLE_NAME]['fields']['intrinsic']['eval']; @@ -98,7 +101,9 @@ public function onSaveField_intrinsic(mixed $value, DataContainer $dc): mixed return $value; } - if ($this->filterElementRegistry->get($row['type'] ?? null)?->isIntrinsicOnly()) { + $element = $this->filterElementRegistry->get($row['type'] ?? null)?->getService(); + + if ($element instanceof IntrinsicContract && $element->isOnlyIntrinsic()) { return '1'; } @@ -175,4 +180,4 @@ public function onLoadField_startStopAt(string $value, DataContainer $dc): strin return $value; } -} \ No newline at end of file +} diff --git a/src/Filter/Element/AbstractFilterElement.php b/src/Filter/Element/AbstractFilterElement.php index 68719b16..298a30ce 100644 --- a/src/Filter/Element/AbstractFilterElement.php +++ b/src/Filter/Element/AbstractFilterElement.php @@ -7,6 +7,7 @@ use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\Config\TransformerResolver; use HeimrichHannot\FlareBundle\Contract\DcaContract; +use HeimrichHannot\FlareBundle\Contract\FilterElement\IntrinsicContract; use HeimrichHannot\FlareBundle\Contract\IsSupportedContract; use HeimrichHannot\FlareBundle\Contract\OptionsContract; use HeimrichHannot\FlareBundle\Contract\TransformerContract; @@ -20,7 +21,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; abstract class AbstractFilterElement implements - FilterElementInterface, OptionsContract, TransformerContract, IsSupportedContract, DcaContract + FilterElementInterface, IntrinsicContract, DcaContract, IsSupportedContract, OptionsContract, TransformerContract { abstract public function configureOptions(OptionsResolver $resolver): void; @@ -48,4 +49,9 @@ public function isSupported(): bool { return true; } + + public function isOnlyIntrinsic(): bool + { + return false; + } } diff --git a/src/Filter/Element/BelongsToRelationFilterElement.php b/src/Filter/Element/BelongsToRelationFilterElement.php index df87e001..0c635e42 100644 --- a/src/Filter/Element/BelongsToRelationFilterElement.php +++ b/src/Filter/Element/BelongsToRelationFilterElement.php @@ -21,7 +21,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Contracts\Translation\TranslatorInterface; -#[AsFilterElement(type: self::TYPE, intrinsicOnly: true)] +#[AsFilterElement(type: self::TYPE)] class BelongsToRelationFilterElement extends AbstractFilterElement { public const TYPE = 'flare_relation_belongsTo'; @@ -30,6 +30,11 @@ public function __construct( private readonly TranslatorInterface $trans, ) {} + public function isOnlyIntrinsic(): bool + { + return true; + } + public function configureOptions(OptionsResolver $resolver): void { $resolver->define('intrinsic')->default(false)->allowedTypes('bool'); diff --git a/src/Filter/Element/PublishedFilterElement.php b/src/Filter/Element/PublishedFilterElement.php index 0859aecc..51a61977 100644 --- a/src/Filter/Element/PublishedFilterElement.php +++ b/src/Filter/Element/PublishedFilterElement.php @@ -14,11 +14,16 @@ use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\OptionsResolver\OptionsResolver; -#[AsFilterElement(type: self::TYPE, intrinsicOnly: true)] +#[AsFilterElement(type: self::TYPE)] class PublishedFilterElement extends AbstractFilterElement { public const TYPE = 'flare_published'; + public function isOnlyIntrinsic(): bool + { + return true; + } + public function configureOptions(OptionsResolver $resolver): void { $resolver->define('intrinsic')->default(false)->allowedTypes('bool'); @@ -33,12 +38,15 @@ protected function transformFilterModel(ConfigBuilder $config, FilterModel $mode $usePublished = (bool) ($model->usePublished ?? true); $useStart = (bool) ($model->useStart ?? true); $useStop = (bool) ($model->useStop ?? true); + $fieldPublished = $model->fieldPublished ?: 'published'; + $fieldStart = $model->fieldStart ?: 'start'; + $fieldStop = $model->fieldStop ?: 'stop'; $config ->set('intrinsic', (bool) $model->intrinsic) - ->set('published_field', $usePublished ? ($model->fieldPublished ?: 'published') : null) - ->set('start_field', $useStart ? ($model->fieldStart ?: 'start') : null) - ->set('stop_field', $useStop ? ($model->fieldStop ?: 'stop') : null) + ->set('published_field', $usePublished ? $fieldPublished : null) + ->set('start_field', $useStart ? $fieldStart : null) + ->set('stop_field', $useStop ? $fieldStop : null) ->set('invert', (bool) $model->invertPublished); } diff --git a/src/Filter/Element/SimpleEquationFilterElement.php b/src/Filter/Element/SimpleEquationFilterElement.php index dd3f1fb4..ebd8a879 100644 --- a/src/Filter/Element/SimpleEquationFilterElement.php +++ b/src/Filter/Element/SimpleEquationFilterElement.php @@ -17,11 +17,16 @@ use HeimrichHannot\FlareBundle\Util\DcaHelper; use Symfony\Component\OptionsResolver\OptionsResolver; -#[AsFilterElement(type: self::TYPE, intrinsicOnly: true, isTargeted: true)] +#[AsFilterElement(type: self::TYPE, isTargeted: true)] class SimpleEquationFilterElement extends AbstractFilterElement { public const TYPE = 'flare_equation_simple'; + public function isOnlyIntrinsic(): bool + { + return true; + } + public function configureOptions(OptionsResolver $resolver): void { $resolver->define('intrinsic')->default(false)->allowedTypes('bool'); diff --git a/src/Registry/Descriptor/FilterElementDescriptor.php b/src/Registry/Descriptor/FilterElementDescriptor.php index 42993215..ce2c9738 100644 --- a/src/Registry/Descriptor/FilterElementDescriptor.php +++ b/src/Registry/Descriptor/FilterElementDescriptor.php @@ -15,7 +15,6 @@ public function __construct( private FilterElementInterface $service, private array $attributes = [], private ?bool $isTargeted = null, - private bool $intrinsicOnly = false, ) {} public function getService(): FilterElementInterface @@ -42,12 +41,4 @@ public function isTargeted(): ?bool { return $this->isTargeted; } - - /** - * Whether the element never renders a form control and must be configured intrinsically. - */ - public function isIntrinsicOnly(): bool - { - return $this->intrinsicOnly; - } } From 61df0e7c3d817c919e3c8303a1e62543272a1152 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 00:19:44 +0200 Subject: [PATCH 35/71] refactor: streamline ChoicesBuilder usage and remove redundant options handling across filter elements --- .../FilterElement/IntrinsicContract.php | 2 + src/Engine/Projector/InteractiveProjector.php | 24 ++++++- src/Filter/Element/AbstractFilterElement.php | 29 +++++++++ src/Filter/Element/ArchiveFilterElement.php | 25 +++----- src/Filter/Element/BooleanFilterElement.php | 1 - .../Element/DcaSelectFieldFilterElement.php | 11 +--- .../Element/FieldValueChoiceFilterElement.php | 20 +++--- src/Form/ChoicesBuilder.php | 63 +++++++++---------- .../CodefogTagsChoiceFilterElement.php | 8 +-- 9 files changed, 101 insertions(+), 82 deletions(-) diff --git a/src/Contract/FilterElement/IntrinsicContract.php b/src/Contract/FilterElement/IntrinsicContract.php index 04da0443..ad6de8bc 100644 --- a/src/Contract/FilterElement/IntrinsicContract.php +++ b/src/Contract/FilterElement/IntrinsicContract.php @@ -1,5 +1,7 @@ > */ @@ -133,7 +133,25 @@ protected function collectFilterData(ListSpec $list, FormInterface $form): array continue; } - $data[$key] = (array) $form->get($filter->alias)->getData(); + $child = $form->get($filter->alias); + + if ($form->isSubmitted()) + { + $data[$key] = (array) $child->getData(); + continue; + } + + // Unsubmitted forms never map the fields' default data (e.g., preselects) back onto + // the compound filter child, so collect the defaults from the fields directly. + // Filters without defaults stay unset here, so Filter::$data can take over. + $values = \array_filter( + \array_map(static fn (FormInterface $field): mixed => $field->getData(), $child->all()), + static fn (mixed $value): bool => !\is_null($value), + ); + + if ($values) { + $data[$key] = $values; + } } return $data; diff --git a/src/Filter/Element/AbstractFilterElement.php b/src/Filter/Element/AbstractFilterElement.php index 298a30ce..24698e59 100644 --- a/src/Filter/Element/AbstractFilterElement.php +++ b/src/Filter/Element/AbstractFilterElement.php @@ -4,6 +4,7 @@ namespace HeimrichHannot\FlareBundle\Filter\Element; +use Doctrine\DBAL\Connection; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\Config\TransformerResolver; use HeimrichHannot\FlareBundle\Contract\DcaContract; @@ -16,13 +17,19 @@ use HeimrichHannot\FlareBundle\Filter\CallbackFilterModelTransformer; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Form\ChoicesBuilder; +use HeimrichHannot\FlareBundle\Form\Factory\ChoicesBuilderFactory; use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Contracts\Service\Attribute\Required; abstract class AbstractFilterElement implements FilterElementInterface, IntrinsicContract, DcaContract, IsSupportedContract, OptionsContract, TransformerContract { + private ChoicesBuilderFactory $choicesBuilderFactory; + private Connection $connection; + abstract public function configureOptions(OptionsResolver $resolver): void; public function configureTransformers(TransformerResolver $resolver): void @@ -54,4 +61,26 @@ public function isOnlyIntrinsic(): bool { return false; } + + #[Required] + public function setChoicesBuilderFactory(ChoicesBuilderFactory $choicesBuilderFactory): void + { + $this->choicesBuilderFactory = $choicesBuilderFactory; + } + + protected function createChoicesBuilder(): ChoicesBuilder + { + return $this->choicesBuilderFactory->createChoicesBuilder(); + } + + #[Required] + public function setConnection(Connection $connection): void + { + $this->connection = $connection; + } + + public function getConnection(): Connection + { + return $this->connection; + } } diff --git a/src/Filter/Element/ArchiveFilterElement.php b/src/Filter/Element/ArchiveFilterElement.php index 450f2dee..9f64072e 100644 --- a/src/Filter/Element/ArchiveFilterElement.php +++ b/src/Filter/Element/ArchiveFilterElement.php @@ -17,7 +17,6 @@ use HeimrichHannot\FlareBundle\Filter\Type\ArchiveFilterType; use HeimrichHannot\FlareBundle\Filter\Type\BelongsToRelationFilterType; use HeimrichHannot\FlareBundle\Form\ChoicesBuilder; -use HeimrichHannot\FlareBundle\Form\Factory\ChoicesBuilderFactory; use HeimrichHannot\FlareBundle\InferPtable\Factory\PtableInferrableFactory; use HeimrichHannot\FlareBundle\InferPtable\PtableInferrer; use HeimrichHannot\FlareBundle\List\ListSpec; @@ -34,10 +33,6 @@ class ArchiveFilterElement extends AbstractFilterElement private array $_inferrer = []; - public function __construct( - private readonly ChoicesBuilderFactory $choicesBuilderFactory, - ) {} - public function configureOptions(OptionsResolver $resolver): void { $resolver->define('intrinsic')->default(false)->allowedTypes('bool'); @@ -90,17 +85,11 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) $inferrer = $this->getPtableInferrer($context->list); - $choices = $this->choicesBuilderFactory->createChoicesBuilder()->enable(); - $builder->setAttribute('flare.choices_builder', $choices); - $formOptions = [ 'label' => false, 'required' => $config['is_mandatory'], 'multiple' => $config['is_multiple'], 'expanded' => $config['is_expanded'], - 'choice_loader' => $choices->buildCallbackChoiceLoader(), - 'choice_label' => $choices->buildChoiceLabelCallback(), - 'choice_value' => $choices->buildChoiceValueCallback(), ]; $data = $this->buildPreselectData($context->list, $config['preselect']); @@ -108,6 +97,9 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) $formOptions['data'] = $data; } + $choices = $this->createChoicesBuilder()->applyFormOptions($formOptions); + $builder->setAttribute('flare.choices_builder', $choices); + $builder->add(FilterContext::FIELD_VALUE, ChoiceType::class, $formOptions); if ($config['has_empty_option']) @@ -464,15 +456,12 @@ public function buildDca(DcaBuilder $dca, DcaContext $context): void */ private function getPreselectOptions(PtableInferrer $inferrer, array $row): array { - $choices = $this->choicesBuilderFactory - ->createChoicesBuilder() - ->setModelSuffix('[%id%]') - ->enable(); + $choices = $this->createChoicesBuilder()->setModelSuffix('[%id%]'); if ($ptable = $inferrer->getDcaMainPtable()) { if (!$parents = $this->fetchParents($ptable, $this->normalizeIds($row['whitelistParents'] ?? null))) { - return $choices->buildOptions(); + return $choices->buildContaoOptions(); } foreach ($parents as $parent) @@ -480,7 +469,7 @@ private function getPreselectOptions(PtableInferrer $inferrer, array $row): arra $choices->add(\sprintf('%s.%s', $ptable, $parent->id), $parent); } - return $choices->buildOptions(); + return $choices->buildContaoOptions(); } if ($inferrer->isDcaDynamicPtable()) @@ -500,7 +489,7 @@ private function getPreselectOptions(PtableInferrer $inferrer, array $row): arra } } - return $choices->buildOptions(); + return $choices->buildContaoOptions(); } /** diff --git a/src/Filter/Element/BooleanFilterElement.php b/src/Filter/Element/BooleanFilterElement.php index 2b92127a..cac557b1 100644 --- a/src/Filter/Element/BooleanFilterElement.php +++ b/src/Filter/Element/BooleanFilterElement.php @@ -164,5 +164,4 @@ protected function getFieldGenericOptions(string $targetTable): array return $options; } - } diff --git a/src/Filter/Element/DcaSelectFieldFilterElement.php b/src/Filter/Element/DcaSelectFieldFilterElement.php index 61a49906..985d77b3 100644 --- a/src/Filter/Element/DcaSelectFieldFilterElement.php +++ b/src/Filter/Element/DcaSelectFieldFilterElement.php @@ -15,7 +15,6 @@ use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\DcaSelectFilterType; -use HeimrichHannot\FlareBundle\Form\Factory\ChoicesBuilderFactory; use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\FormBuilderInterface; @@ -26,10 +25,6 @@ class DcaSelectFieldFilterElement extends AbstractFilterElement { public const TYPE = 'flare_dcaSelectField'; - public function __construct( - private readonly ChoicesBuilderFactory $choicesBuilderFactory, - ) {} - public function configureOptions(OptionsResolver $resolver): void { $resolver->define('intrinsic')->default(false)->allowedTypes('bool'); @@ -82,15 +77,13 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) if (!\is_null($options)) { - $choicesBuilder = $this->choicesBuilderFactory->createChoicesBuilder()->enable(); + $choicesBuilder = $this->createChoicesBuilder(); foreach ($options as $value => $label) { $choicesBuilder->add((string) $value, (string) $label); } - $formOptions['choice_loader'] = $choicesBuilder->buildCallbackChoiceLoader(); - $formOptions['choice_label'] = $choicesBuilder->buildChoiceLabelCallback(); - $formOptions['choice_value'] = $choicesBuilder->buildChoiceValueCallback(); + $choicesBuilder->applyFormOptions($formOptions); $builder->setAttribute('flare.choices_builder', $choicesBuilder); } diff --git a/src/Filter/Element/FieldValueChoiceFilterElement.php b/src/Filter/Element/FieldValueChoiceFilterElement.php index 9298f068..0c992429 100644 --- a/src/Filter/Element/FieldValueChoiceFilterElement.php +++ b/src/Filter/Element/FieldValueChoiceFilterElement.php @@ -68,16 +68,17 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) $choicesBuilder = $this->createChoices($context->list->dc, (string) ($config['field'] ?? '')) ->setEmptyOption(!$config['multiple']); - $builder->add(FilterContext::FIELD_VALUE, ChoiceType::class, [ + $formOptions = [ 'label' => false, 'multiple' => $config['multiple'], 'expanded' => $config['expanded'], 'required' => false, - 'choice_loader' => $choicesBuilder->buildCallbackChoiceLoader(), - 'choice_label' => $choicesBuilder->buildChoiceLabelCallback(), - 'choice_value' => $choicesBuilder->buildChoiceValueCallback(), 'data' => $this->buildPreselectData($choicesBuilder, $config), - ]); + ]; + + $choicesBuilder->applyFormOptions($formOptions); + + $builder->add(FilterContext::FIELD_VALUE, ChoiceType::class, $formOptions); $builder->setAttribute('flare.choices_builder', $choicesBuilder); } @@ -134,7 +135,7 @@ public function buildDca(DcaBuilder $dca, DcaContext $context): void return $this->createChoices($table, $valueField) ->setModelSuffix('[%id%]') - ->buildOptions(); + ->buildContaoOptions(); }); } @@ -144,9 +145,7 @@ public function buildDca(DcaBuilder $dca, DcaContext $context): void */ private function createChoices(string $table, string $field): ChoicesBuilder { - $choices = $this->choicesBuilderFactory - ->createChoicesBuilder() - ->enable(); + $choices = $this->choicesBuilderFactory->createChoicesBuilder(); if (!\is_null($foreignValues = $this->getForeignValues($table, $field))) { @@ -236,8 +235,7 @@ private function normalizePreselect(mixed $preselect, bool $multiple): ?array return $preselect; } - if ($multiple - || (\is_string($preselect) && \preg_match('/^a:\d+:\{.*}$/', $preselect))) + if ($multiple || (\is_string($preselect) && \preg_match('/^a:\d+:\{.*}$/', $preselect))) { return StringUtil::deserialize($preselect, true); } diff --git a/src/Form/ChoicesBuilder.php b/src/Form/ChoicesBuilder.php index c919237c..6f6238cf 100644 --- a/src/Form/ChoicesBuilder.php +++ b/src/Form/ChoicesBuilder.php @@ -45,7 +45,6 @@ * * Group support ({@see addGroup()}, {@see removeGroup()}) is reserved and not yet implemented. * - * @mago-expect lint:too-many-properties * @mago-expect lint:too-many-methods */ class ChoicesBuilder @@ -71,7 +70,6 @@ class ChoicesBuilder // @phpstan-ignore property.onlyWritten private array $choiceGroupMap = []; private string $modelSuffix = ''; - private bool $enabled = false; private bool $emptyOption = false; private string $emptyOptionValue = self::EMPTY_CHOICE_VALUE_DEFAULT; private LabelableInterface|string|null $emptyOptionLabel = null; @@ -173,36 +171,6 @@ public function removeGroup(string $key): static return $this; } - /** @api */ - public function setEnabled(bool $enabled): static - { - $this->enabled = $enabled; - - return $this; - } - - /** @api */ - public function isEnabled(): bool - { - return $this->enabled; - } - - /** @api */ - public function enable(): static - { - $this->enabled = true; - - return $this; - } - - /** @api */ - public function disable(): static - { - $this->enabled = false; - - return $this; - } - public function hasEmptyOption(): bool { return $this->emptyOption; @@ -372,7 +340,7 @@ public function buildChoiceLabel(mixed $choice, string|int $key, mixed $value): * * @api */ - public function buildOptions(): array + public function buildContaoOptions(): array { $options = []; @@ -391,6 +359,35 @@ public function buildOptions(): array return $options; } + /** + * Apply options to a Symfony Forms-compatible options array for a form field. + * + * @param array &$options + * @return $this + */ + public function applyFormOptions(array &$options): self + { + $options['choice_loader'] = $this->buildCallbackChoiceLoader(); + $options['choice_label'] = $this->buildChoiceLabelCallback(); + $options['choice_value'] = $this->buildChoiceValueCallback(); + + return $this; + } + + /** + * Generate a Symfony Forms-compatible options array for a form field. + * + * @return array + */ + public function buildFormOptions(): array + { + $options = []; + + $this->applyFormOptions($options); + + return $options; + } + /** * @param class-string $type * @internal diff --git a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php index 0967a54d..d8d9fd4e 100644 --- a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php +++ b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php @@ -13,7 +13,6 @@ use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\IntegerIdChoiceFilterType; -use HeimrichHannot\FlareBundle\Form\Factory\ChoicesBuilderFactory; use HeimrichHannot\FlareBundle\Integration\CodefogTags\Registry\CfgTagsJoinsRegistry; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Query\Factory\ListExecutionContextFactory; @@ -29,7 +28,6 @@ class CodefogTagsChoiceFilterElement extends AbstractFilterElement public const TYPE = 'cfg_tags_choice'; public function __construct( - private readonly ChoicesBuilderFactory $choicesBuilderFactory, private readonly CfgTagsJoinsRegistry $joinsRegistry, private readonly ListExecutionContextFactory $listExecutionContextFactory, private readonly LoggerInterface $logger, @@ -97,16 +95,12 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) if (!\is_null($optValues)) { - $choicesBuilder = $this->choicesBuilderFactory->createChoicesBuilder()->enable(); + $choicesBuilder = $this->createChoicesBuilder()->applyFormOptions($formOptions); foreach ($optValues as $value => $label) { $choicesBuilder->add((string) $value, (string) $label, (int) $value); } - $formOptions['choice_loader'] = $choicesBuilder->buildCallbackChoiceLoader(); - $formOptions['choice_label'] = $choicesBuilder->buildChoiceLabelCallback(); - $formOptions['choice_value'] = $choicesBuilder->buildChoiceValueCallback(); - $builder->setAttribute('flare.choices_builder', $choicesBuilder); } From 158874871be4badf51d18f054113b5486b819ffc Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 02:38:16 +0200 Subject: [PATCH 36/71] refactor: introduce `FilterFormBuilderInterface` to handle single-field filters and simplify filter form handling across elements --- src/Engine/Projector/InteractiveProjector.php | 19 +- src/Event/FilterElementFormBuiltEvent.php | 19 +- src/Filter/Element/AbstractFilterElement.php | 4 +- src/Filter/Element/ArchiveFilterElement.php | 8 +- src/Filter/Element/BooleanFilterElement.php | 8 +- .../Element/CalendarCurrentFilterElement.php | 4 +- src/Filter/Element/DateRangeFilterElement.php | 4 +- .../Element/DcaSelectFieldFilterElement.php | 8 +- .../Element/FieldValueChoiceFilterElement.php | 8 +- src/Filter/Element/FilterElementInterface.php | 23 +- .../Element/SearchKeywordsFilterElement.php | 8 +- src/Filter/Filter.php | 5 +- src/Filter/FilterContext.php | 12 +- src/Form/Factory/FilterFormFactory.php | 58 ++++- src/Form/FilterFormBuilder.php | 82 +++++++ src/Form/FilterFormBuilderInterface.php | 38 ++++ .../CodefogTagsChoiceFilterElement.php | 8 +- .../Projector/InteractiveProjectorTest.php | 146 +++++++++++++ tests/Form/FilterFormBuilderTest.php | 115 ++++++++++ tests/Form/FilterFormFactoryTest.php | 201 ++++++++++++++++++ 20 files changed, 713 insertions(+), 65 deletions(-) create mode 100644 src/Form/FilterFormBuilder.php create mode 100644 src/Form/FilterFormBuilderInterface.php create mode 100644 tests/Engine/Projector/InteractiveProjectorTest.php create mode 100644 tests/Form/FilterFormBuilderTest.php create mode 100644 tests/Form/FilterFormFactoryTest.php diff --git a/src/Engine/Projector/InteractiveProjector.php b/src/Engine/Projector/InteractiveProjector.php index e47c11ed..cd50f45a 100644 --- a/src/Engine/Projector/InteractiveProjector.php +++ b/src/Engine/Projector/InteractiveProjector.php @@ -15,6 +15,7 @@ use HeimrichHannot\FlareBundle\Engine\View\AggregationView; use HeimrichHannot\FlareBundle\Engine\View\InteractiveView; use HeimrichHannot\FlareBundle\Exception\FlareException; +use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Form\Factory\FilterFormFactory; use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Paginator\Factory\PaginatorFactory; @@ -118,8 +119,9 @@ public function createForm(ListSpec $list, InteractiveContext $context): FormInt } /** - * Collects each filter's form data (the compound child's data array), keyed by the - * filter's list-specification key. + * Collects each filter's form data, keyed by the filter's list-specification key. + * Flat-mounted single fields are normalized to the canonical values-bag shape + * `[FilterContext::DEFAULT_FIELD_NAME => value]` that buildFilter() consumes. * * @return array> */ @@ -135,6 +137,19 @@ protected function collectFilterData(ListSpec $list, FormInterface $form): array $child = $form->get($filter->alias); + if ($child->getConfig()->getAttribute(FilterContext::ATTR_SINGLE_FIELD)) + { + // Submitted value, or the field's configured default (e.g., a `preselect`) when + // unsubmitted. Unsubmitted null defaults stay unset, so Filter::$data can take over. + $value = $child->getData(); + + if ($form->isSubmitted() || !\is_null($value)) { + $data[$key] = [FilterContext::SINGLE_VALUE => $value]; + } + + continue; + } + if ($form->isSubmitted()) { $data[$key] = (array) $child->getData(); diff --git a/src/Event/FilterElementFormBuiltEvent.php b/src/Event/FilterElementFormBuiltEvent.php index e6984c34..1a88d32a 100644 --- a/src/Event/FilterElementFormBuiltEvent.php +++ b/src/Event/FilterElementFormBuiltEvent.php @@ -5,25 +5,28 @@ namespace HeimrichHannot\FlareBundle\Event; use HeimrichHannot\FlareBundle\Filter\FilterContext; -use Symfony\Component\Form\FormBuilderInterface; +use HeimrichHannot\FlareBundle\Form\FilterFormBuilderInterface; use Symfony\Contracts\EventDispatcher\Event; /** - * Dispatched after a filter element built its form children on the per-filter compound - * sub-builder, before the sub-builder is mounted onto the root filter form. + * Dispatched after a filter element built its fields on the collect-only per-filter builder, + * before the factory mounts them onto the root filter form (flat for single() fields without + * companions, nested compound otherwise). * * Listeners may add, remove, or replace children (re-adding a child with the same name - * overwrites it) or cancel mounting altogether. + * overwrites it), adjust the single-field declaration via {@see FilterFormBuilderInterface::single()}, + * or cancel mounting altogether. Adding a child alongside a single() declaration switches the + * filter to the nested compound layout. */ class FilterElementFormBuiltEvent extends Event { public function __construct( - private readonly FormBuilderInterface $builder, - private readonly FilterContext $context, - private bool $cancelled = false, + private readonly FilterFormBuilderInterface $builder, + private readonly FilterContext $context, + private bool $cancelled = false, ) {} - public function getBuilder(): FormBuilderInterface + public function getBuilder(): FilterFormBuilderInterface { return $this->builder; } diff --git a/src/Filter/Element/AbstractFilterElement.php b/src/Filter/Element/AbstractFilterElement.php index 24698e59..8f899c2e 100644 --- a/src/Filter/Element/AbstractFilterElement.php +++ b/src/Filter/Element/AbstractFilterElement.php @@ -19,8 +19,8 @@ use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Form\ChoicesBuilder; use HeimrichHannot\FlareBundle\Form\Factory\ChoicesBuilderFactory; +use HeimrichHannot\FlareBundle\Form\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\Model\FilterModel; -use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Contracts\Service\Attribute\Required; @@ -48,7 +48,7 @@ abstract protected function transformFilterModel(ConfigBuilder $config, FilterMo public function buildDca(DcaBuilder $dca, DcaContext $context): void {} - public function buildForm(FormBuilderInterface $builder, FilterContext $context): void {} + public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void {} diff --git a/src/Filter/Element/ArchiveFilterElement.php b/src/Filter/Element/ArchiveFilterElement.php index 9f64072e..2a8738ba 100644 --- a/src/Filter/Element/ArchiveFilterElement.php +++ b/src/Filter/Element/ArchiveFilterElement.php @@ -17,13 +17,13 @@ use HeimrichHannot\FlareBundle\Filter\Type\ArchiveFilterType; use HeimrichHannot\FlareBundle\Filter\Type\BelongsToRelationFilterType; use HeimrichHannot\FlareBundle\Form\ChoicesBuilder; +use HeimrichHannot\FlareBundle\Form\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\InferPtable\Factory\PtableInferrableFactory; use HeimrichHannot\FlareBundle\InferPtable\PtableInferrer; use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Util\Str; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; -use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE)] @@ -75,7 +75,7 @@ protected function transformFilterModel(ConfigBuilder $config, FilterModel $mode /** * @throws FilterException */ - public function buildForm(FormBuilderInterface $builder, FilterContext $context): void + public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void { $config = $context->config; @@ -100,7 +100,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) $choices = $this->createChoicesBuilder()->applyFormOptions($formOptions); $builder->setAttribute('flare.choices_builder', $choices); - $builder->add(FilterContext::FIELD_VALUE, ChoiceType::class, $formOptions); + $builder->single(ChoiceType::class, $formOptions); if ($config['has_empty_option']) { @@ -174,7 +174,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont /** @var Model[] $selectedModels */ $selectedModels = $config['intrinsic'] ? $this->getWhitelistedParents($context->list, $config) - : $this->processRuntimeValue($values[FilterContext::FIELD_VALUE] ?? null, $context->list, $config); + : $this->processRuntimeValue($values[FilterContext::SINGLE_VALUE] ?? null, $context->list, $config); $inferrer = $this->getPtableInferrer($context->list); diff --git a/src/Filter/Element/BooleanFilterElement.php b/src/Filter/Element/BooleanFilterElement.php index cac557b1..cfe858ad 100644 --- a/src/Filter/Element/BooleanFilterElement.php +++ b/src/Filter/Element/BooleanFilterElement.php @@ -15,9 +15,9 @@ use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\BooleanFilterType; +use HeimrichHannot\FlareBundle\Form\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; -use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE, isTargeted: true)] @@ -46,13 +46,13 @@ protected function transformFilterModel(ConfigBuilder $config, FilterModel $mode ->set('label', $model->label ?: $model->title ?: null); } - public function buildForm(FormBuilderInterface $builder, FilterContext $context): void + public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void { if ($context->config['intrinsic']) { return; } - $builder->add(FilterContext::FIELD_VALUE, CheckboxType::class, [ + $builder->single(CheckboxType::class, [ 'label' => $context->config['label'] ?? 'CBX', 'required' => false, ]); @@ -68,7 +68,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont $value = $config['intrinsic'] ? $config['preselect'] - : $this->resolveRuntimeValue($values[FilterContext::FIELD_VALUE] ?? null, $config); + : $this->resolveRuntimeValue($values[FilterContext::SINGLE_VALUE] ?? null, $config); if ($value === null) { return; diff --git a/src/Filter/Element/CalendarCurrentFilterElement.php b/src/Filter/Element/CalendarCurrentFilterElement.php index 401cfac0..d3fbb76b 100644 --- a/src/Filter/Element/CalendarCurrentFilterElement.php +++ b/src/Filter/Element/CalendarCurrentFilterElement.php @@ -12,10 +12,10 @@ use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\CalendarCurrentFilterType; +use HeimrichHannot\FlareBundle\Form\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Util\DateTimeHelper; use Symfony\Component\Form\Extension\Core\Type\DateType; -use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormError; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; @@ -54,7 +54,7 @@ protected function transformFilterModel(ConfigBuilder $config, FilterModel $mode ->set('has_extended_events', (bool) $model->hasExtendedEvents); } - public function buildForm(FormBuilderInterface $builder, FilterContext $context): void + public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void { $config = $context->config; diff --git a/src/Filter/Element/DateRangeFilterElement.php b/src/Filter/Element/DateRangeFilterElement.php index efd6a398..afc7894f 100644 --- a/src/Filter/Element/DateRangeFilterElement.php +++ b/src/Filter/Element/DateRangeFilterElement.php @@ -12,9 +12,9 @@ use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\DateRangeFilterType; +use HeimrichHannot\FlareBundle\Form\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\Form\Extension\Core\Type\DateType; -use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormError; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; @@ -45,7 +45,7 @@ protected function transformFilterModel(ConfigBuilder $config, FilterModel $mode ->set('field', $model->fieldGeneric ?: null); } - public function buildForm(FormBuilderInterface $builder, FilterContext $context): void + public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void { if ($context->config['intrinsic']) { return; diff --git a/src/Filter/Element/DcaSelectFieldFilterElement.php b/src/Filter/Element/DcaSelectFieldFilterElement.php index 985d77b3..687d2b21 100644 --- a/src/Filter/Element/DcaSelectFieldFilterElement.php +++ b/src/Filter/Element/DcaSelectFieldFilterElement.php @@ -15,9 +15,9 @@ use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\DcaSelectFilterType; +use HeimrichHannot\FlareBundle\Form\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; -use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE)] @@ -55,7 +55,7 @@ protected function transformFilterModel(ConfigBuilder $config, FilterModel $mode : $preselect); } - public function buildForm(FormBuilderInterface $builder, FilterContext $context): void + public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void { $config = $context->config; @@ -92,7 +92,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) $formOptions['data'] = $data; } - $builder->add(FilterContext::FIELD_VALUE, ChoiceType::class, $formOptions); + $builder->single(ChoiceType::class, $formOptions); } public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void @@ -102,7 +102,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont $selected = $config['intrinsic'] ? $config['preselect'] - : $this->normalizeSubmittedValue($values[FilterContext::FIELD_VALUE] ?? null, $options); + : $this->normalizeSubmittedValue($values[FilterContext::SINGLE_VALUE] ?? null, $options); if (!$selected) { return; diff --git a/src/Filter/Element/FieldValueChoiceFilterElement.php b/src/Filter/Element/FieldValueChoiceFilterElement.php index 0c992429..dcafcf02 100644 --- a/src/Filter/Element/FieldValueChoiceFilterElement.php +++ b/src/Filter/Element/FieldValueChoiceFilterElement.php @@ -18,9 +18,9 @@ use HeimrichHannot\FlareBundle\Filter\Type\FieldValueChoiceFilterType; use HeimrichHannot\FlareBundle\Form\ChoicesBuilder; use HeimrichHannot\FlareBundle\Form\Factory\ChoicesBuilderFactory; +use HeimrichHannot\FlareBundle\Form\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; -use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE)] @@ -57,7 +57,7 @@ protected function transformFilterModel(ConfigBuilder $config, FilterModel $mode ->set('preselect', $this->normalizePreselect($model->preselect, $multiple)); } - public function buildForm(FormBuilderInterface $builder, FilterContext $context): void + public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void { $config = $context->config; @@ -78,7 +78,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) $choicesBuilder->applyFormOptions($formOptions); - $builder->add(FilterContext::FIELD_VALUE, ChoiceType::class, $formOptions); + $builder->single(ChoiceType::class, $formOptions); $builder->setAttribute('flare.choices_builder', $choicesBuilder); } @@ -97,7 +97,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont $value = $config['intrinsic'] ? $config['preselect'] - : $this->normalizeRuntimeValue($values[FilterContext::FIELD_VALUE] ?? null, $context); + : $this->normalizeRuntimeValue($values[FilterContext::SINGLE_VALUE] ?? null, $context); if (!$value) { return; diff --git a/src/Filter/Element/FilterElementInterface.php b/src/Filter/Element/FilterElementInterface.php index fbf8331a..2bf6b5f3 100644 --- a/src/Filter/Element/FilterElementInterface.php +++ b/src/Filter/Element/FilterElementInterface.php @@ -6,25 +6,30 @@ use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; -use Symfony\Component\Form\FormBuilderInterface; +use HeimrichHannot\FlareBundle\Form\FilterFormBuilderInterface; interface FilterElementInterface { /** - * Adds form children to the per-filter compound sub-builder. + * Declares the filter's form fields on the collect-only per-filter builder. * - * The element may add any number of children with local names ({@see FilterContext::FIELD_VALUE} - * is the convention for single-field elements). Pre-submission defaults belong in the children's - * native `data` option. Adding no children means the filter has no form representation. + * Single-field elements declare their field via {@see FilterFormBuilderInterface::single()}; + * it is mounted flat on the root form under the filter's alias, and its value reaches + * buildFilter() under {@see FilterContext::SINGLE_VALUE}. Multi-field elements add() + * children with local names, which mount as a compound sub-form. Pre-submission defaults + * belong in the fields' native `data` option. Event listeners registered on the builder are + * replayed onto the mounted form; event subscribers are not supported. Declaring no fields + * means the filter has no form representation. */ - public function buildForm(FormBuilderInterface $builder, FilterContext $context): void; + public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void; /** * Translates canonical config and runtime data into filter type calls. * - * @param array $values Submitted form data of this filter's compound child (keyed by - * the local child names added in buildForm()) or a programmatically set data bag; empty array - * when neither exists (e.g. non-interactive contexts). + * @param array $values Submitted form data of this filter (keyed by the local + * field names declared in buildForm(); single() fields use {@see FilterContext::SINGLE_VALUE}) + * or a programmatically set data bag; empty array when neither exists (e.g. non-interactive + * contexts). */ public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void; } diff --git a/src/Filter/Element/SearchKeywordsFilterElement.php b/src/Filter/Element/SearchKeywordsFilterElement.php index 77eb6763..f7778046 100644 --- a/src/Filter/Element/SearchKeywordsFilterElement.php +++ b/src/Filter/Element/SearchKeywordsFilterElement.php @@ -12,9 +12,9 @@ use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\SearchKeywordsFilterType; +use HeimrichHannot\FlareBundle\Form\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\Form\Extension\Core\Type\TextType; -use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE, isTargeted: true)] @@ -41,7 +41,7 @@ protected function transformFilterModel(ConfigBuilder $config, FilterModel $mode ->set('placeholder', $model->placeholder ?: null); } - public function buildForm(FormBuilderInterface $builder, FilterContext $context): void + public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void { $config = $context->config; @@ -58,7 +58,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) $options['attr']['placeholder'] = $config['placeholder']; } - $builder->add(FilterContext::FIELD_VALUE, TextType::class, $options); + $builder->single(TextType::class, $options); } public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void @@ -67,7 +67,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont $value = $config['intrinsic'] ? $config['prefill'] - : ($values[FilterContext::FIELD_VALUE] ?? null); + : ($values[FilterContext::SINGLE_VALUE] ?? null); if (!$value || !\is_string($value)) { return; diff --git a/src/Filter/Filter.php b/src/Filter/Filter.php index 67ff3c62..3bc2d95f 100644 --- a/src/Filter/Filter.php +++ b/src/Filter/Filter.php @@ -19,8 +19,9 @@ /** * @param FilterElementInterface|string $element Registered element type alias or an inline element instance. * @param array $config Canonical config (element-defined schema); scalars, arrays, and enums only. - * @param array|null $data Runtime data bag, same shape buildFilter() receives. - * Submitted form data takes precedence over this bag. + * @param array|null $data Runtime data bag, same shape buildFilter() receives + * (single-field elements read {@see FilterContext::SINGLE_VALUE}). Submitted form + * data takes precedence over this bag. * @param string|null $alias Form name of the filter. An alias that is not a valid Symfony form * name (e.g. the generated "_.{source}" fallback) never mounts form children. * @param string|null $targetAlias Table alias the filter's conditions apply to. diff --git a/src/Filter/FilterContext.php b/src/Filter/FilterContext.php index d6861c69..88d5045b 100644 --- a/src/Filter/FilterContext.php +++ b/src/Filter/FilterContext.php @@ -14,10 +14,16 @@ final readonly class FilterContext { /** Attribute-bag key under which this context is stored on the per-filter form builder. */ - public const FORM_ATTRIBUTE = 'flare.filter_context'; + public const ATTR_SELF = 'flare.filter_context'; - /** Conventional local child name for single-field filter elements. */ - public const FIELD_VALUE = 'v'; + /** Attribute-bag key marking a root form child as a flat-mounted single field. */ + public const ATTR_SINGLE_FIELD = 'flare.single_field'; + + /** + * Canonical values-bag key under which a single-field filter's value reaches buildFilter(), + * regardless of whether the field was mounted flat or inside a compound filter form. + */ + public const SINGLE_VALUE = '0'; /** * @param array $config Resolved canonical config of the filter. diff --git a/src/Form/Factory/FilterFormFactory.php b/src/Form/Factory/FilterFormFactory.php index 3f0605fe..8a0e41c5 100644 --- a/src/Form/Factory/FilterFormFactory.php +++ b/src/Form/Factory/FilterFormFactory.php @@ -13,8 +13,10 @@ use HeimrichHannot\FlareBundle\Filter\Factory\FilterContextFactory; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterElementResolver; +use HeimrichHannot\FlareBundle\Form\FilterFormBuilder; use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Util\Str; +use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\Form\Extension\Core\Type\FormType; use Symfony\Component\Form\FormFactoryInterface; use Symfony\Component\Form\FormInterface; @@ -69,25 +71,59 @@ public function create(ListSpec $list, FormContextInterface $context): FormInter $filterContext = $this->filterContextFactory->create($list, $filter, $element, $context, $key); - $child = $builder->create($filter->alias, FormType::class, [ - 'inherit_data' => false, - 'label' => false, - 'required' => false, - ]); - $child->setAttribute(FilterContext::FORM_ATTRIBUTE, $filterContext); + // Collect-only builder: never mounted itself; its single-field spec, children, + // attributes, and deferred listeners are transferred onto the mounted builder below. + $wrapper = new FilterFormBuilder($filter->alias, null, new EventDispatcher(), $this->formFactory); + $wrapper->setAttribute(FilterContext::ATTR_SELF, $filterContext); - $element->buildForm($child, $filterContext); + $element->buildForm($wrapper, $filterContext); /** @var FilterElementFormBuiltEvent $event */ - $event = $this->eventDispatcher->dispatch(new FilterElementFormBuiltEvent($child, $filterContext)); + $event = $this->eventDispatcher->dispatch(new FilterElementFormBuiltEvent($wrapper, $filterContext)); - if ($event->isCancelled() || $child->count() === 0) - // Empty compound children are never mounted. + $single = $wrapper->getSingle(); + + if ($event->isCancelled() || (!$single && $wrapper->count() === 0)) + // Filters without any form representation are never mounted. { continue; } - $builder->add($child); + if ($single && $wrapper->count() === 0) + // Flat mount: the field lives at the root under the filter's alias. + { + $mount = $builder->create($filter->alias, $single['type'], $single['options']); + $mount->setAttribute(FilterContext::ATTR_SINGLE_FIELD, true); + } + /** @mago-expect lint:no-else-clause The mount decision is a genuine either-or. */ + else + // Nested mount: real compound; a single() field materializes under the + // canonical field name alongside any explicitly added children. + { + $mount = $builder->create($filter->alias, FormType::class, [ + 'inherit_data' => false, + 'label' => false, + 'required' => false, + ]); + + if ($single) { + $mount->add(FilterContext::SINGLE_VALUE, $single['type'], $single['options']); + } + + foreach ($wrapper->all() as $childBuilder) { + $mount->add($childBuilder); + } + } + + foreach ($wrapper->getAttributes() as $attrName => $attrValue) { + $mount->setAttribute($attrName, $attrValue); + } + + foreach ($wrapper->getDeferredListeners() as [$eventName, $listener, $priority]) { + $mount->addEventListener($eventName, $listener, $priority); + } + + $builder->add($mount); } /* diff --git a/src/Form/FilterFormBuilder.php b/src/Form/FilterFormBuilder.php new file mode 100644 index 00000000..7bf2416b --- /dev/null +++ b/src/Form/FilterFormBuilder.php @@ -0,0 +1,82 @@ +}|null */ + private ?array $single = null; + + /** @var list */ + private array $deferredListeners = []; + + public function single(string $type, array $options = []): static + { + $this->single = ['type' => $type, 'options' => $options]; + + return $this; + } + + public function getSingle(): ?array + { + return $this->single; + } + + /** + * Records the listener for the factory to replay on the mounted builder — this collector's + * own dispatcher never dispatches. Parameters are deliberately untyped: the bundle supports + * Symfony ^5.4|^6|^7, whose signatures differ in native parameter types. + * + * @param string $eventName + * @param callable $listener + * @param int $priority + */ + public function addEventListener($eventName, $listener, $priority = 0): static + { + $this->deferredListeners[] = [$eventName, $listener, $priority]; + + return $this; + } + + /** + * @return list + */ + public function getDeferredListeners(): array + { + return $this->deferredListeners; + } + + /** + * @param \Symfony\Component\EventDispatcher\EventSubscriberInterface $subscriber + */ + public function addEventSubscriber($subscriber): never + { + throw new \LogicException( + 'Event subscribers are not supported on the per-filter form builder.' + . ' Use addEventListener() (replayed onto the mounted form) or register listeners' + . ' on the field builders instead.', + ); + } + + public function getForm(): never + { + throw new \LogicException(\sprintf( + '%s is a collect-only builder and cannot produce a form; it is never mounted.' + . ' FilterFormFactory transfers its fields onto a real builder.', + self::class, + )); + } +} diff --git a/src/Form/FilterFormBuilderInterface.php b/src/Form/FilterFormBuilderInterface.php new file mode 100644 index 00000000..23be10f9 --- /dev/null +++ b/src/Form/FilterFormBuilderInterface.php @@ -0,0 +1,38 @@ + $options Form options of the field. + */ + public function single(string $type, array $options = []): static; + + /** + * @return array{type: class-string, options: array}|null + */ + public function getSingle(): ?array; +} diff --git a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php index d8d9fd4e..880f240f 100644 --- a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php +++ b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php @@ -13,13 +13,13 @@ use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Type\IntegerIdChoiceFilterType; +use HeimrichHannot\FlareBundle\Form\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\Integration\CodefogTags\Registry\CfgTagsJoinsRegistry; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Query\Factory\ListExecutionContextFactory; use HeimrichHannot\FlareBundle\Query\ListExecutionContext; use Psr\Log\LoggerInterface; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; -use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; #[AsFilterElement(type: self::TYPE, isTargeted: true)] @@ -58,7 +58,7 @@ protected function transformFilterModel(ConfigBuilder $config, FilterModel $mode ->set('placeholder', $model->placeholder ?: null); } - public function buildForm(FormBuilderInterface $builder, FilterContext $context): void + public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void { $config = $context->config; @@ -104,7 +104,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) $builder->setAttribute('flare.choices_builder', $choicesBuilder); } - $builder->add(FilterContext::FIELD_VALUE, ChoiceType::class, $formOptions); + $builder->single(ChoiceType::class, $formOptions); } public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void @@ -116,7 +116,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont /** @var ?array $tagIds */ $tagIds = $config['intrinsic'] ? $preselect - : $this->processRuntimeValue($values[FilterContext::FIELD_VALUE] ?? null); + : $this->processRuntimeValue($values[FilterContext::SINGLE_VALUE] ?? null); if (!$tagIds) { return; diff --git a/tests/Engine/Projector/InteractiveProjectorTest.php b/tests/Engine/Projector/InteractiveProjectorTest.php new file mode 100644 index 00000000..3e652403 --- /dev/null +++ b/tests/Engine/Projector/InteractiveProjectorTest.php @@ -0,0 +1,146 @@ +collectFilterData($list, $form); + } + }; + + return $projector->collect($list, $form); + } + + private function createRootBuilder(): FormBuilderInterface + { + return Forms::createFormFactory()->createNamedBuilder('f', FormType::class); + } + + private function addFlatChild(FormBuilderInterface $root, string $alias, array $options = []): void + { + $child = $root->create($alias, TextType::class, $options); + $child->setAttribute(FilterContext::ATTR_SINGLE_FIELD, true); + $root->add($child); + } + + private function listWithFilter(string $key, string $alias): ListSpec + { + return new ListSpec(type: 'test', dc: 'tl_test', filters: [ + $key => new Filter(element: 'test_element', alias: $alias), + ]); + } + + public function testFlatSubmittedValueIsKeyedCanonically(): void + { + $root = $this->createRootBuilder(); + $this->addFlatChild($root, 'suche'); + $form = $root->getForm(); + + $form->submit(['suche' => 'term']); + + $this->assertSame( + ['sucheKey' => [FilterContext::SINGLE_VALUE => 'term']], + $this->collect($this->listWithFilter('sucheKey', 'suche'), $form), + ); + } + + public function testFlatUnsubmittedDefaultIsCollected(): void + { + $root = $this->createRootBuilder(); + $this->addFlatChild($root, 'suche', ['data' => 'preset']); + $form = $root->getForm(); + + $this->assertSame( + ['sucheKey' => [FilterContext::SINGLE_VALUE => 'preset']], + $this->collect($this->listWithFilter('sucheKey', 'suche'), $form), + ); + } + + public function testFlatUnsubmittedWithoutDefaultStaysUnset(): void + { + $root = $this->createRootBuilder(); + $this->addFlatChild($root, 'suche'); + $form = $root->getForm(); + + $this->assertSame([], $this->collect($this->listWithFilter('sucheKey', 'suche'), $form)); + } + + public function testFlatSubmittedEmptyValueIsKeptSoItOverridesDataBags(): void + { + $root = $this->createRootBuilder(); + $this->addFlatChild($root, 'suche', ['data' => 'preset']); + $form = $root->getForm(); + + $form->submit(['suche' => '']); + + $this->assertSame( + ['sucheKey' => [FilterContext::SINGLE_VALUE => null]], + $this->collect($this->listWithFilter('sucheKey', 'suche'), $form), + ); + } + + public function testCompoundSubmittedDataIsCollected(): void + { + $root = $this->createRootBuilder(); + $root->add( + $root->create('range', FormType::class, ['inherit_data' => false]) + ->add('from', TextType::class) + ->add('to', TextType::class), + ); + $form = $root->getForm(); + + $form->submit(['range' => ['from' => 'a', 'to' => 'b']]); + + $this->assertSame( + ['rangeKey' => ['from' => 'a', 'to' => 'b']], + $this->collect($this->listWithFilter('rangeKey', 'range'), $form), + ); + } + + public function testCompoundUnsubmittedFieldDefaultsAreCollected(): void + { + $root = $this->createRootBuilder(); + $root->add( + $root->create('range', FormType::class, ['inherit_data' => false]) + ->add('from', TextType::class, ['data' => 'a']) + ->add('to', TextType::class), + ); + $form = $root->getForm(); + + $this->assertSame( + ['rangeKey' => ['from' => 'a']], + $this->collect($this->listWithFilter('rangeKey', 'range'), $form), + ); + } + + public function testFilterWithoutMountedChildIsSkipped(): void + { + $form = $this->createRootBuilder()->getForm(); + + $this->assertSame([], $this->collect($this->listWithFilter('key', 'missing'), $form)); + } +} diff --git a/tests/Form/FilterFormBuilderTest.php b/tests/Form/FilterFormBuilderTest.php new file mode 100644 index 00000000..505f0950 --- /dev/null +++ b/tests/Form/FilterFormBuilderTest.php @@ -0,0 +1,115 @@ +assertNull($this->createBuilder()->getSingle()); + } + + public function testSingleRecordsTypeAndOptions(): void + { + $builder = $this->createBuilder(); + + $result = $builder->single(TextType::class, ['required' => false]); + + $this->assertSame($builder, $result); + $this->assertSame( + ['type' => TextType::class, 'options' => ['required' => false]], + $builder->getSingle(), + ); + $this->assertSame(0, $builder->count(), 'single() must not add a child'); + } + + public function testSingleOverwritesPreviousDeclaration(): void + { + $builder = $this->createBuilder(); + + $builder->single(TextType::class, ['required' => true]); + $builder->single(TextType::class, ['required' => false]); + + $this->assertSame( + ['type' => TextType::class, 'options' => ['required' => false]], + $builder->getSingle(), + ); + } + + public function testAddEventListenerDefersInsteadOfRegistering(): void + { + $builder = $this->createBuilder(); + $first = static function (): void {}; + $second = static function (): void {}; + + $result = $builder + ->addEventListener(FormEvents::POST_SUBMIT, $first) + ->addEventListener(FormEvents::PRE_SET_DATA, $second, 7); + + $this->assertSame($builder, $result); + $this->assertSame( + [ + [FormEvents::POST_SUBMIT, $first, 0], + [FormEvents::PRE_SET_DATA, $second, 7], + ], + $builder->getDeferredListeners(), + ); + $this->assertFalse( + $builder->getEventDispatcher()->hasListeners(FormEvents::POST_SUBMIT), + 'Deferred listeners must not reach the collector\'s own dispatcher', + ); + } + + public function testAddEventSubscriberThrows(): void + { + $subscriber = new class implements EventSubscriberInterface { + public static function getSubscribedEvents(): array + { + return []; + } + }; + + $this->expectException(\LogicException::class); + + $this->createBuilder()->addEventSubscriber($subscriber); + } + + public function testGetFormThrows(): void + { + $this->expectException(\LogicException::class); + + $this->createBuilder()->getForm(); + } + + public function testAddProducesRealMountableChildBuilders(): void + { + $builder = $this->createBuilder(); + + $builder->add('field', TextType::class, ['required' => false]); + + $this->assertSame(1, $builder->count()); + + $child = $builder->get('field'); + + $this->assertInstanceOf(FormBuilderInterface::class, $child); + $this->assertNotInstanceOf(FilterFormBuilder::class, $child); + $this->assertInstanceOf(FormInterface::class, $child->getForm()); + } +} diff --git a/tests/Form/FilterFormFactoryTest.php b/tests/Form/FilterFormFactoryTest.php new file mode 100644 index 00000000..874be3e4 --- /dev/null +++ b/tests/Form/FilterFormFactoryTest.php @@ -0,0 +1,201 @@ +eventDispatcher = new EventDispatcher(); + } + + private function createFactory(): FilterFormFactory + { + // The CSRF extension only needs to define the "csrf_protection" option; the factory + // always disables it, so the token manager is never used. + $formFactory = Forms::createFormFactoryBuilder() + ->addExtension(new CsrfExtension(new CsrfTokenManager())) + ->getFormFactory(); + + return new FilterFormFactory( + eventDispatcher: $this->eventDispatcher, + filterContextFactory: new FilterContextFactory(new FilterOptionsResolver()), + filterElementResolver: new FilterElementResolver(new FilterElementRegistry(), new NullLogger()), + formFactory: $formFactory, + ); + } + + private function createForm(array $filters): FormInterface + { + $list = new ListSpec(type: 'test', dc: 'tl_test', filters: $filters); + + $context = new class implements ContextInterface, FormContextInterface { + public static function getContextType(): string + { + return 'test'; + } + + public function getFormName(): string + { + return 'flare_test'; + } + + public function getFormActionPage(): int + { + return 0; + } + }; + + return $this->createFactory()->create($list, $context); + } + + /** + * @param callable(FilterFormBuilderInterface, FilterContext): void $buildForm + */ + private function element(callable $buildForm): FilterElementInterface + { + return new class($buildForm) implements FilterElementInterface { + /** @var callable */ + private $buildForm; + + public function __construct(callable $buildForm) + { + $this->buildForm = $buildForm; + } + + public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void + { + ($this->buildForm)($builder, $context); + } + + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void {} + }; + } + + public function testSingleFieldMountsFlatUnderTheAlias(): void + { + $element = $this->element(static function (FilterFormBuilderInterface $builder): void { + $builder->single(TextType::class, ['required' => false]); + $builder->setAttribute('custom.attr', 'kept'); + $builder->addEventListener(FormEvents::POST_SUBMIT, static function (): void {}); + }); + + $form = $this->createForm(['suche' => new Filter(element: $element, alias: 'suche')]); + + $this->assertTrue($form->has('suche')); + + $config = $form->get('suche')->getConfig(); + + $this->assertInstanceOf(TextType::class, $config->getType()->getInnerType()); + $this->assertTrue($config->getAttribute(FilterContext::ATTR_SINGLE_FIELD)); + $this->assertSame('kept', $config->getAttribute('custom.attr')); + $this->assertInstanceOf(FilterContext::class, $config->getAttribute(FilterContext::ATTR_SELF)); + $this->assertTrue( + $config->getEventDispatcher()->hasListeners(FormEvents::POST_SUBMIT), + 'Deferred listeners must be replayed onto the mounted builder', + ); + } + + public function testSingleWithCompanionFieldMountsNestedCompound(): void + { + $element = $this->element(static function (FilterFormBuilderInterface $builder): void { + $builder->single(TextType::class, ['required' => false]); + $builder->add('extra', TextType::class, ['required' => false]); + }); + + $form = $this->createForm(['suche' => new Filter(element: $element, alias: 'suche')]); + + $child = $form->get('suche'); + + $this->assertInstanceOf(FormType::class, $child->getConfig()->getType()->getInnerType()); + $this->assertNull($child->getConfig()->getAttribute(FilterContext::ATTR_SINGLE_FIELD)); + $this->assertTrue($child->has(FilterContext::SINGLE_VALUE)); + $this->assertTrue($child->has('extra')); + } + + public function testMultiFieldElementMountsNestedCompound(): void + { + $element = $this->element(static function (FilterFormBuilderInterface $builder): void { + $builder->add('from', TextType::class); + $builder->add('to', TextType::class); + $builder->addEventListener(FormEvents::POST_SUBMIT, static function (): void {}); + }); + + $form = $this->createForm(['range' => new Filter(element: $element, alias: 'range')]); + + $child = $form->get('range'); + + $this->assertInstanceOf(FormType::class, $child->getConfig()->getType()->getInnerType()); + $this->assertTrue($child->has('from')); + $this->assertTrue($child->has('to')); + $this->assertTrue( + $child->getConfig()->getEventDispatcher()->hasListeners(FormEvents::POST_SUBMIT), + 'Deferred listeners must be replayed onto the mounted compound', + ); + } + + public function testElementWithoutFieldsIsNotMounted(): void + { + $element = $this->element(static function (): void {}); + + $form = $this->createForm(['empty' => new Filter(element: $element, alias: 'empty')]); + + $this->assertFalse($form->has('empty')); + } + + public function testInvalidAliasIsSkipped(): void + { + $element = $this->element(static function (FilterFormBuilderInterface $builder): void { + $builder->single(TextType::class); + }); + + $form = $this->createForm(['x' => new Filter(element: $element, alias: '_.tl_flare_filter.1')]); + + $this->assertSame(0, \count($form)); + } + + public function testCancelledEventPreventsMounting(): void + { + $this->eventDispatcher->addListener( + FilterElementFormBuiltEvent::class, + static fn (FilterElementFormBuiltEvent $event) => $event->cancel(), + ); + + $element = $this->element(static function (FilterFormBuilderInterface $builder): void { + $builder->single(TextType::class); + }); + + $form = $this->createForm(['suche' => new Filter(element: $element, alias: 'suche')]); + + $this->assertFalse($form->has('suche')); + } +} From 81d4c72a04b0a5d15d91c7df96b6e03d826f5195 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 14:49:05 +0200 Subject: [PATCH 37/71] refactor: move `FilterFormBuilder` and `FilterFormFactory` to `Filter` namespace and update references Relocated `FilterFormBuilder` and `FilterFormFactory` from `Form` namespace to `Filter` namespace for improved coherence. Adjusted imports, tests, and relevant documentation to reflect this change. --- src/Config/SchemaResolver.php | 8 ++++++++ src/Engine/Projector/InteractiveProjector.php | 2 +- src/Event/FilterElementFormBuiltEvent.php | 2 +- src/Filter/Element/AbstractFilterElement.php | 2 +- src/Filter/Element/ArchiveFilterElement.php | 2 +- src/Filter/Element/BooleanFilterElement.php | 2 +- src/Filter/Element/CalendarCurrentFilterElement.php | 2 +- src/Filter/Element/DateRangeFilterElement.php | 2 +- src/Filter/Element/DcaSelectFieldFilterElement.php | 2 +- src/Filter/Element/FieldValueChoiceFilterElement.php | 2 +- src/Filter/Element/FilterElementInterface.php | 2 +- src/Filter/Element/SearchKeywordsFilterElement.php | 2 +- src/{Form => Filter}/Factory/FilterFormFactory.php | 7 ++++--- src/{Form => Filter}/FilterFormBuilder.php | 2 +- src/{Form => Filter}/FilterFormBuilderInterface.php | 4 ++-- .../FilterElement/CodefogTagsChoiceFilterElement.php | 2 +- tests/Form/FilterFormBuilderTest.php | 2 +- tests/Form/FilterFormFactoryTest.php | 6 +++--- 18 files changed, 31 insertions(+), 22 deletions(-) create mode 100644 src/Config/SchemaResolver.php rename src/{Form => Filter}/Factory/FilterFormFactory.php (96%) rename src/{Form => Filter}/FilterFormBuilder.php (98%) rename src/{Form => Filter}/FilterFormBuilderInterface.php (92%) diff --git a/src/Config/SchemaResolver.php b/src/Config/SchemaResolver.php new file mode 100644 index 00000000..41c11e84 --- /dev/null +++ b/src/Config/SchemaResolver.php @@ -0,0 +1,8 @@ +formBuilder; return $builder->getForm(); diff --git a/src/Form/FilterFormBuilder.php b/src/Filter/FilterFormBuilder.php similarity index 98% rename from src/Form/FilterFormBuilder.php rename to src/Filter/FilterFormBuilder.php index 7bf2416b..e8f9a8a1 100644 --- a/src/Form/FilterFormBuilder.php +++ b/src/Filter/FilterFormBuilder.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Form; +namespace HeimrichHannot\FlareBundle\Filter; use Symfony\Component\Form\FormBuilder; diff --git a/src/Form/FilterFormBuilderInterface.php b/src/Filter/FilterFormBuilderInterface.php similarity index 92% rename from src/Form/FilterFormBuilderInterface.php rename to src/Filter/FilterFormBuilderInterface.php index 23be10f9..c9069d8d 100644 --- a/src/Form/FilterFormBuilderInterface.php +++ b/src/Filter/FilterFormBuilderInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Form; +namespace HeimrichHannot\FlareBundle\Filter; use Symfony\Component\Form\FormBuilderInterface; @@ -13,7 +13,7 @@ * itself as a single-field filter via {@see single()}. Single fields are mounted flat on the * root filter form under the filter's alias (query parameter `form[alias]=x`), while their * submitted value is always handed back to buildFilter() under - * {@see \HeimrichHannot\FlareBundle\Filter\FilterContext::SINGLE_VALUE}. + * {@see FilterContext::SINGLE_VALUE}. */ interface FilterFormBuilderInterface extends FormBuilderInterface { diff --git a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php index 880f240f..935aec71 100644 --- a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php +++ b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php @@ -12,8 +12,8 @@ use HeimrichHannot\FlareBundle\Filter\Element\AbstractFilterElement; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\Filter\Type\IntegerIdChoiceFilterType; -use HeimrichHannot\FlareBundle\Form\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\Integration\CodefogTags\Registry\CfgTagsJoinsRegistry; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Query\Factory\ListExecutionContextFactory; diff --git a/tests/Form/FilterFormBuilderTest.php b/tests/Form/FilterFormBuilderTest.php index 505f0950..1fda7c45 100644 --- a/tests/Form/FilterFormBuilderTest.php +++ b/tests/Form/FilterFormBuilderTest.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\Tests\Form; -use HeimrichHannot\FlareBundle\Form\FilterFormBuilder; +use HeimrichHannot\FlareBundle\Filter\FilterFormBuilder; use PHPUnit\Framework\TestCase; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\EventSubscriberInterface; diff --git a/tests/Form/FilterFormFactoryTest.php b/tests/Form/FilterFormFactoryTest.php index 874be3e4..f1b70cfe 100644 --- a/tests/Form/FilterFormFactoryTest.php +++ b/tests/Form/FilterFormFactoryTest.php @@ -9,21 +9,21 @@ use HeimrichHannot\FlareBundle\Event\FilterElementFormBuiltEvent; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Factory\FilterContextFactory; +use HeimrichHannot\FlareBundle\Filter\Factory\FilterFormFactory; use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterElementResolver; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterOptionsResolver; -use HeimrichHannot\FlareBundle\Form\Factory\FilterFormFactory; -use HeimrichHannot\FlareBundle\Form\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\Form\Extension\Core\Type\FormType; -use Symfony\Component\Form\Extension\Csrf\CsrfExtension; use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\Extension\Csrf\CsrfExtension; use Symfony\Component\Form\FormEvents; use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\Forms; From be917f4c7aa7e00992436976c7a2a987a8176076 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 14:59:09 +0200 Subject: [PATCH 38/71] refactor: extract `Config\SchemaResolver` Extract the OptionsResolver memoize-seed-resolve mechanism duplicated between `FilterOptionsResolver` and `ListOptionsResolver` into `Config\SchemaResolver`. The wrappers keep their domain concerns (OptionsContract guard, base schema seeding, exception wrapping) and public signatures. Registered `shared: false` so each consumer keeps its own per-key memoization space. --- config/services.yaml | 4 ++ src/Config/SchemaResolver.php | 32 ++++++++- src/Filter/Resolver/FilterOptionsResolver.php | 18 ++--- src/List/Resolver/ListOptionsResolver.php | 20 ++---- tests/Config/SchemaResolverTest.php | 71 +++++++++++++++++++ tests/Filter/FilterOptionsResolverTest.php | 7 +- tests/Form/FilterFormFactoryTest.php | 3 +- tests/List/BaseListOptionsTest.php | 5 +- tests/List/ListBuilderTest.php | 3 +- 9 files changed, 129 insertions(+), 34 deletions(-) create mode 100644 tests/Config/SchemaResolverTest.php diff --git a/config/services.yaml b/config/services.yaml index 9d70fa42..f54df7e7 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -27,6 +27,10 @@ services: HeimrichHannot\FlareBundle\Util\Env: ~ HeimrichHannot\FlareBundle\Util\Str: ~ + # Not shared: each consumer keeps its own per-key schema memoization space + HeimrichHannot\FlareBundle\Config\SchemaResolver: + shared: false + # bind: # $projectDir: '%kernel.project_dir%' # $csrfTokenName: '%contao.csrf_token_name%' diff --git a/src/Config/SchemaResolver.php b/src/Config/SchemaResolver.php index 41c11e84..35bb441e 100644 --- a/src/Config/SchemaResolver.php +++ b/src/Config/SchemaResolver.php @@ -1,8 +1,38 @@ + */ + private array $resolvers = []; + + /** + * @param \Closure(OptionsResolver): void $configure Runs once per $key (memoized). + * @param array $config + * + * @return array + */ + public function resolve(string $key, \Closure $configure, array $config): array + { + if (!isset($this->resolvers[$key])) + { + $resolver = new OptionsResolver(); + $configure($resolver); + $this->resolvers[$key] = $resolver; + } + return $this->resolvers[$key]->resolve($config); + } } diff --git a/src/Filter/Resolver/FilterOptionsResolver.php b/src/Filter/Resolver/FilterOptionsResolver.php index f49d0d30..21d178a6 100644 --- a/src/Filter/Resolver/FilterOptionsResolver.php +++ b/src/Filter/Resolver/FilterOptionsResolver.php @@ -4,11 +4,11 @@ namespace HeimrichHannot\FlareBundle\Filter\Resolver; +use HeimrichHannot\FlareBundle\Config\SchemaResolver; use HeimrichHannot\FlareBundle\Contract\OptionsContract; use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Filter; -use Symfony\Component\OptionsResolver\OptionsResolver; /** * Resolves a filter's canonical config through the element's declared schema. @@ -16,10 +16,9 @@ */ class FilterOptionsResolver { - /** - * @var array - */ - private array $resolvers = []; + public function __construct( + private readonly SchemaResolver $schemaResolver, + ) {} /** * @return array @@ -32,16 +31,9 @@ public function resolve(Filter $filter, FilterElementInterface $element): array return $filter->config; } - if (!isset($this->resolvers[$element::class])) - { - $resolver = new OptionsResolver(); - $element->configureOptions($resolver); - $this->resolvers[$element::class] = $resolver; - } - try { - return $this->resolvers[$element::class]->resolve($filter->config); + return $this->schemaResolver->resolve($element::class, $element->configureOptions(...), $filter->config); } catch (\Throwable $e) { diff --git a/src/List/Resolver/ListOptionsResolver.php b/src/List/Resolver/ListOptionsResolver.php index 84a40a61..fa82070f 100644 --- a/src/List/Resolver/ListOptionsResolver.php +++ b/src/List/Resolver/ListOptionsResolver.php @@ -4,6 +4,7 @@ namespace HeimrichHannot\FlareBundle\List\Resolver; +use HeimrichHannot\FlareBundle\Config\SchemaResolver; use HeimrichHannot\FlareBundle\Contract\OptionsContract; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\List\BaseListOptions; @@ -16,10 +17,9 @@ */ class ListOptionsResolver { - /** - * @var array Keyed by type class; '' for type-less lists. - */ - private array $resolvers = []; + public function __construct( + private readonly SchemaResolver $schemaResolver, + ) {} /** * @param array $config @@ -30,23 +30,17 @@ class ListOptionsResolver */ public function resolve(?object $typeService, array $config, ?string $source = null): array { - $key = $typeService ? $typeService::class : ''; - - if (!isset($this->resolvers[$key])) - { - $resolver = new OptionsResolver(); + $configure = static function (OptionsResolver $resolver) use ($typeService): void { BaseListOptions::configureOptions($resolver); if ($typeService instanceof OptionsContract) { $typeService->configureOptions($resolver); } - - $this->resolvers[$key] = $resolver; - } + }; try { - return $this->resolvers[$key]->resolve($config); + return $this->schemaResolver->resolve($typeService ? $typeService::class : '', $configure, $config); } catch (\Throwable $e) { diff --git a/tests/Config/SchemaResolverTest.php b/tests/Config/SchemaResolverTest.php new file mode 100644 index 00000000..8712f22f --- /dev/null +++ b/tests/Config/SchemaResolverTest.php @@ -0,0 +1,71 @@ +define('foo')->default('bar')->allowedTypes('string'); + }; + + self::assertSame(['foo' => 'bar'], $schemaResolver->resolve('key_a', $configure, [])); + self::assertSame(['foo' => 'baz'], $schemaResolver->resolve('key_a', $configure, ['foo' => 'baz'])); + self::assertSame(1, $calls); + + self::assertSame(['foo' => 'bar'], $schemaResolver->resolve('key_b', $configure, [])); + self::assertSame(2, $calls); + } + + public function testResolutionFailuresPropagateUntouched(): void + { + $schemaResolver = new SchemaResolver(); + + $configure = static function (OptionsResolver $resolver): void { + $resolver->define('foo')->default(null)->allowedTypes('string', 'null'); + }; + + $this->expectException(UndefinedOptionsException::class); + + $schemaResolver->resolve('key', $configure, ['unknown' => 1]); + } + + public function testFailedConfiguratorIsNotMemoized(): void + { + $schemaResolver = new SchemaResolver(); + + $calls = 0; + $configure = function (OptionsResolver $resolver) use (&$calls): void { + if (1 === ++$calls) { + throw new \RuntimeException('seeding failed'); + } + + $resolver->define('foo')->default('bar')->allowedTypes('string'); + }; + + try + { + $schemaResolver->resolve('key', $configure, []); + self::fail('Expected RuntimeException.'); + } + catch (\RuntimeException $e) + { + self::assertSame('seeding failed', $e->getMessage()); + } + + self::assertSame(['foo' => 'bar'], $schemaResolver->resolve('key', $configure, [])); + self::assertSame(2, $calls); + } +} diff --git a/tests/Filter/FilterOptionsResolverTest.php b/tests/Filter/FilterOptionsResolverTest.php index 36e8c561..d005bf5b 100644 --- a/tests/Filter/FilterOptionsResolverTest.php +++ b/tests/Filter/FilterOptionsResolverTest.php @@ -4,6 +4,7 @@ namespace HeimrichHannot\FlareBundle\Tests\Filter; +use HeimrichHannot\FlareBundle\Config\SchemaResolver; use HeimrichHannot\FlareBundle\Contract\OptionsContract; use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; @@ -19,7 +20,7 @@ final class FilterOptionsResolverTest extends TestCase { public function testResolvesOptionsThroughElementSchema(): void { - $resolver = new FilterOptionsResolver(); + $resolver = new FilterOptionsResolver(new SchemaResolver()); $element = new ElementConfigAwareElement(); $config = $resolver->resolve(new Filter(element: 'test', config: ['field' => 'title']), $element); @@ -30,7 +31,7 @@ public function testResolvesOptionsThroughElementSchema(): void public function testReturnsOptionsVerbatimWithoutOptionsContract(): void { - $resolver = new FilterOptionsResolver(); + $resolver = new FilterOptionsResolver(new SchemaResolver()); $element = new PlainElement(); $config = ['anything' => 'goes', 'unvalidated' => true]; @@ -40,7 +41,7 @@ public function testReturnsOptionsVerbatimWithoutOptionsContract(): void public function testWrapsSchemaViolationsInFilterException(): void { - $resolver = new FilterOptionsResolver(); + $resolver = new FilterOptionsResolver(new SchemaResolver()); $element = new ElementConfigAwareElement(); $filter = new Filter(element: 'test', config: ['unknown_key' => 1], source: 'tl_flare_filter.42'); diff --git a/tests/Form/FilterFormFactoryTest.php b/tests/Form/FilterFormFactoryTest.php index f1b70cfe..9e3262ad 100644 --- a/tests/Form/FilterFormFactoryTest.php +++ b/tests/Form/FilterFormFactoryTest.php @@ -4,6 +4,7 @@ namespace HeimrichHannot\FlareBundle\Tests\Form; +use HeimrichHannot\FlareBundle\Config\SchemaResolver; use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; use HeimrichHannot\FlareBundle\Engine\Context\Interface\FormContextInterface; use HeimrichHannot\FlareBundle\Event\FilterElementFormBuiltEvent; @@ -48,7 +49,7 @@ private function createFactory(): FilterFormFactory return new FilterFormFactory( eventDispatcher: $this->eventDispatcher, - filterContextFactory: new FilterContextFactory(new FilterOptionsResolver()), + filterContextFactory: new FilterContextFactory(new FilterOptionsResolver(new SchemaResolver())), filterElementResolver: new FilterElementResolver(new FilterElementRegistry(), new NullLogger()), formFactory: $formFactory, ); diff --git a/tests/List/BaseListOptionsTest.php b/tests/List/BaseListOptionsTest.php index 407c2880..81f1139a 100644 --- a/tests/List/BaseListOptionsTest.php +++ b/tests/List/BaseListOptionsTest.php @@ -5,6 +5,7 @@ namespace HeimrichHannot\FlareBundle\Tests\List; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; +use HeimrichHannot\FlareBundle\Config\SchemaResolver; use HeimrichHannot\FlareBundle\List\BaseListOptions; use HeimrichHannot\FlareBundle\List\Resolver\ListOptionsResolver; use HeimrichHannot\FlareBundle\Model\ListModel; @@ -47,7 +48,7 @@ public function testTransformsStoredRowToCanonicalValues(): void public function testSchemaProvidesDefaultsForEmptyConfig(): void { - $resolved = (new ListOptionsResolver())->resolve(null, []); + $resolved = (new ListOptionsResolver(new SchemaResolver()))->resolve(null, []); self::assertNull($resolved['id']); self::assertSame('', $resolved['title']); @@ -64,7 +65,7 @@ public function testTransformedRowSatisfiesTheSchema(): void BaseListOptions::transform($config = new ConfigBuilder(), $model); - $resolved = (new ListOptionsResolver())->resolve(null, $config->all()); + $resolved = (new ListOptionsResolver(new SchemaResolver()))->resolve(null, $config->all()); self::assertSame(3, $resolved['id']); self::assertSame([], $resolved['sortSettings']); diff --git a/tests/List/ListBuilderTest.php b/tests/List/ListBuilderTest.php index 42a306bd..9391f329 100644 --- a/tests/List/ListBuilderTest.php +++ b/tests/List/ListBuilderTest.php @@ -5,6 +5,7 @@ namespace HeimrichHannot\FlareBundle\Tests\List; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; +use HeimrichHannot\FlareBundle\Config\SchemaResolver; use HeimrichHannot\FlareBundle\Contract\ListType\BuildListContract; use HeimrichHannot\FlareBundle\Event\ListBuildEvent; use HeimrichHannot\FlareBundle\Exception\FlareException; @@ -114,7 +115,7 @@ private function createBuilder( ?ListModel $model = null, ): ListBuilder { return new ListBuilder( - optionsResolver: new ListOptionsResolver(), + optionsResolver: new ListOptionsResolver(new SchemaResolver()), eventDispatcher: $dispatcher, type: 'test_type', typeService: $typeService, From 0397841938b6747bb31d82423156bfc7cc620cd2 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 15:12:52 +0200 Subject: [PATCH 39/71] refactor: make `ListOptionsResolver` and `FilterOptionsResolver` `final readonly`, update argument and exception handling --- src/Filter/Resolver/FilterOptionsResolver.php | 4 ++-- src/List/Resolver/ListOptionsResolver.php | 20 ++++++++++--------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/Filter/Resolver/FilterOptionsResolver.php b/src/Filter/Resolver/FilterOptionsResolver.php index 21d178a6..8afa7149 100644 --- a/src/Filter/Resolver/FilterOptionsResolver.php +++ b/src/Filter/Resolver/FilterOptionsResolver.php @@ -14,10 +14,10 @@ * Resolves a filter's canonical config through the element's declared schema. * Elements without an {@see OptionsContract} receive their config verbatim (unvalidated). */ -class FilterOptionsResolver +final readonly class FilterOptionsResolver { public function __construct( - private readonly SchemaResolver $schemaResolver, + private SchemaResolver $schemaResolver, ) {} /** diff --git a/src/List/Resolver/ListOptionsResolver.php b/src/List/Resolver/ListOptionsResolver.php index fa82070f..ed8fcf77 100644 --- a/src/List/Resolver/ListOptionsResolver.php +++ b/src/List/Resolver/ListOptionsResolver.php @@ -15,10 +15,10 @@ * type's declared schema ({@see OptionsContract}). The combined resolver is memoized * per type class. */ -class ListOptionsResolver +final readonly class ListOptionsResolver { public function __construct( - private readonly SchemaResolver $schemaResolver, + private SchemaResolver $schemaResolver, ) {} /** @@ -28,30 +28,32 @@ public function __construct( * * @throws FlareException If the config does not satisfy the schema. */ - public function resolve(?object $typeService, array $config, ?string $source = null): array + public function resolve(?object $driverService, array $config, ?string $source = null): array { - $configure = static function (OptionsResolver $resolver) use ($typeService): void { + $configure = static function (OptionsResolver $resolver) use ($driverService): void { BaseListOptions::configureOptions($resolver); - if ($typeService instanceof OptionsContract) { - $typeService->configureOptions($resolver); + if ($driverService instanceof OptionsContract) { + $driverService->configureOptions($resolver); } }; + $driverClass = $driverService ? $driverService::class : null; + try { - return $this->schemaResolver->resolve($typeService ? $typeService::class : '', $configure, $config); + return $this->schemaResolver->resolve((string) $driverClass, $configure, $config); } catch (\Throwable $e) { throw new FlareException( \sprintf( '[FLARE] Invalid list config%s: %s', - $typeService ? ' for list type "' . $typeService::class . '"' : '', + $driverService ? ' for list type "' . $driverService::class . '"' : '', $e->getMessage(), ), previous: $e, - method: ($typeService ? $typeService::class : BaseListOptions::class) . '::configureOptions', + method: ($driverClass ?? BaseListOptions::class) . '::configureOptions', source: $source, ); } From b41a295116b7b7a6564eb0d2ba92cdcea910168b Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 15:23:32 +0200 Subject: [PATCH 40/71] refactor: extract `List\Resolver\ListTransformerResolver` Mirror `FilterTransformerResolver` on the list side: memoize the driver's transformer map per class instead of rebuilding it on every `build()` call, and slim `ListBuilder::build()` down to merging the returned canonical values between base translation and explicit overrides (precedence unchanged). --- src/List/Factory/ListBuilderFactory.php | 3 + src/List/ListBuilder.php | 13 ++-- src/List/Resolver/ListTransformerResolver.php | 47 ++++++++++++ tests/List/ListBuilderTest.php | 2 + tests/List/ListTransformerResolverTest.php | 71 +++++++++++++++++++ 5 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 src/List/Resolver/ListTransformerResolver.php create mode 100644 tests/List/ListTransformerResolverTest.php diff --git a/src/List/Factory/ListBuilderFactory.php b/src/List/Factory/ListBuilderFactory.php index 946d9c94..5acbfeed 100644 --- a/src/List/Factory/ListBuilderFactory.php +++ b/src/List/Factory/ListBuilderFactory.php @@ -7,6 +7,7 @@ use HeimrichHannot\FlareBundle\Filter\Collector\ListModelFilterCollector; use HeimrichHannot\FlareBundle\List\ListBuilder; use HeimrichHannot\FlareBundle\List\Resolver\ListOptionsResolver; +use HeimrichHannot\FlareBundle\List\Resolver\ListTransformerResolver; use HeimrichHannot\FlareBundle\List\Type\ListTypeInterface; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Registry\ListTypeRegistry; @@ -22,6 +23,7 @@ public function __construct( private EventDispatcherInterface $eventDispatcher, private ListModelFilterCollector $filterCollector, private ListOptionsResolver $listOptionsResolver, + private ListTransformerResolver $listTransformerResolver, private ListTypeRegistry $listTypeRegistry, ) {} @@ -37,6 +39,7 @@ public function create( return new ListBuilder( optionsResolver: $this->listOptionsResolver, + transformerResolver: $this->listTransformerResolver, eventDispatcher: $this->eventDispatcher, type: $type, typeService: $typeService, diff --git a/src/List/ListBuilder.php b/src/List/ListBuilder.php index 55079d63..d5af236f 100644 --- a/src/List/ListBuilder.php +++ b/src/List/ListBuilder.php @@ -5,13 +5,12 @@ namespace HeimrichHannot\FlareBundle\List; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; -use HeimrichHannot\FlareBundle\Config\TransformerResolver; use HeimrichHannot\FlareBundle\Contract\ListType\BuildListContract; -use HeimrichHannot\FlareBundle\Contract\TransformerContract; use HeimrichHannot\FlareBundle\Event\ListBuildEvent; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\List\Resolver\ListOptionsResolver; +use HeimrichHannot\FlareBundle\List\Resolver\ListTransformerResolver; use HeimrichHannot\FlareBundle\List\Type\ListTypeInterface; use HeimrichHannot\FlareBundle\Model\ListModel; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -40,6 +39,7 @@ final class ListBuilder implements ListBuilderInterface public function __construct( private readonly ListOptionsResolver $optionsResolver, + private readonly ListTransformerResolver $transformerResolver, private readonly EventDispatcherInterface $eventDispatcher, private readonly ListTypeInterface|string $type, private readonly ?object $typeService, @@ -148,13 +148,12 @@ public function build(): ListSpec { BaseListOptions::transform($config, $this->model); - if ($this->typeService instanceof TransformerContract) + if ($this->typeService) { - $transformers = new TransformerResolver(); - $this->typeService->configureTransformers($transformers); + $transformed = $this->transformerResolver->transform($this->typeService, $this->model); - if ($transformer = $transformers->resolve($this->model)) { - $transformer($config, $this->model); + foreach ($transformed ?? [] as $key => $value) { + $config->set($key, $value); } } } diff --git a/src/List/Resolver/ListTransformerResolver.php b/src/List/Resolver/ListTransformerResolver.php new file mode 100644 index 00000000..87126349 --- /dev/null +++ b/src/List/Resolver/ListTransformerResolver.php @@ -0,0 +1,47 @@ + + */ + private array $transformers = []; + + /** + * @return array|null Canonical config values, or null when no transformer matches the source. + */ + public function transform(object $driverService, object $source): ?array + { + if (!isset($this->transformers[$driverService::class])) + { + $transformers = new TransformerResolver(); + + if ($driverService instanceof TransformerContract) { + $driverService->configureTransformers($transformers); + } + + $this->transformers[$driverService::class] = $transformers; + } + + if (!$transformer = $this->transformers[$driverService::class]->resolve($source)) { + return null; + } + + $transformer($config = new ConfigBuilder(), $source); + + return $config->all(); + } +} diff --git a/tests/List/ListBuilderTest.php b/tests/List/ListBuilderTest.php index 9391f329..3524e594 100644 --- a/tests/List/ListBuilderTest.php +++ b/tests/List/ListBuilderTest.php @@ -12,6 +12,7 @@ use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\List\ListBuilder; use HeimrichHannot\FlareBundle\List\Resolver\ListOptionsResolver; +use HeimrichHannot\FlareBundle\List\Resolver\ListTransformerResolver; use HeimrichHannot\FlareBundle\List\Type\AbstractListType; use HeimrichHannot\FlareBundle\Model\ListModel; use PHPUnit\Framework\TestCase; @@ -116,6 +117,7 @@ private function createBuilder( ): ListBuilder { return new ListBuilder( optionsResolver: new ListOptionsResolver(new SchemaResolver()), + transformerResolver: new ListTransformerResolver(), eventDispatcher: $dispatcher, type: 'test_type', typeService: $typeService, diff --git a/tests/List/ListTransformerResolverTest.php b/tests/List/ListTransformerResolverTest.php new file mode 100644 index 00000000..c18918fe --- /dev/null +++ b/tests/List/ListTransformerResolverTest.php @@ -0,0 +1,71 @@ +transform($driver, new SourceStub('from-source')); + + self::assertSame(['title' => 'from-source'], $values); + } + + public function testMemoizesTransformerMapPerDriverClass(): void + { + $resolver = new ListTransformerResolver(); + $driver = new TransformingDriver(); + + $resolver->transform($driver, new SourceStub('a')); + $resolver->transform($driver, new SourceStub('b')); + + self::assertSame(1, $driver->configureCalls); + } + + public function testReturnsNullWhenNoTransformerMatchesTheSource(): void + { + $resolver = new ListTransformerResolver(); + + self::assertNull($resolver->transform(new TransformingDriver(), new \stdClass())); + } + + public function testReturnsNullForDriversWithoutTransformerContract(): void + { + $resolver = new ListTransformerResolver(); + + self::assertNull($resolver->transform(new \stdClass(), new SourceStub('x'))); + } +} + +final class TransformingDriver implements TransformerContract +{ + public int $configureCalls = 0; + + public function configureTransformers(TransformerResolver $resolver): void + { + $this->configureCalls++; + + $resolver->for(SourceStub::class, static function (ConfigBuilder $config, object $source): void { + \assert($source instanceof SourceStub); + $config->set('title', $source->title); + }); + } +} + +final class SourceStub +{ + public function __construct( + public readonly string $title, + ) {} +} From c8ead4a2edfa91f4da122680e1055b6ff0bd5464 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 15:29:03 +0200 Subject: [PATCH 41/71] feat: dispatch `ListTransformerEvent` from `ListTransformerResolver` Complete the analogy to the filter side: `ListTransformerResolver` now dispatches `ListTransformerEvent` once per type class so listeners can register transformers for additional source classes, re-dispatched per type as `flare.list.{type}.transformers` by a NamedDispatch listener. `transform()` is typed on `ListTypeInterface` instead of `object`; `ListBuilder` guards accordingly and passes the type alias through. --- src/Event/ListTransformerEvent.php | 23 ++++++ .../NamedDispatch/ListTransformerListener.php | 26 ++++++ src/List/ListBuilder.php | 8 +- src/List/Resolver/ListTransformerResolver.php | 29 ++++--- tests/List/ListBuilderTest.php | 2 +- tests/List/ListTransformerResolverTest.php | 79 +++++++++++++------ 6 files changed, 131 insertions(+), 36 deletions(-) create mode 100644 src/Event/ListTransformerEvent.php create mode 100644 src/EventListener/NamedDispatch/ListTransformerListener.php diff --git a/src/Event/ListTransformerEvent.php b/src/Event/ListTransformerEvent.php new file mode 100644 index 00000000..f7d4d16f --- /dev/null +++ b/src/Event/ListTransformerEvent.php @@ -0,0 +1,23 @@ +type) { + return; + } + + $this->eventDispatcher->dispatch(event: $event, eventName: "flare.list.{$event->type}.transformers"); + } +} diff --git a/src/List/ListBuilder.php b/src/List/ListBuilder.php index d5af236f..8e67d103 100644 --- a/src/List/ListBuilder.php +++ b/src/List/ListBuilder.php @@ -148,9 +148,13 @@ public function build(): ListSpec { BaseListOptions::transform($config, $this->model); - if ($this->typeService) + if ($this->typeService instanceof ListTypeInterface) { - $transformed = $this->transformerResolver->transform($this->typeService, $this->model); + $transformed = $this->transformerResolver->transform( + $this->typeService, + $this->getTypeAlias(), + $this->model, + ); foreach ($transformed ?? [] as $key => $value) { $config->set($key, $value); diff --git a/src/List/Resolver/ListTransformerResolver.php b/src/List/Resolver/ListTransformerResolver.php index 87126349..61b830e8 100644 --- a/src/List/Resolver/ListTransformerResolver.php +++ b/src/List/Resolver/ListTransformerResolver.php @@ -7,36 +7,45 @@ use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\Config\TransformerResolver; use HeimrichHannot\FlareBundle\Contract\TransformerContract; +use HeimrichHannot\FlareBundle\Event\ListTransformerEvent; +use HeimrichHannot\FlareBundle\List\Type\ListTypeInterface; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; /** - * Runs a list driver's source transformers ({@see TransformerContract}) to translate a stored - * source (e.g. a ListModel) into canonical config values. The configured transformer map is - * memoized per driver class. + * Runs a list type's source transformers ({@see TransformerContract}) to translate a stored + * source (e.g. a ListModel) into canonical config values. The configured transformer map + * is memoized per type class; listeners extend it via {@see ListTransformerEvent}. */ final class ListTransformerResolver { /** * @var array */ - private array $transformers = []; + private array $builders = []; + + public function __construct( + private readonly EventDispatcherInterface $eventDispatcher, + ) {} /** * @return array|null Canonical config values, or null when no transformer matches the source. */ - public function transform(object $driverService, object $source): ?array + public function transform(ListTypeInterface $typeService, ?string $type, object $source): ?array { - if (!isset($this->transformers[$driverService::class])) + if (!isset($this->builders[$typeService::class])) { $transformers = new TransformerResolver(); - if ($driverService instanceof TransformerContract) { - $driverService->configureTransformers($transformers); + if ($typeService instanceof TransformerContract) { + $typeService->configureTransformers($transformers); } - $this->transformers[$driverService::class] = $transformers; + $this->eventDispatcher->dispatch(new ListTransformerEvent($transformers, $typeService, $type)); + + $this->builders[$typeService::class] = $transformers; } - if (!$transformer = $this->transformers[$driverService::class]->resolve($source)) { + if (!$transformer = $this->builders[$typeService::class]->resolve($source)) { return null; } diff --git a/tests/List/ListBuilderTest.php b/tests/List/ListBuilderTest.php index 3524e594..943c0d20 100644 --- a/tests/List/ListBuilderTest.php +++ b/tests/List/ListBuilderTest.php @@ -117,7 +117,7 @@ private function createBuilder( ): ListBuilder { return new ListBuilder( optionsResolver: new ListOptionsResolver(new SchemaResolver()), - transformerResolver: new ListTransformerResolver(), + transformerResolver: new ListTransformerResolver($dispatcher), eventDispatcher: $dispatcher, type: 'test_type', typeService: $typeService, diff --git a/tests/List/ListTransformerResolverTest.php b/tests/List/ListTransformerResolverTest.php index c18918fe..ba170627 100644 --- a/tests/List/ListTransformerResolverTest.php +++ b/tests/List/ListTransformerResolverTest.php @@ -7,48 +7,85 @@ use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\Config\TransformerResolver; use HeimrichHannot\FlareBundle\Contract\TransformerContract; +use HeimrichHannot\FlareBundle\Event\ListTransformerEvent; use HeimrichHannot\FlareBundle\List\Resolver\ListTransformerResolver; +use HeimrichHannot\FlareBundle\List\Type\ListTypeInterface; use PHPUnit\Framework\TestCase; +use Symfony\Component\EventDispatcher\EventDispatcher; final class ListTransformerResolverTest extends TestCase { public function testTransformsSourceThroughDriverTransformers(): void { - $resolver = new ListTransformerResolver(); + $resolver = new ListTransformerResolver(new EventDispatcher()); $driver = new TransformingDriver(); - $values = $resolver->transform($driver, new SourceStub('from-source')); + $values = $resolver->transform($driver, 'test', new SourceStub('from-source')); self::assertSame(['title' => 'from-source'], $values); } - public function testMemoizesTransformerMapPerDriverClass(): void + public function testReturnsNullWithoutMatchingTransformer(): void { - $resolver = new ListTransformerResolver(); - $driver = new TransformingDriver(); - - $resolver->transform($driver, new SourceStub('a')); - $resolver->transform($driver, new SourceStub('b')); + $resolver = new ListTransformerResolver(new EventDispatcher()); - self::assertSame(1, $driver->configureCalls); + self::assertNull($resolver->transform(new TransformingDriver(), 'test', new \stdClass())); + self::assertNull($resolver->transform(new TransformerlessDriver(), 'test', new SourceStub('x'))); } - public function testReturnsNullWhenNoTransformerMatchesTheSource(): void + public function testMemoizesMapAndDispatchesEventOncePerDriverClass(): void { - $resolver = new ListTransformerResolver(); + $dispatchedWith = []; + + $dispatcher = new EventDispatcher(); + $dispatcher->addListener( + ListTransformerEvent::class, + static function (ListTransformerEvent $event) use (&$dispatchedWith): void { + $dispatchedWith[] = $event; + }, + ); + + $resolver = new ListTransformerResolver($dispatcher); + $driver = new TransformingDriver(); + + $resolver->transform($driver, 'test', new SourceStub('a')); + $resolver->transform($driver, 'test', new SourceStub('b')); - self::assertNull($resolver->transform(new TransformingDriver(), new \stdClass())); + self::assertSame(1, $driver->configureCalls); + self::assertCount(1, $dispatchedWith); + self::assertSame($driver, $dispatchedWith[0]->typeService); + self::assertSame('test', $dispatchedWith[0]->type); } - public function testReturnsNullForDriversWithoutTransformerContract(): void + public function testEventListenersCanAddSourceCapabilities(): void { - $resolver = new ListTransformerResolver(); - - self::assertNull($resolver->transform(new \stdClass(), new SourceStub('x'))); + $dispatcher = new EventDispatcher(); + $dispatcher->addListener( + ListTransformerEvent::class, + static function (ListTransformerEvent $event): void { + $event->transformers->for( + \stdClass::class, + static fn (ConfigBuilder $config, object $source) => $config->set('external', true), + ); + }, + ); + + $resolver = new ListTransformerResolver($dispatcher); + + $values = $resolver->transform(new TransformerlessDriver(), 'test', new \stdClass()); + + self::assertSame(['external' => true], $values); } } -final class TransformingDriver implements TransformerContract +final class SourceStub +{ + public function __construct( + public readonly string $title, + ) {} +} + +final class TransformingDriver implements ListTypeInterface, TransformerContract { public int $configureCalls = 0; @@ -56,16 +93,12 @@ public function configureTransformers(TransformerResolver $resolver): void { $this->configureCalls++; - $resolver->for(SourceStub::class, static function (ConfigBuilder $config, object $source): void { - \assert($source instanceof SourceStub); + $resolver->for(SourceStub::class, static function (ConfigBuilder $config, SourceStub $source): void { $config->set('title', $source->title); }); } } -final class SourceStub +final class TransformerlessDriver implements ListTypeInterface { - public function __construct( - public readonly string $title, - ) {} } From 16fb33b752ec1b222e5c87e7c2b66d415884b3e2 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 17:31:15 +0200 Subject: [PATCH 42/71] refactor: consolidate `ListType` to `ListDriver` terminology and update API accordingly Replaced all usages of `ListType` with `ListDriver` across the codebase. Updated class names, interfaces, method arguments, and references to reflect the new terminology. Simplified API by removing `element` property in `Filter`, replaced with `type`. Adjusted tests, factories, and registries accordingly. --- src/Contract/ListType/BuildListContract.php | 4 +- .../ContentElement/ListViewController.php | 4 +- .../ContentElement/ReaderController.php | 4 +- src/DataContainer/ListContainer.php | 6 +- .../Compiler/RegisterListTypesPass.php | 8 +-- src/Engine/Loader/ValidationLoader.php | 4 +- src/Engine/Mod/SimpleEquationMod.php | 2 +- src/Event/ListBuildEvent.php | 4 +- src/Event/ListTransformerEvent.php | 4 +- .../Contao/BreadcrumbListener.php | 4 +- .../Contao/ElementDcaListener.php | 8 +-- .../FlareFilter/FieldsOptionsCallbacks.php | 4 +- .../FlareList/FieldsOptionsCallbacks.php | 6 +- .../NamedDispatch/FilterElementListener.php | 6 +- .../NamedDispatch/ListBuildListener.php | 2 +- .../Reader/GenericReaderPageMetaListener.php | 4 +- .../Collector/ListModelFilterCollector.php | 6 +- src/Filter/Filter.php | 40 +++++--------- src/Filter/FilterBuilder.php | 8 +-- src/Filter/Resolver/FilterElementResolver.php | 8 +-- .../Resolver/FilterTransformerResolver.php | 16 +++--- .../CodefogTagsChoiceFilterElement.php | 6 +- .../ListType/EventsListType.php | 10 ++-- .../Projector/EventsAggregationProjector.php | 4 +- .../Projector/EventsInteractiveProjector.php | 2 +- .../EventListener/ChangelanguageListener.php | 4 +- .../ListType/DcMultilingualListType.php | 4 +- src/List/BaseListOptions.php | 2 +- ...Factory.php => ListSpecBuilderFactory.php} | 33 +++++------ src/List/Factory/ListSpecFactory.php | 36 ++++++++++++ src/List/ListDriverReference.php | 15 +++++ src/List/ListSpec.php | 39 ++++++------- .../{ListBuilder.php => ListSpecBuilder.php} | 51 ++++++++--------- ...rface.php => ListSpecBuilderInterface.php} | 9 +-- src/List/Resolver/ListDriverResolver.php | 55 +++++++++++++++++++ src/List/Resolver/ListOptionsResolver.php | 3 +- src/List/Resolver/ListTransformerResolver.php | 20 +++---- ...actListType.php => AbstractListDriver.php} | 4 +- ...php => GenericDataContainerListDriver.php} | 2 +- ...eInterface.php => ListDriverInterface.php} | 2 +- .../{NewsListType.php => NewsListDriver.php} | 8 +-- src/Query/Executor/FilterExecutor.php | 2 +- .../Factory/ListExecutionContextFactory.php | 30 +++++----- .../Factory/ReaderRequestAttributeFactory.php | 4 +- .../Descriptor/ListTypeDescriptor.php | 4 +- ...ypeRegistry.php => ListDriverRegistry.php} | 4 +- .../Projector/InteractiveProjectorTest.php | 2 +- tests/Filter/FilterOptionsResolverTest.php | 6 +- tests/Filter/FilterTest.php | 18 +----- tests/Form/FilterFormFactoryTest.php | 12 ++-- tests/List/ListBuilderTest.php | 24 ++++---- tests/List/ListSpecTest.php | 20 +++---- tests/List/ListTransformerResolverTest.php | 6 +- 53 files changed, 322 insertions(+), 271 deletions(-) rename src/List/Factory/{ListBuilderFactory.php => ListSpecBuilderFactory.php} (67%) create mode 100644 src/List/Factory/ListSpecFactory.php create mode 100644 src/List/ListDriverReference.php rename src/List/{ListBuilder.php => ListSpecBuilder.php} (75%) rename src/List/{ListBuilderInterface.php => ListSpecBuilderInterface.php} (74%) create mode 100644 src/List/Resolver/ListDriverResolver.php rename src/List/Type/{AbstractListType.php => AbstractListDriver.php} (92%) rename src/List/Type/{GenericDataContainerListType.php => GenericDataContainerListDriver.php} (96%) rename src/List/Type/{ListTypeInterface.php => ListDriverInterface.php} (84%) rename src/List/Type/{NewsListType.php => NewsListDriver.php} (87%) rename src/Registry/{ListTypeRegistry.php => ListDriverRegistry.php} (91%) diff --git a/src/Contract/ListType/BuildListContract.php b/src/Contract/ListType/BuildListContract.php index c2991c0f..f6605dc5 100644 --- a/src/Contract/ListType/BuildListContract.php +++ b/src/Contract/ListType/BuildListContract.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\Contract\ListType; -use HeimrichHannot\FlareBundle\List\ListBuilder; +use HeimrichHannot\FlareBundle\List\ListSpecBuilder; /** * Implemented by list types that take part in their list's build lifecycle — @@ -12,5 +12,5 @@ */ interface BuildListContract { - public function buildList(ListBuilder $builder): void; + public function buildList(ListSpecBuilder $builder): void; } diff --git a/src/Controller/ContentElement/ListViewController.php b/src/Controller/ContentElement/ListViewController.php index feefea90..13f1f45a 100644 --- a/src/Controller/ContentElement/ListViewController.php +++ b/src/Controller/ContentElement/ListViewController.php @@ -19,7 +19,7 @@ use HeimrichHannot\FlareBundle\Event\ListViewRenderEvent; use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Exception\FlareException; -use HeimrichHannot\FlareBundle\List\Factory\ListBuilderFactory; +use HeimrichHannot\FlareBundle\List\Factory\ListSpecBuilderFactory; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Util\Str; use Psr\Log\LoggerInterface; @@ -41,7 +41,7 @@ public function __construct( private readonly EventDispatcherInterface $eventDispatcher, private readonly InteractiveContextFactory $interactiveConfigFactory, private readonly KernelInterface $kernel, - private readonly ListBuilderFactory $listFactory, + private readonly ListSpecBuilderFactory $listFactory, private readonly LoggerInterface $logger, private readonly ScopeMatcher $scopeMatcher, private readonly SymfonyResponseTagger $responseTagger, diff --git a/src/Controller/ContentElement/ReaderController.php b/src/Controller/ContentElement/ReaderController.php index ada1ad11..9250beea 100644 --- a/src/Controller/ContentElement/ReaderController.php +++ b/src/Controller/ContentElement/ReaderController.php @@ -24,7 +24,7 @@ use HeimrichHannot\FlareBundle\Event\ReaderRenderEvent; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Exception\ViewException; -use HeimrichHannot\FlareBundle\List\Factory\ListBuilderFactory; +use HeimrichHannot\FlareBundle\List\Factory\ListSpecBuilderFactory; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Reader\ReaderPageMeta; use HeimrichHannot\FlareBundle\Reader\ReaderRequestAttribute; @@ -48,7 +48,7 @@ public function __construct( private readonly EngineFactory $engineFactory, private readonly EntityCacheTags $entityCacheTags, private readonly KernelInterface $kernel, - private readonly ListBuilderFactory $listFactory, + private readonly ListSpecBuilderFactory $listFactory, private readonly LoggerInterface $logger, private readonly ReaderRequestAttributeResolver $attributeResolver, private readonly ResponseContextAccessor $responseContextAccessor, diff --git a/src/DataContainer/ListContainer.php b/src/DataContainer/ListContainer.php index 10657d24..148a2628 100644 --- a/src/DataContainer/ListContainer.php +++ b/src/DataContainer/ListContainer.php @@ -9,7 +9,7 @@ use Doctrine\DBAL\Connection; use HeimrichHannot\FlareBundle\Contract\ListType\DataContainerContract; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; -use HeimrichHannot\FlareBundle\Registry\ListTypeRegistry; +use HeimrichHannot\FlareBundle\Registry\ListDriverRegistry; use HeimrichHannot\FlareBundle\Util\DcaHelper; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -18,8 +18,8 @@ class ListContainer public const TABLE_NAME = 'tl_flare_list'; public function __construct( - private readonly Connection $connection, - private readonly ListTypeRegistry $listTypeRegistry, + private readonly Connection $connection, + private readonly ListDriverRegistry $listTypeRegistry, ) {} /* ============================= * diff --git a/src/DependencyInjection/Compiler/RegisterListTypesPass.php b/src/DependencyInjection/Compiler/RegisterListTypesPass.php index 8dabfe7d..59e45f3f 100644 --- a/src/DependencyInjection/Compiler/RegisterListTypesPass.php +++ b/src/DependencyInjection/Compiler/RegisterListTypesPass.php @@ -7,7 +7,7 @@ use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListType; use HeimrichHannot\FlareBundle\Registry\Descriptor\ListTypeDescriptor; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; -use HeimrichHannot\FlareBundle\Registry\ListTypeRegistry; +use HeimrichHannot\FlareBundle\Registry\ListDriverRegistry; use HeimrichHannot\FlareBundle\Util\Str; use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; @@ -23,12 +23,12 @@ final class RegisterListTypesPass implements CompilerPassInterface public function process(ContainerBuilder $container): void { - if (!$container->hasDefinition(ListTypeRegistry::class)) { + if (!$container->hasDefinition(ListDriverRegistry::class)) { return; } $tag = AsListType::TAG; - $registry = $container->findDefinition(ListTypeRegistry::class); + $registry = $container->findDefinition(ListDriverRegistry::class); foreach ($this->findAndSortTaggedServices($tag, $container) as $reference) { @@ -96,4 +96,4 @@ protected function getListTypeName(Definition $definition, array $attributes): s return Container::underscore($className); } -} \ No newline at end of file +} diff --git a/src/Engine/Loader/ValidationLoader.php b/src/Engine/Loader/ValidationLoader.php index 1dbef173..edbd12dd 100644 --- a/src/Engine/Loader/ValidationLoader.php +++ b/src/Engine/Loader/ValidationLoader.php @@ -34,7 +34,7 @@ public function fetchEntryById(int $id): ?array try { $idDefinition = new Filter( - element: SimpleEquationFilterElement::TYPE, + type: SimpleEquationFilterElement::TYPE, config: [ 'intrinsic' => true, 'left' => 'id', @@ -69,7 +69,7 @@ public function fetchEntryByAutoItem(string $autoItem): ?array try { $autoItemDefinition = new Filter( - element: SimpleEquationFilterElement::TYPE, + type: SimpleEquationFilterElement::TYPE, config: [ 'intrinsic' => true, 'left' => $this->config->autoItemField, diff --git a/src/Engine/Mod/SimpleEquationMod.php b/src/Engine/Mod/SimpleEquationMod.php index cd664802..9ed9e1ca 100644 --- a/src/Engine/Mod/SimpleEquationMod.php +++ b/src/Engine/Mod/SimpleEquationMod.php @@ -20,7 +20,7 @@ public static function getType(): string public function __invoke(Engine $engine, array $options): void { $filter = new Filter( - element: SimpleEquationFilterElement::TYPE, + type: SimpleEquationFilterElement::TYPE, config: [ 'intrinsic' => true, 'left' => $options['operand1'], diff --git a/src/Event/ListBuildEvent.php b/src/Event/ListBuildEvent.php index ff927731..e30f4062 100644 --- a/src/Event/ListBuildEvent.php +++ b/src/Event/ListBuildEvent.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\Event; -use HeimrichHannot\FlareBundle\List\ListBuilder; +use HeimrichHannot\FlareBundle\List\ListSpecBuilder; use Symfony\Contracts\EventDispatcher\Event; /** @@ -15,6 +15,6 @@ class ListBuildEvent extends Event { public function __construct( - public readonly ListBuilder $builder, + public readonly ListSpecBuilder $builder, ) {} } diff --git a/src/Event/ListTransformerEvent.php b/src/Event/ListTransformerEvent.php index f7d4d16f..bbefd5d3 100644 --- a/src/Event/ListTransformerEvent.php +++ b/src/Event/ListTransformerEvent.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\Event; use HeimrichHannot\FlareBundle\Config\TransformerResolver; -use HeimrichHannot\FlareBundle\List\Type\ListTypeInterface; +use HeimrichHannot\FlareBundle\List\Type\ListDriverInterface; use Symfony\Contracts\EventDispatcher\Event; /** @@ -17,7 +17,7 @@ class ListTransformerEvent extends Event { public function __construct( public readonly TransformerResolver $transformers, - public readonly ListTypeInterface $typeService, + public readonly ListDriverInterface $typeService, public readonly ?string $type, ) {} } diff --git a/src/EventListener/Contao/BreadcrumbListener.php b/src/EventListener/Contao/BreadcrumbListener.php index 8c23d35a..b42309f7 100644 --- a/src/EventListener/Contao/BreadcrumbListener.php +++ b/src/EventListener/Contao/BreadcrumbListener.php @@ -16,7 +16,7 @@ use HeimrichHannot\FlareBundle\Engine\View\ValidationView; use HeimrichHannot\FlareBundle\Event\ReaderPageMetaEvent; use HeimrichHannot\FlareBundle\Exception\ViewException; -use HeimrichHannot\FlareBundle\List\Factory\ListBuilderFactory; +use HeimrichHannot\FlareBundle\List\Factory\ListSpecBuilderFactory; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Registry\ProjectorRegistry; use HeimrichHannot\FlareBundle\Util\Env; @@ -28,7 +28,7 @@ public function __construct( private Connection $connection, private EventDispatcherInterface $eventDispatcher, - private ListBuilderFactory $listFactory, + private ListSpecBuilderFactory $listFactory, private ProjectorRegistry $projectorRegistry, private ValidationContextFactory $validationContextFactory, ) {} diff --git a/src/EventListener/Contao/ElementDcaListener.php b/src/EventListener/Contao/ElementDcaListener.php index 5903ca9c..d67a2b4f 100644 --- a/src/EventListener/Contao/ElementDcaListener.php +++ b/src/EventListener/Contao/ElementDcaListener.php @@ -10,13 +10,13 @@ use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\Event\ElementDcaEvent; -use HeimrichHannot\FlareBundle\List\Factory\ListBuilderFactory; +use HeimrichHannot\FlareBundle\List\Factory\ListSpecBuilderFactory; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Query\Factory\ListExecutionContextFactory; use HeimrichHannot\FlareBundle\Query\ListExecutionContext; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; -use HeimrichHannot\FlareBundle\Registry\ListTypeRegistry; +use HeimrichHannot\FlareBundle\Registry\ListDriverRegistry; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -35,8 +35,8 @@ public function __construct( private EventDispatcherInterface $eventDispatcher, private FilterElementRegistry $filterElementRegistry, private ListExecutionContextFactory $listExecutionContextFactory, - private ListBuilderFactory $listFactory, - private ListTypeRegistry $listTypeRegistry, + private ListSpecBuilderFactory $listFactory, + private ListDriverRegistry $listTypeRegistry, private RequestStack $requestStack, ) {} diff --git a/src/EventListener/DataContainer/FlareFilter/FieldsOptionsCallbacks.php b/src/EventListener/DataContainer/FlareFilter/FieldsOptionsCallbacks.php index 4efdd456..7cb04f38 100644 --- a/src/EventListener/DataContainer/FlareFilter/FieldsOptionsCallbacks.php +++ b/src/EventListener/DataContainer/FlareFilter/FieldsOptionsCallbacks.php @@ -12,7 +12,7 @@ use HeimrichHannot\FlareBundle\Contract\IsSupportedContract; use HeimrichHannot\FlareBundle\DataContainer\FilterContainer; use HeimrichHannot\FlareBundle\InferPtable\PtableInferrer; -use HeimrichHannot\FlareBundle\List\Factory\ListBuilderFactory; +use HeimrichHannot\FlareBundle\List\Factory\ListSpecBuilderFactory; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Query\Factory\ListExecutionContextFactory; @@ -36,7 +36,7 @@ public function __construct( private FilterContainer $filterContainer, private FilterElementRegistry $filterElementRegistry, private TranslatorInterface $translator, - private ListBuilderFactory $listFactory, + private ListSpecBuilderFactory $listFactory, private ListExecutionContextFactory $listExecutionContextFactory, ) {} diff --git a/src/EventListener/DataContainer/FlareList/FieldsOptionsCallbacks.php b/src/EventListener/DataContainer/FlareList/FieldsOptionsCallbacks.php index 528f6456..1261671a 100644 --- a/src/EventListener/DataContainer/FlareList/FieldsOptionsCallbacks.php +++ b/src/EventListener/DataContainer/FlareList/FieldsOptionsCallbacks.php @@ -11,7 +11,7 @@ use Contao\Database; use Contao\DataContainer; use HeimrichHannot\FlareBundle\DataContainer\ListContainer; -use HeimrichHannot\FlareBundle\Registry\ListTypeRegistry; +use HeimrichHannot\FlareBundle\Registry\ListDriverRegistry; use HeimrichHannot\FlareBundle\Util\DcaFieldFilter; use HeimrichHannot\FlareBundle\Util\DcaHelper; use Symfony\Contracts\Translation\TranslatorInterface; @@ -26,7 +26,7 @@ public function __construct( private ContaoFramework $contaoFramework, private ListContainer $listContainer, - private ListTypeRegistry $listTypeRegistry, + private ListDriverRegistry $listTypeRegistry, private ResourceFinderInterface $resourceFinder, private TranslatorInterface $translator, ) {} @@ -133,4 +133,4 @@ public function getFieldOptions_tablePtable(DataContainer $dc): array $tables = \array_filter($tables, $db->tableExists(...)); return \array_combine($tables, $tables) ?: []; } -} \ No newline at end of file +} diff --git a/src/EventListener/NamedDispatch/FilterElementListener.php b/src/EventListener/NamedDispatch/FilterElementListener.php index 74f29219..98b0c515 100644 --- a/src/EventListener/NamedDispatch/FilterElementListener.php +++ b/src/EventListener/NamedDispatch/FilterElementListener.php @@ -19,7 +19,7 @@ public function __construct( #[AsEventListener(priority: -200)] public function onFilterElementBuiltEvent(FilterElementBuiltEvent $event): void { - if (!$type = $event->getContext()->filter->getElementType()) { + if (!$type = $event->getContext()->filter->type) { return; } @@ -29,7 +29,7 @@ public function onFilterElementBuiltEvent(FilterElementBuiltEvent $event): void #[AsEventListener(priority: -200)] public function onFilterElementBuildingEvent(FilterElementBuildingEvent $event): void { - if (!$type = $event->getContext()->filter->getElementType()) { + if (!$type = $event->getContext()->filter->type) { return; } @@ -39,7 +39,7 @@ public function onFilterElementBuildingEvent(FilterElementBuildingEvent $event): #[AsEventListener(priority: -200)] public function onFilterElementFormBuiltEvent(FilterElementFormBuiltEvent $event): void { - if (!$type = $event->getContext()->filter->getElementType()) { + if (!$type = $event->getContext()->filter->type) { return; } diff --git a/src/EventListener/NamedDispatch/ListBuildListener.php b/src/EventListener/NamedDispatch/ListBuildListener.php index 5aec67a5..ef0bf26b 100644 --- a/src/EventListener/NamedDispatch/ListBuildListener.php +++ b/src/EventListener/NamedDispatch/ListBuildListener.php @@ -17,7 +17,7 @@ public function __construct( #[AsEventListener(priority: -200)] public function __invoke(ListBuildEvent $event): void { - if (!$type = $event->builder->getTypeAlias()) { + if (!$type = $event->builder->getType()) { return; } diff --git a/src/EventListener/Reader/GenericReaderPageMetaListener.php b/src/EventListener/Reader/GenericReaderPageMetaListener.php index 2ac7b512..d9ca3290 100644 --- a/src/EventListener/Reader/GenericReaderPageMetaListener.php +++ b/src/EventListener/Reader/GenericReaderPageMetaListener.php @@ -42,7 +42,7 @@ public function __invoke(ReaderPageMetaEvent $event): void } $tokens = [ - 'list.type' => $list->getTypeAlias() ?? $list->type::class, + 'list.type' => $list->type, 'list.dc' => $list->dc, ]; @@ -106,4 +106,4 @@ private function addTokensFromProperties(array &$tokens, array $properties, ?str } } } -} \ No newline at end of file +} diff --git a/src/Filter/Collector/ListModelFilterCollector.php b/src/Filter/Collector/ListModelFilterCollector.php index dca1baaf..f6d9cfb9 100644 --- a/src/Filter/Collector/ListModelFilterCollector.php +++ b/src/Filter/Collector/ListModelFilterCollector.php @@ -11,7 +11,7 @@ use HeimrichHannot\FlareBundle\Filter\Resolver\FilterTransformerResolver; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Model\ListModel; -use HeimrichHannot\FlareBundle\Registry\ListTypeRegistry; +use HeimrichHannot\FlareBundle\Registry\ListDriverRegistry; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; /** @@ -24,7 +24,7 @@ public function __construct( private EventDispatcherInterface $eventDispatcher, private FilterElementResolver $filterElementResolver, private FilterTransformerResolver $filterTransformerResolver, - private ListTypeRegistry $listTypeRegistry, + private ListDriverRegistry $listTypeRegistry, ) {} /** @@ -62,7 +62,7 @@ public function collect(ListModel $listModel): ?array ?? $model->row(); $filter = new Filter( - element: $model->getFilterType(), + type: $model->getFilterType(), config: $config, alias: $model->getFilterFormName() ?: "_.{$source}", targetAlias: $model->getFilterTargetAlias() ?: null, diff --git a/src/Filter/Filter.php b/src/Filter/Filter.php index 3bc2d95f..2ab2ee32 100644 --- a/src/Filter/Filter.php +++ b/src/Filter/Filter.php @@ -4,8 +4,6 @@ namespace HeimrichHannot\FlareBundle\Filter; -use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; - /** * Immutable runtime representation of a single filter within a list. * @@ -17,7 +15,7 @@ final readonly class Filter { /** - * @param FilterElementInterface|string $element Registered element type alias or an inline element instance. + * @param string $type Registered element type alias. * @param array $config Canonical config (element-defined schema); scalars, arrays, and enums only. * @param array|null $data Runtime data bag, same shape buildFilter() receives * (single-field elements read {@see FilterContext::SINGLE_VALUE}). Submitted form @@ -29,32 +27,22 @@ * @param string|null $source Provenance for error messages, e.g. "tl_flare_filter.42". */ public function __construct( - public FilterElementInterface|string $element, - public array $config = [], - public ?array $data = null, - public ?string $alias = null, - public ?string $targetAlias = null, - public bool $targetingForced = false, - public ?string $source = null, + public string $type, + public array $config = [], + public ?array $data = null, + public ?string $alias = null, + public ?string $targetAlias = null, + public bool $targetingForced = false, + public ?string $source = null, ) {} - public function getElementType(): ?string - { - return \is_string($this->element) ? $this->element : null; - } - - public function getElementInstance(): ?FilterElementInterface - { - return $this->element instanceof FilterElementInterface ? $this->element : null; - } - /** * @param array $config */ public function withConfig(array $config): self { return new self( - element: $this->element, + type: $this->type, config: $config, data: $this->data, alias: $this->alias, @@ -70,7 +58,7 @@ public function withConfig(array $config): self public function withData(?array $data): self { return new self( - element: $this->element, + type: $this->type, config: $this->config, data: $data, alias: $this->alias, @@ -83,7 +71,7 @@ public function withData(?array $data): self public function withAlias(?string $alias): self { return new self( - element: $this->element, + type: $this->type, config: $this->config, data: $this->data, alias: $alias, @@ -96,7 +84,7 @@ public function withAlias(?string $alias): self public function withTargetAlias(?string $targetAlias, bool $forced = true): self { return new self( - element: $this->element, + type: $this->type, config: $this->config, data: $this->data, alias: $this->alias, @@ -109,7 +97,7 @@ public function withTargetAlias(?string $targetAlias, bool $forced = true): self public function withSource(?string $source): self { return new self( - element: $this->element, + type: $this->type, config: $this->config, data: $this->data, alias: $this->alias, @@ -126,7 +114,7 @@ public function withSource(?string $source): self public function fingerprint(): array { return [ - 'element' => $this->getElementType() ?? $this->element::class, + 'element' => $this->type, 'config' => $this->config, 'data' => $this->data, 'alias' => $this->alias, diff --git a/src/Filter/FilterBuilder.php b/src/Filter/FilterBuilder.php index 00aff703..6d1d981f 100644 --- a/src/Filter/FilterBuilder.php +++ b/src/Filter/FilterBuilder.php @@ -15,7 +15,7 @@ class FilterBuilder implements FilterBuilderInterface /** * @var array, OptionsResolver> */ - private static array $resolvers = []; + private static array $optionsResolvers = []; /** * @var FilterCall[] @@ -39,18 +39,18 @@ public function add(string $type, array $options = [], ?string $targetAlias = nu throw new FilterException(\sprintf('No FLARE filter type service registered for "%s".', $type)); } - if (!isset(self::$resolvers[$type])) + if (!isset(self::$optionsResolvers[$type])) { $resolver = new OptionsResolver(); $filterType->configureOptions($resolver); - self::$resolvers[$type] = $resolver; + self::$optionsResolvers[$type] = $resolver; } $this->calls[] = new FilterCall( type: $filterType, typeClass: $type, targetAlias: $targetAlias ?: $this->defaultTargetAlias, - options: self::$resolvers[$type]->resolve($options), + options: self::$optionsResolvers[$type]->resolve($options), ); return $this; diff --git a/src/Filter/Resolver/FilterElementResolver.php b/src/Filter/Resolver/FilterElementResolver.php index 196f1b88..1f827f64 100644 --- a/src/Filter/Resolver/FilterElementResolver.php +++ b/src/Filter/Resolver/FilterElementResolver.php @@ -13,7 +13,7 @@ * Resolves the filter element responsible for a filter: an inline instance wins, * otherwise the element is looked up in the registry by its type alias. */ -readonly class FilterElementResolver +final readonly class FilterElementResolver { public function __construct( private FilterElementRegistry $filterElementRegistry, @@ -22,11 +22,7 @@ public function __construct( public function resolve(Filter $filter): ?FilterElementInterface { - if ($instance = $filter->getElementInstance()) { - return $instance; - } - - return $this->resolveType($filter->getElementType(), $filter->source); + return $this->resolveType($filter->type, $filter->source); } public function resolveType(?string $type, ?string $source = null): ?FilterElementInterface diff --git a/src/Filter/Resolver/FilterTransformerResolver.php b/src/Filter/Resolver/FilterTransformerResolver.php index c68f1a11..1e641a3a 100644 --- a/src/Filter/Resolver/FilterTransformerResolver.php +++ b/src/Filter/Resolver/FilterTransformerResolver.php @@ -16,12 +16,12 @@ * source (e.g. a FilterModel) into canonical config values. The configured transformer map * is memoized per element class; listeners extend it via {@see FilterTransformerEvent}. */ -class FilterTransformerResolver +final class FilterTransformerResolver { /** * @var array */ - private array $builders = []; + private array $resolvers = []; public function __construct( private readonly EventDispatcherInterface $eventDispatcher, @@ -32,20 +32,20 @@ public function __construct( */ public function transform(FilterElementInterface $element, ?string $elementType, object $source): ?array { - if (!isset($this->builders[$element::class])) + if (!isset($this->resolvers[$element::class])) { - $transformers = new TransformerResolver(); + $resolver = new TransformerResolver(); if ($element instanceof TransformerContract) { - $element->configureTransformers($transformers); + $element->configureTransformers($resolver); } - $this->eventDispatcher->dispatch(new FilterTransformerEvent($transformers, $element, $elementType)); + $this->eventDispatcher->dispatch(new FilterTransformerEvent($resolver, $element, $elementType)); - $this->builders[$element::class] = $transformers; + $this->resolvers[$element::class] = $resolver; } - if (!$transformer = $this->builders[$element::class]->resolve($source)) { + if (!$transformer = $this->resolvers[$element::class]->resolve($source)) { return null; } diff --git a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php index 935aec71..9fe3335f 100644 --- a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php +++ b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php @@ -85,11 +85,7 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co $optValues = $this->getOptions( executionContext: $executionContext, targetAlias: $context->filter->targetAlias, - listInfo: \sprintf( - '%s (%s)', - $context->list->getTypeAlias() ?? 'inline', - (string) ($context->list->source ?? 'N/A'), - ), + listInfo: \sprintf('%s (%s)', $context->list->type, (string) ($context->list->source ?? 'N/A')), filterInfo: \sprintf('%s (%s)', self::TYPE, $context->filter->source ?? 'inlined'), ); diff --git a/src/Integration/ContaoCalendar/ListType/EventsListType.php b/src/Integration/ContaoCalendar/ListType/EventsListType.php index 5c178b0a..46e4782c 100644 --- a/src/Integration/ContaoCalendar/ListType/EventsListType.php +++ b/src/Integration/ContaoCalendar/ListType/EventsListType.php @@ -11,14 +11,14 @@ use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListType; use HeimrichHannot\FlareBundle\Filter\Element\PublishedFilterElement; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\List\ListBuilder; -use HeimrichHannot\FlareBundle\List\Type\AbstractListType; +use HeimrichHannot\FlareBundle\List\ListSpecBuilder; +use HeimrichHannot\FlareBundle\List\Type\AbstractListDriver; use HeimrichHannot\FlareBundle\Query\JoinTypeEnum; use HeimrichHannot\FlareBundle\Query\SqlJoinStruct; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; #[AsListType(type: self::TYPE, dataContainer: self::DATA_CONTAINER)] -class EventsListType extends AbstractListType implements BuildListContract, DcaContract +class EventsListType extends AbstractListDriver implements BuildListContract, DcaContract { public const TYPE = 'flare_events'; public const DATA_CONTAINER = 'tl_calendar_events'; @@ -52,14 +52,14 @@ public function buildTableRegistry(TableAliasRegistry $registry): void )); } - public function buildList(ListBuilder $builder): void + public function buildList(ListSpecBuilder $builder): void { if ($builder->hasFilterOfType(PublishedFilterElement::TYPE)) { return; } $builder->addFilter(new Filter( - element: PublishedFilterElement::TYPE, + type: PublishedFilterElement::TYPE, config: [ 'intrinsic' => true, 'published_field' => 'published', diff --git a/src/Integration/ContaoCalendar/Projector/EventsAggregationProjector.php b/src/Integration/ContaoCalendar/Projector/EventsAggregationProjector.php index b11cfb35..f509f25b 100644 --- a/src/Integration/ContaoCalendar/Projector/EventsAggregationProjector.php +++ b/src/Integration/ContaoCalendar/Projector/EventsAggregationProjector.php @@ -20,7 +20,7 @@ class EventsAggregationProjector extends AggregationProjector public function supports(ListSpec $list, ContextInterface $context): bool { - return $list->getTypeAlias() === EventsListType::TYPE && $context instanceof AggregationContext; + return $list->type === EventsListType::TYPE && $context instanceof AggregationContext; } public function priority(ListSpec $list, ContextInterface $context): int @@ -35,4 +35,4 @@ protected function createLoader(AggregationLoaderConfig $config): AggregationLoa listQueryDirector: $this->getListQueryDirector(), ); } -} \ No newline at end of file +} diff --git a/src/Integration/ContaoCalendar/Projector/EventsInteractiveProjector.php b/src/Integration/ContaoCalendar/Projector/EventsInteractiveProjector.php index d0247e68..bb0f145a 100644 --- a/src/Integration/ContaoCalendar/Projector/EventsInteractiveProjector.php +++ b/src/Integration/ContaoCalendar/Projector/EventsInteractiveProjector.php @@ -24,7 +24,7 @@ class EventsInteractiveProjector extends InteractiveProjector public function supports(ListSpec $list, ContextInterface $context): bool { - return $list->getTypeAlias() === EventsListType::TYPE && $context instanceof InteractiveContext; + return $list->type === EventsListType::TYPE && $context instanceof InteractiveContext; } public function priority(ListSpec $list, ContextInterface $context): int diff --git a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php index 3445ae87..ae6f72d4 100644 --- a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php +++ b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php @@ -133,7 +133,7 @@ public function listViewFetchCountEvent(FetchCountEvent $event): void // localized list view { $configuredFilter = new Filter( - element: SimpleEquationFilterElement::TYPE, + type: SimpleEquationFilterElement::TYPE, config: [ 'intrinsic' => true, 'left' => DcMultilingualHelper::getPidColumn($table), @@ -145,7 +145,7 @@ public function listViewFetchCountEvent(FetchCountEvent $event): void } $configuredFilter ??= new Filter( - element: SimpleEquationFilterElement::TYPE, + type: SimpleEquationFilterElement::TYPE, config: [ 'intrinsic' => true, 'left' => DcMultilingualHelper::getPidColumn($table), diff --git a/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php b/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php index 4d8c07dd..327b855a 100644 --- a/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php +++ b/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php @@ -10,11 +10,11 @@ use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\Contract\ListType\DataContainerContract; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListType; -use HeimrichHannot\FlareBundle\List\Type\AbstractListType; +use HeimrichHannot\FlareBundle\List\Type\AbstractListDriver; use HeimrichHannot\FlareBundle\Model\ListModel; #[AsListType(type: self::TYPE)] -class DcMultilingualListType extends AbstractListType implements DataContainerContract +class DcMultilingualListType extends AbstractListDriver implements DataContainerContract { public const TYPE = 'flare_generic_dc_multilingual'; public const DEFAULT_PALETTE = <<<'PALETTE' diff --git a/src/List/BaseListOptions.php b/src/List/BaseListOptions.php index d2330cda..7e93aff9 100644 --- a/src/List/BaseListOptions.php +++ b/src/List/BaseListOptions.php @@ -11,7 +11,7 @@ /** * Framework-owned base schema and translation for every list. Applied unconditionally by - * {@see Resolver\ListOptionsResolver} and {@see ListBuilder} before the list type's own + * {@see Resolver\ListOptionsResolver} and {@see ListSpecBuilder} before the list type's own * schema and transformers run, so framework consumers (page meta, comments, contexts) * can rely on these keys regardless of the type implementation. * diff --git a/src/List/Factory/ListBuilderFactory.php b/src/List/Factory/ListSpecBuilderFactory.php similarity index 67% rename from src/List/Factory/ListBuilderFactory.php rename to src/List/Factory/ListSpecBuilderFactory.php index 5acbfeed..03bb4bd1 100644 --- a/src/List/Factory/ListBuilderFactory.php +++ b/src/List/Factory/ListSpecBuilderFactory.php @@ -5,54 +5,49 @@ namespace HeimrichHannot\FlareBundle\List\Factory; use HeimrichHannot\FlareBundle\Filter\Collector\ListModelFilterCollector; -use HeimrichHannot\FlareBundle\List\ListBuilder; +use HeimrichHannot\FlareBundle\List\ListSpecBuilder; +use HeimrichHannot\FlareBundle\List\Resolver\ListDriverResolver; use HeimrichHannot\FlareBundle\List\Resolver\ListOptionsResolver; use HeimrichHannot\FlareBundle\List\Resolver\ListTransformerResolver; -use HeimrichHannot\FlareBundle\List\Type\ListTypeInterface; +use HeimrichHannot\FlareBundle\List\Type\ListDriverInterface; use HeimrichHannot\FlareBundle\Model\ListModel; -use HeimrichHannot\FlareBundle\Registry\ListTypeRegistry; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; /** * Creates ListBuilders — from a stored tl_flare_list model with its published filters * pre-added, or programmatically from a type and data container. */ -final readonly class ListBuilderFactory +final readonly class ListSpecBuilderFactory { public function __construct( private EventDispatcherInterface $eventDispatcher, private ListModelFilterCollector $filterCollector, private ListOptionsResolver $listOptionsResolver, private ListTransformerResolver $listTransformerResolver, - private ListTypeRegistry $listTypeRegistry, + private ListDriverResolver $listDriverResolver, ) {} public function create( - ListTypeInterface|string $type, - string $dc, - ?ListModel $model = null, - ?string $source = null, - ): ListBuilder { - $typeService = $type instanceof ListTypeInterface - ? $type - : $this->listTypeRegistry->get($type)?->getService(); - - return new ListBuilder( + ListDriverInterface|string $driver, + string $dc, + ?ListModel $model = null, + ?string $source = null, + ): ListSpecBuilder { + return new ListSpecBuilder( optionsResolver: $this->listOptionsResolver, transformerResolver: $this->listTransformerResolver, eventDispatcher: $this->eventDispatcher, - type: $type, - typeService: $typeService, + driverReference: $this->listDriverResolver->resolve($driver), dc: $dc, model: $model, source: $source, ); } - public function createFromListModel(ListModel $listModel): ListBuilder + public function createFromListModel(ListModel $listModel): ListSpecBuilder { $builder = $this->create( - type: (string) $listModel->type, + driver: (string) $listModel->type, dc: (string) $listModel->dc, model: $listModel, source: $listModel::getTable() . '.' . $listModel->id, diff --git a/src/List/Factory/ListSpecFactory.php b/src/List/Factory/ListSpecFactory.php new file mode 100644 index 00000000..c57f59d3 --- /dev/null +++ b/src/List/Factory/ListSpecFactory.php @@ -0,0 +1,36 @@ +listDriverResolver->resolve($driver), + dc: $dc, + filters: $filters, + config: $config, + source: $source, + ); + } +} diff --git a/src/List/ListDriverReference.php b/src/List/ListDriverReference.php new file mode 100644 index 00000000..bf32d971 --- /dev/null +++ b/src/List/ListDriverReference.php @@ -0,0 +1,15 @@ + $filters * @param array $config Canonical config, resolved through the base and type schemas. * @param string|null $source Provenance for error messages, e.g. "tl_flare_list.5". */ public function __construct( - public ListTypeInterface|string $type, - public string $dc, - public array $filters = [], - public array $config = [], - public ?string $source = null, - ) {} - - public function getTypeAlias(): ?string - { - return \is_string($this->type) ? $this->type : null; - } - - public function getTypeInstance(): ?ListTypeInterface - { - return $this->type instanceof ListTypeInterface ? $this->type : null; + public ListDriverReference $reference, + public string $dc, + public array $filters = [], + public array $config = [], + public ?string $source = null, + ) { + $this->type = $this->reference->type; + $this->driver = $this->reference->driver; } /** @@ -77,7 +73,7 @@ public function withoutFilter(string $key): self public function withFilters(array $filters): self { return new self( - type: $this->type, + reference: $this->reference, dc: $this->dc, filters: $filters, config: $this->config, @@ -91,7 +87,7 @@ public function withFilters(array $filters): self public function withConfig(array $config): self { return new self( - type: $this->type, + reference: $this->reference, dc: $this->dc, filters: $this->filters, config: $config, @@ -103,7 +99,7 @@ public function hasFilterOfType(string $elementType): bool { foreach ($this->filters as $filter) { - if ($filter->getElementType() === $elementType) { + if ($filter->type === $elementType) { return true; } } @@ -123,7 +119,8 @@ public function getAutoItemField(): string public function hash(): string { return \sha1(\serialize([ - $this->getTypeAlias() ?? $this->type::class, + \get_class($this->driver), + $this->type, $this->dc, $this->source, $this->config, diff --git a/src/List/ListBuilder.php b/src/List/ListSpecBuilder.php similarity index 75% rename from src/List/ListBuilder.php rename to src/List/ListSpecBuilder.php index 8e67d103..dc07d188 100644 --- a/src/List/ListBuilder.php +++ b/src/List/ListSpecBuilder.php @@ -11,7 +11,7 @@ use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\List\Resolver\ListOptionsResolver; use HeimrichHannot\FlareBundle\List\Resolver\ListTransformerResolver; -use HeimrichHannot\FlareBundle\List\Type\ListTypeInterface; +use HeimrichHannot\FlareBundle\List\Type\ListDriverInterface; use HeimrichHannot\FlareBundle\Model\ListModel; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -23,7 +23,7 @@ * base translation, the type's model transformers, and explicit {@see set()} overrides — * resolved through the base and type schemas. */ -final class ListBuilder implements ListBuilderInterface +final class ListSpecBuilder implements ListSpecBuilderInterface { /** * @var array @@ -41,26 +41,20 @@ public function __construct( private readonly ListOptionsResolver $optionsResolver, private readonly ListTransformerResolver $transformerResolver, private readonly EventDispatcherInterface $eventDispatcher, - private readonly ListTypeInterface|string $type, - private readonly ?object $typeService, + private readonly ListDriverReference $driverReference, private readonly string $dc, private readonly ?ListModel $model = null, private readonly ?string $source = null, ) {} - public function getType(): ListTypeInterface|string + public function getDriverReference(): ListDriverReference { - return $this->type; + return $this->driverReference; } - public function getTypeAlias(): ?string + public function getType(): string { - return \is_string($this->type) ? $this->type : null; - } - - public function getTypeService(): ?object - { - return $this->typeService; + return $this->driverReference->type; } public function getDc(): string @@ -123,7 +117,7 @@ public function hasFilterOfType(string $elementType): bool { foreach ($this->filters as $filter) { - if ($filter->getElementType() === $elementType) { + if ($filter->type === $elementType) { return true; } } @@ -136,8 +130,10 @@ public function hasFilterOfType(string $elementType): bool */ public function build(): ListSpec { - if ($this->typeService instanceof BuildListContract) { - $this->typeService->buildList($this); + $driver = $this->driverReference->driver; + + if ($driver instanceof BuildListContract) { + $driver->buildList($this); } $this->eventDispatcher->dispatch(new ListBuildEvent($this)); @@ -148,17 +144,14 @@ public function build(): ListSpec { BaseListOptions::transform($config, $this->model); - if ($this->typeService instanceof ListTypeInterface) - { - $transformed = $this->transformerResolver->transform( - $this->typeService, - $this->getTypeAlias(), - $this->model, - ); - - foreach ($transformed ?? [] as $key => $value) { - $config->set($key, $value); - } + $transformed = $this->transformerResolver->transform( + $driver, + $this->getType(), + $this->model, + ); + + foreach ($transformed ?? [] as $key => $value) { + $config->set($key, $value); } } @@ -167,10 +160,10 @@ public function build(): ListSpec } return new ListSpec( - type: $this->type, + reference: $this->driverReference, dc: $this->dc, filters: $this->filters, - config: $this->optionsResolver->resolve($this->typeService, $config->all(), $this->source), + config: $this->optionsResolver->resolve($driver, $config->all(), $this->source), source: $this->source, ); } diff --git a/src/List/ListBuilderInterface.php b/src/List/ListSpecBuilderInterface.php similarity index 74% rename from src/List/ListBuilderInterface.php rename to src/List/ListSpecBuilderInterface.php index 6503c784..391804c4 100644 --- a/src/List/ListBuilderInterface.php +++ b/src/List/ListSpecBuilderInterface.php @@ -5,16 +5,13 @@ namespace HeimrichHannot\FlareBundle\List; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\List\Type\ListTypeInterface; use HeimrichHannot\FlareBundle\Model\ListModel; -interface ListBuilderInterface +interface ListSpecBuilderInterface { - public function getType(): ListTypeInterface|string; + public function getDriverReference(): ListDriverReference; - public function getTypeAlias(): ?string; - - public function getTypeService(): ?object; + public function getType(): string; public function getDc(): string; diff --git a/src/List/Resolver/ListDriverResolver.php b/src/List/Resolver/ListDriverResolver.php new file mode 100644 index 00000000..622e9cb5 --- /dev/null +++ b/src/List/Resolver/ListDriverResolver.php @@ -0,0 +1,55 @@ +resolveInstance($driver); + } + + return $this->resolveType($driver); + } + + private function resolveInstance(ListDriverInterface $driver): ListDriverReference + { + return new ListDriverReference( + type: \get_class($driver), + driver: $driver, + ); + } + + /** + * @throws FlareException In case it's not possible to resolve the type of the list. + */ + private function resolveType(string $type): ListDriverReference + { + if (!$descriptor = $this->registry->get($type)) { + throw new FlareException(\sprintf( + 'List type "%s" not found', + $type, + )); + } + + return new ListDriverReference( + type: $type, + driver: $descriptor->getService(), + ); + } +} diff --git a/src/List/Resolver/ListOptionsResolver.php b/src/List/Resolver/ListOptionsResolver.php index ed8fcf77..3d8de4cd 100644 --- a/src/List/Resolver/ListOptionsResolver.php +++ b/src/List/Resolver/ListOptionsResolver.php @@ -8,6 +8,7 @@ use HeimrichHannot\FlareBundle\Contract\OptionsContract; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\List\BaseListOptions; +use HeimrichHannot\FlareBundle\List\Type\ListDriverInterface; use Symfony\Component\OptionsResolver\OptionsResolver; /** @@ -28,7 +29,7 @@ public function __construct( * * @throws FlareException If the config does not satisfy the schema. */ - public function resolve(?object $driverService, array $config, ?string $source = null): array + public function resolve(?ListDriverInterface $driverService, array $config, ?string $source = null): array { $configure = static function (OptionsResolver $resolver) use ($driverService): void { BaseListOptions::configureOptions($resolver); diff --git a/src/List/Resolver/ListTransformerResolver.php b/src/List/Resolver/ListTransformerResolver.php index 61b830e8..ba87a78f 100644 --- a/src/List/Resolver/ListTransformerResolver.php +++ b/src/List/Resolver/ListTransformerResolver.php @@ -8,7 +8,7 @@ use HeimrichHannot\FlareBundle\Config\TransformerResolver; use HeimrichHannot\FlareBundle\Contract\TransformerContract; use HeimrichHannot\FlareBundle\Event\ListTransformerEvent; -use HeimrichHannot\FlareBundle\List\Type\ListTypeInterface; +use HeimrichHannot\FlareBundle\List\Type\ListDriverInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; /** @@ -21,7 +21,7 @@ final class ListTransformerResolver /** * @var array */ - private array $builders = []; + private array $resolvers = []; public function __construct( private readonly EventDispatcherInterface $eventDispatcher, @@ -30,22 +30,22 @@ public function __construct( /** * @return array|null Canonical config values, or null when no transformer matches the source. */ - public function transform(ListTypeInterface $typeService, ?string $type, object $source): ?array + public function transform(ListDriverInterface $driver, ?string $type, object $source): ?array { - if (!isset($this->builders[$typeService::class])) + if (!isset($this->resolvers[$driver::class])) { - $transformers = new TransformerResolver(); + $resolver = new TransformerResolver(); - if ($typeService instanceof TransformerContract) { - $typeService->configureTransformers($transformers); + if ($driver instanceof TransformerContract) { + $driver->configureTransformers($resolver); } - $this->eventDispatcher->dispatch(new ListTransformerEvent($transformers, $typeService, $type)); + $this->eventDispatcher->dispatch(new ListTransformerEvent($resolver, $driver, $type)); - $this->builders[$typeService::class] = $transformers; + $this->resolvers[$driver::class] = $resolver; } - if (!$transformer = $this->builders[$typeService::class]->resolve($source)) { + if (!$transformer = $this->resolvers[$driver::class]->resolve($source)) { return null; } diff --git a/src/List/Type/AbstractListType.php b/src/List/Type/AbstractListDriver.php similarity index 92% rename from src/List/Type/AbstractListType.php rename to src/List/Type/AbstractListDriver.php index a7fe4fbe..11f8069d 100644 --- a/src/List/Type/AbstractListType.php +++ b/src/List/Type/AbstractListDriver.php @@ -15,8 +15,8 @@ use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; use Symfony\Component\OptionsResolver\OptionsResolver; -abstract class AbstractListType implements - ListTypeInterface, OptionsContract, TransformerContract, BuildQueryContract +abstract class AbstractListDriver implements + ListDriverInterface, OptionsContract, TransformerContract, BuildQueryContract { /** * Declares the type's config schema on top of {@see \HeimrichHannot\FlareBundle\List\BaseListOptions}. diff --git a/src/List/Type/GenericDataContainerListType.php b/src/List/Type/GenericDataContainerListDriver.php similarity index 96% rename from src/List/Type/GenericDataContainerListType.php rename to src/List/Type/GenericDataContainerListDriver.php index 5db176b5..4ff5686d 100644 --- a/src/List/Type/GenericDataContainerListType.php +++ b/src/List/Type/GenericDataContainerListDriver.php @@ -21,7 +21,7 @@ use Symfony\Contracts\Translation\TranslatorInterface; #[AsListType(type: self::TYPE)] -class GenericDataContainerListType extends AbstractListType implements DataContainerContract, DcaContract +class GenericDataContainerListDriver extends AbstractListDriver implements DataContainerContract, DcaContract { public const TYPE = 'flare_generic_dc'; public const DEFAULT_PALETTE = <<<'PALETTE' diff --git a/src/List/Type/ListTypeInterface.php b/src/List/Type/ListDriverInterface.php similarity index 84% rename from src/List/Type/ListTypeInterface.php rename to src/List/Type/ListDriverInterface.php index a6151165..8d56b015 100644 --- a/src/List/Type/ListTypeInterface.php +++ b/src/List/Type/ListDriverInterface.php @@ -7,4 +7,4 @@ /** * Marker for FLARE list types — registered via #[AsListType] or used inline on a ListSpec. */ -interface ListTypeInterface {} +interface ListDriverInterface {} diff --git a/src/List/Type/NewsListType.php b/src/List/Type/NewsListDriver.php similarity index 87% rename from src/List/Type/NewsListType.php rename to src/List/Type/NewsListDriver.php index fcb8bb99..7f06c36f 100644 --- a/src/List/Type/NewsListType.php +++ b/src/List/Type/NewsListDriver.php @@ -11,13 +11,13 @@ use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListType; use HeimrichHannot\FlareBundle\Filter\Element\PublishedFilterElement; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\List\ListBuilder; +use HeimrichHannot\FlareBundle\List\ListSpecBuilder; use HeimrichHannot\FlareBundle\Query\JoinTypeEnum; use HeimrichHannot\FlareBundle\Query\SqlJoinStruct; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; #[AsListType(type: self::TYPE, dataContainer: 'tl_news')] -class NewsListType extends AbstractListType implements BuildListContract, DcaContract +class NewsListDriver extends AbstractListDriver implements BuildListContract, DcaContract { public const TYPE = 'flare_news'; public const ALIAS_ARCHIVE = 'news_archive'; @@ -38,14 +38,14 @@ public function buildTableRegistry(TableAliasRegistry $registry): void )); } - public function buildList(ListBuilder $builder): void + public function buildList(ListSpecBuilder $builder): void { if ($builder->hasFilterOfType(PublishedFilterElement::TYPE)) { return; } $builder->addFilter(new Filter( - element: PublishedFilterElement::TYPE, + type: PublishedFilterElement::TYPE, config: [ 'intrinsic' => true, 'published_field' => 'published', diff --git a/src/Query/Executor/FilterExecutor.php b/src/Query/Executor/FilterExecutor.php index a32ccd5a..959a0825 100644 --- a/src/Query/Executor/FilterExecutor.php +++ b/src/Query/Executor/FilterExecutor.php @@ -91,7 +91,7 @@ public function invokeFilter(Filter $filter, FilterContext $context, array $data return []; } - $descriptor = ($type = $filter->getElementType()) ? $this->filterElementRegistry->get($type) : null; + $descriptor = $this->filterElementRegistry->get($filter->type); $targetAlias = TableAliasRegistry::ALIAS_MAIN; if ($descriptor?->isTargeted() || $filter->targetingForced) { diff --git a/src/Query/Factory/ListExecutionContextFactory.php b/src/Query/Factory/ListExecutionContextFactory.php index d52f2bb1..35a86de7 100644 --- a/src/Query/Factory/ListExecutionContextFactory.php +++ b/src/Query/Factory/ListExecutionContextFactory.php @@ -12,13 +12,13 @@ use HeimrichHannot\FlareBundle\Query\SqlQueryStruct; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; use HeimrichHannot\FlareBundle\Registry\Descriptor\ListTypeDescriptor; -use HeimrichHannot\FlareBundle\Registry\ListTypeRegistry; +use HeimrichHannot\FlareBundle\Registry\ListDriverRegistry; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; readonly class ListExecutionContextFactory { public function __construct( - private ListTypeRegistry $listTypeRegistry, + private ListDriverRegistry $listTypeRegistry, private EventDispatcherInterface $eventDispatcher, ) {} @@ -27,24 +27,20 @@ public function __construct( */ public function create(ListSpec $list): ListExecutionContext { - $listTypeDescriptor = null; - $listType = $list->getTypeInstance(); + $driver = $list->driver; - if (!$listType) + if (!$mainTable = $list->dc) { - $listTypeDescriptor = $this->listTypeRegistry->get($list->getTypeAlias()); - if (!$listTypeDescriptor instanceof ListTypeDescriptor) { + $listTypeDescriptor = $this->listTypeRegistry->get($list->type); + + if (!$listTypeDescriptor instanceof ListTypeDescriptor + || !$mainTable = $listTypeDescriptor->getDataContainer()) + { throw new FlareException( - \sprintf('No list type registered for type "%s".', $list->getTypeAlias() ?? ''), + \sprintf('Failed to evaluate data container table of list "%s".', $list->type), method: __METHOD__, ); } - - $listType = $listTypeDescriptor->getService(); - } - - if (!$mainTable = $list->dc ?: $listTypeDescriptor?->getDataContainer()) { - throw new FlareException('No data container table set.', method: __METHOD__); } $registry = new TableAliasRegistry(); @@ -56,9 +52,9 @@ public function create(ListSpec $list): ListExecutionContext ->setSelect([TableAliasRegistry::ALIAS_MAIN . '.*']) ->setGroupBy([TableAliasRegistry::ALIAS_MAIN . '.id']); - if ($listType instanceof BuildQueryContract) { - $listType->buildTableRegistry($registry); - $listType->buildBaseQuery($struct); + if ($driver instanceof BuildQueryContract) { + $driver->buildTableRegistry($registry); + $driver->buildBaseQuery($struct); } $this->eventDispatcher->dispatch(new QueryBaseInitializedEvent( diff --git a/src/Reader/Factory/ReaderRequestAttributeFactory.php b/src/Reader/Factory/ReaderRequestAttributeFactory.php index f5dd40b1..5bab858e 100644 --- a/src/Reader/Factory/ReaderRequestAttributeFactory.php +++ b/src/Reader/Factory/ReaderRequestAttributeFactory.php @@ -5,14 +5,14 @@ namespace HeimrichHannot\FlareBundle\Reader\Factory; use Contao\Model; -use HeimrichHannot\FlareBundle\List\Factory\ListBuilderFactory; +use HeimrichHannot\FlareBundle\List\Factory\ListSpecBuilderFactory; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Reader\ReaderRequestAttribute; final readonly class ReaderRequestAttributeFactory { public function __construct( - private ListBuilderFactory $listFactory, + private ListSpecBuilderFactory $listFactory, ) {} public function createFromData(array $data): ?ReaderRequestAttribute diff --git a/src/Registry/Descriptor/ListTypeDescriptor.php b/src/Registry/Descriptor/ListTypeDescriptor.php index 737a1555..bf63a68c 100644 --- a/src/Registry/Descriptor/ListTypeDescriptor.php +++ b/src/Registry/Descriptor/ListTypeDescriptor.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\Registry\Descriptor; use HeimrichHannot\FlareBundle\DependencyInjection\Registry\ServiceDescriptorInterface; -use HeimrichHannot\FlareBundle\List\Type\AbstractListType; +use HeimrichHannot\FlareBundle\List\Type\AbstractListDriver; class ListTypeDescriptor implements ServiceDescriptorInterface { @@ -17,7 +17,7 @@ public function __construct( /** * @noinspection PhpDocSignatureInspection - * @return AbstractListType|object + * @return AbstractListDriver|object */ public function getService(): object { diff --git a/src/Registry/ListTypeRegistry.php b/src/Registry/ListDriverRegistry.php similarity index 91% rename from src/Registry/ListTypeRegistry.php rename to src/Registry/ListDriverRegistry.php index e4a6de0f..7b9337f9 100644 --- a/src/Registry/ListTypeRegistry.php +++ b/src/Registry/ListDriverRegistry.php @@ -12,7 +12,7 @@ * * @template TDescriptor of ListTypeDescriptor */ -class ListTypeRegistry extends AbstractServiceDescriptorRegistry +class ListDriverRegistry extends AbstractServiceDescriptorRegistry { public function getDescriptorClass(): string { @@ -29,4 +29,4 @@ public function get(?string $alias): ?ListTypeDescriptor return $descriptor; } -} \ No newline at end of file +} diff --git a/tests/Engine/Projector/InteractiveProjectorTest.php b/tests/Engine/Projector/InteractiveProjectorTest.php index 3e652403..b0e0f12c 100644 --- a/tests/Engine/Projector/InteractiveProjectorTest.php +++ b/tests/Engine/Projector/InteractiveProjectorTest.php @@ -50,7 +50,7 @@ private function addFlatChild(FormBuilderInterface $root, string $alias, array $ private function listWithFilter(string $key, string $alias): ListSpec { return new ListSpec(type: 'test', dc: 'tl_test', filters: [ - $key => new Filter(element: 'test_element', alias: $alias), + $key => new Filter(type: 'test_element', alias: $alias), ]); } diff --git a/tests/Filter/FilterOptionsResolverTest.php b/tests/Filter/FilterOptionsResolverTest.php index d005bf5b..78c57afa 100644 --- a/tests/Filter/FilterOptionsResolverTest.php +++ b/tests/Filter/FilterOptionsResolverTest.php @@ -23,7 +23,7 @@ public function testResolvesOptionsThroughElementSchema(): void $resolver = new FilterOptionsResolver(new SchemaResolver()); $element = new ElementConfigAwareElement(); - $config = $resolver->resolve(new Filter(element: 'test', config: ['field' => 'title']), $element); + $config = $resolver->resolve(new Filter(type: 'test', config: ['field' => 'title']), $element); self::assertSame('title', $config['field']); self::assertFalse($config['intrinsic']); @@ -36,14 +36,14 @@ public function testReturnsOptionsVerbatimWithoutOptionsContract(): void $config = ['anything' => 'goes', 'unvalidated' => true]; - self::assertSame($config, $resolver->resolve(new Filter(element: 'test', config: $config), $element)); + self::assertSame($config, $resolver->resolve(new Filter(type: 'test', config: $config), $element)); } public function testWrapsSchemaViolationsInFilterException(): void { $resolver = new FilterOptionsResolver(new SchemaResolver()); $element = new ElementConfigAwareElement(); - $filter = new Filter(element: 'test', config: ['unknown_key' => 1], source: 'tl_flare_filter.42'); + $filter = new Filter(type: 'test', config: ['unknown_key' => 1], source: 'tl_flare_filter.42'); try { diff --git a/tests/Filter/FilterTest.php b/tests/Filter/FilterTest.php index 835c8e0b..adee04af 100644 --- a/tests/Filter/FilterTest.php +++ b/tests/Filter/FilterTest.php @@ -13,23 +13,9 @@ final class FilterTest extends TestCase { - public function testElementUnionAccessors(): void - { - $typed = new Filter(element: 'flare_bool'); - - self::assertSame('flare_bool', $typed->getElementType()); - self::assertNull($typed->getElementInstance()); - - $instance = $this->createInlineElement(); - $inline = new Filter(element: $instance); - - self::assertNull($inline->getElementType()); - self::assertSame($instance, $inline->getElementInstance()); - } - public function testWithersPreserveOtherFields(): void { - $filter = new Filter(element: 'test', config: ['a' => 1], alias: 'foo', source: 'tl_flare_filter.1'); + $filter = new Filter(type: 'test', config: ['a' => 1], alias: 'foo', source: 'tl_flare_filter.1'); $withData = $filter->withData(['value' => 42]); @@ -49,7 +35,7 @@ public function testWithersPreserveOtherFields(): void public function testFingerprintRepresentsInlineElementsByClass(): void { $instance = $this->createInlineElement(); - $filter = new Filter(element: $instance); + $filter = new Filter(type: $instance); self::assertSame($instance::class, $filter->fingerprint()['element']); } diff --git a/tests/Form/FilterFormFactoryTest.php b/tests/Form/FilterFormFactoryTest.php index 9e3262ad..df9e110b 100644 --- a/tests/Form/FilterFormFactoryTest.php +++ b/tests/Form/FilterFormFactoryTest.php @@ -110,7 +110,7 @@ public function testSingleFieldMountsFlatUnderTheAlias(): void $builder->addEventListener(FormEvents::POST_SUBMIT, static function (): void {}); }); - $form = $this->createForm(['suche' => new Filter(element: $element, alias: 'suche')]); + $form = $this->createForm(['suche' => new Filter(type: $element, alias: 'suche')]); $this->assertTrue($form->has('suche')); @@ -133,7 +133,7 @@ public function testSingleWithCompanionFieldMountsNestedCompound(): void $builder->add('extra', TextType::class, ['required' => false]); }); - $form = $this->createForm(['suche' => new Filter(element: $element, alias: 'suche')]); + $form = $this->createForm(['suche' => new Filter(type: $element, alias: 'suche')]); $child = $form->get('suche'); @@ -151,7 +151,7 @@ public function testMultiFieldElementMountsNestedCompound(): void $builder->addEventListener(FormEvents::POST_SUBMIT, static function (): void {}); }); - $form = $this->createForm(['range' => new Filter(element: $element, alias: 'range')]); + $form = $this->createForm(['range' => new Filter(type: $element, alias: 'range')]); $child = $form->get('range'); @@ -168,7 +168,7 @@ public function testElementWithoutFieldsIsNotMounted(): void { $element = $this->element(static function (): void {}); - $form = $this->createForm(['empty' => new Filter(element: $element, alias: 'empty')]); + $form = $this->createForm(['empty' => new Filter(type: $element, alias: 'empty')]); $this->assertFalse($form->has('empty')); } @@ -179,7 +179,7 @@ public function testInvalidAliasIsSkipped(): void $builder->single(TextType::class); }); - $form = $this->createForm(['x' => new Filter(element: $element, alias: '_.tl_flare_filter.1')]); + $form = $this->createForm(['x' => new Filter(type: $element, alias: '_.tl_flare_filter.1')]); $this->assertSame(0, \count($form)); } @@ -195,7 +195,7 @@ public function testCancelledEventPreventsMounting(): void $builder->single(TextType::class); }); - $form = $this->createForm(['suche' => new Filter(element: $element, alias: 'suche')]); + $form = $this->createForm(['suche' => new Filter(type: $element, alias: 'suche')]); $this->assertFalse($form->has('suche')); } diff --git a/tests/List/ListBuilderTest.php b/tests/List/ListBuilderTest.php index 943c0d20..cf2e71ca 100644 --- a/tests/List/ListBuilderTest.php +++ b/tests/List/ListBuilderTest.php @@ -10,10 +10,10 @@ use HeimrichHannot\FlareBundle\Event\ListBuildEvent; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\List\ListBuilder; +use HeimrichHannot\FlareBundle\List\ListSpecBuilder; use HeimrichHannot\FlareBundle\List\Resolver\ListOptionsResolver; use HeimrichHannot\FlareBundle\List\Resolver\ListTransformerResolver; -use HeimrichHannot\FlareBundle\List\Type\AbstractListType; +use HeimrichHannot\FlareBundle\List\Type\AbstractListDriver; use HeimrichHannot\FlareBundle\Model\ListModel; use PHPUnit\Framework\TestCase; use Symfony\Component\EventDispatcher\EventDispatcher; @@ -27,16 +27,16 @@ public function testBuildInvokesTypeHookAndDispatchesEvent(): void $dispatcher = new EventDispatcher(); $dispatcher->addListener(ListBuildEvent::class, static function (ListBuildEvent $event) use (&$dispatchedWith): void { $dispatchedWith = $event->builder; - $event->builder->addFilter(new Filter(element: 'from_event', alias: 'via_event')); + $event->builder->addFilter(new Filter(type: 'from_event', alias: 'via_event')); }); - $type = new class extends AbstractListType implements BuildListContract { + $type = new class extends AbstractListDriver implements BuildListContract { public int $buildListCalls = 0; - public function buildList(ListBuilder $builder): void + public function buildList(ListSpecBuilder $builder): void { $this->buildListCalls++; - $builder->addFilter(new Filter(element: 'from_hook', alias: 'via_hook')); + $builder->addFilter(new Filter(type: 'from_hook', alias: 'via_hook')); } }; @@ -53,8 +53,8 @@ public function testFiltersAndTypeCarryOverToTheSpec(): void { $builder = $this->createBuilder(new EventDispatcher()); - $builder->addFilter(new Filter(element: 'a', alias: 'x')); - $builder->addFilter(new Filter(element: 'b')); + $builder->addFilter(new Filter(type: 'a', alias: 'x')); + $builder->addFilter(new Filter(type: 'b')); $builder->removeFilter('x'); self::assertTrue($builder->hasFilterOfType('b')); @@ -71,7 +71,7 @@ public function testFiltersAndTypeCarryOverToTheSpec(): void public function testModelTransformationAndOverridePrecedence(): void { - $type = new class extends AbstractListType { + $type = new class extends AbstractListDriver { protected function transformListModel(ConfigBuilder $config, ListModel $model): void { $config->set('genericPageMeta', true); @@ -114,13 +114,13 @@ private function createBuilder( EventDispatcher $dispatcher, ?object $typeService = null, ?ListModel $model = null, - ): ListBuilder { - return new ListBuilder( + ): ListSpecBuilder { + return new ListSpecBuilder( optionsResolver: new ListOptionsResolver(new SchemaResolver()), transformerResolver: new ListTransformerResolver($dispatcher), eventDispatcher: $dispatcher, type: 'test_type', - typeService: $typeService, + driverService: $typeService, dc: 'tl_test', model: $model, source: 'tl_flare_list.9', diff --git a/tests/List/ListSpecTest.php b/tests/List/ListSpecTest.php index fe47bf26..e72d3882 100644 --- a/tests/List/ListSpecTest.php +++ b/tests/List/ListSpecTest.php @@ -14,7 +14,7 @@ public function testWithFilterKeysByAliasByDefault(): void { $spec = new ListSpec(type: 'test', dc: 'tl_test'); - $spec = $spec->withFilter(new Filter(element: 'flare_bool', alias: 'foo')); + $spec = $spec->withFilter(new Filter(type: 'flare_bool', alias: 'foo')); self::assertArrayHasKey('foo', $spec->filters); } @@ -22,7 +22,7 @@ public function testWithFilterKeysByAliasByDefault(): void public function testWithFilterAcceptsExplicitKey(): void { $spec = (new ListSpec(type: 'test', dc: 'tl_test')) - ->withFilter(new Filter(element: 'flare_bool', alias: 'foo'), 'custom'); + ->withFilter(new Filter(type: 'flare_bool', alias: 'foo'), 'custom'); self::assertArrayHasKey('custom', $spec->filters); self::assertArrayNotHasKey('foo', $spec->filters); @@ -31,16 +31,16 @@ public function testWithFilterAcceptsExplicitKey(): void public function testWithFilterGeneratesCollisionFreeKeysForAliasLessFilters(): void { $spec = (new ListSpec(type: 'test', dc: 'tl_test')) - ->withFilter(new Filter(element: 'a')) - ->withFilter(new Filter(element: 'b')); + ->withFilter(new Filter(type: 'a')) + ->withFilter(new Filter(type: 'b')); self::assertArrayHasKey('_generated_0', $spec->filters); self::assertArrayHasKey('_generated_1', $spec->filters); - $spec = $spec->withoutFilter('_generated_0')->withFilter(new Filter(element: 'c')); + $spec = $spec->withoutFilter('_generated_0')->withFilter(new Filter(type: 'c')); - self::assertSame('c', $spec->filters['_generated_0']->element); - self::assertSame('b', $spec->filters['_generated_1']->element); + self::assertSame('c', $spec->filters['_generated_0']->type); + self::assertSame('b', $spec->filters['_generated_1']->type); } public function testModifiersAreImmutable(): void @@ -48,7 +48,7 @@ public function testModifiersAreImmutable(): void $original = new ListSpec(type: 'test', dc: 'tl_test', config: ['id' => 1]); $modified = $original - ->withFilter(new Filter(element: 'a', alias: 'x')) + ->withFilter(new Filter(type: 'a', alias: 'x')) ->withConfig(['id' => 2]); self::assertSame([], $original->filters); @@ -61,7 +61,7 @@ public function testModifiersAreImmutable(): void public function testHasFilterOfType(): void { $spec = (new ListSpec(type: 'test', dc: 'tl_test')) - ->withFilter(new Filter(element: 'flare_published', alias: 'p')); + ->withFilter(new Filter(type: 'flare_published', alias: 'p')); self::assertTrue($spec->hasFilterOfType('flare_published')); self::assertFalse($spec->hasFilterOfType('flare_bool')); @@ -77,7 +77,7 @@ public function testHashIsStableAndChangesWithContent(): void self::assertNotSame($make()->hash(), $make(source: 'tl_flare_list.5')->hash()); self::assertNotSame( $make()->hash(), - $make()->withFilter(new Filter(element: 'a', alias: 'x'))->hash(), + $make()->withFilter(new Filter(type: 'a', alias: 'x'))->hash(), ); } } diff --git a/tests/List/ListTransformerResolverTest.php b/tests/List/ListTransformerResolverTest.php index ba170627..2f80d641 100644 --- a/tests/List/ListTransformerResolverTest.php +++ b/tests/List/ListTransformerResolverTest.php @@ -9,7 +9,7 @@ use HeimrichHannot\FlareBundle\Contract\TransformerContract; use HeimrichHannot\FlareBundle\Event\ListTransformerEvent; use HeimrichHannot\FlareBundle\List\Resolver\ListTransformerResolver; -use HeimrichHannot\FlareBundle\List\Type\ListTypeInterface; +use HeimrichHannot\FlareBundle\List\Type\ListDriverInterface; use PHPUnit\Framework\TestCase; use Symfony\Component\EventDispatcher\EventDispatcher; @@ -85,7 +85,7 @@ public function __construct( ) {} } -final class TransformingDriver implements ListTypeInterface, TransformerContract +final class TransformingDriver implements ListDriverInterface, TransformerContract { public int $configureCalls = 0; @@ -99,6 +99,6 @@ public function configureTransformers(TransformerResolver $resolver): void } } -final class TransformerlessDriver implements ListTypeInterface +final class TransformerlessDriver implements ListDriverInterface { } From 94a2717a6fa08f1959f4f003dfa270335916e126 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 17:31:27 +0200 Subject: [PATCH 43/71] refactor: rename `ListType` to `ListDriver` in translation files --- translations/flare_list.de.php | 4 ++-- translations/flare_list.en.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/translations/flare_list.de.php b/translations/flare_list.de.php index 6d7176f0..f5d81a4c 100644 --- a/translations/flare_list.de.php +++ b/translations/flare_list.de.php @@ -4,8 +4,8 @@ use HeimrichHannot\FlareBundle\Integration\ContaoCalendar\ListType\EventsListType; return [ - Type\GenericDataContainerListType::TYPE => 'Data-Container', - Type\NewsListType::TYPE => 'Nachrichten', + Type\GenericDataContainerListDriver::TYPE => 'Data-Container', + Type\NewsListDriver::TYPE => 'Nachrichten', EventsListType::TYPE => 'Events', ]; diff --git a/translations/flare_list.en.php b/translations/flare_list.en.php index 0e09f98e..ff88dbd3 100644 --- a/translations/flare_list.en.php +++ b/translations/flare_list.en.php @@ -4,8 +4,8 @@ use HeimrichHannot\FlareBundle\Integration\ContaoCalendar\ListType\EventsListType; return [ - Type\GenericDataContainerListType::TYPE => 'Data Container', - Type\NewsListType::TYPE => 'News', + Type\GenericDataContainerListDriver::TYPE => 'Data Container', + Type\NewsListDriver::TYPE => 'News', EventsListType::TYPE => 'Events', ]; From 5a2e77a1e03f950fd4141dc423fa36d0788d2667 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 17:50:27 +0200 Subject: [PATCH 44/71] refactor: fix usages missed in the ListDriver/ListSpecBuilder rename, adapt tests Missed usages: `ChangelanguageListener` still called the removed `ListSpec::getTypeAlias()`; `ListSpecFactory` accepted a nullable `$dc` that `ListSpec` rejects; `ListTransformerEvent::$typeService` renamed to `$driver`; stale inline-element wording in `Filter`/`FilterElementResolver` docblocks and the `fingerprint()` key. Tests adapted to the new APIs: `ListDriverReference` construction, string-only `Filter::$type` (form-factory elements now register in the `FilterElementRegistry`), `ListBuilderTest` renamed to `ListSpecBuilderTest`. --- src/Event/ListTransformerEvent.php | 2 +- src/Filter/Filter.php | 7 +++-- src/Filter/Resolver/FilterElementResolver.php | 6 ++--- .../EventListener/ChangelanguageListener.php | 2 +- src/List/Factory/ListSpecBuilderFactory.php | 4 +-- src/List/Factory/ListSpecFactory.php | 2 +- .../Projector/InteractiveProjectorTest.php | 6 ++++- tests/Filter/FilterTest.php | 27 +++++-------------- tests/Form/FilterFormFactoryTest.php | 26 +++++++++++++++--- ...uilderTest.php => ListSpecBuilderTest.php} | 20 ++++++++------ tests/List/ListSpecTest.php | 22 ++++++++++----- tests/List/ListTransformerResolverTest.php | 2 +- 12 files changed, 74 insertions(+), 52 deletions(-) rename tests/List/{ListBuilderTest.php => ListSpecBuilderTest.php} (88%) diff --git a/src/Event/ListTransformerEvent.php b/src/Event/ListTransformerEvent.php index bbefd5d3..0f9e4e88 100644 --- a/src/Event/ListTransformerEvent.php +++ b/src/Event/ListTransformerEvent.php @@ -17,7 +17,7 @@ class ListTransformerEvent extends Event { public function __construct( public readonly TransformerResolver $transformers, - public readonly ListDriverInterface $typeService, + public readonly ListDriverInterface $driver, public readonly ?string $type, ) {} } diff --git a/src/Filter/Filter.php b/src/Filter/Filter.php index 2ab2ee32..0a6c9a91 100644 --- a/src/Filter/Filter.php +++ b/src/Filter/Filter.php @@ -7,7 +7,7 @@ /** * Immutable runtime representation of a single filter within a list. * - * Pairs a filter element (registered type string or inline instance) with its canonical, + * Pairs a filter element (referenced by its registered type alias) with its canonical, * element-defined configuration. Contains no DCA/storage specifics — translating a stored * source into config is the element's transformer responsibility * ({@see \HeimrichHannot\FlareBundle\Contract\TransformerContract}). @@ -108,13 +108,12 @@ public function withSource(?string $source): self } /** - * Stable representation for hashing/caching. Inline elements are represented by their - * class name, which makes hashes of anonymous elements request-local. + * Stable representation for hashing/caching. */ public function fingerprint(): array { return [ - 'element' => $this->type, + 'type' => $this->type, 'config' => $this->config, 'data' => $this->data, 'alias' => $this->alias, diff --git a/src/Filter/Resolver/FilterElementResolver.php b/src/Filter/Resolver/FilterElementResolver.php index 1f827f64..c0777550 100644 --- a/src/Filter/Resolver/FilterElementResolver.php +++ b/src/Filter/Resolver/FilterElementResolver.php @@ -10,8 +10,8 @@ use Psr\Log\LoggerInterface; /** - * Resolves the filter element responsible for a filter: an inline instance wins, - * otherwise the element is looked up in the registry by its type alias. + * Resolves the filter element responsible for a filter by looking up its type alias + * in the registry. */ final readonly class FilterElementResolver { @@ -34,7 +34,7 @@ public function resolveType(?string $type, ?string $source = null): ?FilterEleme $this->logger->warning(\sprintf( '[FLARE] No filter element registered for type "%s" — filter skipped. (%s)', $type, - $source ?: 'filter inlined', + $source ?: 'no source', )); return null; diff --git a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php index ae6f72d4..7fd80e52 100644 --- a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php +++ b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php @@ -55,7 +55,7 @@ public function fetchAutoItem(FetchAutoItemEvent $event): void { $list = $event->getList(); - if ($list->getTypeAlias() !== DcMultilingualListType::TYPE) { + if ($list->type !== DcMultilingualListType::TYPE) { return; } diff --git a/src/List/Factory/ListSpecBuilderFactory.php b/src/List/Factory/ListSpecBuilderFactory.php index 03bb4bd1..2ef89683 100644 --- a/src/List/Factory/ListSpecBuilderFactory.php +++ b/src/List/Factory/ListSpecBuilderFactory.php @@ -14,8 +14,8 @@ use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; /** - * Creates ListBuilders — from a stored tl_flare_list model with its published filters - * pre-added, or programmatically from a type and data container. + * Creates ListSpecBuilders — from a stored tl_flare_list model with its published filters + * pre-added, or programmatically from a driver and data container. */ final readonly class ListSpecBuilderFactory { diff --git a/src/List/Factory/ListSpecFactory.php b/src/List/Factory/ListSpecFactory.php index c57f59d3..313256c1 100644 --- a/src/List/Factory/ListSpecFactory.php +++ b/src/List/Factory/ListSpecFactory.php @@ -20,7 +20,7 @@ public function __construct( */ public function create( ListDriverInterface|string $driver, - ?string $dc = null, + string $dc, array $filters = [], array $config = [], ?string $source = null, diff --git a/tests/Engine/Projector/InteractiveProjectorTest.php b/tests/Engine/Projector/InteractiveProjectorTest.php index b0e0f12c..8236b228 100644 --- a/tests/Engine/Projector/InteractiveProjectorTest.php +++ b/tests/Engine/Projector/InteractiveProjectorTest.php @@ -7,7 +7,9 @@ use HeimrichHannot\FlareBundle\Engine\Projector\InteractiveProjector; use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\List\ListDriverReference; use HeimrichHannot\FlareBundle\List\ListSpec; +use HeimrichHannot\FlareBundle\List\Type\ListDriverInterface; use PHPUnit\Framework\TestCase; use Symfony\Component\Form\Extension\Core\Type\FormType; use Symfony\Component\Form\Extension\Core\Type\TextType; @@ -49,7 +51,9 @@ private function addFlatChild(FormBuilderInterface $root, string $alias, array $ private function listWithFilter(string $key, string $alias): ListSpec { - return new ListSpec(type: 'test', dc: 'tl_test', filters: [ + $reference = new ListDriverReference(type: 'test', driver: new class implements ListDriverInterface {}); + + return new ListSpec(reference: $reference, dc: 'tl_test', filters: [ $key => new Filter(type: 'test_element', alias: $alias), ]); } diff --git a/tests/Filter/FilterTest.php b/tests/Filter/FilterTest.php index adee04af..69b70d72 100644 --- a/tests/Filter/FilterTest.php +++ b/tests/Filter/FilterTest.php @@ -4,12 +4,8 @@ namespace HeimrichHannot\FlareBundle\Tests\Filter; -use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\FilterContext; use PHPUnit\Framework\TestCase; -use Symfony\Component\Form\FormBuilderInterface; final class FilterTest extends TestCase { @@ -32,24 +28,15 @@ public function testWithersPreserveOtherFields(): void self::assertFalse($filter->targetingForced); } - public function testFingerprintRepresentsInlineElementsByClass(): void + public function testFingerprintReflectsIdentityAndContent(): void { - $instance = $this->createInlineElement(); - $filter = new Filter(type: $instance); + $filter = new Filter(type: 'test', config: ['a' => 1], alias: 'foo'); - self::assertSame($instance::class, $filter->fingerprint()['element']); - } + $fingerprint = $filter->fingerprint(); - private function createInlineElement(): FilterElementInterface - { - return new class implements FilterElementInterface { - public function buildForm(FormBuilderInterface $builder, FilterContext $context): void - { - } - - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void - { - } - }; + self::assertSame('test', $fingerprint['type']); + self::assertSame(['a' => 1], $fingerprint['config']); + self::assertSame('foo', $fingerprint['alias']); + self::assertNotSame($fingerprint, $filter->withConfig(['a' => 2])->fingerprint()); } } diff --git a/tests/Form/FilterFormFactoryTest.php b/tests/Form/FilterFormFactoryTest.php index df9e110b..a3589f56 100644 --- a/tests/Form/FilterFormFactoryTest.php +++ b/tests/Form/FilterFormFactoryTest.php @@ -17,7 +17,10 @@ use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterElementResolver; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterOptionsResolver; +use HeimrichHannot\FlareBundle\List\ListDriverReference; use HeimrichHannot\FlareBundle\List\ListSpec; +use HeimrichHannot\FlareBundle\List\Type\ListDriverInterface; +use HeimrichHannot\FlareBundle\Registry\Descriptor\FilterElementDescriptor; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; @@ -33,10 +36,14 @@ final class FilterFormFactoryTest extends TestCase { private EventDispatcher $eventDispatcher; + private FilterElementRegistry $elementRegistry; + private int $elementCount = 0; protected function setUp(): void { $this->eventDispatcher = new EventDispatcher(); + $this->elementRegistry = new FilterElementRegistry(); + $this->elementCount = 0; } private function createFactory(): FilterFormFactory @@ -50,14 +57,18 @@ private function createFactory(): FilterFormFactory return new FilterFormFactory( eventDispatcher: $this->eventDispatcher, filterContextFactory: new FilterContextFactory(new FilterOptionsResolver(new SchemaResolver())), - filterElementResolver: new FilterElementResolver(new FilterElementRegistry(), new NullLogger()), + filterElementResolver: new FilterElementResolver($this->elementRegistry, new NullLogger()), formFactory: $formFactory, ); } private function createForm(array $filters): FormInterface { - $list = new ListSpec(type: 'test', dc: 'tl_test', filters: $filters); + $list = new ListSpec( + reference: new ListDriverReference(type: 'test', driver: new class implements ListDriverInterface {}), + dc: 'tl_test', + filters: $filters, + ); $context = new class implements ContextInterface, FormContextInterface { public static function getContextType(): string @@ -80,11 +91,13 @@ public function getFormActionPage(): int } /** + * Registers an element building its form via the given callable; returns its type alias. + * * @param callable(FilterFormBuilderInterface, FilterContext): void $buildForm */ - private function element(callable $buildForm): FilterElementInterface + private function element(callable $buildForm): string { - return new class($buildForm) implements FilterElementInterface { + $element = new class($buildForm) implements FilterElementInterface { /** @var callable */ private $buildForm; @@ -100,6 +113,11 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void {} }; + + $type = 'element_' . ++$this->elementCount; + $this->elementRegistry->add($type, new FilterElementDescriptor($element)); + + return $type; } public function testSingleFieldMountsFlatUnderTheAlias(): void diff --git a/tests/List/ListBuilderTest.php b/tests/List/ListSpecBuilderTest.php similarity index 88% rename from tests/List/ListBuilderTest.php rename to tests/List/ListSpecBuilderTest.php index cf2e71ca..b0490a85 100644 --- a/tests/List/ListBuilderTest.php +++ b/tests/List/ListSpecBuilderTest.php @@ -10,15 +10,17 @@ use HeimrichHannot\FlareBundle\Event\ListBuildEvent; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\Filter; +use HeimrichHannot\FlareBundle\List\ListDriverReference; use HeimrichHannot\FlareBundle\List\ListSpecBuilder; use HeimrichHannot\FlareBundle\List\Resolver\ListOptionsResolver; use HeimrichHannot\FlareBundle\List\Resolver\ListTransformerResolver; use HeimrichHannot\FlareBundle\List\Type\AbstractListDriver; +use HeimrichHannot\FlareBundle\List\Type\ListDriverInterface; use HeimrichHannot\FlareBundle\Model\ListModel; use PHPUnit\Framework\TestCase; use Symfony\Component\EventDispatcher\EventDispatcher; -final class ListBuilderTest extends TestCase +final class ListSpecBuilderTest extends TestCase { public function testBuildInvokesTypeHookAndDispatchesEvent(): void { @@ -40,7 +42,7 @@ public function buildList(ListSpecBuilder $builder): void } }; - $builder = $this->createBuilder($dispatcher, typeService: $type); + $builder = $this->createBuilder($dispatcher, driver: $type); $spec = $builder->build(); self::assertSame(1, $type->buildListCalls); @@ -81,7 +83,7 @@ protected function transformListModel(ConfigBuilder $config, ListModel $model): $builder = $this->createBuilder( new EventDispatcher(), - typeService: $type, + driver: $type, model: new ListModelStub(['id' => '9', 'title' => 'from-model']), ); @@ -111,16 +113,18 @@ public function testInvalidConfigThrowsWithSourceProvenance(): void } private function createBuilder( - EventDispatcher $dispatcher, - ?object $typeService = null, - ?ListModel $model = null, + EventDispatcher $dispatcher, + ?ListDriverInterface $driver = null, + ?ListModel $model = null, ): ListSpecBuilder { return new ListSpecBuilder( optionsResolver: new ListOptionsResolver(new SchemaResolver()), transformerResolver: new ListTransformerResolver($dispatcher), eventDispatcher: $dispatcher, - type: 'test_type', - driverService: $typeService, + driverReference: new ListDriverReference( + type: 'test_type', + driver: $driver ?? new class implements ListDriverInterface {}, + ), dc: 'tl_test', model: $model, source: 'tl_flare_list.9', diff --git a/tests/List/ListSpecTest.php b/tests/List/ListSpecTest.php index e72d3882..1ba1a420 100644 --- a/tests/List/ListSpecTest.php +++ b/tests/List/ListSpecTest.php @@ -5,14 +5,24 @@ namespace HeimrichHannot\FlareBundle\Tests\List; use HeimrichHannot\FlareBundle\Filter\Filter; +use HeimrichHannot\FlareBundle\List\ListDriverReference; use HeimrichHannot\FlareBundle\List\ListSpec; +use HeimrichHannot\FlareBundle\List\Type\ListDriverInterface; use PHPUnit\Framework\TestCase; final class ListSpecTest extends TestCase { + private static function reference(): ListDriverReference + { + static $driver = null; + $driver ??= new class implements ListDriverInterface {}; + + return new ListDriverReference(type: 'test', driver: $driver); + } + public function testWithFilterKeysByAliasByDefault(): void { - $spec = new ListSpec(type: 'test', dc: 'tl_test'); + $spec = new ListSpec(reference: self::reference(), dc: 'tl_test'); $spec = $spec->withFilter(new Filter(type: 'flare_bool', alias: 'foo')); @@ -21,7 +31,7 @@ public function testWithFilterKeysByAliasByDefault(): void public function testWithFilterAcceptsExplicitKey(): void { - $spec = (new ListSpec(type: 'test', dc: 'tl_test')) + $spec = (new ListSpec(reference: self::reference(), dc: 'tl_test')) ->withFilter(new Filter(type: 'flare_bool', alias: 'foo'), 'custom'); self::assertArrayHasKey('custom', $spec->filters); @@ -30,7 +40,7 @@ public function testWithFilterAcceptsExplicitKey(): void public function testWithFilterGeneratesCollisionFreeKeysForAliasLessFilters(): void { - $spec = (new ListSpec(type: 'test', dc: 'tl_test')) + $spec = (new ListSpec(reference: self::reference(), dc: 'tl_test')) ->withFilter(new Filter(type: 'a')) ->withFilter(new Filter(type: 'b')); @@ -45,7 +55,7 @@ public function testWithFilterGeneratesCollisionFreeKeysForAliasLessFilters(): v public function testModifiersAreImmutable(): void { - $original = new ListSpec(type: 'test', dc: 'tl_test', config: ['id' => 1]); + $original = new ListSpec(reference: self::reference(), dc: 'tl_test', config: ['id' => 1]); $modified = $original ->withFilter(new Filter(type: 'a', alias: 'x')) @@ -60,7 +70,7 @@ public function testModifiersAreImmutable(): void public function testHasFilterOfType(): void { - $spec = (new ListSpec(type: 'test', dc: 'tl_test')) + $spec = (new ListSpec(reference: self::reference(), dc: 'tl_test')) ->withFilter(new Filter(type: 'flare_published', alias: 'p')); self::assertTrue($spec->hasFilterOfType('flare_published')); @@ -70,7 +80,7 @@ public function testHasFilterOfType(): void public function testHashIsStableAndChangesWithContent(): void { $make = static fn (array $config = [], ?string $source = null): ListSpec => - new ListSpec(type: 'test', dc: 'tl_test', config: $config, source: $source); + new ListSpec(reference: self::reference(), dc: 'tl_test', config: $config, source: $source); self::assertSame($make()->hash(), $make()->hash()); self::assertNotSame($make()->hash(), $make(config: ['id' => 1])->hash()); diff --git a/tests/List/ListTransformerResolverTest.php b/tests/List/ListTransformerResolverTest.php index 2f80d641..b2dd24e9 100644 --- a/tests/List/ListTransformerResolverTest.php +++ b/tests/List/ListTransformerResolverTest.php @@ -53,7 +53,7 @@ static function (ListTransformerEvent $event) use (&$dispatchedWith): void { self::assertSame(1, $driver->configureCalls); self::assertCount(1, $dispatchedWith); - self::assertSame($driver, $dispatchedWith[0]->typeService); + self::assertSame($driver, $dispatchedWith[0]->driver); self::assertSame('test', $dispatchedWith[0]->type); } From 24507852b40725f1cf3085666b71dea92f752752 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 19:07:37 +0200 Subject: [PATCH 45/71] refactor: rename `ListType` to `ListDriver` across the codebase Renamed all occurrences of `ListType` to `ListDriver`, including class names, interfaces, namespaces, attributes, and references. Updated tests, translation files, and documentation accordingly for consistency. --- .../{AsListType.php => AsListDriver.php} | 2 +- ...esPass.php => RegisterListDriversPass.php} | 8 +++---- .../HeimrichHannotFlareExtension.php | 6 ++--- src/Event/ListTransformerEvent.php | 2 +- src/HeimrichHannotFlareBundle.php | 4 ++-- .../EventsListDriver.php} | 11 ++++----- .../Projector/EventsAggregationProjector.php | 4 ++-- .../Projector/EventsInteractiveProjector.php | 4 ++-- .../EventListener/ContaoCommentsListener.php | 2 +- .../ListType/DcMultilingualListType.php | 6 ++--- .../{Type => Driver}/AbstractListDriver.php | 9 ++++++-- .../GenericDataContainerListDriver.php | 23 ++++--------------- .../{Type => Driver}/ListDriverInterface.php | 2 +- src/List/{Type => Driver}/NewsListDriver.php | 9 ++++---- src/List/Factory/ListSpecBuilderFactory.php | 2 +- src/List/Factory/ListSpecFactory.php | 2 +- src/List/ListDriverReference.php | 2 +- src/List/ListSpec.php | 2 +- src/List/ListSpecBuilder.php | 2 +- src/List/Resolver/ListDriverResolver.php | 2 +- src/List/Resolver/ListOptionsResolver.php | 2 +- src/List/Resolver/ListTransformerResolver.php | 2 +- .../Descriptor/ListTypeDescriptor.php | 2 +- .../Projector/InteractiveProjectorTest.php | 2 +- tests/Form/FilterFormFactoryTest.php | 2 +- tests/List/ListSpecBuilderTest.php | 4 ++-- tests/List/ListSpecTest.php | 2 +- tests/List/ListTransformerResolverTest.php | 2 +- translations/flare_list.de.php | 10 ++++---- translations/flare_list.en.php | 10 ++++---- 30 files changed, 65 insertions(+), 77 deletions(-) rename src/DependencyInjection/Attribute/{AsListType.php => AsListDriver.php} (96%) rename src/DependencyInjection/Compiler/{RegisterListTypesPass.php => RegisterListDriversPass.php} (96%) rename src/Integration/ContaoCalendar/{ListType/EventsListType.php => ListDriver/EventsListDriver.php} (88%) rename src/List/{Type => Driver}/AbstractListDriver.php (79%) rename src/List/{Type => Driver}/GenericDataContainerListDriver.php (83%) rename src/List/{Type => Driver}/ListDriverInterface.php (77%) rename src/List/{Type => Driver}/NewsListDriver.php (90%) diff --git a/src/DependencyInjection/Attribute/AsListType.php b/src/DependencyInjection/Attribute/AsListDriver.php similarity index 96% rename from src/DependencyInjection/Attribute/AsListType.php rename to src/DependencyInjection/Attribute/AsListDriver.php index c0f5c2ce..7c01ed41 100644 --- a/src/DependencyInjection/Attribute/AsListType.php +++ b/src/DependencyInjection/Attribute/AsListDriver.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\DependencyInjection\Attribute; #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] -class AsListType +class AsListDriver { public const TAG = 'huh.flare.list_type'; diff --git a/src/DependencyInjection/Compiler/RegisterListTypesPass.php b/src/DependencyInjection/Compiler/RegisterListDriversPass.php similarity index 96% rename from src/DependencyInjection/Compiler/RegisterListTypesPass.php rename to src/DependencyInjection/Compiler/RegisterListDriversPass.php index 59e45f3f..54d5b7f5 100644 --- a/src/DependencyInjection/Compiler/RegisterListTypesPass.php +++ b/src/DependencyInjection/Compiler/RegisterListDriversPass.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\DependencyInjection\Compiler; -use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListType; +use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListDriver; use HeimrichHannot\FlareBundle\Registry\Descriptor\ListTypeDescriptor; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; use HeimrichHannot\FlareBundle\Registry\ListDriverRegistry; @@ -17,7 +17,7 @@ use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Reference; -final class RegisterListTypesPass implements CompilerPassInterface +final class RegisterListDriversPass implements CompilerPassInterface { use PriorityTaggedServiceTrait; @@ -27,7 +27,7 @@ public function process(ContainerBuilder $container): void return; } - $tag = AsListType::TAG; + $tag = AsListDriver::TAG; $registry = $container->findDefinition(ListDriverRegistry::class); foreach ($this->findAndSortTaggedServices($tag, $container) as $reference) @@ -92,7 +92,7 @@ protected function getListTypeName(Definition $definition, array $attributes): s $className = $definition->getClass(); $className = \ltrim(\strrchr($className, '\\'), '\\'); - $className = Str::trimSubstrings($className, suffix: ['ListType', 'Type']); + $className = Str::trimSubstrings($className, suffix: ['ListDriver', 'Driver']); return Container::underscore($className); } diff --git a/src/DependencyInjection/HeimrichHannotFlareExtension.php b/src/DependencyInjection/HeimrichHannotFlareExtension.php index 5ff4c0de..ba08ea9c 100644 --- a/src/DependencyInjection/HeimrichHannotFlareExtension.php +++ b/src/DependencyInjection/HeimrichHannotFlareExtension.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\DependencyInjection; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; -use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListType; +use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListDriver; use HeimrichHannot\FlareBundle\Util\Env; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ChildDefinition; @@ -51,7 +51,7 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter($this->getAlias() . '.format_label_defaults', $flareConfig['format_label_defaults'] ?? []); $attributesForAutoconfiguration = [ - AsListType::class => AsListType::TAG, + AsListDriver::class => AsListDriver::TAG, AsFilterElement::class => AsFilterElement::TAG, ]; @@ -80,4 +80,4 @@ public function prepend(ContainerBuilder $container): void $loader = new YamlFileLoader($container, new FileLocator(\dirname(__DIR__) . '/../config')); $loader->load('config.yaml'); } -} \ No newline at end of file +} diff --git a/src/Event/ListTransformerEvent.php b/src/Event/ListTransformerEvent.php index 0f9e4e88..0eb8c006 100644 --- a/src/Event/ListTransformerEvent.php +++ b/src/Event/ListTransformerEvent.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\Event; use HeimrichHannot\FlareBundle\Config\TransformerResolver; -use HeimrichHannot\FlareBundle\List\Type\ListDriverInterface; +use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use Symfony\Contracts\EventDispatcher\Event; /** diff --git a/src/HeimrichHannotFlareBundle.php b/src/HeimrichHannotFlareBundle.php index bc5ae1a9..21764e7a 100644 --- a/src/HeimrichHannotFlareBundle.php +++ b/src/HeimrichHannotFlareBundle.php @@ -47,7 +47,7 @@ public function build(ContainerBuilder $container): void ###> Fill Registries ### $container->addCompilerPass(new DependencyInjection\Compiler\RegisterFilterElementsPass()); - $container->addCompilerPass(new DependencyInjection\Compiler\RegisterListTypesPass()); + $container->addCompilerPass(new DependencyInjection\Compiler\RegisterListDriversPass()); ###< Fill Registries ### } -} \ No newline at end of file +} diff --git a/src/Integration/ContaoCalendar/ListType/EventsListType.php b/src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php similarity index 88% rename from src/Integration/ContaoCalendar/ListType/EventsListType.php rename to src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php index 46e4782c..9b9ba8e6 100644 --- a/src/Integration/ContaoCalendar/ListType/EventsListType.php +++ b/src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php @@ -2,23 +2,22 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Integration\ContaoCalendar\ListType; +namespace HeimrichHannot\FlareBundle\Integration\ContaoCalendar\ListDriver; -use HeimrichHannot\FlareBundle\Contract\DcaContract; use HeimrichHannot\FlareBundle\Contract\ListType\BuildListContract; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; -use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListType; +use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListDriver; use HeimrichHannot\FlareBundle\Filter\Element\PublishedFilterElement; use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\List\ListSpecBuilder; -use HeimrichHannot\FlareBundle\List\Type\AbstractListDriver; +use HeimrichHannot\FlareBundle\List\Driver\AbstractListDriver; use HeimrichHannot\FlareBundle\Query\JoinTypeEnum; use HeimrichHannot\FlareBundle\Query\SqlJoinStruct; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; -#[AsListType(type: self::TYPE, dataContainer: self::DATA_CONTAINER)] -class EventsListType extends AbstractListDriver implements BuildListContract, DcaContract +#[AsListDriver(type: self::TYPE, dataContainer: self::DATA_CONTAINER)] +class EventsListDriver extends AbstractListDriver implements BuildListContract { public const TYPE = 'flare_events'; public const DATA_CONTAINER = 'tl_calendar_events'; diff --git a/src/Integration/ContaoCalendar/Projector/EventsAggregationProjector.php b/src/Integration/ContaoCalendar/Projector/EventsAggregationProjector.php index f509f25b..02072bcc 100644 --- a/src/Integration/ContaoCalendar/Projector/EventsAggregationProjector.php +++ b/src/Integration/ContaoCalendar/Projector/EventsAggregationProjector.php @@ -10,7 +10,7 @@ use HeimrichHannot\FlareBundle\Engine\Loader\AggregationLoaderInterface; use HeimrichHannot\FlareBundle\Engine\Projector\AggregationProjector; use HeimrichHannot\FlareBundle\Integration\ContaoCalendar\GroupsEntriesTrait; -use HeimrichHannot\FlareBundle\Integration\ContaoCalendar\ListType\EventsListType; +use HeimrichHannot\FlareBundle\Integration\ContaoCalendar\ListDriver\EventsListDriver; use HeimrichHannot\FlareBundle\Integration\ContaoCalendar\Loader\EventsAggregationLoader; use HeimrichHannot\FlareBundle\List\ListSpec; @@ -20,7 +20,7 @@ class EventsAggregationProjector extends AggregationProjector public function supports(ListSpec $list, ContextInterface $context): bool { - return $list->type === EventsListType::TYPE && $context instanceof AggregationContext; + return $list->type === EventsListDriver::TYPE && $context instanceof AggregationContext; } public function priority(ListSpec $list, ContextInterface $context): int diff --git a/src/Integration/ContaoCalendar/Projector/EventsInteractiveProjector.php b/src/Integration/ContaoCalendar/Projector/EventsInteractiveProjector.php index bb0f145a..80e43af8 100644 --- a/src/Integration/ContaoCalendar/Projector/EventsInteractiveProjector.php +++ b/src/Integration/ContaoCalendar/Projector/EventsInteractiveProjector.php @@ -10,7 +10,7 @@ use HeimrichHannot\FlareBundle\Engine\Loader\InteractiveLoaderInterface; use HeimrichHannot\FlareBundle\Engine\Projector\InteractiveProjector; use HeimrichHannot\FlareBundle\Integration\ContaoCalendar\GroupsEntriesTrait; -use HeimrichHannot\FlareBundle\Integration\ContaoCalendar\ListType\EventsListType; +use HeimrichHannot\FlareBundle\Integration\ContaoCalendar\ListDriver\EventsListDriver; use HeimrichHannot\FlareBundle\Integration\ContaoCalendar\Loader\EventsInteractiveLoader; use HeimrichHannot\FlareBundle\Integration\ContaoCalendar\View\InteractiveEventsView; use HeimrichHannot\FlareBundle\List\ListSpec; @@ -24,7 +24,7 @@ class EventsInteractiveProjector extends InteractiveProjector public function supports(ListSpec $list, ContextInterface $context): bool { - return $list->type === EventsListType::TYPE && $context instanceof InteractiveContext; + return $list->type === EventsListDriver::TYPE && $context instanceof InteractiveContext; } public function priority(ListSpec $list, ContextInterface $context): int diff --git a/src/Integration/ContaoComments/EventListener/ContaoCommentsListener.php b/src/Integration/ContaoComments/EventListener/ContaoCommentsListener.php index ad36716e..b9d571a5 100644 --- a/src/Integration/ContaoComments/EventListener/ContaoCommentsListener.php +++ b/src/Integration/ContaoComments/EventListener/ContaoCommentsListener.php @@ -141,4 +141,4 @@ public function onFlareReaderLoad(?DataContainer $dc = null): void ->addField('com_template', 'template_legend', PaletteManipulator::POSITION_APPEND) ->applyToString($palettes['flare_reader']); } -} \ No newline at end of file +} diff --git a/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php b/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php index 327b855a..90a1f72a 100644 --- a/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php +++ b/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php @@ -9,11 +9,11 @@ use Contao\DataContainer; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\Contract\ListType\DataContainerContract; -use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListType; -use HeimrichHannot\FlareBundle\List\Type\AbstractListDriver; +use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListDriver; +use HeimrichHannot\FlareBundle\List\Driver\AbstractListDriver; use HeimrichHannot\FlareBundle\Model\ListModel; -#[AsListType(type: self::TYPE)] +#[AsListDriver(type: self::TYPE)] class DcMultilingualListType extends AbstractListDriver implements DataContainerContract { public const TYPE = 'flare_generic_dc_multilingual'; diff --git a/src/List/Type/AbstractListDriver.php b/src/List/Driver/AbstractListDriver.php similarity index 79% rename from src/List/Type/AbstractListDriver.php rename to src/List/Driver/AbstractListDriver.php index 11f8069d..5643b753 100644 --- a/src/List/Type/AbstractListDriver.php +++ b/src/List/Driver/AbstractListDriver.php @@ -2,13 +2,16 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\List\Type; +namespace HeimrichHannot\FlareBundle\List\Driver; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\Config\TransformerResolver; +use HeimrichHannot\FlareBundle\Contract\DcaContract; use HeimrichHannot\FlareBundle\Contract\ListType\BuildQueryContract; use HeimrichHannot\FlareBundle\Contract\OptionsContract; use HeimrichHannot\FlareBundle\Contract\TransformerContract; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\List\CallbackListModelTransformer; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Query\SqlQueryStruct; @@ -16,7 +19,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; abstract class AbstractListDriver implements - ListDriverInterface, OptionsContract, TransformerContract, BuildQueryContract + ListDriverInterface, BuildQueryContract, DcaContract, OptionsContract, TransformerContract { /** * Declares the type's config schema on top of {@see \HeimrichHannot\FlareBundle\List\BaseListOptions}. @@ -37,6 +40,8 @@ public function configureTransformers(TransformerResolver $resolver): void */ protected function transformListModel(ConfigBuilder $config, ListModel $model): void {} + public function buildDca(DcaBuilder $dca, DcaContext $context): void {} + public function buildTableRegistry(TableAliasRegistry $registry): void {} public function buildBaseQuery(SqlQueryStruct $struct): void {} diff --git a/src/List/Type/GenericDataContainerListDriver.php b/src/List/Driver/GenericDataContainerListDriver.php similarity index 83% rename from src/List/Type/GenericDataContainerListDriver.php rename to src/List/Driver/GenericDataContainerListDriver.php index 4ff5686d..c12904b7 100644 --- a/src/List/Type/GenericDataContainerListDriver.php +++ b/src/List/Driver/GenericDataContainerListDriver.php @@ -2,26 +2,23 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\List\Type; +namespace HeimrichHannot\FlareBundle\List\Driver; use Contao\CoreBundle\DataContainer\PaletteManipulator; -use Contao\CoreBundle\String\HtmlDecoder; -use Contao\CoreBundle\String\SimpleTokenParser; use Contao\DataContainer; use Contao\Message; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; -use HeimrichHannot\FlareBundle\Contract\DcaContract; use HeimrichHannot\FlareBundle\Contract\ListType\DataContainerContract; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; -use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListType; +use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListDriver; use HeimrichHannot\FlareBundle\Exception\InferenceException; use HeimrichHannot\FlareBundle\InferPtable\PtableInferrer; use HeimrichHannot\FlareBundle\Model\ListModel; use Symfony\Contracts\Translation\TranslatorInterface; -#[AsListType(type: self::TYPE)] -class GenericDataContainerListDriver extends AbstractListDriver implements DataContainerContract, DcaContract +#[AsListDriver(type: self::TYPE)] +class GenericDataContainerListDriver extends AbstractListDriver implements DataContainerContract { public const TYPE = 'flare_generic_dc'; public const DEFAULT_PALETTE = <<<'PALETTE' @@ -30,21 +27,9 @@ class GenericDataContainerListDriver extends AbstractListDriver implements DataC PALETTE; public function __construct( - private readonly HtmlDecoder $htmlDecoder, - private readonly SimpleTokenParser $simpleTokenParser, private readonly TranslatorInterface $trans, ) {} - protected function getHtmlDecoder(): HtmlDecoder - { - return $this->htmlDecoder; - } - - protected function getSimpleTokenParser(): SimpleTokenParser - { - return $this->simpleTokenParser; - } - public function getDataContainerName(array $row, DataContainer $dc): string { return $row['dc'] ?? ''; diff --git a/src/List/Type/ListDriverInterface.php b/src/List/Driver/ListDriverInterface.php similarity index 77% rename from src/List/Type/ListDriverInterface.php rename to src/List/Driver/ListDriverInterface.php index 8d56b015..9ca7f881 100644 --- a/src/List/Type/ListDriverInterface.php +++ b/src/List/Driver/ListDriverInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\List\Type; +namespace HeimrichHannot\FlareBundle\List\Driver; /** * Marker for FLARE list types — registered via #[AsListType] or used inline on a ListSpec. diff --git a/src/List/Type/NewsListDriver.php b/src/List/Driver/NewsListDriver.php similarity index 90% rename from src/List/Type/NewsListDriver.php rename to src/List/Driver/NewsListDriver.php index 7f06c36f..7936e48b 100644 --- a/src/List/Type/NewsListDriver.php +++ b/src/List/Driver/NewsListDriver.php @@ -2,13 +2,12 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\List\Type; +namespace HeimrichHannot\FlareBundle\List\Driver; -use HeimrichHannot\FlareBundle\Contract\DcaContract; use HeimrichHannot\FlareBundle\Contract\ListType\BuildListContract; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; -use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListType; +use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListDriver; use HeimrichHannot\FlareBundle\Filter\Element\PublishedFilterElement; use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\List\ListSpecBuilder; @@ -16,8 +15,8 @@ use HeimrichHannot\FlareBundle\Query\SqlJoinStruct; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; -#[AsListType(type: self::TYPE, dataContainer: 'tl_news')] -class NewsListDriver extends AbstractListDriver implements BuildListContract, DcaContract +#[AsListDriver(type: self::TYPE, dataContainer: 'tl_news')] +class NewsListDriver extends AbstractListDriver implements BuildListContract { public const TYPE = 'flare_news'; public const ALIAS_ARCHIVE = 'news_archive'; diff --git a/src/List/Factory/ListSpecBuilderFactory.php b/src/List/Factory/ListSpecBuilderFactory.php index 2ef89683..ba7a894e 100644 --- a/src/List/Factory/ListSpecBuilderFactory.php +++ b/src/List/Factory/ListSpecBuilderFactory.php @@ -9,7 +9,7 @@ use HeimrichHannot\FlareBundle\List\Resolver\ListDriverResolver; use HeimrichHannot\FlareBundle\List\Resolver\ListOptionsResolver; use HeimrichHannot\FlareBundle\List\Resolver\ListTransformerResolver; -use HeimrichHannot\FlareBundle\List\Type\ListDriverInterface; +use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use HeimrichHannot\FlareBundle\Model\ListModel; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; diff --git a/src/List/Factory/ListSpecFactory.php b/src/List/Factory/ListSpecFactory.php index 313256c1..ad01899a 100644 --- a/src/List/Factory/ListSpecFactory.php +++ b/src/List/Factory/ListSpecFactory.php @@ -7,7 +7,7 @@ use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\List\Resolver\ListDriverResolver; -use HeimrichHannot\FlareBundle\List\Type\ListDriverInterface; +use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; final readonly class ListSpecFactory { diff --git a/src/List/ListDriverReference.php b/src/List/ListDriverReference.php index bf32d971..0c1d31b1 100644 --- a/src/List/ListDriverReference.php +++ b/src/List/ListDriverReference.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\List; -use HeimrichHannot\FlareBundle\List\Type\ListDriverInterface; +use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; final readonly class ListDriverReference { diff --git a/src/List/ListSpec.php b/src/List/ListSpec.php index 769a0b2a..39b138f8 100644 --- a/src/List/ListSpec.php +++ b/src/List/ListSpec.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\List; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\List\Type\ListDriverInterface; +use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use HeimrichHannot\FlareBundle\Util\DcaHelper; /** diff --git a/src/List/ListSpecBuilder.php b/src/List/ListSpecBuilder.php index dc07d188..922698ae 100644 --- a/src/List/ListSpecBuilder.php +++ b/src/List/ListSpecBuilder.php @@ -11,7 +11,7 @@ use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\List\Resolver\ListOptionsResolver; use HeimrichHannot\FlareBundle\List\Resolver\ListTransformerResolver; -use HeimrichHannot\FlareBundle\List\Type\ListDriverInterface; +use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use HeimrichHannot\FlareBundle\Model\ListModel; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; diff --git a/src/List/Resolver/ListDriverResolver.php b/src/List/Resolver/ListDriverResolver.php index 622e9cb5..cdde594e 100644 --- a/src/List/Resolver/ListDriverResolver.php +++ b/src/List/Resolver/ListDriverResolver.php @@ -6,7 +6,7 @@ use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\List\ListDriverReference; -use HeimrichHannot\FlareBundle\List\Type\ListDriverInterface; +use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use HeimrichHannot\FlareBundle\Registry\ListDriverRegistry; final readonly class ListDriverResolver diff --git a/src/List/Resolver/ListOptionsResolver.php b/src/List/Resolver/ListOptionsResolver.php index 3d8de4cd..cda557ae 100644 --- a/src/List/Resolver/ListOptionsResolver.php +++ b/src/List/Resolver/ListOptionsResolver.php @@ -8,7 +8,7 @@ use HeimrichHannot\FlareBundle\Contract\OptionsContract; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\List\BaseListOptions; -use HeimrichHannot\FlareBundle\List\Type\ListDriverInterface; +use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use Symfony\Component\OptionsResolver\OptionsResolver; /** diff --git a/src/List/Resolver/ListTransformerResolver.php b/src/List/Resolver/ListTransformerResolver.php index ba87a78f..59b48fdc 100644 --- a/src/List/Resolver/ListTransformerResolver.php +++ b/src/List/Resolver/ListTransformerResolver.php @@ -8,7 +8,7 @@ use HeimrichHannot\FlareBundle\Config\TransformerResolver; use HeimrichHannot\FlareBundle\Contract\TransformerContract; use HeimrichHannot\FlareBundle\Event\ListTransformerEvent; -use HeimrichHannot\FlareBundle\List\Type\ListDriverInterface; +use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; /** diff --git a/src/Registry/Descriptor/ListTypeDescriptor.php b/src/Registry/Descriptor/ListTypeDescriptor.php index bf63a68c..72551793 100644 --- a/src/Registry/Descriptor/ListTypeDescriptor.php +++ b/src/Registry/Descriptor/ListTypeDescriptor.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\Registry\Descriptor; use HeimrichHannot\FlareBundle\DependencyInjection\Registry\ServiceDescriptorInterface; -use HeimrichHannot\FlareBundle\List\Type\AbstractListDriver; +use HeimrichHannot\FlareBundle\List\Driver\AbstractListDriver; class ListTypeDescriptor implements ServiceDescriptorInterface { diff --git a/tests/Engine/Projector/InteractiveProjectorTest.php b/tests/Engine/Projector/InteractiveProjectorTest.php index 8236b228..4e33e34c 100644 --- a/tests/Engine/Projector/InteractiveProjectorTest.php +++ b/tests/Engine/Projector/InteractiveProjectorTest.php @@ -9,7 +9,7 @@ use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\List\ListDriverReference; use HeimrichHannot\FlareBundle\List\ListSpec; -use HeimrichHannot\FlareBundle\List\Type\ListDriverInterface; +use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use PHPUnit\Framework\TestCase; use Symfony\Component\Form\Extension\Core\Type\FormType; use Symfony\Component\Form\Extension\Core\Type\TextType; diff --git a/tests/Form/FilterFormFactoryTest.php b/tests/Form/FilterFormFactoryTest.php index a3589f56..5fc4ebba 100644 --- a/tests/Form/FilterFormFactoryTest.php +++ b/tests/Form/FilterFormFactoryTest.php @@ -19,7 +19,7 @@ use HeimrichHannot\FlareBundle\Filter\Resolver\FilterOptionsResolver; use HeimrichHannot\FlareBundle\List\ListDriverReference; use HeimrichHannot\FlareBundle\List\ListSpec; -use HeimrichHannot\FlareBundle\List\Type\ListDriverInterface; +use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use HeimrichHannot\FlareBundle\Registry\Descriptor\FilterElementDescriptor; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; use PHPUnit\Framework\TestCase; diff --git a/tests/List/ListSpecBuilderTest.php b/tests/List/ListSpecBuilderTest.php index b0490a85..d5396855 100644 --- a/tests/List/ListSpecBuilderTest.php +++ b/tests/List/ListSpecBuilderTest.php @@ -14,8 +14,8 @@ use HeimrichHannot\FlareBundle\List\ListSpecBuilder; use HeimrichHannot\FlareBundle\List\Resolver\ListOptionsResolver; use HeimrichHannot\FlareBundle\List\Resolver\ListTransformerResolver; -use HeimrichHannot\FlareBundle\List\Type\AbstractListDriver; -use HeimrichHannot\FlareBundle\List\Type\ListDriverInterface; +use HeimrichHannot\FlareBundle\List\Driver\AbstractListDriver; +use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use HeimrichHannot\FlareBundle\Model\ListModel; use PHPUnit\Framework\TestCase; use Symfony\Component\EventDispatcher\EventDispatcher; diff --git a/tests/List/ListSpecTest.php b/tests/List/ListSpecTest.php index 1ba1a420..18f1ba7e 100644 --- a/tests/List/ListSpecTest.php +++ b/tests/List/ListSpecTest.php @@ -7,7 +7,7 @@ use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\List\ListDriverReference; use HeimrichHannot\FlareBundle\List\ListSpec; -use HeimrichHannot\FlareBundle\List\Type\ListDriverInterface; +use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use PHPUnit\Framework\TestCase; final class ListSpecTest extends TestCase diff --git a/tests/List/ListTransformerResolverTest.php b/tests/List/ListTransformerResolverTest.php index b2dd24e9..c6125a4a 100644 --- a/tests/List/ListTransformerResolverTest.php +++ b/tests/List/ListTransformerResolverTest.php @@ -9,7 +9,7 @@ use HeimrichHannot\FlareBundle\Contract\TransformerContract; use HeimrichHannot\FlareBundle\Event\ListTransformerEvent; use HeimrichHannot\FlareBundle\List\Resolver\ListTransformerResolver; -use HeimrichHannot\FlareBundle\List\Type\ListDriverInterface; +use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use PHPUnit\Framework\TestCase; use Symfony\Component\EventDispatcher\EventDispatcher; diff --git a/translations/flare_list.de.php b/translations/flare_list.de.php index f5d81a4c..0c5c46e8 100644 --- a/translations/flare_list.de.php +++ b/translations/flare_list.de.php @@ -1,11 +1,11 @@ 'Data-Container', - Type\NewsListDriver::TYPE => 'Nachrichten', + Driver\GenericDataContainerListDriver::TYPE => 'Data-Container', + Driver\NewsListDriver::TYPE => 'Nachrichten', - EventsListType::TYPE => 'Events', + EventsListDriver::TYPE => 'Events', ]; diff --git a/translations/flare_list.en.php b/translations/flare_list.en.php index ff88dbd3..e554a0c5 100644 --- a/translations/flare_list.en.php +++ b/translations/flare_list.en.php @@ -1,11 +1,11 @@ 'Data Container', - Type\NewsListDriver::TYPE => 'News', + Driver\GenericDataContainerListDriver::TYPE => 'Data Container', + Driver\NewsListDriver::TYPE => 'News', - EventsListType::TYPE => 'Events', + EventsListDriver::TYPE => 'Events', ]; From 2002692388cb44e5888bad7cc62c402bf6f00d5c Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 19:33:52 +0200 Subject: [PATCH 46/71] refactor: centralize `ListDriverReference` handling, replace inline construction with static factory methods Replaced ad-hoc construction of `ListDriverReference` with static factory methods `registered()` and `inline()` for clearer intent and better type management. Updated event dispatchers, listeners, transformers, and tests to use the new structure. Refined `ListTransformerEvent` and related named event handling accordingly. --- src/Event/ListTransformerEvent.php | 11 ++-- .../NamedDispatch/ListBuildListener.php | 6 ++- .../NamedDispatch/ListTransformerListener.php | 7 ++- src/List/ListDriverReference.php | 20 ++++++- src/List/ListSpecBuilder.php | 6 +-- src/List/Resolver/ListDriverResolver.php | 10 +--- src/List/Resolver/ListTransformerResolver.php | 8 +-- .../Projector/InteractiveProjectorTest.php | 2 +- tests/Form/FilterFormFactoryTest.php | 2 +- tests/List/ListSpecBuilderTest.php | 6 +-- tests/List/ListSpecTest.php | 2 +- tests/List/ListTransformerResolverTest.php | 53 +++++++++++++++---- 12 files changed, 91 insertions(+), 42 deletions(-) diff --git a/src/Event/ListTransformerEvent.php b/src/Event/ListTransformerEvent.php index 0eb8c006..a5d59957 100644 --- a/src/Event/ListTransformerEvent.php +++ b/src/Event/ListTransformerEvent.php @@ -5,19 +5,18 @@ namespace HeimrichHannot\FlareBundle\Event; use HeimrichHannot\FlareBundle\Config\TransformerResolver; -use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; +use HeimrichHannot\FlareBundle\List\ListDriverReference; use Symfony\Contracts\EventDispatcher\Event; /** - * Dispatched once per list type class when its transformer map is configured. - * Listeners may register transformers for additional source classes — also per type - * via the named event `flare.list.{type}.transformers`. + * Dispatched once per list driver class when its transformer map is configured. + * Listeners may register transformers for additional source classes — for registered + * drivers also per type via the named event `flare.list.{type}.transformers`. */ class ListTransformerEvent extends Event { public function __construct( public readonly TransformerResolver $transformers, - public readonly ListDriverInterface $driver, - public readonly ?string $type, + public readonly ListDriverReference $reference, ) {} } diff --git a/src/EventListener/NamedDispatch/ListBuildListener.php b/src/EventListener/NamedDispatch/ListBuildListener.php index ef0bf26b..66391204 100644 --- a/src/EventListener/NamedDispatch/ListBuildListener.php +++ b/src/EventListener/NamedDispatch/ListBuildListener.php @@ -17,10 +17,12 @@ public function __construct( #[AsEventListener(priority: -200)] public function __invoke(ListBuildEvent $event): void { - if (!$type = $event->builder->getType()) { + $reference = $event->builder->getDriverReference(); + + if ($reference->inline) { return; } - $this->eventDispatcher->dispatch(event: $event, eventName: "flare.list.{$type}.build"); + $this->eventDispatcher->dispatch(event: $event, eventName: "flare.list.{$reference->type}.build"); } } diff --git a/src/EventListener/NamedDispatch/ListTransformerListener.php b/src/EventListener/NamedDispatch/ListTransformerListener.php index 58bb40d4..08fcb92b 100644 --- a/src/EventListener/NamedDispatch/ListTransformerListener.php +++ b/src/EventListener/NamedDispatch/ListTransformerListener.php @@ -17,10 +17,13 @@ public function __construct( #[AsEventListener(priority: -200)] public function __invoke(ListTransformerEvent $event): void { - if (!$event->type) { + if ($event->reference->inline) { return; } - $this->eventDispatcher->dispatch(event: $event, eventName: "flare.list.{$event->type}.transformers"); + $this->eventDispatcher->dispatch( + event: $event, + eventName: "flare.list.{$event->reference->type}.transformers", + ); } } diff --git a/src/List/ListDriverReference.php b/src/List/ListDriverReference.php index 0c1d31b1..8fbcbf50 100644 --- a/src/List/ListDriverReference.php +++ b/src/List/ListDriverReference.php @@ -8,8 +8,26 @@ final readonly class ListDriverReference { - public function __construct( + private function __construct( public string $type, public ListDriverInterface $driver, + public bool $inline, ) {} + + /** + * References a driver registered in the registry under the given type alias. + */ + public static function registered(string $type, ListDriverInterface $driver): self + { + return new self(type: $type, driver: $driver, inline: false); + } + + /** + * References an inline driver instance; its class name stands in for the type alias. + * Inline drivers take part in no `flare.list.{type}.*` named dispatch. + */ + public static function inline(ListDriverInterface $driver): self + { + return new self(type: \get_class($driver), driver: $driver, inline: true); + } } diff --git a/src/List/ListSpecBuilder.php b/src/List/ListSpecBuilder.php index 922698ae..edd89aa1 100644 --- a/src/List/ListSpecBuilder.php +++ b/src/List/ListSpecBuilder.php @@ -144,11 +144,7 @@ public function build(): ListSpec { BaseListOptions::transform($config, $this->model); - $transformed = $this->transformerResolver->transform( - $driver, - $this->getType(), - $this->model, - ); + $transformed = $this->transformerResolver->transform($this->driverReference, $this->model); foreach ($transformed ?? [] as $key => $value) { $config->set($key, $value); diff --git a/src/List/Resolver/ListDriverResolver.php b/src/List/Resolver/ListDriverResolver.php index cdde594e..7bbcecf0 100644 --- a/src/List/Resolver/ListDriverResolver.php +++ b/src/List/Resolver/ListDriverResolver.php @@ -29,10 +29,7 @@ public function resolve(ListDriverInterface|string $driver): ListDriverReference private function resolveInstance(ListDriverInterface $driver): ListDriverReference { - return new ListDriverReference( - type: \get_class($driver), - driver: $driver, - ); + return ListDriverReference::inline($driver); } /** @@ -47,9 +44,6 @@ private function resolveType(string $type): ListDriverReference )); } - return new ListDriverReference( - type: $type, - driver: $descriptor->getService(), - ); + return ListDriverReference::registered($type, $descriptor->getService()); } } diff --git a/src/List/Resolver/ListTransformerResolver.php b/src/List/Resolver/ListTransformerResolver.php index 59b48fdc..e294b3ee 100644 --- a/src/List/Resolver/ListTransformerResolver.php +++ b/src/List/Resolver/ListTransformerResolver.php @@ -8,7 +8,7 @@ use HeimrichHannot\FlareBundle\Config\TransformerResolver; use HeimrichHannot\FlareBundle\Contract\TransformerContract; use HeimrichHannot\FlareBundle\Event\ListTransformerEvent; -use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; +use HeimrichHannot\FlareBundle\List\ListDriverReference; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; /** @@ -30,8 +30,10 @@ public function __construct( /** * @return array|null Canonical config values, or null when no transformer matches the source. */ - public function transform(ListDriverInterface $driver, ?string $type, object $source): ?array + public function transform(ListDriverReference $reference, object $source): ?array { + $driver = $reference->driver; + if (!isset($this->resolvers[$driver::class])) { $resolver = new TransformerResolver(); @@ -40,7 +42,7 @@ public function transform(ListDriverInterface $driver, ?string $type, object $so $driver->configureTransformers($resolver); } - $this->eventDispatcher->dispatch(new ListTransformerEvent($resolver, $driver, $type)); + $this->eventDispatcher->dispatch(new ListTransformerEvent($resolver, $reference)); $this->resolvers[$driver::class] = $resolver; } diff --git a/tests/Engine/Projector/InteractiveProjectorTest.php b/tests/Engine/Projector/InteractiveProjectorTest.php index 4e33e34c..9fd10bdf 100644 --- a/tests/Engine/Projector/InteractiveProjectorTest.php +++ b/tests/Engine/Projector/InteractiveProjectorTest.php @@ -51,7 +51,7 @@ private function addFlatChild(FormBuilderInterface $root, string $alias, array $ private function listWithFilter(string $key, string $alias): ListSpec { - $reference = new ListDriverReference(type: 'test', driver: new class implements ListDriverInterface {}); + $reference = ListDriverReference::registered('test', new class implements ListDriverInterface {}); return new ListSpec(reference: $reference, dc: 'tl_test', filters: [ $key => new Filter(type: 'test_element', alias: $alias), diff --git a/tests/Form/FilterFormFactoryTest.php b/tests/Form/FilterFormFactoryTest.php index 5fc4ebba..7c2fa4e0 100644 --- a/tests/Form/FilterFormFactoryTest.php +++ b/tests/Form/FilterFormFactoryTest.php @@ -65,7 +65,7 @@ private function createFactory(): FilterFormFactory private function createForm(array $filters): FormInterface { $list = new ListSpec( - reference: new ListDriverReference(type: 'test', driver: new class implements ListDriverInterface {}), + reference: ListDriverReference::registered('test', new class implements ListDriverInterface {}), dc: 'tl_test', filters: $filters, ); diff --git a/tests/List/ListSpecBuilderTest.php b/tests/List/ListSpecBuilderTest.php index d5396855..6232bd32 100644 --- a/tests/List/ListSpecBuilderTest.php +++ b/tests/List/ListSpecBuilderTest.php @@ -121,9 +121,9 @@ private function createBuilder( optionsResolver: new ListOptionsResolver(new SchemaResolver()), transformerResolver: new ListTransformerResolver($dispatcher), eventDispatcher: $dispatcher, - driverReference: new ListDriverReference( - type: 'test_type', - driver: $driver ?? new class implements ListDriverInterface {}, + driverReference: ListDriverReference::registered( + 'test_type', + $driver ?? new class implements ListDriverInterface {}, ), dc: 'tl_test', model: $model, diff --git a/tests/List/ListSpecTest.php b/tests/List/ListSpecTest.php index 18f1ba7e..51b0f553 100644 --- a/tests/List/ListSpecTest.php +++ b/tests/List/ListSpecTest.php @@ -17,7 +17,7 @@ private static function reference(): ListDriverReference static $driver = null; $driver ??= new class implements ListDriverInterface {}; - return new ListDriverReference(type: 'test', driver: $driver); + return ListDriverReference::registered('test', $driver); } public function testWithFilterKeysByAliasByDefault(): void diff --git a/tests/List/ListTransformerResolverTest.php b/tests/List/ListTransformerResolverTest.php index c6125a4a..ecb7b078 100644 --- a/tests/List/ListTransformerResolverTest.php +++ b/tests/List/ListTransformerResolverTest.php @@ -8,6 +8,7 @@ use HeimrichHannot\FlareBundle\Config\TransformerResolver; use HeimrichHannot\FlareBundle\Contract\TransformerContract; use HeimrichHannot\FlareBundle\Event\ListTransformerEvent; +use HeimrichHannot\FlareBundle\List\ListDriverReference; use HeimrichHannot\FlareBundle\List\Resolver\ListTransformerResolver; use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use PHPUnit\Framework\TestCase; @@ -18,9 +19,9 @@ final class ListTransformerResolverTest extends TestCase public function testTransformsSourceThroughDriverTransformers(): void { $resolver = new ListTransformerResolver(new EventDispatcher()); - $driver = new TransformingDriver(); + $reference = ListDriverReference::registered('test', new TransformingDriver()); - $values = $resolver->transform($driver, 'test', new SourceStub('from-source')); + $values = $resolver->transform($reference, new SourceStub('from-source')); self::assertSame(['title' => 'from-source'], $values); } @@ -29,8 +30,14 @@ public function testReturnsNullWithoutMatchingTransformer(): void { $resolver = new ListTransformerResolver(new EventDispatcher()); - self::assertNull($resolver->transform(new TransformingDriver(), 'test', new \stdClass())); - self::assertNull($resolver->transform(new TransformerlessDriver(), 'test', new SourceStub('x'))); + self::assertNull($resolver->transform( + ListDriverReference::registered('test', new TransformingDriver()), + new \stdClass(), + )); + self::assertNull($resolver->transform( + ListDriverReference::registered('test', new TransformerlessDriver()), + new SourceStub('x'), + )); } public function testMemoizesMapAndDispatchesEventOncePerDriverClass(): void @@ -47,14 +54,39 @@ static function (ListTransformerEvent $event) use (&$dispatchedWith): void { $resolver = new ListTransformerResolver($dispatcher); $driver = new TransformingDriver(); + $reference = ListDriverReference::registered('test', $driver); - $resolver->transform($driver, 'test', new SourceStub('a')); - $resolver->transform($driver, 'test', new SourceStub('b')); + $resolver->transform($reference, new SourceStub('a')); + $resolver->transform($reference, new SourceStub('b')); self::assertSame(1, $driver->configureCalls); self::assertCount(1, $dispatchedWith); - self::assertSame($driver, $dispatchedWith[0]->driver); - self::assertSame('test', $dispatchedWith[0]->type); + self::assertSame($reference, $dispatchedWith[0]->reference); + self::assertSame($driver, $dispatchedWith[0]->reference->driver); + self::assertSame('test', $dispatchedWith[0]->reference->type); + } + + public function testInlineReferenceCarriesOverIntoTheEvent(): void + { + $dispatchedWith = []; + + $dispatcher = new EventDispatcher(); + $dispatcher->addListener( + ListTransformerEvent::class, + static function (ListTransformerEvent $event) use (&$dispatchedWith): void { + $dispatchedWith[] = $event; + }, + ); + + $resolver = new ListTransformerResolver($dispatcher); + $driver = new TransformingDriver(); + $reference = ListDriverReference::inline($driver); + + $resolver->transform($reference, new SourceStub('a')); + + self::assertCount(1, $dispatchedWith); + self::assertSame($reference, $dispatchedWith[0]->reference); + self::assertTrue($dispatchedWith[0]->reference->inline); } public function testEventListenersCanAddSourceCapabilities(): void @@ -72,7 +104,10 @@ static function (ListTransformerEvent $event): void { $resolver = new ListTransformerResolver($dispatcher); - $values = $resolver->transform(new TransformerlessDriver(), 'test', new \stdClass()); + $values = $resolver->transform( + ListDriverReference::registered('test', new TransformerlessDriver()), + new \stdClass(), + ); self::assertSame(['external' => true], $values); } From 5407cbe6bd94fd1da08f1b54f98bb7dc2e0059ea Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 19:39:03 +0200 Subject: [PATCH 47/71] feat: add PHPUnit CI workflow and configuration Introduced `phpunit.yaml` GitHub Actions workflow for running unit tests. Added `phpunit.xml.dist` configuration file and updated documentation to reflect the new setup. --- .github/workflows/phpunit.yaml | 51 ++++++++++++++++++++++++++++++++++ AGENTS.md | 3 +- README.md | 1 + phpunit.xml.dist | 27 ++++++++++++++++++ 4 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/phpunit.yaml create mode 100644 phpunit.xml.dist diff --git a/.github/workflows/phpunit.yaml b/.github/workflows/phpunit.yaml new file mode 100644 index 00000000..0a8573b1 --- /dev/null +++ b/.github/workflows/phpunit.yaml @@ -0,0 +1,51 @@ +name: PHPUnit + +on: + push: + branches-ignore: + - 'docs/**' + paths: + - '**.php' + - 'composer.json' + - 'phpunit.xml.dist' + workflow_dispatch: ~ + +jobs: + test: + name: Unit Tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + tools: composer + coverage: none + + - name: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Restore Composer cache + id: composer-cache-restore + uses: actions/cache/restore@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-8.2-${{ hashFiles('composer.json') }} + restore-keys: composer-8.2- + + - name: Install dependencies + run: composer update --no-progress --prefer-dist + + - name: Save Composer cache + if: always() && steps.composer-cache-restore.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-8.2-${{ hashFiles('composer.json') }} + + - name: Run PHPUnit + run: vendor/bin/phpunit diff --git a/AGENTS.md b/AGENTS.md index 4f668d91..a9576be9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,8 +101,9 @@ are in `src/Event/`. Prefer events over overriding services for customization. ## Testing & CI -* **Unit tests** live in `tests/` (PHPUnit 9); run them with `make php vendor/bin/phpunit tests`. There is no `phpunit.xml` and no test CI workflow yet, and no `make test` target. +* **Unit tests** live in `tests/` (PHPUnit 9, configured via `phpunit.xml.dist`); run them with `make php vendor/bin/phpunit`. There is no `make test` target. * CI workflows in `.github/workflows/`: + * `phpunit.yaml` — PHPUnit test suite * `phpstan.yaml` — PHPStan analysis * `mago.yaml` — Mago lint (`--minimum-fail-level note`, PHP 8.2–8.5) * `compatibility.yaml` — `composer validate` + dependency-resolution matrix (PHP 8.2–8.5 × Contao 4.13/5.x) diff --git a/README.md b/README.md index 0fc34cf6..94f7a6ac 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![Latest Version on Packagist](https://img.shields.io/packagist/v/heimrichhannot/contao-flare-bundle.svg)](https://packagist.org/packages/heimrichhannot/contao-flare-bundle) [![PHP Version](https://img.shields.io/packagist/dependency-v/heimrichhannot/contao-flare-bundle/php.svg)](https://packagist.org/packages/heimrichhannot/contao-flare-bundle) [![Contao Version](https://img.shields.io/packagist/dependency-v/heimrichhannot/contao-flare-bundle/contao/core-bundle.svg)](https://packagist.org/packages/heimrichhannot/contao-flare-bundle) +[![PHPUnit](https://github.com/heimrichhannot/contao-flare-bundle/actions/workflows/phpunit.yaml/badge.svg)](https://github.com/heimrichhannot/contao-flare-bundle/actions/workflows/phpunit.yaml) [![PHPStan](https://github.com/heimrichhannot/contao-flare-bundle/actions/workflows/phpstan.yaml/badge.svg)](https://github.com/heimrichhannot/contao-flare-bundle/actions/workflows/phpstan.yaml) [![Mago](https://github.com/heimrichhannot/contao-flare-bundle/actions/workflows/mago.yaml/badge.svg)](https://github.com/heimrichhannot/contao-flare-bundle/actions/workflows/mago.yaml) [![Compatibility](https://github.com/heimrichhannot/contao-flare-bundle/actions/workflows/compatibility.yaml/badge.svg)](https://github.com/heimrichhannot/contao-flare-bundle/actions/workflows/compatibility.yaml) diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 00000000..eb0d55bd --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,27 @@ + + + + + + + + + tests + + + + + + src + + + src/Model + + + From 3646040ec8fc0be19072dfb8c06fea1f0d9de930 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 19:43:36 +0200 Subject: [PATCH 48/71] docs: improve README formatting and emphasize description Added a line break after badges for better readability and emphasized the bundle description with bold formatting. --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 94f7a6ac..f5573ba6 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,13 @@ [![Latest Version on Packagist](https://img.shields.io/packagist/v/heimrichhannot/contao-flare-bundle.svg)](https://packagist.org/packages/heimrichhannot/contao-flare-bundle) [![PHP Version](https://img.shields.io/packagist/dependency-v/heimrichhannot/contao-flare-bundle/php.svg)](https://packagist.org/packages/heimrichhannot/contao-flare-bundle) [![Contao Version](https://img.shields.io/packagist/dependency-v/heimrichhannot/contao-flare-bundle/contao/core-bundle.svg)](https://packagist.org/packages/heimrichhannot/contao-flare-bundle) +
[![PHPUnit](https://github.com/heimrichhannot/contao-flare-bundle/actions/workflows/phpunit.yaml/badge.svg)](https://github.com/heimrichhannot/contao-flare-bundle/actions/workflows/phpunit.yaml) [![PHPStan](https://github.com/heimrichhannot/contao-flare-bundle/actions/workflows/phpstan.yaml/badge.svg)](https://github.com/heimrichhannot/contao-flare-bundle/actions/workflows/phpstan.yaml) [![Mago](https://github.com/heimrichhannot/contao-flare-bundle/actions/workflows/mago.yaml/badge.svg)](https://github.com/heimrichhannot/contao-flare-bundle/actions/workflows/mago.yaml) [![Compatibility](https://github.com/heimrichhannot/contao-flare-bundle/actions/workflows/compatibility.yaml/badge.svg)](https://github.com/heimrichhannot/contao-flare-bundle/actions/workflows/compatibility.yaml) -A Contao CMS bundle for building filterable lists and detail pages — for news, events, or any DCA-based entity. +**A Contao CMS bundle for building filterable lists and detail pages — for news, events, or any DCA-based entity.** > [!NOTE] > Flare is a work in progress. We are actively working on it and will release updates regularly. From 787361bd5f1125a63ac9272f953ab78ae75982d8 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 19:48:57 +0200 Subject: [PATCH 49/71] docs: add security CI badge to README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index f5573ba6..b4afda42 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ [![PHPStan](https://github.com/heimrichhannot/contao-flare-bundle/actions/workflows/phpstan.yaml/badge.svg)](https://github.com/heimrichhannot/contao-flare-bundle/actions/workflows/phpstan.yaml) [![Mago](https://github.com/heimrichhannot/contao-flare-bundle/actions/workflows/mago.yaml/badge.svg)](https://github.com/heimrichhannot/contao-flare-bundle/actions/workflows/mago.yaml) [![Compatibility](https://github.com/heimrichhannot/contao-flare-bundle/actions/workflows/compatibility.yaml/badge.svg)](https://github.com/heimrichhannot/contao-flare-bundle/actions/workflows/compatibility.yaml) +[![Security](https://github.com/heimrichhannot/contao-flare-bundle/actions/workflows/security.yaml/badge.svg)](https://github.com/heimrichhannot/contao-flare-bundle/actions/workflows/security.yaml) **A Contao CMS bundle for building filterable lists and detail pages — for news, events, or any DCA-based entity.** From 0d41df8e3ea2937ffa5173fa9f64069691bb00a3 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Fri, 17 Jul 2026 01:55:42 +0200 Subject: [PATCH 50/71] refactor: replace `type` with `driver` in ListSpec and rename `getDataContainerName` to `resolveDataContainerTable` --- src/Contract/ListType/DataContainerContract.php | 4 ++-- src/DataContainer/ListContainer.php | 2 +- src/Engine/Projector/InteractiveProjector.php | 2 +- src/EventListener/Reader/GenericReaderPageMetaListener.php | 2 +- .../FilterElement/CodefogTagsChoiceFilterElement.php | 2 +- .../ContaoCalendar/Projector/EventsAggregationProjector.php | 2 +- .../ContaoCalendar/Projector/EventsInteractiveProjector.php | 2 +- .../Terminal42Languages/ListType/DcMultilingualListType.php | 2 +- src/List/Driver/GenericDataContainerListDriver.php | 2 +- src/List/ListSpec.php | 4 ++++ 10 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/Contract/ListType/DataContainerContract.php b/src/Contract/ListType/DataContainerContract.php index 9904b6d3..3f70fb2f 100644 --- a/src/Contract/ListType/DataContainerContract.php +++ b/src/Contract/ListType/DataContainerContract.php @@ -8,5 +8,5 @@ interface DataContainerContract { - public function getDataContainerName(array $row, DataContainer $dc): string; -} \ No newline at end of file + public function resolveDataContainerTable(array $row, DataContainer $dc): string; +} diff --git a/src/DataContainer/ListContainer.php b/src/DataContainer/ListContainer.php index 148a2628..bed5e6de 100644 --- a/src/DataContainer/ListContainer.php +++ b/src/DataContainer/ListContainer.php @@ -46,7 +46,7 @@ public function onSubmitConfig(DataContainer $dc): void $service = $listTypeConfig->getService(); if (($service instanceof DataContainerContract) - && !$expectedDataContainer = $service->getDataContainerName($row, $dc)) + && !$expectedDataContainer = $service->resolveDataContainerTable($row, $dc)) { return; } diff --git a/src/Engine/Projector/InteractiveProjector.php b/src/Engine/Projector/InteractiveProjector.php index 6b0c5c85..e2443316 100644 --- a/src/Engine/Projector/InteractiveProjector.php +++ b/src/Engine/Projector/InteractiveProjector.php @@ -121,7 +121,7 @@ public function createForm(ListSpec $list, InteractiveContext $context): FormInt /** * Collects each filter's form data, keyed by the filter's list-specification key. * Flat-mounted single fields are normalized to the canonical values-bag shape - * `[FilterContext::DEFAULT_FIELD_NAME => value]` that buildFilter() consumes. + * `[FilterContext::SINGLE_VALUE => value]` that buildFilter() consumes. * * @return array> */ diff --git a/src/EventListener/Reader/GenericReaderPageMetaListener.php b/src/EventListener/Reader/GenericReaderPageMetaListener.php index d9ca3290..7687ea3f 100644 --- a/src/EventListener/Reader/GenericReaderPageMetaListener.php +++ b/src/EventListener/Reader/GenericReaderPageMetaListener.php @@ -42,7 +42,7 @@ public function __invoke(ReaderPageMetaEvent $event): void } $tokens = [ - 'list.type' => $list->type, + 'list.driver_class' => \get_class($list->driver), 'list.dc' => $list->dc, ]; diff --git a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php index 9fe3335f..3cbe6aeb 100644 --- a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php +++ b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php @@ -85,7 +85,7 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co $optValues = $this->getOptions( executionContext: $executionContext, targetAlias: $context->filter->targetAlias, - listInfo: \sprintf('%s (%s)', $context->list->type, (string) ($context->list->source ?? 'N/A')), + listInfo: \sprintf('%s (%s)', \get_class($context->list->driver), (string) ($context->list->source ?? 'N/A')), filterInfo: \sprintf('%s (%s)', self::TYPE, $context->filter->source ?? 'inlined'), ); diff --git a/src/Integration/ContaoCalendar/Projector/EventsAggregationProjector.php b/src/Integration/ContaoCalendar/Projector/EventsAggregationProjector.php index 02072bcc..0bf9c751 100644 --- a/src/Integration/ContaoCalendar/Projector/EventsAggregationProjector.php +++ b/src/Integration/ContaoCalendar/Projector/EventsAggregationProjector.php @@ -20,7 +20,7 @@ class EventsAggregationProjector extends AggregationProjector public function supports(ListSpec $list, ContextInterface $context): bool { - return $list->type === EventsListDriver::TYPE && $context instanceof AggregationContext; + return $list->driver instanceof EventsListDriver && $context instanceof AggregationContext; } public function priority(ListSpec $list, ContextInterface $context): int diff --git a/src/Integration/ContaoCalendar/Projector/EventsInteractiveProjector.php b/src/Integration/ContaoCalendar/Projector/EventsInteractiveProjector.php index 80e43af8..0daf97da 100644 --- a/src/Integration/ContaoCalendar/Projector/EventsInteractiveProjector.php +++ b/src/Integration/ContaoCalendar/Projector/EventsInteractiveProjector.php @@ -24,7 +24,7 @@ class EventsInteractiveProjector extends InteractiveProjector public function supports(ListSpec $list, ContextInterface $context): bool { - return $list->type === EventsListDriver::TYPE && $context instanceof InteractiveContext; + return $list->driver instanceof EventsListDriver && $context instanceof InteractiveContext; } public function priority(ListSpec $list, ContextInterface $context): int diff --git a/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php b/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php index 90a1f72a..3325bd6e 100644 --- a/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php +++ b/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php @@ -37,7 +37,7 @@ protected function getSimpleTokenParser(): SimpleTokenParser return $this->simpleTokenParser; } - public function getDataContainerName(array $row, DataContainer $dc): string + public function resolveDataContainerTable(array $row, DataContainer $dc): string { return $row['dc'] ?? ''; } diff --git a/src/List/Driver/GenericDataContainerListDriver.php b/src/List/Driver/GenericDataContainerListDriver.php index c12904b7..fed9345c 100644 --- a/src/List/Driver/GenericDataContainerListDriver.php +++ b/src/List/Driver/GenericDataContainerListDriver.php @@ -30,7 +30,7 @@ public function __construct( private readonly TranslatorInterface $trans, ) {} - public function getDataContainerName(array $row, DataContainer $dc): string + public function resolveDataContainerTable(array $row, DataContainer $dc): string { return $row['dc'] ?? ''; } diff --git a/src/List/ListSpec.php b/src/List/ListSpec.php index 39b138f8..e6e87290 100644 --- a/src/List/ListSpec.php +++ b/src/List/ListSpec.php @@ -19,6 +19,10 @@ */ final readonly class ListSpec { + /** + * @deprecated + * @var string $type + */ public string $type; public ListDriverInterface $driver; From cfca7a53473c2bd71bcde497daac4b67064ed958 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Fri, 17 Jul 2026 03:37:20 +0200 Subject: [PATCH 51/71] refactor: remove obsolete classes and interfaces related to ListDriver and FilterElement handling --- config/services.yaml | 1 - .../BuildListContract.php | 0 .../BuildQueryContract.php | 0 .../DataContainerContract.php | 2 + src/DataContainer/ListContainer.php | 11 +- .../Attribute/AsFilterElement.php | 16 +- .../Attribute/AsListDriver.php | 11 +- .../Compiler/RegisterFilterElementsPass.php | 30 +--- .../Compiler/RegisterListDriversPass.php | 33 +---- .../AbstractServiceDescriptorRegistry.php | 104 ------------- .../Registry/ServiceDescriptorInterface.php | 12 -- src/Engine/Factory/LoaderFactory.php | 3 + src/Engine/Loader/ValidationLoader.php | 11 +- src/Engine/Mod/SimpleEquationMod.php | 10 +- src/Engine/Projector/AbstractProjector.php | 7 - src/Engine/Projector/InteractiveProjector.php | 2 +- src/Engine/Projector/ValidationProjector.php | 2 +- src/Event/ListTransformerEvent.php | 4 +- .../Contao/ElementDcaListener.php | 6 +- .../AddTargetAliasFieldCallback.php | 8 +- .../FieldsLoadAndSaveCallbacks.php | 4 +- .../FlareFilter/FieldsOptionsCallbacks.php | 4 +- .../FlareList/FieldsOptionsCallbacks.php | 4 +- .../NamedDispatch/ListBuildListener.php | 13 +- .../NamedDispatch/ListTransformerListener.php | 16 +- .../Reader/GenericReaderPageMetaListener.php | 2 +- .../Collector/ListModelFilterCollector.php | 21 ++- src/Filter/Element/ArchiveFilterElement.php | 2 +- .../BelongsToRelationFilterElement.php | 2 +- .../Element/DcaSelectFieldFilterElement.php | 6 +- .../Element/FieldValueChoiceFilterElement.php | 4 +- src/Filter/Factory/FilterFactory.php | 60 ++++++++ src/Filter/Factory/FilterFormFactory.php | 6 +- src/Filter/Filter.php | 37 +++-- src/Filter/Resolver/FilterElementResolver.php | 45 ------ .../RegisterTagsTablesListener.php | 2 +- .../ListDriver/EventsListDriver.php | 10 +- .../EventListener/ChangelanguageListener.php | 15 +- src/List/BaseListOptions.php | 6 +- src/List/Driver/AbstractListDriver.php | 5 + src/List/Driver/ListDriverInterface.php | 13 +- src/List/Driver/NewsListDriver.php | 10 +- src/List/Factory/ListSpecBuilderFactory.php | 23 +-- src/List/Factory/ListSpecFactory.php | 51 ++++++- src/List/ListDriverReference.php | 33 ----- src/List/ListSpec.php | 51 +++---- src/List/ListSpecBuilder.php | 45 +++--- src/List/ListSpecBuilderInterface.php | 7 +- src/List/Resolver/ListDriverResolver.php | 49 ------ src/List/Resolver/ListTransformerResolver.php | 8 +- src/Query/Executor/FilterExecutor.php | 22 +-- .../Factory/ListExecutionContextFactory.php | 19 +-- .../Descriptor/FilterElementDescriptor.php | 44 ------ .../Descriptor/ListTypeDescriptor.php | 51 ------- src/Registry/FilterElementRegistry.php | 139 ++++++++++++++++-- src/Registry/ListDriverRegistry.php | 132 +++++++++++++++-- .../Projector/InteractiveProjectorTest.php | 23 ++- .../NamedDispatch/ListBuildListenerTest.php | 79 ++++++++++ tests/Filter/FilterFactoryTest.php | 63 ++++++++ tests/Filter/FilterOptionsResolverTest.php | 6 +- tests/Filter/FilterTest.php | 28 +++- tests/Form/FilterFormFactoryTest.php | 44 +++--- tests/List/BaseListOptionsTest.php | 3 + tests/List/ListSpecBuilderTest.php | 76 +++++++--- tests/List/ListSpecFactoryTest.php | 85 +++++++++++ tests/List/ListSpecTest.php | 64 +++++--- tests/List/ListTransformerResolverTest.php | 60 ++------ tests/Registry/FilterElementRegistryTest.php | 63 ++++++++ tests/Registry/ListDriverRegistryTest.php | 106 +++++++++++++ 69 files changed, 1167 insertions(+), 767 deletions(-) rename src/Contract/{ListType => ListDriver}/BuildListContract.php (100%) rename src/Contract/{ListType => ListDriver}/BuildQueryContract.php (100%) rename src/Contract/{ListType => ListDriver}/DataContainerContract.php (54%) delete mode 100644 src/DependencyInjection/Registry/AbstractServiceDescriptorRegistry.php delete mode 100644 src/DependencyInjection/Registry/ServiceDescriptorInterface.php create mode 100644 src/Filter/Factory/FilterFactory.php delete mode 100644 src/Filter/Resolver/FilterElementResolver.php delete mode 100644 src/List/ListDriverReference.php delete mode 100644 src/List/Resolver/ListDriverResolver.php delete mode 100644 src/Registry/Descriptor/FilterElementDescriptor.php delete mode 100644 src/Registry/Descriptor/ListTypeDescriptor.php create mode 100644 tests/EventListener/NamedDispatch/ListBuildListenerTest.php create mode 100644 tests/Filter/FilterFactoryTest.php create mode 100644 tests/List/ListSpecFactoryTest.php create mode 100644 tests/Registry/FilterElementRegistryTest.php create mode 100644 tests/Registry/ListDriverRegistryTest.php diff --git a/config/services.yaml b/config/services.yaml index f54df7e7..660150a1 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -13,7 +13,6 @@ services: - ../src/{Config,Contao,ContaoManager,Contract,DependencyInjection,Dto,Engine,Event,Integration,Model,Trait,Util} - ../src/{Filter,Form,InferPtable,List,Paginator,Query,Sort}/*.php - ../src/DataContainer/Builder - - ../src/Registry/Descriptor HeimrichHannot\FlareBundle\Engine\: resource: ../src/Engine diff --git a/src/Contract/ListType/BuildListContract.php b/src/Contract/ListDriver/BuildListContract.php similarity index 100% rename from src/Contract/ListType/BuildListContract.php rename to src/Contract/ListDriver/BuildListContract.php diff --git a/src/Contract/ListType/BuildQueryContract.php b/src/Contract/ListDriver/BuildQueryContract.php similarity index 100% rename from src/Contract/ListType/BuildQueryContract.php rename to src/Contract/ListDriver/BuildQueryContract.php diff --git a/src/Contract/ListType/DataContainerContract.php b/src/Contract/ListDriver/DataContainerContract.php similarity index 54% rename from src/Contract/ListType/DataContainerContract.php rename to src/Contract/ListDriver/DataContainerContract.php index 3f70fb2f..89175fff 100644 --- a/src/Contract/ListType/DataContainerContract.php +++ b/src/Contract/ListDriver/DataContainerContract.php @@ -6,7 +6,9 @@ use Contao\DataContainer; +/** @api Implement on a ListDriver to resolve a data container for list config storage. */ interface DataContainerContract { + /** @internal Used internally to resolve the data container table for a given row and data container. */ public function resolveDataContainerTable(array $row, DataContainer $dc): string; } diff --git a/src/DataContainer/ListContainer.php b/src/DataContainer/ListContainer.php index bed5e6de..27d2958b 100644 --- a/src/DataContainer/ListContainer.php +++ b/src/DataContainer/ListContainer.php @@ -19,7 +19,7 @@ class ListContainer public function __construct( private readonly Connection $connection, - private readonly ListDriverRegistry $listTypeRegistry, + private readonly ListDriverRegistry $listDriverRegistry, ) {} /* ============================= * @@ -39,12 +39,10 @@ public function onSubmitConfig(DataContainer $dc): void return; } - if (!$listTypeConfig = $this->listTypeRegistry->get($type)) { + if (!$service = $this->listDriverRegistry->getService($type)) { return; } - $service = $listTypeConfig->getService(); - if (($service instanceof DataContainerContract) && !$expectedDataContainer = $service->resolveDataContainerTable($row, $dc)) { @@ -52,10 +50,11 @@ public function onSubmitConfig(DataContainer $dc): void } // if no data container is set, use the default data container of the list type - $expectedDataContainer ??= $listTypeConfig->getDataContainer(); + $default = $this->listDriverRegistry->getAttribute($type)?->dataContainer; + $expectedDataContainer ??= \is_string($default) ? $default : null; if (!$expectedDataContainer) { - throw new BadRequestHttpException('No data container found for list type ' . $type); + throw new BadRequestHttpException(\sprintf('No data container found for list type "%s".', $type)); } if ($expectedDataContainer !== ($row['dc'] ?? null)) diff --git a/src/DependencyInjection/Attribute/AsFilterElement.php b/src/DependencyInjection/Attribute/AsFilterElement.php index 7705113b..082fcc01 100644 --- a/src/DependencyInjection/Attribute/AsFilterElement.php +++ b/src/DependencyInjection/Attribute/AsFilterElement.php @@ -9,19 +9,17 @@ class AsFilterElement { public const TAG = 'huh.flare.filter_element'; + public ?string $type; public array $attributes; - /** - * @param ?string $type - * @param bool|null $isTargeted - * @param mixed ...$attributes - */ public function __construct( - ?string $type = null, - ?bool $isTargeted = null, - mixed ...$attributes + ?string $type = null, + public ?bool $isTargeted = null, + mixed ...$attributes ) { - $attributes['type'] = $type ?? $attributes['alias'] ?? null; + $this->type = $type ?? $attributes['alias'] ?? null; + + $attributes['type'] = $this->type; $attributes['isTargeted'] = $isTargeted; $this->attributes = $attributes; diff --git a/src/DependencyInjection/Attribute/AsListDriver.php b/src/DependencyInjection/Attribute/AsListDriver.php index 7c01ed41..f0c4b0ca 100644 --- a/src/DependencyInjection/Attribute/AsListDriver.php +++ b/src/DependencyInjection/Attribute/AsListDriver.php @@ -9,14 +9,17 @@ class AsListDriver { public const TAG = 'huh.flare.list_type'; + public ?string $type; public array $attributes; public function __construct( - ?string $type = null, - string|array|null $dataContainer = null, - mixed ...$attributes + ?string $type = null, + public string|array|null $dataContainer = null, + mixed ...$attributes ) { - $attributes['type'] = $type ?? $attributes['alias'] ?? null; + $this->type = $type ?? $attributes['alias'] ?? null; + + $attributes['type'] = $this->type; $attributes['dataContainer'] = $dataContainer; $this->attributes = $attributes; diff --git a/src/DependencyInjection/Compiler/RegisterFilterElementsPass.php b/src/DependencyInjection/Compiler/RegisterFilterElementsPass.php index 0e36945b..e473e281 100644 --- a/src/DependencyInjection/Compiler/RegisterFilterElementsPass.php +++ b/src/DependencyInjection/Compiler/RegisterFilterElementsPass.php @@ -6,14 +6,12 @@ use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\DependencyInjection\Factory\TypeNameFactory; -use HeimrichHannot\FlareBundle\Registry\Descriptor\FilterElementDescriptor; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\Compiler\PriorityTaggedServiceTrait; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; -use Symfony\Component\DependencyInjection\Reference; final class RegisterFilterElementsPass implements CompilerPassInterface { @@ -30,10 +28,6 @@ public function process(ContainerBuilder $container): void foreach ($this->findAndSortTaggedServices($tag, $container) as $reference) { - if (\str_starts_with((string) $reference, 'huh.flare.filter_element._')) { - continue; - } - $definition = $container->findDefinition((string) $reference); $tags = $definition->getTag($tag); $definition->clearTag($tag); @@ -41,17 +35,17 @@ public function process(ContainerBuilder $container): void foreach ($tags as $attributes) { $type = $this->getFilterElementType($definition, $attributes); - $attributes['type'] = $type; $serviceId = 'huh.flare.filter_element.' . $type; $childDefinition = new ChildDefinition((string) $reference); $childDefinition->setPublic(true); - $config = $this->getFilterElementConfig($container, $reference, $attributes); + /** @see AsFilterElement::__construct */ + $attribute = new Definition(AsFilterElement::class, [$type, $attributes['isTargeted'] ?? null]); /** @see FilterElementRegistry::add() */ - $registry->addMethodCall('add', [$type, $config]); + $registry->addMethodCall('add', [$reference, $attribute, $type]); $childDefinition->setTags($definition->getTags()); $container->setDefinition($serviceId, $childDefinition); @@ -59,24 +53,6 @@ public function process(ContainerBuilder $container): void } } - protected function getFilterElementConfig( - ContainerBuilder $container, - Reference $reference, - array $attributes - ): Reference { - /** @see \HeimrichHannot\FlareBundle\Registry\Descriptor\FilterElementDescriptor::__construct */ - $definition = new Definition(FilterElementDescriptor::class, [ - $reference, - $attributes, - $attributes['isTargeted'] ?? null, - ]); - - $serviceId = 'huh.flare.filter_element._config_' . ContainerBuilder::hash($definition); - $container->setDefinition($serviceId, $definition); - - return new Reference($serviceId); - } - protected function getFilterElementType(Definition $definition, array $attributes): string { if ($type = (string) ($attributes['type'] ?? null)) diff --git a/src/DependencyInjection/Compiler/RegisterListDriversPass.php b/src/DependencyInjection/Compiler/RegisterListDriversPass.php index 54d5b7f5..c06f228b 100644 --- a/src/DependencyInjection/Compiler/RegisterListDriversPass.php +++ b/src/DependencyInjection/Compiler/RegisterListDriversPass.php @@ -5,8 +5,6 @@ namespace HeimrichHannot\FlareBundle\DependencyInjection\Compiler; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListDriver; -use HeimrichHannot\FlareBundle\Registry\Descriptor\ListTypeDescriptor; -use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; use HeimrichHannot\FlareBundle\Registry\ListDriverRegistry; use HeimrichHannot\FlareBundle\Util\Str; use Symfony\Component\DependencyInjection\ChildDefinition; @@ -15,7 +13,6 @@ use Symfony\Component\DependencyInjection\Container; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; -use Symfony\Component\DependencyInjection\Reference; final class RegisterListDriversPass implements CompilerPassInterface { @@ -32,10 +29,6 @@ public function process(ContainerBuilder $container): void foreach ($this->findAndSortTaggedServices($tag, $container) as $reference) { - if (\str_starts_with((string) $reference, 'huh.flare.list_type._')) { - continue; - } - $definition = $container->findDefinition((string) $reference); $tags = $definition->getTag($tag); $definition->clearTag($tag); @@ -43,17 +36,17 @@ public function process(ContainerBuilder $container): void foreach ($tags as $attributes) { $type = $this->getListTypeName($definition, $attributes); - $attributes['type'] = $type; $serviceId = 'huh.flare.list_type.' . $type; $childDefinition = new ChildDefinition((string) $reference); $childDefinition->setPublic(true); - $config = $this->getListTypeConfig($container, $reference, $attributes); + /** @see AsListDriver::__construct */ + $attribute = new Definition(AsListDriver::class, [$type, $attributes['dataContainer'] ?? null]); - /** @see FilterElementRegistry::add() */ - $registry->addMethodCall('add', [$type, $config]); + /** @see ListDriverRegistry::add() */ + $registry->addMethodCall('add', [$reference, $attribute, $type]); $childDefinition->setTags($definition->getTags()); $container->setDefinition($serviceId, $childDefinition); @@ -61,24 +54,6 @@ public function process(ContainerBuilder $container): void } } - protected function getListTypeConfig( - ContainerBuilder $container, - Reference $reference, - array $attributes - ): Reference { - /** @see ListTypeDescriptor::__construct */ - $definition = new Definition(ListTypeDescriptor::class, [ - $reference, - $attributes, - $attributes['dataContainer'] ?? null, - ]); - - $serviceId = 'huh.flare.list_type._config_' . ContainerBuilder::hash($definition); - $container->setDefinition($serviceId, $definition); - - return new Reference($serviceId); - } - protected function getListTypeName(Definition $definition, array $attributes): string { if ($type = (string) ($attributes['type'] ?? '')) diff --git a/src/DependencyInjection/Registry/AbstractServiceDescriptorRegistry.php b/src/DependencyInjection/Registry/AbstractServiceDescriptorRegistry.php deleted file mode 100644 index 98b369b0..00000000 --- a/src/DependencyInjection/Registry/AbstractServiceDescriptorRegistry.php +++ /dev/null @@ -1,104 +0,0 @@ - - */ - private array $elements = []; - - /** - * Returns the class name of the config class. - * - * @return class-string - */ - abstract public function getDescriptorClass(): string; - - /** - * Registers a new filter element. - * - * @param TNamespace $alias - * @param TDescriptor $descriptor - * - * @throws \InvalidArgumentException if the config is not an instance of the expected class. - */ - public function add(string $alias, ServiceDescriptorInterface $descriptor): static - { - if (!\is_a($descriptor, $this->getDescriptorClass())) { - throw new \InvalidArgumentException('Config must be an instance of ' . $this->getDescriptorClass() . '.'); - } - - $this->elements[$alias] = $descriptor; - - return $this; - } - - /** - * Removes a filter element from the registry. - * - * @param TNamespace $alias - */ - public function remove(string $alias): static - { - unset($this->elements[$alias]); - - return $this; - } - - /** - * Checks if a filter element is registered. - * - * @param TNamespace $alias - */ - public function has(string $alias): bool - { - return isset($this->elements[$alias]); - } - - /** - * Returns a specific filter element by its alias. - * - * @param ?TNamespace $alias - * @return ?TDescriptor - */ - public function get(?string $alias): ?ServiceDescriptorInterface - { - if ($alias === null) { - return null; - } - - return $this->elements[$alias] ?? null; - } - - /** - * Returns all registered filter elements. - * - * @return array - */ - public function all(): array - { - return $this->elements; - } - - /** - * Returns all registered filter element aliases. - * - * @return TNamespace[] - */ - public function keys(): array - { - return \array_keys($this->elements); - } -} \ No newline at end of file diff --git a/src/DependencyInjection/Registry/ServiceDescriptorInterface.php b/src/DependencyInjection/Registry/ServiceDescriptorInterface.php deleted file mode 100644 index f7449747..00000000 --- a/src/DependencyInjection/Registry/ServiceDescriptorInterface.php +++ /dev/null @@ -1,12 +0,0 @@ -filterFactory, listQueryDirector: $this->listQueryDirector, ); } diff --git a/src/Engine/Loader/ValidationLoader.php b/src/Engine/Loader/ValidationLoader.php index edbd12dd..0f83df74 100644 --- a/src/Engine/Loader/ValidationLoader.php +++ b/src/Engine/Loader/ValidationLoader.php @@ -8,7 +8,7 @@ use HeimrichHannot\FlareBundle\Enum\SqlEquationOperator; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\Element\SimpleEquationFilterElement; -use HeimrichHannot\FlareBundle\Filter\Filter; +use HeimrichHannot\FlareBundle\Filter\Factory\FilterFactory; use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Query\Executor\ListQueryDirector; use HeimrichHannot\FlareBundle\Query\ListQueryConfig; @@ -17,6 +17,7 @@ { public function __construct( private ValidationLoaderConfig $config, + private FilterFactory $filterFactory, private ListQueryDirector $listQueryDirector, ) {} @@ -33,8 +34,8 @@ public function fetchEntryById(int $id): ?array try { - $idDefinition = new Filter( - type: SimpleEquationFilterElement::TYPE, + $idDefinition = $this->filterFactory->create( + element: SimpleEquationFilterElement::TYPE, config: [ 'intrinsic' => true, 'left' => 'id', @@ -68,8 +69,8 @@ public function fetchEntryByAutoItem(string $autoItem): ?array try { - $autoItemDefinition = new Filter( - type: SimpleEquationFilterElement::TYPE, + $autoItemDefinition = $this->filterFactory->create( + element: SimpleEquationFilterElement::TYPE, config: [ 'intrinsic' => true, 'left' => $this->config->autoItemField, diff --git a/src/Engine/Mod/SimpleEquationMod.php b/src/Engine/Mod/SimpleEquationMod.php index 9ed9e1ca..1a447e2d 100644 --- a/src/Engine/Mod/SimpleEquationMod.php +++ b/src/Engine/Mod/SimpleEquationMod.php @@ -7,11 +7,15 @@ use HeimrichHannot\FlareBundle\Engine\Engine; use HeimrichHannot\FlareBundle\Enum\SqlEquationOperator; use HeimrichHannot\FlareBundle\Filter\Element\SimpleEquationFilterElement; -use HeimrichHannot\FlareBundle\Filter\Filter; +use HeimrichHannot\FlareBundle\Filter\Factory\FilterFactory; use Symfony\Component\OptionsResolver\OptionsResolver; class SimpleEquationMod extends AbstractMod { + public function __construct( + private readonly FilterFactory $filterFactory, + ) {} + public static function getType(): string { return 'equation'; @@ -19,8 +23,8 @@ public static function getType(): string public function __invoke(Engine $engine, array $options): void { - $filter = new Filter( - type: SimpleEquationFilterElement::TYPE, + $filter = $this->filterFactory->create( + element: SimpleEquationFilterElement::TYPE, config: [ 'intrinsic' => true, 'left' => $options['operand1'], diff --git a/src/Engine/Projector/AbstractProjector.php b/src/Engine/Projector/AbstractProjector.php index fbbca4ec..62ae23dc 100644 --- a/src/Engine/Projector/AbstractProjector.php +++ b/src/Engine/Projector/AbstractProjector.php @@ -12,7 +12,6 @@ use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Query\Executor\ListQueryDirector; use HeimrichHannot\FlareBundle\Query\ListQueryConfig; -use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; use HeimrichHannot\FlareBundle\Registry\ProjectorRegistry; use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; @@ -35,7 +34,6 @@ public function setContainer(ContainerInterface $container): void public static function getSubscribedServices(): array { return [ - FilterElementRegistry::class, ListQueryDirector::class, ProjectorRegistry::class, RequestStack::class, @@ -64,11 +62,6 @@ public function priority(ListSpec $list, ContextInterface $context): int */ abstract public function project(ListSpec $list, ContextInterface $context): ViewInterface; - protected function getFilterElementRegistry(): FilterElementRegistry - { - return $this->container->get(FilterElementRegistry::class); - } - protected function getListQueryDirector(): ListQueryDirector { return $this->container->get(ListQueryDirector::class); diff --git a/src/Engine/Projector/InteractiveProjector.php b/src/Engine/Projector/InteractiveProjector.php index e2443316..3fd9e254 100644 --- a/src/Engine/Projector/InteractiveProjector.php +++ b/src/Engine/Projector/InteractiveProjector.php @@ -79,7 +79,7 @@ public function project(ListSpec $list, ContextInterface $context): InteractiveV form: $form, paginator: $paginator, readerUrlGenerator: $readerUrlGenerator, - table: $list->dc, + table: $list->getDataContainerName(), totalItems: $totalItems, ); } diff --git a/src/Engine/Projector/ValidationProjector.php b/src/Engine/Projector/ValidationProjector.php index 97c40b3e..56755dce 100644 --- a/src/Engine/Projector/ValidationProjector.php +++ b/src/Engine/Projector/ValidationProjector.php @@ -48,7 +48,7 @@ public function project(ListSpec $list, ContextInterface $context): ValidationVi return $this->createView( loader: $loader, readerUrlGenerator: $readerUrlGenerator, - table: $list->dc, + table: $list->getDataContainerName(), autoItemField: $autoItemField, backLink: $context->createBackLink(), ); diff --git a/src/Event/ListTransformerEvent.php b/src/Event/ListTransformerEvent.php index a5d59957..847a7b14 100644 --- a/src/Event/ListTransformerEvent.php +++ b/src/Event/ListTransformerEvent.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\Event; use HeimrichHannot\FlareBundle\Config\TransformerResolver; -use HeimrichHannot\FlareBundle\List\ListDriverReference; +use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use Symfony\Contracts\EventDispatcher\Event; /** @@ -17,6 +17,6 @@ class ListTransformerEvent extends Event { public function __construct( public readonly TransformerResolver $transformers, - public readonly ListDriverReference $reference, + public readonly ListDriverInterface $driver, ) {} } diff --git a/src/EventListener/Contao/ElementDcaListener.php b/src/EventListener/Contao/ElementDcaListener.php index d67a2b4f..95be9196 100644 --- a/src/EventListener/Contao/ElementDcaListener.php +++ b/src/EventListener/Contao/ElementDcaListener.php @@ -36,7 +36,7 @@ public function __construct( private FilterElementRegistry $filterElementRegistry, private ListExecutionContextFactory $listExecutionContextFactory, private ListSpecBuilderFactory $listFactory, - private ListDriverRegistry $listTypeRegistry, + private ListDriverRegistry $listDriverRegistry, private RequestStack $requestStack, ) {} @@ -70,7 +70,7 @@ private function configure(string $table): void $filterModel = FilterModel::findByPk($id); $listModel = $filterModel?->getRelated('pid'); $type = (string) ($filterModel->type ?? ''); - $service = $this->filterElementRegistry->get($type)?->getService(); + $service = $this->filterElementRegistry->getService($type); } /** @mago-expect lint:no-else-clause This else clause is fine. */ else @@ -78,7 +78,7 @@ private function configure(string $table): void $filterModel = null; $listModel = ListModel::findByPk($id); $type = (string) ($listModel->type ?? ''); - $service = $this->listTypeRegistry->get($type)?->getService(); + $service = $this->listDriverRegistry->getService($type); } if (!$listModel instanceof ListModel || !$type || $type === 'default' || \str_starts_with($type, '__')) { diff --git a/src/EventListener/DataContainer/FlareFilter/AddTargetAliasFieldCallback.php b/src/EventListener/DataContainer/FlareFilter/AddTargetAliasFieldCallback.php index fd10b8f2..00e4d9f4 100644 --- a/src/EventListener/DataContainer/FlareFilter/AddTargetAliasFieldCallback.php +++ b/src/EventListener/DataContainer/FlareFilter/AddTargetAliasFieldCallback.php @@ -42,11 +42,7 @@ public function __invoke(?DataContainer $dc = null): void return; } - if (!$descriptor = $this->filterElementRegistry->get($filterModel->type)) { - return; - } - - if (!$descriptor->isTargeted()) { + if (!$this->filterElementRegistry->getAttribute($filterModel->type)?->isTargeted) { return; } @@ -56,4 +52,4 @@ public function __invoke(?DataContainer $dc = null): void ->addField('targetAlias', 'intrinsic') ->applyToString('' . $prefix); } -} \ No newline at end of file +} diff --git a/src/EventListener/DataContainer/FlareFilter/FieldsLoadAndSaveCallbacks.php b/src/EventListener/DataContainer/FlareFilter/FieldsLoadAndSaveCallbacks.php index 1ba9f50d..c178a821 100644 --- a/src/EventListener/DataContainer/FlareFilter/FieldsLoadAndSaveCallbacks.php +++ b/src/EventListener/DataContainer/FlareFilter/FieldsLoadAndSaveCallbacks.php @@ -80,7 +80,7 @@ public function onLoadField_intrinsic(mixed $value, DataContainer $dc): bool return $value; } - $filterElement = $this->filterElementRegistry->get($row['type'] ?? null)?->getService(); + $filterElement = $this->filterElementRegistry->getService($row['type'] ?? null); if ($filterElement instanceof IntrinsicContract && $filterElement->isOnlyIntrinsic()) { @@ -101,7 +101,7 @@ public function onSaveField_intrinsic(mixed $value, DataContainer $dc): mixed return $value; } - $element = $this->filterElementRegistry->get($row['type'] ?? null)?->getService(); + $element = $this->filterElementRegistry->getService($row['type'] ?? null); if ($element instanceof IntrinsicContract && $element->isOnlyIntrinsic()) { return '1'; diff --git a/src/EventListener/DataContainer/FlareFilter/FieldsOptionsCallbacks.php b/src/EventListener/DataContainer/FlareFilter/FieldsOptionsCallbacks.php index 7cb04f38..58b99f5a 100644 --- a/src/EventListener/DataContainer/FlareFilter/FieldsOptionsCallbacks.php +++ b/src/EventListener/DataContainer/FlareFilter/FieldsOptionsCallbacks.php @@ -45,9 +45,9 @@ public function getFieldOptions_type(): array { $options = []; - foreach ($this->filterElementRegistry->all() as $type => $filterElementDescriptor) + foreach ($this->filterElementRegistry->keys() as $type) { - $filterElement = $filterElementDescriptor->getService(); + $filterElement = $this->filterElementRegistry->getService($type); if ($filterElement instanceof IsSupportedContract && !$filterElement->isSupported()) { diff --git a/src/EventListener/DataContainer/FlareList/FieldsOptionsCallbacks.php b/src/EventListener/DataContainer/FlareList/FieldsOptionsCallbacks.php index 1261671a..6c862c6e 100644 --- a/src/EventListener/DataContainer/FlareList/FieldsOptionsCallbacks.php +++ b/src/EventListener/DataContainer/FlareList/FieldsOptionsCallbacks.php @@ -26,7 +26,7 @@ public function __construct( private ContaoFramework $contaoFramework, private ListContainer $listContainer, - private ListDriverRegistry $listTypeRegistry, + private ListDriverRegistry $listDriverRegistry, private ResourceFinderInterface $resourceFinder, private TranslatorInterface $translator, ) {} @@ -39,7 +39,7 @@ public function getTypeOptions(): array { $options = []; - foreach ($this->listTypeRegistry->all() as $type => $listTypeConfig) + foreach ($this->listDriverRegistry->keys() as $type) { $options[$type] = $this->translator->trans($type, [], 'flare_list'); } diff --git a/src/EventListener/NamedDispatch/ListBuildListener.php b/src/EventListener/NamedDispatch/ListBuildListener.php index 66391204..a46e6265 100644 --- a/src/EventListener/NamedDispatch/ListBuildListener.php +++ b/src/EventListener/NamedDispatch/ListBuildListener.php @@ -5,6 +5,7 @@ namespace HeimrichHannot\FlareBundle\EventListener\NamedDispatch; use HeimrichHannot\FlareBundle\Event\ListBuildEvent; +use HeimrichHannot\FlareBundle\Registry\ListDriverRegistry; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -12,17 +13,19 @@ { public function __construct( private EventDispatcherInterface $eventDispatcher, + private ListDriverRegistry $listDriverRegistry, ) {} #[AsEventListener(priority: -200)] public function __invoke(ListBuildEvent $event): void { - $reference = $event->builder->getDriverReference(); + foreach ($this->listDriverRegistry->getTypes($event->builder->getDriver()) as $type) + { + $this->eventDispatcher->dispatch(event: $event, eventName: "flare.list.{$type}.build"); - if ($reference->inline) { - return; + if ($event->isPropagationStopped()) { + break; + } } - - $this->eventDispatcher->dispatch(event: $event, eventName: "flare.list.{$reference->type}.build"); } } diff --git a/src/EventListener/NamedDispatch/ListTransformerListener.php b/src/EventListener/NamedDispatch/ListTransformerListener.php index 08fcb92b..0c378f98 100644 --- a/src/EventListener/NamedDispatch/ListTransformerListener.php +++ b/src/EventListener/NamedDispatch/ListTransformerListener.php @@ -5,6 +5,7 @@ namespace HeimrichHannot\FlareBundle\EventListener\NamedDispatch; use HeimrichHannot\FlareBundle\Event\ListTransformerEvent; +use HeimrichHannot\FlareBundle\Registry\ListDriverRegistry; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -12,18 +13,19 @@ { public function __construct( private EventDispatcherInterface $eventDispatcher, + private ListDriverRegistry $listDriverRegistry, ) {} #[AsEventListener(priority: -200)] public function __invoke(ListTransformerEvent $event): void { - if ($event->reference->inline) { - return; - } + foreach ($this->listDriverRegistry->getTypes($event->driver) as $type) + { + $this->eventDispatcher->dispatch(event: $event, eventName: "flare.list.{$type}.transformers"); - $this->eventDispatcher->dispatch( - event: $event, - eventName: "flare.list.{$event->reference->type}.transformers", - ); + if ($event->isPropagationStopped()) { + break; + } + } } } diff --git a/src/EventListener/Reader/GenericReaderPageMetaListener.php b/src/EventListener/Reader/GenericReaderPageMetaListener.php index 7687ea3f..3cfe726a 100644 --- a/src/EventListener/Reader/GenericReaderPageMetaListener.php +++ b/src/EventListener/Reader/GenericReaderPageMetaListener.php @@ -43,7 +43,7 @@ public function __invoke(ReaderPageMetaEvent $event): void $tokens = [ 'list.driver_class' => \get_class($list->driver), - 'list.dc' => $list->dc, + 'list.dc' => $list->getDataContainerName(), ]; $this->addTokensFromProperties($tokens, $list->config, prefix: 'list'); diff --git a/src/Filter/Collector/ListModelFilterCollector.php b/src/Filter/Collector/ListModelFilterCollector.php index f6d9cfb9..59aa21da 100644 --- a/src/Filter/Collector/ListModelFilterCollector.php +++ b/src/Filter/Collector/ListModelFilterCollector.php @@ -7,11 +7,12 @@ use Contao\Controller; use HeimrichHannot\FlareBundle\Event\FilterCollectedEvent; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\Filter\Resolver\FilterElementResolver; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterTransformerResolver; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Model\ListModel; +use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; use HeimrichHannot\FlareBundle\Registry\ListDriverRegistry; +use Psr\Log\LoggerInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; /** @@ -22,9 +23,10 @@ { public function __construct( private EventDispatcherInterface $eventDispatcher, - private FilterElementResolver $filterElementResolver, + private FilterElementRegistry $filterElementRegistry, private FilterTransformerResolver $filterTransformerResolver, - private ListDriverRegistry $listTypeRegistry, + private ListDriverRegistry $listDriverRegistry, + private LoggerInterface $logger, ) {} /** @@ -36,7 +38,7 @@ public function collect(ListModel $listModel): ?array return null; } - if (!$this->listTypeRegistry->get((string) $listModel->type)?->getService()) { + if (!$this->listDriverRegistry->getService((string) $listModel->type)) { return null; } @@ -53,8 +55,16 @@ public function collect(ListModel $listModel): ?array } $source = "{$model::getTable()}.{$model->id}"; + $type = $model->getFilterType(); + + if (!$element = $this->filterElementRegistry->getService($type)) + { + $this->logger->warning(\sprintf( + '[FLARE] No filter element registered for type "%s" — filter skipped. (%s)', + $type, + $source, + )); - if (!$element = $this->filterElementResolver->resolveType($model->getFilterType(), $source)) { continue; } @@ -62,6 +72,7 @@ public function collect(ListModel $listModel): ?array ?? $model->row(); $filter = new Filter( + element: $element, type: $model->getFilterType(), config: $config, alias: $model->getFilterFormName() ?: "_.{$source}", diff --git a/src/Filter/Element/ArchiveFilterElement.php b/src/Filter/Element/ArchiveFilterElement.php index 616dc8d2..a8b89d95 100644 --- a/src/Filter/Element/ArchiveFilterElement.php +++ b/src/Filter/Element/ArchiveFilterElement.php @@ -401,7 +401,7 @@ private function getPtableInferrer(ListSpec $list): PtableInferrer } $inferrable = PtableInferrableFactory::createFromConfig($list->config); - return $this->_inferrer[$cacheKey] = new PtableInferrer($inferrable, $list->dc); + return $this->_inferrer[$cacheKey] = new PtableInferrer($inferrable, $list->getDataContainerName()); } public function buildDca(DcaBuilder $dca, DcaContext $context): void diff --git a/src/Filter/Element/BelongsToRelationFilterElement.php b/src/Filter/Element/BelongsToRelationFilterElement.php index 0c635e42..b2e88fca 100644 --- a/src/Filter/Element/BelongsToRelationFilterElement.php +++ b/src/Filter/Element/BelongsToRelationFilterElement.php @@ -70,7 +70,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont } $inferrable = PtableInferrableFactory::createFromConfig($context->list->config); - $inferrer = new PtableInferrer($inferrable, $context->list->dc); + $inferrer = new PtableInferrer($inferrable, $context->list->getDataContainerName()); try { diff --git a/src/Filter/Element/DcaSelectFieldFilterElement.php b/src/Filter/Element/DcaSelectFieldFilterElement.php index a78382a3..59f3637b 100644 --- a/src/Filter/Element/DcaSelectFieldFilterElement.php +++ b/src/Filter/Element/DcaSelectFieldFilterElement.php @@ -73,7 +73,7 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co 'placeholder' => $config['placeholder'] ?: $defaultPlaceholder, ]; - $options = $this->getOptions($context->list->dc, $config['field']); + $options = $this->getOptions($context->list->getDataContainerName(), $config['field']); if (!\is_null($options)) { @@ -98,7 +98,7 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void { $config = $context->config; - $options = $this->getOptions($context->list->dc, $config['field']) ?? []; + $options = $this->getOptions($context->list->getDataContainerName(), $config['field']) ?? []; $selected = $config['intrinsic'] ? $config['preselect'] @@ -120,7 +120,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont $builder->abort(); } - $dcaOptionsField = $this->getOptionsField($context->list->dc, $config['field']) ?? []; + $dcaOptionsField = $this->getOptionsField($context->list->getDataContainerName(), $config['field']) ?? []; $isMultiple = $dcaOptionsField['eval']['multiple'] ?? false; $builder->add(DcaSelectFilterType::class, [ diff --git a/src/Filter/Element/FieldValueChoiceFilterElement.php b/src/Filter/Element/FieldValueChoiceFilterElement.php index 6a02fa7e..3027119e 100644 --- a/src/Filter/Element/FieldValueChoiceFilterElement.php +++ b/src/Filter/Element/FieldValueChoiceFilterElement.php @@ -65,7 +65,7 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co return; } - $choicesBuilder = $this->createChoices($context->list->dc, (string) ($config['field'] ?? '')) + $choicesBuilder = $this->createChoices($context->list->getDataContainerName(), (string) ($config['field'] ?? '')) ->setEmptyOption(!$config['multiple']); $formOptions = [ @@ -203,7 +203,7 @@ private function normalizeRuntimeValue(mixed $value, FilterContext $context): ?a return null; } - $choicesBuilder = $this->createChoices($context->list->dc, (string) ($context->config['field'] ?? '')); + $choicesBuilder = $this->createChoices($context->list->getDataContainerName(), (string) ($context->config['field'] ?? '')); $choices = $choicesBuilder->buildChoices(); $toValue = $choicesBuilder->buildChoiceValueCallback(); diff --git a/src/Filter/Factory/FilterFactory.php b/src/Filter/Factory/FilterFactory.php new file mode 100644 index 00000000..4abc550e --- /dev/null +++ b/src/Filter/Factory/FilterFactory.php @@ -0,0 +1,60 @@ + $config + * @param array|null $data + * + * @throws FlareException In case no filter element is registered under the given type alias. + * + * @see Filter::__construct for the remaining parameters. + */ + public function create( + FilterElementInterface|string $element, + array $config = [], + ?array $data = null, + ?string $alias = null, + ?string $targetAlias = null, + bool $targetingForced = false, + ?string $source = null, + ): Filter { + $type = null; + + if (\is_string($element)) + { + $type = $element; + + $element = $this->filterElementRegistry->getService($type) + ?? throw new FlareException(\sprintf('Filter element type "%s" not found', $type)); + } + + return new Filter( + element: $element, + type: $type, + config: $config, + data: $data, + alias: $alias, + targetAlias: $targetAlias, + targetingForced: $targetingForced, + source: $source, + ); + } +} diff --git a/src/Filter/Factory/FilterFormFactory.php b/src/Filter/Factory/FilterFormFactory.php index d5c934bb..411f9e6c 100644 --- a/src/Filter/Factory/FilterFormFactory.php +++ b/src/Filter/Factory/FilterFormFactory.php @@ -12,7 +12,6 @@ use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilder; -use HeimrichHannot\FlareBundle\Filter\Resolver\FilterElementResolver; use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Util\Str; use Symfony\Component\EventDispatcher\EventDispatcher; @@ -27,7 +26,6 @@ public function __construct( private EventDispatcherInterface $eventDispatcher, private FilterContextFactory $filterContextFactory, - private FilterElementResolver $filterElementResolver, private FormFactoryInterface $formFactory, ) {} @@ -65,9 +63,7 @@ public function create(ListSpec $list, FormContextInterface $context): FormInter continue; } - if (!$element = $this->filterElementResolver->resolve($filter)) { - continue; - } + $element = $filter->element; $filterContext = $this->filterContextFactory->create($list, $filter, $element, $context, $key); diff --git a/src/Filter/Filter.php b/src/Filter/Filter.php index 0a6c9a91..9f197a02 100644 --- a/src/Filter/Filter.php +++ b/src/Filter/Filter.php @@ -4,18 +4,24 @@ namespace HeimrichHannot\FlareBundle\Filter; +use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; + /** * Immutable runtime representation of a single filter within a list. * - * Pairs a filter element (referenced by its registered type alias) with its canonical, - * element-defined configuration. Contains no DCA/storage specifics — translating a stored - * source into config is the element's transformer responsibility + * Pairs a filter element instance with its canonical, element-defined configuration. + * Contains no DCA/storage specifics — translating a stored source into config is the + * element's transformer responsibility * ({@see \HeimrichHannot\FlareBundle\Contract\TransformerContract}). + * + * Use {@see Factory\FilterFactory} to create filters from a registered type alias. */ final readonly class Filter { /** - * @param string $type Registered element type alias. + * @param FilterElementInterface $element Filter element service (registered or inline). + * @param string|null $type Registered element type alias, if known. Only used for named + * event dispatch (`flare.filter_element.{type}.*`) and targeting lookups. * @param array $config Canonical config (element-defined schema); scalars, arrays, and enums only. * @param array|null $data Runtime data bag, same shape buildFilter() receives * (single-field elements read {@see FilterContext::SINGLE_VALUE}). Submitted form @@ -27,13 +33,14 @@ * @param string|null $source Provenance for error messages, e.g. "tl_flare_filter.42". */ public function __construct( - public string $type, - public array $config = [], - public ?array $data = null, - public ?string $alias = null, - public ?string $targetAlias = null, - public bool $targetingForced = false, - public ?string $source = null, + public FilterElementInterface $element, + public ?string $type = null, + public array $config = [], + public ?array $data = null, + public ?string $alias = null, + public ?string $targetAlias = null, + public bool $targetingForced = false, + public ?string $source = null, ) {} /** @@ -42,6 +49,7 @@ public function __construct( public function withConfig(array $config): self { return new self( + element: $this->element, type: $this->type, config: $config, data: $this->data, @@ -58,6 +66,7 @@ public function withConfig(array $config): self public function withData(?array $data): self { return new self( + element: $this->element, type: $this->type, config: $this->config, data: $data, @@ -71,6 +80,7 @@ public function withData(?array $data): self public function withAlias(?string $alias): self { return new self( + element: $this->element, type: $this->type, config: $this->config, data: $this->data, @@ -84,6 +94,7 @@ public function withAlias(?string $alias): self public function withTargetAlias(?string $targetAlias, bool $forced = true): self { return new self( + element: $this->element, type: $this->type, config: $this->config, data: $this->data, @@ -97,13 +108,14 @@ public function withTargetAlias(?string $targetAlias, bool $forced = true): self public function withSource(?string $source): self { return new self( + element: $this->element, type: $this->type, config: $this->config, data: $this->data, alias: $this->alias, targetAlias: $this->targetAlias, targetingForced: $this->targetingForced, - source: $source + source: $source, ); } @@ -113,6 +125,7 @@ public function withSource(?string $source): self public function fingerprint(): array { return [ + 'element' => \get_class($this->element), 'type' => $this->type, 'config' => $this->config, 'data' => $this->data, diff --git a/src/Filter/Resolver/FilterElementResolver.php b/src/Filter/Resolver/FilterElementResolver.php deleted file mode 100644 index c0777550..00000000 --- a/src/Filter/Resolver/FilterElementResolver.php +++ /dev/null @@ -1,45 +0,0 @@ -resolveType($filter->type, $filter->source); - } - - public function resolveType(?string $type, ?string $source = null): ?FilterElementInterface - { - $service = $this->filterElementRegistry->get((string) $type)?->getService(); - - if (!$service instanceof FilterElementInterface) - { - $this->logger->warning(\sprintf( - '[FLARE] No filter element registered for type "%s" — filter skipped. (%s)', - $type, - $source ?: 'no source', - )); - - return null; - } - - return $service; - } -} diff --git a/src/Integration/CodefogTags/EventListener/RegisterTagsTablesListener.php b/src/Integration/CodefogTags/EventListener/RegisterTagsTablesListener.php index ff3fd5e7..8d318809 100644 --- a/src/Integration/CodefogTags/EventListener/RegisterTagsTablesListener.php +++ b/src/Integration/CodefogTags/EventListener/RegisterTagsTablesListener.php @@ -28,7 +28,7 @@ public function __construct( public function __invoke(QueryBaseInitializedEvent $event): void { - $table = $event->list->dc; + $table = $event->list->getDataContainerName(); if (!$columns = $this->managersRegistry->fieldsOf($table)) { return; } diff --git a/src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php b/src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php index 9b9ba8e6..635f0c53 100644 --- a/src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php +++ b/src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php @@ -9,7 +9,7 @@ use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListDriver; use HeimrichHannot\FlareBundle\Filter\Element\PublishedFilterElement; -use HeimrichHannot\FlareBundle\Filter\Filter; +use HeimrichHannot\FlareBundle\Filter\Factory\FilterFactory; use HeimrichHannot\FlareBundle\List\ListSpecBuilder; use HeimrichHannot\FlareBundle\List\Driver\AbstractListDriver; use HeimrichHannot\FlareBundle\Query\JoinTypeEnum; @@ -23,6 +23,10 @@ class EventsListDriver extends AbstractListDriver implements BuildListContract public const DATA_CONTAINER = 'tl_calendar_events'; public const ALIAS_ARCHIVE = 'events_archive'; + public function __construct( + private readonly FilterFactory $filterFactory, + ) {} + public function buildDca(DcaBuilder $dca, DcaContext $context): void { $dca->suffix(static function (string $suffix): string { @@ -57,8 +61,8 @@ public function buildList(ListSpecBuilder $builder): void return; } - $builder->addFilter(new Filter( - type: PublishedFilterElement::TYPE, + $builder->addFilter($this->filterFactory->create( + element: PublishedFilterElement::TYPE, config: [ 'intrinsic' => true, 'published_field' => 'published', diff --git a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php index 7fd80e52..9eb89419 100644 --- a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php +++ b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php @@ -16,7 +16,7 @@ use HeimrichHannot\FlareBundle\Event\FetchCountEvent; use HeimrichHannot\FlareBundle\Event\FetchListEntriesEvent; use HeimrichHannot\FlareBundle\Filter\Element\SimpleEquationFilterElement; -use HeimrichHannot\FlareBundle\Filter\Filter; +use HeimrichHannot\FlareBundle\Filter\Factory\FilterFactory; use HeimrichHannot\FlareBundle\Integration\Terminal42Languages\ListType\DcMultilingualListType; use HeimrichHannot\FlareBundle\Query\JoinTypeEnum; use HeimrichHannot\FlareBundle\Query\ListQueryBuilder; @@ -37,6 +37,7 @@ class ChangelanguageListener public function __construct( private readonly Connection $connection, + private readonly FilterFactory $filterFactory, private readonly ReaderRequestAttributeResolver $attributeResolver, private readonly RequestStack $requestStack, ) {} @@ -55,7 +56,7 @@ public function fetchAutoItem(FetchAutoItemEvent $event): void { $list = $event->getList(); - if ($list->type !== DcMultilingualListType::TYPE) { + if (!$list->driver instanceof DcMultilingualListType) { return; } @@ -63,7 +64,7 @@ public function fetchAutoItem(FetchAutoItemEvent $event): void return; } - $table = $list->dc; + $table = $list->getDataContainerName(); $this->applyMlQueriesIfNecessary( $event->getListQueryBuilder(), @@ -132,8 +133,8 @@ public function listViewFetchCountEvent(FetchCountEvent $event): void if ($lang !== $langFallback && $dcMultilingualDisplay === DcMultilingualHelper::DISPLAY_LOCALIZED) // localized list view { - $configuredFilter = new Filter( - type: SimpleEquationFilterElement::TYPE, + $configuredFilter = $this->filterFactory->create( + element: SimpleEquationFilterElement::TYPE, config: [ 'intrinsic' => true, 'left' => DcMultilingualHelper::getPidColumn($table), @@ -144,8 +145,8 @@ public function listViewFetchCountEvent(FetchCountEvent $event): void $configuredFilter = $configuredFilter->withTargetAlias('translation'); } - $configuredFilter ??= new Filter( - type: SimpleEquationFilterElement::TYPE, + $configuredFilter ??= $this->filterFactory->create( + element: SimpleEquationFilterElement::TYPE, config: [ 'intrinsic' => true, 'left' => DcMultilingualHelper::getPidColumn($table), diff --git a/src/List/BaseListOptions.php b/src/List/BaseListOptions.php index 7e93aff9..34da78d2 100644 --- a/src/List/BaseListOptions.php +++ b/src/List/BaseListOptions.php @@ -21,9 +21,8 @@ final class BaseListOptions { public static function configureOptions(OptionsResolver $resolver): void { - $resolver->define('id')->default(null)->allowedTypes('int', 'null'); + $resolver->define('dc')->default('')->allowedTypes('string')->required(); $resolver->define('title')->default('')->allowedTypes('string'); - $resolver->define('published')->default(false)->allowedTypes('bool'); $resolver->define('jumpToListView')->default(null)->allowedTypes('int', 'null'); $resolver->define('jumpToReader')->default(null)->allowedTypes('int', 'null'); $resolver->define('sortSettings')->default([])->allowedTypes('array'); @@ -45,9 +44,8 @@ public static function configureOptions(OptionsResolver $resolver): void public static function transform(ConfigBuilder $config, ListModel $model): void { $config - ->set('id', $model->id ? (int) $model->id : null) + ->set('dc', (string) $model->dc) ->set('title', (string) $model->title) - ->set('published', (bool) $model->published) ->set('jumpToListView', $model->jumpToListView ? (int) $model->jumpToListView : null) ->set('jumpToReader', $model->jumpToReader ? (int) $model->jumpToReader : null) ->set('sortSettings', StringUtil::deserialize($model->sortSettings, true)) diff --git a/src/List/Driver/AbstractListDriver.php b/src/List/Driver/AbstractListDriver.php index 5643b753..17136032 100644 --- a/src/List/Driver/AbstractListDriver.php +++ b/src/List/Driver/AbstractListDriver.php @@ -21,6 +21,11 @@ abstract class AbstractListDriver implements ListDriverInterface, BuildQueryContract, DcaContract, OptionsContract, TransformerContract { + public function getDataContainerName(array $config): string + { + return (string) ($config['dc'] ?? ''); + } + /** * Declares the type's config schema on top of {@see \HeimrichHannot\FlareBundle\List\BaseListOptions}. */ diff --git a/src/List/Driver/ListDriverInterface.php b/src/List/Driver/ListDriverInterface.php index 9ca7f881..385b5b9b 100644 --- a/src/List/Driver/ListDriverInterface.php +++ b/src/List/Driver/ListDriverInterface.php @@ -5,6 +5,15 @@ namespace HeimrichHannot\FlareBundle\List\Driver; /** - * Marker for FLARE list types — registered via #[AsListType] or used inline on a ListSpec. + * A FLARE list driver — registered via #[AsListDriver] or used inline on a ListSpec. */ -interface ListDriverInterface {} +interface ListDriverInterface +{ + /** + * Returns the main data container table of a list, derived from its canonical config. + * Drivers pinned to a single table may ignore the config and return that table. + * + * @param array $config Canonical, resolved list config. + */ + public function getDataContainerName(array $config): string; +} diff --git a/src/List/Driver/NewsListDriver.php b/src/List/Driver/NewsListDriver.php index 7936e48b..792e9649 100644 --- a/src/List/Driver/NewsListDriver.php +++ b/src/List/Driver/NewsListDriver.php @@ -9,7 +9,7 @@ use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListDriver; use HeimrichHannot\FlareBundle\Filter\Element\PublishedFilterElement; -use HeimrichHannot\FlareBundle\Filter\Filter; +use HeimrichHannot\FlareBundle\Filter\Factory\FilterFactory; use HeimrichHannot\FlareBundle\List\ListSpecBuilder; use HeimrichHannot\FlareBundle\Query\JoinTypeEnum; use HeimrichHannot\FlareBundle\Query\SqlJoinStruct; @@ -21,6 +21,10 @@ class NewsListDriver extends AbstractListDriver implements BuildListContract public const TYPE = 'flare_news'; public const ALIAS_ARCHIVE = 'news_archive'; + public function __construct( + private readonly FilterFactory $filterFactory, + ) {} + public function buildDca(DcaBuilder $dca, DcaContext $context): void { $dca->palette('{filter_legend},'); @@ -43,8 +47,8 @@ public function buildList(ListSpecBuilder $builder): void return; } - $builder->addFilter(new Filter( - type: PublishedFilterElement::TYPE, + $builder->addFilter($this->filterFactory->create( + element: PublishedFilterElement::TYPE, config: [ 'intrinsic' => true, 'published_field' => 'published', diff --git a/src/List/Factory/ListSpecBuilderFactory.php b/src/List/Factory/ListSpecBuilderFactory.php index ba7a894e..1d05c30b 100644 --- a/src/List/Factory/ListSpecBuilderFactory.php +++ b/src/List/Factory/ListSpecBuilderFactory.php @@ -4,51 +4,52 @@ namespace HeimrichHannot\FlareBundle\List\Factory; +use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\Collector\ListModelFilterCollector; +use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use HeimrichHannot\FlareBundle\List\ListSpecBuilder; -use HeimrichHannot\FlareBundle\List\Resolver\ListDriverResolver; -use HeimrichHannot\FlareBundle\List\Resolver\ListOptionsResolver; use HeimrichHannot\FlareBundle\List\Resolver\ListTransformerResolver; -use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use HeimrichHannot\FlareBundle\Model\ListModel; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; /** * Creates ListSpecBuilders — from a stored tl_flare_list model with its published filters - * pre-added, or programmatically from a driver and data container. + * pre-added, or programmatically from a driver. */ final readonly class ListSpecBuilderFactory { public function __construct( private EventDispatcherInterface $eventDispatcher, private ListModelFilterCollector $filterCollector, - private ListOptionsResolver $listOptionsResolver, + private ListSpecFactory $specFactory, private ListTransformerResolver $listTransformerResolver, - private ListDriverResolver $listDriverResolver, ) {} + /** + * @throws FlareException In case the list driver cannot be resolved. + */ public function create( ListDriverInterface|string $driver, - string $dc, ?ListModel $model = null, ?string $source = null, ): ListSpecBuilder { return new ListSpecBuilder( - optionsResolver: $this->listOptionsResolver, + specFactory: $this->specFactory, transformerResolver: $this->listTransformerResolver, eventDispatcher: $this->eventDispatcher, - driverReference: $this->listDriverResolver->resolve($driver), - dc: $dc, + driver: $this->specFactory->resolveDriver($driver), model: $model, source: $source, ); } + /** + * @throws FlareException In case the list driver cannot be resolved. + */ public function createFromListModel(ListModel $listModel): ListSpecBuilder { $builder = $this->create( driver: (string) $listModel->type, - dc: (string) $listModel->dc, model: $listModel, source: $listModel::getTable() . '.' . $listModel->id, ); diff --git a/src/List/Factory/ListSpecFactory.php b/src/List/Factory/ListSpecFactory.php index ad01899a..003ebbe8 100644 --- a/src/List/Factory/ListSpecFactory.php +++ b/src/List/Factory/ListSpecFactory.php @@ -5,32 +5,69 @@ namespace HeimrichHannot\FlareBundle\List\Factory; use HeimrichHannot\FlareBundle\Exception\FlareException; -use HeimrichHannot\FlareBundle\List\ListSpec; -use HeimrichHannot\FlareBundle\List\Resolver\ListDriverResolver; +use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; +use HeimrichHannot\FlareBundle\List\ListSpec; +use HeimrichHannot\FlareBundle\List\Resolver\ListOptionsResolver; +use HeimrichHannot\FlareBundle\Registry\ListDriverRegistry; +/** + * The single construction path for {@see ListSpec}: resolves the driver from its type alias if + * necessary, resolves the config through the base and driver schemas, and guarantees a + * well-defined data container ({@see ListDriverInterface::getDataContainerName()}). + */ final readonly class ListSpecFactory { public function __construct( - private ListDriverResolver $listDriverResolver, + private ListDriverRegistry $listDriverRegistry, + private ListOptionsResolver $listOptionsResolver, ) {} /** - * @throws FlareException In case the list driver cannot be resolved. + * @param array $filters + * @param array $config Canonical config values, unresolved. + * + * @throws FlareException In case the driver cannot be resolved, the config does not satisfy + * the schema, or no data container can be determined. */ public function create( ListDriverInterface|string $driver, - string $dc, array $filters = [], array $config = [], ?string $source = null, ): ListSpec { + $driver = $this->resolveDriver($driver); + + $config = $this->listOptionsResolver->resolve($driver, $config, $source); + + if (!$dc = $driver->getDataContainerName($config)) + { + throw new FlareException( + \sprintf('Failed to evaluate data container table of list "%s".', $source ?? \get_class($driver)), + method: __METHOD__, + ); + } + + $config['dc'] = $dc; + return new ListSpec( - reference: $this->listDriverResolver->resolve($driver), - dc: $dc, + driver: $driver, filters: $filters, config: $config, source: $source, ); } + + /** + * @throws FlareException In case no driver is registered under the given type alias. + */ + public function resolveDriver(ListDriverInterface|string $driver): ListDriverInterface + { + if ($driver instanceof ListDriverInterface) { + return $driver; + } + + return $this->listDriverRegistry->getService($driver) + ?? throw new FlareException(\sprintf('List type "%s" not found', $driver)); + } } diff --git a/src/List/ListDriverReference.php b/src/List/ListDriverReference.php deleted file mode 100644 index 8fbcbf50..00000000 --- a/src/List/ListDriverReference.php +++ /dev/null @@ -1,33 +0,0 @@ - $filters - * @param array $config Canonical config, resolved through the base and type schemas. + * @param array $config Canonical config, resolved through the base and driver schemas. * @param string|null $source Provenance for error messages, e.g. "tl_flare_list.5". */ public function __construct( - public ListDriverReference $reference, - public string $dc, + public ListDriverInterface $driver, public array $filters = [], public array $config = [], public ?string $source = null, ) { - $this->type = $this->reference->type; - $this->driver = $this->reference->driver; + $this->dc = (string) ($this->config['dc'] ?? ''); + } + + /** + * Returns the main data container table of the list. + */ + public function getDataContainerName(): string + { + return $this->dc; } /** @@ -77,8 +80,7 @@ public function withoutFilter(string $key): self public function withFilters(array $filters): self { return new self( - reference: $this->reference, - dc: $this->dc, + driver: $this->driver, filters: $filters, config: $this->config, source: $this->source, @@ -91,8 +93,7 @@ public function withFilters(array $filters): self public function withConfig(array $config): self { return new self( - reference: $this->reference, - dc: $this->dc, + driver: $this->driver, filters: $this->filters, config: $config, source: $this->source, @@ -113,10 +114,12 @@ public function hasFilterOfType(string $elementType): bool public function getAutoItemField(): string { + $dc = $this->getDataContainerName(); + return DcaHelper::tryGetColumnName( - $this->dc, + $dc, (string) ($this->config['fieldAutoItem'] ?? ''), - DcaHelper::tryGetColumnName($this->dc, 'alias', 'id'), + DcaHelper::tryGetColumnName($dc, 'alias', 'id'), ); } @@ -124,8 +127,6 @@ public function hash(): string { return \sha1(\serialize([ \get_class($this->driver), - $this->type, - $this->dc, $this->source, $this->config, \array_map(static fn (Filter $filter): array => $filter->fingerprint(), $this->filters), diff --git a/src/List/ListSpecBuilder.php b/src/List/ListSpecBuilder.php index edd89aa1..8d806d74 100644 --- a/src/List/ListSpecBuilder.php +++ b/src/List/ListSpecBuilder.php @@ -9,19 +9,19 @@ use HeimrichHannot\FlareBundle\Event\ListBuildEvent; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\List\Resolver\ListOptionsResolver; -use HeimrichHannot\FlareBundle\List\Resolver\ListTransformerResolver; use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; +use HeimrichHannot\FlareBundle\List\Factory\ListSpecFactory; +use HeimrichHannot\FlareBundle\List\Resolver\ListTransformerResolver; use HeimrichHannot\FlareBundle\Model\ListModel; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; /** * Configures a list and its filters, then builds the immutable {@see ListSpec}. * - * Build order: the type's {@see BuildListContract::buildList()} hook, the + * Build order: the driver's {@see BuildListContract::buildList()} hook, the * {@see ListBuildEvent} (named dispatch `flare.list.{type}.build`), then config assembly — - * base translation, the type's model transformers, and explicit {@see set()} overrides — - * resolved through the base and type schemas. + * base translation, the driver's model transformers, and explicit {@see set()} overrides — + * handed to {@see ListSpecFactory} for schema resolution and construction. */ final class ListSpecBuilder implements ListSpecBuilderInterface { @@ -38,28 +38,17 @@ final class ListSpecBuilder implements ListSpecBuilderInterface private int $generatedFilterKeys = 0; public function __construct( - private readonly ListOptionsResolver $optionsResolver, + private readonly ListSpecFactory $specFactory, private readonly ListTransformerResolver $transformerResolver, private readonly EventDispatcherInterface $eventDispatcher, - private readonly ListDriverReference $driverReference, - private readonly string $dc, + private readonly ListDriverInterface $driver, private readonly ?ListModel $model = null, private readonly ?string $source = null, ) {} - public function getDriverReference(): ListDriverReference - { - return $this->driverReference; - } - - public function getType(): string - { - return $this->driverReference->type; - } - - public function getDc(): string + public function getDriver(): ListDriverInterface { - return $this->dc; + return $this->driver; } public function getModel(): ?ListModel @@ -73,7 +62,7 @@ public function getSource(): ?string } /** - * Sets a canonical config value, overriding base translation and type transformers. + * Sets a canonical config value, overriding base translation and driver transformers. */ public function set(string $key, mixed $value): self { @@ -126,11 +115,12 @@ public function hasFilterOfType(string $elementType): bool } /** - * @throws FlareException If the resulting config does not satisfy the schema. + * @throws FlareException If the resulting config does not satisfy the schema or no data + * container can be determined. */ public function build(): ListSpec { - $driver = $this->driverReference->driver; + $driver = $this->driver; if ($driver instanceof BuildListContract) { $driver->buildList($this); @@ -144,7 +134,7 @@ public function build(): ListSpec { BaseListOptions::transform($config, $this->model); - $transformed = $this->transformerResolver->transform($this->driverReference, $this->model); + $transformed = $this->transformerResolver->transform($driver, $this->model); foreach ($transformed ?? [] as $key => $value) { $config->set($key, $value); @@ -155,11 +145,10 @@ public function build(): ListSpec $config->set($key, $value); } - return new ListSpec( - reference: $this->driverReference, - dc: $this->dc, + return $this->specFactory->create( + driver: $driver, filters: $this->filters, - config: $this->optionsResolver->resolve($driver, $config->all(), $this->source), + config: $config->all(), source: $this->source, ); } diff --git a/src/List/ListSpecBuilderInterface.php b/src/List/ListSpecBuilderInterface.php index 391804c4..06761b7d 100644 --- a/src/List/ListSpecBuilderInterface.php +++ b/src/List/ListSpecBuilderInterface.php @@ -5,15 +5,12 @@ namespace HeimrichHannot\FlareBundle\List; use HeimrichHannot\FlareBundle\Filter\Filter; +use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use HeimrichHannot\FlareBundle\Model\ListModel; interface ListSpecBuilderInterface { - public function getDriverReference(): ListDriverReference; - - public function getType(): string; - - public function getDc(): string; + public function getDriver(): ListDriverInterface; public function getModel(): ?ListModel; diff --git a/src/List/Resolver/ListDriverResolver.php b/src/List/Resolver/ListDriverResolver.php deleted file mode 100644 index 7bbcecf0..00000000 --- a/src/List/Resolver/ListDriverResolver.php +++ /dev/null @@ -1,49 +0,0 @@ -resolveInstance($driver); - } - - return $this->resolveType($driver); - } - - private function resolveInstance(ListDriverInterface $driver): ListDriverReference - { - return ListDriverReference::inline($driver); - } - - /** - * @throws FlareException In case it's not possible to resolve the type of the list. - */ - private function resolveType(string $type): ListDriverReference - { - if (!$descriptor = $this->registry->get($type)) { - throw new FlareException(\sprintf( - 'List type "%s" not found', - $type, - )); - } - - return ListDriverReference::registered($type, $descriptor->getService()); - } -} diff --git a/src/List/Resolver/ListTransformerResolver.php b/src/List/Resolver/ListTransformerResolver.php index e294b3ee..3a6876f9 100644 --- a/src/List/Resolver/ListTransformerResolver.php +++ b/src/List/Resolver/ListTransformerResolver.php @@ -8,7 +8,7 @@ use HeimrichHannot\FlareBundle\Config\TransformerResolver; use HeimrichHannot\FlareBundle\Contract\TransformerContract; use HeimrichHannot\FlareBundle\Event\ListTransformerEvent; -use HeimrichHannot\FlareBundle\List\ListDriverReference; +use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; /** @@ -30,10 +30,8 @@ public function __construct( /** * @return array|null Canonical config values, or null when no transformer matches the source. */ - public function transform(ListDriverReference $reference, object $source): ?array + public function transform(ListDriverInterface $driver, object $source): ?array { - $driver = $reference->driver; - if (!isset($this->resolvers[$driver::class])) { $resolver = new TransformerResolver(); @@ -42,7 +40,7 @@ public function transform(ListDriverReference $reference, object $source): ?arra $driver->configureTransformers($resolver); } - $this->eventDispatcher->dispatch(new ListTransformerEvent($resolver, $reference)); + $this->eventDispatcher->dispatch(new ListTransformerEvent($resolver, $driver)); $this->resolvers[$driver::class] = $resolver; } diff --git a/src/Query/Executor/FilterExecutor.php b/src/Query/Executor/FilterExecutor.php index 959a0825..01ff2a6e 100644 --- a/src/Query/Executor/FilterExecutor.php +++ b/src/Query/Executor/FilterExecutor.php @@ -14,7 +14,6 @@ use HeimrichHannot\FlareBundle\Filter\FilterBuilder; use HeimrichHannot\FlareBundle\Filter\FilterCall; use HeimrichHannot\FlareBundle\Filter\FilterContext; -use HeimrichHannot\FlareBundle\Filter\Resolver\FilterElementResolver; use HeimrichHannot\FlareBundle\Query\Factory\FilterQueryBuilderFactory; use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; use HeimrichHannot\FlareBundle\Query\ListQueryConfig; @@ -30,7 +29,6 @@ public function __construct( private EventDispatcherInterface $eventDispatcher, private FilterContextFactory $filterContextFactory, private FilterElementRegistry $filterElementRegistry, - private FilterElementResolver $filterElementResolver, private FilterQueryBuilderFactory $filterQueryBuilderFactory, private FilterTypeRegistry $filterTypeRegistry, ) {} @@ -50,11 +48,7 @@ public function invokeFilters(ListQueryConfig $options): array foreach ($list->filters as $key => $filter) { - if (!$element = $this->filterElementResolver->resolve($filter)) { - continue; - } - - $context = $this->filterContextFactory->create($list, $filter, $element, $options->context, $key); + $context = $this->filterContextFactory->create($list, $filter, $filter->element, $options->context, $key); $data = (array) ($options->filterValues[$key] ?? $filter->data ?? []); @@ -79,7 +73,7 @@ public function invokeFilters(ListQueryConfig $options): array */ public function invokeFilter(Filter $filter, FilterContext $context, array $data = []): array { - if (!Str::isValidSqlName($table = $context->list->dc)) + if (!Str::isValidSqlName($table = $context->list->getDataContainerName())) { throw new FlareException(\sprintf( '[FLARE] ListSpec data container cannot be used as SQL table identifier: "%s"', @@ -87,14 +81,10 @@ public function invokeFilter(Filter $filter, FilterContext $context, array $data ), method: __METHOD__); } - if (!$element = $this->filterElementResolver->resolve($filter)) { - return []; - } - - $descriptor = $this->filterElementRegistry->get($filter->type); + $isTargeted = $this->filterElementRegistry->getAttribute($filter->type)?->isTargeted; $targetAlias = TableAliasRegistry::ALIAS_MAIN; - if ($descriptor?->isTargeted() || $filter->targetingForced) { + if ($isTargeted || $filter->targetingForced) { $targetAlias = $filter->targetAlias ?: TableAliasRegistry::ALIAS_MAIN; } @@ -112,7 +102,7 @@ public function invokeFilter(Filter $filter, FilterContext $context, array $data try { - $element->buildFilter($builder, $context, $data); + $filter->element->buildFilter($builder, $context, $data); } catch (AbortFilteringException $e) { @@ -120,7 +110,7 @@ public function invokeFilter(Filter $filter, FilterContext $context, array $data } catch (FilterException $e) { - throw $this->createFilterException($e, $filter, $element::class . '::buildFilter'); + throw $this->createFilterException($e, $filter, $filter->element::class . '::buildFilter'); } catch (\Throwable $e) { diff --git a/src/Query/Factory/ListExecutionContextFactory.php b/src/Query/Factory/ListExecutionContextFactory.php index 35a86de7..d7bae54d 100644 --- a/src/Query/Factory/ListExecutionContextFactory.php +++ b/src/Query/Factory/ListExecutionContextFactory.php @@ -11,14 +11,11 @@ use HeimrichHannot\FlareBundle\Query\ListExecutionContext; use HeimrichHannot\FlareBundle\Query\SqlQueryStruct; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; -use HeimrichHannot\FlareBundle\Registry\Descriptor\ListTypeDescriptor; -use HeimrichHannot\FlareBundle\Registry\ListDriverRegistry; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; readonly class ListExecutionContextFactory { public function __construct( - private ListDriverRegistry $listTypeRegistry, private EventDispatcherInterface $eventDispatcher, ) {} @@ -29,18 +26,12 @@ public function create(ListSpec $list): ListExecutionContext { $driver = $list->driver; - if (!$mainTable = $list->dc) + if (!$mainTable = $list->getDataContainerName()) { - $listTypeDescriptor = $this->listTypeRegistry->get($list->type); - - if (!$listTypeDescriptor instanceof ListTypeDescriptor - || !$mainTable = $listTypeDescriptor->getDataContainer()) - { - throw new FlareException( - \sprintf('Failed to evaluate data container table of list "%s".', $list->type), - method: __METHOD__, - ); - } + throw new FlareException( + \sprintf('Failed to evaluate data container table of list "%s".', $list->source ?? \get_class($driver)), + method: __METHOD__, + ); } $registry = new TableAliasRegistry(); diff --git a/src/Registry/Descriptor/FilterElementDescriptor.php b/src/Registry/Descriptor/FilterElementDescriptor.php deleted file mode 100644 index ce2c9738..00000000 --- a/src/Registry/Descriptor/FilterElementDescriptor.php +++ /dev/null @@ -1,44 +0,0 @@ -service; - } - - public function setService(FilterElementInterface $service): void - { - $this->service = $service; - } - - public function getAttributes(): array - { - return $this->attributes; - } - - public function setAttributes(array $attributes): void - { - $this->attributes = $attributes; - } - - public function isTargeted(): ?bool - { - return $this->isTargeted; - } -} diff --git a/src/Registry/Descriptor/ListTypeDescriptor.php b/src/Registry/Descriptor/ListTypeDescriptor.php deleted file mode 100644 index 72551793..00000000 --- a/src/Registry/Descriptor/ListTypeDescriptor.php +++ /dev/null @@ -1,51 +0,0 @@ -service; - } - - public function setService(object $service): void - { - $this->service = $service; - } - - public function getAttributes(): array - { - return $this->attributes; - } - - public function setAttributes(array $attributes): void - { - $this->attributes = $attributes; - } - - public function getDataContainer(): ?string - { - return $this->dataContainer; - } - - public function setDataContainer(?string $dataContainer): void - { - $this->dataContainer = $dataContainer; - } -} diff --git a/src/Registry/FilterElementRegistry.php b/src/Registry/FilterElementRegistry.php index c1335d49..00ec857b 100644 --- a/src/Registry/FilterElementRegistry.php +++ b/src/Registry/FilterElementRegistry.php @@ -1,32 +1,143 @@ - + */ + private array $elements = []; + + /** + * @var array> + */ + private array $typesByClass = []; + + /** + * Registers a filter element under a type alias. Re-registering a type overrides it. + * A null $type registers the instance inline under its class name. + */ + public function add(FilterElementInterface $service, ?AsFilterElement $attribute = null, ?string $type = null): self + { + $inline = $type === null; + $serviceClass = \get_class($service); + $type ??= $serviceClass; + + $this->prune($type); + + $this->elements[$type] = [ + 'service' => $service, + 'attribute' => $attribute, + 'service_class' => $serviceClass, + 'inline' => $inline, + ]; + + $this->typesByClass[$serviceClass][] = $type; + + return $this; + } + + public function remove(string $type): self + { + $this->prune($type); + + return $this; + } + + public function has(string $type): bool + { + return isset($this->elements[$type]); + } + + public function getService(?string $type): ?FilterElementInterface + { + return $type !== null ? ($this->elements[$type]['service'] ?? null) : null; + } + + public function getAttribute(?string $type): ?AsFilterElement { - return FilterElementDescriptor::class; + return $type !== null ? ($this->elements[$type]['attribute'] ?? null) : null; } - public function get(?string $alias): ?FilterElementDescriptor + public function isInline(string $type): bool { - $descriptor = parent::get($alias); + return $this->elements[$type]['inline'] ?? false; + } + + /** + * @return list + */ + public function keys(): array + { + return \array_keys($this->elements); + } + + /** + * Returns the types an element is registered under, in registration order. An object + * argument matches only the exact registered instance — an unregistered inline instance of + * a registered class yields no types. A class-string matches all registrations of that class. + * + * @param FilterElementInterface|class-string $serviceOrClass + * @return list + */ + public function getTypes(FilterElementInterface|string $serviceOrClass): array + { + if (\is_string($serviceOrClass)) { + return $this->typesByClass[$serviceOrClass] ?? []; + } + + $types = []; + + foreach ($this->typesByClass[\get_class($serviceOrClass)] ?? [] as $type) + { + if (($this->elements[$type]['service'] ?? null) === $serviceOrClass) { + $types[] = $type; + } + } + + return $types; + } + + private function prune(string $type): void + { + if (!$entry = $this->elements[$type] ?? null) { + return; + } + + unset($this->elements[$type]); + + $class = $entry['service_class']; + + $types = \array_values(\array_filter( + $this->typesByClass[$class] ?? [], + static fn (string $registered): bool => $registered !== $type, + )); + + if (!$types) { + unset($this->typesByClass[$class]); - if (!$descriptor instanceof FilterElementDescriptor) { - return null; + return; } - return $descriptor; + $this->typesByClass[$class] = $types; } -} \ No newline at end of file +} diff --git a/src/Registry/ListDriverRegistry.php b/src/Registry/ListDriverRegistry.php index 7b9337f9..ff43c019 100644 --- a/src/Registry/ListDriverRegistry.php +++ b/src/Registry/ListDriverRegistry.php @@ -1,32 +1,138 @@ - + */ + private array $drivers = []; + + /** + * @var array> + */ + private array $typesByClass = []; + + /** + * Registers a driver under a type alias. Re-registering a type overrides it. + * A null $type registers the instance inline under its class name. + */ + public function add(ListDriverInterface $service, ?AsListDriver $attribute = null, ?string $type = null): self + { + $inline = $type === null; + $serviceClass = \get_class($service); + $type ??= $serviceClass; + + $this->prune($type); + + $this->drivers[$type] = [ + 'service' => $service, + 'attribute' => $attribute, + 'service_class' => $serviceClass, + 'inline' => $inline, + ]; + + $this->typesByClass[$serviceClass][] = $type; + + return $this; + } + + public function remove(string $type): self + { + $this->prune($type); + + return $this; + } + + public function has(string $type): bool + { + return isset($this->drivers[$type]); + } + + public function getService(?string $type): ?ListDriverInterface + { + return $type !== null ? ($this->drivers[$type]['service'] ?? null) : null; + } + + public function getAttribute(?string $type): ?AsListDriver { - return ListTypeDescriptor::class; + return $type !== null ? ($this->drivers[$type]['attribute'] ?? null) : null; } - public function get(?string $alias): ?ListTypeDescriptor + public function isInline(string $type): bool { - $descriptor = parent::get($alias); + return $this->drivers[$type]['inline'] ?? false; + } + + /** + * @return list + */ + public function keys(): array + { + return \array_keys($this->drivers); + } + + /** + * Returns the types a driver is registered under, in registration order. An object argument + * matches only the exact registered instance — an unregistered inline instance of a + * registered class yields no types. A class-string matches all registrations of that class. + * + * @param ListDriverInterface|class-string $serviceOrClass + * @return list + */ + public function getTypes(ListDriverInterface|string $serviceOrClass): array + { + if (\is_string($serviceOrClass)) { + return $this->typesByClass[$serviceOrClass] ?? []; + } + + $types = []; + + foreach ($this->typesByClass[\get_class($serviceOrClass)] ?? [] as $type) + { + if (($this->drivers[$type]['service'] ?? null) === $serviceOrClass) { + $types[] = $type; + } + } + + return $types; + } + + private function prune(string $type): void + { + if (!$entry = $this->drivers[$type] ?? null) { + return; + } + + unset($this->drivers[$type]); + + $class = $entry['service_class']; + + $types = \array_values(\array_filter( + $this->typesByClass[$class] ?? [], + static fn (string $registered): bool => $registered !== $type, + )); + + if (!$types) { + unset($this->typesByClass[$class]); - if (!$descriptor instanceof ListTypeDescriptor) { - return null; + return; } - return $descriptor; + $this->typesByClass[$class] = $types; } } diff --git a/tests/Engine/Projector/InteractiveProjectorTest.php b/tests/Engine/Projector/InteractiveProjectorTest.php index 9fd10bdf..40ffda82 100644 --- a/tests/Engine/Projector/InteractiveProjectorTest.php +++ b/tests/Engine/Projector/InteractiveProjectorTest.php @@ -5,9 +5,11 @@ namespace HeimrichHannot\FlareBundle\Tests\Engine\Projector; use HeimrichHannot\FlareBundle\Engine\Projector\InteractiveProjector; +use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Filter; +use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; -use HeimrichHannot\FlareBundle\List\ListDriverReference; +use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use PHPUnit\Framework\TestCase; @@ -51,11 +53,22 @@ private function addFlatChild(FormBuilderInterface $root, string $alias, array $ private function listWithFilter(string $key, string $alias): ListSpec { - $reference = ListDriverReference::registered('test', new class implements ListDriverInterface {}); + $driver = new class implements ListDriverInterface { + public function getDataContainerName(array $config): string + { + return (string) ($config['dc'] ?? ''); + } + }; + + $element = new class implements FilterElementInterface { + public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} + + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void {} + }; - return new ListSpec(reference: $reference, dc: 'tl_test', filters: [ - $key => new Filter(type: 'test_element', alias: $alias), - ]); + return new ListSpec(driver: $driver, filters: [ + $key => new Filter(element: $element, type: 'test_element', alias: $alias), + ], config: ['dc' => 'tl_test']); } public function testFlatSubmittedValueIsKeyedCanonically(): void diff --git a/tests/EventListener/NamedDispatch/ListBuildListenerTest.php b/tests/EventListener/NamedDispatch/ListBuildListenerTest.php new file mode 100644 index 00000000..85b0ac01 --- /dev/null +++ b/tests/EventListener/NamedDispatch/ListBuildListenerTest.php @@ -0,0 +1,79 @@ +add($driver, null, 'a'); + $registry->add($driver, null, 'b'); + + self::assertSame( + ['flare.list.a.build', 'flare.list.b.build'], + $this->dispatchedNames($driver, $registry), + ); + } + + public function testUnregisteredInlineDriverTriggersNoNamedDispatch(): void + { + $registered = new class extends AbstractListDriver {}; + + $registry = new ListDriverRegistry(); + $registry->add($registered, null, 'a'); + + $inline = new ($registered::class)(); + + self::assertSame([], $this->dispatchedNames($inline, $registry)); + } + + /** + * @return list + */ + private function dispatchedNames(ListDriverInterface $driver, ListDriverRegistry $registry): array + { + $names = []; + + $dispatcher = new EventDispatcher(); + + foreach (['a', 'b'] as $type) + { + $dispatcher->addListener( + "flare.list.{$type}.build", + static function () use (&$names, $type): void { + $names[] = "flare.list.{$type}.build"; + }, + ); + } + + $builder = new ListSpecBuilder( + specFactory: new ListSpecFactory($registry, new ListOptionsResolver(new SchemaResolver())), + transformerResolver: new ListTransformerResolver($dispatcher), + eventDispatcher: $dispatcher, + driver: $driver, + ); + + $listener = new ListBuildListener($dispatcher, $registry); + $listener(new ListBuildEvent($builder)); + + return $names; + } +} diff --git a/tests/Filter/FilterFactoryTest.php b/tests/Filter/FilterFactoryTest.php new file mode 100644 index 00000000..005425ff --- /dev/null +++ b/tests/Filter/FilterFactoryTest.php @@ -0,0 +1,63 @@ +add($element, null, 'my_element'); + + $filter = (new FilterFactory($registry))->create( + element: 'my_element', + config: ['a' => 1], + alias: 'foo', + ); + + self::assertSame($element, $filter->element); + self::assertSame('my_element', $filter->type); + self::assertSame(['a' => 1], $filter->config); + self::assertSame('foo', $filter->alias); + } + + public function testCreatesFromInstanceWithoutType(): void + { + $element = self::element(); + + $filter = (new FilterFactory(new FilterElementRegistry()))->create(element: $element); + + self::assertSame($element, $filter->element); + self::assertNull($filter->type); + } + + public function testThrowsForUnknownTypeAlias(): void + { + $this->expectException(FlareException::class); + $this->expectExceptionMessage('Filter element type "missing" not found'); + + (new FilterFactory(new FilterElementRegistry()))->create(element: 'missing'); + } +} diff --git a/tests/Filter/FilterOptionsResolverTest.php b/tests/Filter/FilterOptionsResolverTest.php index 78c57afa..6d3c373a 100644 --- a/tests/Filter/FilterOptionsResolverTest.php +++ b/tests/Filter/FilterOptionsResolverTest.php @@ -23,7 +23,7 @@ public function testResolvesOptionsThroughElementSchema(): void $resolver = new FilterOptionsResolver(new SchemaResolver()); $element = new ElementConfigAwareElement(); - $config = $resolver->resolve(new Filter(type: 'test', config: ['field' => 'title']), $element); + $config = $resolver->resolve(new Filter(element: $element, type: 'test', config: ['field' => 'title']), $element); self::assertSame('title', $config['field']); self::assertFalse($config['intrinsic']); @@ -36,14 +36,14 @@ public function testReturnsOptionsVerbatimWithoutOptionsContract(): void $config = ['anything' => 'goes', 'unvalidated' => true]; - self::assertSame($config, $resolver->resolve(new Filter(type: 'test', config: $config), $element)); + self::assertSame($config, $resolver->resolve(new Filter(element: $element, type: 'test', config: $config), $element)); } public function testWrapsSchemaViolationsInFilterException(): void { $resolver = new FilterOptionsResolver(new SchemaResolver()); $element = new ElementConfigAwareElement(); - $filter = new Filter(type: 'test', config: ['unknown_key' => 1], source: 'tl_flare_filter.42'); + $filter = new Filter(element: $element, type: 'test', config: ['unknown_key' => 1], source: 'tl_flare_filter.42'); try { diff --git a/tests/Filter/FilterTest.php b/tests/Filter/FilterTest.php index 69b70d72..d141d4db 100644 --- a/tests/Filter/FilterTest.php +++ b/tests/Filter/FilterTest.php @@ -4,19 +4,42 @@ namespace HeimrichHannot\FlareBundle\Tests\Filter; +use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Filter; +use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; use PHPUnit\Framework\TestCase; final class FilterTest extends TestCase { + private static function element(): FilterElementInterface + { + static $element = null; + + return $element ??= new class implements FilterElementInterface { + public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} + + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void {} + }; + } + public function testWithersPreserveOtherFields(): void { - $filter = new Filter(type: 'test', config: ['a' => 1], alias: 'foo', source: 'tl_flare_filter.1'); + $filter = new Filter( + element: self::element(), + type: 'test', + config: ['a' => 1], + alias: 'foo', + source: 'tl_flare_filter.1', + ); $withData = $filter->withData(['value' => 42]); self::assertNull($filter->data); self::assertSame(['value' => 42], $withData->data); + self::assertSame(self::element(), $withData->element); + self::assertSame('test', $withData->type); self::assertSame('foo', $withData->alias); self::assertSame(['a' => 1], $withData->config); self::assertSame('tl_flare_filter.1', $withData->source); @@ -30,10 +53,11 @@ public function testWithersPreserveOtherFields(): void public function testFingerprintReflectsIdentityAndContent(): void { - $filter = new Filter(type: 'test', config: ['a' => 1], alias: 'foo'); + $filter = new Filter(element: self::element(), type: 'test', config: ['a' => 1], alias: 'foo'); $fingerprint = $filter->fingerprint(); + self::assertSame(\get_class(self::element()), $fingerprint['element']); self::assertSame('test', $fingerprint['type']); self::assertSame(['a' => 1], $fingerprint['config']); self::assertSame('foo', $fingerprint['alias']); diff --git a/tests/Form/FilterFormFactoryTest.php b/tests/Form/FilterFormFactoryTest.php index 7c2fa4e0..012d783e 100644 --- a/tests/Form/FilterFormFactoryTest.php +++ b/tests/Form/FilterFormFactoryTest.php @@ -15,15 +15,10 @@ use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\Resolver\FilterElementResolver; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterOptionsResolver; -use HeimrichHannot\FlareBundle\List\ListDriverReference; use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; -use HeimrichHannot\FlareBundle\Registry\Descriptor\FilterElementDescriptor; -use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; use PHPUnit\Framework\TestCase; -use Psr\Log\NullLogger; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\Form\Extension\Core\Type\FormType; use Symfony\Component\Form\Extension\Core\Type\TextType; @@ -36,14 +31,10 @@ final class FilterFormFactoryTest extends TestCase { private EventDispatcher $eventDispatcher; - private FilterElementRegistry $elementRegistry; - private int $elementCount = 0; protected function setUp(): void { $this->eventDispatcher = new EventDispatcher(); - $this->elementRegistry = new FilterElementRegistry(); - $this->elementCount = 0; } private function createFactory(): FilterFormFactory @@ -57,17 +48,23 @@ private function createFactory(): FilterFormFactory return new FilterFormFactory( eventDispatcher: $this->eventDispatcher, filterContextFactory: new FilterContextFactory(new FilterOptionsResolver(new SchemaResolver())), - filterElementResolver: new FilterElementResolver($this->elementRegistry, new NullLogger()), formFactory: $formFactory, ); } private function createForm(array $filters): FormInterface { + $driver = new class implements ListDriverInterface { + public function getDataContainerName(array $config): string + { + return (string) ($config['dc'] ?? ''); + } + }; + $list = new ListSpec( - reference: ListDriverReference::registered('test', new class implements ListDriverInterface {}), - dc: 'tl_test', + driver: $driver, filters: $filters, + config: ['dc' => 'tl_test'], ); $context = new class implements ContextInterface, FormContextInterface { @@ -91,13 +88,13 @@ public function getFormActionPage(): int } /** - * Registers an element building its form via the given callable; returns its type alias. + * Creates an element building its form via the given callable. * * @param callable(FilterFormBuilderInterface, FilterContext): void $buildForm */ - private function element(callable $buildForm): string + private function element(callable $buildForm): FilterElementInterface { - $element = new class($buildForm) implements FilterElementInterface { + return new class($buildForm) implements FilterElementInterface { /** @var callable */ private $buildForm; @@ -113,11 +110,6 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void {} }; - - $type = 'element_' . ++$this->elementCount; - $this->elementRegistry->add($type, new FilterElementDescriptor($element)); - - return $type; } public function testSingleFieldMountsFlatUnderTheAlias(): void @@ -128,7 +120,7 @@ public function testSingleFieldMountsFlatUnderTheAlias(): void $builder->addEventListener(FormEvents::POST_SUBMIT, static function (): void {}); }); - $form = $this->createForm(['suche' => new Filter(type: $element, alias: 'suche')]); + $form = $this->createForm(['suche' => new Filter(element: $element, alias: 'suche')]); $this->assertTrue($form->has('suche')); @@ -151,7 +143,7 @@ public function testSingleWithCompanionFieldMountsNestedCompound(): void $builder->add('extra', TextType::class, ['required' => false]); }); - $form = $this->createForm(['suche' => new Filter(type: $element, alias: 'suche')]); + $form = $this->createForm(['suche' => new Filter(element: $element, alias: 'suche')]); $child = $form->get('suche'); @@ -169,7 +161,7 @@ public function testMultiFieldElementMountsNestedCompound(): void $builder->addEventListener(FormEvents::POST_SUBMIT, static function (): void {}); }); - $form = $this->createForm(['range' => new Filter(type: $element, alias: 'range')]); + $form = $this->createForm(['range' => new Filter(element: $element, alias: 'range')]); $child = $form->get('range'); @@ -186,7 +178,7 @@ public function testElementWithoutFieldsIsNotMounted(): void { $element = $this->element(static function (): void {}); - $form = $this->createForm(['empty' => new Filter(type: $element, alias: 'empty')]); + $form = $this->createForm(['empty' => new Filter(element: $element, alias: 'empty')]); $this->assertFalse($form->has('empty')); } @@ -197,7 +189,7 @@ public function testInvalidAliasIsSkipped(): void $builder->single(TextType::class); }); - $form = $this->createForm(['x' => new Filter(type: $element, alias: '_.tl_flare_filter.1')]); + $form = $this->createForm(['x' => new Filter(element: $element, alias: '_.tl_flare_filter.1')]); $this->assertSame(0, \count($form)); } @@ -213,7 +205,7 @@ public function testCancelledEventPreventsMounting(): void $builder->single(TextType::class); }); - $form = $this->createForm(['suche' => new Filter(type: $element, alias: 'suche')]); + $form = $this->createForm(['suche' => new Filter(element: $element, alias: 'suche')]); $this->assertFalse($form->has('suche')); } diff --git a/tests/List/BaseListOptionsTest.php b/tests/List/BaseListOptionsTest.php index 81f1139a..fb5ad034 100644 --- a/tests/List/BaseListOptionsTest.php +++ b/tests/List/BaseListOptionsTest.php @@ -17,6 +17,7 @@ public function testTransformsStoredRowToCanonicalValues(): void { $model = new ListModelStub([ 'id' => '5', + 'dc' => 'tl_news', 'title' => 'My List', 'published' => '1', 'jumpToListView' => '', @@ -33,6 +34,7 @@ public function testTransformsStoredRowToCanonicalValues(): void $all = $config->all(); self::assertSame(5, $all['id']); + self::assertSame('tl_news', $all['dc']); self::assertSame('My List', $all['title']); self::assertTrue($all['published']); self::assertNull($all['jumpToListView']); @@ -51,6 +53,7 @@ public function testSchemaProvidesDefaultsForEmptyConfig(): void $resolved = (new ListOptionsResolver(new SchemaResolver()))->resolve(null, []); self::assertNull($resolved['id']); + self::assertSame('', $resolved['dc']); self::assertSame('', $resolved['title']); self::assertFalse($resolved['published']); self::assertSame([], $resolved['sortSettings']); diff --git a/tests/List/ListSpecBuilderTest.php b/tests/List/ListSpecBuilderTest.php index 6232bd32..10174f6c 100644 --- a/tests/List/ListSpecBuilderTest.php +++ b/tests/List/ListSpecBuilderTest.php @@ -9,54 +9,72 @@ use HeimrichHannot\FlareBundle\Contract\ListType\BuildListContract; use HeimrichHannot\FlareBundle\Event\ListBuildEvent; use HeimrichHannot\FlareBundle\Exception\FlareException; +use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\List\ListDriverReference; +use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; +use HeimrichHannot\FlareBundle\List\Factory\ListSpecFactory; use HeimrichHannot\FlareBundle\List\ListSpecBuilder; use HeimrichHannot\FlareBundle\List\Resolver\ListOptionsResolver; use HeimrichHannot\FlareBundle\List\Resolver\ListTransformerResolver; use HeimrichHannot\FlareBundle\List\Driver\AbstractListDriver; use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use HeimrichHannot\FlareBundle\Model\ListModel; +use HeimrichHannot\FlareBundle\Registry\ListDriverRegistry; use PHPUnit\Framework\TestCase; use Symfony\Component\EventDispatcher\EventDispatcher; final class ListSpecBuilderTest extends TestCase { - public function testBuildInvokesTypeHookAndDispatchesEvent(): void + public static function filter(string $type, ?string $alias = null): Filter + { + static $element = null; + + $element ??= new class implements FilterElementInterface { + public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} + + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void {} + }; + + return new Filter(element: $element, type: $type, alias: $alias); + } + + public function testBuildInvokesDriverHookAndDispatchesEvent(): void { $dispatchedWith = null; $dispatcher = new EventDispatcher(); $dispatcher->addListener(ListBuildEvent::class, static function (ListBuildEvent $event) use (&$dispatchedWith): void { $dispatchedWith = $event->builder; - $event->builder->addFilter(new Filter(type: 'from_event', alias: 'via_event')); + $event->builder->addFilter(self::filter('from_event', 'via_event')); }); - $type = new class extends AbstractListDriver implements BuildListContract { + $driver = new class extends AbstractListDriver implements BuildListContract { public int $buildListCalls = 0; public function buildList(ListSpecBuilder $builder): void { $this->buildListCalls++; - $builder->addFilter(new Filter(type: 'from_hook', alias: 'via_hook')); + $builder->addFilter(ListSpecBuilderTest::filter('from_hook', 'via_hook')); } }; - $builder = $this->createBuilder($dispatcher, driver: $type); + $builder = $this->createBuilder($dispatcher, driver: $driver); $spec = $builder->build(); - self::assertSame(1, $type->buildListCalls); + self::assertSame(1, $driver->buildListCalls); self::assertSame($builder, $dispatchedWith); self::assertArrayHasKey('via_hook', $spec->filters); self::assertArrayHasKey('via_event', $spec->filters); } - public function testFiltersAndTypeCarryOverToTheSpec(): void + public function testFiltersDcAndSourceCarryOverToTheSpec(): void { $builder = $this->createBuilder(new EventDispatcher()); - $builder->addFilter(new Filter(type: 'a', alias: 'x')); - $builder->addFilter(new Filter(type: 'b')); + $builder->addFilter(self::filter('a', 'x')); + $builder->addFilter(self::filter('b')); $builder->removeFilter('x'); self::assertTrue($builder->hasFilterOfType('b')); @@ -64,8 +82,9 @@ public function testFiltersAndTypeCarryOverToTheSpec(): void $spec = $builder->build(); - self::assertSame('test_type', $spec->type); - self::assertSame('tl_test', $spec->dc); + self::assertSame($builder->getDriver(), $spec->driver); + self::assertSame('tl_test', $spec->getDataContainerName()); + self::assertSame('tl_test', $spec->config['dc']); self::assertSame('tl_flare_list.9', $spec->source); self::assertArrayHasKey('_generated_0', $spec->filters); self::assertArrayNotHasKey('x', $spec->filters); @@ -73,7 +92,7 @@ public function testFiltersAndTypeCarryOverToTheSpec(): void public function testModelTransformationAndOverridePrecedence(): void { - $type = new class extends AbstractListDriver { + $driver = new class extends AbstractListDriver { protected function transformListModel(ConfigBuilder $config, ListModel $model): void { $config->set('genericPageMeta', true); @@ -83,7 +102,7 @@ protected function transformListModel(ConfigBuilder $config, ListModel $model): $builder = $this->createBuilder( new EventDispatcher(), - driver: $type, + driver: $driver, model: new ListModelStub(['id' => '9', 'title' => 'from-model']), ); @@ -92,10 +111,24 @@ protected function transformListModel(ConfigBuilder $config, ListModel $model): $config = $builder->build()->config; self::assertSame(9, $config['id']); // base transformation - self::assertTrue($config['genericPageMeta']); // type transformer over base + self::assertTrue($config['genericPageMeta']); // driver transformer over base self::assertSame('from-override', $config['title']); // explicit override wins } + public function testBuildFailsWithoutAnyDataContainer(): void + { + $builder = new ListSpecBuilder( + specFactory: self::specFactory(), + transformerResolver: new ListTransformerResolver(new EventDispatcher()), + eventDispatcher: new EventDispatcher(), + driver: new class extends AbstractListDriver {}, + source: 'tl_flare_list.9', + ); + + $this->expectException(FlareException::class); + $builder->build(); + } + public function testInvalidConfigThrowsWithSourceProvenance(): void { $builder = $this->createBuilder(new EventDispatcher()); @@ -112,20 +145,21 @@ public function testInvalidConfigThrowsWithSourceProvenance(): void } } + private static function specFactory(): ListSpecFactory + { + return new ListSpecFactory(new ListDriverRegistry(), new ListOptionsResolver(new SchemaResolver())); + } + private function createBuilder( EventDispatcher $dispatcher, ?ListDriverInterface $driver = null, ?ListModel $model = null, ): ListSpecBuilder { return new ListSpecBuilder( - optionsResolver: new ListOptionsResolver(new SchemaResolver()), + specFactory: self::specFactory(), transformerResolver: new ListTransformerResolver($dispatcher), eventDispatcher: $dispatcher, - driverReference: ListDriverReference::registered( - 'test_type', - $driver ?? new class implements ListDriverInterface {}, - ), - dc: 'tl_test', + driver: $driver ?? new class extends AbstractListDriver {}, model: $model, source: 'tl_flare_list.9', ); diff --git a/tests/List/ListSpecFactoryTest.php b/tests/List/ListSpecFactoryTest.php new file mode 100644 index 00000000..33c00f16 --- /dev/null +++ b/tests/List/ListSpecFactoryTest.php @@ -0,0 +1,85 @@ +createFactory()->create( + driver: $driver, + config: ['dc' => 'tl_test', 'title' => 'My List'], + source: 'tl_flare_list.1', + ); + + self::assertSame($driver, $spec->driver); + self::assertSame('tl_test', $spec->getDataContainerName()); + self::assertSame('tl_test', $spec->config['dc']); + self::assertSame('My List', $spec->config['title']); + self::assertFalse($spec->config['published']); // schema default applied + self::assertSame('tl_flare_list.1', $spec->source); + } + + public function testResolvesDriverFromRegisteredTypeAlias(): void + { + $driver = new class extends AbstractListDriver {}; + + $registry = new ListDriverRegistry(); + $registry->add($driver, null, 'my_type'); + + $spec = $this->createFactory($registry)->create(driver: 'my_type', config: ['dc' => 'tl_test']); + + self::assertSame($driver, $spec->driver); + } + + public function testThrowsForUnknownTypeAlias(): void + { + $this->expectException(FlareException::class); + $this->expectExceptionMessage('List type "missing" not found'); + + $this->createFactory()->create(driver: 'missing'); + } + + public function testThrowsWhenNoDataContainerCanBeDetermined(): void + { + $this->expectException(FlareException::class); + $this->expectExceptionMessage('data container'); + + $this->createFactory()->create(driver: new class extends AbstractListDriver {}); + } + + public function testDriverPinnedToATableDefinesTheDcRegardlessOfConfig(): void + { + $driver = new class extends AbstractListDriver { + public function getDataContainerName(array $config): string + { + return 'tl_news'; + } + }; + + $spec = $this->createFactory()->create(driver: $driver); + + self::assertSame('tl_news', $spec->getDataContainerName()); + self::assertSame('tl_news', $spec->config['dc']); + } +} diff --git a/tests/List/ListSpecTest.php b/tests/List/ListSpecTest.php index 51b0f553..edd7939d 100644 --- a/tests/List/ListSpecTest.php +++ b/tests/List/ListSpecTest.php @@ -4,35 +4,63 @@ namespace HeimrichHannot\FlareBundle\Tests\List; +use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\List\ListDriverReference; +use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use PHPUnit\Framework\TestCase; final class ListSpecTest extends TestCase { - private static function reference(): ListDriverReference + private static function driver(): ListDriverInterface { static $driver = null; - $driver ??= new class implements ListDriverInterface {}; - return ListDriverReference::registered('test', $driver); + return $driver ??= new class implements ListDriverInterface { + public function getDataContainerName(array $config): string + { + return (string) ($config['dc'] ?? ''); + } + }; + } + + private static function filter(string $type, ?string $alias = null): Filter + { + static $element = null; + + $element ??= new class implements FilterElementInterface { + public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} + + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void {} + }; + + return new Filter(element: $element, type: $type, alias: $alias); + } + + public function testDataContainerNameComesFromConfig(): void + { + $spec = new ListSpec(driver: self::driver(), config: ['dc' => 'tl_test']); + + self::assertSame('tl_test', $spec->getDataContainerName()); + self::assertSame('', (new ListSpec(driver: self::driver()))->getDataContainerName()); } public function testWithFilterKeysByAliasByDefault(): void { - $spec = new ListSpec(reference: self::reference(), dc: 'tl_test'); + $spec = new ListSpec(driver: self::driver()); - $spec = $spec->withFilter(new Filter(type: 'flare_bool', alias: 'foo')); + $spec = $spec->withFilter(self::filter('flare_bool', 'foo')); self::assertArrayHasKey('foo', $spec->filters); } public function testWithFilterAcceptsExplicitKey(): void { - $spec = (new ListSpec(reference: self::reference(), dc: 'tl_test')) - ->withFilter(new Filter(type: 'flare_bool', alias: 'foo'), 'custom'); + $spec = (new ListSpec(driver: self::driver())) + ->withFilter(self::filter('flare_bool', 'foo'), 'custom'); self::assertArrayHasKey('custom', $spec->filters); self::assertArrayNotHasKey('foo', $spec->filters); @@ -40,14 +68,14 @@ public function testWithFilterAcceptsExplicitKey(): void public function testWithFilterGeneratesCollisionFreeKeysForAliasLessFilters(): void { - $spec = (new ListSpec(reference: self::reference(), dc: 'tl_test')) - ->withFilter(new Filter(type: 'a')) - ->withFilter(new Filter(type: 'b')); + $spec = (new ListSpec(driver: self::driver())) + ->withFilter(self::filter('a')) + ->withFilter(self::filter('b')); self::assertArrayHasKey('_generated_0', $spec->filters); self::assertArrayHasKey('_generated_1', $spec->filters); - $spec = $spec->withoutFilter('_generated_0')->withFilter(new Filter(type: 'c')); + $spec = $spec->withoutFilter('_generated_0')->withFilter(self::filter('c')); self::assertSame('c', $spec->filters['_generated_0']->type); self::assertSame('b', $spec->filters['_generated_1']->type); @@ -55,10 +83,10 @@ public function testWithFilterGeneratesCollisionFreeKeysForAliasLessFilters(): v public function testModifiersAreImmutable(): void { - $original = new ListSpec(reference: self::reference(), dc: 'tl_test', config: ['id' => 1]); + $original = new ListSpec(driver: self::driver(), config: ['id' => 1]); $modified = $original - ->withFilter(new Filter(type: 'a', alias: 'x')) + ->withFilter(self::filter('a', 'x')) ->withConfig(['id' => 2]); self::assertSame([], $original->filters); @@ -70,8 +98,8 @@ public function testModifiersAreImmutable(): void public function testHasFilterOfType(): void { - $spec = (new ListSpec(reference: self::reference(), dc: 'tl_test')) - ->withFilter(new Filter(type: 'flare_published', alias: 'p')); + $spec = (new ListSpec(driver: self::driver())) + ->withFilter(self::filter('flare_published', 'p')); self::assertTrue($spec->hasFilterOfType('flare_published')); self::assertFalse($spec->hasFilterOfType('flare_bool')); @@ -80,14 +108,14 @@ public function testHasFilterOfType(): void public function testHashIsStableAndChangesWithContent(): void { $make = static fn (array $config = [], ?string $source = null): ListSpec => - new ListSpec(reference: self::reference(), dc: 'tl_test', config: $config, source: $source); + new ListSpec(driver: self::driver(), config: $config, source: $source); self::assertSame($make()->hash(), $make()->hash()); self::assertNotSame($make()->hash(), $make(config: ['id' => 1])->hash()); self::assertNotSame($make()->hash(), $make(source: 'tl_flare_list.5')->hash()); self::assertNotSame( $make()->hash(), - $make()->withFilter(new Filter(type: 'a', alias: 'x'))->hash(), + $make()->withFilter(self::filter('a', 'x'))->hash(), ); } } diff --git a/tests/List/ListTransformerResolverTest.php b/tests/List/ListTransformerResolverTest.php index ecb7b078..34a61611 100644 --- a/tests/List/ListTransformerResolverTest.php +++ b/tests/List/ListTransformerResolverTest.php @@ -8,7 +8,6 @@ use HeimrichHannot\FlareBundle\Config\TransformerResolver; use HeimrichHannot\FlareBundle\Contract\TransformerContract; use HeimrichHannot\FlareBundle\Event\ListTransformerEvent; -use HeimrichHannot\FlareBundle\List\ListDriverReference; use HeimrichHannot\FlareBundle\List\Resolver\ListTransformerResolver; use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use PHPUnit\Framework\TestCase; @@ -19,9 +18,8 @@ final class ListTransformerResolverTest extends TestCase public function testTransformsSourceThroughDriverTransformers(): void { $resolver = new ListTransformerResolver(new EventDispatcher()); - $reference = ListDriverReference::registered('test', new TransformingDriver()); - $values = $resolver->transform($reference, new SourceStub('from-source')); + $values = $resolver->transform(new TransformingDriver(), new SourceStub('from-source')); self::assertSame(['title' => 'from-source'], $values); } @@ -30,14 +28,8 @@ public function testReturnsNullWithoutMatchingTransformer(): void { $resolver = new ListTransformerResolver(new EventDispatcher()); - self::assertNull($resolver->transform( - ListDriverReference::registered('test', new TransformingDriver()), - new \stdClass(), - )); - self::assertNull($resolver->transform( - ListDriverReference::registered('test', new TransformerlessDriver()), - new SourceStub('x'), - )); + self::assertNull($resolver->transform(new TransformingDriver(), new \stdClass())); + self::assertNull($resolver->transform(new TransformerlessDriver(), new SourceStub('x'))); } public function testMemoizesMapAndDispatchesEventOncePerDriverClass(): void @@ -54,39 +46,13 @@ static function (ListTransformerEvent $event) use (&$dispatchedWith): void { $resolver = new ListTransformerResolver($dispatcher); $driver = new TransformingDriver(); - $reference = ListDriverReference::registered('test', $driver); - $resolver->transform($reference, new SourceStub('a')); - $resolver->transform($reference, new SourceStub('b')); + $resolver->transform($driver, new SourceStub('a')); + $resolver->transform($driver, new SourceStub('b')); self::assertSame(1, $driver->configureCalls); self::assertCount(1, $dispatchedWith); - self::assertSame($reference, $dispatchedWith[0]->reference); - self::assertSame($driver, $dispatchedWith[0]->reference->driver); - self::assertSame('test', $dispatchedWith[0]->reference->type); - } - - public function testInlineReferenceCarriesOverIntoTheEvent(): void - { - $dispatchedWith = []; - - $dispatcher = new EventDispatcher(); - $dispatcher->addListener( - ListTransformerEvent::class, - static function (ListTransformerEvent $event) use (&$dispatchedWith): void { - $dispatchedWith[] = $event; - }, - ); - - $resolver = new ListTransformerResolver($dispatcher); - $driver = new TransformingDriver(); - $reference = ListDriverReference::inline($driver); - - $resolver->transform($reference, new SourceStub('a')); - - self::assertCount(1, $dispatchedWith); - self::assertSame($reference, $dispatchedWith[0]->reference); - self::assertTrue($dispatchedWith[0]->reference->inline); + self::assertSame($driver, $dispatchedWith[0]->driver); } public function testEventListenersCanAddSourceCapabilities(): void @@ -104,10 +70,7 @@ static function (ListTransformerEvent $event): void { $resolver = new ListTransformerResolver($dispatcher); - $values = $resolver->transform( - ListDriverReference::registered('test', new TransformerlessDriver()), - new \stdClass(), - ); + $values = $resolver->transform(new TransformerlessDriver(), new \stdClass()); self::assertSame(['external' => true], $values); } @@ -124,6 +87,11 @@ final class TransformingDriver implements ListDriverInterface, TransformerContra { public int $configureCalls = 0; + public function getDataContainerName(array $config): string + { + return (string) ($config['dc'] ?? ''); + } + public function configureTransformers(TransformerResolver $resolver): void { $this->configureCalls++; @@ -136,4 +104,8 @@ public function configureTransformers(TransformerResolver $resolver): void final class TransformerlessDriver implements ListDriverInterface { + public function getDataContainerName(array $config): string + { + return (string) ($config['dc'] ?? ''); + } } diff --git a/tests/Registry/FilterElementRegistryTest.php b/tests/Registry/FilterElementRegistryTest.php new file mode 100644 index 00000000..f701e9b4 --- /dev/null +++ b/tests/Registry/FilterElementRegistryTest.php @@ -0,0 +1,63 @@ +add($element, $attribute, 'choice'); + + self::assertTrue($registry->has('choice')); + self::assertSame($element, $registry->getService('choice')); + self::assertTrue($registry->getAttribute('choice')?->isTargeted); + self::assertFalse($registry->isInline('choice')); + self::assertSame(['choice'], $registry->keys()); + } + + public function testGetTypesMatchesRegisteredInstanceOnly(): void + { + $registry = new FilterElementRegistry(); + $registered = new RegistryElementStub(); + $inline = new RegistryElementStub(); + + $registry->add($registered, null, 'a'); + + self::assertSame(['a'], $registry->getTypes($registered)); + self::assertSame([], $registry->getTypes($inline)); + self::assertSame(['a'], $registry->getTypes(RegistryElementStub::class)); + } + + public function testInlineRegistrationUsesClassNameAsType(): void + { + $registry = new FilterElementRegistry(); + $element = new RegistryElementStub(); + + $registry->add($element); + + self::assertTrue($registry->isInline(RegistryElementStub::class)); + self::assertSame($element, $registry->getService(RegistryElementStub::class)); + self::assertSame([RegistryElementStub::class], $registry->getTypes($element)); + } +} + +final class RegistryElementStub implements FilterElementInterface +{ + public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} + + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void {} +} diff --git a/tests/Registry/ListDriverRegistryTest.php b/tests/Registry/ListDriverRegistryTest.php new file mode 100644 index 00000000..77e53f12 --- /dev/null +++ b/tests/Registry/ListDriverRegistryTest.php @@ -0,0 +1,106 @@ +add($driver, $attribute, 'news'); + + self::assertTrue($registry->has('news')); + self::assertSame($driver, $registry->getService('news')); + self::assertSame($attribute, $registry->getAttribute('news')); + self::assertSame('tl_news', $registry->getAttribute('news')?->dataContainer); + self::assertFalse($registry->isInline('news')); + self::assertSame(['news'], $registry->keys()); + + self::assertNull($registry->getService('unknown')); + self::assertNull($registry->getService(null)); + self::assertNull($registry->getAttribute(null)); + } + + public function testGetTypesMatchesRegisteredInstanceOnly(): void + { + $registry = new ListDriverRegistry(); + $registered = new RegistryDriverStub(); + $inline = new RegistryDriverStub(); + + $registry->add($registered, null, 'a'); + $registry->add($registered, null, 'b'); + + self::assertSame(['a', 'b'], $registry->getTypes($registered)); + self::assertSame([], $registry->getTypes($inline), 'An unregistered instance of a registered class has no types.'); + self::assertSame(['a', 'b'], $registry->getTypes(RegistryDriverStub::class)); + } + + public function testInlineRegistrationUsesClassNameAsType(): void + { + $registry = new ListDriverRegistry(); + $driver = new RegistryDriverStub(); + + $registry->add($driver); + + self::assertTrue($registry->has(RegistryDriverStub::class)); + self::assertTrue($registry->isInline(RegistryDriverStub::class)); + self::assertSame($driver, $registry->getService(RegistryDriverStub::class)); + self::assertSame([RegistryDriverStub::class], $registry->getTypes($driver)); + } + + public function testOverridingATypePrunesTheReverseMap(): void + { + $registry = new ListDriverRegistry(); + $first = new RegistryDriverStub(); + $second = new OtherRegistryDriverStub(); + + $registry->add($first, null, 'shared'); + $registry->add($second, null, 'shared'); + + self::assertSame($second, $registry->getService('shared')); + self::assertSame([], $registry->getTypes($first)); + self::assertSame([], $registry->getTypes(RegistryDriverStub::class)); + self::assertSame(['shared'], $registry->getTypes($second)); + } + + public function testRemoveCleansForwardAndReverseMaps(): void + { + $registry = new ListDriverRegistry(); + $driver = new RegistryDriverStub(); + + $registry->add($driver, null, 'a'); + $registry->add($driver, null, 'b'); + $registry->remove('a'); + + self::assertFalse($registry->has('a')); + self::assertTrue($registry->has('b')); + self::assertSame(['b'], $registry->getTypes($driver)); + + $registry->remove('b'); + + self::assertSame([], $registry->getTypes($driver)); + self::assertSame([], $registry->keys()); + } +} + +class RegistryDriverStub implements ListDriverInterface +{ + public function getDataContainerName(array $config): string + { + return (string) ($config['dc'] ?? ''); + } +} + +final class OtherRegistryDriverStub extends RegistryDriverStub +{ +} From 38d3b906e3761d54f8c57db417b7051a0a73e83b Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Fri, 17 Jul 2026 03:58:12 +0200 Subject: [PATCH 52/71] refactor: rename `ListType` namespaces to `ListDriver` and update related references --- src/Contract/ListDriver/BuildListContract.php | 2 +- src/Contract/ListDriver/BuildQueryContract.php | 2 +- src/Contract/ListDriver/DataContainerContract.php | 2 +- src/DataContainer/ListContainer.php | 2 +- .../ContaoCalendar/ListDriver/EventsListDriver.php | 2 +- .../ListType/DcMultilingualListType.php | 2 +- src/List/Driver/AbstractListDriver.php | 2 +- src/List/Driver/GenericDataContainerListDriver.php | 2 +- src/List/Driver/NewsListDriver.php | 2 +- src/List/ListSpecBuilder.php | 2 +- src/Query/Factory/ListExecutionContextFactory.php | 2 +- tests/List/BaseListOptionsTest.php | 8 +++----- tests/List/ListSpecBuilderTest.php | 8 ++++---- tests/List/ListSpecFactoryTest.php | 2 +- 14 files changed, 19 insertions(+), 21 deletions(-) diff --git a/src/Contract/ListDriver/BuildListContract.php b/src/Contract/ListDriver/BuildListContract.php index f6605dc5..bf801773 100644 --- a/src/Contract/ListDriver/BuildListContract.php +++ b/src/Contract/ListDriver/BuildListContract.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Contract\ListType; +namespace HeimrichHannot\FlareBundle\Contract\ListDriver; use HeimrichHannot\FlareBundle\List\ListSpecBuilder; diff --git a/src/Contract/ListDriver/BuildQueryContract.php b/src/Contract/ListDriver/BuildQueryContract.php index dc969a6d..ea54b501 100644 --- a/src/Contract/ListDriver/BuildQueryContract.php +++ b/src/Contract/ListDriver/BuildQueryContract.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Contract\ListType; +namespace HeimrichHannot\FlareBundle\Contract\ListDriver; use HeimrichHannot\FlareBundle\Query\SqlQueryStruct; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; diff --git a/src/Contract/ListDriver/DataContainerContract.php b/src/Contract/ListDriver/DataContainerContract.php index 89175fff..283796bb 100644 --- a/src/Contract/ListDriver/DataContainerContract.php +++ b/src/Contract/ListDriver/DataContainerContract.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Contract\ListType; +namespace HeimrichHannot\FlareBundle\Contract\ListDriver; use Contao\DataContainer; diff --git a/src/DataContainer/ListContainer.php b/src/DataContainer/ListContainer.php index 27d2958b..b7f82d3a 100644 --- a/src/DataContainer/ListContainer.php +++ b/src/DataContainer/ListContainer.php @@ -7,7 +7,7 @@ use Contao\CoreBundle\DependencyInjection\Attribute\AsCallback; use Contao\DataContainer; use Doctrine\DBAL\Connection; -use HeimrichHannot\FlareBundle\Contract\ListType\DataContainerContract; +use HeimrichHannot\FlareBundle\Contract\ListDriver\DataContainerContract; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; use HeimrichHannot\FlareBundle\Registry\ListDriverRegistry; use HeimrichHannot\FlareBundle\Util\DcaHelper; diff --git a/src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php b/src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php index 635f0c53..afc5506e 100644 --- a/src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php +++ b/src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\Integration\ContaoCalendar\ListDriver; -use HeimrichHannot\FlareBundle\Contract\ListType\BuildListContract; +use HeimrichHannot\FlareBundle\Contract\ListDriver\BuildListContract; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListDriver; diff --git a/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php b/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php index 3325bd6e..acaaed02 100644 --- a/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php +++ b/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php @@ -8,7 +8,7 @@ use Contao\CoreBundle\String\SimpleTokenParser; use Contao\DataContainer; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; -use HeimrichHannot\FlareBundle\Contract\ListType\DataContainerContract; +use HeimrichHannot\FlareBundle\Contract\ListDriver\DataContainerContract; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListDriver; use HeimrichHannot\FlareBundle\List\Driver\AbstractListDriver; use HeimrichHannot\FlareBundle\Model\ListModel; diff --git a/src/List/Driver/AbstractListDriver.php b/src/List/Driver/AbstractListDriver.php index 17136032..f44aaa92 100644 --- a/src/List/Driver/AbstractListDriver.php +++ b/src/List/Driver/AbstractListDriver.php @@ -7,7 +7,7 @@ use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\Config\TransformerResolver; use HeimrichHannot\FlareBundle\Contract\DcaContract; -use HeimrichHannot\FlareBundle\Contract\ListType\BuildQueryContract; +use HeimrichHannot\FlareBundle\Contract\ListDriver\BuildQueryContract; use HeimrichHannot\FlareBundle\Contract\OptionsContract; use HeimrichHannot\FlareBundle\Contract\TransformerContract; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; diff --git a/src/List/Driver/GenericDataContainerListDriver.php b/src/List/Driver/GenericDataContainerListDriver.php index fed9345c..61922e47 100644 --- a/src/List/Driver/GenericDataContainerListDriver.php +++ b/src/List/Driver/GenericDataContainerListDriver.php @@ -8,7 +8,7 @@ use Contao\DataContainer; use Contao\Message; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; -use HeimrichHannot\FlareBundle\Contract\ListType\DataContainerContract; +use HeimrichHannot\FlareBundle\Contract\ListDriver\DataContainerContract; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListDriver; diff --git a/src/List/Driver/NewsListDriver.php b/src/List/Driver/NewsListDriver.php index 792e9649..c6ad4374 100644 --- a/src/List/Driver/NewsListDriver.php +++ b/src/List/Driver/NewsListDriver.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\List\Driver; -use HeimrichHannot\FlareBundle\Contract\ListType\BuildListContract; +use HeimrichHannot\FlareBundle\Contract\ListDriver\BuildListContract; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListDriver; diff --git a/src/List/ListSpecBuilder.php b/src/List/ListSpecBuilder.php index 8d806d74..6dfabd0a 100644 --- a/src/List/ListSpecBuilder.php +++ b/src/List/ListSpecBuilder.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\List; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; -use HeimrichHannot\FlareBundle\Contract\ListType\BuildListContract; +use HeimrichHannot\FlareBundle\Contract\ListDriver\BuildListContract; use HeimrichHannot\FlareBundle\Event\ListBuildEvent; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\Filter; diff --git a/src/Query/Factory/ListExecutionContextFactory.php b/src/Query/Factory/ListExecutionContextFactory.php index d7bae54d..db51b584 100644 --- a/src/Query/Factory/ListExecutionContextFactory.php +++ b/src/Query/Factory/ListExecutionContextFactory.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\Query\Factory; -use HeimrichHannot\FlareBundle\Contract\ListType\BuildQueryContract; +use HeimrichHannot\FlareBundle\Contract\ListDriver\BuildQueryContract; use HeimrichHannot\FlareBundle\Event\QueryBaseInitializedEvent; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\List\ListSpec; diff --git a/tests/List/BaseListOptionsTest.php b/tests/List/BaseListOptionsTest.php index fb5ad034..5c531257 100644 --- a/tests/List/BaseListOptionsTest.php +++ b/tests/List/BaseListOptionsTest.php @@ -33,10 +33,10 @@ public function testTransformsStoredRowToCanonicalValues(): void BaseListOptions::transform($config = new ConfigBuilder(), $model); $all = $config->all(); - self::assertSame(5, $all['id']); + self::assertArrayNotHasKey('id', $all); + self::assertArrayNotHasKey('published', $all); self::assertSame('tl_news', $all['dc']); self::assertSame('My List', $all['title']); - self::assertTrue($all['published']); self::assertNull($all['jumpToListView']); self::assertSame(12, $all['jumpToReader']); self::assertSame([['column' => 'title', 'direction' => 'ASC']], $all['sortSettings']); @@ -52,10 +52,8 @@ public function testSchemaProvidesDefaultsForEmptyConfig(): void { $resolved = (new ListOptionsResolver(new SchemaResolver()))->resolve(null, []); - self::assertNull($resolved['id']); self::assertSame('', $resolved['dc']); self::assertSame('', $resolved['title']); - self::assertFalse($resolved['published']); self::assertSame([], $resolved['sortSettings']); self::assertNull($resolved['metaTitleFormat']); self::assertSame('', $resolved['whichPtable']); @@ -70,7 +68,7 @@ public function testTransformedRowSatisfiesTheSchema(): void $resolved = (new ListOptionsResolver(new SchemaResolver()))->resolve(null, $config->all()); - self::assertSame(3, $resolved['id']); + self::assertSame('x', $resolved['title']); self::assertSame([], $resolved['sortSettings']); } } diff --git a/tests/List/ListSpecBuilderTest.php b/tests/List/ListSpecBuilderTest.php index 10174f6c..776fac36 100644 --- a/tests/List/ListSpecBuilderTest.php +++ b/tests/List/ListSpecBuilderTest.php @@ -6,7 +6,7 @@ use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\Config\SchemaResolver; -use HeimrichHannot\FlareBundle\Contract\ListType\BuildListContract; +use HeimrichHannot\FlareBundle\Contract\ListDriver\BuildListContract; use HeimrichHannot\FlareBundle\Event\ListBuildEvent; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; @@ -103,14 +103,14 @@ protected function transformListModel(ConfigBuilder $config, ListModel $model): $builder = $this->createBuilder( new EventDispatcher(), driver: $driver, - model: new ListModelStub(['id' => '9', 'title' => 'from-model']), + model: new ListModelStub(['dc' => 'tl_test', 'title' => 'from-model']), ); $builder->set('title', 'from-override'); $config = $builder->build()->config; - self::assertSame(9, $config['id']); // base transformation + self::assertSame('tl_test', $config['dc']); // base transformation self::assertTrue($config['genericPageMeta']); // driver transformer over base self::assertSame('from-override', $config['title']); // explicit override wins } @@ -160,7 +160,7 @@ private function createBuilder( transformerResolver: new ListTransformerResolver($dispatcher), eventDispatcher: $dispatcher, driver: $driver ?? new class extends AbstractListDriver {}, - model: $model, + model: $model ?? new ListModelStub(['dc' => 'tl_test']), source: 'tl_flare_list.9', ); } diff --git a/tests/List/ListSpecFactoryTest.php b/tests/List/ListSpecFactoryTest.php index 33c00f16..e73afbc9 100644 --- a/tests/List/ListSpecFactoryTest.php +++ b/tests/List/ListSpecFactoryTest.php @@ -36,7 +36,7 @@ public function testCreatesSpecWithResolvedConfigAndDc(): void self::assertSame('tl_test', $spec->getDataContainerName()); self::assertSame('tl_test', $spec->config['dc']); self::assertSame('My List', $spec->config['title']); - self::assertFalse($spec->config['published']); // schema default applied + self::assertFalse($spec->config['genericPageMeta']); // schema default applied self::assertSame('tl_flare_list.1', $spec->source); } From d089e1e63f8cb6b9dec5ad857bbbdda268885e69 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Fri, 17 Jul 2026 04:16:17 +0200 Subject: [PATCH 53/71] refactor: replace `getDataContainerName` method with `dc` property across ListSpec and related classes --- src/Engine/Projector/InteractiveProjector.php | 2 +- src/Engine/Projector/ValidationProjector.php | 2 +- .../Reader/GenericReaderPageMetaListener.php | 2 +- src/Filter/Element/ArchiveFilterElement.php | 2 +- .../Element/BelongsToRelationFilterElement.php | 2 +- src/Filter/Element/DcaSelectFieldFilterElement.php | 6 +++--- .../Element/FieldValueChoiceFilterElement.php | 4 ++-- .../EventListener/RegisterTagsTablesListener.php | 4 ++-- .../EventListener/ChangelanguageListener.php | 2 +- src/List/ListSpec.php | 13 ++++--------- src/Query/Executor/FilterExecutor.php | 2 +- src/Query/Factory/ListExecutionContextFactory.php | 2 +- tests/List/ListSpecBuilderTest.php | 2 +- tests/List/ListSpecFactoryTest.php | 4 ++-- tests/List/ListSpecTest.php | 4 ++-- 15 files changed, 24 insertions(+), 29 deletions(-) diff --git a/src/Engine/Projector/InteractiveProjector.php b/src/Engine/Projector/InteractiveProjector.php index 3fd9e254..e2443316 100644 --- a/src/Engine/Projector/InteractiveProjector.php +++ b/src/Engine/Projector/InteractiveProjector.php @@ -79,7 +79,7 @@ public function project(ListSpec $list, ContextInterface $context): InteractiveV form: $form, paginator: $paginator, readerUrlGenerator: $readerUrlGenerator, - table: $list->getDataContainerName(), + table: $list->dc, totalItems: $totalItems, ); } diff --git a/src/Engine/Projector/ValidationProjector.php b/src/Engine/Projector/ValidationProjector.php index 56755dce..97c40b3e 100644 --- a/src/Engine/Projector/ValidationProjector.php +++ b/src/Engine/Projector/ValidationProjector.php @@ -48,7 +48,7 @@ public function project(ListSpec $list, ContextInterface $context): ValidationVi return $this->createView( loader: $loader, readerUrlGenerator: $readerUrlGenerator, - table: $list->getDataContainerName(), + table: $list->dc, autoItemField: $autoItemField, backLink: $context->createBackLink(), ); diff --git a/src/EventListener/Reader/GenericReaderPageMetaListener.php b/src/EventListener/Reader/GenericReaderPageMetaListener.php index 3cfe726a..7687ea3f 100644 --- a/src/EventListener/Reader/GenericReaderPageMetaListener.php +++ b/src/EventListener/Reader/GenericReaderPageMetaListener.php @@ -43,7 +43,7 @@ public function __invoke(ReaderPageMetaEvent $event): void $tokens = [ 'list.driver_class' => \get_class($list->driver), - 'list.dc' => $list->getDataContainerName(), + 'list.dc' => $list->dc, ]; $this->addTokensFromProperties($tokens, $list->config, prefix: 'list'); diff --git a/src/Filter/Element/ArchiveFilterElement.php b/src/Filter/Element/ArchiveFilterElement.php index a8b89d95..616dc8d2 100644 --- a/src/Filter/Element/ArchiveFilterElement.php +++ b/src/Filter/Element/ArchiveFilterElement.php @@ -401,7 +401,7 @@ private function getPtableInferrer(ListSpec $list): PtableInferrer } $inferrable = PtableInferrableFactory::createFromConfig($list->config); - return $this->_inferrer[$cacheKey] = new PtableInferrer($inferrable, $list->getDataContainerName()); + return $this->_inferrer[$cacheKey] = new PtableInferrer($inferrable, $list->dc); } public function buildDca(DcaBuilder $dca, DcaContext $context): void diff --git a/src/Filter/Element/BelongsToRelationFilterElement.php b/src/Filter/Element/BelongsToRelationFilterElement.php index b2e88fca..0c635e42 100644 --- a/src/Filter/Element/BelongsToRelationFilterElement.php +++ b/src/Filter/Element/BelongsToRelationFilterElement.php @@ -70,7 +70,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont } $inferrable = PtableInferrableFactory::createFromConfig($context->list->config); - $inferrer = new PtableInferrer($inferrable, $context->list->getDataContainerName()); + $inferrer = new PtableInferrer($inferrable, $context->list->dc); try { diff --git a/src/Filter/Element/DcaSelectFieldFilterElement.php b/src/Filter/Element/DcaSelectFieldFilterElement.php index 59f3637b..a78382a3 100644 --- a/src/Filter/Element/DcaSelectFieldFilterElement.php +++ b/src/Filter/Element/DcaSelectFieldFilterElement.php @@ -73,7 +73,7 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co 'placeholder' => $config['placeholder'] ?: $defaultPlaceholder, ]; - $options = $this->getOptions($context->list->getDataContainerName(), $config['field']); + $options = $this->getOptions($context->list->dc, $config['field']); if (!\is_null($options)) { @@ -98,7 +98,7 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void { $config = $context->config; - $options = $this->getOptions($context->list->getDataContainerName(), $config['field']) ?? []; + $options = $this->getOptions($context->list->dc, $config['field']) ?? []; $selected = $config['intrinsic'] ? $config['preselect'] @@ -120,7 +120,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont $builder->abort(); } - $dcaOptionsField = $this->getOptionsField($context->list->getDataContainerName(), $config['field']) ?? []; + $dcaOptionsField = $this->getOptionsField($context->list->dc, $config['field']) ?? []; $isMultiple = $dcaOptionsField['eval']['multiple'] ?? false; $builder->add(DcaSelectFilterType::class, [ diff --git a/src/Filter/Element/FieldValueChoiceFilterElement.php b/src/Filter/Element/FieldValueChoiceFilterElement.php index 3027119e..6a02fa7e 100644 --- a/src/Filter/Element/FieldValueChoiceFilterElement.php +++ b/src/Filter/Element/FieldValueChoiceFilterElement.php @@ -65,7 +65,7 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co return; } - $choicesBuilder = $this->createChoices($context->list->getDataContainerName(), (string) ($config['field'] ?? '')) + $choicesBuilder = $this->createChoices($context->list->dc, (string) ($config['field'] ?? '')) ->setEmptyOption(!$config['multiple']); $formOptions = [ @@ -203,7 +203,7 @@ private function normalizeRuntimeValue(mixed $value, FilterContext $context): ?a return null; } - $choicesBuilder = $this->createChoices($context->list->getDataContainerName(), (string) ($context->config['field'] ?? '')); + $choicesBuilder = $this->createChoices($context->list->dc, (string) ($context->config['field'] ?? '')); $choices = $choicesBuilder->buildChoices(); $toValue = $choicesBuilder->buildChoiceValueCallback(); diff --git a/src/Integration/CodefogTags/EventListener/RegisterTagsTablesListener.php b/src/Integration/CodefogTags/EventListener/RegisterTagsTablesListener.php index 8d318809..82ed8da6 100644 --- a/src/Integration/CodefogTags/EventListener/RegisterTagsTablesListener.php +++ b/src/Integration/CodefogTags/EventListener/RegisterTagsTablesListener.php @@ -28,7 +28,7 @@ public function __construct( public function __invoke(QueryBaseInitializedEvent $event): void { - $table = $event->list->getDataContainerName(); + $table = $event->list->dc; if (!$columns = $this->managersRegistry->fieldsOf($table)) { return; } @@ -92,4 +92,4 @@ public function __invoke(QueryBaseInitializedEvent $event): void manager: $manager, )); } -} \ No newline at end of file +} diff --git a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php index 9eb89419..4ca9230f 100644 --- a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php +++ b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php @@ -64,7 +64,7 @@ public function fetchAutoItem(FetchAutoItemEvent $event): void return; } - $table = $list->getDataContainerName(); + $table = $list->dc; $this->applyMlQueriesIfNecessary( $event->getListQueryBuilder(), diff --git a/src/List/ListSpec.php b/src/List/ListSpec.php index c0fcb3c0..3b3dc1c2 100644 --- a/src/List/ListSpec.php +++ b/src/List/ListSpec.php @@ -22,6 +22,9 @@ */ final readonly class ListSpec { + /** + * The main data container table of the list. + */ public string $dc; /** @@ -39,14 +42,6 @@ public function __construct( $this->dc = (string) ($this->config['dc'] ?? ''); } - /** - * Returns the main data container table of the list. - */ - public function getDataContainerName(): string - { - return $this->dc; - } - /** * Adds a filter. The key defaults to the filter's alias; alias-less filters receive a generated key. */ @@ -114,7 +109,7 @@ public function hasFilterOfType(string $elementType): bool public function getAutoItemField(): string { - $dc = $this->getDataContainerName(); + $dc = $this->dc; return DcaHelper::tryGetColumnName( $dc, diff --git a/src/Query/Executor/FilterExecutor.php b/src/Query/Executor/FilterExecutor.php index 01ff2a6e..73b22fd2 100644 --- a/src/Query/Executor/FilterExecutor.php +++ b/src/Query/Executor/FilterExecutor.php @@ -73,7 +73,7 @@ public function invokeFilters(ListQueryConfig $options): array */ public function invokeFilter(Filter $filter, FilterContext $context, array $data = []): array { - if (!Str::isValidSqlName($table = $context->list->getDataContainerName())) + if (!Str::isValidSqlName($table = $context->list->dc)) { throw new FlareException(\sprintf( '[FLARE] ListSpec data container cannot be used as SQL table identifier: "%s"', diff --git a/src/Query/Factory/ListExecutionContextFactory.php b/src/Query/Factory/ListExecutionContextFactory.php index db51b584..633eb9c4 100644 --- a/src/Query/Factory/ListExecutionContextFactory.php +++ b/src/Query/Factory/ListExecutionContextFactory.php @@ -26,7 +26,7 @@ public function create(ListSpec $list): ListExecutionContext { $driver = $list->driver; - if (!$mainTable = $list->getDataContainerName()) + if (!$mainTable = $list->dc) { throw new FlareException( \sprintf('Failed to evaluate data container table of list "%s".', $list->source ?? \get_class($driver)), diff --git a/tests/List/ListSpecBuilderTest.php b/tests/List/ListSpecBuilderTest.php index 776fac36..d39a8172 100644 --- a/tests/List/ListSpecBuilderTest.php +++ b/tests/List/ListSpecBuilderTest.php @@ -83,7 +83,7 @@ public function testFiltersDcAndSourceCarryOverToTheSpec(): void $spec = $builder->build(); self::assertSame($builder->getDriver(), $spec->driver); - self::assertSame('tl_test', $spec->getDataContainerName()); + self::assertSame('tl_test', $spec->dc); self::assertSame('tl_test', $spec->config['dc']); self::assertSame('tl_flare_list.9', $spec->source); self::assertArrayHasKey('_generated_0', $spec->filters); diff --git a/tests/List/ListSpecFactoryTest.php b/tests/List/ListSpecFactoryTest.php index e73afbc9..b2059bbc 100644 --- a/tests/List/ListSpecFactoryTest.php +++ b/tests/List/ListSpecFactoryTest.php @@ -33,7 +33,7 @@ public function testCreatesSpecWithResolvedConfigAndDc(): void ); self::assertSame($driver, $spec->driver); - self::assertSame('tl_test', $spec->getDataContainerName()); + self::assertSame('tl_test', $spec->dc); self::assertSame('tl_test', $spec->config['dc']); self::assertSame('My List', $spec->config['title']); self::assertFalse($spec->config['genericPageMeta']); // schema default applied @@ -79,7 +79,7 @@ public function getDataContainerName(array $config): string $spec = $this->createFactory()->create(driver: $driver); - self::assertSame('tl_news', $spec->getDataContainerName()); + self::assertSame('tl_news', $spec->dc); self::assertSame('tl_news', $spec->config['dc']); } } diff --git a/tests/List/ListSpecTest.php b/tests/List/ListSpecTest.php index edd7939d..c75915df 100644 --- a/tests/List/ListSpecTest.php +++ b/tests/List/ListSpecTest.php @@ -44,8 +44,8 @@ public function testDataContainerNameComesFromConfig(): void { $spec = new ListSpec(driver: self::driver(), config: ['dc' => 'tl_test']); - self::assertSame('tl_test', $spec->getDataContainerName()); - self::assertSame('', (new ListSpec(driver: self::driver()))->getDataContainerName()); + self::assertSame('tl_test', $spec->dc); + self::assertSame('', (new ListSpec(driver: self::driver()))->dc); } public function testWithFilterKeysByAliasByDefault(): void From f64176ce9b5f24f007b294fdca1b9070a8759d0f Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Fri, 17 Jul 2026 04:26:36 +0200 Subject: [PATCH 54/71] refactor: mark filter-related classes as `final` and enhance immutability with `readonly` --- src/Filter/Factory/FilterContextFactory.php | 2 +- src/Filter/Factory/FilterFormFactory.php | 2 +- src/Filter/FilterBuilder.php | 2 +- src/Filter/FilterCall.php | 4 ++-- src/Filter/FilterFormBuilder.php | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Filter/Factory/FilterContextFactory.php b/src/Filter/Factory/FilterContextFactory.php index 1400d60d..7e686ec3 100644 --- a/src/Filter/Factory/FilterContextFactory.php +++ b/src/Filter/Factory/FilterContextFactory.php @@ -16,7 +16,7 @@ * Builds the invocation context handed to filter elements, resolving the filter's * canonical config through the element's declared schema. */ -readonly class FilterContextFactory +final readonly class FilterContextFactory { public function __construct( private FilterOptionsResolver $filterOptionsResolver, diff --git a/src/Filter/Factory/FilterFormFactory.php b/src/Filter/Factory/FilterFormFactory.php index 411f9e6c..8078073d 100644 --- a/src/Filter/Factory/FilterFormFactory.php +++ b/src/Filter/Factory/FilterFormFactory.php @@ -21,7 +21,7 @@ use Symfony\Component\Form\FormInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; -readonly class FilterFormFactory +final readonly class FilterFormFactory { public function __construct( private EventDispatcherInterface $eventDispatcher, diff --git a/src/Filter/FilterBuilder.php b/src/Filter/FilterBuilder.php index 6d1d981f..6a317f7a 100644 --- a/src/Filter/FilterBuilder.php +++ b/src/Filter/FilterBuilder.php @@ -10,7 +10,7 @@ use HeimrichHannot\FlareBundle\Registry\FilterTypeRegistry; use Symfony\Component\OptionsResolver\OptionsResolver; -class FilterBuilder implements FilterBuilderInterface +final class FilterBuilder implements FilterBuilderInterface { /** * @var array, OptionsResolver> diff --git a/src/Filter/FilterCall.php b/src/Filter/FilterCall.php index 3f724cc6..6c721cc9 100644 --- a/src/Filter/FilterCall.php +++ b/src/Filter/FilterCall.php @@ -6,7 +6,7 @@ use HeimrichHannot\FlareBundle\Filter\Type\FilterTypeInterface; -readonly class FilterCall +final readonly class FilterCall { public function __construct( public FilterTypeInterface $type, @@ -14,4 +14,4 @@ public function __construct( public string $targetAlias, public array $options, ) {} -} \ No newline at end of file +} diff --git a/src/Filter/FilterFormBuilder.php b/src/Filter/FilterFormBuilder.php index e8f9a8a1..d0bad94f 100644 --- a/src/Filter/FilterFormBuilder.php +++ b/src/Filter/FilterFormBuilder.php @@ -15,7 +15,7 @@ * listeners onto a real builder. Children created through add()/create() are real, factory-built * builders because they route through the injected form factory. */ -class FilterFormBuilder extends FormBuilder implements FilterFormBuilderInterface +final class FilterFormBuilder extends FormBuilder implements FilterFormBuilderInterface { /** @var array{type: class-string, options: array}|null */ private ?array $single = null; From ef66a59beaa9226305f6a1081978057539cf37c4 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Fri, 17 Jul 2026 04:57:01 +0200 Subject: [PATCH 55/71] refactor: replace `hasFilterOfType` with `hasFilterInstance` for improved type safety and clarity --- .../ListDriver/EventsListDriver.php | 2 +- src/List/Driver/NewsListDriver.php | 2 +- src/List/ListSpec.php | 8 ++++++-- src/List/ListSpecBuilder.php | 8 ++++++-- src/List/ListSpecBuilderInterface.php | 6 +++++- tests/List/ListSpecBuilderTest.php | 6 +++--- tests/List/ListSpecTest.php | 10 ++++++---- tests/List/StubFilterElement.php | 17 +++++++++++++++++ 8 files changed, 45 insertions(+), 14 deletions(-) create mode 100644 tests/List/StubFilterElement.php diff --git a/src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php b/src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php index afc5506e..c486cbab 100644 --- a/src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php +++ b/src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php @@ -57,7 +57,7 @@ public function buildTableRegistry(TableAliasRegistry $registry): void public function buildList(ListSpecBuilder $builder): void { - if ($builder->hasFilterOfType(PublishedFilterElement::TYPE)) { + if ($builder->hasFilterInstance(PublishedFilterElement::class)) { return; } diff --git a/src/List/Driver/NewsListDriver.php b/src/List/Driver/NewsListDriver.php index c6ad4374..3b559dbb 100644 --- a/src/List/Driver/NewsListDriver.php +++ b/src/List/Driver/NewsListDriver.php @@ -43,7 +43,7 @@ public function buildTableRegistry(TableAliasRegistry $registry): void public function buildList(ListSpecBuilder $builder): void { - if ($builder->hasFilterOfType(PublishedFilterElement::TYPE)) { + if ($builder->hasFilterInstance(PublishedFilterElement::class)) { return; } diff --git a/src/List/ListSpec.php b/src/List/ListSpec.php index 3b3dc1c2..257a6342 100644 --- a/src/List/ListSpec.php +++ b/src/List/ListSpec.php @@ -4,6 +4,7 @@ namespace HeimrichHannot\FlareBundle\List; +use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use HeimrichHannot\FlareBundle\Util\DcaHelper; @@ -95,11 +96,14 @@ public function withConfig(array $config): self ); } - public function hasFilterOfType(string $elementType): bool + /** + * @param class-string $class + */ + public function hasFilterInstance(string $class): bool { foreach ($this->filters as $filter) { - if ($filter->type === $elementType) { + if ($filter->element instanceof $class) { return true; } } diff --git a/src/List/ListSpecBuilder.php b/src/List/ListSpecBuilder.php index 6dfabd0a..a994a2c9 100644 --- a/src/List/ListSpecBuilder.php +++ b/src/List/ListSpecBuilder.php @@ -8,6 +8,7 @@ use HeimrichHannot\FlareBundle\Contract\ListDriver\BuildListContract; use HeimrichHannot\FlareBundle\Event\ListBuildEvent; use HeimrichHannot\FlareBundle\Exception\FlareException; +use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use HeimrichHannot\FlareBundle\List\Factory\ListSpecFactory; @@ -102,11 +103,14 @@ public function getFilter(string $key): ?Filter return $this->filters[$key] ?? null; } - public function hasFilterOfType(string $elementType): bool + /** + * @param class-string $class + */ + public function hasFilterInstance(string $class): bool { foreach ($this->filters as $filter) { - if ($filter->type === $elementType) { + if ($filter->element instanceof $class) { return true; } } diff --git a/src/List/ListSpecBuilderInterface.php b/src/List/ListSpecBuilderInterface.php index 06761b7d..615858e4 100644 --- a/src/List/ListSpecBuilderInterface.php +++ b/src/List/ListSpecBuilderInterface.php @@ -4,6 +4,7 @@ namespace HeimrichHannot\FlareBundle\List; +use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use HeimrichHannot\FlareBundle\Model\ListModel; @@ -22,7 +23,10 @@ public function addFilter(Filter $filter, ?string $key = null): self; public function removeFilter(string $key): self; - public function hasFilterOfType(string $elementType): bool; + /** + * @param class-string $class + */ + public function hasFilterInstance(string $class): bool; public function getFilters(): array; diff --git a/tests/List/ListSpecBuilderTest.php b/tests/List/ListSpecBuilderTest.php index d39a8172..13112978 100644 --- a/tests/List/ListSpecBuilderTest.php +++ b/tests/List/ListSpecBuilderTest.php @@ -73,12 +73,12 @@ public function testFiltersDcAndSourceCarryOverToTheSpec(): void { $builder = $this->createBuilder(new EventDispatcher()); - $builder->addFilter(self::filter('a', 'x')); + $builder->addFilter(new Filter(element: new StubFilterElement(), alias: 'x')); $builder->addFilter(self::filter('b')); $builder->removeFilter('x'); - self::assertTrue($builder->hasFilterOfType('b')); - self::assertFalse($builder->hasFilterOfType('a')); + self::assertTrue($builder->hasFilterInstance(FilterElementInterface::class)); + self::assertFalse($builder->hasFilterInstance(StubFilterElement::class)); $spec = $builder->build(); diff --git a/tests/List/ListSpecTest.php b/tests/List/ListSpecTest.php index c75915df..85699a6e 100644 --- a/tests/List/ListSpecTest.php +++ b/tests/List/ListSpecTest.php @@ -5,6 +5,7 @@ namespace HeimrichHannot\FlareBundle\Tests\List; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; +use HeimrichHannot\FlareBundle\Filter\Element\PublishedFilterElement; use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; @@ -96,13 +97,14 @@ public function testModifiersAreImmutable(): void self::assertArrayHasKey('x', $modified->filters); } - public function testHasFilterOfType(): void + public function testHasFilterInstance(): void { $spec = (new ListSpec(driver: self::driver())) - ->withFilter(self::filter('flare_published', 'p')); + ->withFilter(new Filter(element: new StubFilterElement(), alias: 'p')); - self::assertTrue($spec->hasFilterOfType('flare_published')); - self::assertFalse($spec->hasFilterOfType('flare_bool')); + self::assertTrue($spec->hasFilterInstance(StubFilterElement::class)); + self::assertTrue($spec->hasFilterInstance(FilterElementInterface::class)); + self::assertFalse($spec->hasFilterInstance(PublishedFilterElement::class)); } public function testHashIsStableAndChangesWithContent(): void diff --git a/tests/List/StubFilterElement.php b/tests/List/StubFilterElement.php new file mode 100644 index 00000000..416aba7a --- /dev/null +++ b/tests/List/StubFilterElement.php @@ -0,0 +1,17 @@ + Date: Fri, 17 Jul 2026 05:19:20 +0200 Subject: [PATCH 56/71] refactor: replace `DcaBuilder` with `DcaBuilderInterface` across all filter elements and drivers --- src/Contract/DcaContract.php | 4 ++-- src/Filter/Element/AbstractFilterElement.php | 4 ++-- src/Filter/Element/ArchiveFilterElement.php | 4 ++-- src/Filter/Element/BelongsToRelationFilterElement.php | 4 ++-- src/Filter/Element/BooleanFilterElement.php | 4 ++-- src/Filter/Element/CalendarCurrentFilterElement.php | 4 ++-- src/Filter/Element/DateRangeFilterElement.php | 4 ++-- src/Filter/Element/DcaSelectFieldFilterElement.php | 4 ++-- src/Filter/Element/FieldValueChoiceFilterElement.php | 4 ++-- src/Filter/Element/PublishedFilterElement.php | 4 ++-- src/Filter/Element/SearchKeywordsFilterElement.php | 4 ++-- src/Filter/Element/SimpleEquationFilterElement.php | 4 ++-- .../FilterElement/CodefogTagsChoiceFilterElement.php | 4 ++-- .../FilterElement/CodefogTagsSearchElement.php | 4 ++-- .../ContaoCalendar/ListDriver/EventsListDriver.php | 7 +++---- src/List/Driver/AbstractListDriver.php | 10 +++++++--- src/List/Driver/GenericDataContainerListDriver.php | 4 ++-- src/List/Driver/NewsListDriver.php | 7 +++---- tests/List/ListSpecBuilderTest.php | 2 +- 19 files changed, 44 insertions(+), 42 deletions(-) diff --git a/src/Contract/DcaContract.php b/src/Contract/DcaContract.php index a9d26dc1..7fd90a54 100644 --- a/src/Contract/DcaContract.php +++ b/src/Contract/DcaContract.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\Contract; -use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; /** @@ -15,5 +15,5 @@ */ interface DcaContract { - public function buildDca(DcaBuilder $dca, DcaContext $context): void; + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void; } diff --git a/src/Filter/Element/AbstractFilterElement.php b/src/Filter/Element/AbstractFilterElement.php index bf34f9ed..337979d3 100644 --- a/src/Filter/Element/AbstractFilterElement.php +++ b/src/Filter/Element/AbstractFilterElement.php @@ -12,7 +12,7 @@ use HeimrichHannot\FlareBundle\Contract\IsSupportedContract; use HeimrichHannot\FlareBundle\Contract\OptionsContract; use HeimrichHannot\FlareBundle\Contract\TransformerContract; -use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\Filter\CallbackFilterModelTransformer; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; @@ -46,7 +46,7 @@ public function configureTransformers(TransformerResolver $resolver): void */ abstract protected function transformFilterModel(ConfigBuilder $config, FilterModel $model): void; - public function buildDca(DcaBuilder $dca, DcaContext $context): void {} + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void {} public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} diff --git a/src/Filter/Element/ArchiveFilterElement.php b/src/Filter/Element/ArchiveFilterElement.php index 616dc8d2..e5688c8f 100644 --- a/src/Filter/Element/ArchiveFilterElement.php +++ b/src/Filter/Element/ArchiveFilterElement.php @@ -8,7 +8,7 @@ use Contao\Model\Collection; use Contao\StringUtil; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; -use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Exception\FilterException; @@ -404,7 +404,7 @@ private function getPtableInferrer(ListSpec $list): PtableInferrer return $this->_inferrer[$cacheKey] = new PtableInferrer($inferrable, $list->dc); } - public function buildDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void { if (!$filterModel = $context->filterModel) { return; diff --git a/src/Filter/Element/BelongsToRelationFilterElement.php b/src/Filter/Element/BelongsToRelationFilterElement.php index 0c635e42..51866899 100644 --- a/src/Filter/Element/BelongsToRelationFilterElement.php +++ b/src/Filter/Element/BelongsToRelationFilterElement.php @@ -7,7 +7,7 @@ use Contao\Message; use Contao\StringUtil; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; -use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Exception\FilterException; @@ -162,7 +162,7 @@ public function getDynamicParentGroups(array $parentGroups): array return $groups; } - public function buildDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void { $listModel = $context->listModel; $filterModel = $context->filterModel; diff --git a/src/Filter/Element/BooleanFilterElement.php b/src/Filter/Element/BooleanFilterElement.php index 0a87c0cd..beb458b8 100644 --- a/src/Filter/Element/BooleanFilterElement.php +++ b/src/Filter/Element/BooleanFilterElement.php @@ -7,7 +7,7 @@ use Contao\Controller; use Contao\Message; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; -use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Enum\BoolBinaryChoices; @@ -106,7 +106,7 @@ public function normalizeValue(mixed $value, ?BoolBinaryChoices $choices = null) return \filter_var($value, \FILTER_VALIDATE_BOOLEAN, \FILTER_NULL_ON_FAILURE); } - public function buildDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void { $intrinsic = (bool) $context->filterModel?->intrinsic; diff --git a/src/Filter/Element/CalendarCurrentFilterElement.php b/src/Filter/Element/CalendarCurrentFilterElement.php index 05ed4631..19867e53 100644 --- a/src/Filter/Element/CalendarCurrentFilterElement.php +++ b/src/Filter/Element/CalendarCurrentFilterElement.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\Filter\Element; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; -use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Engine\Context\ValidationContext; @@ -135,7 +135,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont ]); } - public function buildDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void { $palette = '{date_start_legend},configureStart,hasExtendedEvents;{date_stop_legend},configureStop;'; diff --git a/src/Filter/Element/DateRangeFilterElement.php b/src/Filter/Element/DateRangeFilterElement.php index 38b80f6e..cb4f95b5 100644 --- a/src/Filter/Element/DateRangeFilterElement.php +++ b/src/Filter/Element/DateRangeFilterElement.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\Filter\Element; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; -use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Exception\FilterException; @@ -88,7 +88,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont ]); } - public function buildDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void { $dca->palette('fieldGeneric'); } diff --git a/src/Filter/Element/DcaSelectFieldFilterElement.php b/src/Filter/Element/DcaSelectFieldFilterElement.php index a78382a3..5639fec4 100644 --- a/src/Filter/Element/DcaSelectFieldFilterElement.php +++ b/src/Filter/Element/DcaSelectFieldFilterElement.php @@ -9,7 +9,7 @@ use Contao\StringUtil; use Contao\System; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; -use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; @@ -198,7 +198,7 @@ private function normalizeSubmittedValue(mixed $value, array $options): mixed return $toKey($value); } - public function buildDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void { $intrinsic = (bool) $context->filterModel?->intrinsic; diff --git a/src/Filter/Element/FieldValueChoiceFilterElement.php b/src/Filter/Element/FieldValueChoiceFilterElement.php index 6a02fa7e..b25df98e 100644 --- a/src/Filter/Element/FieldValueChoiceFilterElement.php +++ b/src/Filter/Element/FieldValueChoiceFilterElement.php @@ -9,7 +9,7 @@ use Contao\StringUtil; use Doctrine\DBAL\Connection; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; -use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Engine\Context\ValidationContext; @@ -109,7 +109,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont ]); } - public function buildDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void { $dca->palette('{filter_legend},fieldGeneric,isMultiple,isExpanded,preselect'); diff --git a/src/Filter/Element/PublishedFilterElement.php b/src/Filter/Element/PublishedFilterElement.php index 51a61977..9038e632 100644 --- a/src/Filter/Element/PublishedFilterElement.php +++ b/src/Filter/Element/PublishedFilterElement.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\Filter\Element; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; -use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; @@ -63,7 +63,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont ]); } - public function buildDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void { $dca->palette('{filter_legend},usePublished,useStart,useStop'); } diff --git a/src/Filter/Element/SearchKeywordsFilterElement.php b/src/Filter/Element/SearchKeywordsFilterElement.php index 030419cc..f78ab9dc 100644 --- a/src/Filter/Element/SearchKeywordsFilterElement.php +++ b/src/Filter/Element/SearchKeywordsFilterElement.php @@ -6,7 +6,7 @@ use Contao\StringUtil; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; -use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; @@ -83,7 +83,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont ]); } - public function buildDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void { $palette = '{filter_legend},columnsGeneric'; diff --git a/src/Filter/Element/SimpleEquationFilterElement.php b/src/Filter/Element/SimpleEquationFilterElement.php index ebd8a879..80bb4387 100644 --- a/src/Filter/Element/SimpleEquationFilterElement.php +++ b/src/Filter/Element/SimpleEquationFilterElement.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\Filter\Element; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; -use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Enum\SqlEquationOperator; @@ -62,7 +62,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont ]); } - public function buildDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void { $operatorValue = $context->filterModel?->equationOperator; $operator = $operatorValue ? SqlEquationOperator::match($operatorValue) : null; diff --git a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php index 3cbe6aeb..f96ce720 100644 --- a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php +++ b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php @@ -6,7 +6,7 @@ use Contao\StringUtil; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; -use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Filter\Element\AbstractFilterElement; @@ -124,7 +124,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont ]); } - public function buildDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void { $dca->palette('{form_legend},label,isMandatory,isMultiple,isExpanded;{filter_legend},preselect'); diff --git a/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php b/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php index ef1598c0..aa49b1ac 100644 --- a/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php +++ b/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\Integration\CodefogTags\FilterElement; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; -use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Filter\Element\AbstractFilterElement; @@ -22,7 +22,7 @@ public function isSupported(): bool return false; } - public function buildDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void { $dca->palette('{filter_legend},fieldGeneric,isMultiple,preselect'); } diff --git a/src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php b/src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php index c486cbab..413f1ed4 100644 --- a/src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php +++ b/src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php @@ -4,8 +4,7 @@ namespace HeimrichHannot\FlareBundle\Integration\ContaoCalendar\ListDriver; -use HeimrichHannot\FlareBundle\Contract\ListDriver\BuildListContract; -use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListDriver; use HeimrichHannot\FlareBundle\Filter\Element\PublishedFilterElement; @@ -17,7 +16,7 @@ use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; #[AsListDriver(type: self::TYPE, dataContainer: self::DATA_CONTAINER)] -class EventsListDriver extends AbstractListDriver implements BuildListContract +class EventsListDriver extends AbstractListDriver { public const TYPE = 'flare_events'; public const DATA_CONTAINER = 'tl_calendar_events'; @@ -27,7 +26,7 @@ public function __construct( private readonly FilterFactory $filterFactory, ) {} - public function buildDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void { $dca->suffix(static function (string $suffix): string { if (!$suffix) { diff --git a/src/List/Driver/AbstractListDriver.php b/src/List/Driver/AbstractListDriver.php index f44aaa92..61adaa1b 100644 --- a/src/List/Driver/AbstractListDriver.php +++ b/src/List/Driver/AbstractListDriver.php @@ -7,19 +7,21 @@ use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\Config\TransformerResolver; use HeimrichHannot\FlareBundle\Contract\DcaContract; +use HeimrichHannot\FlareBundle\Contract\ListDriver\BuildListContract; use HeimrichHannot\FlareBundle\Contract\ListDriver\BuildQueryContract; use HeimrichHannot\FlareBundle\Contract\OptionsContract; use HeimrichHannot\FlareBundle\Contract\TransformerContract; -use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\List\CallbackListModelTransformer; +use HeimrichHannot\FlareBundle\List\ListSpecBuilder; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Query\SqlQueryStruct; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; use Symfony\Component\OptionsResolver\OptionsResolver; abstract class AbstractListDriver implements - ListDriverInterface, BuildQueryContract, DcaContract, OptionsContract, TransformerContract + ListDriverInterface, BuildListContract, BuildQueryContract, DcaContract, OptionsContract, TransformerContract { public function getDataContainerName(array $config): string { @@ -45,9 +47,11 @@ public function configureTransformers(TransformerResolver $resolver): void */ protected function transformListModel(ConfigBuilder $config, ListModel $model): void {} - public function buildDca(DcaBuilder $dca, DcaContext $context): void {} + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void {} public function buildTableRegistry(TableAliasRegistry $registry): void {} public function buildBaseQuery(SqlQueryStruct $struct): void {} + + public function buildList(ListSpecBuilder $builder): void {} } diff --git a/src/List/Driver/GenericDataContainerListDriver.php b/src/List/Driver/GenericDataContainerListDriver.php index 61922e47..dccd0a72 100644 --- a/src/List/Driver/GenericDataContainerListDriver.php +++ b/src/List/Driver/GenericDataContainerListDriver.php @@ -9,7 +9,7 @@ use Contao\Message; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\Contract\ListDriver\DataContainerContract; -use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListDriver; use HeimrichHannot\FlareBundle\Exception\InferenceException; @@ -40,7 +40,7 @@ protected function transformListModel(ConfigBuilder $config, ListModel $model): $config->set('genericPageMeta', true); } - public function buildDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void { $listModel = $context->listModel; diff --git a/src/List/Driver/NewsListDriver.php b/src/List/Driver/NewsListDriver.php index 3b559dbb..447f7b30 100644 --- a/src/List/Driver/NewsListDriver.php +++ b/src/List/Driver/NewsListDriver.php @@ -4,8 +4,7 @@ namespace HeimrichHannot\FlareBundle\List\Driver; -use HeimrichHannot\FlareBundle\Contract\ListDriver\BuildListContract; -use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListDriver; use HeimrichHannot\FlareBundle\Filter\Element\PublishedFilterElement; @@ -16,7 +15,7 @@ use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; #[AsListDriver(type: self::TYPE, dataContainer: 'tl_news')] -class NewsListDriver extends AbstractListDriver implements BuildListContract +class NewsListDriver extends AbstractListDriver { public const TYPE = 'flare_news'; public const ALIAS_ARCHIVE = 'news_archive'; @@ -25,7 +24,7 @@ public function __construct( private readonly FilterFactory $filterFactory, ) {} - public function buildDca(DcaBuilder $dca, DcaContext $context): void + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void { $dca->palette('{filter_legend},'); } diff --git a/tests/List/ListSpecBuilderTest.php b/tests/List/ListSpecBuilderTest.php index 13112978..ad048d04 100644 --- a/tests/List/ListSpecBuilderTest.php +++ b/tests/List/ListSpecBuilderTest.php @@ -50,7 +50,7 @@ public function testBuildInvokesDriverHookAndDispatchesEvent(): void $event->builder->addFilter(self::filter('from_event', 'via_event')); }); - $driver = new class extends AbstractListDriver implements BuildListContract { + $driver = new class extends AbstractListDriver { public int $buildListCalls = 0; public function buildList(ListSpecBuilder $builder): void From 39065f739974c1edcc51404bf100503c52caa7da Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Fri, 17 Jul 2026 05:19:31 +0200 Subject: [PATCH 57/71] add `test` target to Makefile and update AGENTS.md to document its usage --- AGENTS.md | 2 +- Makefile | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a9576be9..6038a324 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,7 +101,7 @@ are in `src/Event/`. Prefer events over overriding services for customization. ## Testing & CI -* **Unit tests** live in `tests/` (PHPUnit 9, configured via `phpunit.xml.dist`); run them with `make php vendor/bin/phpunit`. There is no `make test` target. +* **Unit tests** live in `tests/` (PHPUnit 9, configured via `phpunit.xml.dist`); run them with `make test` (optionally passing phpunit args, e.g. `make test tests/SomeTest.php`). * CI workflows in `.github/workflows/`: * `phpunit.yaml` — PHPUnit test suite * `phpstan.yaml` — PHPStan analysis diff --git a/Makefile b/Makefile index 469937e6..074a5e82 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help docs-setup docs-remove php composer semgrep-sec +.PHONY: help docs-setup docs-remove php composer semgrep-sec test # Configuration DOCS_DIR = docs @@ -23,6 +23,9 @@ phpstan-pro: ## Run PHPStan static analysis in Pro GUI semgrep-sec: ## Run Semgrep security scanner docker compose run --rm semgrep-sec +test: ## Run PHPUnit test suite + docker compose run --rm php vendor/bin/phpunit $(filter-out $@,$(MAKECMDGOALS)) + docs-setup: ## Setup the Docusaurus worktree environment locally @if [ -d "$(DOCS_DIR)" ]; then \ echo "Directory $(DOCS_DIR) already exists."; \ From 4949a2291d6fc0d8b1052194e54c03dd7d79cca3 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Sat, 18 Jul 2026 04:22:02 +0200 Subject: [PATCH 58/71] refactor: streamline type handling across ListDriver and FilterElement, remove obsolete variants, and enhance immutability --- src/Config/ConfigBuilder.php | 4 +- ...nerContract.php => OnSubmitDcContract.php} | 4 +- .../ContentElement/ReaderController.php | 5 +- src/DataContainer/ListContainer.php | 14 ++- .../Attribute/AsListDriver.php | 2 +- .../Compiler/RegisterListDriversPass.php | 2 +- .../Factory/InteractiveContextFactory.php | 4 +- src/Event/FilterTransformerEvent.php | 2 +- src/Event/ListTransformerEvent.php | 1 + .../FilterTransformerListener.php | 5 +- .../NamedDispatch/ListBuildListener.php | 12 +- .../NamedDispatch/ListTransformerListener.php | 12 +- src/Filter/Factory/FilterFactory.php | 71 +++++++++-- src/Filter/Filter.php | 44 ++----- .../Resolver/FilterTransformerResolver.php | 12 +- .../ListDriver/EventsListDriver.php | 5 + .../EventListener/ChangelanguageListener.php | 4 +- .../ListType/DcMultilingualListType.php | 6 +- .../Collector/ListModelFilterCollector.php | 38 +++--- src/List/Driver/AbstractListDriver.php | 4 +- .../Driver/GenericDataContainerListDriver.php | 6 +- src/List/Driver/ListDriverInterface.php | 9 +- src/List/Driver/NewsListDriver.php | 8 +- src/List/Factory/ListSpecBuilderFactory.php | 10 +- src/List/Factory/ListSpecFactory.php | 116 ++++++++++++++++-- src/List/ListSpec.php | 32 ++--- src/List/ListSpecBuilder.php | 37 +++--- src/List/ListSpecBuilderInterface.php | 2 +- src/List/Resolver/ListTransformerResolver.php | 12 +- src/Model/FilterModel.php | 4 +- src/Model/ListModel.php | 5 + .../Factory/ReaderRequestAttributeFactory.php | 18 ++- src/Reader/ReaderRequestAttribute.php | 26 ++-- 33 files changed, 313 insertions(+), 223 deletions(-) rename src/Contract/ListDriver/{DataContainerContract.php => OnSubmitDcContract.php} (73%) rename src/{Filter => List}/Collector/ListModelFilterCollector.php (61%) diff --git a/src/Config/ConfigBuilder.php b/src/Config/ConfigBuilder.php index b659af38..d06693e9 100644 --- a/src/Config/ConfigBuilder.php +++ b/src/Config/ConfigBuilder.php @@ -12,9 +12,9 @@ final class ConfigBuilder implements ConfigBuilderInterface { /** - * @var array + * @param array $config */ - private array $config = []; + public function __construct(private array $config = []) {} public function set(string $key, mixed $value): self { diff --git a/src/Contract/ListDriver/DataContainerContract.php b/src/Contract/ListDriver/OnSubmitDcContract.php similarity index 73% rename from src/Contract/ListDriver/DataContainerContract.php rename to src/Contract/ListDriver/OnSubmitDcContract.php index 283796bb..e8458988 100644 --- a/src/Contract/ListDriver/DataContainerContract.php +++ b/src/Contract/ListDriver/OnSubmitDcContract.php @@ -7,8 +7,8 @@ use Contao\DataContainer; /** @api Implement on a ListDriver to resolve a data container for list config storage. */ -interface DataContainerContract +interface OnSubmitDcContract { /** @internal Used internally to resolve the data container table for a given row and data container. */ - public function resolveDataContainerTable(array $row, DataContainer $dc): string; + public function resolveDcOnSubmit(array $row, DataContainer $dc): string; } diff --git a/src/Controller/ContentElement/ReaderController.php b/src/Controller/ContentElement/ReaderController.php index 9250beea..5ff8c9c5 100644 --- a/src/Controller/ContentElement/ReaderController.php +++ b/src/Controller/ContentElement/ReaderController.php @@ -26,6 +26,7 @@ use HeimrichHannot\FlareBundle\Exception\ViewException; use HeimrichHannot\FlareBundle\List\Factory\ListSpecBuilderFactory; use HeimrichHannot\FlareBundle\Model\ListModel; +use HeimrichHannot\FlareBundle\Reader\Factory\ReaderRequestAttributeFactory; use HeimrichHannot\FlareBundle\Reader\ReaderPageMeta; use HeimrichHannot\FlareBundle\Reader\ReaderRequestAttribute; use HeimrichHannot\FlareBundle\Reader\Resolver\ReaderRequestAttributeResolver; @@ -50,6 +51,7 @@ public function __construct( private readonly KernelInterface $kernel, private readonly ListSpecBuilderFactory $listFactory, private readonly LoggerInterface $logger, + private readonly ReaderRequestAttributeFactory $attributeFactory, private readonly ReaderRequestAttributeResolver $attributeResolver, private readonly ResponseContextAccessor $responseContextAccessor, private readonly ScopeMatcher $scopeMatcher, @@ -133,7 +135,8 @@ protected function getFrontendResponse(Template $template, ContentModel $content $errData[] = "{$autoItemModel::getTable()}.id={$autoItemModel->id}"; - $this->attributeResolver->store(new ReaderRequestAttribute($autoItemModel, $list), $request); + $attribute = $this->attributeFactory->createFromModels($autoItemModel, $listModel); + $this->attributeResolver->store($attribute, $request); $this->entityCacheTags->tagWith($autoItemModel); /** @var ReaderPageMetaEvent $pageMetaEvent $pageMetaEvent */ diff --git a/src/DataContainer/ListContainer.php b/src/DataContainer/ListContainer.php index b7f82d3a..bbaed2a2 100644 --- a/src/DataContainer/ListContainer.php +++ b/src/DataContainer/ListContainer.php @@ -7,7 +7,7 @@ use Contao\CoreBundle\DependencyInjection\Attribute\AsCallback; use Contao\DataContainer; use Doctrine\DBAL\Connection; -use HeimrichHannot\FlareBundle\Contract\ListDriver\DataContainerContract; +use HeimrichHannot\FlareBundle\Contract\ListDriver\OnSubmitDcContract; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; use HeimrichHannot\FlareBundle\Registry\ListDriverRegistry; use HeimrichHannot\FlareBundle\Util\DcaHelper; @@ -43,15 +43,19 @@ public function onSubmitConfig(DataContainer $dc): void return; } - if (($service instanceof DataContainerContract) - && !$expectedDataContainer = $service->resolveDataContainerTable($row, $dc)) + $expectedDataContainer = null; + + if (($service instanceof OnSubmitDcContract) + && !$expectedDataContainer = $service->resolveDcOnSubmit($row, $dc)) { return; } // if no data container is set, use the default data container of the list type - $default = $this->listDriverRegistry->getAttribute($type)?->dataContainer; - $expectedDataContainer ??= \is_string($default) ? $default : null; + if (!$expectedDataContainer) { + $default = $this->listDriverRegistry->getAttribute($type)?->dataContainer; + $expectedDataContainer = \is_string($default) ? $default : null; + } if (!$expectedDataContainer) { throw new BadRequestHttpException(\sprintf('No data container found for list type "%s".', $type)); diff --git a/src/DependencyInjection/Attribute/AsListDriver.php b/src/DependencyInjection/Attribute/AsListDriver.php index f0c4b0ca..b8ec50c5 100644 --- a/src/DependencyInjection/Attribute/AsListDriver.php +++ b/src/DependencyInjection/Attribute/AsListDriver.php @@ -7,7 +7,7 @@ #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] class AsListDriver { - public const TAG = 'huh.flare.list_type'; + public const TAG = 'huh.flare.list_driver'; public ?string $type; public array $attributes; diff --git a/src/DependencyInjection/Compiler/RegisterListDriversPass.php b/src/DependencyInjection/Compiler/RegisterListDriversPass.php index c06f228b..79919183 100644 --- a/src/DependencyInjection/Compiler/RegisterListDriversPass.php +++ b/src/DependencyInjection/Compiler/RegisterListDriversPass.php @@ -37,7 +37,7 @@ public function process(ContainerBuilder $container): void { $type = $this->getListTypeName($definition, $attributes); - $serviceId = 'huh.flare.list_type.' . $type; + $serviceId = 'huh.flare.list_driver.' . $type; $childDefinition = new ChildDefinition((string) $reference); $childDefinition->setPublic(true); diff --git a/src/Engine/Context/Factory/InteractiveContextFactory.php b/src/Engine/Context/Factory/InteractiveContextFactory.php index 7827633d..f42a4ed9 100644 --- a/src/Engine/Context/Factory/InteractiveContextFactory.php +++ b/src/Engine/Context/Factory/InteractiveContextFactory.php @@ -23,7 +23,7 @@ public function __construct( public function createFromContent(ContentModel $contentModel, ListSpec $list): InteractiveContext { $filterFormName = $contentModel->{ContentContainer::FIELD_FORM_NAME} - ?: ('fl' . ($list->config['id'] ?? '')); + ?: ('fl' . ($contentModel->id ?? '')); $paginatorConfig = new PaginatorConfig( itemsPerPage: (int) ($contentModel->{ContentContainer::FIELD_ITEMS_PER_PAGE} ?: 0), @@ -54,4 +54,4 @@ public function createFromContent(ContentModel $contentModel, ListSpec $list): I return $config; } -} \ No newline at end of file +} diff --git a/src/Event/FilterTransformerEvent.php b/src/Event/FilterTransformerEvent.php index b7f4325b..aae0680b 100644 --- a/src/Event/FilterTransformerEvent.php +++ b/src/Event/FilterTransformerEvent.php @@ -18,6 +18,6 @@ class FilterTransformerEvent extends Event public function __construct( public readonly TransformerResolver $transformers, public readonly FilterElementInterface $element, - public readonly ?string $type, + public readonly string $type, ) {} } diff --git a/src/Event/ListTransformerEvent.php b/src/Event/ListTransformerEvent.php index 847a7b14..db24d128 100644 --- a/src/Event/ListTransformerEvent.php +++ b/src/Event/ListTransformerEvent.php @@ -18,5 +18,6 @@ class ListTransformerEvent extends Event public function __construct( public readonly TransformerResolver $transformers, public readonly ListDriverInterface $driver, + public readonly string $type, ) {} } diff --git a/src/EventListener/NamedDispatch/FilterTransformerListener.php b/src/EventListener/NamedDispatch/FilterTransformerListener.php index 2b5498ab..173e4747 100644 --- a/src/EventListener/NamedDispatch/FilterTransformerListener.php +++ b/src/EventListener/NamedDispatch/FilterTransformerListener.php @@ -5,6 +5,7 @@ namespace HeimrichHannot\FlareBundle\EventListener\NamedDispatch; use HeimrichHannot\FlareBundle\Event\FilterTransformerEvent; +use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -17,10 +18,6 @@ public function __construct( #[AsEventListener(priority: -200)] public function __invoke(FilterTransformerEvent $event): void { - if (!$event->type) { - return; - } - $this->eventDispatcher->dispatch(event: $event, eventName: "flare.filter_element.{$event->type}.transformers"); } } diff --git a/src/EventListener/NamedDispatch/ListBuildListener.php b/src/EventListener/NamedDispatch/ListBuildListener.php index a46e6265..ea480fc0 100644 --- a/src/EventListener/NamedDispatch/ListBuildListener.php +++ b/src/EventListener/NamedDispatch/ListBuildListener.php @@ -13,19 +13,17 @@ { public function __construct( private EventDispatcherInterface $eventDispatcher, - private ListDriverRegistry $listDriverRegistry, ) {} #[AsEventListener(priority: -200)] public function __invoke(ListBuildEvent $event): void { - foreach ($this->listDriverRegistry->getTypes($event->builder->getDriver()) as $type) - { - $this->eventDispatcher->dispatch(event: $event, eventName: "flare.list.{$type}.build"); + $type = $event->builder->getDriver(); - if ($event->isPropagationStopped()) { - break; - } + if (!$type ||!\is_string($type)) { + return; } + + $this->eventDispatcher->dispatch(event: $event, eventName: "flare.list.{$type}.build"); } } diff --git a/src/EventListener/NamedDispatch/ListTransformerListener.php b/src/EventListener/NamedDispatch/ListTransformerListener.php index 0c378f98..b733e456 100644 --- a/src/EventListener/NamedDispatch/ListTransformerListener.php +++ b/src/EventListener/NamedDispatch/ListTransformerListener.php @@ -13,19 +13,15 @@ { public function __construct( private EventDispatcherInterface $eventDispatcher, - private ListDriverRegistry $listDriverRegistry, ) {} #[AsEventListener(priority: -200)] public function __invoke(ListTransformerEvent $event): void { - foreach ($this->listDriverRegistry->getTypes($event->driver) as $type) - { - $this->eventDispatcher->dispatch(event: $event, eventName: "flare.list.{$type}.transformers"); - - if ($event->isPropagationStopped()) { - break; - } + if (!$event->type) { + return; } + + $this->eventDispatcher->dispatch(event: $event, eventName: "flare.list.{$event->type}.transformers"); } } diff --git a/src/Filter/Factory/FilterFactory.php b/src/Filter/Factory/FilterFactory.php index 4abc550e..bc885cf1 100644 --- a/src/Filter/Factory/FilterFactory.php +++ b/src/Filter/Factory/FilterFactory.php @@ -7,6 +7,8 @@ use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Filter; +use HeimrichHannot\FlareBundle\Filter\Resolver\FilterTransformerResolver; +use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; /** @@ -15,7 +17,8 @@ final readonly class FilterFactory { public function __construct( - private FilterElementRegistry $filterElementRegistry, + private FilterElementRegistry $filterElementRegistry, + private FilterTransformerResolver $filterTransformerResolver, ) {} /** @@ -36,15 +39,8 @@ public function create( bool $targetingForced = false, ?string $source = null, ): Filter { - $type = null; - - if (\is_string($element)) - { - $type = $element; - - $element = $this->filterElementRegistry->getService($type) - ?? throw new FlareException(\sprintf('Filter element type "%s" not found', $type)); - } + $type = $this->resolveType($element, $source); + $element = $this->resolveElement($element, $source); return new Filter( element: $element, @@ -57,4 +53,59 @@ public function create( source: $source, ); } + + /** + * @throws FlareException In case the filter element service cannot be resolved. + */ + public function createFromFilterModel( + FilterModel $filterModel + ): Filter { + $source = "{$filterModel::getTable()}.{$filterModel->id}"; + $type = $this->resolveType($filterModel->getFilterElementType(), $source); + $element = $this->resolveElement($type, $source); + + $config = $this->filterTransformerResolver->transform($element, $type, $filterModel) ?? $filterModel->row(); + + return new Filter( + element: $element, + type: $type, + config: $config, + alias: $filterModel->getFilterFormName() ?: "_.{$source}", + targetAlias: $filterModel->getFilterTargetAlias() ?: null, + source: $source, + ); + } + + /** + * @throws FlareException + */ + private function resolveType(FilterElementInterface|string $element, ?string $source = null): string + { + if (!$type = \is_object($element) ? \get_class($element) : $element) + { + throw new FlareException(\sprintf( + 'A filter element instance or registered type alias must be provided%s.', + $source ? " ($source)" : "" + ), method: __METHOD__); + } + + return $type; + } + + /** + * @throws FlareException + */ + private function resolveElement(FilterElementInterface|string $element, ?string $source = null): FilterElementInterface + { + if ($element instanceof FilterElementInterface) { + return $element; + } + + return $this->filterElementRegistry->getService($element) + ?? throw new FlareException(\sprintf( + 'Filter element type "%s" not found%s', + $element, + $source ? " ($source)" : "" + ), method: __METHOD__); + } } diff --git a/src/Filter/Filter.php b/src/Filter/Filter.php index 9f197a02..e34848ba 100644 --- a/src/Filter/Filter.php +++ b/src/Filter/Filter.php @@ -4,6 +4,7 @@ namespace HeimrichHannot\FlareBundle\Filter; +use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; /** @@ -14,14 +15,16 @@ * element's transformer responsibility * ({@see \HeimrichHannot\FlareBundle\Contract\TransformerContract}). * - * Use {@see Factory\FilterFactory} to create filters from a registered type alias. + * Use {@see Factory\FilterFactory} to create instances. + * + * @api */ final readonly class Filter { /** * @param FilterElementInterface $element Filter element service (registered or inline). - * @param string|null $type Registered element type alias, if known. Only used for named - * event dispatch (`flare.filter_element.{type}.*`) and targeting lookups. + * @param string $type Registered element type alias. Only used for named event dispatch + * (`flare.filter_element.{type}.*`) and targeting lookups. * @param array $config Canonical config (element-defined schema); scalars, arrays, and enums only. * @param array|null $data Runtime data bag, same shape buildFilter() receives * (single-field elements read {@see FilterContext::SINGLE_VALUE}). Submitted form @@ -31,10 +34,12 @@ * @param string|null $targetAlias Table alias the filter's conditions apply to. * @param bool $targetingForced Whether the target alias applies even if the element is not marked as targeted. * @param string|null $source Provenance for error messages, e.g. "tl_flare_filter.42". + * + * @internal Use {@see Factory\FilterFactory} to create instances. */ public function __construct( public FilterElementInterface $element, - public ?string $type = null, + public string $type, public array $config = [], public ?array $data = null, public ?string $alias = null, @@ -43,23 +48,6 @@ public function __construct( public ?string $source = null, ) {} - /** - * @param array $config - */ - public function withConfig(array $config): self - { - return new self( - element: $this->element, - type: $this->type, - config: $config, - data: $this->data, - alias: $this->alias, - targetAlias: $this->targetAlias, - targetingForced: $this->targetingForced, - source: $this->source, - ); - } - /** * @param array|null $data */ @@ -105,20 +93,6 @@ public function withTargetAlias(?string $targetAlias, bool $forced = true): self ); } - public function withSource(?string $source): self - { - return new self( - element: $this->element, - type: $this->type, - config: $this->config, - data: $this->data, - alias: $this->alias, - targetAlias: $this->targetAlias, - targetingForced: $this->targetingForced, - source: $source, - ); - } - /** * Stable representation for hashing/caching. */ diff --git a/src/Filter/Resolver/FilterTransformerResolver.php b/src/Filter/Resolver/FilterTransformerResolver.php index 1e641a3a..1f2ca5e1 100644 --- a/src/Filter/Resolver/FilterTransformerResolver.php +++ b/src/Filter/Resolver/FilterTransformerResolver.php @@ -30,9 +30,11 @@ public function __construct( /** * @return array|null Canonical config values, or null when no transformer matches the source. */ - public function transform(FilterElementInterface $element, ?string $elementType, object $source): ?array + public function transform(FilterElementInterface $element, string $type, object $source): ?array { - if (!isset($this->resolvers[$element::class])) + $cacheKey = \sprintf('%s@%s', $type, $element::class); + + if (!isset($this->resolvers[$cacheKey])) { $resolver = new TransformerResolver(); @@ -40,12 +42,12 @@ public function transform(FilterElementInterface $element, ?string $elementType, $element->configureTransformers($resolver); } - $this->eventDispatcher->dispatch(new FilterTransformerEvent($resolver, $element, $elementType)); + $this->eventDispatcher->dispatch(new FilterTransformerEvent($resolver, $element, $type)); - $this->resolvers[$element::class] = $resolver; + $this->resolvers[$cacheKey] = $resolver; } - if (!$transformer = $this->resolvers[$element::class]->resolve($source)) { + if (!$transformer = $this->resolvers[$cacheKey]->resolve($source)) { return null; } diff --git a/src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php b/src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php index 413f1ed4..4a9ba462 100644 --- a/src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php +++ b/src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php @@ -26,6 +26,11 @@ public function __construct( private readonly FilterFactory $filterFactory, ) {} + public function resolveDcTable(string $type, array $config, array $attributes): string + { + return self::DATA_CONTAINER; + } + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void { $dca->suffix(static function (string $suffix): string { diff --git a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php index 4ca9230f..d5cb101f 100644 --- a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php +++ b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php @@ -269,8 +269,8 @@ public function onChangeLanguageNavigation(ChangelanguageNavigationEvent $event) return; } - $table = $reader->getModel()::getTable(); - $listModel = $reader->getListModel(); + $table = $reader->displayModel::getTable(); + $listModel = $reader->listModel; if ($listModel->dc !== $table) { return; diff --git a/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php b/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php index acaaed02..76280a5d 100644 --- a/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php +++ b/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php @@ -8,13 +8,13 @@ use Contao\CoreBundle\String\SimpleTokenParser; use Contao\DataContainer; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; -use HeimrichHannot\FlareBundle\Contract\ListDriver\DataContainerContract; +use HeimrichHannot\FlareBundle\Contract\ListDriver\OnSubmitDcContract; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListDriver; use HeimrichHannot\FlareBundle\List\Driver\AbstractListDriver; use HeimrichHannot\FlareBundle\Model\ListModel; #[AsListDriver(type: self::TYPE)] -class DcMultilingualListType extends AbstractListDriver implements DataContainerContract +class DcMultilingualListType extends AbstractListDriver implements OnSubmitDcContract { public const TYPE = 'flare_generic_dc_multilingual'; public const DEFAULT_PALETTE = <<<'PALETTE' @@ -37,7 +37,7 @@ protected function getSimpleTokenParser(): SimpleTokenParser return $this->simpleTokenParser; } - public function resolveDataContainerTable(array $row, DataContainer $dc): string + public function resolveDcOnSubmit(array $row, DataContainer $dc): string { return $row['dc'] ?? ''; } diff --git a/src/Filter/Collector/ListModelFilterCollector.php b/src/List/Collector/ListModelFilterCollector.php similarity index 61% rename from src/Filter/Collector/ListModelFilterCollector.php rename to src/List/Collector/ListModelFilterCollector.php index 59aa21da..8396b5f5 100644 --- a/src/Filter/Collector/ListModelFilterCollector.php +++ b/src/List/Collector/ListModelFilterCollector.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Filter\Collector; +namespace HeimrichHannot\FlareBundle\List\Collector; use Contao\Controller; use HeimrichHannot\FlareBundle\Event\FilterCollectedEvent; +use HeimrichHannot\FlareBundle\Exception\FlareException; +use HeimrichHannot\FlareBundle\Filter\Factory\FilterFactory; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\Filter\Resolver\FilterTransformerResolver; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Model\ListModel; -use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; use HeimrichHannot\FlareBundle\Registry\ListDriverRegistry; use Psr\Log\LoggerInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -23,8 +23,7 @@ { public function __construct( private EventDispatcherInterface $eventDispatcher, - private FilterElementRegistry $filterElementRegistry, - private FilterTransformerResolver $filterTransformerResolver, + private FilterFactory $filterFactory, private ListDriverRegistry $listDriverRegistry, private LoggerInterface $logger, ) {} @@ -54,32 +53,23 @@ public function collect(ListModel $listModel): ?array continue; } - $source = "{$model::getTable()}.{$model->id}"; - $type = $model->getFilterType(); - - if (!$element = $this->filterElementRegistry->getService($type)) + try + { + $filter = $this->filterFactory->createFromFilterModel($model); + } + catch (FlareException $e) { $this->logger->warning(\sprintf( - '[FLARE] No filter element registered for type "%s" — filter skipped. (%s)', - $type, - $source, + '[FLARE] Error while creating Filter of type "%s" on [%s.%s] -- [Message] %e', + $model->getFilterElementType(), + $listModel::getTable(), + $listModel->id, + $e->getMessage(), )); continue; } - $config = $this->filterTransformerResolver->transform($element, $model->getFilterType(), $model) - ?? $model->row(); - - $filter = new Filter( - element: $element, - type: $model->getFilterType(), - config: $config, - alias: $model->getFilterFormName() ?: "_.{$source}", - targetAlias: $model->getFilterTargetAlias() ?: null, - source: $source, - ); - $filter = $this->eventDispatcher->dispatch(new FilterCollectedEvent($filter, $model))->filter; $filters[$filter->alias] = $filter; diff --git a/src/List/Driver/AbstractListDriver.php b/src/List/Driver/AbstractListDriver.php index 61adaa1b..cb2b3c80 100644 --- a/src/List/Driver/AbstractListDriver.php +++ b/src/List/Driver/AbstractListDriver.php @@ -23,9 +23,9 @@ abstract class AbstractListDriver implements ListDriverInterface, BuildListContract, BuildQueryContract, DcaContract, OptionsContract, TransformerContract { - public function getDataContainerName(array $config): string + public function resolveDcTable(string $type, array $config, array $attributes): string { - return (string) ($config['dc'] ?? ''); + return ((string) ($config['dc'] ?? '') ?: (string) ($attributes['dataContainer'] ?? '')); } /** diff --git a/src/List/Driver/GenericDataContainerListDriver.php b/src/List/Driver/GenericDataContainerListDriver.php index dccd0a72..0e7a3d73 100644 --- a/src/List/Driver/GenericDataContainerListDriver.php +++ b/src/List/Driver/GenericDataContainerListDriver.php @@ -8,7 +8,7 @@ use Contao\DataContainer; use Contao\Message; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; -use HeimrichHannot\FlareBundle\Contract\ListDriver\DataContainerContract; +use HeimrichHannot\FlareBundle\Contract\ListDriver\OnSubmitDcContract; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListDriver; @@ -18,7 +18,7 @@ use Symfony\Contracts\Translation\TranslatorInterface; #[AsListDriver(type: self::TYPE)] -class GenericDataContainerListDriver extends AbstractListDriver implements DataContainerContract +class GenericDataContainerListDriver extends AbstractListDriver implements OnSubmitDcContract { public const TYPE = 'flare_generic_dc'; public const DEFAULT_PALETTE = <<<'PALETTE' @@ -30,7 +30,7 @@ public function __construct( private readonly TranslatorInterface $trans, ) {} - public function resolveDataContainerTable(array $row, DataContainer $dc): string + public function resolveDcOnSubmit(array $row, DataContainer $dc): string { return $row['dc'] ?? ''; } diff --git a/src/List/Driver/ListDriverInterface.php b/src/List/Driver/ListDriverInterface.php index 385b5b9b..c25daa97 100644 --- a/src/List/Driver/ListDriverInterface.php +++ b/src/List/Driver/ListDriverInterface.php @@ -5,15 +5,18 @@ namespace HeimrichHannot\FlareBundle\List\Driver; /** - * A FLARE list driver — registered via #[AsListDriver] or used inline on a ListSpec. + * A Flare list driver, registered via #[AsListDriver] or used inline on a ListSpec. */ interface ListDriverInterface { /** - * Returns the main data container table of a list, derived from its canonical config. + * Returns the main data container table of a list, derived from its canonical config and, + * if registered via #[AsListDriver], the driver's attributes. * Drivers pinned to a single table may ignore the config and return that table. * + * @param string $type The registered type alias of the list driver. * @param array $config Canonical, resolved list config. + * @param array $attributes The registered attributes of the list driver. */ - public function getDataContainerName(array $config): string; + public function resolveDcTable(string $type, array $config, array $attributes): string; } diff --git a/src/List/Driver/NewsListDriver.php b/src/List/Driver/NewsListDriver.php index 447f7b30..c5309c94 100644 --- a/src/List/Driver/NewsListDriver.php +++ b/src/List/Driver/NewsListDriver.php @@ -14,16 +14,22 @@ use HeimrichHannot\FlareBundle\Query\SqlJoinStruct; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; -#[AsListDriver(type: self::TYPE, dataContainer: 'tl_news')] +#[AsListDriver(type: self::TYPE, dataContainer: self::DATA_CONTAINER)] class NewsListDriver extends AbstractListDriver { public const TYPE = 'flare_news'; + public const DATA_CONTAINER = 'tl_news'; public const ALIAS_ARCHIVE = 'news_archive'; public function __construct( private readonly FilterFactory $filterFactory, ) {} + public function resolveDcTable(string $type, array $config, array $attributes): string + { + return self::DATA_CONTAINER; + } + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void { $dca->palette('{filter_legend},'); diff --git a/src/List/Factory/ListSpecBuilderFactory.php b/src/List/Factory/ListSpecBuilderFactory.php index 1d05c30b..7819c9be 100644 --- a/src/List/Factory/ListSpecBuilderFactory.php +++ b/src/List/Factory/ListSpecBuilderFactory.php @@ -5,10 +5,9 @@ namespace HeimrichHannot\FlareBundle\List\Factory; use HeimrichHannot\FlareBundle\Exception\FlareException; -use HeimrichHannot\FlareBundle\Filter\Collector\ListModelFilterCollector; +use HeimrichHannot\FlareBundle\List\Collector\ListModelFilterCollector; use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use HeimrichHannot\FlareBundle\List\ListSpecBuilder; -use HeimrichHannot\FlareBundle\List\Resolver\ListTransformerResolver; use HeimrichHannot\FlareBundle\Model\ListModel; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -22,12 +21,8 @@ public function __construct( private EventDispatcherInterface $eventDispatcher, private ListModelFilterCollector $filterCollector, private ListSpecFactory $specFactory, - private ListTransformerResolver $listTransformerResolver, ) {} - /** - * @throws FlareException In case the list driver cannot be resolved. - */ public function create( ListDriverInterface|string $driver, ?ListModel $model = null, @@ -35,9 +30,8 @@ public function create( ): ListSpecBuilder { return new ListSpecBuilder( specFactory: $this->specFactory, - transformerResolver: $this->listTransformerResolver, eventDispatcher: $this->eventDispatcher, - driver: $this->specFactory->resolveDriver($driver), + driver: $driver, model: $model, source: $source, ); diff --git a/src/List/Factory/ListSpecFactory.php b/src/List/Factory/ListSpecFactory.php index 003ebbe8..f6a60e20 100644 --- a/src/List/Factory/ListSpecFactory.php +++ b/src/List/Factory/ListSpecFactory.php @@ -4,23 +4,32 @@ namespace HeimrichHannot\FlareBundle\List\Factory; +use Contao\Controller; +use HeimrichHannot\FlareBundle\Config\ConfigBuilder; +use HeimrichHannot\FlareBundle\Event\FilterCollectedEvent; use HeimrichHannot\FlareBundle\Exception\FlareException; +use HeimrichHannot\FlareBundle\Filter\Factory\FilterFactory; use HeimrichHannot\FlareBundle\Filter\Filter; +use HeimrichHannot\FlareBundle\List\BaseListOptions; use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\List\Resolver\ListOptionsResolver; +use HeimrichHannot\FlareBundle\List\Resolver\ListTransformerResolver; +use HeimrichHannot\FlareBundle\Model\FilterModel; +use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Registry\ListDriverRegistry; /** * The single construction path for {@see ListSpec}: resolves the driver from its type alias if * necessary, resolves the config through the base and driver schemas, and guarantees a - * well-defined data container ({@see ListDriverInterface::getDataContainerName()}). + * well-defined data container ({@see ListDriverInterface::resolveDcTable()}). */ final readonly class ListSpecFactory { public function __construct( - private ListDriverRegistry $listDriverRegistry, - private ListOptionsResolver $listOptionsResolver, + private ListDriverRegistry $listDriverRegistry, + private ListOptionsResolver $listOptionsResolver, + private ListTransformerResolver $transformerResolver, ) {} /** @@ -36,38 +45,119 @@ public function create( array $config = [], ?string $source = null, ): ListSpec { + $type = $this->resolveType($driver); $driver = $this->resolveDriver($driver); $config = $this->listOptionsResolver->resolve($driver, $config, $source); - if (!$dc = $driver->getDataContainerName($config)) - { - throw new FlareException( - \sprintf('Failed to evaluate data container table of list "%s".', $source ?? \get_class($driver)), - method: __METHOD__, - ); + $dc = $this->resolveDataContainer($config, $driver, $type, $source); + + return new ListSpec( + driver: $driver, + type: $type, + dc: $dc, + filters: $filters, + config: $config, + source: $source, + ); + } + + /** + * @throws FlareException In case the driver cannot be resolved, the config does not satisfy + * the schema, or no data container can be determined. + */ + public function createFromListModel( + ?ListModel $listModel, + ListDriverInterface|string|null $driver = null, + array $filters = [], + array $config = [], + ?string $source = null, + ): ListSpec { + $driver ??= $listModel->getListDriverType(); + $type = $this->resolveType($driver); + $driver = $this->resolveDriver($driver); + + $configBuilder = new ConfigBuilder(); + + BaseListOptions::transform($configBuilder, $listModel); + + $transformed = $this->transformerResolver->transform($driver, $type, $listModel); + + foreach ($transformed ?? [] as $key => $value) { + $configBuilder->set($key, $value); } - $config['dc'] = $dc; + foreach ($config as $key => $value) { + $configBuilder->set($key, $value); + } + + $finalConfig = $this->listOptionsResolver->resolve($driver, $configBuilder->all(), $source); + + $dc = $this->resolveDataContainer($finalConfig, $driver, $type, $source); return new ListSpec( driver: $driver, + type: $type, + dc: $dc, filters: $filters, - config: $config, + config: $finalConfig, source: $source, ); } + /** + * @throws FlareException + */ + private function resolveType(ListDriverInterface|string $driver, ?string $source = null): string + { + if (!$type = \is_object($driver) ? \get_class($driver) : (string) $driver) + { + throw new FlareException(\sprintf( + 'A list driver instance or registered type alias must be provided%s.', + $source ? " ($source)" : '', + ), method: __METHOD__); + } + + return $type; + } + /** * @throws FlareException In case no driver is registered under the given type alias. */ - public function resolveDriver(ListDriverInterface|string $driver): ListDriverInterface + private function resolveDriver(ListDriverInterface|string $driver, ?string $source = null): ListDriverInterface { if ($driver instanceof ListDriverInterface) { return $driver; } return $this->listDriverRegistry->getService($driver) - ?? throw new FlareException(\sprintf('List type "%s" not found', $driver)); + ?? throw new FlareException(\sprintf( + 'List type "%s" not found%s.', + $driver, + $source ? " ($source)" : '' + ), method: __METHOD__); + } + + /** + * @throws FlareException + */ + private function resolveDataContainer( + array $config, + ListDriverInterface $driver, + string $type, + ?string $source = null + ): string { + $attributes = $this->listDriverRegistry->getAttribute($type)?->attributes ?? []; + + if (!$dc = $driver->resolveDcTable($type, $config, $attributes)) + { + throw new FlareException(\sprintf( + 'Failed to evaluate data container table of list type "%s"%s.', + $type, + $source ? " ($source)" : '' + ), method: __METHOD__); + } + + return $dc; } } diff --git a/src/List/ListSpec.php b/src/List/ListSpec.php index 257a6342..7bb8f537 100644 --- a/src/List/ListSpec.php +++ b/src/List/ListSpec.php @@ -20,28 +20,27 @@ * * Use {@see Factory\ListSpecFactory} to create instances — it resolves the config schema * and guarantees a well-defined data container. + * + * @api */ final readonly class ListSpec { - /** - * The main data container table of the list. - */ - public string $dc; - /** * @param ListDriverInterface $driver List driver service (registered or inline). * @param array $filters * @param array $config Canonical config, resolved through the base and driver schemas. * @param string|null $source Provenance for error messages, e.g. "tl_flare_list.5". + * + * @internal Use {@see Factory\ListSpecFactory} to create instances. */ public function __construct( public ListDriverInterface $driver, + public string $type, + public string $dc, public array $filters = [], public array $config = [], public ?string $source = null, - ) { - $this->dc = (string) ($this->config['dc'] ?? ''); - } + ) {} /** * Adds a filter. The key defaults to the filter's alias; alias-less filters receive a generated key. @@ -77,25 +76,14 @@ public function withFilters(array $filters): self { return new self( driver: $this->driver, + type: $this->type, + dc: $this->dc, filters: $filters, config: $this->config, source: $this->source, ); } - /** - * @param array $config - */ - public function withConfig(array $config): self - { - return new self( - driver: $this->driver, - filters: $this->filters, - config: $config, - source: $this->source, - ); - } - /** * @param class-string $class */ @@ -126,6 +114,8 @@ public function hash(): string { return \sha1(\serialize([ \get_class($this->driver), + $this->type, + $this->dc, $this->source, $this->config, \array_map(static fn (Filter $filter): array => $filter->fingerprint(), $this->filters), diff --git a/src/List/ListSpecBuilder.php b/src/List/ListSpecBuilder.php index a994a2c9..d22405a6 100644 --- a/src/List/ListSpecBuilder.php +++ b/src/List/ListSpecBuilder.php @@ -4,7 +4,6 @@ namespace HeimrichHannot\FlareBundle\List; -use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\Contract\ListDriver\BuildListContract; use HeimrichHannot\FlareBundle\Event\ListBuildEvent; use HeimrichHannot\FlareBundle\Exception\FlareException; @@ -12,7 +11,6 @@ use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use HeimrichHannot\FlareBundle\List\Factory\ListSpecFactory; -use HeimrichHannot\FlareBundle\List\Resolver\ListTransformerResolver; use HeimrichHannot\FlareBundle\Model\ListModel; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -39,15 +37,14 @@ final class ListSpecBuilder implements ListSpecBuilderInterface private int $generatedFilterKeys = 0; public function __construct( - private readonly ListSpecFactory $specFactory, - private readonly ListTransformerResolver $transformerResolver, - private readonly EventDispatcherInterface $eventDispatcher, - private readonly ListDriverInterface $driver, - private readonly ?ListModel $model = null, - private readonly ?string $source = null, + private readonly ListSpecFactory $specFactory, + private readonly EventDispatcherInterface $eventDispatcher, + private readonly ListDriverInterface|string $driver, + private readonly ?ListModel $model = null, + private readonly ?string $source = null, ) {} - public function getDriver(): ListDriverInterface + public function getDriver(): ListDriverInterface|string { return $this->driver; } @@ -132,27 +129,21 @@ public function build(): ListSpec $this->eventDispatcher->dispatch(new ListBuildEvent($this)); - $config = new ConfigBuilder(); - if ($this->model) { - BaseListOptions::transform($config, $this->model); - - $transformed = $this->transformerResolver->transform($driver, $this->model); - - foreach ($transformed ?? [] as $key => $value) { - $config->set($key, $value); - } - } - - foreach ($this->overrides as $key => $value) { - $config->set($key, $value); + return $this->specFactory->createFromListModel( + listModel: $this->model, + driver: $driver, + filters: $this->filters, + config: $this->overrides, + source: $this->source, + ); } return $this->specFactory->create( driver: $driver, filters: $this->filters, - config: $config->all(), + config: $this->overrides, source: $this->source, ); } diff --git a/src/List/ListSpecBuilderInterface.php b/src/List/ListSpecBuilderInterface.php index 615858e4..fcdb6482 100644 --- a/src/List/ListSpecBuilderInterface.php +++ b/src/List/ListSpecBuilderInterface.php @@ -11,7 +11,7 @@ interface ListSpecBuilderInterface { - public function getDriver(): ListDriverInterface; + public function getDriver(): ListDriverInterface|string; public function getModel(): ?ListModel; diff --git a/src/List/Resolver/ListTransformerResolver.php b/src/List/Resolver/ListTransformerResolver.php index 3a6876f9..42c2d620 100644 --- a/src/List/Resolver/ListTransformerResolver.php +++ b/src/List/Resolver/ListTransformerResolver.php @@ -30,9 +30,11 @@ public function __construct( /** * @return array|null Canonical config values, or null when no transformer matches the source. */ - public function transform(ListDriverInterface $driver, object $source): ?array + public function transform(ListDriverInterface $driver, string $type, object $source): ?array { - if (!isset($this->resolvers[$driver::class])) + $cacheKey = \sprintf('%s@%s', $type, $driver::class); + + if (!isset($this->resolvers[$cacheKey])) { $resolver = new TransformerResolver(); @@ -40,12 +42,12 @@ public function transform(ListDriverInterface $driver, object $source): ?array $driver->configureTransformers($resolver); } - $this->eventDispatcher->dispatch(new ListTransformerEvent($resolver, $driver)); + $this->eventDispatcher->dispatch(new ListTransformerEvent($resolver, $driver, $type)); - $this->resolvers[$driver::class] = $resolver; + $this->resolvers[$cacheKey] = $resolver; } - if (!$transformer = $this->resolvers[$driver::class]->resolve($source)) { + if (!$transformer = $this->resolvers[$cacheKey]->resolve($source)) { return null; } diff --git a/src/Model/FilterModel.php b/src/Model/FilterModel.php index a042fdbc..d9c4f75a 100644 --- a/src/Model/FilterModel.php +++ b/src/Model/FilterModel.php @@ -26,7 +26,7 @@ public function getFilterIdentifier(): string return (string) $this->id; } - public function getFilterType(): string + public function getFilterElementType(): string { return (string) $this->type; } @@ -115,4 +115,4 @@ public function __get($strKey) default => parent::__get($strKey), }; } -} \ No newline at end of file +} diff --git a/src/Model/ListModel.php b/src/Model/ListModel.php index d0cc62d5..a1c34a36 100644 --- a/src/Model/ListModel.php +++ b/src/Model/ListModel.php @@ -19,6 +19,11 @@ class ListModel extends Model implements PtableInferrableInterface protected static $strTable = ListContainer::TABLE_NAME; + public function getListDriverType(): ?string + { + return $this->type; + } + public function getAutoItemField(): string { return $this->fieldAutoItem ?: DcaHelper::tryGetColumnName($this->dc, 'alias', 'id'); diff --git a/src/Reader/Factory/ReaderRequestAttributeFactory.php b/src/Reader/Factory/ReaderRequestAttributeFactory.php index 5bab858e..7eccc0ec 100644 --- a/src/Reader/Factory/ReaderRequestAttributeFactory.php +++ b/src/Reader/Factory/ReaderRequestAttributeFactory.php @@ -5,15 +5,15 @@ namespace HeimrichHannot\FlareBundle\Reader\Factory; use Contao\Model; -use HeimrichHannot\FlareBundle\List\Factory\ListSpecBuilderFactory; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Reader\ReaderRequestAttribute; final readonly class ReaderRequestAttributeFactory { - public function __construct( - private ListSpecBuilderFactory $listFactory, - ) {} + public function createFromModels(Model $displayModel, ListModel $listModel): ReaderRequestAttribute + { + return new ReaderRequestAttribute($displayModel, $listModel); + } public function createFromData(array $data): ?ReaderRequestAttribute { @@ -30,16 +30,14 @@ public function createFromData(array $data): ?ReaderRequestAttribute return null; } - /** @var Model $model */ - $model = $modelClass::findByPk($modelId); + /** @var Model $displayModel */ + $displayModel = $modelClass::findByPk($modelId); $listModel = ListModel::findByPk($listId); - if (!$model || !$listModel) { + if (!$displayModel || !$listModel) { throw new \InvalidArgumentException('Invalid data for ReaderRequestAttribute unmarshalling.'); } - $spec = $this->listFactory->createFromListModel($listModel)->build(); - - return new ReaderRequestAttribute($model, $spec); + return new ReaderRequestAttribute($displayModel, $listModel); } } diff --git a/src/Reader/ReaderRequestAttribute.php b/src/Reader/ReaderRequestAttribute.php index b5131fb6..18f6466f 100644 --- a/src/Reader/ReaderRequestAttribute.php +++ b/src/Reader/ReaderRequestAttribute.php @@ -5,32 +5,22 @@ namespace HeimrichHannot\FlareBundle\Reader; use Contao\Model; -use HeimrichHannot\FlareBundle\List\ListSpec; +use HeimrichHannot\FlareBundle\Model\ListModel; readonly class ReaderRequestAttribute { public function __construct( - private Model $model, - private ListSpec $list, + public Model $displayModel, + public ListModel $listModel, ) {} - public function getModel(): Model - { - return $this->model; - } - - public function getList(): ListSpec - { - return $this->list; - } - public function marshal(): array { return [ - 'model_class' => $this->model::class, - 'model_table' => $this->model::getTable(), - 'model_id' => $this->model->id, - 'list_id' => $this->list->config['id'] ?? null, + 'model_class' => $this->displayModel::class, + 'model_table' => $this->displayModel::getTable(), + 'model_id' => $this->displayModel->id, + 'list_id' => $this->listModel->id, ]; } -} \ No newline at end of file +} From 6f2cddc983f379f75d50b350d315dd4446153e12 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Sat, 18 Jul 2026 04:36:13 +0200 Subject: [PATCH 59/71] refactor: update tests to reflect recent Filter and ListSpec type handling changes and improve consistency --- .../Projector/InteractiveProjectorTest.php | 6 +-- .../NamedDispatch/ListBuildListenerTest.php | 35 ++++++----------- tests/Filter/FilterFactoryTest.php | 20 +++++++--- tests/Filter/FilterTest.php | 4 +- .../Filter/FilterTransformerResolverTest.php | 12 +++--- tests/Form/FilterFormFactoryTest.php | 17 ++++---- tests/List/ListSpecBuilderTest.php | 14 ++++--- tests/List/ListSpecFactoryTest.php | 7 +++- tests/List/ListSpecTest.php | 39 +++++++++---------- tests/List/ListTransformerResolverTest.php | 16 ++++---- tests/Registry/ListDriverRegistryTest.php | 2 +- 11 files changed, 88 insertions(+), 84 deletions(-) diff --git a/tests/Engine/Projector/InteractiveProjectorTest.php b/tests/Engine/Projector/InteractiveProjectorTest.php index 40ffda82..841da951 100644 --- a/tests/Engine/Projector/InteractiveProjectorTest.php +++ b/tests/Engine/Projector/InteractiveProjectorTest.php @@ -54,7 +54,7 @@ private function addFlatChild(FormBuilderInterface $root, string $alias, array $ private function listWithFilter(string $key, string $alias): ListSpec { $driver = new class implements ListDriverInterface { - public function getDataContainerName(array $config): string + public function resolveDcTable(string $type, array $config, array $attributes): string { return (string) ($config['dc'] ?? ''); } @@ -66,9 +66,9 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void {} }; - return new ListSpec(driver: $driver, filters: [ + return new ListSpec(driver: $driver, type: 'test_list', dc: 'tl_test', filters: [ $key => new Filter(element: $element, type: 'test_element', alias: $alias), - ], config: ['dc' => 'tl_test']); + ]); } public function testFlatSubmittedValueIsKeyedCanonically(): void diff --git a/tests/EventListener/NamedDispatch/ListBuildListenerTest.php b/tests/EventListener/NamedDispatch/ListBuildListenerTest.php index 85b0ac01..02b06ba1 100644 --- a/tests/EventListener/NamedDispatch/ListBuildListenerTest.php +++ b/tests/EventListener/NamedDispatch/ListBuildListenerTest.php @@ -19,36 +19,22 @@ final class ListBuildListenerTest extends TestCase { - public function testDispatchesOncePerRegisteredType(): void + public function testDispatchesNamedEventForStringDriverType(): void { - $driver = new class extends AbstractListDriver {}; - - $registry = new ListDriverRegistry(); - $registry->add($driver, null, 'a'); - $registry->add($driver, null, 'b'); - - self::assertSame( - ['flare.list.a.build', 'flare.list.b.build'], - $this->dispatchedNames($driver, $registry), - ); + self::assertSame(['flare.list.a.build'], $this->dispatchedNames('a')); } - public function testUnregisteredInlineDriverTriggersNoNamedDispatch(): void + public function testInstanceDriverTriggersNoNamedDispatch(): void { - $registered = new class extends AbstractListDriver {}; - - $registry = new ListDriverRegistry(); - $registry->add($registered, null, 'a'); - - $inline = new ($registered::class)(); + $driver = new class extends AbstractListDriver {}; - self::assertSame([], $this->dispatchedNames($inline, $registry)); + self::assertSame([], $this->dispatchedNames($driver)); } /** * @return list */ - private function dispatchedNames(ListDriverInterface $driver, ListDriverRegistry $registry): array + private function dispatchedNames(ListDriverInterface|string $driver): array { $names = []; @@ -65,13 +51,16 @@ static function () use (&$names, $type): void { } $builder = new ListSpecBuilder( - specFactory: new ListSpecFactory($registry, new ListOptionsResolver(new SchemaResolver())), - transformerResolver: new ListTransformerResolver($dispatcher), + specFactory: new ListSpecFactory( + new ListDriverRegistry(), + new ListOptionsResolver(new SchemaResolver()), + new ListTransformerResolver($dispatcher), + ), eventDispatcher: $dispatcher, driver: $driver, ); - $listener = new ListBuildListener($dispatcher, $registry); + $listener = new ListBuildListener($dispatcher); $listener(new ListBuildEvent($builder)); return $names; diff --git a/tests/Filter/FilterFactoryTest.php b/tests/Filter/FilterFactoryTest.php index 005425ff..d996efb9 100644 --- a/tests/Filter/FilterFactoryTest.php +++ b/tests/Filter/FilterFactoryTest.php @@ -10,11 +10,21 @@ use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\Resolver\FilterTransformerResolver; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; use PHPUnit\Framework\TestCase; +use Symfony\Component\EventDispatcher\EventDispatcher; final class FilterFactoryTest extends TestCase { + private static function factory(?FilterElementRegistry $registry = null): FilterFactory + { + return new FilterFactory( + $registry ?? new FilterElementRegistry(), + new FilterTransformerResolver(new EventDispatcher()), + ); + } + private static function element(): FilterElementInterface { return new class implements FilterElementInterface { @@ -31,7 +41,7 @@ public function testCreatesFromRegisteredTypeAlias(): void $registry = new FilterElementRegistry(); $registry->add($element, null, 'my_element'); - $filter = (new FilterFactory($registry))->create( + $filter = self::factory($registry)->create( element: 'my_element', config: ['a' => 1], alias: 'foo', @@ -43,14 +53,14 @@ public function testCreatesFromRegisteredTypeAlias(): void self::assertSame('foo', $filter->alias); } - public function testCreatesFromInstanceWithoutType(): void + public function testCreatesFromInstanceUsingItsClassNameAsType(): void { $element = self::element(); - $filter = (new FilterFactory(new FilterElementRegistry()))->create(element: $element); + $filter = self::factory()->create(element: $element); self::assertSame($element, $filter->element); - self::assertNull($filter->type); + self::assertSame(\get_class($element), $filter->type); } public function testThrowsForUnknownTypeAlias(): void @@ -58,6 +68,6 @@ public function testThrowsForUnknownTypeAlias(): void $this->expectException(FlareException::class); $this->expectExceptionMessage('Filter element type "missing" not found'); - (new FilterFactory(new FilterElementRegistry()))->create(element: 'missing'); + self::factory()->create(element: 'missing'); } } diff --git a/tests/Filter/FilterTest.php b/tests/Filter/FilterTest.php index d141d4db..22e61f0f 100644 --- a/tests/Filter/FilterTest.php +++ b/tests/Filter/FilterTest.php @@ -61,6 +61,8 @@ public function testFingerprintReflectsIdentityAndContent(): void self::assertSame('test', $fingerprint['type']); self::assertSame(['a' => 1], $fingerprint['config']); self::assertSame('foo', $fingerprint['alias']); - self::assertNotSame($fingerprint, $filter->withConfig(['a' => 2])->fingerprint()); + $changedConfig = new Filter(element: self::element(), type: 'test', config: ['a' => 2], alias: 'foo'); + + self::assertNotSame($fingerprint, $changedConfig->fingerprint()); } } diff --git a/tests/Filter/FilterTransformerResolverTest.php b/tests/Filter/FilterTransformerResolverTest.php index 43baf8f3..e2ee82e3 100644 --- a/tests/Filter/FilterTransformerResolverTest.php +++ b/tests/Filter/FilterTransformerResolverTest.php @@ -23,7 +23,7 @@ public function testTransformsSourceThroughElementTransformer(): void $resolver = new FilterTransformerResolver(new EventDispatcher()); $element = new TransformingElement(); - $config = $resolver->transform($element, 'test', new RowSource(['value' => 'x'])); + $config = $resolver->transform($element, 'transforming', new RowSource(['value' => 'x'])); self::assertSame(['value' => 'x'], $config); } @@ -32,8 +32,8 @@ public function testReturnsNullWithoutMatchingTransformer(): void { $resolver = new FilterTransformerResolver(new EventDispatcher()); - self::assertNull($resolver->transform(new TransformingElement(), 'test', new \stdClass())); - self::assertNull($resolver->transform(new PlainTransformerlessElement(), 'test', new RowSource([]))); + self::assertNull($resolver->transform(new TransformingElement(), 'transforming', new \stdClass())); + self::assertNull($resolver->transform(new PlainTransformerlessElement(), 'plain', new RowSource([]))); } public function testMemoizesBuilderAndDispatchesEventOncePerElementClass(): void @@ -48,8 +48,8 @@ public function testMemoizesBuilderAndDispatchesEventOncePerElementClass(): void $resolver = new FilterTransformerResolver($dispatcher); $element = new TransformingElement(); - $resolver->transform($element, 'test', new RowSource([])); - $resolver->transform($element, 'test', new RowSource([])); + $resolver->transform($element, 'transforming', new RowSource([])); + $resolver->transform($element, 'transforming', new RowSource([])); self::assertSame(1, $dispatched); } @@ -69,7 +69,7 @@ static function (FilterTransformerEvent $event): void { $resolver = new FilterTransformerResolver($dispatcher); - $config = $resolver->transform(new PlainTransformerlessElement(), 'test', new \stdClass()); + $config = $resolver->transform(new PlainTransformerlessElement(), 'plain', new \stdClass()); self::assertSame(['external' => true], $config); } diff --git a/tests/Form/FilterFormFactoryTest.php b/tests/Form/FilterFormFactoryTest.php index 012d783e..9dbef1ff 100644 --- a/tests/Form/FilterFormFactoryTest.php +++ b/tests/Form/FilterFormFactoryTest.php @@ -55,7 +55,7 @@ private function createFactory(): FilterFormFactory private function createForm(array $filters): FormInterface { $driver = new class implements ListDriverInterface { - public function getDataContainerName(array $config): string + public function resolveDcTable(string $type, array $config, array $attributes): string { return (string) ($config['dc'] ?? ''); } @@ -63,8 +63,9 @@ public function getDataContainerName(array $config): string $list = new ListSpec( driver: $driver, + type: 'test_list', + dc: 'tl_test', filters: $filters, - config: ['dc' => 'tl_test'], ); $context = new class implements ContextInterface, FormContextInterface { @@ -120,7 +121,7 @@ public function testSingleFieldMountsFlatUnderTheAlias(): void $builder->addEventListener(FormEvents::POST_SUBMIT, static function (): void {}); }); - $form = $this->createForm(['suche' => new Filter(element: $element, alias: 'suche')]); + $form = $this->createForm(['suche' => new Filter(element: $element, type: 'test_element', alias: 'suche')]); $this->assertTrue($form->has('suche')); @@ -143,7 +144,7 @@ public function testSingleWithCompanionFieldMountsNestedCompound(): void $builder->add('extra', TextType::class, ['required' => false]); }); - $form = $this->createForm(['suche' => new Filter(element: $element, alias: 'suche')]); + $form = $this->createForm(['suche' => new Filter(element: $element, type: 'test_element', alias: 'suche')]); $child = $form->get('suche'); @@ -161,7 +162,7 @@ public function testMultiFieldElementMountsNestedCompound(): void $builder->addEventListener(FormEvents::POST_SUBMIT, static function (): void {}); }); - $form = $this->createForm(['range' => new Filter(element: $element, alias: 'range')]); + $form = $this->createForm(['range' => new Filter(element: $element, type: 'test_element', alias: 'range')]); $child = $form->get('range'); @@ -178,7 +179,7 @@ public function testElementWithoutFieldsIsNotMounted(): void { $element = $this->element(static function (): void {}); - $form = $this->createForm(['empty' => new Filter(element: $element, alias: 'empty')]); + $form = $this->createForm(['empty' => new Filter(element: $element, type: 'test_element', alias: 'empty')]); $this->assertFalse($form->has('empty')); } @@ -189,7 +190,7 @@ public function testInvalidAliasIsSkipped(): void $builder->single(TextType::class); }); - $form = $this->createForm(['x' => new Filter(element: $element, alias: '_.tl_flare_filter.1')]); + $form = $this->createForm(['x' => new Filter(element: $element, type: 'test_element', alias: '_.tl_flare_filter.1')]); $this->assertSame(0, \count($form)); } @@ -205,7 +206,7 @@ public function testCancelledEventPreventsMounting(): void $builder->single(TextType::class); }); - $form = $this->createForm(['suche' => new Filter(element: $element, alias: 'suche')]); + $form = $this->createForm(['suche' => new Filter(element: $element, type: 'test_element', alias: 'suche')]); $this->assertFalse($form->has('suche')); } diff --git a/tests/List/ListSpecBuilderTest.php b/tests/List/ListSpecBuilderTest.php index ad048d04..e798b1bb 100644 --- a/tests/List/ListSpecBuilderTest.php +++ b/tests/List/ListSpecBuilderTest.php @@ -73,7 +73,7 @@ public function testFiltersDcAndSourceCarryOverToTheSpec(): void { $builder = $this->createBuilder(new EventDispatcher()); - $builder->addFilter(new Filter(element: new StubFilterElement(), alias: 'x')); + $builder->addFilter(new Filter(element: new StubFilterElement(), type: 'stub', alias: 'x')); $builder->addFilter(self::filter('b')); $builder->removeFilter('x'); @@ -119,7 +119,6 @@ public function testBuildFailsWithoutAnyDataContainer(): void { $builder = new ListSpecBuilder( specFactory: self::specFactory(), - transformerResolver: new ListTransformerResolver(new EventDispatcher()), eventDispatcher: new EventDispatcher(), driver: new class extends AbstractListDriver {}, source: 'tl_flare_list.9', @@ -145,9 +144,13 @@ public function testInvalidConfigThrowsWithSourceProvenance(): void } } - private static function specFactory(): ListSpecFactory + private static function specFactory(?EventDispatcher $dispatcher = null): ListSpecFactory { - return new ListSpecFactory(new ListDriverRegistry(), new ListOptionsResolver(new SchemaResolver())); + return new ListSpecFactory( + new ListDriverRegistry(), + new ListOptionsResolver(new SchemaResolver()), + new ListTransformerResolver($dispatcher ?? new EventDispatcher()), + ); } private function createBuilder( @@ -156,8 +159,7 @@ private function createBuilder( ?ListModel $model = null, ): ListSpecBuilder { return new ListSpecBuilder( - specFactory: self::specFactory(), - transformerResolver: new ListTransformerResolver($dispatcher), + specFactory: self::specFactory($dispatcher), eventDispatcher: $dispatcher, driver: $driver ?? new class extends AbstractListDriver {}, model: $model ?? new ListModelStub(['dc' => 'tl_test']), diff --git a/tests/List/ListSpecFactoryTest.php b/tests/List/ListSpecFactoryTest.php index b2059bbc..76889130 100644 --- a/tests/List/ListSpecFactoryTest.php +++ b/tests/List/ListSpecFactoryTest.php @@ -8,9 +8,11 @@ use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\List\Factory\ListSpecFactory; use HeimrichHannot\FlareBundle\List\Resolver\ListOptionsResolver; +use HeimrichHannot\FlareBundle\List\Resolver\ListTransformerResolver; use HeimrichHannot\FlareBundle\List\Driver\AbstractListDriver; use HeimrichHannot\FlareBundle\Registry\ListDriverRegistry; use PHPUnit\Framework\TestCase; +use Symfony\Component\EventDispatcher\EventDispatcher; final class ListSpecFactoryTest extends TestCase { @@ -19,6 +21,7 @@ private function createFactory(?ListDriverRegistry $registry = null): ListSpecFa return new ListSpecFactory( $registry ?? new ListDriverRegistry(), new ListOptionsResolver(new SchemaResolver()), + new ListTransformerResolver(new EventDispatcher()), ); } @@ -71,7 +74,7 @@ public function testThrowsWhenNoDataContainerCanBeDetermined(): void public function testDriverPinnedToATableDefinesTheDcRegardlessOfConfig(): void { $driver = new class extends AbstractListDriver { - public function getDataContainerName(array $config): string + public function resolveDcTable(string $type, array $config, array $attributes): string { return 'tl_news'; } @@ -80,6 +83,6 @@ public function getDataContainerName(array $config): string $spec = $this->createFactory()->create(driver: $driver); self::assertSame('tl_news', $spec->dc); - self::assertSame('tl_news', $spec->config['dc']); + self::assertSame('', $spec->config['dc']); // dc lives on the spec; config keeps its own value } } diff --git a/tests/List/ListSpecTest.php b/tests/List/ListSpecTest.php index 85699a6e..610bbb2e 100644 --- a/tests/List/ListSpecTest.php +++ b/tests/List/ListSpecTest.php @@ -21,7 +21,7 @@ private static function driver(): ListDriverInterface static $driver = null; return $driver ??= new class implements ListDriverInterface { - public function getDataContainerName(array $config): string + public function resolveDcTable(string $type, array $config, array $attributes): string { return (string) ($config['dc'] ?? ''); } @@ -41,27 +41,27 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont return new Filter(element: $element, type: $type, alias: $alias); } - public function testDataContainerNameComesFromConfig(): void + private static function spec(array $config = [], ?string $source = null): ListSpec { - $spec = new ListSpec(driver: self::driver(), config: ['dc' => 'tl_test']); - - self::assertSame('tl_test', $spec->dc); - self::assertSame('', (new ListSpec(driver: self::driver()))->dc); + return new ListSpec( + driver: self::driver(), + type: 'test_list', + dc: 'tl_test', + config: $config, + source: $source, + ); } public function testWithFilterKeysByAliasByDefault(): void { - $spec = new ListSpec(driver: self::driver()); - - $spec = $spec->withFilter(self::filter('flare_bool', 'foo')); + $spec = self::spec()->withFilter(self::filter('flare_bool', 'foo')); self::assertArrayHasKey('foo', $spec->filters); } public function testWithFilterAcceptsExplicitKey(): void { - $spec = (new ListSpec(driver: self::driver())) - ->withFilter(self::filter('flare_bool', 'foo'), 'custom'); + $spec = self::spec()->withFilter(self::filter('flare_bool', 'foo'), 'custom'); self::assertArrayHasKey('custom', $spec->filters); self::assertArrayNotHasKey('foo', $spec->filters); @@ -69,7 +69,7 @@ public function testWithFilterAcceptsExplicitKey(): void public function testWithFilterGeneratesCollisionFreeKeysForAliasLessFilters(): void { - $spec = (new ListSpec(driver: self::driver())) + $spec = self::spec() ->withFilter(self::filter('a')) ->withFilter(self::filter('b')); @@ -84,23 +84,20 @@ public function testWithFilterGeneratesCollisionFreeKeysForAliasLessFilters(): v public function testModifiersAreImmutable(): void { - $original = new ListSpec(driver: self::driver(), config: ['id' => 1]); + $original = self::spec(config: ['id' => 1]); - $modified = $original - ->withFilter(self::filter('a', 'x')) - ->withConfig(['id' => 2]); + $modified = $original->withFilter(self::filter('a', 'x')); self::assertSame([], $original->filters); - self::assertSame(['id' => 1], $original->config); self::assertNotSame($original, $modified); - self::assertSame(['id' => 2], $modified->config); + self::assertSame(['id' => 1], $modified->config); self::assertArrayHasKey('x', $modified->filters); } public function testHasFilterInstance(): void { - $spec = (new ListSpec(driver: self::driver())) - ->withFilter(new Filter(element: new StubFilterElement(), alias: 'p')); + $spec = self::spec() + ->withFilter(new Filter(element: new StubFilterElement(), type: 'stub', alias: 'p')); self::assertTrue($spec->hasFilterInstance(StubFilterElement::class)); self::assertTrue($spec->hasFilterInstance(FilterElementInterface::class)); @@ -110,7 +107,7 @@ public function testHasFilterInstance(): void public function testHashIsStableAndChangesWithContent(): void { $make = static fn (array $config = [], ?string $source = null): ListSpec => - new ListSpec(driver: self::driver(), config: $config, source: $source); + self::spec(config: $config, source: $source); self::assertSame($make()->hash(), $make()->hash()); self::assertNotSame($make()->hash(), $make(config: ['id' => 1])->hash()); diff --git a/tests/List/ListTransformerResolverTest.php b/tests/List/ListTransformerResolverTest.php index 34a61611..4019261d 100644 --- a/tests/List/ListTransformerResolverTest.php +++ b/tests/List/ListTransformerResolverTest.php @@ -19,7 +19,7 @@ public function testTransformsSourceThroughDriverTransformers(): void { $resolver = new ListTransformerResolver(new EventDispatcher()); - $values = $resolver->transform(new TransformingDriver(), new SourceStub('from-source')); + $values = $resolver->transform(new TransformingDriver(), 'transforming', new SourceStub('from-source')); self::assertSame(['title' => 'from-source'], $values); } @@ -28,8 +28,8 @@ public function testReturnsNullWithoutMatchingTransformer(): void { $resolver = new ListTransformerResolver(new EventDispatcher()); - self::assertNull($resolver->transform(new TransformingDriver(), new \stdClass())); - self::assertNull($resolver->transform(new TransformerlessDriver(), new SourceStub('x'))); + self::assertNull($resolver->transform(new TransformingDriver(), 'transforming', new \stdClass())); + self::assertNull($resolver->transform(new TransformerlessDriver(), 'plain', new SourceStub('x'))); } public function testMemoizesMapAndDispatchesEventOncePerDriverClass(): void @@ -47,8 +47,8 @@ static function (ListTransformerEvent $event) use (&$dispatchedWith): void { $resolver = new ListTransformerResolver($dispatcher); $driver = new TransformingDriver(); - $resolver->transform($driver, new SourceStub('a')); - $resolver->transform($driver, new SourceStub('b')); + $resolver->transform($driver, 'transforming', new SourceStub('a')); + $resolver->transform($driver, 'transforming', new SourceStub('b')); self::assertSame(1, $driver->configureCalls); self::assertCount(1, $dispatchedWith); @@ -70,7 +70,7 @@ static function (ListTransformerEvent $event): void { $resolver = new ListTransformerResolver($dispatcher); - $values = $resolver->transform(new TransformerlessDriver(), new \stdClass()); + $values = $resolver->transform(new TransformerlessDriver(), 'plain', new \stdClass()); self::assertSame(['external' => true], $values); } @@ -87,7 +87,7 @@ final class TransformingDriver implements ListDriverInterface, TransformerContra { public int $configureCalls = 0; - public function getDataContainerName(array $config): string + public function resolveDcTable(string $type, array $config, array $attributes): string { return (string) ($config['dc'] ?? ''); } @@ -104,7 +104,7 @@ public function configureTransformers(TransformerResolver $resolver): void final class TransformerlessDriver implements ListDriverInterface { - public function getDataContainerName(array $config): string + public function resolveDcTable(string $type, array $config, array $attributes): string { return (string) ($config['dc'] ?? ''); } diff --git a/tests/Registry/ListDriverRegistryTest.php b/tests/Registry/ListDriverRegistryTest.php index 77e53f12..66462600 100644 --- a/tests/Registry/ListDriverRegistryTest.php +++ b/tests/Registry/ListDriverRegistryTest.php @@ -95,7 +95,7 @@ public function testRemoveCleansForwardAndReverseMaps(): void class RegistryDriverStub implements ListDriverInterface { - public function getDataContainerName(array $config): string + public function resolveDcTable(string $type, array $config, array $attributes): string { return (string) ($config['dc'] ?? ''); } From 5bc8e519248f36dbc0963ea1c1f9e8e1375568c6 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Sat, 18 Jul 2026 04:53:21 +0200 Subject: [PATCH 60/71] refactor: replace getters and setters with public readonly properties across events and related classes for improved consistency and immutability --- .../ContentElement/ReaderController.php | 5 +-- .../Compiler/RegisterListDriversPass.php | 4 +- src/Event/DetailsPageUrlGeneratedEvent.php | 45 +++---------------- src/Event/FilterElementBuildingEvent.php | 36 ++------------- src/Event/FilterElementBuiltEvent.php | 24 ++-------- src/Event/FilterElementFormBuiltEvent.php | 16 ++----- src/Event/ListViewRenderEvent.php | 23 ++-------- src/Event/ReaderPageMetaEvent.php | 35 +++------------ src/Event/ReaderRenderEvent.php | 44 +++--------------- .../Contao/BreadcrumbListener.php | 2 +- .../NamedDispatch/FilterElementListener.php | 6 +-- .../FilterTransformerListener.php | 1 - .../NamedDispatch/ListBuildListener.php | 1 - .../NamedDispatch/ListTransformerListener.php | 1 - .../Reader/GenericReaderPageMetaListener.php | 10 ++--- .../Reader/ReaderPageMetaTitleListener.php | 9 ++-- src/Filter/Factory/FilterFactory.php | 4 +- src/Filter/Filter.php | 1 - .../EventsReaderPageMetaListener.php | 8 ++-- .../EventListener/ContaoCommentsListener.php | 9 ++-- .../NewsReaderPageMetaListener.php | 10 ++--- .../EventListener/ChangelanguageListener.php | 12 +++-- src/List/Factory/ListSpecFactory.php | 12 ++--- src/Query/Executor/FilterExecutor.php | 2 +- src/Reader/ReaderUrlGenerator.php | 4 +- 25 files changed, 72 insertions(+), 252 deletions(-) diff --git a/src/Controller/ContentElement/ReaderController.php b/src/Controller/ContentElement/ReaderController.php index 5ff8c9c5..f6ed867d 100644 --- a/src/Controller/ContentElement/ReaderController.php +++ b/src/Controller/ContentElement/ReaderController.php @@ -28,7 +28,6 @@ use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Reader\Factory\ReaderRequestAttributeFactory; use HeimrichHannot\FlareBundle\Reader\ReaderPageMeta; -use HeimrichHannot\FlareBundle\Reader\ReaderRequestAttribute; use HeimrichHannot\FlareBundle\Reader\Resolver\ReaderRequestAttributeResolver; use HeimrichHannot\FlareBundle\Util\Str; use Psr\Log\LoggerInterface; @@ -145,7 +144,7 @@ protected function getFrontendResponse(Template $template, ContentModel $content displayModel: $autoItemModel, list: $list, )); - $pageMeta = $pageMetaEvent->getPageMeta(); + $pageMeta = $pageMetaEvent->pageMeta; } catch (FlareException $e) { @@ -175,7 +174,7 @@ protected function getFrontendResponse(Template $template, ContentModel $content $data['headline'] = Str::normalizeHeadline($contentModel->headline ?: null); $template->setData($data); - $this->applyPageMeta($event->getPageMeta()); + $this->applyPageMeta($event->pageMeta); try { diff --git a/src/DependencyInjection/Compiler/RegisterListDriversPass.php b/src/DependencyInjection/Compiler/RegisterListDriversPass.php index 79919183..d4d216fa 100644 --- a/src/DependencyInjection/Compiler/RegisterListDriversPass.php +++ b/src/DependencyInjection/Compiler/RegisterListDriversPass.php @@ -35,7 +35,7 @@ public function process(ContainerBuilder $container): void foreach ($tags as $attributes) { - $type = $this->getListTypeName($definition, $attributes); + $type = $this->getListDriverName($definition, $attributes); $serviceId = 'huh.flare.list_driver.' . $type; @@ -54,7 +54,7 @@ public function process(ContainerBuilder $container): void } } - protected function getListTypeName(Definition $definition, array $attributes): string + protected function getListDriverName(Definition $definition, array $attributes): string { if ($type = (string) ($attributes['type'] ?? '')) { diff --git a/src/Event/DetailsPageUrlGeneratedEvent.php b/src/Event/DetailsPageUrlGeneratedEvent.php index 7278ed7b..78dad913 100644 --- a/src/Event/DetailsPageUrlGeneratedEvent.php +++ b/src/Event/DetailsPageUrlGeneratedEvent.php @@ -11,44 +11,9 @@ class DetailsPageUrlGeneratedEvent extends Event { public function __construct( - private readonly Model $model, - private string $autoItem, - private PageModel $page, - private string $url, + public readonly Model $model, + public string $autoItem, + public PageModel $page, + public string $url, ) {} - - public function getModel(): Model - { - return $this->model; - } - - public function getAutoItem(): string - { - return $this->autoItem; - } - - public function setAutoItem(string $autoItem): void - { - $this->autoItem = $autoItem; - } - - public function getPage(): PageModel - { - return $this->page; - } - - public function setPage(PageModel $page): void - { - $this->page = $page; - } - - public function getUrl(): string - { - return $this->url; - } - - public function setUrl(string $url): void - { - $this->url = $url; - } -} \ No newline at end of file +} diff --git a/src/Event/FilterElementBuildingEvent.php b/src/Event/FilterElementBuildingEvent.php index 58cd2bb1..7d4e7076 100644 --- a/src/Event/FilterElementBuildingEvent.php +++ b/src/Event/FilterElementBuildingEvent.php @@ -14,37 +14,9 @@ class FilterElementBuildingEvent extends Event * @param array $data */ public function __construct( - private readonly FilterContext $context, - private readonly FilterBuilderInterface $builder, - private readonly array $data = [], - private bool $shouldBuild = true, + public readonly FilterContext $context, + public readonly FilterBuilderInterface $builder, + public readonly array $data = [], + public bool $shouldBuild = true, ) {} - - public function getContext(): FilterContext - { - return $this->context; - } - - public function getBuilder(): FilterBuilderInterface - { - return $this->builder; - } - - /** - * @return array - */ - public function getData(): array - { - return $this->data; - } - - public function shouldBuild(): bool - { - return $this->shouldBuild; - } - - public function setShouldBuild(bool $shouldBuild): void - { - $this->shouldBuild = $shouldBuild; - } } diff --git a/src/Event/FilterElementBuiltEvent.php b/src/Event/FilterElementBuiltEvent.php index 5524fa8a..1fed5bd3 100644 --- a/src/Event/FilterElementBuiltEvent.php +++ b/src/Event/FilterElementBuiltEvent.php @@ -14,26 +14,8 @@ class FilterElementBuiltEvent extends Event * @param array $data */ public function __construct( - private readonly FilterContext $context, - private readonly FilterBuilderInterface $builder, - private readonly array $data = [], + public readonly FilterContext $context, + public readonly FilterBuilderInterface $builder, + public readonly array $data = [], ) {} - - public function getContext(): FilterContext - { - return $this->context; - } - - public function getBuilder(): FilterBuilderInterface - { - return $this->builder; - } - - /** - * @return array - */ - public function getData(): array - { - return $this->data; - } } diff --git a/src/Event/FilterElementFormBuiltEvent.php b/src/Event/FilterElementFormBuiltEvent.php index d9a0bcaa..63b91101 100644 --- a/src/Event/FilterElementFormBuiltEvent.php +++ b/src/Event/FilterElementFormBuiltEvent.php @@ -21,21 +21,11 @@ class FilterElementFormBuiltEvent extends Event { public function __construct( - private readonly FilterFormBuilderInterface $builder, - private readonly FilterContext $context, - private bool $cancelled = false, + public readonly FilterFormBuilderInterface $builder, + public readonly FilterContext $context, + private bool $cancelled = false, ) {} - public function getBuilder(): FilterFormBuilderInterface - { - return $this->builder; - } - - public function getContext(): FilterContext - { - return $this->context; - } - public function cancel(): void { $this->cancelled = true; diff --git a/src/Event/ListViewRenderEvent.php b/src/Event/ListViewRenderEvent.php index a0314fe6..03f65346 100644 --- a/src/Event/ListViewRenderEvent.php +++ b/src/Event/ListViewRenderEvent.php @@ -15,27 +15,12 @@ class ListViewRenderEvent extends Event use ModifiesTemplateTrait; public function __construct( - private readonly ContentModel $contentModel, - private readonly Engine $engine, - private readonly ListModel $listModel, + public readonly ContentModel $contentModel, + public readonly Engine $engine, + public readonly ListModel $listModel, private Template $template, ) {} - public function getContentModel(): ContentModel - { - return $this->contentModel; - } - - public function getEngine(): Engine - { - return $this->engine; - } - - public function getListModel(): ListModel - { - return $this->listModel; - } - public function getTemplate(): Template { return $this->template; @@ -45,4 +30,4 @@ public function setTemplate(Template $template): void { $this->template = $template; } -} \ No newline at end of file +} diff --git a/src/Event/ReaderPageMetaEvent.php b/src/Event/ReaderPageMetaEvent.php index cd27ebe4..7b552367 100644 --- a/src/Event/ReaderPageMetaEvent.php +++ b/src/Event/ReaderPageMetaEvent.php @@ -11,39 +11,14 @@ class ReaderPageMetaEvent { - private ReaderPageMeta $pageMeta; + public ReaderPageMeta $pageMeta; public function __construct( - private readonly ContentModel $contentModel, - private readonly Model $displayModel, - private readonly ListSpec $list, - ?ReaderPageMeta $pageMeta = null, + public readonly ContentModel $contentModel, + public readonly Model $displayModel, + public readonly ListSpec $list, + ?ReaderPageMeta $pageMeta = null, ) { $this->pageMeta = $pageMeta ?? new ReaderPageMeta(); } - - public function getContentModel(): ContentModel - { - return $this->contentModel; - } - - public function getDisplayModel(): Model - { - return $this->displayModel; - } - - public function getList(): ListSpec - { - return $this->list; - } - - public function getPageMeta(): ReaderPageMeta - { - return $this->pageMeta; - } - - public function setPageMeta(ReaderPageMeta $pageMeta): void - { - $this->pageMeta = $pageMeta; - } } diff --git a/src/Event/ReaderRenderEvent.php b/src/Event/ReaderRenderEvent.php index a066fcf7..5ecd7429 100644 --- a/src/Event/ReaderRenderEvent.php +++ b/src/Event/ReaderRenderEvent.php @@ -17,46 +17,14 @@ class ReaderRenderEvent extends Event use ModifiesTemplateTrait; public function __construct( - private readonly ContentModel $contentModel, - private readonly ContextInterface $context, - private readonly Model $displayModel, - private readonly ListSpec $list, - private ReaderPageMeta $pageMeta, - private Template $template, + public readonly ContentModel $contentModel, + public readonly ContextInterface $context, + public readonly Model $displayModel, + public readonly ListSpec $list, + public ReaderPageMeta $pageMeta, + private Template $template, ) {} - public function getContentModel(): ContentModel - { - return $this->contentModel; - } - - public function getContext(): ContextInterface - { - return $this->context; - } - - public function getDisplayModel(): Model - { - return $this->displayModel; - } - - public function getList(): ListSpec - { - return $this->list; - } - - public function getPageMeta(): ReaderPageMeta - { - return $this->pageMeta; - } - - public function setPageMeta(ReaderPageMeta $pageMeta): self - { - $this->pageMeta = $pageMeta; - - return $this; - } - public function getTemplate(): Template { return $this->template; diff --git a/src/EventListener/Contao/BreadcrumbListener.php b/src/EventListener/Contao/BreadcrumbListener.php index b42309f7..c895d993 100644 --- a/src/EventListener/Contao/BreadcrumbListener.php +++ b/src/EventListener/Contao/BreadcrumbListener.php @@ -118,7 +118,7 @@ public function __invoke(array $items, Module $module): array list: $listSpec, )); - $title = $pageMetaEvent->getPageMeta()->getTitle(); + $title = $pageMetaEvent->pageMeta->getTitle(); $item = &$items[\count($items) - 1]; if ($title && $item) diff --git a/src/EventListener/NamedDispatch/FilterElementListener.php b/src/EventListener/NamedDispatch/FilterElementListener.php index 98b0c515..2ad106a3 100644 --- a/src/EventListener/NamedDispatch/FilterElementListener.php +++ b/src/EventListener/NamedDispatch/FilterElementListener.php @@ -19,7 +19,7 @@ public function __construct( #[AsEventListener(priority: -200)] public function onFilterElementBuiltEvent(FilterElementBuiltEvent $event): void { - if (!$type = $event->getContext()->filter->type) { + if (!$type = $event->context->filter->type) { return; } @@ -29,7 +29,7 @@ public function onFilterElementBuiltEvent(FilterElementBuiltEvent $event): void #[AsEventListener(priority: -200)] public function onFilterElementBuildingEvent(FilterElementBuildingEvent $event): void { - if (!$type = $event->getContext()->filter->type) { + if (!$type = $event->context->filter->type) { return; } @@ -39,7 +39,7 @@ public function onFilterElementBuildingEvent(FilterElementBuildingEvent $event): #[AsEventListener(priority: -200)] public function onFilterElementFormBuiltEvent(FilterElementFormBuiltEvent $event): void { - if (!$type = $event->getContext()->filter->type) { + if (!$type = $event->context->filter->type) { return; } diff --git a/src/EventListener/NamedDispatch/FilterTransformerListener.php b/src/EventListener/NamedDispatch/FilterTransformerListener.php index 173e4747..5ce43f47 100644 --- a/src/EventListener/NamedDispatch/FilterTransformerListener.php +++ b/src/EventListener/NamedDispatch/FilterTransformerListener.php @@ -5,7 +5,6 @@ namespace HeimrichHannot\FlareBundle\EventListener\NamedDispatch; use HeimrichHannot\FlareBundle\Event\FilterTransformerEvent; -use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; diff --git a/src/EventListener/NamedDispatch/ListBuildListener.php b/src/EventListener/NamedDispatch/ListBuildListener.php index ea480fc0..1fbae64b 100644 --- a/src/EventListener/NamedDispatch/ListBuildListener.php +++ b/src/EventListener/NamedDispatch/ListBuildListener.php @@ -5,7 +5,6 @@ namespace HeimrichHannot\FlareBundle\EventListener\NamedDispatch; use HeimrichHannot\FlareBundle\Event\ListBuildEvent; -use HeimrichHannot\FlareBundle\Registry\ListDriverRegistry; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; diff --git a/src/EventListener/NamedDispatch/ListTransformerListener.php b/src/EventListener/NamedDispatch/ListTransformerListener.php index b733e456..58bb40d4 100644 --- a/src/EventListener/NamedDispatch/ListTransformerListener.php +++ b/src/EventListener/NamedDispatch/ListTransformerListener.php @@ -5,7 +5,6 @@ namespace HeimrichHannot\FlareBundle\EventListener\NamedDispatch; use HeimrichHannot\FlareBundle\Event\ListTransformerEvent; -use HeimrichHannot\FlareBundle\Registry\ListDriverRegistry; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; diff --git a/src/EventListener/Reader/GenericReaderPageMetaListener.php b/src/EventListener/Reader/GenericReaderPageMetaListener.php index 7687ea3f..7f9cc807 100644 --- a/src/EventListener/Reader/GenericReaderPageMetaListener.php +++ b/src/EventListener/Reader/GenericReaderPageMetaListener.php @@ -22,15 +22,13 @@ public function __construct( public function __invoke(ReaderPageMetaEvent $event): void { - $list = $event->getList(); - $contentModel = $event->getContentModel(); - $model = $event->getDisplayModel(); + $list = $event->list; if (!($list->config['genericPageMeta'] ?? false)) { return; } - $pageMeta = $event->getPageMeta(); + $pageMeta = $event->pageMeta; $titleFormat = $pageMeta->getTitle() ? null : $list->config['metaTitleFormat']; $descriptionFormat = $pageMeta->getDescription() ? null : $list->config['metaDescriptionFormat']; @@ -47,8 +45,8 @@ public function __invoke(ReaderPageMetaEvent $event): void ]; $this->addTokensFromProperties($tokens, $list->config, prefix: 'list'); - $this->addTokensFromProperties($tokens, $contentModel->row(), prefix: 'ce'); - $this->addTokensFromProperties($tokens, $model->row()); + $this->addTokensFromProperties($tokens, $event->contentModel->row(), prefix: 'ce'); + $this->addTokensFromProperties($tokens, $event->displayModel->row()); if ($titleFormat) { diff --git a/src/EventListener/Reader/ReaderPageMetaTitleListener.php b/src/EventListener/Reader/ReaderPageMetaTitleListener.php index 9b7ff2eb..82ef7dcb 100644 --- a/src/EventListener/Reader/ReaderPageMetaTitleListener.php +++ b/src/EventListener/Reader/ReaderPageMetaTitleListener.php @@ -18,12 +18,11 @@ public function __construct( public function __invoke(ReaderPageMetaEvent $event): void { - $pageMeta = $event->getPageMeta(); - if ($pageMeta->getTitle()) { + if ($event->pageMeta->getTitle()) { return; } - $model = $event->getDisplayModel(); + $model = $event->displayModel; $title = $this->htmlDecoder->inputEncodedToPlainText( (string) ( @@ -40,6 +39,6 @@ public function __invoke(ReaderPageMetaEvent $event): void return; } - $pageMeta->setTitle($title); + $event->pageMeta->setTitle($title); } -} \ No newline at end of file +} diff --git a/src/Filter/Factory/FilterFactory.php b/src/Filter/Factory/FilterFactory.php index bc885cf1..35226984 100644 --- a/src/Filter/Factory/FilterFactory.php +++ b/src/Filter/Factory/FilterFactory.php @@ -85,7 +85,7 @@ private function resolveType(FilterElementInterface|string $element, ?string $so { throw new FlareException(\sprintf( 'A filter element instance or registered type alias must be provided%s.', - $source ? " ($source)" : "" + $source ? " ({$source})" : "" ), method: __METHOD__); } @@ -105,7 +105,7 @@ private function resolveElement(FilterElementInterface|string $element, ?string ?? throw new FlareException(\sprintf( 'Filter element type "%s" not found%s', $element, - $source ? " ($source)" : "" + $source ? " ({$source})" : "" ), method: __METHOD__); } } diff --git a/src/Filter/Filter.php b/src/Filter/Filter.php index e34848ba..b1d698c7 100644 --- a/src/Filter/Filter.php +++ b/src/Filter/Filter.php @@ -4,7 +4,6 @@ namespace HeimrichHannot\FlareBundle\Filter; -use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; /** diff --git a/src/Integration/ContaoCalendar/EventListener/EventsReaderPageMetaListener.php b/src/Integration/ContaoCalendar/EventListener/EventsReaderPageMetaListener.php index d8a9365f..690232b1 100644 --- a/src/Integration/ContaoCalendar/EventListener/EventsReaderPageMetaListener.php +++ b/src/Integration/ContaoCalendar/EventListener/EventsReaderPageMetaListener.php @@ -22,12 +22,12 @@ public function __invoke(ReaderPageMetaEvent $event): void global $objPage; /** @var CalendarEventsModel $model */ - $model = $event->getDisplayModel(); + $model = $event->displayModel; if (!$model instanceof CalendarEventsModel) { return; } - $pageMeta = $event->getPageMeta(); + $pageMeta = $event->pageMeta; $pageMeta->setTitle($this->htmlDecoder->inputEncodedToPlainText( Str::coalesce($model->pageTitle, $model->title, $objPage?->title) ?? '' @@ -51,6 +51,6 @@ public function __invoke(ReaderPageMetaEvent $event): void $pageMeta->setRobots($robots); } - $event->setPageMeta($pageMeta); + $event->pageMeta = $pageMeta; } -} \ No newline at end of file +} diff --git a/src/Integration/ContaoComments/EventListener/ContaoCommentsListener.php b/src/Integration/ContaoComments/EventListener/ContaoCommentsListener.php index b9d571a5..e15a8fdc 100644 --- a/src/Integration/ContaoComments/EventListener/ContaoCommentsListener.php +++ b/src/Integration/ContaoComments/EventListener/ContaoCommentsListener.php @@ -32,8 +32,7 @@ public function __construct( #[AsEventListener] public function onReaderBuilt(ReaderRenderEvent $event): void { - $list = $event->getList(); - if (!($list->config['comments_enabled'] ?? false)) { + if (!($event->list->config['comments_enabled'] ?? false)) { return; } @@ -44,7 +43,7 @@ public function onReaderBuilt(ReaderRenderEvent $event): void } /** @var NewsModel $newsModel */ - $newsModel = $event->getDisplayModel(); + $newsModel = $event->displayModel; if (!$newsModel instanceof NewsModel) { return; } @@ -60,7 +59,7 @@ public function onReaderBuilt(ReaderRenderEvent $event): void $notifies = []; - if ($list->config['comments_sendNativeEmails'] ?? false) + if ($event->list->config['comments_sendNativeEmails'] ?? false) { if ($archiveModel->notify !== 'notify_author' && isset($GLOBALS['TL_ADMIN_EMAIL'])) @@ -78,7 +77,7 @@ public function onReaderBuilt(ReaderRenderEvent $event): void $config = new \stdClass(); $config->perPage = $archiveModel->perPage; $config->order = $archiveModel->sortOrder; - $config->template = $event->getContentModel()->com_template ?: null; + $config->template = $event->contentModel->com_template ?: null; $config->requireLogin = $archiveModel->requireLogin; $config->disableCaptcha = $archiveModel->disableCaptcha; $config->bbcode = $archiveModel->bbcode; diff --git a/src/Integration/ContaoNews/EventListener/NewsReaderPageMetaListener.php b/src/Integration/ContaoNews/EventListener/NewsReaderPageMetaListener.php index 36a6dbc1..a26245d8 100644 --- a/src/Integration/ContaoNews/EventListener/NewsReaderPageMetaListener.php +++ b/src/Integration/ContaoNews/EventListener/NewsReaderPageMetaListener.php @@ -21,16 +21,14 @@ public function __invoke(ReaderPageMetaEvent $event): void { global $objPage; - $model = $event->getDisplayModel(); + $model = $event->displayModel; if (!$model instanceof NewsModel) { return; } - $contentModel = $event->getContentModel(); + $pageMeta = $event->pageMeta; - $pageMeta = $event->getPageMeta(); - - $headline = Str::formatHeadline($model->headline) ?: Str::formatHeadline($contentModel->headline); + $headline = Str::formatHeadline($model->headline) ?: Str::formatHeadline($event->contentModel->headline); $title = $headline ?: $this->htmlDecoder->inputEncodedToPlainText($objPage->title); $pageMeta->setTitle($title); @@ -42,4 +40,4 @@ public function __invoke(ReaderPageMetaEvent $event): void $pageMeta->setDescription(Str::htmlToMeta($teaser, 250)); } } -} \ No newline at end of file +} diff --git a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php index d5cb101f..99b8b73b 100644 --- a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php +++ b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php @@ -209,21 +209,19 @@ private function applyMlQueriesIfNecessary( #[AsEventListener(priority: 220)] public function onListViewDetailsPageUrlGenerated(DetailsPageUrlGeneratedEvent $event): void { - $eventPage = $event->getPage(); - - if (!$langPage = $this->findPageForLanguage($eventPage)) { + if (!$langPage = $this->findPageForLanguage($event->page)) { return; } /** @noinspection PhpCastIsUnnecessaryInspection */ - if ((int) $langPage->id === (int) $eventPage->id) { + if ((int) $langPage->id === (int) $event->page->id) { return; } - $url = $langPage->getAbsoluteUrl('/' . $event->getAutoItem()); + $url = $langPage->getAbsoluteUrl('/' . $event->autoItem); - $event->setPage($langPage); - $event->setUrl($url); + $event->page = $langPage; + $event->url = $url; } private function findPageForLanguage(PageModel $page): ?PageModel diff --git a/src/List/Factory/ListSpecFactory.php b/src/List/Factory/ListSpecFactory.php index f6a60e20..15827b49 100644 --- a/src/List/Factory/ListSpecFactory.php +++ b/src/List/Factory/ListSpecFactory.php @@ -4,18 +4,14 @@ namespace HeimrichHannot\FlareBundle\List\Factory; -use Contao\Controller; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; -use HeimrichHannot\FlareBundle\Event\FilterCollectedEvent; use HeimrichHannot\FlareBundle\Exception\FlareException; -use HeimrichHannot\FlareBundle\Filter\Factory\FilterFactory; use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\List\BaseListOptions; use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\List\Resolver\ListOptionsResolver; use HeimrichHannot\FlareBundle\List\Resolver\ListTransformerResolver; -use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Registry\ListDriverRegistry; @@ -114,7 +110,7 @@ private function resolveType(ListDriverInterface|string $driver, ?string $source { throw new FlareException(\sprintf( 'A list driver instance or registered type alias must be provided%s.', - $source ? " ($source)" : '', + $source ? " ({$source})" : '', ), method: __METHOD__); } @@ -134,7 +130,7 @@ private function resolveDriver(ListDriverInterface|string $driver, ?string $sour ?? throw new FlareException(\sprintf( 'List type "%s" not found%s.', $driver, - $source ? " ($source)" : '' + $source ? " ({$source})" : '' ), method: __METHOD__); } @@ -147,14 +143,14 @@ private function resolveDataContainer( string $type, ?string $source = null ): string { - $attributes = $this->listDriverRegistry->getAttribute($type)?->attributes ?? []; + $attributes = $this->listDriverRegistry->getAttribute($type)->attributes ?? []; if (!$dc = $driver->resolveDcTable($type, $config, $attributes)) { throw new FlareException(\sprintf( 'Failed to evaluate data container table of list type "%s"%s.', $type, - $source ? " ($source)" : '' + $source ? " ({$source})" : '' ), method: __METHOD__); } diff --git a/src/Query/Executor/FilterExecutor.php b/src/Query/Executor/FilterExecutor.php index 73b22fd2..f8fda29e 100644 --- a/src/Query/Executor/FilterExecutor.php +++ b/src/Query/Executor/FilterExecutor.php @@ -96,7 +96,7 @@ public function invokeFilter(Filter $filter, FilterContext $context, array $data data: $data, )); - if (!$event->shouldBuild()) { + if (!$event->shouldBuild) { return []; } diff --git a/src/Reader/ReaderUrlGenerator.php b/src/Reader/ReaderUrlGenerator.php index 6a48d2fa..f2431fe6 100644 --- a/src/Reader/ReaderUrlGenerator.php +++ b/src/Reader/ReaderUrlGenerator.php @@ -33,6 +33,6 @@ public function generate(Model $model): ?string ) ); - return $event->getUrl(); + return $event->url; } -} \ No newline at end of file +} From bb6e78442fbe35fe15ab3b30f734033ab23565ef Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Sat, 18 Jul 2026 05:02:53 +0200 Subject: [PATCH 61/71] fix: model validation logic in `LinksToReaderTrait` --- src/Engine/View/LinksToReaderTrait.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Engine/View/LinksToReaderTrait.php b/src/Engine/View/LinksToReaderTrait.php index 2928112d..47440744 100644 --- a/src/Engine/View/LinksToReaderTrait.php +++ b/src/Engine/View/LinksToReaderTrait.php @@ -41,10 +41,12 @@ public function to(Model|int|string $target): ?string return $this->readerUrls[$id] = null; } - if ($target instanceof Model && $model->id !== $target->id && $model::getTable() !== $target::getTable()) { - throw new \InvalidArgumentException('The provided model does not match the model resolved by the list context.'); + if ($target instanceof Model && ($id !== ((int) $model->id) || $model::getTable() !== $target::getTable())) { + throw new \InvalidArgumentException( + 'The provided model does not match the model resolved by the list context.', + ); } return $this->readerUrls[$id] = $this->getReaderUrlGenerator()->generate($model); } -} \ No newline at end of file +} From 12a386e364cdf7199d61d6a13412e549b3f67a4f Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Sat, 18 Jul 2026 05:49:40 +0200 Subject: [PATCH 62/71] refactor: remove redundant element parameter from Filter and adjust related APIs for improved consistency and immutability --- composer.json | 3 +++ .../Attribute/AsFilterElement.php | 2 +- src/DependencyInjection/Attribute/AsListDriver.php | 2 +- .../Compiler/RegisterFilterElementsPass.php | 4 ++-- .../Compiler/RegisterListDriversPass.php | 4 ++-- src/Engine/Context/InteractiveContext.php | 12 +----------- .../FlareFilter/AddTargetAliasFieldCallback.php | 2 -- src/Filter/Factory/FilterContextFactory.php | 6 ++---- src/Filter/Factory/FilterFactory.php | 2 +- src/Filter/Factory/FilterFormFactory.php | 6 ++---- src/Filter/Resolver/FilterOptionsResolver.php | 5 +++-- src/Filter/Type/FilterTypeInterface.php | 4 ++-- src/Query/Executor/FilterExecutor.php | 2 +- tests/Filter/FilterOptionsResolverTest.php | 6 +++--- 14 files changed, 24 insertions(+), 36 deletions(-) diff --git a/composer.json b/composer.json index 71985cc8..a654d11e 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "symfony/event-dispatcher-contracts": "^1.0 || ^2.0 || ^3.0", "symfony/filesystem": "^5.4 || ^6.0 || ^7.0", "symfony/form": "^5.4 || ^6.0 || ^7.0", + "symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0", "symfony/http-foundation": "^5.4 || ^6.0 || ^7.0", "symfony/http-kernel": "^5.4 || ^6.0 || ^7.0", "symfony/options-resolver": "^5.4 || ^6.0 || ^7.0", @@ -24,6 +25,7 @@ "symfony/property-info": "^5.4 || ^6.0 || ^7.0", "symfony/serializer": "^5.4 || ^6.0 || ^7.0", "symfony/string": "^5.2 || ^6.0 || ^7.0", + "symfony/translation-contracts": "^1.0 || ^2.0 || ^3.0", "symfony/validator": "^5.4 || ^6.0 || ^7.0", "twig/twig": "^3.13" }, @@ -33,6 +35,7 @@ "heimrichhannot/contao-test-utilities-bundle": "^0.1", "phpunit/phpunit": "^8.0 || ^9.0", "php-coveralls/php-coveralls": "^2.0", + "symfony/event-dispatcher": "^5.4 || ^6.0 || ^7.0", "symfony/phpunit-bridge": "^5.4 || ^6.0 || ^7.0", "phpstan/phpstan": "^1.10", "phpstan/phpstan-symfony": "^1.2" diff --git a/src/DependencyInjection/Attribute/AsFilterElement.php b/src/DependencyInjection/Attribute/AsFilterElement.php index 082fcc01..7e85a561 100644 --- a/src/DependencyInjection/Attribute/AsFilterElement.php +++ b/src/DependencyInjection/Attribute/AsFilterElement.php @@ -7,7 +7,7 @@ #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] class AsFilterElement { - public const TAG = 'huh.flare.filter_element'; + public const TAG = 'flare.filter_element'; public ?string $type; public array $attributes; diff --git a/src/DependencyInjection/Attribute/AsListDriver.php b/src/DependencyInjection/Attribute/AsListDriver.php index b8ec50c5..8a4ab525 100644 --- a/src/DependencyInjection/Attribute/AsListDriver.php +++ b/src/DependencyInjection/Attribute/AsListDriver.php @@ -7,7 +7,7 @@ #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] class AsListDriver { - public const TAG = 'huh.flare.list_driver'; + public const TAG = 'flare.list_driver'; public ?string $type; public array $attributes; diff --git a/src/DependencyInjection/Compiler/RegisterFilterElementsPass.php b/src/DependencyInjection/Compiler/RegisterFilterElementsPass.php index e473e281..9f31f664 100644 --- a/src/DependencyInjection/Compiler/RegisterFilterElementsPass.php +++ b/src/DependencyInjection/Compiler/RegisterFilterElementsPass.php @@ -36,7 +36,7 @@ public function process(ContainerBuilder $container): void { $type = $this->getFilterElementType($definition, $attributes); - $serviceId = 'huh.flare.filter_element.' . $type; + $serviceId = 'flare.filter_element.' . $type; $childDefinition = new ChildDefinition((string) $reference); $childDefinition->setPublic(true); @@ -45,7 +45,7 @@ public function process(ContainerBuilder $container): void $attribute = new Definition(AsFilterElement::class, [$type, $attributes['isTargeted'] ?? null]); /** @see FilterElementRegistry::add() */ - $registry->addMethodCall('add', [$reference, $attribute, $type]); + $registry->addMethodCall('add', [$childDefinition, $attribute, $type]); $childDefinition->setTags($definition->getTags()); $container->setDefinition($serviceId, $childDefinition); diff --git a/src/DependencyInjection/Compiler/RegisterListDriversPass.php b/src/DependencyInjection/Compiler/RegisterListDriversPass.php index d4d216fa..2748ad5f 100644 --- a/src/DependencyInjection/Compiler/RegisterListDriversPass.php +++ b/src/DependencyInjection/Compiler/RegisterListDriversPass.php @@ -37,7 +37,7 @@ public function process(ContainerBuilder $container): void { $type = $this->getListDriverName($definition, $attributes); - $serviceId = 'huh.flare.list_driver.' . $type; + $serviceId = 'flare.list_driver.' . $type; $childDefinition = new ChildDefinition((string) $reference); $childDefinition->setPublic(true); @@ -46,7 +46,7 @@ public function process(ContainerBuilder $container): void $attribute = new Definition(AsListDriver::class, [$type, $attributes['dataContainer'] ?? null]); /** @see ListDriverRegistry::add() */ - $registry->addMethodCall('add', [$reference, $attribute, $type]); + $registry->addMethodCall('add', [$childDefinition, $attribute, $type]); $childDefinition->setTags($definition->getTags()); $container->setDefinition($serviceId, $childDefinition); diff --git a/src/Engine/Context/InteractiveContext.php b/src/Engine/Context/InteractiveContext.php index 2a3fdfc6..1a46efb3 100644 --- a/src/Engine/Context/InteractiveContext.php +++ b/src/Engine/Context/InteractiveContext.php @@ -4,7 +4,6 @@ namespace HeimrichHannot\FlareBundle\Engine\Context; -use Contao\ContentModel; use HeimrichHannot\FlareBundle\Paginator\PaginatorConfig; use HeimrichHannot\FlareBundle\Sort\SortOrderSequence; use Symfony\Component\Validator\Constraints as Assert; @@ -33,15 +32,6 @@ public function __construct( public ?string $pageParam = null, ) {} - public function getContentModel(): ?ContentModel - { - if ($this->contentModelId === 0) { - return null; - } - - return ContentModel::findByPk($this->contentModelId); - } - public function getFormName(): string { return $this->formName; @@ -93,4 +83,4 @@ public function with( return $clone; } -} \ No newline at end of file +} diff --git a/src/EventListener/DataContainer/FlareFilter/AddTargetAliasFieldCallback.php b/src/EventListener/DataContainer/FlareFilter/AddTargetAliasFieldCallback.php index 00e4d9f4..73131040 100644 --- a/src/EventListener/DataContainer/FlareFilter/AddTargetAliasFieldCallback.php +++ b/src/EventListener/DataContainer/FlareFilter/AddTargetAliasFieldCallback.php @@ -9,13 +9,11 @@ use Contao\CoreBundle\DependencyInjection\Attribute\AsCallback; use Contao\DataContainer; use HeimrichHannot\FlareBundle\DataContainer\FilterContainer; -use HeimrichHannot\FlareBundle\EventListener\DataContainer\AutoTypePalettesCallback; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; /** * Callback class that adds a targetAlias field to the filter palette when the filter type declares isTargeted. - * > Required to load before {@see AutoTypePalettesCallback}, hence the priority. * * @internal For internal use only. Do not call this class or its methods directly. */ diff --git a/src/Filter/Factory/FilterContextFactory.php b/src/Filter/Factory/FilterContextFactory.php index 7e686ec3..daee5a40 100644 --- a/src/Filter/Factory/FilterContextFactory.php +++ b/src/Filter/Factory/FilterContextFactory.php @@ -6,7 +6,6 @@ use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; use HeimrichHannot\FlareBundle\Exception\FilterException; -use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterOptionsResolver; @@ -26,16 +25,15 @@ public function __construct( * @throws FilterException If the filter's config violates the element's schema */ public function create( - ListSpec $list, + ListSpec $list, Filter $filter, - FilterElementInterface $element, ContextInterface $engineContext, string|int|null $key = null, ): FilterContext { return new FilterContext( list: $list, filter: $filter, - config: $this->filterOptionsResolver->resolve($filter, $element), + config: $this->filterOptionsResolver->resolve($filter), engineContext: $engineContext, key: $key, ); diff --git a/src/Filter/Factory/FilterFactory.php b/src/Filter/Factory/FilterFactory.php index 35226984..236da740 100644 --- a/src/Filter/Factory/FilterFactory.php +++ b/src/Filter/Factory/FilterFactory.php @@ -64,7 +64,7 @@ public function createFromFilterModel( $type = $this->resolveType($filterModel->getFilterElementType(), $source); $element = $this->resolveElement($type, $source); - $config = $this->filterTransformerResolver->transform($element, $type, $filterModel) ?? $filterModel->row(); + $config = $this->filterTransformerResolver->transform($element, $type, $filterModel) ?? []; return new Filter( element: $element, diff --git a/src/Filter/Factory/FilterFormFactory.php b/src/Filter/Factory/FilterFormFactory.php index 8078073d..9493e319 100644 --- a/src/Filter/Factory/FilterFormFactory.php +++ b/src/Filter/Factory/FilterFormFactory.php @@ -63,16 +63,14 @@ public function create(ListSpec $list, FormContextInterface $context): FormInter continue; } - $element = $filter->element; - - $filterContext = $this->filterContextFactory->create($list, $filter, $element, $context, $key); + $filterContext = $this->filterContextFactory->create($list, $filter, $context, $key); // Collect-only builder: never mounted itself; its single-field spec, children, // attributes, and deferred listeners are transferred onto the mounted builder below. $wrapper = new FilterFormBuilder($filter->alias, null, new EventDispatcher(), $this->formFactory); $wrapper->setAttribute(FilterContext::ATTR_SELF, $filterContext); - $element->buildForm($wrapper, $filterContext); + $filter->element->buildForm($wrapper, $filterContext); /** @var FilterElementFormBuiltEvent $event */ $event = $this->eventDispatcher->dispatch(new FilterElementFormBuiltEvent($wrapper, $filterContext)); diff --git a/src/Filter/Resolver/FilterOptionsResolver.php b/src/Filter/Resolver/FilterOptionsResolver.php index 8afa7149..f9427486 100644 --- a/src/Filter/Resolver/FilterOptionsResolver.php +++ b/src/Filter/Resolver/FilterOptionsResolver.php @@ -7,7 +7,6 @@ use HeimrichHannot\FlareBundle\Config\SchemaResolver; use HeimrichHannot\FlareBundle\Contract\OptionsContract; use HeimrichHannot\FlareBundle\Exception\FilterException; -use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Filter; /** @@ -25,8 +24,10 @@ public function __construct( * * @throws FilterException If the config does not satisfy the element's schema. */ - public function resolve(Filter $filter, FilterElementInterface $element): array + public function resolve(Filter $filter): array { + $element = $filter->element; + if (!$element instanceof OptionsContract) { return $filter->config; } diff --git a/src/Filter/Type/FilterTypeInterface.php b/src/Filter/Type/FilterTypeInterface.php index 9cf16fbe..8c1e06ff 100644 --- a/src/Filter/Type/FilterTypeInterface.php +++ b/src/Filter/Type/FilterTypeInterface.php @@ -11,7 +11,7 @@ #[AutoconfigureTag(self::FLARE_FILTER_TYPE_TAG)] interface FilterTypeInterface { - public const FLARE_FILTER_TYPE_TAG = 'huh.flare.filter_type'; + public const FLARE_FILTER_TYPE_TAG = 'flare.filter_type'; /** * Configures the options for this type. @@ -24,4 +24,4 @@ public function configureOptions(OptionsResolver $resolver): void; * @param array $options */ public function buildQuery(FilterQueryBuilder $builder, array $options): void; -} \ No newline at end of file +} diff --git a/src/Query/Executor/FilterExecutor.php b/src/Query/Executor/FilterExecutor.php index f8fda29e..60a7bb22 100644 --- a/src/Query/Executor/FilterExecutor.php +++ b/src/Query/Executor/FilterExecutor.php @@ -48,7 +48,7 @@ public function invokeFilters(ListQueryConfig $options): array foreach ($list->filters as $key => $filter) { - $context = $this->filterContextFactory->create($list, $filter, $filter->element, $options->context, $key); + $context = $this->filterContextFactory->create($list, $filter, $options->context, $key); $data = (array) ($options->filterValues[$key] ?? $filter->data ?? []); diff --git a/tests/Filter/FilterOptionsResolverTest.php b/tests/Filter/FilterOptionsResolverTest.php index 6d3c373a..2309de63 100644 --- a/tests/Filter/FilterOptionsResolverTest.php +++ b/tests/Filter/FilterOptionsResolverTest.php @@ -23,7 +23,7 @@ public function testResolvesOptionsThroughElementSchema(): void $resolver = new FilterOptionsResolver(new SchemaResolver()); $element = new ElementConfigAwareElement(); - $config = $resolver->resolve(new Filter(element: $element, type: 'test', config: ['field' => 'title']), $element); + $config = $resolver->resolve(new Filter(element: $element, type: 'test', config: ['field' => 'title'])); self::assertSame('title', $config['field']); self::assertFalse($config['intrinsic']); @@ -36,7 +36,7 @@ public function testReturnsOptionsVerbatimWithoutOptionsContract(): void $config = ['anything' => 'goes', 'unvalidated' => true]; - self::assertSame($config, $resolver->resolve(new Filter(element: $element, type: 'test', config: $config), $element)); + self::assertSame($config, $resolver->resolve(new Filter(element: $element, type: 'test', config: $config))); } public function testWrapsSchemaViolationsInFilterException(): void @@ -47,7 +47,7 @@ public function testWrapsSchemaViolationsInFilterException(): void try { - $resolver->resolve($filter, $element); + $resolver->resolve($filter); self::fail('Expected FilterException.'); } catch (FilterException $e) From 7fab61fb163ab0e3c77f07581ba5ffb42418c0ed Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Sun, 19 Jul 2026 00:12:50 +0200 Subject: [PATCH 63/71] refactor: replace `ChildDefinition` with `setAlias` for FilterElement and ListDriver registration, streamline type handling via `TypeNameFactory` --- .../Compiler/RegisterFilterElementsPass.php | 15 +++++------- .../Compiler/RegisterListDriversPass.php | 24 +++++++------------ .../Factory/TypeNameFactory.php | 18 ++++++++++---- 3 files changed, 28 insertions(+), 29 deletions(-) diff --git a/src/DependencyInjection/Compiler/RegisterFilterElementsPass.php b/src/DependencyInjection/Compiler/RegisterFilterElementsPass.php index 9f31f664..9b3644f0 100644 --- a/src/DependencyInjection/Compiler/RegisterFilterElementsPass.php +++ b/src/DependencyInjection/Compiler/RegisterFilterElementsPass.php @@ -7,7 +7,6 @@ use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\DependencyInjection\Factory\TypeNameFactory; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; -use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\Compiler\PriorityTaggedServiceTrait; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -36,19 +35,17 @@ public function process(ContainerBuilder $container): void { $type = $this->getFilterElementType($definition, $attributes); - $serviceId = 'flare.filter_element.' . $type; - - $childDefinition = new ChildDefinition((string) $reference); - $childDefinition->setPublic(true); - /** @see AsFilterElement::__construct */ $attribute = new Definition(AsFilterElement::class, [$type, $attributes['isTargeted'] ?? null]); /** @see FilterElementRegistry::add() */ - $registry->addMethodCall('add', [$childDefinition, $attribute, $type]); + $registry->addMethodCall('add', [$reference, $attribute, $type]); + + $serviceId = 'flare.filter_element.' . $type; - $childDefinition->setTags($definition->getTags()); - $container->setDefinition($serviceId, $childDefinition); + $container + ->setAlias($serviceId, (string) $reference) + ->setPublic(true); } } } diff --git a/src/DependencyInjection/Compiler/RegisterListDriversPass.php b/src/DependencyInjection/Compiler/RegisterListDriversPass.php index 2748ad5f..98f63c06 100644 --- a/src/DependencyInjection/Compiler/RegisterListDriversPass.php +++ b/src/DependencyInjection/Compiler/RegisterListDriversPass.php @@ -5,12 +5,10 @@ namespace HeimrichHannot\FlareBundle\DependencyInjection\Compiler; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListDriver; +use HeimrichHannot\FlareBundle\DependencyInjection\Factory\TypeNameFactory; use HeimrichHannot\FlareBundle\Registry\ListDriverRegistry; -use HeimrichHannot\FlareBundle\Util\Str; -use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\Compiler\PriorityTaggedServiceTrait; -use Symfony\Component\DependencyInjection\Container; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; @@ -37,19 +35,17 @@ public function process(ContainerBuilder $container): void { $type = $this->getListDriverName($definition, $attributes); - $serviceId = 'flare.list_driver.' . $type; - - $childDefinition = new ChildDefinition((string) $reference); - $childDefinition->setPublic(true); - /** @see AsListDriver::__construct */ $attribute = new Definition(AsListDriver::class, [$type, $attributes['dataContainer'] ?? null]); /** @see ListDriverRegistry::add() */ - $registry->addMethodCall('add', [$childDefinition, $attribute, $type]); + $registry->addMethodCall('add', [$reference, $attribute, $type]); - $childDefinition->setTags($definition->getTags()); - $container->setDefinition($serviceId, $childDefinition); + $serviceId = 'flare.list_driver.' . $type; + + $container + ->setAlias($serviceId, (string) $reference) + ->setPublic(true); } } } @@ -65,10 +61,6 @@ protected function getListDriverName(Definition $definition, array $attributes): return $type; } - $className = $definition->getClass(); - $className = \ltrim(\strrchr($className, '\\'), '\\'); - $className = Str::trimSubstrings($className, suffix: ['ListDriver', 'Driver']); - - return Container::underscore($className); + return TypeNameFactory::createListDriverType($definition->getClass()); } } diff --git a/src/DependencyInjection/Factory/TypeNameFactory.php b/src/DependencyInjection/Factory/TypeNameFactory.php index 8d696646..0dac04d2 100644 --- a/src/DependencyInjection/Factory/TypeNameFactory.php +++ b/src/DependencyInjection/Factory/TypeNameFactory.php @@ -7,13 +7,23 @@ use HeimrichHannot\FlareBundle\Util\Str; use function Symfony\Component\String\u; -class TypeNameFactory +final readonly class TypeNameFactory { - public static function createFilterElementType(string $className): string + private static function createType(string $className, array $suffixes): string { $shortName = \basename(\str_replace('\\', '/', $className)); - $trimmedName = Str::trimSubstrings($shortName, suffix: ['Controller', 'FilterElement', 'Element']); + $trimmedName = Str::trimSubstrings($shortName, suffix: $suffixes); return u($trimmedName)->snake()->toString(); } -} \ No newline at end of file + + public static function createFilterElementType(string $className): string + { + return self::createType($className, ['Controller', 'FilterElement', 'Element']); + } + + public static function createListDriverType(string $className): string + { + return self::createType($className, ['Controller', 'ListDriver', 'Driver']); + } +} From c2cb5701f05d53da9c80838c7916e87b2b6a318a Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Sun, 19 Jul 2026 01:23:10 +0200 Subject: [PATCH 64/71] refactor: enhance immutability and streamline type handling across multiple components, improve tag registration, and fix minor logic inconsistencies --- .../ContentElement/ListViewController.php | 2 +- src/Engine/Loader/AggregationLoader.php | 4 ++-- src/Engine/Mod/ModInterface.php | 6 ++++-- src/Engine/Projector/ProjectorInterface.php | 6 ++++-- .../Element/FieldValueChoiceFilterElement.php | 2 ++ src/Filter/Type/SearchKeywordsFilterType.php | 4 ++-- src/Form/ChoicesBuilder.php | 4 +++- src/Paginator/Paginator.php | 4 ++-- src/Registry/EngineModRegistry.php | 4 ++-- src/Registry/ProjectorRegistry.php | 4 ++-- src/Util/Str.php | 13 ++++++++----- 11 files changed, 32 insertions(+), 21 deletions(-) diff --git a/src/Controller/ContentElement/ListViewController.php b/src/Controller/ContentElement/ListViewController.php index 13f1f45a..628a2122 100644 --- a/src/Controller/ContentElement/ListViewController.php +++ b/src/Controller/ContentElement/ListViewController.php @@ -116,7 +116,7 @@ protected function getFrontendResponse(Template $template, ContentModel $content return $this->getErrorResponse($e); } - $this->responseTagger->addTags(['contao.db.' . $listModel->dc]); + $this->responseTagger->addTags(['contao.db.' . $engine->getList()->dc]); $event = $this->eventDispatcher->dispatch( new ListViewRenderEvent( diff --git a/src/Engine/Loader/AggregationLoader.php b/src/Engine/Loader/AggregationLoader.php index dd66e212..1ae40d72 100644 --- a/src/Engine/Loader/AggregationLoader.php +++ b/src/Engine/Loader/AggregationLoader.php @@ -49,7 +49,7 @@ public function fetchCount(): int } catch (\Throwable $e) { - throw new FlareException($e->getMessage(), $e->getCode(), $e, source: __METHOD__); + throw new FlareException($e->getMessage(), $e->getCode(), $e, method: __METHOD__); } } -} \ No newline at end of file +} diff --git a/src/Engine/Mod/ModInterface.php b/src/Engine/Mod/ModInterface.php index 2e8b90a4..b8486430 100644 --- a/src/Engine/Mod/ModInterface.php +++ b/src/Engine/Mod/ModInterface.php @@ -7,10 +7,12 @@ use HeimrichHannot\FlareBundle\Engine\Engine; use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; -#[AutoconfigureTag('flare.engine_mod')] +#[AutoconfigureTag(self::FLARE_ENGINE_MOD_TAG)] interface ModInterface { + public const FLARE_ENGINE_MOD_TAG = 'flare.engine_mod'; + public static function getType(): string; public function apply(Engine $engine, array $options): void; -} \ No newline at end of file +} diff --git a/src/Engine/Projector/ProjectorInterface.php b/src/Engine/Projector/ProjectorInterface.php index 2e9fd724..3080f85d 100644 --- a/src/Engine/Projector/ProjectorInterface.php +++ b/src/Engine/Projector/ProjectorInterface.php @@ -13,9 +13,11 @@ * @template TView of ViewInterface * @template TContext of ContextInterface */ -#[AutoconfigureTag('flare.projector')] +#[AutoconfigureTag(self::FLARE_PROJECTOR_TAG)] interface ProjectorInterface { + public const FLARE_PROJECTOR_TAG = 'flare.projector'; + /** * Checks if this projector supports the given context configuration. */ @@ -34,4 +36,4 @@ public function priority(ListSpec $list, ContextInterface $context): int; * @return ViewInterface */ public function project(ListSpec $list, ContextInterface $context): ViewInterface; -} \ No newline at end of file +} diff --git a/src/Filter/Element/FieldValueChoiceFilterElement.php b/src/Filter/Element/FieldValueChoiceFilterElement.php index b25df98e..2249a873 100644 --- a/src/Filter/Element/FieldValueChoiceFilterElement.php +++ b/src/Filter/Element/FieldValueChoiceFilterElement.php @@ -264,6 +264,8 @@ private function getForeignValues(string $table, string $field): ?array return $this->foreignValueCache[$table][$field]; } + Controller::loadDataContainer($table); + $dca = $GLOBALS['TL_DCA'][$table]['fields'][$field] ?? []; if (!$foreignKey = $dca['foreignKey'] ?? null) { diff --git a/src/Filter/Type/SearchKeywordsFilterType.php b/src/Filter/Type/SearchKeywordsFilterType.php index 66332fe4..1db06f1d 100644 --- a/src/Filter/Type/SearchKeywordsFilterType.php +++ b/src/Filter/Type/SearchKeywordsFilterType.php @@ -29,7 +29,7 @@ public function buildQuery(FilterQueryBuilder $builder, array $options): void foreach ($searchTermGroups ?: [] as $i => $searchTermGroup) { if (!$searchTerms = $this->makeTerms($searchTermGroup)) { - return; + continue; } $and = []; @@ -62,4 +62,4 @@ private function makeTerms(string $text): array return $stopWords ? \array_diff($terms, $stopWords) : $terms; } -} \ No newline at end of file +} diff --git a/src/Form/ChoicesBuilder.php b/src/Form/ChoicesBuilder.php index 6f6238cf..9b24eb5b 100644 --- a/src/Form/ChoicesBuilder.php +++ b/src/Form/ChoicesBuilder.php @@ -256,7 +256,9 @@ public function buildChoiceValueCallback(): callable return $this->emptyOptionValue; } - if (!$alias = \array_search($choice, $this->choices, true)) + $alias = \array_search($choice, $this->choices, true); + + if ($alias === false) { return ''; } diff --git a/src/Paginator/Paginator.php b/src/Paginator/Paginator.php index adc3aab1..78bc7b92 100644 --- a/src/Paginator/Paginator.php +++ b/src/Paginator/Paginator.php @@ -219,7 +219,7 @@ public function navigation( */ public function makePageNumberWindow(int $padding): array { - $maxPages = \max($padding, 0) + 1; // Ensure at least one page is shown + $maxPages = 2 * \max($padding, 0) + 1; // Ensure at least one page is shown $start = \max(1, $this->currentPage - \floor($maxPages / 2)); $end = \min($this->getLastPageNumber(), $start + $maxPages - 1); @@ -257,4 +257,4 @@ public function with( urlGenerator: $urlGenerator ?? $this->urlGenerator, ); } -} \ No newline at end of file +} diff --git a/src/Registry/EngineModRegistry.php b/src/Registry/EngineModRegistry.php index 704c880b..a9e1cccf 100644 --- a/src/Registry/EngineModRegistry.php +++ b/src/Registry/EngineModRegistry.php @@ -12,7 +12,7 @@ class EngineModRegistry private array $resolved; public function __construct( - #[TaggedIterator('flare.engine_mod', defaultIndexMethod: 'getType')] + #[TaggedIterator(ModInterface::FLARE_ENGINE_MOD_TAG, defaultIndexMethod: 'getType')] private readonly iterable $mods, ) {} @@ -25,4 +25,4 @@ public function get(string $type): ?ModInterface { return $this->resolve()[$type] ?? null; } -} \ No newline at end of file +} diff --git a/src/Registry/ProjectorRegistry.php b/src/Registry/ProjectorRegistry.php index a5d35e22..e1644645 100644 --- a/src/Registry/ProjectorRegistry.php +++ b/src/Registry/ProjectorRegistry.php @@ -16,7 +16,7 @@ * @param iterable $projectors */ public function __construct( - #[TaggedIterator('flare.projector')] + #[TaggedIterator(ProjectorInterface::FLARE_PROJECTOR_TAG)] private iterable $projectors, ) {} @@ -61,4 +61,4 @@ public function getProjectorFor( return $winner; } -} \ No newline at end of file +} diff --git a/src/Util/Str.php b/src/Util/Str.php index c8409ea5..0857db29 100644 --- a/src/Util/Str.php +++ b/src/Util/Str.php @@ -81,7 +81,7 @@ public static function implode( } if ($format) { - \array_walk($pieces, $format); + $pieces = \array_map($format, $pieces); } return \implode($glue, $pieces); @@ -94,9 +94,12 @@ public static function implode( */ public static function mergePalettes(?string ...$palettes): string { - $palettes = \array_filter($palettes); - \array_walk($palettes, static fn (string $palette): string => \trim($palette, ";, \n\r\t\v\0")); - return \implode(';', \array_filter($palettes)); + $palettes = \array_filter(\array_map( + static fn (string $palette): string => \trim($palette, ";, \n\r\t\v\0"), + $palettes, + )); + + return \implode(';', $palettes); } public static function isValidSqlName(?string $db_or_col_name): bool @@ -341,4 +344,4 @@ public static function htmlJoinClasses(string|array|null ...$classes): string { return \implode(' ', self::htmlListClasses(...$classes)); } -} \ No newline at end of file +} From 9eca4d473be38ee9e7f7ad34cbb8a2551588f477 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Sun, 19 Jul 2026 15:07:50 +0200 Subject: [PATCH 65/71] refactor: improve headline processing and HTML tag handling in `Str`, ensure better type consistency and enhance filters and projector logic across components --- .../content_element/flare_listview.html.twig | 2 +- .../content_element/flare_reader.html.twig | 2 +- .../ContentElement/ListViewController.php | 15 +++----- src/DataContainer/ListContainer.php | 22 ++++++++++++ src/Engine/Context/ValidationContext.php | 9 ++--- src/Engine/Loader/ValidationLoaderConfig.php | 4 +-- src/Engine/Projector/AbstractProjector.php | 14 ++++++++ src/Engine/Projector/AggregationProjector.php | 9 ++--- src/Engine/Projector/InteractiveProjector.php | 7 ++-- src/Engine/Projector/ValidationProjector.php | 11 ++---- src/Engine/View/HandlesModelsTrait.php | 9 +++-- .../Contao/ElementDcaListener.php | 4 +-- .../Driver/GenericDataContainerListDriver.php | 30 ++++++++++++++-- src/Model/FilterModel.php | 2 +- .../Factory/ReaderRequestAttributeFactory.php | 4 +++ src/Util/Str.php | 36 ++++++++++++++++--- translations/flare.de.yaml | 3 ++ translations/flare.en.yaml | 3 ++ 18 files changed, 133 insertions(+), 53 deletions(-) diff --git a/contao/templates/content_element/flare_listview.html.twig b/contao/templates/content_element/flare_listview.html.twig index ee8739a0..dbc0741d 100644 --- a/contao/templates/content_element/flare_listview.html.twig +++ b/contao/templates/content_element/flare_listview.html.twig @@ -30,7 +30,7 @@
{{ 'list.default_template.description'|trans({}, 'flare') }} - {% if app.request.get('_preview') or app.debug %} + {% if app.debug %} {% for entry in flare_list.entries %}
#{{ entry.id }} {{ (entry.title ?? entry.email ?? entry.alias ?? null) ?: ('to reader <' ~ loop.index ~ '>') }} diff --git a/contao/templates/content_element/flare_reader.html.twig b/contao/templates/content_element/flare_reader.html.twig index 9be9e0dc..43a7610e 100644 --- a/contao/templates/content_element/flare_reader.html.twig +++ b/contao/templates/content_element/flare_reader.html.twig @@ -14,7 +14,7 @@
{{ 'reader.default_template.description'|trans({}, 'flare') }} - {% if app.request.get('_preview') or app.debug %} + {% if app.debug %}
{{ model.table }} {% for key, field in model.row -%} diff --git a/src/Controller/ContentElement/ListViewController.php b/src/Controller/ContentElement/ListViewController.php index 628a2122..54723d10 100644 --- a/src/Controller/ContentElement/ListViewController.php +++ b/src/Controller/ContentElement/ListViewController.php @@ -161,17 +161,12 @@ protected function getBackendResponse(Template $template, ContentModel $model, R return new Response($e->getMessage()); } - if (($headline = StringUtil::deserialize($model->headline, true)) && isset($headline['value'])) { - $unit = ($headline['unit'] ?? null) ?: 'h2'; - $hl = \sprintf('<%s>%s', $unit, $headline['value'], $unit); - } - return new Response(\sprintf( - '%s%s [%s, %s]', - $hl ?? '', - $listModel->title, - $this->translator->trans($listModel->type, [], 'flare_list'), - $listModel->dc + '
%s
%s [%s, %s]', + (string) Str::formatHeadline($model->headline), + \strip_tags((string) $listModel->title), + \strip_tags($this->translator->trans($listModel->type, [], 'flare_list')), + \strip_tags((string) $listModel->dc) )); } } diff --git a/src/DataContainer/ListContainer.php b/src/DataContainer/ListContainer.php index bbaed2a2..fca6bdc4 100644 --- a/src/DataContainer/ListContainer.php +++ b/src/DataContainer/ListContainer.php @@ -4,10 +4,13 @@ namespace HeimrichHannot\FlareBundle\DataContainer; +use Contao\Controller; use Contao\CoreBundle\DependencyInjection\Attribute\AsCallback; use Contao\DataContainer; use Doctrine\DBAL\Connection; use HeimrichHannot\FlareBundle\Contract\ListDriver\OnSubmitDcContract; +use HeimrichHannot\FlareBundle\Model\FilterModel; +use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; use HeimrichHannot\FlareBundle\Registry\ListDriverRegistry; use HeimrichHannot\FlareBundle\Util\DcaHelper; @@ -22,6 +25,25 @@ public function __construct( private readonly ListDriverRegistry $listDriverRegistry, ) {} + public function hasFilterConfigured(ListModel $listModel, string $filterType): bool + { + $filterTable = FilterModel::getTable(); + + $result = $this->connection->createQueryBuilder() + ->select('1') + ->from($filterTable) + ->where('pid = :pid') + ->andWhere('published = 1') + ->andWhere('tstamp > 0') + ->andWhere('type = :type') + ->setMaxResults(1) + ->setParameter('pid', $listModel->id) + ->setParameter('type', $filterType) + ->executeQuery(); + + return (bool) $result->rowCount(); + } + /* ============================= * * CONFIG * * ============================= */ diff --git a/src/Engine/Context/ValidationContext.php b/src/Engine/Context/ValidationContext.php index de51a4eb..0540385a 100644 --- a/src/Engine/Context/ValidationContext.php +++ b/src/Engine/Context/ValidationContext.php @@ -29,7 +29,7 @@ public function __construct( private ?\Closure $entryCache = null, #[Assert\PositiveOrZero] public int $jumpToReaderPageId = 0, #[Assert\PositiveOrZero] public int $jumpToListViewPageId = 0, - #[Assert\NotBlank] private string $autoItemField = 'id', + #[Assert\NotBlank] public string $autoItemField = 'id', private array $filterValues = [], ) { $this->paginatorConfig = new PaginatorConfig(itemsPerPage: 1); @@ -48,11 +48,6 @@ public function createBackLink(): ?BackLink return BackLink::fromPage($pageModel); } - public function getAutoItemField(): string - { - return $this->autoItemField; - } - public function getEntryCache(): array { if (!\is_callable($this->entryCache)) { @@ -94,4 +89,4 @@ public function withFilterValues(array $values): self filterValues: $values, ); } -} \ No newline at end of file +} diff --git a/src/Engine/Loader/ValidationLoaderConfig.php b/src/Engine/Loader/ValidationLoaderConfig.php index 70740f4b..dcc754e4 100644 --- a/src/Engine/Loader/ValidationLoaderConfig.php +++ b/src/Engine/Loader/ValidationLoaderConfig.php @@ -10,8 +10,8 @@ readonly class ValidationLoaderConfig { public function __construct( - public ListSpec $list, + public ListSpec $list, public ValidationContext $context, public string $autoItemField, ) {} -} \ No newline at end of file +} diff --git a/src/Engine/Projector/AbstractProjector.php b/src/Engine/Projector/AbstractProjector.php index 62ae23dc..d1107a9e 100644 --- a/src/Engine/Projector/AbstractProjector.php +++ b/src/Engine/Projector/AbstractProjector.php @@ -6,12 +6,14 @@ use Doctrine\DBAL\Query\QueryBuilder; use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; +use HeimrichHannot\FlareBundle\Engine\Factory\LoaderFactory; use HeimrichHannot\FlareBundle\Engine\View\ViewInterface; use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Query\Executor\ListQueryDirector; use HeimrichHannot\FlareBundle\Query\ListQueryConfig; +use HeimrichHannot\FlareBundle\Reader\Factory\ReaderUrlGeneratorFactory; use HeimrichHannot\FlareBundle\Registry\ProjectorRegistry; use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; @@ -35,7 +37,9 @@ public static function getSubscribedServices(): array { return [ ListQueryDirector::class, + LoaderFactory::class, ProjectorRegistry::class, + ReaderUrlGeneratorFactory::class, RequestStack::class, ]; } @@ -67,6 +71,16 @@ protected function getListQueryDirector(): ListQueryDirector return $this->container->get(ListQueryDirector::class); } + protected function getLoaderFactory(): LoaderFactory + { + return $this->container->get(LoaderFactory::class); + } + + protected function getReaderUrlGeneratorFactory(): ReaderUrlGeneratorFactory + { + return $this->container->get(ReaderUrlGeneratorFactory::class); + } + /** * @throws FlareException */ diff --git a/src/Engine/Projector/AggregationProjector.php b/src/Engine/Projector/AggregationProjector.php index 5ccb7e6c..f24dc852 100644 --- a/src/Engine/Projector/AggregationProjector.php +++ b/src/Engine/Projector/AggregationProjector.php @@ -6,7 +6,6 @@ use HeimrichHannot\FlareBundle\Engine\Context\AggregationContext; use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; -use HeimrichHannot\FlareBundle\Engine\Factory\LoaderFactory; use HeimrichHannot\FlareBundle\Engine\Loader\AggregationLoaderConfig; use HeimrichHannot\FlareBundle\Engine\Loader\AggregationLoaderInterface; use HeimrichHannot\FlareBundle\Engine\View\AggregationView; @@ -17,10 +16,6 @@ */ class AggregationProjector extends AbstractProjector { - public function __construct( - private readonly LoaderFactory $loaderFactory, - ) {} - public function supports(ListSpec $list, ContextInterface $context): bool { return $context instanceof AggregationContext; @@ -41,11 +36,11 @@ public function project(ListSpec $list, ContextInterface $context): AggregationV protected function createLoader(AggregationLoaderConfig $config): AggregationLoaderInterface { - return $this->loaderFactory->createAggregationLoader($config); + return $this->getLoaderFactory()->createAggregationLoader($config); } protected function createView(AggregationLoaderInterface $loader): AggregationView { return new AggregationView(loader: $loader); } -} \ No newline at end of file +} diff --git a/src/Engine/Projector/InteractiveProjector.php b/src/Engine/Projector/InteractiveProjector.php index e2443316..22dd3c03 100644 --- a/src/Engine/Projector/InteractiveProjector.php +++ b/src/Engine/Projector/InteractiveProjector.php @@ -32,9 +32,7 @@ class InteractiveProjector extends AbstractProjector public function __construct( private readonly AggregationContextFactory $aggregationConfigFactory, private readonly FilterFormFactory $filterFormFactory, - private readonly LoaderFactory $loaderFactory, private readonly PaginatorFactory $paginatorFactory, - private readonly ReaderUrlGeneratorFactory $readerUrlGeneratorFactory, ) {} public function supports(ListSpec $list, ContextInterface $context): bool @@ -72,7 +70,8 @@ public function project(ListSpec $list, ContextInterface $context): InteractiveV $loader = $this->createLoader($config); } - $readerUrlGenerator = $this->readerUrlGeneratorFactory->create($context->createReaderUrlConfig()); + $readerUrlConfig = $context->createReaderUrlConfig(); + $readerUrlGenerator = $this->getReaderUrlGeneratorFactory()->create($readerUrlConfig); return $this->createView( loader: $loader, @@ -86,7 +85,7 @@ public function project(ListSpec $list, ContextInterface $context): InteractiveV protected function createLoader(InteractiveLoaderConfig $config): InteractiveLoaderInterface { - return $this->loaderFactory->createInteractiveLoader($config); + return $this->getLoaderFactory()->createInteractiveLoader($config); } protected function createView( diff --git a/src/Engine/Projector/ValidationProjector.php b/src/Engine/Projector/ValidationProjector.php index 97c40b3e..52a7c208 100644 --- a/src/Engine/Projector/ValidationProjector.php +++ b/src/Engine/Projector/ValidationProjector.php @@ -20,11 +20,6 @@ */ class ValidationProjector extends AbstractProjector { - public function __construct( - private readonly LoaderFactory $loaderFactory, - private readonly ReaderUrlGeneratorFactory $readerUrlGeneratorFactory, - ) {} - public function supports(ListSpec $list, ContextInterface $context): bool { return $context instanceof ValidationContext; @@ -35,7 +30,7 @@ public function project(ListSpec $list, ContextInterface $context): ValidationVi \assert($context instanceof ValidationContext, '$config must be an instance of ValidationConfig'); $readerUrlConfig = $context->createReaderUrlConfig(); - $autoItemField = $readerUrlConfig->autoItemField ?? $context->getAutoItemField(); + $autoItemField = $readerUrlConfig->autoItemField ?? $context->autoItemField; $loader = $this->createLoader(new ValidationLoaderConfig( list: $list, @@ -43,7 +38,7 @@ public function project(ListSpec $list, ContextInterface $context): ValidationVi autoItemField: $autoItemField, )); - $readerUrlGenerator = $this->readerUrlGeneratorFactory->create($readerUrlConfig); + $readerUrlGenerator = $this->getReaderUrlGeneratorFactory()->create($readerUrlConfig); return $this->createView( loader: $loader, @@ -56,7 +51,7 @@ public function project(ListSpec $list, ContextInterface $context): ValidationVi protected function createLoader(ValidationLoaderConfig $config): ValidationLoaderInterface { - return $this->loaderFactory->createValidationLoader($config); + return $this->getLoaderFactory()->createValidationLoader($config); } protected function createView( diff --git a/src/Engine/View/HandlesModelsTrait.php b/src/Engine/View/HandlesModelsTrait.php index dbed48e8..42549897 100644 --- a/src/Engine/View/HandlesModelsTrait.php +++ b/src/Engine/View/HandlesModelsTrait.php @@ -4,6 +4,7 @@ namespace HeimrichHannot\FlareBundle\Engine\View; +use Contao\Controller; use Contao\Model; use HeimrichHannot\FlareBundle\Exception\FlareException; @@ -20,7 +21,11 @@ public function fetchModel(string $table, int|string $id_or_alias, callable $get if ($model = $registry->fetch($table, $id_or_alias, strAlias: $column)) // Contao native model cache { - return $model; + Controller::loadDataContainer($table); + + if (!isset($GLOBALS['TL_DCA'][$table]['fields']['published']) || $model->published) { + return $model; + } } $modelClass = Model::getClassFromTable($table); @@ -78,4 +83,4 @@ public function createModelsFromEntries(string $table, array $entries): array return $models; } -} \ No newline at end of file +} diff --git a/src/EventListener/Contao/ElementDcaListener.php b/src/EventListener/Contao/ElementDcaListener.php index 95be9196..68b249e8 100644 --- a/src/EventListener/Contao/ElementDcaListener.php +++ b/src/EventListener/Contao/ElementDcaListener.php @@ -54,9 +54,7 @@ public function __invoke(string $table): void return; } - $GLOBALS['TL_DCA'][$table]['config']['onload_callback'][] = function () use ($table): void { - $this->configure($table); - }; + $this->configure($table); } private function configure(string $table): void diff --git a/src/List/Driver/GenericDataContainerListDriver.php b/src/List/Driver/GenericDataContainerListDriver.php index 0e7a3d73..1369eb75 100644 --- a/src/List/Driver/GenericDataContainerListDriver.php +++ b/src/List/Driver/GenericDataContainerListDriver.php @@ -4,16 +4,21 @@ namespace HeimrichHannot\FlareBundle\List\Driver; +use Contao\Controller; use Contao\CoreBundle\DataContainer\PaletteManipulator; use Contao\DataContainer; use Contao\Message; +use Doctrine\DBAL\Connection; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\Contract\ListDriver\OnSubmitDcContract; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; +use HeimrichHannot\FlareBundle\DataContainer\ListContainer; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListDriver; use HeimrichHannot\FlareBundle\Exception\InferenceException; +use HeimrichHannot\FlareBundle\Filter\Element\PublishedFilterElement; use HeimrichHannot\FlareBundle\InferPtable\PtableInferrer; +use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Model\ListModel; use Symfony\Contracts\Translation\TranslatorInterface; @@ -27,7 +32,9 @@ class GenericDataContainerListDriver extends AbstractListDriver implements OnSub PALETTE; public function __construct( + private readonly Connection $connection, private readonly TranslatorInterface $trans, + private readonly ListContainer $listContainer, ) {} public function resolveDcOnSubmit(array $row, DataContainer $dc): string @@ -54,12 +61,11 @@ public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void ->addField('whichPtable', 'parent_legend', PaletteManipulator::POSITION_APPEND) ; - $table = $listModel->dc; - $inferrer = new PtableInferrer($listModel, $listModel->dc); try { + $table = $listModel->dc; $ptable = $inferrer->getInferredPtable(); Message::addInfo(match (true) { @@ -87,6 +93,26 @@ public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void $listModel->whichPtable_disableAutoOption(); } + $this->checkPublishedFilter($listModel); + $dca->palette($pm->applyToString(self::DEFAULT_PALETTE)); } + + private function checkPublishedFilter(ListModel $listModel): void + { + $displayTable = $listModel->dc; + Controller::loadDataContainer($displayTable); + + if (!isset($GLOBALS['TL_DCA'][$displayTable]['fields']['published'])) { + return; + } + + if ($this->listContainer->hasFilterConfigured($listModel, PublishedFilterElement::TYPE)) { + return; + } + + Message::addInfo($this->trans->trans('list.info.no_published_filter', [ + '%target%' => "{$displayTable}.published", + ], 'flare')); + } } diff --git a/src/Model/FilterModel.php b/src/Model/FilterModel.php index d9c4f75a..f229e05a 100644 --- a/src/Model/FilterModel.php +++ b/src/Model/FilterModel.php @@ -63,7 +63,7 @@ public function getFilterProperty(string $name): mixed public static function findByPid(int $pid, ?bool $published = null): Collection { $result = $published !== null - ? static::findBy(['pid=?', 'published=?'], [$pid, $published], ['order' => 'sorting']) + ? static::findBy(['pid=?', 'published=?', 'tstamp>0'], [$pid, $published], ['order' => 'sorting']) : static::findBy(['pid=?'], [$pid], ['order' => 'sorting']); if (!$result) { diff --git a/src/Reader/Factory/ReaderRequestAttributeFactory.php b/src/Reader/Factory/ReaderRequestAttributeFactory.php index 7eccc0ec..ecb79747 100644 --- a/src/Reader/Factory/ReaderRequestAttributeFactory.php +++ b/src/Reader/Factory/ReaderRequestAttributeFactory.php @@ -30,6 +30,10 @@ public function createFromData(array $data): ?ReaderRequestAttribute return null; } + if ($modelClass::getTable() !== $modelTable) { + return null; + } + /** @var Model $displayModel */ $displayModel = $modelClass::findByPk($modelId); $listModel = ListModel::findByPk($listId); diff --git a/src/Util/Str.php b/src/Util/Str.php index 0857db29..def15c7a 100644 --- a/src/Util/Str.php +++ b/src/Util/Str.php @@ -167,7 +167,7 @@ public static function random(int $length = 10, ?string $chars = null): string public static function normalizeHeadline(array|string|null $headline): ?array { - if (!$headline) { + if ($headline === null || $headline === '' || $headline === []) { return null; } @@ -187,9 +187,25 @@ public static function normalizeHeadline(array|string|null $headline): ?array ]; } + /** + * Formats a Contao-formatted headline by processing the given input and optionally + * wrapping it in HTML tags. + * + * If the `$withTags` parameter is set to true, the content is wrapped in the computed tag + * (defaulting to `

` if none is provided, or it's invalid). Supported tags are limited + * to valid headings (`h1` through `h6`), `
`, and `

`. If the tag is invalid, the + * raw content is returned without tags. + * + * @param array|string|null $headline The headline input to be formatted, which can be a + * string, an associative array, or null. + * @param bool $withTags Whether to wrap the headline in HTML tags. Defaults to false. + * + * @return string|null The formatted headline, optionally wrapped in HTML tags, or null if + * the input is invalid or empty. + */ public static function formatHeadline(array|string|null $headline, bool $withTags = false): ?string { - if (!$headline) { + if ($headline === null || $headline === '' || $headline === []) { return null; } @@ -198,20 +214,30 @@ public static function formatHeadline(array|string|null $headline, bool $withTag } if (\is_string($headline)) { - return $headline ?: null; + return $headline; } if (!\is_array($headline)) { return null; } - $tagName = $headline['tag_name'] ?? $headline['unit'] ?? 'h2'; + $value = $headline['text'] ?? $headline['value'] ?? ''; + + if ($value === '') { + return null; + } + + $tagName = \strtolower($headline['tag_name'] ?? $headline['unit'] ?? 'h2'); if (\is_numeric($tagName)) { $tagName = "h{$tagName}"; } - $value = $headline['text'] ?? $headline['value'] ?? ''; + $allowedTags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hgroup', 'p', 'span']; + + if (!\in_array($tagName, $allowedTags, true)) { + return $value; + } return $withTags ? "<{$tagName}>{$value}" : $value; } diff --git a/translations/flare.de.yaml b/translations/flare.de.yaml index 9353165a..d8f2a4f9 100644 --- a/translations/flare.de.yaml +++ b/translations/flare.de.yaml @@ -9,6 +9,9 @@ list: warning: "Standard-Template ausgewählt" description: "Bitte erstellen/wählen Sie ein benutzerdefiniertes Template für diese FLARE-Liste." + info: + no_published_filter: "Einträge dieser Liste haben einen Veröffentlichungsstatus (%target%). Es ist kein Veröffentlicht-Filter konfiguriert, der dies berücksichtigt." + filter: limited_scope: single: "Dieser Filter ist ausschließlich anwendbar auf: %scopes%" diff --git a/translations/flare.en.yaml b/translations/flare.en.yaml index 7674dcda..c65025f6 100644 --- a/translations/flare.en.yaml +++ b/translations/flare.en.yaml @@ -9,6 +9,9 @@ list: warning: "Default template selected" description: "Please create/select a custom template for this FLARE list." + info: + no_published_filter: "Entries in this list have a publication status (%target%). No publication filter is configured to account for this." + filter: limited_scope: single: "This filter is limited to the following scope: %scopes%" From 5940ad665bb5ab0dd1cd9aa0bd1b34ce99be97bc Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Sun, 19 Jul 2026 15:11:15 +0200 Subject: [PATCH 66/71] refactor: remove unused imports and redundant constructor dependencies across multiple components --- src/Controller/ContentElement/ListViewController.php | 1 - src/DataContainer/ListContainer.php | 1 - src/Engine/Projector/InteractiveProjector.php | 2 -- src/Engine/Projector/ValidationProjector.php | 2 -- src/List/Driver/GenericDataContainerListDriver.php | 3 --- 5 files changed, 9 deletions(-) diff --git a/src/Controller/ContentElement/ListViewController.php b/src/Controller/ContentElement/ListViewController.php index 54723d10..be182efb 100644 --- a/src/Controller/ContentElement/ListViewController.php +++ b/src/Controller/ContentElement/ListViewController.php @@ -10,7 +10,6 @@ use Contao\CoreBundle\Exception\ResponseException; use Contao\CoreBundle\Monolog\ContaoContext; use Contao\CoreBundle\Routing\ScopeMatcher; -use Contao\StringUtil; use Contao\Template; use FOS\HttpCacheBundle\Http\SymfonyResponseTagger; use HeimrichHannot\FlareBundle\DataContainer\ContentContainer; diff --git a/src/DataContainer/ListContainer.php b/src/DataContainer/ListContainer.php index fca6bdc4..c190f2bf 100644 --- a/src/DataContainer/ListContainer.php +++ b/src/DataContainer/ListContainer.php @@ -4,7 +4,6 @@ namespace HeimrichHannot\FlareBundle\DataContainer; -use Contao\Controller; use Contao\CoreBundle\DependencyInjection\Attribute\AsCallback; use Contao\DataContainer; use Doctrine\DBAL\Connection; diff --git a/src/Engine/Projector/InteractiveProjector.php b/src/Engine/Projector/InteractiveProjector.php index 22dd3c03..a12536a2 100644 --- a/src/Engine/Projector/InteractiveProjector.php +++ b/src/Engine/Projector/InteractiveProjector.php @@ -8,7 +8,6 @@ use HeimrichHannot\FlareBundle\Engine\Context\Factory\AggregationContextFactory; use HeimrichHannot\FlareBundle\Engine\Context\InteractiveContext; use HeimrichHannot\FlareBundle\Engine\Context\Interface\PaginatedContextInterface; -use HeimrichHannot\FlareBundle\Engine\Factory\LoaderFactory; use HeimrichHannot\FlareBundle\Engine\Loader\InteractiveEmptyLoader; use HeimrichHannot\FlareBundle\Engine\Loader\InteractiveLoaderConfig; use HeimrichHannot\FlareBundle\Engine\Loader\InteractiveLoaderInterface; @@ -20,7 +19,6 @@ use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Paginator\Factory\PaginatorFactory; use HeimrichHannot\FlareBundle\Paginator\Paginator; -use HeimrichHannot\FlareBundle\Reader\Factory\ReaderUrlGeneratorFactory; use HeimrichHannot\FlareBundle\Reader\ReaderUrlGeneratorInterface; use Symfony\Component\Form\FormInterface; diff --git a/src/Engine/Projector/ValidationProjector.php b/src/Engine/Projector/ValidationProjector.php index 52a7c208..e48c2e0e 100644 --- a/src/Engine/Projector/ValidationProjector.php +++ b/src/Engine/Projector/ValidationProjector.php @@ -6,13 +6,11 @@ use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; use HeimrichHannot\FlareBundle\Engine\Context\ValidationContext; -use HeimrichHannot\FlareBundle\Engine\Factory\LoaderFactory; use HeimrichHannot\FlareBundle\Engine\Loader\ValidationLoaderConfig; use HeimrichHannot\FlareBundle\Engine\Loader\ValidationLoaderInterface; use HeimrichHannot\FlareBundle\Engine\View\ValidationView; use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Reader\BackLink; -use HeimrichHannot\FlareBundle\Reader\Factory\ReaderUrlGeneratorFactory; use HeimrichHannot\FlareBundle\Reader\ReaderUrlGeneratorInterface; /** diff --git a/src/List/Driver/GenericDataContainerListDriver.php b/src/List/Driver/GenericDataContainerListDriver.php index 1369eb75..6e6968a8 100644 --- a/src/List/Driver/GenericDataContainerListDriver.php +++ b/src/List/Driver/GenericDataContainerListDriver.php @@ -8,7 +8,6 @@ use Contao\CoreBundle\DataContainer\PaletteManipulator; use Contao\DataContainer; use Contao\Message; -use Doctrine\DBAL\Connection; use HeimrichHannot\FlareBundle\Config\ConfigBuilder; use HeimrichHannot\FlareBundle\Contract\ListDriver\OnSubmitDcContract; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; @@ -18,7 +17,6 @@ use HeimrichHannot\FlareBundle\Exception\InferenceException; use HeimrichHannot\FlareBundle\Filter\Element\PublishedFilterElement; use HeimrichHannot\FlareBundle\InferPtable\PtableInferrer; -use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Model\ListModel; use Symfony\Contracts\Translation\TranslatorInterface; @@ -32,7 +30,6 @@ class GenericDataContainerListDriver extends AbstractListDriver implements OnSub PALETTE; public function __construct( - private readonly Connection $connection, private readonly TranslatorInterface $trans, private readonly ListContainer $listContainer, ) {} From 71c57d8f646ede4484c3e422d7250b4a868022d9 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Mon, 20 Jul 2026 01:43:44 +0200 Subject: [PATCH 67/71] add combined audit findings documentation for tests, CI, security, architecture, and performance --- .audit/260719012-combined/00-uebersicht.md | 54 +++++++ .audit/260719012-combined/10-architektur.md | 93 +++++++++++ .audit/260719012-combined/20-korrektheit.md | 116 ++++++++++++++ .audit/260719012-combined/30-sicherheit.md | 33 ++++ .../40-performance-stabilitaet.md | 150 ++++++++++++++++++ .audit/260719012-combined/50-tests-ci.md | 81 ++++++++++ .../60-contao-integration-doku.md | 111 +++++++++++++ .../260719012-combined/99-positive-punkte.md | 69 ++++++++ 8 files changed, 707 insertions(+) create mode 100644 .audit/260719012-combined/00-uebersicht.md create mode 100644 .audit/260719012-combined/10-architektur.md create mode 100644 .audit/260719012-combined/20-korrektheit.md create mode 100644 .audit/260719012-combined/30-sicherheit.md create mode 100644 .audit/260719012-combined/40-performance-stabilitaet.md create mode 100644 .audit/260719012-combined/50-tests-ci.md create mode 100644 .audit/260719012-combined/60-contao-integration-doku.md create mode 100644 .audit/260719012-combined/99-positive-punkte.md diff --git a/.audit/260719012-combined/00-uebersicht.md b/.audit/260719012-combined/00-uebersicht.md new file mode 100644 index 00000000..f74e9415 --- /dev/null +++ b/.audit/260719012-combined/00-uebersicht.md @@ -0,0 +1,54 @@ +# Kombiniertes Audit: contao-flare-bundle — Branch `feat/filter-types` + +**Datum:** 2026-07-20 · **Stand:** Commit `5940ad6` +**Quellen:** `.audit/2607171801-claude/` und `.audit/2607171755-codex/` (beide vom 2026-07-17, Review-Commit `39065f73`) + +## Methodik + +Jeder Punkt beider Audits wurde gegen den aktuellen Code (`5940ad6`) verifiziert. Enthalten sind **ausschließlich weiterhin valide Punkte** mit aktuellen Datei-/Zeilen-Belegen; inzwischen behobene sowie widerlegte Punkte wurden entfernt, Duplikate beider Audits zusammengeführt. Positive Beobachtungen stehen separat in [99-positive-punkte.md](99-positive-punkte.md), damit die actionable Dateien schlank bleiben. + +Seit dem Audit-Datum wurden mehrere der ursprünglichen Top-Findings behoben — darunter der `?_preview`-Feld-Dump, die verlorene Listen-ID im `ListSpec`-Pfad, die Transformer-Memoization pro Klasse, das Suche-verwirft-sich-selbst-Kernproblem und der `'0'`-Verlust im `ChoicesBuilder`. Die Kapitel Tests/CI und Performance/Stabilität sind dagegen vollständig unverändert offen. + +## Die Dateien + +| Datei | Thema | Schwerste offene Findings | +|---|---|---| +| [10-architektur.md](10-architektur.md) | Architektur & Design | Terminal42 als toter, nicht kompilierbarer Code (A-01); stale AGENTS.md (A-02) | +| [20-korrektheit.md](20-korrektheit.md) | Korrektheit & Bugs | Boolean-Binary-Modi nicht implementiert (K-01), unabwählbarer Preselect (K-02), Kalender-Datumsgrenzen ignoriert (K-03), totes Stop-Word-Feature (K-07), `'0'`-Verluste (K-08) | +| [30-sicherheit.md](30-sicherheit.md) | Security & Query-Safety | Model-Registry umgeht `start`/`stop`-Fenster (SEC-01); Backend-Ausgabe teils unescaped (SEC-03) | +| [40-performance-stabilitaet.md](40-performance-stabilitaet.md) | Performance & Stabilität | 500er statt Degradierung im Render-Pfad (PS-01), positionaler Entry-Cache (PS-02), DBAL-Constraint (PS-03), Calendar-OOM-Potenzial (PS-15/16), doppelte Query-Pipeline (PS-14) | +| [50-tests-ci.md](50-tests-ci.md) | Tests, CI & Tooling | Query-Schicht/Filter-Types/Engine ungetestet (T-01), Compatibility-Gate durch `continue-on-error` entwertet (CI-02), PHPUnit nur PHP 8.2 (CI-01) | +| [60-contao-integration-doku.md](60-contao-integration-doku.md) | Contao-Integration, API, Doku & Kompatibilität | Doku-Beispiele mit Fatal Error (C-01), Intrinsic-DX-Falle (C-02), tote DB-Felder der Terminal42-Integration (C-03), rohe Backend-Labels (C-04) | +| [99-positive-punkte.md](99-positive-punkte.md) | Positivbefunde (nicht actionable) | — | + +## Priorisierung (konsolidiert, nur offene Punkte) + +### Vor dem Merge fixen + +1. **DBAL-Constraint `^2.13 || ^3.0` erlaubt Versionen ohne `ArrayParameterType`** → Fatal auf Contao 4.13; Fix ist eine Zeile: `^3.6 || ^4.0` (PS-03, C-05). +2. **Render-Pfad-Stabilität:** `createView()` läuft erst im Template; Laufzeitfehler eines kaputten Filters reißt die Seite in einen 500er — `createView()` in den Controller ziehen bzw. `FlareException` beim Rendern abfangen; dazu 200-vs-500-Inkonsistenz Listview/Reader (PS-01, PS-05). +3. **Entry-Cache positional statt per ID indiziert** — falscher Datensatz im Reader-Pfad möglich, öffentliche API (PS-02). +4. **Doku-`buildDca()`-Beispiele erzeugen Fatal Error** (konkrete Klasse statt `DcaBuilderInterface`; C-01). + +### Zeitnah (Korrektheit der namensgebenden Features) + +5. **BooleanFilterElement:** `NULL_FALSE`/`TRUE_FALSE` nicht implementiert, unabwählbarer Preselect, „CBX"-Label, rohe Backend-Keys (K-01, K-02, K-11, C-04). +6. **CalendarCurrentFilterElement:** numerische Datumsgrenzen wirkungslos, kein Gating durch `configure_*`, ungefangene Exception (K-03, K-04, K-05). +7. **Suche:** nur-leere Suchgruppen → `ArgumentCountError` (K-06); Stop-Word-Feature komplett tot — Parameter existiert nie (K-07). +8. **`'0'`-/falsy-Verluste in den Choice-Pfaden** inkl. Label-Kollisionen (K-08, K-09). +9. **Sichtbarkeit:** still geskippte intrinsische Filter (PS-08), Registry-Shortcut umgeht `start`/`stop` (SEC-01), fehlende Generic-Driver-Warnung im No-Parent-Zweig (SEC-02), Preview-Modus ignoriert (PS-09). +10. **Intrinsic-Pflichtmuster** in Interface-Docblock/zentralem Guard verankern; Migrationsdoku um Alias-Skip + Intrinsic-Verlagerung ergänzen (C-02, C-06). + +### Vor dem ersten Stable-Release + +11. **Testabdeckung der Risikozonen:** `src/Query/` (SQL-Leitplanken!), Filter-Types, Engine-Pipeline, Paginator, ChoicesBuilder; Regressionstests für K-01–K-08 gleich mitnehmen; Stubs autoloadbar machen, Random-Order aktivieren (T-01, T-02, T-03). +12. **CI reparieren:** `continue-on-error` raus, PHPUnit-Matrix (lowest-deps + Contao 4.13), `pull_request`-Trigger, `composer audit` ohne `|| true`, Mago wieder inkl. `tests/` (CI-01–CI-05). +13. **Terminal42-Integration entscheiden:** portieren oder entfernen — toter, nicht kompilierbarer Code plus tote DB-Felder, unsichtbar nur dank PHPStan-Excludes (A-01, C-03). +14. **Public-API-/DX-Politur:** Registry-Vereinheitlichung, `#[TaggedIterator]`-Ablösung, `PaginatorConfig`-TypeErrors und Off-by-one, Alias-Kollisions-Warning, Übersetzungslücken/Waisen (A-03–A-16, K-12, K-13, C-07–C-19). + +### Performance-Backlog (kein Blocker, aber lohnend) + +- Filter-Pipeline-Ergebnis request-scoped teilen — Count + Entries + Partials rechnen bis zu 3× dasselbe (PS-14, PS-17, PS-21). +- Calendar-Integration: SQL-seitiges Zeitfenster + harte Occurrence-Obergrenze — Full-Fetch ×2 + unbegrenzte Expansion = OOM-Risiko durch Redakteurs-Eingabe (PS-15, PS-16). +- Shared-Service-Caches via `kernel.reset` leeren — sonst stale unter Worker-Runtimes (PS-07). +- Choices begrenzen (LIMIT/Suche/Ajax) und O(n²)-Wertauflösung beheben (PS-19, PS-20). diff --git a/.audit/260719012-combined/10-architektur.md b/.audit/260719012-combined/10-architektur.md new file mode 100644 index 00000000..b1c200b7 --- /dev/null +++ b/.audit/260719012-combined/10-architektur.md @@ -0,0 +1,93 @@ +# Architektur & Design + +Kombinierte, am Stand `5940ad6` (2026-07-20) verifizierte Findings aus beiden Audits (claude 2607171801, codex 2607171755). Die ursprünglichen Top-Findings beider Audits — verlorene Listen-ID im `ListSpec`-Pfad (Formularnamen-Kollision) und Transformer-Memoization pro Klasse statt (Klasse, Typ) — sind inzwischen behoben und daher hier nicht mehr enthalten. Positive Beobachtungen: siehe [99-positive-punkte.md](99-positive-punkte.md). + +## A-01: Terminal42-ChangeLanguage-Integration ist toter, nicht kompilierbarer Code — Major (beide Audits) + +Der Listener importiert fünf nicht existierende Klassen (`Event\AbstractFetchEvent`, `Event\FetchAutoItemEvent`, `Event\FetchCountEvent`, `Event\FetchListEntriesEvent`, `Query\ListQueryBuilder`), abonniert nie dispatchte Event-Namen und benutzt die entfernte Fetch-API (`getListQueryBuilder()`, `getFilters()`, `getContentContext()`). Das Laden der Integration ist auskommentiert; PHPStan excludiert das Verzeichnis komplett und ignoriert `class.notFound` für `src/Integration/` — der Bruch bleibt systematisch unsichtbar. Nebenbefund: Klasse/Namespace `DcMultilingualListType` tragen als letzte Stelle das alte `ListType`-Vokabular (Attribut ist bereits `#[AsListDriver]`). + +**Entscheidung nötig: portieren oder entfernen.** + +- `src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php:13-17,22` (tote Imports), `:92,116` (nie dispatchte Events), `:70,100-101,108-109,119-129` (entfernte API) +- `src/DependencyInjection/HeimrichHannotFlareExtension.php:43-45` (auskommentierter Loader) +- `phpstan.neon:13,18-20` · `src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php:16-19` + +## A-02: AGENTS.md/CLAUDE.md beschreiben nicht mehr existierende APIs — Minor (claude) + +Doku-Drift gegen den aktuellen Code: `ListBuilderFactory` heißt `ListSpecBuilderFactory`; `#[AsListType]` heißt `AsListDriver`; `ListTypeRegistry` heißt `ListDriverRegistry`; `FilterElementResolver` existiert nicht; „EngineFactory creates Engine with appropriate Context" ist falsch — die Factory erhält den Context als Parameter (`src/Engine/Factory/EngineFactory.php:21-29`). Dazu Doc-Drift im Code: Verweis auf `DcaContract::configureDca()`, die Methode heißt `buildDca()` (`src/EventListener/Contao/ElementDcaListener.php:24` vs. `src/Contract/DcaContract.php:18`). + +- `AGENTS.md:21,43,55,60,71` + +## A-03: Registry-Duplikation und heterogene Lookup-Semantik — Minor (claude) + +`FilterElementRegistry` und `ListDriverRegistry` sind strukturell nahezu identisch (gleiches `add`/`remove`/`prune`/`typesByClass`-Muster) — Kandidat für Basis/Trait. Daneben drei weitere Stile: `FilterTypeRegistry` (TaggedIterator, Key = Klassenname), `EngineModRegistry` (TaggedIterator, `defaultIndexMethod: 'getType'`), `ProjectorRegistry` (`supports()`/`priority()`-Scan). Fünf Registries, vier Lookup-Semantiken. + +- `src/Registry/FilterElementRegistry.php:39-57` vs. `src/Registry/ListDriverRegistry.php:34-52` · `src/Registry/FilterTypeRegistry.php:25-28,53` · `src/Registry/EngineModRegistry.php:15` · `src/Registry/ProjectorRegistry.php:28-63` + +## A-04: `PaginatorConfig`: latenter `TypeError` in `count()` + deprecated `\Serializable` — Minor (claude) + +`count(): int` gibt `getLastPageNumber(): ?int` zurück — `TypeError` bei `itemsPerPage < 1` oder unbekanntem `totalItems`. Zusätzlich implementiert die Klasse das deprecated `\Serializable`-Interface mit `serialize()`/`unserialize()` neben `__serialize`/`__unserialize`. + +- `src/Paginator/PaginatorConfig.php:192-195` (`count()`), `:107-118` (`getLastPageNumber(): ?int`), `:7,197-205` (`\Serializable`) + +## A-05: `InteractiveProjector`: COUNT-Query läuft vor der Invalid-Form-Prüfung — Minor (claude) + +Die Aggregations-COUNT-Query (`src/Engine/Projector/InteractiveProjector.php:50`) wird ausgeführt, bevor geprüft wird, ob das Formular invalid submitted wurde (`:56-58`) — pro invalidem Submit eine unnötige Query. Zudem wird `totalItems` unverändert an die View durchgereicht, sodass diese `totalItems > 0` bei leerem `InteractiveEmptyLoader` meldet (`:74-81`). + +## A-06: Context-Verträge mit kleinen LSP/ISP-Brüchen — Minor (claude) + +`InteractiveContext::getPaginatorConfig(): PaginatorConfig` gibt das nullable Property ungeprüft zurück — `TypeError` bei programmatischer Konstruktion ohne Validator-Lauf (`src/Engine/Context/InteractiveContext.php:25,45-48`). Die readonly `ValidationContext` trägt einen No-op-Setter `setPaginatorQueryParameter()`, weil `PaginatedContextInterface` ihn erzwingt (`src/Engine/Context/ValidationContext.php:77-80`). + +## A-07: Stille Alias-Kollision im Filter-Collector — Minor (claude) + +`$filters[$filter->alias] = $filter;` — zwei publizierte Filter derselben Liste mit gleichem Formular-Alias überschreiben sich kommentarlos; nur der letzte wird angewendet. Ein Kollisions-Warning fehlt (das Factory-Fehler-Warning existiert dagegen). + +- `src/List/Collector/ListModelFilterCollector.php:75` + +## A-08: `FlareException`: `method` vs. `source` inkonsistent — Minor (claude) + +Die Exception bietet beide Parameter (`src/Exception/FlareException.php:17-18`), der Code nutzt beide uneinheitlich mit demselben Inhalt (`__METHOD__`): Loader nutzen `method:` (`src/Engine/Loader/InteractiveLoader.php:52`, `AggregationLoader.php:52`), Projector/Views/Calendar-Integration `source:` (`src/Engine/Projector/AbstractProjector.php:124`, `src/Engine/View/HandlesModelsTrait.php:33,42,57,66,75`, `src/Integration/ContaoCalendar/Loader/EventsAggregationLoader.php:66`), `ValidationLoader` keins von beiden (`src/Engine/Loader/ValidationLoader.php:57,92`). + +## A-09: `symfony/event-dispatcher` nicht direkt deklariert — Minor (claude, reduzierter Umfang) + +`FilterFormFactory` instanziiert direkt `new EventDispatcher()` (`src/Filter/Factory/FilterFormFactory.php:17,70`), deklariert ist aber nur `symfony/event-dispatcher-contracts` (`composer.json:17`); das konkrete Paket kommt nur transitiv über `contao/core-bundle`. + +## A-10: `ValidationLoader::executeQuery()` liefert `[]` statt `null` bei abgebrochenem Query-Aufbau — Minor (claude) + +Bei `!$qb` wird `[]` zurückgegeben — harmlos (falsy), aber semantisch schief gegenüber dem `?array`-Vertrag, in dem `null` „nicht gefunden" bedeutet (`:117`: `return $entry ?: null;`). + +- `src/Engine/Loader/ValidationLoader.php:107-109` + +## A-11: Query-Assemblierung lebt in Event-Listener-Prioritäten ohne zentrale Übersicht — Info (claude) + +Select@490, Conditions@470, Page@430, Order@420, Join@-450; Integrations-Listener dazwischen (250/220/200/190/100). Die Gesamtordnung ist nirgends zentral dokumentiert (kein Pipeline-Kommentar im `ListQueryDirector`). + +- `src/EventListener/QueryStructModifier/SelectModifierListener.php:13`, `ConditionsModifierListener.php:11`, `PageModifierListener.php:11`, `OrderModifierListener.php:12`, `JoinModifierListener.php:10` · `src/Integration/ContaoCalendar/EventListener/CountEventsModifierListener.php:14` u. a. + +## A-12: `ViewInterface` ist leerer Marker; Aufrufer müssen downcasten — Info (claude) + +Das Interface ist leer (`src/Engine/View/ViewInterface.php:7-9`); `ReaderController` downcastet auf `ValidationView` (`src/Controller/ContentElement/ReaderController.php:127`). Die `@template`-Annotationen sind nur mit dem `generics.noParent`-Ignore in PHPStan haltbar. + +## A-13: `#[TaggedIterator]` ist seit Symfony 7.1 deprecated — Info (claude) + +Genutzt in drei Registries; relevant für Deprecation-Logs bei Support-Matrix ^5.4|^6|^7. Nachfolger `AutowireIterator` existiert erst ab 6.3 → für die Matrix ggf. `!tagged_iterator` in YAML. + +- `src/Registry/EngineModRegistry.php:15` · `src/Registry/ProjectorRegistry.php:19` · `src/Registry/FilterTypeRegistry.php:18` + +## A-14: Statische Contao-Aufrufe in Context-DTOs — Info (claude, reduzierter Umfang) + +`PageModel::findByPk` in wertartigen Context-Objekten — DB-Zugriffe, testfeindlich, aber Contao-idiomatisch. + +- `src/Engine/Context/ReaderUrlConfigCreatorTrait.php:18` · `src/Engine/Context/ValidationContext.php:44` + +## A-15: Backend-Responses ohne Null-Check auf `$listModel` — Info (claude) + +`getRelated()` kann `null` liefern; der Catch deckt nur Exceptions ab. Danach werden `$listModel->title` / `trans($listModel->type)` ungeprüft dereferenziert — in beiden Controllern. (Gelöschte/fehlende Liste → Backend-Crash; siehe auch SEC-03 in [30-sicherheit.md](30-sicherheit.md).) + +- `src/Controller/ContentElement/ReaderController.php:220-236` (Zugriff `:232-233`) · `src/Controller/ContentElement/ListViewController.php:154-168` (Zugriff `:166-167`) + +## A-16: `Engine`-Mods-API mischt Semantiken — Info (claude) + +`addMod()` appendet numerisch, `setMod()`/`unsetMod()` arbeiten mit String-Keys im selben Array; `unsetMod()` kann appendete Mods nicht adressieren — öffentlicher `@api`-Punkt. + +- `src/Engine/Engine.php:66-93` diff --git a/.audit/260719012-combined/20-korrektheit.md b/.audit/260719012-combined/20-korrektheit.md new file mode 100644 index 00000000..6f1ea61d --- /dev/null +++ b/.audit/260719012-combined/20-korrektheit.md @@ -0,0 +1,116 @@ +# Korrektheit & Bugs + +Kombinierte, am Stand `5940ad6` (2026-07-20) verifizierte Findings aus beiden Audits (claude 2607171801, codex 2607171755). Mehrere ursprüngliche Top-Findings sind inzwischen behoben (u. a. Suche-verwirft-sich-selbst im Kern, `'0'`-Verlust im `ChoicesBuilder`, DCA-Laden im Frontend, halbiertes Paginator-Fenster, `mergePalettes`-No-Op) und daher nicht mehr enthalten. Positive Beobachtungen: siehe [99-positive-punkte.md](99-positive-punkte.md). + +## K-01: Boolean-Filter: `binary_choices` `NULL_FALSE`/`TRUE_FALSE` nicht implementiert — Major/Hoch (beide Audits) + +`normalizeValue()` behandelt ausschließlich `NULL_TRUE` speziell; `NULL_FALSE` und `TRUE_FALSE` laufen in `filter_var()`, wodurch z. B. bei `null_false` eine angehakte Checkbox auf `true` statt `false` filtert. Die Enum-Helper `hasNull()`/`hasTrue()`/`hasFalse()` bleiben ungenutzt. + +- `src/Filter/Element/BooleanFilterElement.php:90-107` (Sonderfall nur `:101`) + +## K-02: Boolean-Filter: Preselect nicht abwählbar, Formular zeigt ihn nicht an — Major/Hoch (beide Audits) + +`buildForm()` setzt kein `'data' => $preselect` (Checkbox rendert unangehakt trotz aktivem Filter). Abwählen + Submit ergibt `false` → `normalizeValue(false, NULL_TRUE)` → `null` → `?? $config['preselect']` reaktiviert den Filter — der Preselect ist unabwählbar. + +- `src/Filter/Element/BooleanFilterElement.php:55-58` (kein `data`), `:87` (Preselect-Fallback) + +## K-03: Calendar-Filter: numerisch gespeicherte Datumsgrenzen werden ignoriert — Major/Mittel (beide Audits) + +Im Modus `date` normalisiert der Load-/Save-Callback `startAt`/`stopAt` auf einen numerischen Timestamp (`src/EventListener/DataContainer/FlareFilter/FieldsLoadAndSaveCallbacks.php:169-173`), aber `buildFilter()` ruft `\strtotime((string) $config['start_at'])` auf — `strtotime('1750723200')` ist `false` → `$start = 0`, `$stop = maxTimestamp()`; ebenso fehlen die min/max-Formattribute. `DateTimeHelper::toTimestamp()` (`src/Util/DateTimeHelper.php:103`) wird nicht benutzt. + +- `src/Filter/Element/CalendarCurrentFilterElement.php:110-111,166-177` + +## K-04: Calendar-Filter: `configure_start`/`configure_stop` gaten den Filter nicht; Save-Callback räumt nicht auf — Minor (beide Audits) + +`buildFilter()` nutzt `start_at`/`stop_at` bedingungslos; der Save-Callback early-returnt bei Leerwahl (`if (!$value) return $value;`) und lässt den alten `startAt`-Wert stehen — der Filter filtert veraltet weiter. + +- `src/Filter/Element/CalendarCurrentFilterElement.php:110-111` · `src/EventListener/DataContainer/FlareFilter/FieldsLoadAndSaveCallbacks.php:115-119` + +## K-05: Calendar-Filter: ungefangene Exception bei Garbage-Strings — Minor (claude) + +`mixedToDateTime()` wirft bei unparsebaren Strings ungefangen (`new \DateTimeImmutable($input)`), erreichbar über programmatische `Filter::$data`. + +- `src/Filter/Element/CalendarCurrentFilterElement.php:225-227` + +## K-06: Suchfilter: nur-leere Suchgruppen führen zu `ArgumentCountError` — Minor, Rest eines Major-Findings (beide Audits) + +Der Kernbug (`return` mitten in der Schleife verwarf die gesamte Suche) ist behoben (`continue`). Die empfohlene Behandlung „gar keine valide Gruppe übrig" fehlt aber: Bei Suchtext nur aus Garbage/Stoppwörtern (z. B. `"!!!"` — erreichbar, da `SearchKeywordsFilterElement::buildFilter` jeden nicht-leeren String durchreicht, `src/Filter/Element/SearchKeywordsFilterElement.php:72-83`) bleibt `$or = []` und `$builder->expr()->or(...$or)` wird ohne Argumente aufgerufen — DBAL verlangt mindestens ein Argument → `ArgumentCountError` statt Ergebnisliste. + +- `src/Filter/Type/SearchKeywordsFilterType.php:29-52` (insb. `:52`) + +## K-07: Such-Stoppwörter erreichen den `ConfigProvider` nie — Mittel (codex) + +Die Extension setzt nur `huh_flare` (Gesamtarray) und `huh_flare.format_label_defaults`; `ConfigProvider` fragt `huh_flare.search_stop_words.` ab, das nirgends erzeugt wird — die ausgelieferten Stoppwortlisten (`config/config.yaml:17`) sind wirkungslos (totes Feature). + +- `src/DependencyInjection/HeimrichHannotFlareExtension.php:50-51` · `src/ConfigProvider.php:30-37` + +## K-08: Choice-Elemente: Wert `'0'` und falsy Labels gehen verloren — Minor–Mittel (beide Audits, Teilaspekt `ChoicesBuilder` behoben) + +Weiterhin valide Teilaspekte: + +- `FieldValueChoiceFilterElement::extractSubmittedData()`: erstes `\array_filter($submittedData)` ohne Callback entfernt `'0'`; zudem pauschales `array_map('strtolower', ...)`. — `src/Filter/Element/FieldValueChoiceFilterElement.php:251-252` +- `DcaSelectFieldFilterElement::buildFilter()`: `if (!$selected) { return; }` verwirft sowohl den intrinsischen Preselect `'0'` als auch eine Runtime-Einzelauswahl mit Key `'0'`. — `src/Filter/Element/DcaSelectFieldFilterElement.php:103-109` +- `DcaSelectFilterType` Multi-Pfad: `if ($validOptions[$value] ?? null)` filtert Keys mit falsy Label (`'0'`, `''`) aus → ggf. `$filtered` leer → `abort()` → ganze Liste leer; der Single-Pfad nutzt korrekt `array_key_exists` (inkonsistent). — `src/Filter/Type/DcaSelectFilterType.php:59` vs. `:38` + +## K-09: DcaSelect: Label→Key-Rückabbildung kollidiert bei doppelten Labels — Minor (beide Audits) + +`normalizeSubmittedValue()` mappt submittete Labels per `array_search` auf Keys — bei identischen (übersetzten) Labels gewinnt immer der erste Key, unmappbare Werte werden `''`. + +- `src/Filter/Element/DcaSelectFieldFilterElement.php:184-198` + +## K-10: `DateRangeFilterElement`: `intrinsic`-Modus ist funktionslos — Minor (claude) + +`intrinsic` ist über die Basis-Palette wählbar (`contao/dca/tl_flare_filter.php:645`), aber es gibt keine intrinsischen from/to-Konfigwerte; `buildFilter()` erhält leere `$values` → keinerlei Bedingung. + +- `src/Filter/Element/DateRangeFilterElement.php:33-46,78-89` + +## K-11: BooleanFilterElement: Debug-Platzhalter „CBX" als Frontend-Label — Minor (claude) + +- `src/Filter/Element/BooleanFilterElement.php:56` (`'label' => $context->config['label'] ?? 'CBX'`) + +## K-12: PaginatorFactory: `getTotalItems()` kann `null` liefern → TypeError — Minor (claude) + +`PaginatorConfig::getTotalItems(): ?int` liefert `null` bei Default `-1`; `Paginator::__construct(int $totalItems)` ist nicht nullable — jeder API-Konsument mit Default-Config crasht. + +- `src/Paginator/Factory/PaginatorFactory.php:39` · `src/Paginator/PaginatorConfig.php:57-60` · `src/Paginator/Paginator.php:17-21` + +## K-13: `PaginatorConfig::getCurrentPageItemCount`: Off-by-one — Minor (beide Audits) + +`getLastItemNumber() - getFirstItemNumber()` ohne `+1` — Seite mit Items 1–10 meldet 9. + +- `src/Paginator/PaginatorConfig.php:158-161` + +## K-14: `TableAliasRegistry`: aktivierter, aber nicht registrierter Alias wird still übersprungen — Minor (codex) + +`resolveActiveJoins()` überspringt aktivierte Aliasse ohne registrierten Join kommentarlos, während `ConditionsModifierListener` die zugehörige Filterbedingung trotzdem anhängt — die Query referenziert dann einen nicht existierenden SQL-Alias (SQL-Fehler statt klarer Exception). + +- `src/Query/TableAliasRegistry.php:150-152` · `src/EventListener/QueryStructModifier/ConditionsModifierListener.php:30-37` + +## K-15: `FilterQueryBuilder`: Parameter-Prefixer ersetzt Tokens auch in String-Literalen — Minor, theoretisch (claude) + +`build()` schreibt `:name`-Tokens per Regex über den gesamten SQL-String um, ohne String-Literale (z. B. gequotete REGEXP-Muster aus `SqlHelper`) auszunehmen. Nur ausgelöst, wenn ein gleichnamiger Parameter existiert — fragil, derzeit kaum erreichbar. + +- `src/Query/FilterQueryBuilder.php:262-281` + +## K-16: Expliziter `pageParam` gleich dem Formularnamen wird ungefragt suffigiert — Minor (claude, teilweise entschärft) + +Ein explizit konfigurierter `pageParam`, der dem Formularnamen entspricht, wird kommentarlos mit `_page` suffigiert. (Der Teilaspekt „Vergleich läuft vor der Sanitisierung" ist behoben.) + +- `src/Engine/Projector/InteractiveProjector.php:194-197` + +## Beobachtungen (kein unmittelbarer Fix, aber weiterhin zutreffend) + +- **`which_ptable` wird transformiert, aber nie gelesen** (claude O1): Runtime-Inferenz basiert auf der Listen-Config, `buildDca()` auf dem Filter-Model. — `src/Filter/Element/BelongsToRelationFilterElement.php:55` (gesetzt) vs. `:63-104` (ungelesen) +- **`genericPageMeta` im Schema definiert, aber in `transform()` nicht gemappt** (claude O2). — `src/List/BaseListOptions.php:41` vs. `:44-64` +- **`PtableInferrer`: `explode('.', $foreignKey)` ohne Limit/Guard** (claude O4) — foreignKey ohne Punkt erzeugt „Undefined array key 1". — `src/InferPtable/PtableInferrer.php:180` + +## Querverweise (in anderen Kapiteln behandelt) + +- DBAL-Constraint erlaubt inkompatibles 2.13/3.0–3.5 (codex STAB-01): PS-03 in [40-performance-stabilitaet.md](40-performance-stabilitaet.md) +- Entry-Cache positional statt per ID (codex STAB-02): PS-02 ebd. +- Laufzeitfehler erst im Twig-Rendern, 200-vs-500-Inkonsistenz (codex STAB-03): PS-01/PS-05 ebd. +- Backend-Vorschau dereferenziert gelöschte Liste (codex STAB-04): PS-04 ebd. / A-15 in [10-architektur.md](10-architektur.md) +- Alias-Kollision im Collector/Builder (beide, N8): A-07 in [10-architektur.md](10-architektur.md); zusätzlich betroffen: `src/List/ListSpecBuilder.php:77-78` +- `AggregationLoader::fetchCount()` ohne int-Cast (beide, N10): PS-12 ebd. +- `ValidationLoader` liefert `[]` statt `null` (beide, N13): A-10 in [10-architektur.md](10-architektur.md) +- Terminal42: tote Klassen (claude O3): A-01 in [10-architektur.md](10-architektur.md) diff --git a/.audit/260719012-combined/30-sicherheit.md b/.audit/260719012-combined/30-sicherheit.md new file mode 100644 index 00000000..c1710dac --- /dev/null +++ b/.audit/260719012-combined/30-sicherheit.md @@ -0,0 +1,33 @@ +# Security & Query-Safety + +Kombinierte, am Stand `5940ad6` (2026-07-20) verifizierte Findings aus beiden Audits (claude 2607171801, codex 2607171755). Bereits behobene Punkte (u. a. der `?_preview`-Feld-Dump und die fehlende `model_table`-Verifikation beim Unmarshal) sind nicht mehr enthalten. Positive Beobachtungen: siehe [99-positive-punkte.md](99-positive-punkte.md). + +## SEC-01: Contao-Model-Registry kann Reader-/Listenfilter teilweise umgehen — Niedrig (beide Audits, teilweise entschärft) + +`fetchModel()` bedient sich zuerst aus Contaos globaler Model-Registry, bevor die durch FLARE-Filter abgesicherte Query läuft. Seit dem Audit wurde eine Prüfung des `published`-Flags ergänzt (`src/Engine/View/HandlesModelsTrait.php:24-28`), die den ursprünglichen Kernfall abfängt. **Nicht abgedeckt bleiben:** die `start`/`stop`-Zeitfenster, die der `PublishedFilterType` in der Query erzwingt (`src/Filter/Type/PublishedFilterType.php:32-45`), sowie sämtliche anderen konfigurierten Filterbedingungen — ein im selben Request ungefiltert gecachtes Modell mit `published=1`, aber abgelaufenem `stop`-Datum (oder außerhalb anderer Filterkriterien) wird über den Registry-Shortcut ausgeliefert, ohne dass die gefilterte Query je läuft. + +Beleg: `src/Engine/View/HandlesModelsTrait.php:20-29` + +## SEC-02: Generischer List-Driver ohne Published-Filter — „secure by default" fehlt — Info (beide Audits, teilweise entschärft) + +`NewsListDriver` und `EventsListDriver` fügen automatisch einen intrinsischen `PublishedFilterElement` hinzu (`src/List/Driver/NewsListDriver.php:51-58`, `src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php:64-69`); der `GenericDataContainerListDriver` nicht — eine generische Liste ohne konfigurierten Published-Filter liefert unpublizierte Datensätze an anonyme Besucher aus. Die empfohlene Backend-Warnung wurde inzwischen implementiert (`checkPublishedFilter()`, `src/List/Driver/GenericDataContainerListDriver.php:98-114`), hat aber eine Lücke: Sie läuft nur im `hasParent`-Zweig — für Listen **ohne** Parent greift der Early-Return in `buildDca()` (`src/List/Driver/GenericDataContainerListDriver.php:51-54`) vor dem Aufruf in Zeile 93, dort erscheint keine Warnung. Zudem ist es nur `Message::addInfo`, keine Warnung. + +## SEC-03: Unescapte Ausgabe in Backend-Vorschau-Responses — Niedrig [BE-Admin] (beide Audits, teilweise entschärft) + +Teilfixes seit dem Audit: ListView schleust `title`, Typ-Übersetzung und `dc` durch `strip_tags()` (`src/Controller/ContentElement/ListViewController.php:166-168`); der Headline-Tag-Name wird gegen eine Whitelist geprüft (`src/Util/Str.php:236-242`). **Weiterhin offen:** + +- Der Headline-**Wert** wird in beiden Controllern unescaped in HTML interpoliert (`src/Controller/ContentElement/ListViewController.php:165`, `src/Controller/ContentElement/ReaderController.php:231` — `Str::formatHeadline()` escapet den Text nicht). +- Der `ReaderController` gibt `$listModel->title` und `$listModel->dc` komplett roh aus, ohne `strip_tags`/Escaping (`src/Controller/ContentElement/ReaderController.php:229-235`). +- Beide `catch`-Blöcke geben rohe Exception-Messages als Response aus (`src/Controller/ContentElement/ListViewController.php:160`, `src/Controller/ContentElement/ReaderController.php:226`). + +## SEC-04: Zentrale Query-Struktur validiert SQL-Identifier nicht — Niedrig (codex) + +`ListExecutionContextFactory::create()` setzt `ListSpec::$dc` ungeprüft als `FROM` (`src/Query/Factory/ListExecutionContextFactory.php:29-44`). `SqlQueryStruct` nimmt Select-/Join-/Group-/Order-/Having-Fragmente als rohe Strings entgegen (`src/Query/SqlQueryStruct.php:55-146`); die Validator-Constraints prüfen nur `NotNull`/`NotBlank`/`Count`, keine Identifier-Form. `QueryBuilderFactory::create()` reicht alle Fragmente ungequotet an den DBAL-QueryBuilder durch (`src/Query/Factory/QueryBuilderFactory.php:30-64`). Die `Str::isValidSqlName()`-Prüfung der Tabelle läuft nur pro Filter in `FilterExecutor::invokeFilter()` (`src/Query/Executor/FilterExecutor.php:76-82`) — bei einer Liste ohne Filter gar nicht. Kein anonymer Angriffspfad (Driver/Events sind Erweiterungscode), aber die Factory erzwingt ihre eigenen Invarianten nicht — Defense in Depth gegen fehlerhafte Driver/Events/Redakteursdaten fehlt. + +## SEC-05: `composer audit || true` im Security-Workflow — Niedrig, Prozess (codex) + +Ein zukünftiges Advisory kann den CI-Job nie fehlschlagen lassen. Beleg: `.github/workflows/security.yaml:56`. (Siehe auch CI-04 in [50-tests-ci.md](50-tests-ci.md).) + +## SEC-06: Keine Testabdeckung der Sicherheits-Leitplanken in `src/Query/` — Info (claude) + +`tests/` enthält keinerlei Tests für `src/Query/`. Regressionen an `FilterQueryBuilder::column()`/`setParameter()` blieben still und wären sicherheitsrelevant. (Siehe auch T-01 in [50-tests-ci.md](50-tests-ci.md).) diff --git a/.audit/260719012-combined/40-performance-stabilitaet.md b/.audit/260719012-combined/40-performance-stabilitaet.md new file mode 100644 index 00000000..edeea700 --- /dev/null +++ b/.audit/260719012-combined/40-performance-stabilitaet.md @@ -0,0 +1,150 @@ +# Performance & Stabilität + +Kombinierte, am Stand `5940ad6` (2026-07-20) verifizierte Findings aus beiden Audits (claude 2607171801, codex 2607171755). Keines der Findings dieses Kapitels wurde seit dem Audit-Datum behoben. Positive Beobachtungen: siehe [99-positive-punkte.md](99-positive-punkte.md). + +## Stabilität + +### PS-01: Frontend-500 statt Degradierung — Query-/Filterfehler schlagen erst beim Twig-Rendern zu — Major (claude) + +Der Graceful-Catch umschließt nur Spec-/Engine-Bau; `createView()` (Count + Entries + Formular) läuft erst im Template. Eine `FilterException` zur Laufzeit propagiert als Twig-`RuntimeError`; der Render-Catch rethrowt alles außer eingebetteter `ResponseException` — die ganze Seite wird zum 500er. Kaputte Filter-Konfiguration eines einzelnen Elements darf nicht die Seite reißen: `createView()` in den Controller ziehen bzw. `FlareException` beim Rendern abfangen. (`AbortFilteringException` ist dagegen sauber gelöst — leere Liste.) + +- `src/Controller/ContentElement/ListViewController.php:95-116` (Catch nur um Bau), `:131` (Engine ans Template), `:136-151` (Render-Catch rethrowt) · `contao/templates/content_element/flare_listview.html.twig:7` · sauber: `src/Query/Executor/ListQueryDirector.php:60-67` + +### PS-02: Latenter Korrektheitsbug: Entry-Cache positional statt per ID indiziert — Major (claude) + +`ValidationLoader::fetchEntryById()` greift per `getEntryCache()[$id]` zu; die Cache-Closure aus `createFromInteractiveView()` liefert aber `InteractiveView::getEntries()` = rohes, positionsindiziertes `fetchAllAssociative()`-Resultat. Der Lookup trifft den Datensatz an *Position* `$id` — falscher Entry oder wirkungsloser Cache. `createFromInteractiveView()` ist öffentliche API. + +- `src/Engine/Loader/ValidationLoader.php:29` · `src/Engine/Context/Factory/ValidationContextFactory.php:45-51` · `src/Engine/Loader/InteractiveLoader.php:40` · `src/Engine/View/InteractiveView.php:54-57` + +### PS-03: DBAL-Constraint erlaubt Versionen, mit denen der Code fatal scheitert — Major (claude) + +`composer.json:12` erlaubt `doctrine/dbal ^2.13 || ^3.0 || ^4.0`; der Code nutzt `Doctrine\DBAL\ArrayParameterType` (erst ab DBAL 3.6) und `executeQuery()` (ab 3.1). Contao 4.13 kann DBAL 3.3–3.5 auflösen → „Class not found" zur Laufzeit. **Fix ist eine Zeile: `^3.6 || ^4.0`.** + +- `composer.json:12` · `src/Query/FilterQueryBuilder.php:7,123,160,202,206` + +### PS-04: Backend-Vorschau crasht mit TypeError bei gelöschter Liste — Major (claude) + +Siehe A-15 in [10-architektur.md](10-architektur.md): beide Backend-Responses dereferenzieren `$listModel` ohne Null-Guard außerhalb jedes try/catch (`src/Controller/ContentElement/ListViewController.php:154-170`, `src/Controller/ContentElement/ReaderController.php:220-236`). + +### PS-05: Fehlerpfade uneinheitlich: Reader wirft 500, Listview antwortet cachebare 200 — Medium (claude) + +`ReaderController` liefert für Fehler Status 500 bzw. `InternalServerErrorHttpException` (`src/Controller/ContentElement/ReaderController.php:74-82,149-155`); `ListViewController::getErrorResponse()` gibt für denselben Fehlertyp eine 200er-Response mit Fehlertext zurück — ohne `Cache-Control: no-store` (`src/Controller/ContentElement/ListViewController.php:63-71`). + +### PS-06: Stiller Schlucker: ungültige `sortSettings` werden lautlos zu null — Medium (claude) + +`SortOrderSequenceFactory::createFromList()` fängt die `FlareException` aus `createFromSettings()` und gibt kommentarlos null zurück — Liste rendert unsortiert, keine Logzeile. + +- `src/Sort/Factory/SortOrderSequenceFactory.php:21-28` + +### PS-07: Zustand in Shared Services: Caches wachsen prozessweit, kein `ResetInterface` — Medium (beide Audits) + +Ohne Invalidierung/Reset: `ArchiveFilterElement::$_inferrer` (gekeyt per `ListSpec::hash()`, wächst unbegrenzt), `FieldValueChoiceFilterElement::$foreignValueCache`/`$localValueCache` (stale Choices unter Worker-Runtimes), `CfgTagsJoinsRegistry::$entries` (akkumuliert), `DcaHelper` mit `static $dcTableCache`. `ResetInterface`/`kernel.reset` kommt in `src/` und `config/` nicht vor. + +- `src/Filter/Element/ArchiveFilterElement.php:34,399-404` · `src/Filter/Element/FieldValueChoiceFilterElement.php:31-32,263,300` · `src/Integration/CodefogTags/Registry/CfgTagsJoinsRegistry.php:14-18` · `src/Util/DcaHelper.php:64-77` + +### PS-08: Fehlendes Filter-Element wird auch bei intrinsischen Sicherheitsfiltern kommentarlos geskippt — Minor, sicherheitsrelevant (claude) + +Wirft `FilterFactory::createFromFilterModel()` (Element-Typ nicht registriert, Extension deinstalliert), loggt der Collector nur ein Warning und macht `continue` — auch für intrinsische Sicherheitsfilter wie `flare_published` → Liste zeigt ggf. Unveröffentlichtes (Sichtbarkeits-Leak). + +- `src/List/Collector/ListModelFilterCollector.php:56-71` · `src/Filter/Factory/FilterFactory.php:104-107` + +### PS-09: `PublishedFilterElement` ignoriert den Contao-Preview-Modus — Minor (claude) + +Immer `published`-/`start`-/`stop`-Bedingung mit `'now' => time()`, ohne `TokenChecker::isPreviewMode()`-Bypass (`TokenChecker` kommt in `src/` nicht vor) — unveröffentlichte Einträge sind in der offiziellen Frontend-Vorschau unsichtbar. + +- `src/Filter/Element/PublishedFilterElement.php:53-64` + +### PS-10: HTTP-Cache vs. zeitabhängige Filter: nur Tabellen-Tags — Minor (claude) + +Invalidierung ausschließlich über `contao.db.`-Tags; ein rein zeitgesteuerter `start`/`stop`-Wechsel invalidiert nichts. + +- `src/Controller/ContentElement/ListViewController.php:118` · `src/Filter/Element/PublishedFilterElement.php:62` + +### PS-11: MariaDB + `ONLY_FULL_GROUP_BY`: `SELECT main.* … GROUP BY main.id` — Minor (claude) + +MariaDB erkennt die funktionale Abhängigkeit vom PK nicht → Fehler 1055 bei aktivem `ONLY_FULL_GROUP_BY`. (Der Count-Pfad ist unbetroffen, da `SelectModifierListener` das GROUP BY entfernt.) + +- `src/Query/Factory/ListExecutionContextFactory.php:40-44` + +### PS-12: `AggregationLoader::fetchCount()` ohne int-Cast — Info (claude) + +`$count = $result->fetchOne() ?: 0;` direkt aus einer `int`-typisierten Methode returnt — liefert der Treiber (Emulation + stringify) einen String, gibt es unter `strict_types` einen TypeError. + +- `src/Engine/Loader/AggregationLoader.php:40-44` + +### PS-13: `FlareCollector::getSemVersion()` crasht bei null-Version — Info (claude) + +`data['version']` kommt aus `InstalledVersions::getVersion()` (kann null sein); `getSemVersion()` ruft `\explode('-', $this->data['version'])` ohne Guard — TypeError (nur mit aktivem Profiler relevant). + +- `src/DataCollector/FlareCollector.php:19,38-44` + +## Performance + +### PS-14: Query-/Filter-Pipeline läuft pro Request doppelt (Count + Daten) — Major (beide Audits) + +`InteractiveProjector::project()` erzeugt zuerst die AggregationView für den Count und danach den InteractiveLoader — beide Pfade laufen über `ListQueryDirector::createQueryBuilder()` und führen `FilterExecutor::invokeFilters()` komplett erneut aus, inkl. `FilterContextFactory::create()` mit OptionsResolver-`resolve()` pro Filter und Event-Dispatches. Filter-Elemente mit DB-Zugriff in `buildFilter()` zahlen doppelt; `ArchiveFilterElement` macht `findMultipleByIds`-Fetches zusätzlich ein drittes Mal in `buildForm()` (nur der `PtableInferrer` ist memoiert, `fetchParents()` nicht). Empfehlung: Filterquery-Fragmente request-scoped zwischen Count und Datenquery teilen. + +- `src/Engine/Projector/InteractiveProjector.php:50,61-68` · `src/Query/Executor/ListQueryDirector.php:48` · `src/Query/Executor/FilterExecutor.php:49-62` · `src/Filter/Element/ArchiveFilterElement.php:118,282-314,584-598` + +### PS-15: Calendar-Integration lädt die komplette Ergebnismenge unpaginiert — zweimal — Major/Hoch (beide Audits) + +`EventsInteractiveLoader` setzt `ContaoCalendar_doNotPaginate` (Listener entfernt LIMIT/OFFSET), holt alle Zeilen per `fetchAllAssociative()`, expandiert via `groupEntriesByDate()` und paginiert erst in PHP. `EventsAggregationLoader::fetchCount()` macht denselben Full-Fetch samt kompletter Expansion separat noch einmal. Zwei Full-Fetches + zwei Recurrence-Expansionen pro Request. Empfehlung: SQL-seitiges Zeitfenster. + +- `src/Integration/ContaoCalendar/Loader/EventsInteractiveLoader.php:31-48,50-86` · `src/Integration/ContaoCalendar/EventListener/DoNotPaginateModifierListener.php:15-19` · `src/Integration/ContaoCalendar/Loader/EventsAggregationLoader.php:30-58` + +### PS-16: Unbegrenzte Recurrence-Expansion (OOM-/CPU-Risiko) — Major/Hoch (beide Audits) + +`fillRecurringEvents()` läuft `while ($repeatDate <= $repeatEnd)` ohne Obergrenze; `fillInitialEvents()` legt per `DatePeriod` einen Eintrag pro Tag der gesamten Event-Dauer an. Ein minütlich wiederholtes Event mit fernem `repeatEnd` erzeugt Hunderttausende Array-Einträge pro Event — im Frontend-Request, auch im Count-Pfad. Redakteurs-Fehleingabe genügt für OOM. Empfehlung: harte Occurrence-Limits. + +- `src/Integration/ContaoCalendar/GroupsEntriesTrait.php:132-137,67-71` + +### PS-17: Partial-Templates triggern jeweils die volle Pipeline — Medium (beide Audits) + +Alle drei Partials setzen selbst `{% set flare_list = flare.createView %}`; `Engine::createView()` memoiert nichts. Formular/Liste/Paginator als drei Content-Elemente derselben Liste → 3× Spec-Bau, Formular-Bau, Count- und ggf. Entries-Query. + +- `contao/templates/content_element/flare_listview/form_only.html.twig:3`, `list_only.html.twig:3`, `paginator_only.html.twig:3` · `src/Engine/Engine.php:44-61` + +### PS-18: Count-Query läuft auch bei ungültig submittetem Formular; View meldet inkonsistenten Count — Minor (beide Audits) + +Siehe A-05 in [10-architektur.md](10-architektur.md): `$totalItems` wird vor der Validitätsprüfung berechnet und trotz `InteractiveEmptyLoader` unverändert an die View gereicht — `InteractiveView::getCount()` kann n > 0 bei leerer Liste melden (`src/Engine/Projector/InteractiveProjector.php:50,56-58,74-81`, `src/Engine/View/InteractiveView.php:49-52`). + +### PS-19: `ChoicesBuilder`: O(n²)-Wertauflösung via `array_search` — Minor (beide Audits) + +`buildChoiceValueCallback()` macht pro Choice ein lineares `array_search($choice, $this->choices, true)` — quadratisch beim Rendern großer Choice-Mengen; keine Reverse-Map. + +- `src/Form/ChoicesBuilder.php:251-266` + +### PS-20: `FieldValueChoiceFilterElement`: unbegrenzte DISTINCT-/Fremdtabellen-Scans — Minor/Mittel (beide Audits) + +`getLocalValues()` macht `SELECT DISTINCT CAST(… AS CHAR) … ORDER BY` ohne LIMIT über die ganze Tabelle; `getForeignValues()` lädt die komplette Fremdtabelle (`fetchAllKeyValue` mit `CONCAT`-Label, kein LIMIT). Ergebnis wird ungebremst zu Form-Choices; keine Begrenzung/Suche/Ajax-Pfad. + +- `src/Filter/Element/FieldValueChoiceFilterElement.php:298-325,261-295` + +### PS-21: DCA-`options_callback` läuft pro Request bis zu dreimal — Mittel (codex) + +`DcaSelectFieldFilterElement::getOptions()` (→ beliebige Contao-Callbacks) wird beim Formularbau und erneut beim Filterbau benötigt; da der Filterbau für Count und Daten doppelt läuft (PS-14), laufen Callbacks mit DB-Zugriff bis zu dreimal. Kein Request-Cache pro Tabelle/Feld. + +- `src/Filter/Element/DcaSelectFieldFilterElement.php:76,101,123,308` + +### PS-22: Suchfilter: unverankertes `LIKE '%…%'`, Term-Anzahl unbegrenzt — Info (beide Audits) + +Pro Term × Spalte ein nicht verankertes LIKE (kein Index nutzbar); Term-Anzahl aus User-Input unbegrenzt (nur Stopwords/Deduplizierung). Positiv: `makeTerms()` entfernt Wildcards (`%`, `_`) zuverlässig. + +- `src/Filter/Type/SearchKeywordsFilterType.php:37-47,55-64` + +### PS-23: Eager-Instanziierung aller Filter-Elemente über die Registry — Info (claude) + +Der Compiler-Pass injiziert echte Service-Referenzen per `addMethodCall('add', …)` — beim Instanziieren der `FilterElementRegistry` werden alle Elemente eager gebaut. Derzeit verschmerzbar; bei wachsendem Ökosystem auf ServiceLocator/lazy umstellen. + +- `src/DependencyInjection/Compiler/RegisterFilterElementsPass.php:39-48` + +### PS-24: `ListSpec::hash()` serialisiert die vollständige Config — Info (codex) + +`sha1(serialize([...]))` über Driver-Klasse, Typ, dc, source, komplette Config und alle Filter-Fingerprints — bei großen dynamischen Config-Arrays potenziell teuer; für typische Listen unkritisch. + +- `src/List/ListSpec.php:113-123` + +## Querverweise + +- Terminal42-Integration (toter Code): siehe A-01 in [10-architektur.md](10-architektur.md). +- `#[TaggedIterator]`-Deprecation: siehe A-13 in [10-architektur.md](10-architektur.md). diff --git a/.audit/260719012-combined/50-tests-ci.md b/.audit/260719012-combined/50-tests-ci.md new file mode 100644 index 00000000..a5a6010c --- /dev/null +++ b/.audit/260719012-combined/50-tests-ci.md @@ -0,0 +1,81 @@ +# Tests, CI & Tooling + +Kombinierte, am Stand `5940ad6` (2026-07-20) verifizierte Findings aus beiden Audits (claude 2607171801, codex 2607171755). Alle hier gelisteten Punkte wurden gegen den aktuellen Code geprüft und bestehen fort. Positive Beobachtungen: siehe [99-positive-punkte.md](99-positive-punkte.md). + +## T-01: Risikoreichste Subsysteme ohne jegliche Tests — Major (beide Audits) + +Die Testsuite besteht aus 22 Dateien (21 Testklassen + 1 Stub). Weiterhin vollständig ungetestet: + +- `src/Query/` — insbesondere `src/Query/FilterQueryBuilder.php` (SQL-Injection-Leitplanke, Identifier-Whitelist, Parameterbindung) und `src/Query/TableAliasRegistry.php` (rekursive JOIN-Auflösung), außerdem `src/Query/Executor/ListQueryDirector.php`, `src/Query/Executor/FilterExecutor.php` +- `src/Filter/Type/` — 0 von 11 konkreten Filter-Types getestet, obwohl namensgebendes Feature des Branches +- Filter-Elemente: nur 2 von 10 konkreten Elementen getestet (`ArchiveFilterElement`, `SimpleEquationFilterElement`); `BooleanFilterElement`, `DateRangeFilterElement`, `PublishedFilterElement`, `SearchKeywordsFilterElement` etc. ungetestet +- Engine-Pipeline (Contexts, Loader, Mods, Views, `EngineFactory`) — nur `tests/Engine/Projector/InteractiveProjectorTest.php` existiert +- `src/EventListener/QueryStructModifier/`, `src/Paginator/Paginator.php`, `src/Form/ChoicesBuilder.php` — 0 Tests +- `src/Util/`, `src/Reader/` (inkl. Marshal-Logik in `src/Reader/ReaderRequestAttribute.php`), `src/InferPtable/`, `src/Controller/`, `src/Sort/`, `src/DataContainer/`, `src/Twig/`, `src/Integration/` (alle), `src/DataCollector/`, `src/DependencyInjection/` +- List-Driver: `src/List/Driver/NewsListDriver.php`, `src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php` + +Alles überwiegend pure PHP-Logik und gut unit-testbar. Regressionstests für die validen Korrektheits-Findings (siehe [20-korrektheit.md](20-korrektheit.md)) sollten gleich mitgenommen werden. + +## T-02: Stub-Klassen nicht autoloadbar — Einzeldatei-Testläufe brechen — Minor/Mittel (beide Audits) + +`FilterModelStub` ist in `tests/Filter/Element/SimpleEquationFilterElementTest.php:82` definiert, wird aber in `tests/Filter/Element/ArchiveFilterElementTest.php:95` benutzt; `ListModelStub` ist in `tests/List/BaseListOptionsTest.php:76` definiert, wird in `tests/List/ListSpecBuilderTest.php:106` und `:165` benutzt. Dateiname ≠ Klassenname → PSR-4-autoload-dev kann sie nicht auflösen; isolierte Einzeldatei-Läufe und randomisierte Reihenfolge sind fragil. Vorlage für den Fix existiert bereits: `tests/List/StubFilterElement.php` (eigene Datei). + +## T-03: `phpunit.xml.dist` ohne `executionOrder="random"` / `beStrictAboutOutputDuringTests` — Minor (beide Audits) + +`phpunit.xml.dist:2-8` enthält nur `failOnRisky`/`failOnWarning`; keine Random-Order, kein `resolveDependencies`, kein `beStrictAboutOutputDuringTests`. Random-Order würde das Stub-Problem (T-02) sofort aufdecken. + +## T-04: `symfony/phpunit-bridge` in require-dev, aber nicht im Bootstrap — Minor (claude) + +`phpunit.xml.dist:4` bootstrapt plain `vendor/autoload.php`; `composer.json:39` deklariert `symfony/phpunit-bridge` — kein Deprecation-Tracking. + +## T-05: Coverage konfiguriert, aber nirgends erzeugt — Info (claude) + +`phpunit.xml.dist:19-26` definiert den Coverage-Filter, aber alle Workflows setzen `coverage: none`; kein Coveralls-Upload trotz `php-coveralls` in require-dev. + +## T-06: Keine DataProvider in der Suite — Info (claude) + +0 Treffer für `dataProvider`/`DataProvider` in `tests/`. Geschmackssache, bei Transformer-/Boolean-/Choice-Tests aber deutlich kompakter. + +## CI-01: PHPUnit läuft nur auf PHP 8.2 mit Highest-Deps — keine Runtime-Matrix — Major (beide Audits) + +`.github/workflows/phpunit.yaml:24` pinnt `php-version: '8.2'`; `composer update` (`:41`) installiert Highest-Deps; kein `--prefer-lowest`, kein Contao-4.13-Lauf, keine Matrix — obwohl `composer.json:7,10` PHP ^8.2 × Contao ^4.13||^5.0 verspricht. Die Compatibility-Matrix (`.github/workflows/compatibility.yaml:20-26`) prüft nur `composer update --dry-run` (`:50-52`), nie Verhalten. + +## CI-02: Compatibility-Matrix durch `continue-on-error: true` entwertet — Major (beide Audits) + +`.github/workflows/compatibility.yaml:16` setzt `continue-on-error: true` auf Job-Ebene — jede rote Matrix-Zelle wird grün durchgewunken. Ausgerechnet der einzige Workflow mit `pull_request`-Trigger (`:4`) ist damit dekorativ. + +## CI-03: Kein `pull_request`-Trigger auf PHPUnit/PHPStan/Mago — Fork-PRs ungeprüft — Minor (beide Audits) + +`.github/workflows/phpunit.yaml:3-11`, `.github/workflows/phpstan.yaml:3-11` und `.github/workflows/mago.yaml:3-11` triggern nur auf `push` + `workflow_dispatch`. Fork-PRs laufen ohne Tests und Statik. + +## CI-04: `composer audit || true` kann nie fehlschlagen — Minor/Mittel (beide Audits) + +`.github/workflows/security.yaml:56` enthält `composer audit || true` — Advisories werden nie zum Gate. (Semgrep failt dagegen korrekt via `--error`, `security.yaml:68`.) + +## CI-05: Mago lintet die Tests auf dem Branch nicht mehr — Niedrig (codex) + +Branch-Regression: `mago.toml:6` enthält nur noch `paths = ["src/"]`; auf `main` steht `paths = ["src/", "tests/"]`. Die neue Testsuite wird nicht gelintet/formatiert. + +## ST-01: PHPStan-Ignores zu breit — Minor/Niedrig (beide Audits) + +`phpstan.neon:25` und `:29` ignorieren `Access to an undefined property Contao\…Model::$…` bzw. undefined static methods repo-weit ohne `path`-Eingrenzung — echte Tippfehler in `src/` werden verschluckt. `phpstan.neon:19-20` ignoriert `class.notFound` für ganz `src/Integration/` (auch hausgemachte Klassen in ContaoCalendar/ContaoNews/ContaoComments); `phpstan.neon:13` schließt `src/Integration/Terminal42Languages` komplett aus. + +## ST-02: `phpVersion: 80200` — PHPStan sieht keine 8.4/8.5-Deprecations — Info (claude) + +`phpstan.neon:7`; teilkompensiert durch Magos Multi-Version-Lint (`mago.yaml:57-75`). + +## D-01: Tote Dev-Dependencies — Minor (claude) + +Per Grep über `tests/`, `src/`, `.github/` verifiziert (0 Treffer): `contao/test-case` (`composer.json:33`), `heimrichhannot/contao-test-utilities-bundle` (`:35`), `php-coveralls/php-coveralls` (`:37`, kein Coverage-Workflow) und `symfony/phpunit-bridge` (`:39`, nicht im Bootstrap) werden nirgends benutzt. + +## D-02: PHPUnit-Constraint `^8.0 || ^9.0` — `^8`-Standbein stale — Minor (claude) + +`composer.json:36`; `phpunit.xml.dist:3` nutzt das 9.5-Schema, AGENTS.md dokumentiert PHPUnit 9. + +## D-03: CSRF-Komponente in Tests nur transitiv deklariert — Info (claude) + +`tests/Form/FilterFormFactoryTest.php:29` importiert `Symfony\Component\Security\Csrf\CsrfTokenManager`; `symfony/security-csrf` fehlt in `composer.json` (kommt nur transitiv über `contao/core-bundle`). + +## M-01: Makefile-`.PHONY` unvollständig; Catch-all schluckt Tippfehler — Minor (claude) + +`Makefile:1` listet `phpstan`/`phpstan-pro` (`Makefile:17-21`) nicht in `.PHONY`. Catch-all `%: @:` (`Makefile:52-53`) beendet Tippfehler lautlos mit Exit 0. diff --git a/.audit/260719012-combined/60-contao-integration-doku.md b/.audit/260719012-combined/60-contao-integration-doku.md new file mode 100644 index 00000000..f2e7171f --- /dev/null +++ b/.audit/260719012-combined/60-contao-integration-doku.md @@ -0,0 +1,111 @@ +# Contao-Integration, Public API, Doku & Kompatibilität + +Kombinierte, am Stand `5940ad6` (2026-07-20) verifizierte Findings aus beiden Audits (claude 2607171801, codex 2607171755). Bereits behobene Punkte (u. a. `mergePalettes`-No-Op, `huh.flare.list_type`-Alt-Tags, Attribut-Fallback für feste Driver-Tabellen, Terminal42-Doku-Markierung) sind nicht mehr enthalten. Positive Beobachtungen: siehe [99-positive-punkte.md](99-positive-punkte.md). + +## C-01: Doku-Beispiele erzeugen Fatal Error: `DcaBuilder` statt `DcaBuilderInterface` — Major (beide Audits) + +Alle `buildDca()`-Beispiele typisieren den Parameter als konkrete Klasse; `DcaContract` verlangt das Interface (`src/Contract/DcaContract.php:18`) → Kontravarianz-Verletzung, Fatal Error beim Copy-Paste. + +- `docs/docs/dev/dca-builder.md:15,77` · `docs/docs/dev/contracts/dca-contract.md:10,30` · `docs/docs/dev/filter-elements/index.md:125,226` · `docs/docs/dev/list-types/index.md:200` · `docs/docs/migrating-from-v0.1.md:140` + +## C-02: Intrinsic-Handling ist Element-Verantwortung — Drittanbieterfalle — Major (beide Audits, teilentschärft) + +Die Form-Factory filtert intrinsische Filter nicht zentral (`src/Filter/Factory/FilterFormFactory.php:60-121`); `AbstractFilterElement::buildForm()` ist No-Op-Default (`src/Filter/Element/AbstractFilterElement.php:51`). Ein Dritt-Element ohne eigenen `$context->config['intrinsic']`-Check rendert Formfelder für intrinsische Filter im Frontend. Der Dev-Guide dokumentiert das Muster inzwischen inkl. Beispiel (`docs/docs/dev/filter-elements/index.md:145-146,256-262`) — der Interface-Docblock nennt das Pflichtmuster aber weiterhin nicht (`src/Filter/Element/FilterElementInterface.php:13-24`), und ein zentraler Guard fehlt. Zusammen mit dem stillen Skip fehlender intrinsischer Elemente (PS-08 in [40-performance-stabilitaet.md](40-performance-stabilitaet.md)) ein potentielles Sichtbarkeits-Leak. + +## C-03: Terminal42-/DcMultilingual-Integration halb verdrahtet — tote DB-Felder — Major (beide Audits, teilentschärft) + +Code-Seite siehe A-01 in [10-architektur.md](10-architektur.md). Zusätzlich auf DCA-Seite: `tl_content.flare_dcMultilingualDisplay` definiert (`contao/dca/tl_content.php:76-85`), in keiner Palette (`:92-99`); `tl_flare_list.dcMultilingual_display` definiert (`contao/dca/tl_flare_list.php:288-297`), in keiner Palette (`:317-323`); Label für `flare_generic_dc_multilingual` fehlt in `translations/flare_list.{de,en}.php`. Es entstehen SQL-Spalten, die kein Redakteur sieht. Doku/README markieren die Integration inzwischen korrekt als disabled — die Entscheidung „aktivieren oder ausbauen" steht aus. + +## C-04: Boolean-Element: Backend-Select zeigt rohe Übersetzungs-Keys, Feld-Labels fehlen — Major (claude) + +`preselect`-Options nutzen die Keys `flare.bool_preselect.{null,true,false}`, die nirgends definiert sind (weder `translations/` noch `contao/languages/`); Contao übersetzt Options-Labels nicht automatisch. Zusätzlich fehlen Labels für `boolMode`/`boolBinaryChoices` in beiden Sprachdateien. + +- `src/Filter/Element/BooleanFilterElement.php:117-130` · Felder `contao/dca/tl_flare_filter.php:616,630` · keine Label-Einträge in `contao/languages/{de,en}/tl_flare_filter.php` + +## C-05: DBAL-2-Versprechen nicht erfüllt; Compatibility-CI toleriert alle Fehler — Mittel (codex) + +`composer.json:12` erlaubt `doctrine/dbal ^2.13`, der Code nutzt `Doctrine\DBAL\ArrayParameterType` (erst ab DBAL 3.6): `src/Query/FilterQueryBuilder.php:7,123,160,202,206`, `src/Filter/Type/ArchiveFilterType.php:7,29`, `src/Filter/Type/IntegerIdChoiceFilterType.php:7`. Die Compatibility-Matrix läuft mit `continue-on-error: true` und prüft nur `composer update --dry-run`. (Fix: PS-03 in [40-performance-stabilitaet.md](40-performance-stabilitaet.md); CI: CI-01/CI-02 in [50-tests-ci.md](50-tests-ci.md).) + +## C-06: Verhaltensänderungen fehlen in der Migrationsdoku: stiller Alias-Skip & Intrinsic-Verlagerung — Minor (beide Audits) + +Aliase, die kein gültiger Symfony-Formname sind, werden still nicht gemountet (`src/Filter/Factory/FilterFormFactory.php:62-64`); das ist nur als Code-Docblock erklärt (`src/Util/Str.php:110-119`), nicht in `docs/docs/migrating-from-v0.1.md`. Gleiches gilt für die Intrinsic-Verantwortungsverlagerung (C-02) — beide Verhaltensänderungen gegenüber `main` fehlen auf der Migrationsseite. + +## C-07: Übersetzungs-Domain-Mismatch bei Fehlermeldungen — Minor (claude) + +Beide Controller fragen `ERR.flare.listview.malconfigured` mit Domain `contao_modules` an; definiert ist der Key in der Default-Sprachdatei (Domain `contao_default`) — funktioniert nur, weil Contao diese global lädt. Der Reader nutzt zudem denselben „list view"-Text. (Statuscode-Inkonsistenz 200 vs. 500: PS-05 in [40-performance-stabilitaet.md](40-performance-stabilitaet.md).) + +- `src/Controller/ContentElement/ListViewController.php:69-70` · `src/Controller/ContentElement/ReaderController.php:80-81` · `contao/languages/{de,en}/default.php:73` + +## C-08: DateRange: Optionen ohne Backend-Repräsentation, Palette ohne Legende — Minor (claude, teilentschärft) + +`from_enabled`/`to_enabled` sind im Schema definiert (`src/Filter/Element/DateRangeFilterElement.php:37-38`), aber weder `transformFilterModel()` (`:41-46`) noch ein DCA-Feld setzt sie — nur programmatisch nutzbar. Palette ohne `{filter_legend}` (`:93`). (Inzwischen immerhin dokumentiert: `docs/docs/reference/filter-elements.md:13`. Funktionsloser `intrinsic`-Modus: K-10 in [20-korrektheit.md](20-korrektheit.md).) + +## C-09: Übersetzungen: DE/EN-Lücken, Waisen, Tippfehler — Minor (claude) + +- EN fehlt der Eintrag für `cfg_tags_search` (DE: `translations/flare_filter.de.php:19`). Abgeschwächt: das Element ist via `isSupported(): false` im Backend ausgeblendet. +- Verwaist: `useTablePtable` in `contao/languages/{de,en}/tl_flare_filter.php:25` (keine DCA-/Code-Referenz). +- Ungenutzt: `filter.limited_scope.*`, `filter.scope.*`, `filter.info.alias` in `translations/flare.{de,en}.yaml`; auch `filter.info.intrinsic.yes|no` scheinen ungenutzt. +- Tippfehler: „This filter **ist** not intrinsic" — `translations/flare.en.yaml:31`. + +## C-10: Literal-Key-Trick in `messages.{de,en}.yaml` unkommentiert — Info (claude) + +Die Keys `Listen (FLARE)`/`Listings (FLARE)` spiegeln die MOD-Labels (`contao/languages/de/modules.php:7`) und brechen still bei Label-Änderung; `src/EventListener/BackendMenuBuildListener.php:30-33` string-matcht zusätzlich am `(FLARE)`-Suffix. Ein erklärender Kommentar fehlt. + +- `translations/messages.de.yaml:1` · `translations/messages.en.yaml:1` + +## C-11: Stale Excludes in `services.yaml` — Minor (claude) + +`../src/{…,Dto,…,Trait,…}` wird exkludiert — beide Verzeichnisse existieren nicht. + +- `config/services.yaml:14` + +## C-12: Tote Klasse `DateRangeFormType` inkl. verwaister Validator-Keys — Minor (claude) + +Nur Selbstreferenzen; die einzig dort genutzten Keys `flare.form.date_range.from_invalid|to_invalid` (`src/Form/Type/DateRangeFormType.php:95,102`) stehen noch in `translations/validators.{de,en}.yaml`. + +- `src/Form/Type/DateRangeFormType.php:14` + +## C-13: `CodefogTagsSearchElement` als Stub im Auslieferungszustand — Info (claude) + +`isSupported(): false`, zwei `TODO`-Bodies, als einziges Element nicht auf `…FilterElement`-Suffix umbenannt. Doku markiert es als disabled (`docs/docs/reference/filter-elements.md:32`). + +- `src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php:16-38` + +## C-14: Dokumentierte Builder-API `getDc()` existiert nicht — Minor (beide Audits) + +Der Text bewirbt `getDc()` auf dem Builder; `ListSpecBuilder` (`src/List/ListSpecBuilder.php:39-122`) und das Interface besitzen keine solche Methode. Die dc-Auflösung passiert erst in `ListSpecFactory::resolveDataContainer()`. + +- `docs/docs/dev/list-types/index.md:141-144` + +## C-15: `field()` liefert laut Doku `DcaFieldBuilder`, Interface liefert `DcaFieldBuilderInterface` — Minor (claude) + +- `docs/docs/dev/dca-builder.md:39` vs. `src/DataContainer/Builder/DcaBuilderInterface.php:17` + +## C-16: AGENTS.md/CLAUDE.md verwendet alte Namen — Minor (beide Audits) + +`ListBuilderFactory`/`ListBuilder` (tatsächlich `ListSpecBuilderFactory`/`ListSpecBuilder`), `#[AsListType]` (tatsächlich `AsListDriver`), `ListTypeRegistry` (tatsächlich `ListDriverRegistry`). (Vollständige Liste inkl. `FilterElementResolver`/EngineFactory: A-02 in [10-architektur.md](10-architektur.md).) + +- `AGENTS.md:21,39,60,71` + +## C-17: Kleinere Schönheitsfehler — Info (claude) + +- Fallback-Label `'CBX'` erreicht ungefiltert das Frontend: `src/Filter/Element/BooleanFilterElement.php:56` (= K-11) +- `Message::addError(...)` hartkodiert Englisch (`src/Filter/Element/BooleanFilterElement.php:136`), während `src/List/Driver/GenericDataContainerListDriver.php:111` sauber den Translator nutzt +- Docblocks verweisen auf nicht existentes `configureDca()` (tatsächlich `buildDca`): `src/EventListener/Contao/ElementDcaListener.php:24`, `src/Event/ElementDcaEvent.php:12` +- Palette enthält `guests` — Feld existiert in Contao 5 nicht mehr: `contao/dca/tl_content.php:89` + +## C-18: Offene Doku-Wünsche — Niedrig (codex) + +- Skalierungsgrenzen für `FieldValueChoice` (DISTINCT-Werte) und Calendar nicht dokumentiert (`docs/docs/reference/filter-elements.md:12,16`) +- Suchverhalten (OR-Semantik, Stoppwörter, Sonderzeichen) nicht spezifiziert (`docs/docs/reference/filter-elements.md:19`) +- Expliziter Hinweis fehlt, dass der Generic-Driver keine Published-/Access-Filter ergänzt (`docs/docs/reference/list-types.md:11-13`); teilentschärft durch die neue Backend-Info-Meldung (`src/List/Driver/GenericDataContainerListDriver.php:97-113`, siehe SEC-02 in [30-sicherheit.md](30-sicherheit.md)) + +## C-19: DX-Reibungspunkte — Info (claude) + +- `AbstractFilterElement` erzwingt `transformFilterModel()` als abstract — rein programmatische Elemente müssen eine leere Methode implementieren (`src/Filter/Element/AbstractFilterElement.php:47`) +- Elemente ohne `DcaContract` erhalten kommentarlos die nackte Prefix/Suffix-Palette, kein Hinweis-Log (`src/EventListener/Contao/ElementDcaListener.php:96-104`) + +## Querverweise + +- Stop-Word-Feature tot (`huh_flare.search_stop_words.{locale}` existiert nie): K-07 in [20-korrektheit.md](20-korrektheit.md) +- Backend-Ansicht ohne Null-Guard auf `$listModel`: PS-04 in [40-performance-stabilitaet.md](40-performance-stabilitaet.md) / A-15 in [10-architektur.md](10-architektur.md) diff --git a/.audit/260719012-combined/99-positive-punkte.md b/.audit/260719012-combined/99-positive-punkte.md new file mode 100644 index 00000000..74bc83b7 --- /dev/null +++ b/.audit/260719012-combined/99-positive-punkte.md @@ -0,0 +1,69 @@ +# Positive Punkte (nicht actionable) + +Positivbefunde aus beiden Audits (claude 2607171801, codex 2607171755), am Stand `5940ad6` (2026-07-20) nachgeprüft und weiterhin zutreffend. Bewusst aus den actionable Dateien herausgehalten — dieser Katalog dient dazu, dass diese Punkte in künftigen Reviews nicht erneut als Verdachtsfälle aufschlagen. + +## Architektur & Design + +- Kern-DTOs `ListSpec` und `Filter` sind `final readonly` mit `with*()`-Kopiersemantik (`src/List/ListSpec.php:26`, `src/Filter/Filter.php:21`); `type` und `dc` sind explizite Spec-Properties und fließen zusammen mit Driver-Klasse, Source, Config und Filter-Fingerprints in den Spec-Hash ein (`src/List/ListSpec.php:113-122`). *(beide)* +- `ListSpecFactory` ist der zentrale Konstruktionspfad für Typ-, Driver-, Config- und Data-Container-Auflösung; `OptionsResolver` an den Konstruktionsgrenzen macht Configfehler früh sichtbar. *(beide)* +- Transformer-Caches sind nach `(type, class)`-Paar getrennt memoiziert (`src/Filter/Resolver/FilterTransformerResolver.php:35-47`) — kein aliasübergreifendes Teilen von Konfiguration. *(ursprüngliches Audit-Finding, inzwischen gefixt)* +- Compiler-Passes exponieren Typ-Services als Aliase auf die Original-Definition (`src/DependencyInjection/Compiler/RegisterListDriversPass.php:47`, analog `RegisterFilterElementsPass.php:47`) — Container und Registry liefern dieselbe Instanz. *(claude)* +- Erweiterbarkeit intern bewiesen: Die ContaoCalendar-Integration ersetzt Projector/Loader/View ausschließlich über `supports()`/`priority()` (`src/Integration/ContaoCalendar/Projector/EventsInteractiveProjector.php:25-30`), ohne Kern-Services zu überschreiben. *(beide)* +- Collect-only `FilterFormBuilder` mit klaren Fehlerbarrieren (`addEventSubscriber(): never`, `getForm(): never` — `src/Form/FilterFormBuilder.php:65,74`). *(claude)* +- Named-Dispatch-Events als klare Alternative zu Service-Overrides; einheitliches, schlankes Muster (`src/EventListener/NamedDispatch/`). *(beide)* +- Lifecycle-Taxonomie `configure*` vs. `build*` konsequent über Filter-Elemente und List-Driver durchgezogen. *(beide)* +- Export-Achse bewusst unimplementiert und konsistent verdrahtet: `ExportProjector::supports()` → `false` (`src/Engine/Projector/ExportProjector.php:17-19`), keine broken References. *(claude)* +- DI-Tags auf einheitlichen `flare.*`-Namespace konsolidiert; Event-Klassen durchgängig im readonly-Property-Stil (Ausnahme by design: Render-Event-Familie mit `ModifiesTemplateTrait`). *(claude)* + +## Security & Query-Safety + +- Identifier-Validierung durchgängig: `FilterQueryBuilder::column()` erzwingt Regex `^[a-zA-Z0-9_]+$` + `quoteIdentifier()` (`src/Query/FilterQueryBuilder.php:51-57`); rohe SQL-Fragmente der FilterTypes interpolieren nur das validierte Ergebnis (z. B. `src/Filter/Type/PublishedFilterType.php:34,42`). +- Werte strikt parametrisiert: Parameternamen regex-validiert (`src/Query/FilterQueryBuilder.php:127`), Prefix-Rewriting ebenfalls (`:252`); Werte gelangen nie als String-Literal in SQL. **Keine SQL-Injection über anonyme Frontend-Requests gefunden.** *(beide)* +- ORDER BY abgesichert: `SortOrder` validiert Alias und Spalte via `Str::isValidSqlName()` (`src/Sort/SortOrder.php:134-138`). +- Serialisierte Spaltensuche gehärtet: `SqlHelper::findInSerializedArrayColumn()` nutzt `preg_quote` und quotet das Pattern über die Connection (`src/Util/SqlHelper.php:16,23`). +- Keine PHP-Object-Injection: alle nativen `unserialize()`-Aufrufe mit `['allowed_classes' => false]` (`src/Sort/SortOrder.php:65`, `src/Paginator/PaginatorConfig.php:204`). +- CSRF-Design korrekt: Filterformular bewusst GET ohne CSRF-Token für idempotente Queries (`src/Filter/Factory/FilterFormFactory.php:45`). +- Paginator-Input gehärtet: Seite via `query->getInt()`, Parameternamen sanitisiert (`src/Paginator/Factory/PaginatorFactory.php:38,127-138`). +- News-/Events-Driver fügen automatisch einen intrinsischen Published-Filter hinzu (`src/List/Driver/NewsListDriver.php:51-58`, `src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php:64-69`). +- Tabellenname wird pro Filterausführung validiert (`src/Query/Executor/FilterExecutor.php:76-82`). +- Baseline zum Audit-Zeitpunkt: Semgrep 0 Findings, Composer Audit ohne Advisories. + +## Korrektheit + +- Die `abort()`-Muster (`FilterBuilder::abort()` / `FilterQueryBuilder::abort()` als `never`-werfende Methoden) sind korrekt; verdächtig aussehende `if (!$x = …) { $builder->abort(); }`-Konstrukte sind unproblematisch (`src/Query/FilterQueryBuilder.php:69-73`). +- Keine OR/AND-Präzedenzfalle: Conditions werden durchgängig über DBALs `CompositeExpression` kombiniert, die bei ≥2 Teilen jeden Teil einklammert (`src/Query/FilterQueryBuilder.php:245`, `src/EventListener/QueryStructModifier/ConditionsModifierListener.php:50-56`). +- `setParameter(':name', …)` mit führendem Doppelpunkt ist unschädlich (`ltrim($param, ':')`, `src/Query/FilterQueryBuilder.php:125`). +- Geprüfter Nicht-Bug: Callbacks interner Funktionen (`array_filter`/`array_map`) laufen coercive — der `fn (string $key)`-Callback in `PaginatorFactory` mit numerischen Query-Keys ist kein TypeError (empirisch bestätigt; `src/Paginator/Factory/PaginatorFactory.php:79-83`). +- Als geprüft-in-Ordnung bestätigt: OptionsResolver-Memoisierung, `FilterContext::SINGLE_VALUE = '0'` als Formkey, `collectFilterData`-Pfade, Build-Reihenfolge des `ListSpecBuilder` (Overrides gewinnen wie dokumentiert), `TransformerResolver`-Fastpath, topologische Join-Sortierung inkl. `requires` (`src/Query/TableAliasRegistry.php:176-207`), `FilterModel::findByPid` nie null-foreach. + +## Performance & Stabilität + +- COUNT-/Daten-Trennung korrekt: der Count-Pfad läuft ohne ORDER BY, LIMIT/OFFSET und GROUP BY (`SelectModifierListener` setzt `COUNT(DISTINCT main.id)` + `setGroupBy(null)`; `PageModifierListener`/`OrderModifierListener` steigen bei `isCounting` früh aus). +- `AbortFilteringException` sauber gelöst: `src/Query/Executor/ListQueryDirector.php:60-67` fängt sie, loggt debug und liefert eine leere Liste statt eines Fehlers. +- Wildcard-Entschärfung der Suche wirksam: `SearchKeywordsFilterType::makeTerms()` entfernt `%`/`_` zuverlässig aus User-Input (`:55-64`). +- Kein N+1 auf Model-Ebene: `HandlesModelsTrait::createModelsFromEntries()` hydratisiert aus dem geladenen Resultset; Reader-URLs werden pro ID gecacht (`src/Engine/View/LinksToReaderTrait.php:36-50`). +- Memoization der `configure*`-Familie funktioniert wie dokumentiert (`SchemaResolver` pro Key, `src/Filter/FilterBuilder.php:18` statisch). +- Pagination korrekt via LIMIT/OFFSET; Offset nie negativ (`src/Paginator/PaginatorConfig.php:26-28`); Reader-Lookup effektiv mit LIMIT 1 (`src/Engine/Context/ValidationContext.php:35`). +- Indizes auf `tl_flare_filter` decken die Zugriffe ab (`contao/dca/tl_flare_filter.php:21-26`). +- Exception-Hygiene: breite `catch (\Throwable)` in `FilterExecutor` und den Loadern wrappen konsequent in `FilterException`/`FlareException` mit Quellen-Metadaten; `FilterOptionsResolver` (`src/Filter/Resolver/FilterOptionsResolver.php:35-47`) liefert vorbildliche Fehlermeldungen. +- Unbekannter Listentyp degradiert sauber (Collector → null, Controller-Graceful-Path greift). +- `FlareCollector` läuft nur mit aktivem Profiler — kein Produktions-Overhead. + +## Tests & Tooling + +- Testqualität vorbildlich: nur 2 `createMock`-Aufrufe in der gesamten Suite; echte Kollaborateure (echte Form-Factory inkl. CSRF-Extension, echter `EventDispatcher`) und echte Ergebnis-Assertions statt Interaktionsprüfung. +- Präzise Edge-Cases und Schema-Roundtrip-Tests („Transform erfüllt das eigene Schema") mit realistischen Contao-Daten (`serialize()`-Blobs, Checkbox-`'1'`/`''`, String-IDs); schnelle Suite ohne Framework-Boot. +- `phpunit.xml.dist` setzt `failOnRisky`/`failOnWarning`, `error_reporting=-1`; Coverage-Filter konsistent mit PHPStan-Excludes. +- PHPStan Level 5 mit `bleedingEdge` + Symfony-Extension, ohne Baseline-Datei — keine versteckten Altlasten. +- Semgrep mit `--error` tatsächlich verpflichtend (`.github/workflows/security.yaml:68`); Mago lintet streng (`--minimum-fail-level note`) über PHP 8.2–8.5, Version gepinnt. +- `composer validate --strict` in den Workflows; Composer-Caching und Path-Filter konsistent; Makefile konsistent mit AGENTS.md. + +## Contao-Integration, Doku & DX + +- Übersetzungs-Rename sauber: `translations/flare_filter.{de,en}.php` und `flare_list.{de,en}.php` nutzen `::TYPE`-Klassenkonstanten direkt als Keys — verwaiste Typ-Keys strukturell ausgeschlossen. +- Migrationsdoku vorhanden und substanziell: `docs/docs/migrating-from-v0.1.md`, `docs/docs/removed-in-v0.2.md`; Named-Dispatch-Muster in `docs/docs/dev/events.md` dokumentiert. +- Erweiterbarkeits-DX gut: eigenes FilterElement mit `#[AsFilterElement]` + `AbstractFilterElement` in wenigen Zeilen; `registerAttributeForAutoconfiguration` wirkt auch für Fremd-Bundles (`src/DependencyInjection/HeimrichHannotFlareExtension.php:58-70`); reservierte Typnamen werden validiert (`src/DependencyInjection/Compiler/RegisterListDriversPass.php:57-59`). +- Bundle-Bootstrap korrekt und vollständig: `contao/config/config.php`, Backend-Modul, `ContaoManager\Plugin`, Compiler-Passes; bedingte Integration-Loads passen zu `config/integrations/*.yaml`. +- Template-↔-View-Datenvertrag konsistent (`flare_listview.html.twig`/`flare_reader.html.twig` gegen die View-Klassen); Twig-Globals `flare_str`/`flare_env` verdrahtet. +- Der v0.1-Snapshot unter `docs/versioned_docs/` dokumentiert absichtlich die Alt-API — kein Drift-Problem. +- Seit dem Audit verbessert: Intrinsic-Muster im Filter-Element-Guide dokumentiert (`docs/docs/dev/filter-elements/index.md:256-262`); Generic-Driver zeigt Backend-Info bei fehlendem Published-Filter (`src/List/Driver/GenericDataContainerListDriver.php:111`); Terminal42-Integration in Doku/README als disabled markiert. From 1ca2243f1fb70b7f200e45f88749b0952ad55193 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Mon, 20 Jul 2026 18:15:56 +0200 Subject: [PATCH 68/71] refactor: improve null safety in context constructors, introduce `EntryCache` utility Made `PaginatorConfig` non-nullable in `InteractiveContext` for improved null safety. Introduced a new `EntryCache` utility for handling cached entries in a more structured manner. Replaced positional entry lookups with ID-based indexing. Updated `ValidationLoader` and removed stale `entryCache` logic from `ValidationContextFactory`. Adjusted `composer.json` to require `doctrine/dbal` `^3.6 || ^4.0`. Refined `count()` in `PaginatorConfig` to return `1` by default if `totalItems` is unknown. --- .audit/260719012-combined/00-uebersicht.md | 2 + .audit/260719012-combined/10-architektur.md | 47 +++++++++++-------- composer.json | 2 +- .../Factory/InteractiveContextFactory.php | 2 +- .../Factory/ValidationContextFactory.php | 19 +------- src/Engine/Context/InteractiveContext.php | 41 +++++++--------- src/Engine/Context/ValidationContext.php | 16 ------- src/Engine/Loader/ValidationLoader.php | 43 ++++++++++++++--- src/Paginator/PaginatorConfig.php | 9 +++- src/Util/EntryCache.php | 45 ++++++++++++++++++ 10 files changed, 140 insertions(+), 86 deletions(-) create mode 100644 src/Util/EntryCache.php diff --git a/.audit/260719012-combined/00-uebersicht.md b/.audit/260719012-combined/00-uebersicht.md index f74e9415..d529d022 100644 --- a/.audit/260719012-combined/00-uebersicht.md +++ b/.audit/260719012-combined/00-uebersicht.md @@ -26,7 +26,9 @@ Seit dem Audit-Datum wurden mehrere der ursprünglichen Top-Findings behoben — ### Vor dem Merge fixen 1. **DBAL-Constraint `^2.13 || ^3.0` erlaubt Versionen ohne `ArrayParameterType`** → Fatal auf Contao 4.13; Fix ist eine Zeile: `^3.6 || ^4.0` (PS-03, C-05). + * Gefixt. 2. **Render-Pfad-Stabilität:** `createView()` läuft erst im Template; Laufzeitfehler eines kaputten Filters reißt die Seite in einen 500er — `createView()` in den Controller ziehen bzw. `FlareException` beim Rendern abfangen; dazu 200-vs-500-Inkonsistenz Listview/Reader (PS-01, PS-05). + * Nein: Dieses Verhalten ist exakt richtig. Unbehandelte Exceptions sorgen für Fehler 500, auch vom Template aus. 3. **Entry-Cache positional statt per ID indiziert** — falscher Datensatz im Reader-Pfad möglich, öffentliche API (PS-02). 4. **Doku-`buildDca()`-Beispiele erzeugen Fatal Error** (konkrete Klasse statt `DcaBuilderInterface`; C-01). diff --git a/.audit/260719012-combined/10-architektur.md b/.audit/260719012-combined/10-architektur.md index b1c200b7..d83b9b72 100644 --- a/.audit/260719012-combined/10-architektur.md +++ b/.audit/260719012-combined/10-architektur.md @@ -18,25 +18,34 @@ Doku-Drift gegen den aktuellen Code: `ListBuilderFactory` heißt `ListSpecBuilde - `AGENTS.md:21,43,55,60,71` -## A-03: Registry-Duplikation und heterogene Lookup-Semantik — Minor (claude) - -`FilterElementRegistry` und `ListDriverRegistry` sind strukturell nahezu identisch (gleiches `add`/`remove`/`prune`/`typesByClass`-Muster) — Kandidat für Basis/Trait. Daneben drei weitere Stile: `FilterTypeRegistry` (TaggedIterator, Key = Klassenname), `EngineModRegistry` (TaggedIterator, `defaultIndexMethod: 'getType'`), `ProjectorRegistry` (`supports()`/`priority()`-Scan). Fünf Registries, vier Lookup-Semantiken. - -- `src/Registry/FilterElementRegistry.php:39-57` vs. `src/Registry/ListDriverRegistry.php:34-52` · `src/Registry/FilterTypeRegistry.php:25-28,53` · `src/Registry/EngineModRegistry.php:15` · `src/Registry/ProjectorRegistry.php:28-63` - -## A-04: `PaginatorConfig`: latenter `TypeError` in `count()` + deprecated `\Serializable` — Minor (claude) - -`count(): int` gibt `getLastPageNumber(): ?int` zurück — `TypeError` bei `itemsPerPage < 1` oder unbekanntem `totalItems`. Zusätzlich implementiert die Klasse das deprecated `\Serializable`-Interface mit `serialize()`/`unserialize()` neben `__serialize`/`__unserialize`. - -- `src/Paginator/PaginatorConfig.php:192-195` (`count()`), `:107-118` (`getLastPageNumber(): ?int`), `:7,197-205` (`\Serializable`) - -## A-05: `InteractiveProjector`: COUNT-Query läuft vor der Invalid-Form-Prüfung — Minor (claude) - -Die Aggregations-COUNT-Query (`src/Engine/Projector/InteractiveProjector.php:50`) wird ausgeführt, bevor geprüft wird, ob das Formular invalid submitted wurde (`:56-58`) — pro invalidem Submit eine unnötige Query. Zudem wird `totalItems` unverändert an die View durchgereicht, sodass diese `totalItems > 0` bei leerem `InteractiveEmptyLoader` meldet (`:74-81`). - -## A-06: Context-Verträge mit kleinen LSP/ISP-Brüchen — Minor (claude) - -`InteractiveContext::getPaginatorConfig(): PaginatorConfig` gibt das nullable Property ungeprüft zurück — `TypeError` bei programmatischer Konstruktion ohne Validator-Lauf (`src/Engine/Context/InteractiveContext.php:25,45-48`). Die readonly `ValidationContext` trägt einen No-op-Setter `setPaginatorQueryParameter()`, weil `PaginatedContextInterface` ihn erzwingt (`src/Engine/Context/ValidationContext.php:77-80`). +> ## A-03: Registry-Duplikation und heterogene Lookup-Semantik — Minor (claude) +> +> `FilterElementRegistry` und `ListDriverRegistry` sind strukturell nahezu identisch (gleiches `add`/`remove`/`prune`/`typesByClass`-Muster) — Kandidat für Basis/Trait. Daneben drei weitere Stile: `FilterTypeRegistry` (TaggedIterator, Key = Klassenname), `EngineModRegistry` (TaggedIterator, `defaultIndexMethod: 'getType'`), `ProjectorRegistry` (`supports()`/`priority()`-Scan). Fünf Registries, vier Lookup-Semantiken. +> +> - `src/Registry/FilterElementRegistry.php:39-57` vs. `src/Registry/ListDriverRegistry.php:34-52` · `src/Registry/FilterTypeRegistry.php:25-28,53` · `src/Registry/EngineModRegistry.php:15` · `src/Registry/ProjectorRegistry.php:28-63` +> +> **Nutzer-Antwort: Das ist kein Design-Fehler, sondern eine Konvention. Einzelne Klassen sorgen für Typsicherheit. Die Klassen sind atomar und benötigen künftig keiner Feature-Erweiterung, daher keine gemeinsame Basisklasse.** + +> ## A-04: `PaginatorConfig`: latenter `TypeError` in `count()` + deprecated `\Serializable` — Minor (claude) +> +> `count(): int` gibt `getLastPageNumber(): ?int` zurück — `TypeError` bei `itemsPerPage < 1` oder unbekanntem `totalItems`. Zusätzlich implementiert die Klasse das deprecated `\Serializable`-Interface mit `serialize()`/`unserialize()` neben `__serialize`/`__unserialize`. +> +> - `src/Paginator/PaginatorConfig.php:192-195` (`count()`), `:107-118` (`getLastPageNumber(): ?int`), `:7,197-205` (`\Serializable`) +> +> **Nutzer-Antwort: TypeError erledigt, \Serializable ist nicht deprecated, siehe folgende Notiz.** +> > As of PHP 8.1.0, a class which implements Serializable without also implementing __serialize() and __unserialize() will generate a deprecation warning. + +> ## A-05: `InteractiveProjector`: COUNT-Query läuft vor der Invalid-Form-Prüfung — Minor (claude) +> +> Die Aggregations-COUNT-Query (`src/Engine/Projector/InteractiveProjector.php:50`) wird ausgeführt, bevor geprüft wird, ob das Formular invalid submitted wurde (`:56-58`) — pro invalidem Submit eine unnötige Query. Zudem wird `totalItems` unverändert an die View durchgereicht, sodass diese `totalItems > 0` bei leerem `InteractiveEmptyLoader` meldet (`:74-81`). +> +> **Nutzer-Antwort: Das ist kein Fehler. Da sich das Formular nicht auf die Aggregation-COUNT-Query auswirkt, muss die totale Anzahl der Elemente trotzdem berechnet werden.** + +> ## A-06: Context-Verträge mit kleinen LSP/ISP-Brüchen — Minor (claude) +> +> `InteractiveContext::getPaginatorConfig(): PaginatorConfig` gibt das nullable Property ungeprüft zurück — `TypeError` bei programmatischer Konstruktion ohne Validator-Lauf (`src/Engine/Context/InteractiveContext.php:25,45-48`). Die readonly `ValidationContext` trägt einen No-op-Setter `setPaginatorQueryParameter()`, weil `PaginatedContextInterface` ihn erzwingt (`src/Engine/Context/ValidationContext.php:77-80`). +> +> **Nutzer-Antwort: PaginatorConfig nun korrekt null-safe, ValidationContext no-op-Setter ist korrekt für den Zweck.** ## A-07: Stille Alias-Kollision im Filter-Collector — Minor (claude) diff --git a/composer.json b/composer.json index a654d11e..da034a2e 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,7 @@ "friendsofsymfony/http-cache-bundle": "^2.17 || ^3.0", "contao/core-bundle": "^4.13 || ^5.0", "composer/semver": "^3.4", - "doctrine/dbal": "^2.13 || ^3.0 || ^4.0", + "doctrine/dbal": "^3.6 || ^4.0", "mvo/contao-group-widget": "^1.5", "psr/log": "^1.0 || ^2.0 || ^3.0", "symfony/config": "^5.4 || ^6.0 || ^7.0", diff --git a/src/Engine/Context/Factory/InteractiveContextFactory.php b/src/Engine/Context/Factory/InteractiveContextFactory.php index f42a4ed9..e9ef7203 100644 --- a/src/Engine/Context/Factory/InteractiveContextFactory.php +++ b/src/Engine/Context/Factory/InteractiveContextFactory.php @@ -39,9 +39,9 @@ public function createFromContent(ContentModel $contentModel, ListSpec $list): I $config = new InteractiveContext( paginatorConfig: $paginatorConfig, sortOrderSequence: $sortOrderSequence, + formName: $filterFormName, contentModelId: (int) $contentModel->id, formActionPage: (int) $contentModel->{ContentContainer::FIELD_JUMP_TO}, - formName: $filterFormName, jumpToReaderPageId: $jumpToReaderPageId, autoItemField: $fieldAutoItem, ); diff --git a/src/Engine/Context/Factory/ValidationContextFactory.php b/src/Engine/Context/Factory/ValidationContextFactory.php index 1694d51a..008b5ba5 100644 --- a/src/Engine/Context/Factory/ValidationContextFactory.php +++ b/src/Engine/Context/Factory/ValidationContextFactory.php @@ -41,21 +41,4 @@ public function createFromContent(ContentModel $contentModel, ListSpec $list): V return $config; } - - public function createFromInteractiveView(InteractiveView $interactiveView): ValidationContext - { - $config = new ValidationContext( - entryCache: static fn (): ?array => $interactiveView->issetEntries() - ? $interactiveView->getEntries() - : null, - ); - - $violations = $this->validator->validate($config); - - if ($violations->count()) { - throw new ValidationFailedException($config, $violations); - } - - return $config; - } -} \ No newline at end of file +} diff --git a/src/Engine/Context/InteractiveContext.php b/src/Engine/Context/InteractiveContext.php index 1a46efb3..8ed01494 100644 --- a/src/Engine/Context/InteractiveContext.php +++ b/src/Engine/Context/InteractiveContext.php @@ -22,14 +22,14 @@ public static function getContextType(): string } public function __construct( - #[Assert\NotNull] public ?PaginatorConfig $paginatorConfig = null, - public ?SortOrderSequence $sortOrderSequence = null, - #[Assert\PositiveOrZero] public int $contentModelId = 0, - #[Assert\PositiveOrZero] public int $formActionPage = 0, - #[Assert\NotBlank] public string $formName = '', - #[Assert\PositiveOrZero] public int $jumpToReaderPageId = 0, - #[Assert\NotBlank] public string $autoItemField = 'id', - public ?string $pageParam = null, + public PaginatorConfig $paginatorConfig, + public ?SortOrderSequence $sortOrderSequence = null, + #[Assert\NotBlank] public string $formName, + #[Assert\PositiveOrZero] public int $contentModelId = 0, + #[Assert\PositiveOrZero] public int $formActionPage = 0, + #[Assert\PositiveOrZero] public int $jumpToReaderPageId = 0, + #[Assert\NotBlank] public string $autoItemField = 'id', + public ?string $pageParam = null, ) {} public function getFormName(): string @@ -67,20 +67,15 @@ public function with( ?string $formName = null, ?string $pageParam = null, ): static { - $clone = clone $this; - - if ($paginatorConfig !== null) { - $clone->paginatorConfig = $paginatorConfig; - } - - if ($formName !== null) { - $clone->formName = $formName; - } - - if ($pageParam !== null) { - $clone->pageParam = $pageParam; - } - - return $clone; + return new self( + paginatorConfig: $paginatorConfig ?? $this->paginatorConfig, + sortOrderSequence: $this->sortOrderSequence, + formName: $formName ?? $this->formName, + contentModelId: $this->contentModelId, + formActionPage: $this->formActionPage, + jumpToReaderPageId: $this->jumpToReaderPageId, + autoItemField: $this->autoItemField, + pageParam: $pageParam ?? $this->pageParam + ); } } diff --git a/src/Engine/Context/ValidationContext.php b/src/Engine/Context/ValidationContext.php index 0540385a..fb98b89a 100644 --- a/src/Engine/Context/ValidationContext.php +++ b/src/Engine/Context/ValidationContext.php @@ -22,11 +22,7 @@ public static function getContextType(): string return 'validation'; } - /** - * @param null|\Closure(): array $entryCache - */ public function __construct( - private ?\Closure $entryCache = null, #[Assert\PositiveOrZero] public int $jumpToReaderPageId = 0, #[Assert\PositiveOrZero] public int $jumpToListViewPageId = 0, #[Assert\NotBlank] public string $autoItemField = 'id', @@ -48,17 +44,6 @@ public function createBackLink(): ?BackLink return BackLink::fromPage($pageModel); } - public function getEntryCache(): array - { - if (!\is_callable($this->entryCache)) { - return []; - } - - // Closure return value MUST NOT be cached locally, as it may change during runtime, - // e.g., when used with InteractiveProjection, entries are only available after a lazy fetch. - return \is_array($cache = ($this->entryCache)()) ? $cache : []; - } - public function getFilterValues(): array { return $this->filterValues; @@ -82,7 +67,6 @@ public function setPaginatorQueryParameter(?string $queryParameter): void public function withFilterValues(array $values): self { return new self( - entryCache: $this->entryCache, jumpToReaderPageId: $this->jumpToReaderPageId, jumpToListViewPageId: $this->jumpToListViewPageId, autoItemField: $this->autoItemField, diff --git a/src/Engine/Loader/ValidationLoader.php b/src/Engine/Loader/ValidationLoader.php index 0f83df74..bab17347 100644 --- a/src/Engine/Loader/ValidationLoader.php +++ b/src/Engine/Loader/ValidationLoader.php @@ -12,24 +12,33 @@ use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Query\Executor\ListQueryDirector; use HeimrichHannot\FlareBundle\Query\ListQueryConfig; +use HeimrichHannot\FlareBundle\Util\EntryCache; readonly class ValidationLoader implements ValidationLoaderInterface { + protected EntryCache $entryCache; + public function __construct( private ValidationLoaderConfig $config, private FilterFactory $filterFactory, private ListQueryDirector $listQueryDirector, - ) {} + ) { + $this->entryCache = new EntryCache($this->config->list->dc); + } + + public function getEntryCache(): EntryCache + { + return $this->entryCache; + } /** * @throws FlareException */ public function fetchEntryById(int $id): ?array { - if ($hit = $this->config->context->getEntryCache()[$id] ?? null) - // Fast lane cache check + if ($this->entryCache->has($id)) { - return $hit; + return $this->entryCache->get($id); } try @@ -46,7 +55,17 @@ public function fetchEntryById(int $id): ?array $list = $this->config->list->withFilter($idDefinition); - return $this->executeQuery($list, $this->config->context); + $entry = $this->executeQuery($list, $this->config->context); + + $this->entryCache->add('id:' . $id, $entry); + + if (($autoItemField = $this->config->autoItemField) + && ($autoItem = $entry[$autoItemField] ?? null)) + { + $this->entryCache->add('autoItem:' . $autoItem, $entry); + } + + return $entry; } catch (FlareException $e) { @@ -67,6 +86,10 @@ public function fetchEntryByAutoItem(string $autoItem): ?array return null; } + if ($entry = $this->entryCache->get('autoItem:' . $autoItem)) { + return $entry; + } + try { $autoItemDefinition = $this->filterFactory->create( @@ -81,7 +104,15 @@ public function fetchEntryByAutoItem(string $autoItem): ?array $list = $this->config->list->withFilter($autoItemDefinition); - return $this->executeQuery($list, $this->config->context); + $entry = $this->executeQuery($list, $this->config->context); + + $this->entryCache->add('autoItem:' . $autoItem, $entry); + + if ($id = $entry['id'] ?? null) { + $this->entryCache->add('id:' . $id, $entry); + } + + return $entry; } catch (FlareException $e) { diff --git a/src/Paginator/PaginatorConfig.php b/src/Paginator/PaginatorConfig.php index 62396619..3252dbc1 100644 --- a/src/Paginator/PaginatorConfig.php +++ b/src/Paginator/PaginatorConfig.php @@ -189,9 +189,14 @@ public function with( ); } + /** + * Get the number of pages. + * + * @return int The number of pages, or 1 if the total number of items is unknown. + */ public function count(): int { - return $this->getLastPageNumber(); + return $this->getLastPageNumber() ?? 1; } public function serialize(): string @@ -232,4 +237,4 @@ public function __toString(): string $this->getLastItemNumber(), ); } -} \ No newline at end of file +} diff --git a/src/Util/EntryCache.php b/src/Util/EntryCache.php new file mode 100644 index 00000000..d00b2a47 --- /dev/null +++ b/src/Util/EntryCache.php @@ -0,0 +1,45 @@ +cache[$key] = $value; + + return $this; + } + + public function addMany(array $entries): self + { + foreach ($entries as $key => $value) { + $this->add($key, $value); + } + + return $this; + } + + public function remove(int|string $key): self + { + unset($this->cache[$key]); + + return $this; + } + + public function get(int|string $key) + { + return $this->cache[$key] ?? null; + } + + public function has(int|string $key): bool + { + return \array_key_exists($key, $this->cache); + } +} From 807793bdb6d0f58af2a48e7bc52c7acd7327896e Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Mon, 20 Jul 2026 19:21:55 +0200 Subject: [PATCH 69/71] feat: add duplicate filter alias detection and improve ListDriver resolution mechanics Implemented detection for duplicate filter aliases in `ListModel` and introduced backend error messages for better user feedback. Refactored `ListDriver` resolution with a dedicated `ListDriverResolver` class to centralize logic. Updated `ListSpecBuilder`, `ListSpecFactory`, and related classes to use the resolver. Enhanced backend filter info template to display alias conflicts. Added corresponding translations and adjusted `ElementDcaListener` for alias check logic. --- .audit/260719012-combined/10-architektur.md | 12 +-- .../Contao/ElementDcaListener.php | 54 ++++++++++++- .../FlareFilter/ListCallbacks.php | 6 +- src/List/Factory/ListSpecBuilderFactory.php | 3 + src/List/Factory/ListSpecFactory.php | 80 ++++++------------- src/List/ListSpecBuilder.php | 9 ++- src/List/ResolvedListDriver.php | 13 +++ src/List/Resolver/ListDriverResolver.php | 58 ++++++++++++++ templates/backend/be_filter_info.html.twig | 16 ++++ translations/flare.de.yaml | 2 + translations/flare.en.yaml | 2 + 11 files changed, 184 insertions(+), 71 deletions(-) create mode 100644 src/List/ResolvedListDriver.php create mode 100644 src/List/Resolver/ListDriverResolver.php diff --git a/.audit/260719012-combined/10-architektur.md b/.audit/260719012-combined/10-architektur.md index d83b9b72..a1dd0e7b 100644 --- a/.audit/260719012-combined/10-architektur.md +++ b/.audit/260719012-combined/10-architektur.md @@ -47,11 +47,13 @@ Doku-Drift gegen den aktuellen Code: `ListBuilderFactory` heißt `ListSpecBuilde > > **Nutzer-Antwort: PaginatorConfig nun korrekt null-safe, ValidationContext no-op-Setter ist korrekt für den Zweck.** -## A-07: Stille Alias-Kollision im Filter-Collector — Minor (claude) - -`$filters[$filter->alias] = $filter;` — zwei publizierte Filter derselben Liste mit gleichem Formular-Alias überschreiben sich kommentarlos; nur der letzte wird angewendet. Ein Kollisions-Warning fehlt (das Factory-Fehler-Warning existiert dagegen). - -- `src/List/Collector/ListModelFilterCollector.php:75` +> ## A-07: Stille Alias-Kollision im Filter-Collector — Minor (claude) +> +> `$filters[$filter->alias] = $filter;` — zwei publizierte Filter derselben Liste mit gleichem Formular-Alias überschreiben sich kommentarlos; nur der letzte wird angewendet. Ein Kollisions-Warning fehlt (das Factory-Fehler-Warning existiert dagegen). +> +> - `src/List/Collector/ListModelFilterCollector.php:75` +> +> **Nutzer-Antwort: Im Backend wird nun ein Fehler ausgegeben, wenn zwei Filter mit demselben Alias publiziert werden.** ## A-08: `FlareException`: `method` vs. `source` inkonsistent — Minor (claude) diff --git a/src/EventListener/Contao/ElementDcaListener.php b/src/EventListener/Contao/ElementDcaListener.php index 68b249e8..2d8b0094 100644 --- a/src/EventListener/Contao/ElementDcaListener.php +++ b/src/EventListener/Contao/ElementDcaListener.php @@ -6,10 +6,13 @@ use Contao\CoreBundle\DependencyInjection\Attribute\AsHook; use Contao\Input; +use Contao\Message; +use Doctrine\DBAL\Connection; use HeimrichHannot\FlareBundle\Contract\DcaContract; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilder; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\Event\ElementDcaEvent; +use HeimrichHannot\FlareBundle\EventListener\DataContainer\FlareFilter\ListCallbacks; use HeimrichHannot\FlareBundle\List\Factory\ListSpecBuilderFactory; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Model\ListModel; @@ -19,6 +22,7 @@ use HeimrichHannot\FlareBundle\Registry\ListDriverRegistry; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\Translation\TranslatorInterface; /** * Applies each element's backend configuration ({@see DcaContract::configureDca()}) and @@ -29,15 +33,17 @@ * of the previous palette assembly. */ #[AsHook('loadDataContainer', priority: -100)] -readonly class ElementDcaListener +final readonly class ElementDcaListener { public function __construct( + private Connection $connection, private EventDispatcherInterface $eventDispatcher, private FilterElementRegistry $filterElementRegistry, private ListExecutionContextFactory $listExecutionContextFactory, private ListSpecBuilderFactory $listFactory, private ListDriverRegistry $listDriverRegistry, private RequestStack $requestStack, + private TranslatorInterface $translator, ) {} public function __invoke(string $table): void @@ -74,9 +80,14 @@ private function configure(string $table): void else { $filterModel = null; - $listModel = ListModel::findByPk($id); - $type = (string) ($listModel->type ?? ''); - $service = $this->listDriverRegistry->getService($type); + + if ($listModel = ListModel::findByPk($id)) + { + $type = (string) ($listModel->type ?? ''); + $service = $this->listDriverRegistry->getService($type); + + $this->checkDuplicateFilterAliases($listModel); + } } if (!$listModel instanceof ListModel || !$type || $type === 'default' || \str_starts_with($type, '__')) { @@ -119,4 +130,39 @@ private function createExecutionContext(ListModel $listModel): ?ListExecutionCon return null; } + + private function checkDuplicateFilterAliases(ListModel $listModel): void + { + $qTable = $this->connection->quoteIdentifier(FilterModel::getTable()); + + $sql = << 0 + AND `formAlias` IS NOT NULL + AND `formAlias` <> '' + GROUP BY `formAlias` + HAVING COUNT(*) > 1 + SQL; + + $duplicateFormAliases = $this->connection->fetchFirstColumn($sql, [ + 'pid' => $listModel->id, + ]); + + if (!$duplicateFormAliases) { + return; + } + + /** Used in {@see ListCallbacks} to notify user of duplicate filter aliases in the Contao backend. */ + $GLOBALS['FLARE']['duplicate_filter_aliases'] = $duplicateFormAliases; + + Message::addError($this->translator->trans('list.info.duplicate_filter_alias', [ + '%alias%' => implode(', ', \array_map( + static fn (string $alias): string => "\"{$alias}\"", + $duplicateFormAliases + )), + ], 'flare')); + } } diff --git a/src/EventListener/DataContainer/FlareFilter/ListCallbacks.php b/src/EventListener/DataContainer/FlareFilter/ListCallbacks.php index 1ea7a007..10e627f8 100644 --- a/src/EventListener/DataContainer/FlareFilter/ListCallbacks.php +++ b/src/EventListener/DataContainer/FlareFilter/ListCallbacks.php @@ -43,6 +43,9 @@ public function listLabelLabel(array $row): string $formFieldName = FilterModel::generateFormName($row); + $duplicateFilterAliases = $GLOBALS['FLARE']['duplicate_filter_aliases'] ?? []; + $duplicateFilterAliases = \array_fill_keys($duplicateFilterAliases, true); + return $this->twig->render('@HeimrichHannotFlare/backend/be_filter_info.html.twig', [ 'row' => $row, 'is_intrinsic' => $isIntrinsic, @@ -50,6 +53,7 @@ public function listLabelLabel(array $row): string 'title' => $title, 'type_label' => $typeLabel, 'form_alias' => $formFieldName, + 'duplicate_filter_aliases' => $duplicateFilterAliases, ]); } -} \ No newline at end of file +} diff --git a/src/List/Factory/ListSpecBuilderFactory.php b/src/List/Factory/ListSpecBuilderFactory.php index 7819c9be..e5f069d3 100644 --- a/src/List/Factory/ListSpecBuilderFactory.php +++ b/src/List/Factory/ListSpecBuilderFactory.php @@ -8,6 +8,7 @@ use HeimrichHannot\FlareBundle\List\Collector\ListModelFilterCollector; use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use HeimrichHannot\FlareBundle\List\ListSpecBuilder; +use HeimrichHannot\FlareBundle\List\Resolver\ListDriverResolver; use HeimrichHannot\FlareBundle\Model\ListModel; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -19,6 +20,7 @@ { public function __construct( private EventDispatcherInterface $eventDispatcher, + private ListDriverResolver $listDriverResolver, private ListModelFilterCollector $filterCollector, private ListSpecFactory $specFactory, ) {} @@ -29,6 +31,7 @@ public function create( ?string $source = null, ): ListSpecBuilder { return new ListSpecBuilder( + listDriverResolver: $this->listDriverResolver, specFactory: $this->specFactory, eventDispatcher: $this->eventDispatcher, driver: $driver, diff --git a/src/List/Factory/ListSpecFactory.php b/src/List/Factory/ListSpecFactory.php index 15827b49..adffab34 100644 --- a/src/List/Factory/ListSpecFactory.php +++ b/src/List/Factory/ListSpecFactory.php @@ -10,6 +10,8 @@ use HeimrichHannot\FlareBundle\List\BaseListOptions; use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use HeimrichHannot\FlareBundle\List\ListSpec; +use HeimrichHannot\FlareBundle\List\ResolvedListDriver; +use HeimrichHannot\FlareBundle\List\Resolver\ListDriverResolver; use HeimrichHannot\FlareBundle\List\Resolver\ListOptionsResolver; use HeimrichHannot\FlareBundle\List\Resolver\ListTransformerResolver; use HeimrichHannot\FlareBundle\Model\ListModel; @@ -26,6 +28,7 @@ public function __construct( private ListDriverRegistry $listDriverRegistry, private ListOptionsResolver $listOptionsResolver, private ListTransformerResolver $transformerResolver, + private ListDriverResolver $listDriverResolver, ) {} /** @@ -36,21 +39,18 @@ public function __construct( * the schema, or no data container can be determined. */ public function create( - ListDriverInterface|string $driver, - array $filters = [], - array $config = [], - ?string $source = null, + ResolvedListDriver|ListDriverInterface|string $driver, + array $filters = [], + array $config = [], + ?string $source = null, ): ListSpec { - $type = $this->resolveType($driver); - $driver = $this->resolveDriver($driver); - - $config = $this->listOptionsResolver->resolve($driver, $config, $source); - - $dc = $this->resolveDataContainer($config, $driver, $type, $source); + $resolved = $this->listDriverResolver->resolve($driver); + $config = $this->listOptionsResolver->resolve($resolved->driver, $config, $source); + $dc = $this->resolveDataContainer($config, $resolved->driver, $resolved->type, $source); return new ListSpec( - driver: $driver, - type: $type, + driver: $resolved->driver, + type: $resolved->type, dc: $dc, filters: $filters, config: $config, @@ -63,21 +63,20 @@ public function create( * the schema, or no data container can be determined. */ public function createFromListModel( - ?ListModel $listModel, - ListDriverInterface|string|null $driver = null, - array $filters = [], - array $config = [], - ?string $source = null, + ?ListModel $listModel, + ResolvedListDriver|ListDriverInterface|string|null $driver = null, + array $filters = [], + array $config = [], + ?string $source = null, ): ListSpec { $driver ??= $listModel->getListDriverType(); - $type = $this->resolveType($driver); - $driver = $this->resolveDriver($driver); + $resolved = $this->listDriverResolver->resolve($driver); $configBuilder = new ConfigBuilder(); BaseListOptions::transform($configBuilder, $listModel); - $transformed = $this->transformerResolver->transform($driver, $type, $listModel); + $transformed = $this->transformerResolver->transform($resolved->driver, $resolved->type, $listModel); foreach ($transformed ?? [] as $key => $value) { $configBuilder->set($key, $value); @@ -87,13 +86,13 @@ public function createFromListModel( $configBuilder->set($key, $value); } - $finalConfig = $this->listOptionsResolver->resolve($driver, $configBuilder->all(), $source); + $finalConfig = $this->listOptionsResolver->resolve($resolved->driver, $configBuilder->all(), $source); - $dc = $this->resolveDataContainer($finalConfig, $driver, $type, $source); + $dc = $this->resolveDataContainer($finalConfig, $resolved->driver, $resolved->type, $source); return new ListSpec( - driver: $driver, - type: $type, + driver: $resolved->driver, + type: $resolved->type, dc: $dc, filters: $filters, config: $finalConfig, @@ -101,39 +100,6 @@ public function createFromListModel( ); } - /** - * @throws FlareException - */ - private function resolveType(ListDriverInterface|string $driver, ?string $source = null): string - { - if (!$type = \is_object($driver) ? \get_class($driver) : (string) $driver) - { - throw new FlareException(\sprintf( - 'A list driver instance or registered type alias must be provided%s.', - $source ? " ({$source})" : '', - ), method: __METHOD__); - } - - return $type; - } - - /** - * @throws FlareException In case no driver is registered under the given type alias. - */ - private function resolveDriver(ListDriverInterface|string $driver, ?string $source = null): ListDriverInterface - { - if ($driver instanceof ListDriverInterface) { - return $driver; - } - - return $this->listDriverRegistry->getService($driver) - ?? throw new FlareException(\sprintf( - 'List type "%s" not found%s.', - $driver, - $source ? " ({$source})" : '' - ), method: __METHOD__); - } - /** * @throws FlareException */ diff --git a/src/List/ListSpecBuilder.php b/src/List/ListSpecBuilder.php index d22405a6..5752fa5d 100644 --- a/src/List/ListSpecBuilder.php +++ b/src/List/ListSpecBuilder.php @@ -11,6 +11,7 @@ use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use HeimrichHannot\FlareBundle\List\Factory\ListSpecFactory; +use HeimrichHannot\FlareBundle\List\Resolver\ListDriverResolver; use HeimrichHannot\FlareBundle\Model\ListModel; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -37,6 +38,7 @@ final class ListSpecBuilder implements ListSpecBuilderInterface private int $generatedFilterKeys = 0; public function __construct( + private readonly ListDriverResolver $listDriverResolver, private readonly ListSpecFactory $specFactory, private readonly EventDispatcherInterface $eventDispatcher, private readonly ListDriverInterface|string $driver, @@ -121,10 +123,9 @@ public function hasFilterInstance(string $class): bool */ public function build(): ListSpec { - $driver = $this->driver; - - if ($driver instanceof BuildListContract) { - $driver->buildList($this); + $driver = $this->listDriverResolver->resolve($this->driver); + if ($driver->driver instanceof BuildListContract) { + $driver->driver->buildList($this); } $this->eventDispatcher->dispatch(new ListBuildEvent($this)); diff --git a/src/List/ResolvedListDriver.php b/src/List/ResolvedListDriver.php new file mode 100644 index 00000000..1270961b --- /dev/null +++ b/src/List/ResolvedListDriver.php @@ -0,0 +1,13 @@ +resolveType($driver, $source); + $driver = $this->resolveDriver($driver, $source); + + return new ResolvedListDriver($type, $driver); + } + + /** + * @throws FlareException + */ + private function resolveType(ListDriverInterface|string $driver, ?string $source = null): string + { + if (!$type = \is_object($driver) ? \get_class($driver) : (string) $driver) + { + throw new FlareException(\sprintf( + 'A list driver instance or registered type alias must be provided%s.', + $source ? " ({$source})" : '', + ), method: __METHOD__); + } + + return $type; + } + + /** + * @throws FlareException In case no driver is registered under the given type alias. + */ + private function resolveDriver(ListDriverInterface|string $driver, ?string $source = null): ListDriverInterface + { + if ($driver instanceof ListDriverInterface) { + return $driver; + } + + return $this->listDriverRegistry->getService($driver) + ?? throw new FlareException(\sprintf( + 'List type "%s" not found%s.', + $driver, + $source ? " ({$source})" : '' + ), method: __METHOD__); + } +} diff --git a/templates/backend/be_filter_info.html.twig b/templates/backend/be_filter_info.html.twig index 4fbaab92..801cd623 100644 --- a/templates/backend/be_filter_info.html.twig +++ b/templates/backend/be_filter_info.html.twig @@ -1,5 +1,8 @@ {% trans_default_domain 'flare' %} +{% set form_alias = form_alias ?? null %} +{% set is_alias_duplicated = form_alias ? ((duplicate_filter_aliases|default([]))[form_alias] ?? false) : false %} +
[{{ type_label }}]
@@ -27,6 +30,19 @@ {% endif %}
+ {% if is_alias_duplicated %} + + + {{ 'filter.info.duplicate_alias'|trans({'%alias%': form_alias}) }} + + + + + + + {% endif %}
{% if form_alias|default %} {{ form_alias }} diff --git a/translations/flare.de.yaml b/translations/flare.de.yaml index d8f2a4f9..fbdfb477 100644 --- a/translations/flare.de.yaml +++ b/translations/flare.de.yaml @@ -11,6 +11,7 @@ list: info: no_published_filter: "Einträge dieser Liste haben einen Veröffentlichungsstatus (%target%). Es ist kein Veröffentlicht-Filter konfiguriert, der dies berücksichtigt." + duplicate_filter_alias: "Duplizierte Filter-Aliasse: %alias%. Der letzte Filter überschreibt vorherige mit dem gleichen Alias." filter: limited_scope: @@ -29,6 +30,7 @@ filter: intrinsic: yes: "Dieser Filter ist intrinsisch" no: "Dieser Filter ist nicht intrinsisch" + duplicate_alias: "Dieser Filter hat den Alias \"%alias%\", der bereits von einem anderen Filter verwendet wird." errors: missing_model: 'Listen- oder Filtermodell nicht gefunden' diff --git a/translations/flare.en.yaml b/translations/flare.en.yaml index c65025f6..a6177d9d 100644 --- a/translations/flare.en.yaml +++ b/translations/flare.en.yaml @@ -11,6 +11,7 @@ list: info: no_published_filter: "Entries in this list have a publication status (%target%). No publication filter is configured to account for this." + duplicate_filter_alias: "Duplicate filter aliases: %alias%. The last filter overwrites the previous ones with the same alias." filter: limited_scope: @@ -29,6 +30,7 @@ filter: intrinsic: yes: "This filter is intrinsic" no: "This filter ist not intrinsic" + duplicate_alias: "This filter has the alias \"%alias%\", which is already used by another filter." errors: missing_model: 'List model or filter model not found.' From 417309eb03ed7d536d4224093c7155a66c98b459 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 21 Jul 2026 10:41:05 +0200 Subject: [PATCH 70/71] refactor: standardize `FlareException` method parameter usage across codebase Aligned all `FlareException` instantiations to use the `method: __METHOD__` parameter for consistency. Updated exception messages and adjusted formatting where necessary to ensure compliance with the revised standard. --- .audit/260719012-combined/10-architektur.md | 8 +++++--- .../ContentElement/ListViewController.php | 10 +++++++--- .../ContentElement/ReaderController.php | 2 +- src/Engine/Engine.php | 5 ++++- src/Engine/Loader/ValidationLoader.php | 4 ++-- src/Engine/Projector/AbstractProjector.php | 4 ++-- src/Engine/View/HandlesModelsTrait.php | 12 ++++++------ src/Exception/InferenceException.php | 7 ++++--- src/Exception/ViewException.php | 4 ++-- src/Filter/Element/ArchiveFilterElement.php | 15 +++++++++------ src/Filter/Factory/FilterFormFactory.php | 5 ++++- src/Filter/FilterBuilder.php | 5 ++++- src/Filter/Type/ArchiveFilterType.php | 4 ++-- src/Filter/Type/DcaSelectFilterType.php | 4 ++-- src/Filter/Type/SimpleEquationFilterType.php | 9 ++++++--- src/InferPtable/PtableInferrer.php | 12 +++++++++--- .../Loader/EventsAggregationLoader.php | 4 ++-- src/Query/Executor/FilterExecutor.php | 8 +++++--- src/Query/FilterQueryBuilder.php | 7 +++++-- src/Registry/ProjectorRegistry.php | 12 ++++++------ src/Sort/Factory/SortOrderSequenceFactory.php | 12 +++++++++--- src/Sort/SortOrderSequence.php | 10 +++++++--- 22 files changed, 103 insertions(+), 60 deletions(-) diff --git a/.audit/260719012-combined/10-architektur.md b/.audit/260719012-combined/10-architektur.md index a1dd0e7b..9ed82daf 100644 --- a/.audit/260719012-combined/10-architektur.md +++ b/.audit/260719012-combined/10-architektur.md @@ -55,9 +55,11 @@ Doku-Drift gegen den aktuellen Code: `ListBuilderFactory` heißt `ListSpecBuilde > > **Nutzer-Antwort: Im Backend wird nun ein Fehler ausgegeben, wenn zwei Filter mit demselben Alias publiziert werden.** -## A-08: `FlareException`: `method` vs. `source` inkonsistent — Minor (claude) - -Die Exception bietet beide Parameter (`src/Exception/FlareException.php:17-18`), der Code nutzt beide uneinheitlich mit demselben Inhalt (`__METHOD__`): Loader nutzen `method:` (`src/Engine/Loader/InteractiveLoader.php:52`, `AggregationLoader.php:52`), Projector/Views/Calendar-Integration `source:` (`src/Engine/Projector/AbstractProjector.php:124`, `src/Engine/View/HandlesModelsTrait.php:33,42,57,66,75`, `src/Integration/ContaoCalendar/Loader/EventsAggregationLoader.php:66`), `ValidationLoader` keins von beiden (`src/Engine/Loader/ValidationLoader.php:57,92`). +> ## A-08: `FlareException`: `method` vs. `source` inkonsistent — Minor (claude) +> +> Die Exception bietet beide Parameter (`src/Exception/FlareException.php:17-18`), der Code nutzt beide uneinheitlich mit demselben Inhalt (`__METHOD__`): Loader nutzen `method:` (`src/Engine/Loader/InteractiveLoader.php:52`, `AggregationLoader.php:52`), Projector/Views/Calendar-Integration `source:` (`src/Engine/Projector/AbstractProjector.php:124`, `src/Engine/View/HandlesModelsTrait.php:33,42,57,66,75`, `src/Integration/ContaoCalendar/Loader/EventsAggregationLoader.php:66`), `ValidationLoader` keins von beiden (`src/Engine/Loader/ValidationLoader.php:57,92`). +> +> **Nutzer-Antwort: Angeglichen -- method: __METHOD__, source, wenn verfügbar: table.id -- übertragen auf gesamte Codebase** ## A-09: `symfony/event-dispatcher` nicht direkt deklariert — Minor (claude, reduzierter Umfang) diff --git a/src/Controller/ContentElement/ListViewController.php b/src/Controller/ContentElement/ListViewController.php index be182efb..c8864d9e 100644 --- a/src/Controller/ContentElement/ListViewController.php +++ b/src/Controller/ContentElement/ListViewController.php @@ -81,7 +81,7 @@ protected function getFrontendResponse(Template $template, ContentModel $content $listModel = $contentModel->getRelated(ContentContainer::FIELD_LIST); if (!$listModel instanceof ListModel) { - throw new FilterException('No list model found.'); + throw new FilterException('No list model found.', method: __METHOD__); } } catch (\Exception $e) @@ -109,8 +109,12 @@ protected function getFrontendResponse(Template $template, ContentModel $content } catch (FlareException $e) { - $this->logger->error(\sprintf('%s (tl_content.id=%s, tl_flare_list.id=%s)', $e->getMessage(), $contentModel->id, $listModel->id), - ['contao' => new ContaoContext(__METHOD__, ContaoContext::ERROR), 'exception' => $e]); + $this->logger->error(\sprintf( + '%s (tl_content.id=%s, tl_flare_list.id=%s)', + $e->getMessage(), + $contentModel->id, + $listModel->id + ), ['contao' => new ContaoContext(__METHOD__, ContaoContext::ERROR), 'exception' => $e]); return $this->getErrorResponse($e); } diff --git a/src/Controller/ContentElement/ReaderController.php b/src/Controller/ContentElement/ReaderController.php index f6ed867d..7213a6ad 100644 --- a/src/Controller/ContentElement/ReaderController.php +++ b/src/Controller/ContentElement/ReaderController.php @@ -95,7 +95,7 @@ protected function getFrontendResponse(Template $template, ContentModel $content $listModel = $contentModel->getRelated(ContentContainer::FIELD_LIST); if (!$listModel instanceof ListModel) { - throw new FlareException('No list model found.'); + throw new FlareException('No list model found.', method: __METHOD__); } } catch (\Exception $e) diff --git a/src/Engine/Engine.php b/src/Engine/Engine.php index 7efb845f..b95a4ed1 100644 --- a/src/Engine/Engine.php +++ b/src/Engine/Engine.php @@ -50,7 +50,10 @@ public function createView(): ViewInterface ['type' => $type, 'config' => $config] = $modConf; $mod = $this->engineModRegistry->get($type) - ?? throw new FlareException(\sprintf('No FLARE engine mod registered with type "%s".', $type)); + ?? throw new FlareException( + \sprintf('No FLARE engine mod registered with type "%s".', $type), + method: __METHOD__, + ); $mod->apply($engine, $config); } diff --git a/src/Engine/Loader/ValidationLoader.php b/src/Engine/Loader/ValidationLoader.php index bab17347..40df10a0 100644 --- a/src/Engine/Loader/ValidationLoader.php +++ b/src/Engine/Loader/ValidationLoader.php @@ -73,7 +73,7 @@ public function fetchEntryById(int $id): ?array } catch (\Throwable $e) { - throw new FlareException($e->getMessage(), $e->getCode(), $e); + throw new FlareException($e->getMessage(), $e->getCode(), $e, method: __METHOD__); } } @@ -120,7 +120,7 @@ public function fetchEntryByAutoItem(string $autoItem): ?array } catch (\Throwable $e) { - throw new FlareException($e->getMessage(), $e->getCode(), $e); + throw new FlareException($e->getMessage(), $e->getCode(), $e, method: __METHOD__); } } diff --git a/src/Engine/Projector/AbstractProjector.php b/src/Engine/Projector/AbstractProjector.php index d1107a9e..82c52dfc 100644 --- a/src/Engine/Projector/AbstractProjector.php +++ b/src/Engine/Projector/AbstractProjector.php @@ -96,7 +96,7 @@ protected function getProjectorFor( catch (ContainerExceptionInterface $e) { throw new FlareException(\sprintf('Failed to locate service "%s"', ProjectorRegistry::class), - previous: $e, source: __METHOD__); + previous: $e, method: __METHOD__); } } @@ -121,7 +121,7 @@ protected function getCurrentRequest(): Request } catch (ContainerExceptionInterface $e) { - throw new FlareException('Request not available', previous: $e, source: __METHOD__); + throw new FlareException('Request not available', previous: $e, method: __METHOD__); } return $request; diff --git a/src/Engine/View/HandlesModelsTrait.php b/src/Engine/View/HandlesModelsTrait.php index 42549897..5c14ae7b 100644 --- a/src/Engine/View/HandlesModelsTrait.php +++ b/src/Engine/View/HandlesModelsTrait.php @@ -22,7 +22,7 @@ public function fetchModel(string $table, int|string $id_or_alias, callable $get // Contao native model cache { Controller::loadDataContainer($table); - + if (!isset($GLOBALS['TL_DCA'][$table]['fields']['published']) || $model->published) { return $model; } @@ -30,7 +30,7 @@ public function fetchModel(string $table, int|string $id_or_alias, callable $get $modelClass = Model::getClassFromTable($table); if (!\class_exists($modelClass)) { - throw new FlareException(\sprintf('Model class does not exist: "%s"', $modelClass), source: __METHOD__); + throw new FlareException(\sprintf('Model class does not exist: "%s"', $modelClass), method: __METHOD__); } if (!$row = $getEntry($id_or_alias)) { @@ -39,7 +39,7 @@ public function fetchModel(string $table, int|string $id_or_alias, callable $get $model = new $modelClass($row); if (!$model instanceof Model) { - throw new FlareException('Invalid model instance.', source: __METHOD__); + throw new FlareException('Invalid model instance.', method: __METHOD__); } $registry->register($model); @@ -54,7 +54,7 @@ public function createModelsFromEntries(string $table, array $entries): array { $modelClass = Model::getClassFromTable($table); if (!\class_exists($modelClass)) { - throw new FlareException(\sprintf('Model class does not exist: "%s"', $modelClass), source: __METHOD__); + throw new FlareException(\sprintf('Model class does not exist: "%s"', $modelClass), method: __METHOD__); } $registry = Model\Registry::getInstance(); @@ -63,7 +63,7 @@ public function createModelsFromEntries(string $table, array $entries): array foreach ($entries as $entry) { if (!$id = $entry['id'] ?? null) { - throw new FlareException('Entry does not have an ID.', source: __METHOD__); + throw new FlareException('Entry does not have an ID.', method: __METHOD__); } if (!$model = $registry->fetch($table, $id)) @@ -72,7 +72,7 @@ public function createModelsFromEntries(string $table, array $entries): array $model = new $modelClass($entry); if (!$model instanceof Model) { - throw new FlareException('Invalid model instance.', source: __METHOD__); + throw new FlareException('Invalid model instance.', method: __METHOD__); } $registry->register($model); diff --git a/src/Exception/InferenceException.php b/src/Exception/InferenceException.php index fbccb4ee..c79518a1 100644 --- a/src/Exception/InferenceException.php +++ b/src/Exception/InferenceException.php @@ -15,9 +15,10 @@ public function __construct( protected string $translationKey = '', protected array $formatParams = [], int $code = 0, - ?\Throwable $previous = null + ?\Throwable $previous = null, + ?string $method = null, ) { - parent::__construct($message, $code, $previous); + parent::__construct($message, $code, $previous, $method); } public function getTranslationKey(): string @@ -29,4 +30,4 @@ public function getFormatParams(): array { return $this->formatParams; } -} \ No newline at end of file +} diff --git a/src/Exception/ViewException.php b/src/Exception/ViewException.php index 318899be..f2054b0f 100644 --- a/src/Exception/ViewException.php +++ b/src/Exception/ViewException.php @@ -21,7 +21,7 @@ public static function create(string $expectedClass, mixed $var, ?string $method return new self( message: \sprintf('Expected instance of %s, got %s', $expectedClass, $type), - method: $method + method: $method, ); } -} \ No newline at end of file +} diff --git a/src/Filter/Element/ArchiveFilterElement.php b/src/Filter/Element/ArchiveFilterElement.php index e5688c8f..18b0592b 100644 --- a/src/Filter/Element/ArchiveFilterElement.php +++ b/src/Filter/Element/ArchiveFilterElement.php @@ -118,7 +118,10 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co $parents = $this->fetchParents($ptable, $config['whitelist_parents']); if (!$parents) { - throw new FilterException('No whitelisted parents defined or parent table class invalid.'); + throw new FilterException( + 'No whitelisted parents defined or parent table class invalid.', + method: __METHOD__, + ); } foreach ($parents as $parent) @@ -132,7 +135,7 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co if (!$inferrer->isDcaDynamicPtable()) // no valid ptable available { - throw new FilterException('No valid ptable found.'); + throw new FilterException('No valid ptable found.', method: __METHOD__); } /** @@ -141,7 +144,7 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co if (!$groups = $config['group_whitelist_parents']) { - throw new FilterException('No whitelisted parents defined.'); + throw new FilterException('No whitelisted parents defined.', method: __METHOD__); } foreach ($groups as $group) @@ -158,7 +161,7 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co } if (!$choices->count()) { - throw new FilterException('No valid whitelisted parents defined.'); + throw new FilterException('No valid whitelisted parents defined.', method: __METHOD__); } $choices->setModelSuffix('(%@name%)'); @@ -190,7 +193,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont if ($inferrer->getDcaMainPtable()) { if (!$pids = \array_column($selectedModels, 'id')) { - throw new FilterException('No valid parent archive ids extracted.'); + throw new FilterException('No valid parent archive ids extracted.', method: __METHOD__); } $builder->add(ArchiveFilterType::class, [ @@ -204,7 +207,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont if (!$inferrer->isDcaDynamicPtable()) // no valid ptable available { - throw new FilterException('No valid ptable found.'); + throw new FilterException('No valid ptable found.', method: __METHOD__); } /** diff --git a/src/Filter/Factory/FilterFormFactory.php b/src/Filter/Factory/FilterFormFactory.php index 9493e319..6ad5db51 100644 --- a/src/Filter/Factory/FilterFormFactory.php +++ b/src/Filter/Factory/FilterFormFactory.php @@ -35,7 +35,10 @@ public function __construct( public function create(ListSpec $list, FormContextInterface $context): FormInterface { if (!$context instanceof ContextInterface) { - throw new FlareException('Filter form context must implement ContextInterface.', method: __METHOD__); + throw new FlareException( + 'Filter form context must implement ContextInterface.', + method: __METHOD__, + ); } $name = $context->getFormName(); diff --git a/src/Filter/FilterBuilder.php b/src/Filter/FilterBuilder.php index 6a317f7a..5d8863e7 100644 --- a/src/Filter/FilterBuilder.php +++ b/src/Filter/FilterBuilder.php @@ -36,7 +36,10 @@ public function __construct( public function add(string $type, array $options = [], ?string $targetAlias = null): static { if (!$filterType = $this->filterTypeRegistry->get($type)) { - throw new FilterException(\sprintf('No FLARE filter type service registered for "%s".', $type)); + throw new FilterException( + \sprintf('No FLARE filter type service registered for "%s".', $type), + method: __METHOD__, + ); } if (!isset(self::$optionsResolvers[$type])) diff --git a/src/Filter/Type/ArchiveFilterType.php b/src/Filter/Type/ArchiveFilterType.php index 88cfcc86..8e605f96 100644 --- a/src/Filter/Type/ArchiveFilterType.php +++ b/src/Filter/Type/ArchiveFilterType.php @@ -22,10 +22,10 @@ public function buildQuery(FilterQueryBuilder $builder, array $options): void $ids = \array_values(\array_unique(\array_filter(\array_map('\intval', $options['parent_ids'])))); if (!$ids) { - throw new FilterException('No valid parent archive ids extracted.'); + throw new FilterException('No valid parent archive ids extracted.', method: __METHOD__); } $builder->where($builder->expr()->in($builder->column($options['field']), ':pids')) ->setParameter('pids', $ids, ArrayParameterType::INTEGER); } -} \ No newline at end of file +} diff --git a/src/Filter/Type/DcaSelectFilterType.php b/src/Filter/Type/DcaSelectFilterType.php index 12ace775..4fa383b9 100644 --- a/src/Filter/Type/DcaSelectFilterType.php +++ b/src/Filter/Type/DcaSelectFilterType.php @@ -50,7 +50,7 @@ public function buildQuery(FilterQueryBuilder $builder, array $options): void } if (\count(\array_unique($validOptions)) !== \count($validOptions)) { - throw new FilterException('The options for the DCA select field must be unique.'); + throw new FilterException('Options for the DCA select field must be unique.', method: __METHOD__); } $filtered = []; @@ -73,4 +73,4 @@ public function buildQuery(FilterQueryBuilder $builder, array $options): void $builder->where($builder->expr()->in($builder->column($field), ':values')) ->setParameter('values', $filtered); } -} \ No newline at end of file +} diff --git a/src/Filter/Type/SimpleEquationFilterType.php b/src/Filter/Type/SimpleEquationFilterType.php index b7735c69..fefffdd7 100644 --- a/src/Filter/Type/SimpleEquationFilterType.php +++ b/src/Filter/Type/SimpleEquationFilterType.php @@ -44,7 +44,7 @@ public function buildQuery(FilterQueryBuilder $builder, array $options): void $operator = SqlEquationOperator::match($options['operator']); if (!$operandLeft || !$operator instanceof SqlEquationOperator) { - throw new FilterException('Invalid filter configuration.'); + throw new FilterException('Invalid filter configuration.', method: __METHOD__); } $operandLeft = $builder->column($operandLeft); @@ -64,7 +64,10 @@ public function buildQuery(FilterQueryBuilder $builder, array $options): void }; if (!$where) { - throw new FilterException('Invalid filter configuration: Operator not supported.'); + throw new FilterException( + 'Invalid filter configuration: Operator not supported.', + method: __METHOD__, + ); } $builder->where($where); @@ -74,4 +77,4 @@ public function buildQuery(FilterQueryBuilder $builder, array $options): void $builder->setParameter(':eq_right', $operandRight); } } -} \ No newline at end of file +} diff --git a/src/InferPtable/PtableInferrer.php b/src/InferPtable/PtableInferrer.php index 4312ec50..05429905 100644 --- a/src/InferPtable/PtableInferrer.php +++ b/src/InferPtable/PtableInferrer.php @@ -86,17 +86,23 @@ public function getEntityDca(): array } if (!$this->entityTable) { - throw new InferenceException('No entity table set'); + throw new InferenceException('No entity table set', method: __METHOD__); } Controller::loadDataContainer($this->entityTable); if (!$dca = $GLOBALS['TL_DCA'][$this->entityTable] ?? null) { - throw new InferenceException(\sprintf('No data container array found for "%s"', $this->entityTable)); + throw new InferenceException( + \sprintf('No data container array found for "%s"', $this->entityTable), + method: __METHOD__, + ); } if (!\is_array($dca)) { - throw new \InvalidArgumentException(\sprintf('Invalid data container array for "%s"', $this->entityTable)); + throw new \InvalidArgumentException(\sprintf( + 'Invalid data container array for "%s"', + $this->entityTable + )); } return $this->entityDca = $dca; diff --git a/src/Integration/ContaoCalendar/Loader/EventsAggregationLoader.php b/src/Integration/ContaoCalendar/Loader/EventsAggregationLoader.php index 0b221654..d6d52160 100644 --- a/src/Integration/ContaoCalendar/Loader/EventsAggregationLoader.php +++ b/src/Integration/ContaoCalendar/Loader/EventsAggregationLoader.php @@ -63,7 +63,7 @@ public function fetchCount(): int } catch (\Throwable $e) { - throw new FlareException($e->getMessage(), $e->getCode(), $e, source: __METHOD__); + throw new FlareException($e->getMessage(), $e->getCode(), $e, method: __METHOD__); } } -} \ No newline at end of file +} diff --git a/src/Query/Executor/FilterExecutor.php b/src/Query/Executor/FilterExecutor.php index 60a7bb22..0dafecf1 100644 --- a/src/Query/Executor/FilterExecutor.php +++ b/src/Query/Executor/FilterExecutor.php @@ -78,7 +78,7 @@ public function invokeFilter(Filter $filter, FilterContext $context, array $data throw new FlareException(\sprintf( '[FLARE] ListSpec data container cannot be used as SQL table identifier: "%s"', $table - ), method: __METHOD__); + ), method: __METHOD__, source: $filter->source ?: 'filter inlined'); } $isTargeted = $this->filterElementRegistry->getAttribute($filter->type)?->isTargeted; @@ -114,7 +114,8 @@ public function invokeFilter(Filter $filter, FilterContext $context, array $data } catch (\Throwable $e) { - throw new FilterException($e->getMessage(), code: $e->getCode(), previous: $e, method: __METHOD__); + throw new FilterException($e->getMessage(), code: $e->getCode(), previous: $e, + method: __METHOD__, source: $filter->source ?: 'filter inlined'); } $this->eventDispatcher->dispatch(new FilterElementBuiltEvent($context, $builder, $data)); @@ -148,7 +149,8 @@ private function buildQueryBuilders(array $calls, Filter $filter): array } catch (\Throwable $e) { - throw new FilterException($e->getMessage(), code: $e->getCode(), previous: $e, method: $call->typeClass); + throw new FilterException($e->getMessage(), code: $e->getCode(), previous: $e, + method: $call->typeClass, source: $filter->source ?: 'filter inlined'); } $filterQueryBuilders[] = $filterQueryBuilder; diff --git a/src/Query/FilterQueryBuilder.php b/src/Query/FilterQueryBuilder.php index 5ef813d8..16243c56 100644 --- a/src/Query/FilterQueryBuilder.php +++ b/src/Query/FilterQueryBuilder.php @@ -51,7 +51,10 @@ public function alias(): string public function column(string $column): string { if (!\preg_match('/^[a-zA-Z0-9_]+$/', $column)) { - throw new FilterException('Invalid column name: only alphanumeric characters and underscores are allowed.'); + throw new FilterException( + 'Invalid column name: only alphanumeric characters and underscores are allowed.', + method: __METHOD__, + ); } return $this->connection->quoteIdentifier($this->alias() . '.' . $column); @@ -282,4 +285,4 @@ function (array $matches) use ($prefix, &$parameters, &$types): string return new FilterQuery($alias, $sql, $parameters, $types); } -} \ No newline at end of file +} diff --git a/src/Registry/ProjectorRegistry.php b/src/Registry/ProjectorRegistry.php index e1644645..1dd87f3a 100644 --- a/src/Registry/ProjectorRegistry.php +++ b/src/Registry/ProjectorRegistry.php @@ -26,9 +26,9 @@ public function __construct( * @throws FlareException If no projector is found. */ public function getProjectorFor( - ListSpec $spec, - ContextInterface $config, - ?array $exclude = null + ListSpec $list, + ContextInterface $config, + ?array $exclude = null ): ProjectorInterface { $exclude = $exclude ? \array_fill_keys($exclude, true) : null; $winner = null; @@ -40,11 +40,11 @@ public function getProjectorFor( continue; } - if (!$projector->supports($spec, $config)) { + if (!$projector->supports($list, $config)) { continue; } - $priority = $projector->priority($spec, $config); + $priority = $projector->priority($list, $config); if ($priority > $highestPriority) { $highestPriority = $priority; @@ -56,7 +56,7 @@ public function getProjectorFor( throw new FlareException(\sprintf( 'No projector found supporting context configuration "%s".', \get_class($config) - )); + ), method: __METHOD__, source: $list->source ?? 'list inlined'); } return $winner; diff --git a/src/Sort/Factory/SortOrderSequenceFactory.php b/src/Sort/Factory/SortOrderSequenceFactory.php index b3101d16..dcd1f619 100644 --- a/src/Sort/Factory/SortOrderSequenceFactory.php +++ b/src/Sort/Factory/SortOrderSequenceFactory.php @@ -38,11 +38,17 @@ public function createFromSettings(array $settings, ?string $defaultAlias = null foreach ($settings as $item) { if (!\is_array($item) || \count($item) < 2 || \count($item) > 3) { - throw new FlareException('Invalid sort settings format. Expected array of arrays with two or three elements.'); + throw new FlareException( + 'Invalid sort settings format. Expected array of arrays with two or three elements.', + method: __METHOD__, + ); } if (!isset($item['column'], $item['direction'])) { - throw new FlareException('Invalid sort settings format. Expected array with "column" and "direction" keys (optionally "alias").'); + throw new FlareException( + 'Invalid sort settings format. Expected array with "column" and "direction" keys (optionally "alias").', + method: __METHOD__, + ); } ['column' => $column, 'direction' => $direction] = $item; @@ -66,4 +72,4 @@ public function createFromSettings(array $settings, ?string $defaultAlias = null return new SortOrderSequence($orders); } -} \ No newline at end of file +} diff --git a/src/Sort/SortOrderSequence.php b/src/Sort/SortOrderSequence.php index 972c4274..756ce6a8 100644 --- a/src/Sort/SortOrderSequence.php +++ b/src/Sort/SortOrderSequence.php @@ -46,12 +46,16 @@ public function append(SortOrder $sort): self private function assertUnique(array $items): void { $seen = []; - foreach ($items as $order) { + + foreach ($items as $order) + { $key = $order->key(); + if (isset($seen[$key])) { - throw new FlareException("Duplicate sort key in sequence: {$key}"); + throw new FlareException("Duplicate sort key in sequence: {$key}", method: __METHOD__); } + $seen[$key] = true; } } -} \ No newline at end of file +} From f222f997c678f9fcb6d411180c361493da060314 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 21 Jul 2026 12:03:44 +0200 Subject: [PATCH 71/71] refactor: improve error handling and type consistency in List and Reader controllers --- .audit/260719012-combined/10-architektur.md | 86 +++++++++++-------- composer.json | 2 +- .../ContentElement/ListViewController.php | 8 ++ .../ContentElement/ReaderController.php | 10 ++- .../Factory/InteractiveContextFactory.php | 2 +- .../Factory/ValidationContextFactory.php | 1 - src/Engine/Context/InteractiveContext.php | 6 +- .../Context/ReaderUrlConfigCreatorTrait.php | 24 ++++-- src/Engine/Context/ValidationContext.php | 12 ++- src/Engine/Loader/ValidationLoader.php | 4 +- src/List/ResolvedListDriver.php | 2 + src/List/Resolver/ListDriverResolver.php | 2 + src/Util/EntryCache.php | 2 + translations/flare.de.yaml | 1 + translations/flare.en.yaml | 1 + 15 files changed, 112 insertions(+), 51 deletions(-) diff --git a/.audit/260719012-combined/10-architektur.md b/.audit/260719012-combined/10-architektur.md index 9ed82daf..4d8a6b65 100644 --- a/.audit/260719012-combined/10-architektur.md +++ b/.audit/260719012-combined/10-architektur.md @@ -61,46 +61,60 @@ Doku-Drift gegen den aktuellen Code: `ListBuilderFactory` heißt `ListSpecBuilde > > **Nutzer-Antwort: Angeglichen -- method: __METHOD__, source, wenn verfügbar: table.id -- übertragen auf gesamte Codebase** -## A-09: `symfony/event-dispatcher` nicht direkt deklariert — Minor (claude, reduzierter Umfang) - -`FilterFormFactory` instanziiert direkt `new EventDispatcher()` (`src/Filter/Factory/FilterFormFactory.php:17,70`), deklariert ist aber nur `symfony/event-dispatcher-contracts` (`composer.json:17`); das konkrete Paket kommt nur transitiv über `contao/core-bundle`. - -## A-10: `ValidationLoader::executeQuery()` liefert `[]` statt `null` bei abgebrochenem Query-Aufbau — Minor (claude) - -Bei `!$qb` wird `[]` zurückgegeben — harmlos (falsy), aber semantisch schief gegenüber dem `?array`-Vertrag, in dem `null` „nicht gefunden" bedeutet (`:117`: `return $entry ?: null;`). - -- `src/Engine/Loader/ValidationLoader.php:107-109` - -## A-11: Query-Assemblierung lebt in Event-Listener-Prioritäten ohne zentrale Übersicht — Info (claude) - -Select@490, Conditions@470, Page@430, Order@420, Join@-450; Integrations-Listener dazwischen (250/220/200/190/100). Die Gesamtordnung ist nirgends zentral dokumentiert (kein Pipeline-Kommentar im `ListQueryDirector`). - -- `src/EventListener/QueryStructModifier/SelectModifierListener.php:13`, `ConditionsModifierListener.php:11`, `PageModifierListener.php:11`, `OrderModifierListener.php:12`, `JoinModifierListener.php:10` · `src/Integration/ContaoCalendar/EventListener/CountEventsModifierListener.php:14` u. a. - -## A-12: `ViewInterface` ist leerer Marker; Aufrufer müssen downcasten — Info (claude) - -Das Interface ist leer (`src/Engine/View/ViewInterface.php:7-9`); `ReaderController` downcastet auf `ValidationView` (`src/Controller/ContentElement/ReaderController.php:127`). Die `@template`-Annotationen sind nur mit dem `generics.noParent`-Ignore in PHPStan haltbar. - -## A-13: `#[TaggedIterator]` ist seit Symfony 7.1 deprecated — Info (claude) - -Genutzt in drei Registries; relevant für Deprecation-Logs bei Support-Matrix ^5.4|^6|^7. Nachfolger `AutowireIterator` existiert erst ab 6.3 → für die Matrix ggf. `!tagged_iterator` in YAML. - -- `src/Registry/EngineModRegistry.php:15` · `src/Registry/ProjectorRegistry.php:19` · `src/Registry/FilterTypeRegistry.php:18` - -## A-14: Statische Contao-Aufrufe in Context-DTOs — Info (claude, reduzierter Umfang) - -`PageModel::findByPk` in wertartigen Context-Objekten — DB-Zugriffe, testfeindlich, aber Contao-idiomatisch. +> ## A-09: `symfony/event-dispatcher` nicht direkt deklariert — Minor (claude, reduzierter Umfang) +> +> `FilterFormFactory` instanziiert direkt `new EventDispatcher()` (`src/Filter/Factory/FilterFormFactory.php:17,70`), deklariert ist aber nur `symfony/event-dispatcher-contracts` (`composer.json:17`); das konkrete Paket kommt nur transitiv über `contao/core-bundle`. +> +> **Nutzer-Antwort: Required in composer.json** -- `src/Engine/Context/ReaderUrlConfigCreatorTrait.php:18` · `src/Engine/Context/ValidationContext.php:44` +> ## A-10: `ValidationLoader::executeQuery()` liefert `[]` statt `null` bei abgebrochenem Query-Aufbau — Minor (claude) +> +> Bei `!$qb` wird `[]` zurückgegeben — harmlos (falsy), aber semantisch schief gegenüber dem `?array`-Vertrag, in dem `null` „nicht gefunden" bedeutet (`:117`: `return $entry ?: null;`). +> +> - `src/Engine/Loader/ValidationLoader.php:107-109` +> +> **Nutzer-Antwort: Return-type auf `array` angepasst.** -## A-15: Backend-Responses ohne Null-Check auf `$listModel` — Info (claude) +> ## A-11: Query-Assemblierung lebt in Event-Listener-Prioritäten ohne zentrale Übersicht — Info (claude) +> +> Select@490, Conditions@470, Page@430, Order@420, Join@-450; Integrations-Listener dazwischen (250/220/200/190/100). Die Gesamtordnung ist nirgends zentral dokumentiert (kein Pipeline-Kommentar im `ListQueryDirector`). +> +> - `src/EventListener/QueryStructModifier/SelectModifierListener.php:13`, `ConditionsModifierListener.php:11`, `PageModifierListener.php:11`, `OrderModifierListener.php:12`, `JoinModifierListener.php:10` · `src/Integration/ContaoCalendar/EventListener/CountEventsModifierListener.php:14` u. a. +> +> **Nutzer-Antwort: Das muss in einem zukünftigen PR nochmal überarbeitet werden.** -`getRelated()` kann `null` liefern; der Catch deckt nur Exceptions ab. Danach werden `$listModel->title` / `trans($listModel->type)` ungeprüft dereferenziert — in beiden Controllern. (Gelöschte/fehlende Liste → Backend-Crash; siehe auch SEC-03 in [30-sicherheit.md](30-sicherheit.md).) +> ## A-12: `ViewInterface` ist leerer Marker; Aufrufer müssen downcasten — Info (claude) +> +> Das Interface ist leer (`src/Engine/View/ViewInterface.php:7-9`); `ReaderController` downcastet auf `ValidationView` (`src/Controller/ContentElement/ReaderController.php:127`). Die `@template`-Annotationen sind nur mit dem `generics.noParent`-Ignore in PHPStan haltbar. -- `src/Controller/ContentElement/ReaderController.php:220-236` (Zugriff `:232-233`) · `src/Controller/ContentElement/ListViewController.php:154-168` (Zugriff `:166-167`) +> ## A-13: `#[TaggedIterator]` ist seit Symfony 7.1 deprecated — Info (claude) +> +> Genutzt in drei Registries; relevant für Deprecation-Logs bei Support-Matrix ^5.4|^6|^7. Nachfolger `AutowireIterator` existiert erst ab 6.3 → für die Matrix ggf. `!tagged_iterator` in YAML. +> +> - `src/Registry/EngineModRegistry.php:15` · `src/Registry/ProjectorRegistry.php:19` · `src/Registry/FilterTypeRegistry.php:18` +> +> **Nutzer-Antwort: Passt so.** -## A-16: `Engine`-Mods-API mischt Semantiken — Info (claude) +> ## A-14: Statische Contao-Aufrufe in Context-DTOs — Info (claude, reduzierter Umfang) +> +> `PageModel::findByPk` in wertartigen Context-Objekten — DB-Zugriffe, testfeindlich, aber Contao-idiomatisch. +> +> - `src/Engine/Context/ReaderUrlConfigCreatorTrait.php:18` · `src/Engine/Context/ValidationContext.php:44` +> +> **Nutzer-Antwort: Weiterhin statische Aufrufe, aber nun besser gekapselt.** -`addMod()` appendet numerisch, `setMod()`/`unsetMod()` arbeiten mit String-Keys im selben Array; `unsetMod()` kann appendete Mods nicht adressieren — öffentlicher `@api`-Punkt. +> ## A-15: Backend-Responses ohne Null-Check auf `$listModel` — Info (claude) +> +> `getRelated()` kann `null` liefern; der Catch deckt nur Exceptions ab. Danach werden `$listModel->title` / `trans($listModel->type)` ungeprüft dereferenziert — in beiden Controllern. (Gelöschte/fehlende Liste → Backend-Crash; siehe auch SEC-03 in [30-sicherheit.md](30-sicherheit.md).) +> +> - `src/Controller/ContentElement/ReaderController.php:220-236` (Zugriff `:232-233`) · `src/Controller/ContentElement/ListViewController.php:154-168` (Zugriff `:166-167`) +> +> **Nutzer-Antwort: Good Catch! Ist jetzt mit einer entsprechenden Warnung gesichert.** -- `src/Engine/Engine.php:66-93` +> ## A-16: `Engine`-Mods-API mischt Semantiken — Info (claude) +> +> `addMod()` appendet numerisch, `setMod()`/`unsetMod()` arbeiten mit String-Keys im selben Array; `unsetMod()` kann appendete Mods nicht adressieren — öffentlicher `@api`-Punkt. +> +> - `src/Engine/Engine.php:66-93` +> +> **Nutzer-Antwort: Das ist kein Fehler sondern explizit so gewollt. Der Nutzer hat die Wahl, Filter für mehrfache veränderung überschreibbar zu machen, oder nicht. In den meisten Fällen wird das nicht gebraucht, daher reicht Listenindexierung ohne Möglichkeit zur Änderung.** diff --git a/composer.json b/composer.json index da034a2e..d92c8439 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "psr/log": "^1.0 || ^2.0 || ^3.0", "symfony/config": "^5.4 || ^6.0 || ^7.0", "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0", + "symfony/event-dispatcher": "^5.4 || ^6.0 || ^7.0", "symfony/event-dispatcher-contracts": "^1.0 || ^2.0 || ^3.0", "symfony/filesystem": "^5.4 || ^6.0 || ^7.0", "symfony/form": "^5.4 || ^6.0 || ^7.0", @@ -35,7 +36,6 @@ "heimrichhannot/contao-test-utilities-bundle": "^0.1", "phpunit/phpunit": "^8.0 || ^9.0", "php-coveralls/php-coveralls": "^2.0", - "symfony/event-dispatcher": "^5.4 || ^6.0 || ^7.0", "symfony/phpunit-bridge": "^5.4 || ^6.0 || ^7.0", "phpstan/phpstan": "^1.10", "phpstan/phpstan-symfony": "^1.2" diff --git a/src/Controller/ContentElement/ListViewController.php b/src/Controller/ContentElement/ListViewController.php index c8864d9e..83af124b 100644 --- a/src/Controller/ContentElement/ListViewController.php +++ b/src/Controller/ContentElement/ListViewController.php @@ -164,6 +164,14 @@ protected function getBackendResponse(Template $template, ContentModel $model, R return new Response($e->getMessage()); } + if (!$listModel instanceof ListModel) { + return new Response(\sprintf( + '
%s
%s
', + $this->translator->trans('reader.invalid_list', [], 'flare'), + Str::formatHeadline($model->headline, withTags: true), + )); + } + return new Response(\sprintf( '
%s
%s [%s, %s]', (string) Str::formatHeadline($model->headline), diff --git a/src/Controller/ContentElement/ReaderController.php b/src/Controller/ContentElement/ReaderController.php index 7213a6ad..7d007465 100644 --- a/src/Controller/ContentElement/ReaderController.php +++ b/src/Controller/ContentElement/ReaderController.php @@ -125,7 +125,7 @@ protected function getFrontendResponse(Template $template, ContentModel $content $validationView = $engine->createView(); if (!$validationView instanceof ValidationView) { - throw ViewException::create(ValidationView::class, $validationView, __METHOD__); + throw ViewException::create(ValidationView::class, $validationView, method: __METHOD__); } if (!$autoItemModel = $validationView->getModelByAutoItem($autoItem)) { @@ -226,6 +226,14 @@ protected function getBackendResponse(Template $template, ContentModel $model, R return new Response($e->getMessage()); } + if (!$listModel instanceof ListModel) { + return new Response(\sprintf( + '
%s
%s
', + $this->translator->trans('reader.invalid_list', [], 'flare'), + Str::formatHeadline($model->headline, withTags: true), + )); + } + return new Response(\sprintf( '%s%s [%s, %s]', Str::formatHeadline($model->headline, withTags: true), diff --git a/src/Engine/Context/Factory/InteractiveContextFactory.php b/src/Engine/Context/Factory/InteractiveContextFactory.php index e9ef7203..b020b547 100644 --- a/src/Engine/Context/Factory/InteractiveContextFactory.php +++ b/src/Engine/Context/Factory/InteractiveContextFactory.php @@ -38,8 +38,8 @@ public function createFromContent(ContentModel $contentModel, ListSpec $list): I $config = new InteractiveContext( paginatorConfig: $paginatorConfig, - sortOrderSequence: $sortOrderSequence, formName: $filterFormName, + sortOrderSequence: $sortOrderSequence, contentModelId: (int) $contentModel->id, formActionPage: (int) $contentModel->{ContentContainer::FIELD_JUMP_TO}, jumpToReaderPageId: $jumpToReaderPageId, diff --git a/src/Engine/Context/Factory/ValidationContextFactory.php b/src/Engine/Context/Factory/ValidationContextFactory.php index 008b5ba5..7465dff4 100644 --- a/src/Engine/Context/Factory/ValidationContextFactory.php +++ b/src/Engine/Context/Factory/ValidationContextFactory.php @@ -7,7 +7,6 @@ use Contao\ContentModel; use HeimrichHannot\FlareBundle\DataContainer\ContentContainer; use HeimrichHannot\FlareBundle\Engine\Context\ValidationContext; -use HeimrichHannot\FlareBundle\Engine\View\InteractiveView; use HeimrichHannot\FlareBundle\List\ListSpec; use Symfony\Component\Validator\Exception\ValidationFailedException; use Symfony\Component\Validator\Validator\ValidatorInterface; diff --git a/src/Engine/Context/InteractiveContext.php b/src/Engine/Context/InteractiveContext.php index 8ed01494..8ca7e8e1 100644 --- a/src/Engine/Context/InteractiveContext.php +++ b/src/Engine/Context/InteractiveContext.php @@ -23,14 +23,16 @@ public static function getContextType(): string public function __construct( public PaginatorConfig $paginatorConfig, - public ?SortOrderSequence $sortOrderSequence = null, #[Assert\NotBlank] public string $formName, + public ?SortOrderSequence $sortOrderSequence = null, #[Assert\PositiveOrZero] public int $contentModelId = 0, #[Assert\PositiveOrZero] public int $formActionPage = 0, #[Assert\PositiveOrZero] public int $jumpToReaderPageId = 0, #[Assert\NotBlank] public string $autoItemField = 'id', public ?string $pageParam = null, - ) {} + ) { + $this->initJumpToReaderPage(); + } public function getFormName(): string { diff --git a/src/Engine/Context/ReaderUrlConfigCreatorTrait.php b/src/Engine/Context/ReaderUrlConfigCreatorTrait.php index 490fdcc6..eaae2acd 100644 --- a/src/Engine/Context/ReaderUrlConfigCreatorTrait.php +++ b/src/Engine/Context/ReaderUrlConfigCreatorTrait.php @@ -9,16 +9,28 @@ trait ReaderUrlConfigCreatorTrait { - public function createReaderUrlConfig(): ?ReaderUrlConfig + private \Closure $jumpToReaderPage; + + final protected function initJumpToReaderPage(): void { - if (!$this->jumpToReaderPageId) { - return null; - } + $this->jumpToReaderPage = function (): ?PageModel { + $pageModel = PageModel::findByPk($this->jumpToReaderPageId); + $this->jumpToReaderPage = static fn (): ?PageModel => $pageModel; + return $pageModel; + }; + } - if (!$pageModel = PageModel::findByPk($this->jumpToReaderPageId)) { + protected function getJumpToReaderPage(): ?PageModel + { + return ($this->jumpToReaderPage)(); + } + + public function createReaderUrlConfig(): ?ReaderUrlConfig + { + if (!$pageModel = $this->getJumpToReaderPage()) { return null; } return new ReaderUrlConfig(readerPage: $pageModel, autoItemField: $this->autoItemField); } -} \ No newline at end of file +} diff --git a/src/Engine/Context/ValidationContext.php b/src/Engine/Context/ValidationContext.php index fb98b89a..3e47d86f 100644 --- a/src/Engine/Context/ValidationContext.php +++ b/src/Engine/Context/ValidationContext.php @@ -16,6 +16,8 @@ use ReaderUrlConfigCreatorTrait; private PaginatorConfig $paginatorConfig; + private \Closure $jumpToListViewPage; + private \Closure $jumpToReaderPage; public static function getContextType(): string { @@ -29,6 +31,14 @@ public function __construct( private array $filterValues = [], ) { $this->paginatorConfig = new PaginatorConfig(itemsPerPage: 1); + + $this->jumpToListViewPage = function (): ?PageModel { + $pageModel = PageModel::findByPk($this->jumpToListViewPageId); + $this->jumpToListViewPage = static fn (): ?PageModel => $pageModel; + return $pageModel; + }; + + $this->initJumpToReaderPage(); } public function createBackLink(): ?BackLink @@ -37,7 +47,7 @@ public function createBackLink(): ?BackLink return null; } - if (!$pageModel = PageModel::findByPk($this->jumpToListViewPageId)) { + if (!$pageModel = ($this->jumpToListViewPage)()) { return null; } diff --git a/src/Engine/Loader/ValidationLoader.php b/src/Engine/Loader/ValidationLoader.php index 40df10a0..3f8284a3 100644 --- a/src/Engine/Loader/ValidationLoader.php +++ b/src/Engine/Loader/ValidationLoader.php @@ -127,7 +127,7 @@ public function fetchEntryByAutoItem(string $autoItem): ?array /** * @throws \Exception */ - private function executeQuery(ListSpec $list, ValidationContext $context): ?array + private function executeQuery(ListSpec $list, ValidationContext $context): array { $qb = $this->listQueryDirector->createQueryBuilder(new ListQueryConfig( list: $list, @@ -145,6 +145,6 @@ private function executeQuery(ListSpec $list, ValidationContext $context): ?arra $result->free(); - return $entry ?: null; + return $entry ?: []; } } diff --git a/src/List/ResolvedListDriver.php b/src/List/ResolvedListDriver.php index 1270961b..72dc8a14 100644 --- a/src/List/ResolvedListDriver.php +++ b/src/List/ResolvedListDriver.php @@ -1,5 +1,7 @@