From 2f821a786313421debbd0ac68dfe1a40513eacef Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 28 Apr 2026 16:44:28 +0200 Subject: [PATCH 01/96] Refactor: Introduce `SimpleEquationFilterType` and `AbstractFilterType` to modularize filter handling --- src/FilterElement/SimpleEquationElement.php | 53 +++------------ src/FilterType/AbstractFilterType.php | 23 +++++++ src/FilterType/FilterTypeInterface.php | 21 ++++++ src/FilterType/SimpleEquationFilterType.php | 75 +++++++++++++++++++++ 4 files changed, 129 insertions(+), 43 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 76e71aec..541411ab 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,35 +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::IN => $qb->expr()->in($operand, ':eq_right'), - SqlEquationOperator::NOT_IN => $qb->expr()->notIn($operand, ':eq_right'), - SqlEquationOperator::IS_NULL => $qb->expr()->isNull($operand), - // the default arm below is a runtime safety net for operators added to the enum later - // @phpstan-ignore match.alwaysTrue - 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')] @@ -70,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 50c9fad427a76ae1173ecbb5115508828fd54533 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 28 Apr 2026 17:36:01 +0200 Subject: [PATCH 02/96] 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/96] 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/96] 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 a19dae5e5379e867456e1f51c03b1c198b4a3c15 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Fri, 1 May 2026 19:50:17 +0200 Subject: [PATCH 05/96] 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 ------------------ .../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 | 26 ++-- 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 | 10 +- src/Query/Executor/FilterExecutor.php | 107 ++++++++++------ 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 ++++++++++++++ 75 files changed, 1511 insertions(+), 1218 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/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 29edf78b..413b6bbe 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; } @@ -167,16 +160,15 @@ private function hydrateForm(FormInterface $form, ListSpecification $list): void message: 'Filter form does not contain field: ' . $filterName, previous: $exception, method: __METHOD__, - source: $filterDefinition->getDataSource()?->getFilterIdentifier() ?? 'filter inlined' + source: $configuredFilter->getDataSource()?->getFilterIdentifier() ?? 'filter inlined' ); } - $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)); } @@ -186,9 +178,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; @@ -199,7 +191,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 541411ab..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, ); 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 2844c470..9247d5a3 100644 --- a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php +++ b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php @@ -127,27 +127,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, // )); diff --git a/src/Query/Executor/FilterExecutor.php b/src/Query/Executor/FilterExecutor.php index 0fa014bb..ffbf5b96 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()) 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 6aee1018b5801a541a339b40b6e2ab90568f89a3 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Fri, 1 May 2026 20:04:00 +0200 Subject: [PATCH 06/96] 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/96] 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 61b8b1e3da7de7678c8b901d17f88e5375cc00cd Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Fri, 1 May 2026 22:55:31 +0200 Subject: [PATCH 08/96] 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 04066bbbbe8b0ffb8295a234c34c8dd75085070e Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Mon, 13 Jul 2026 13:55:30 +0200 Subject: [PATCH 09/96] 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 e0cd2b408887901ca7f0cb2fe0c4e7a44130682b Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Mon, 13 Jul 2026 17:13:28 +0200 Subject: [PATCH 10/96] 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 | 82 +-- 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 | 116 ++-- .../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, 2702 insertions(+), 3776 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 8d866618..0f8a0aa1 100644 --- a/src/Engine/Mod/SimpleEquationMod.php +++ b/src/Engine/Mod/SimpleEquationMod.php @@ -27,14 +27,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 413b6bbe..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,88 +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) - { - throw new FlareException( - message: 'Filter form does not contain field: ' . $filterName, - previous: $exception, - method: __METHOD__, - source: $configuredFilter->getDataSource()?->getFilterIdentifier() ?? '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 1336431c..dcf48ef4 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 ffbf5b96..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: $filter->getDataSource()?->getFilterIdentifier() ?? 'filter inlined', + source: $filter->source ?: 'filter inlined', ); } } 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 a2532380..573eb409 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 4c20aea7ab09f49723f8db9c644953cc39751b8c Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Mon, 13 Jul 2026 18:17:45 +0200 Subject: [PATCH 11/96] 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 | 4 +-- .../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 | 8 ++--- 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, 238 insertions(+), 213 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 0f8a0aa1..7e1c6901 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 @@ -21,7 +21,7 @@ public function __invoke(Engine $engine, array $options): void $operator = SqlEquationOperator::match($options['operator']) ?? throw new \InvalidArgumentException('Invalid equation operator provided'); - $filter = SimpleEquationElement::define( + $filter = SimpleEquationFilterElement::define( equationLeft: $options['operand1'], equationOperator: $operator, equationRight: $options['operand2'], 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 9247d5a3..cdebc0b8 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 HeimrichHannot\FlareBundle\Util\Str; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; @@ -132,7 +132,7 @@ public function listViewFetchCountEvent(FetchCountEvent $event): void 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' @@ -140,7 +140,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' 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 676ecaf18da37fc050bc1dde4800b35acd2d8230 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Mon, 13 Jul 2026 19:20:41 +0200 Subject: [PATCH 12/96] 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 0b2e8d32778d92b40bda9793a264d9a38d648682 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Mon, 13 Jul 2026 19:20:56 +0200 Subject: [PATCH 13/96] 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 582935dba8cd3ec94c168124b98c5390fda5fd3a Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Mon, 13 Jul 2026 19:36:22 +0200 Subject: [PATCH 14/96] 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 7873a9a405434233cbee34537361e6d89de35511 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Mon, 13 Jul 2026 19:54:09 +0200 Subject: [PATCH 15/96] 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 3f063252b5119ef677685515fcbc578072cc6023 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Mon, 13 Jul 2026 20:02:09 +0200 Subject: [PATCH 16/96] 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 12910a9e866640d71ac96a164b1bc9e614aaf309 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Mon, 13 Jul 2026 20:13:01 +0200 Subject: [PATCH 17/96] 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 78d5555ea5f336122a6e0b92b759a6d756c96e1e Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 12:21:30 +0200 Subject: [PATCH 18/96] 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 3ace5c19b1f37b239a45d2d4083a79edf40018ff Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 12:28:48 +0200 Subject: [PATCH 19/96] 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 b61fa4b6f5b26675cc4f61dec320be50c10b3915 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 12:41:31 +0200 Subject: [PATCH 20/96] 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 | 16 ++-- .../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(+), 462 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 fbd3b35a..094e6a6a 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 7e1c6901..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,13 +19,14 @@ public static function getType(): string public function __invoke(Engine $engine, array $options): void { - $operator = SqlEquationOperator::match($options['operator']) - ?? throw new \InvalidArgumentException('Invalid equation operator provided'); - - $filter = SimpleEquationFilterElement::define( - equationLeft: $options['operand1'], - equationOperator: $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 cdebc0b8..1b53419c 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; @@ -132,18 +133,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 25f7b86875ae43d313f651dc0cd1fdc2c173879a Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 12:44:04 +0200 Subject: [PATCH 21/96] =?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 6995ec3be151257a8c391a65ed17b8dfd8da7cb5 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 12:55:56 +0200 Subject: [PATCH 22/96] 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 56d3ad50..4c16a4b7 100644 --- a/src/Controller/ContentElement/ListViewController.php +++ b/src/Controller/ContentElement/ListViewController.php @@ -21,7 +21,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; @@ -47,7 +47,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, @@ -101,13 +101,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 e569e3ee..f54d2189 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; } } 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 1b53419c..e53d2ed7 100644 --- a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php +++ b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php @@ -54,9 +54,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 92cf17e38c7bca4f3ca74e73c45c96be1cd49a79 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 12:56:41 +0200 Subject: [PATCH 23/96] 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 747d5398e5e992407dc1583670cd784523cb8553 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 13:02:46 +0200 Subject: [PATCH 24/96] 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 9be42372351894e0348bfc8f5fe2e500c01955ac Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 14:01:33 +0200 Subject: [PATCH 25/96] 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 4c16a4b7..00db336c 100644 --- a/src/Controller/ContentElement/ListViewController.php +++ b/src/Controller/ContentElement/ListViewController.php @@ -47,7 +47,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, @@ -225,4 +225,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 92d80f66d06f796222ac7f6c07b680992cba5cbd Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 14:05:02 +0200 Subject: [PATCH 26/96] 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 00db336c..a57ca95c 100644 --- a/src/Controller/ContentElement/ListViewController.php +++ b/src/Controller/ContentElement/ListViewController.php @@ -21,7 +21,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 f54d2189..6e0ca222 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 4ce3c7716d7974fc18a3911bd0e83d8dedfc3825 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 14:46:11 +0200 Subject: [PATCH 27/96] 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 | 2 +- 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, 97 insertions(+), 114 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 a57ca95c..46fc3e2d 100644 --- a/src/Controller/ContentElement/ListViewController.php +++ b/src/Controller/ContentElement/ListViewController.php @@ -13,15 +13,15 @@ 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\Engine\View\InteractiveView; -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 6e0ca222..03b79078 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 { 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 e53d2ed7..a1d09aca 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 ef32f49ec33f3c086350808fbec13de1183a1ef0 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 16:15:06 +0200 Subject: [PATCH 28/96] 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 1bc98c9d92c238919e41b44f0031aaf7fe308b04 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 16:38:31 +0200 Subject: [PATCH 29/96] 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 8a5de6edc3bf05aa4a40d0b9e895dac869699d65 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 16:45:15 +0200 Subject: [PATCH 30/96] 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 24e1324e294d64a4da3eb8d676143873240c5de3 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 16:50:52 +0200 Subject: [PATCH 31/96] 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/96] 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 d45a834cda3c6ecde33c7da4b7f689f4d721ead9 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 17:55:17 +0200 Subject: [PATCH 33/96] 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 20a5f4055c17466896dc61bd88dcf8e3ae93248a Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 14 Jul 2026 18:55:22 +0200 Subject: [PATCH 34/96] 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 4c285108b2326d3b541f24eb6977d8254e8b066d Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 00:19:44 +0200 Subject: [PATCH 35/96] 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 5a8e867eddc55704945140ca459738bf731c3b52 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 02:38:16 +0200 Subject: [PATCH 36/96] 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 8e347e706e60d6084226e19d41bc74b6b15486d0 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 14:49:05 +0200 Subject: [PATCH 37/96] 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 45ab8ded54640c3fed8fc40e3e59a4d452abb934 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 14:59:09 +0200 Subject: [PATCH 38/96] 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 994d6c330692265566541626e4b66df351a294b8 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 15:12:52 +0200 Subject: [PATCH 39/96] 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 8abf7cfe471f2afe2644a645f71421a7958c9f19 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 15:23:32 +0200 Subject: [PATCH 40/96] 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 209271022080294e45e85e669d423a18b00b958a Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 15:29:03 +0200 Subject: [PATCH 41/96] 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 5d9cb334b83bffb2a797b42b1e7d911078455bd8 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 17:31:15 +0200 Subject: [PATCH 42/96] 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 46fc3e2d..99ab98cd 100644 --- a/src/Controller/ContentElement/ListViewController.php +++ b/src/Controller/ContentElement/ListViewController.php @@ -20,7 +20,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; @@ -47,7 +47,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 a1d09aca..0caabf26 100644 --- a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php +++ b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php @@ -134,7 +134,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), @@ -146,7 +146,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 e228fc6d0fa7d21940348b9e857899f349718fc5 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 17:31:27 +0200 Subject: [PATCH 43/96] 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 a8e0fedc27241a305809cf92317e615a17c7c40c Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 17:50:27 +0200 Subject: [PATCH 44/96] 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 0caabf26..ee4768fd 100644 --- a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php +++ b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php @@ -56,7 +56,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 a4fad6e70efa2414b223e57aed4b9d3e6b812def Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 19:07:37 +0200 Subject: [PATCH 45/96] 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 f30ec8d5593ed78747eaab2de37340405d30194a Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 19:33:52 +0200 Subject: [PATCH 46/96] 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 aa025bef698cbfc7fa338f81977252def4e49b25 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 19:39:03 +0200 Subject: [PATCH 47/96] 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 2918dda46443c755871de3957762e6b36a6e87d4 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 19:43:36 +0200 Subject: [PATCH 48/96] 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 110250c2a0a7c2ab49210c92c98c2ca16e48d472 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 15 Jul 2026 19:48:57 +0200 Subject: [PATCH 49/96] 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 84d49bcff3dab89bfd8f357add2dfcf6a8965001 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Fri, 17 Jul 2026 01:55:42 +0200 Subject: [PATCH 50/96] 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 a23d08d5eb121107af0e4e9aacada9bfc209404d Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Fri, 17 Jul 2026 03:37:20 +0200 Subject: [PATCH 51/96] 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 ee4768fd..3301dc46 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; @@ -38,6 +38,7 @@ class ChangelanguageListener public function __construct( private readonly Connection $connection, + private readonly FilterFactory $filterFactory, private readonly ReaderRequestAttributeResolver $attributeResolver, private readonly RequestStack $requestStack, ) {} @@ -56,7 +57,7 @@ public function fetchAutoItem(FetchAutoItemEvent $event): void { $list = $event->getList(); - if ($list->type !== DcMultilingualListType::TYPE) { + if (!$list->driver instanceof DcMultilingualListType) { return; } @@ -64,7 +65,7 @@ public function fetchAutoItem(FetchAutoItemEvent $event): void return; } - $table = $list->dc; + $table = $list->getDataContainerName(); $this->applyMlQueriesIfNecessary( $event->getListQueryBuilder(), @@ -133,8 +134,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), @@ -145,8 +146,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 fe32a52802b861b10ea735891a20e57df94f8b0d Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Fri, 17 Jul 2026 03:58:12 +0200 Subject: [PATCH 52/96] 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 333f09e8bb1baf7542f443b8ebd4acb11faea593 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Fri, 17 Jul 2026 04:16:17 +0200 Subject: [PATCH 53/96] 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 3301dc46..26b4b481 100644 --- a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php +++ b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php @@ -65,7 +65,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 ff729703fe8831b7038532b79283530844c48e14 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Fri, 17 Jul 2026 04:26:36 +0200 Subject: [PATCH 54/96] 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 f593ff5b6c24ad23858868eb795062d59200fc61 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Fri, 17 Jul 2026 04:57:01 +0200 Subject: [PATCH 55/96] 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/96] 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 977e43adea0d28cfff055d95206a6712b90962e1 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Fri, 17 Jul 2026 05:19:31 +0200 Subject: [PATCH 57/96] 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 d8ad46fe46e61208510d98bf74edbbd2eedb06dd Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Sat, 18 Jul 2026 04:22:02 +0200 Subject: [PATCH 58/96] 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 | 2 +- src/Model/ListModel.php | 5 + .../Factory/ReaderRequestAttributeFactory.php | 18 ++- src/Reader/ReaderRequestAttribute.php | 26 ++-- 33 files changed, 312 insertions(+), 222 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 26b4b481..69e7c2b4 100644 --- a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php +++ b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php @@ -270,8 +270,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 dcf48ef4..aafb3f30 100644 --- a/src/Model/FilterModel.php +++ b/src/Model/FilterModel.php @@ -26,7 +26,7 @@ public function getFilterIdentifier(): string return \sprintf('%s.id=%s', static::$strTable, $this->id); } - public function getFilterType(): string + public function getFilterElementType(): string { return (string) $this->type; } 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 63a0231e0785a595ef5152514b33f707b40b3c83 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Sat, 18 Jul 2026 04:36:13 +0200 Subject: [PATCH 59/96] 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 fd667dccbb47303a58915bec51ab3ea8a1c12d7a Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Sat, 18 Jul 2026 04:53:21 +0200 Subject: [PATCH 60/96] 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 | 2 +- 25 files changed, 71 insertions(+), 251 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 69e7c2b4..a52c4cfd 100644 --- a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php +++ b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php @@ -210,21 +210,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('/' . Str::urlEncodePath($event->getAutoItem())); + $url = $langPage->getAbsoluteUrl('/' . Str::urlEncodePath($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 cbcb7179..3a8b3006 100644 --- a/src/Reader/ReaderUrlGenerator.php +++ b/src/Reader/ReaderUrlGenerator.php @@ -34,6 +34,6 @@ public function generate(Model $model): ?string ) ); - return $event->getUrl(); + return $event->url; } } From 1c8d1bc1703d2e24608d424657148b4226546537 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Sat, 18 Jul 2026 05:02:53 +0200 Subject: [PATCH 61/96] 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 6a816ecef34487510fdd8192ab5750b2774cf6f8 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Sat, 18 Jul 2026 05:49:40 +0200 Subject: [PATCH 62/96] 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 0f8ceecff90d19f0d8fd9c5200363f8f4ea70f32 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Sun, 19 Jul 2026 00:12:50 +0200 Subject: [PATCH 63/96] 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 db6084f3b5cfd533b4330f17be9cc1557abb4128 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Sun, 19 Jul 2026 01:23:10 +0200 Subject: [PATCH 64/96] refactor: enhance immutability and streamline type handling across multiple components, improve tag registration, and fix minor logic inconsistencies --- src/Controller/ContentElement/ListViewController.php | 2 +- src/Engine/Loader/AggregationLoader.php | 4 ++-- src/Engine/Mod/ModInterface.php | 6 ++++-- src/Engine/Projector/ProjectorInterface.php | 6 ++++-- src/Filter/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 | 11 +++++++---- 11 files changed, 31 insertions(+), 20 deletions(-) diff --git a/src/Controller/ContentElement/ListViewController.php b/src/Controller/ContentElement/ListViewController.php index 99ab98cd..9cfc9b90 100644 --- a/src/Controller/ContentElement/ListViewController.php +++ b/src/Controller/ContentElement/ListViewController.php @@ -122,7 +122,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 573eb409..e17f1bd3 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 From e7e22cc05366e39a56fd05dd819e558c4b4ec15b Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Sun, 19 Jul 2026 15:07:50 +0200 Subject: [PATCH 65/96] 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 094e6a6a..c39f4132 100644 --- a/contao/templates/content_element/flare_listview.html.twig +++ b/contao/templates/content_element/flare_listview.html.twig @@ -32,7 +32,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 9cfc9b90..5d8cf16f 100644 --- a/src/Controller/ContentElement/ListViewController.php +++ b/src/Controller/ContentElement/ListViewController.php @@ -212,17 +212,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 aafb3f30..60903ae1 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 e17f1bd3..b5041a1e 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 b07c88fcee16b0831978e0a8fab838df028e1e43 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Sun, 19 Jul 2026 15:11:15 +0200 Subject: [PATCH 66/96] 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 5d8cf16f..c359dfa5 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 2f98cadf460f2b0e395406e5097020c25fda7178 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Mon, 20 Jul 2026 01:43:44 +0200 Subject: [PATCH 67/96] 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 b80e41be6746960fffe203f5cc7d4116e8d998e3 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Mon, 20 Jul 2026 18:15:56 +0200 Subject: [PATCH 68/96] 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 2e37580aaf8033880c417df0ce436e10ce2d22e6 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Mon, 20 Jul 2026 19:21:55 +0200 Subject: [PATCH 69/96] 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 a0bedb2b4aa926b87b5eeecc3f68d89ff31811a2 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 21 Jul 2026 10:41:05 +0200 Subject: [PATCH 70/96] 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 c359dfa5..81aa23ec 100644 --- a/src/Controller/ContentElement/ListViewController.php +++ b/src/Controller/ContentElement/ListViewController.php @@ -87,7 +87,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) @@ -115,8 +115,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 03b79078..9a84fe49 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 5ff459a27ca7b3197ff9f3a7c10402e2743f36e8 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 21 Jul 2026 12:03:44 +0200 Subject: [PATCH 71/96] 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 81aa23ec..0269a69b 100644 --- a/src/Controller/ContentElement/ListViewController.php +++ b/src/Controller/ContentElement/ListViewController.php @@ -215,6 +215,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 @@ Date: Wed, 2 Sep 2026 16:36:27 +0200 Subject: [PATCH 72/96] refactor: enhance `ConfigBuilder`, improve `FilterFactory` and `FilterTransformerResolver` readability Added `has()` and `unset()` methods in `ConfigBuilder` for better configuration management. Refactored `FilterFactory::resolveElement()` and `FilterTransformerResolver` for improved code clarity and structure. --- src/Config/ConfigBuilder.php | 12 ++++++++++++ src/Filter/Factory/FilterFactory.php | 6 ++++-- src/Filter/Resolver/FilterTransformerResolver.php | 4 +++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/Config/ConfigBuilder.php b/src/Config/ConfigBuilder.php index d06693e9..53d2d4eb 100644 --- a/src/Config/ConfigBuilder.php +++ b/src/Config/ConfigBuilder.php @@ -23,6 +23,18 @@ public function set(string $key, mixed $value): self return $this; } + public function unset(string $key): self + { + unset($this->config[$key]); + + return $this; + } + + public function has(string $key): bool + { + return \array_key_exists($key, $this->config); + } + public function get(string $key): mixed { return $this->config[$key] ?? null; diff --git a/src/Filter/Factory/FilterFactory.php b/src/Filter/Factory/FilterFactory.php index 236da740..4fd50312 100644 --- a/src/Filter/Factory/FilterFactory.php +++ b/src/Filter/Factory/FilterFactory.php @@ -95,8 +95,10 @@ private function resolveType(FilterElementInterface|string $element, ?string $so /** * @throws FlareException */ - private function resolveElement(FilterElementInterface|string $element, ?string $source = null): FilterElementInterface - { + private function resolveElement( + FilterElementInterface|string $element, + ?string $source = null + ): FilterElementInterface { if ($element instanceof FilterElementInterface) { return $element; } diff --git a/src/Filter/Resolver/FilterTransformerResolver.php b/src/Filter/Resolver/FilterTransformerResolver.php index 1f2ca5e1..57ded7a7 100644 --- a/src/Filter/Resolver/FilterTransformerResolver.php +++ b/src/Filter/Resolver/FilterTransformerResolver.php @@ -51,7 +51,9 @@ public function transform(FilterElementInterface $element, string $type, object return null; } - $transformer($config = new ConfigBuilder(), $source); + $config = new ConfigBuilder(); + + $transformer($config, $source); return $config->all(); } From 8dc132a8eb1040a0bb4c0ad28d13f09eeaeb445c Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 2 Sep 2026 18:41:03 +0200 Subject: [PATCH 73/96] refactor: replace closures with `LazyPage` for jump-to page handling in contexts Introduced the `LazyPage` utility for lazy-loading `PageModel` instances, replacing closure-based logic for jump-to pages. Updated `InteractiveContext`, `ValidationContext`, and `ReaderUrlConfigCreatorTrait` to use `LazyPage` for improved readability and consistency. --- src/Engine/Context/InteractiveContext.php | 7 ++--- .../Context/ReaderUrlConfigCreatorTrait.php | 15 +++-------- src/Engine/Context/ValidationContext.php | 21 ++++----------- src/Util/LazyPage.php | 27 +++++++++++++++++++ 4 files changed, 40 insertions(+), 30 deletions(-) create mode 100644 src/Util/LazyPage.php diff --git a/src/Engine/Context/InteractiveContext.php b/src/Engine/Context/InteractiveContext.php index 8ca7e8e1..8391f333 100644 --- a/src/Engine/Context/InteractiveContext.php +++ b/src/Engine/Context/InteractiveContext.php @@ -6,6 +6,7 @@ use HeimrichHannot\FlareBundle\Paginator\PaginatorConfig; use HeimrichHannot\FlareBundle\Sort\SortOrderSequence; +use HeimrichHannot\FlareBundle\Util\LazyPage; use Symfony\Component\Validator\Constraints as Assert; class InteractiveContext implements @@ -31,7 +32,7 @@ public function __construct( #[Assert\NotBlank] public string $autoItemField = 'id', public ?string $pageParam = null, ) { - $this->initJumpToReaderPage(); + $this->jumpToReaderPage = new LazyPage($jumpToReaderPageId); } public function getFormName(): string @@ -71,13 +72,13 @@ public function with( ): static { return new self( paginatorConfig: $paginatorConfig ?? $this->paginatorConfig, - sortOrderSequence: $this->sortOrderSequence, formName: $formName ?? $this->formName, + sortOrderSequence: $this->sortOrderSequence, contentModelId: $this->contentModelId, formActionPage: $this->formActionPage, jumpToReaderPageId: $this->jumpToReaderPageId, autoItemField: $this->autoItemField, - pageParam: $pageParam ?? $this->pageParam + pageParam: $pageParam ?? $this->pageParam, ); } } diff --git a/src/Engine/Context/ReaderUrlConfigCreatorTrait.php b/src/Engine/Context/ReaderUrlConfigCreatorTrait.php index eaae2acd..424873ad 100644 --- a/src/Engine/Context/ReaderUrlConfigCreatorTrait.php +++ b/src/Engine/Context/ReaderUrlConfigCreatorTrait.php @@ -6,23 +6,16 @@ use Contao\PageModel; use HeimrichHannot\FlareBundle\Reader\ReaderUrlConfig; +use HeimrichHannot\FlareBundle\Util\LazyPage; trait ReaderUrlConfigCreatorTrait { - private \Closure $jumpToReaderPage; - - final protected function initJumpToReaderPage(): void - { - $this->jumpToReaderPage = function (): ?PageModel { - $pageModel = PageModel::findByPk($this->jumpToReaderPageId); - $this->jumpToReaderPage = static fn (): ?PageModel => $pageModel; - return $pageModel; - }; - } + /** Must be initialized by the using class' constructor. */ + private readonly LazyPage $jumpToReaderPage; protected function getJumpToReaderPage(): ?PageModel { - return ($this->jumpToReaderPage)(); + return $this->jumpToReaderPage->get(); } public function createReaderUrlConfig(): ?ReaderUrlConfig diff --git a/src/Engine/Context/ValidationContext.php b/src/Engine/Context/ValidationContext.php index 3e47d86f..c1fd6896 100644 --- a/src/Engine/Context/ValidationContext.php +++ b/src/Engine/Context/ValidationContext.php @@ -4,9 +4,9 @@ namespace HeimrichHannot\FlareBundle\Engine\Context; -use Contao\PageModel; use HeimrichHannot\FlareBundle\Paginator\PaginatorConfig; use HeimrichHannot\FlareBundle\Reader\BackLink; +use HeimrichHannot\FlareBundle\Util\LazyPage; use Symfony\Component\Validator\Constraints as Assert; readonly class ValidationContext implements @@ -16,8 +16,7 @@ use ReaderUrlConfigCreatorTrait; private PaginatorConfig $paginatorConfig; - private \Closure $jumpToListViewPage; - private \Closure $jumpToReaderPage; + private LazyPage $jumpToListViewPage; public static function getContextType(): string { @@ -31,23 +30,13 @@ 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(); + $this->jumpToReaderPage = new LazyPage($jumpToReaderPageId); + $this->jumpToListViewPage = new LazyPage($jumpToListViewPageId); } public function createBackLink(): ?BackLink { - if (!$this->jumpToListViewPageId) { - return null; - } - - if (!$pageModel = ($this->jumpToListViewPage)()) { + if (!$pageModel = $this->jumpToListViewPage->get()) { return null; } diff --git a/src/Util/LazyPage.php b/src/Util/LazyPage.php new file mode 100644 index 00000000..bb00f0bf --- /dev/null +++ b/src/Util/LazyPage.php @@ -0,0 +1,27 @@ +resolved) { + $this->page = $this->id > 0 ? PageModel::findByPk($this->id) : null; + $this->resolved = true; + } + + return $this->page; + } +} From e9872fd53e6894fcdd59c28b01b04b7defc9d6cc Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 2 Sep 2026 18:53:21 +0200 Subject: [PATCH 74/96] refactor: integrate `ListDriverResolver` into `ListSpecFactory` and related tests Updated `ListSpecFactory` and its test suite to use `ListDriverResolver` for resolving drivers. Adjusted `InteractiveContext` return type to `self` for consistency. Introduced new dependency injection in `ListSpecBuilderTest` for improved driver resolution handling. Added initializations in `ElementDcaListener` for clearer variable declarations. --- src/Engine/Context/InteractiveContext.php | 2 +- .../Contao/ElementDcaListener.php | 3 +++ .../NamedDispatch/ListBuildListenerTest.php | 8 ++++++- tests/List/ListSpecBuilderTest.php | 23 +++++++++++++++---- tests/List/ListSpecFactoryTest.php | 6 ++++- 5 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/Engine/Context/InteractiveContext.php b/src/Engine/Context/InteractiveContext.php index 8391f333..d4e902aa 100644 --- a/src/Engine/Context/InteractiveContext.php +++ b/src/Engine/Context/InteractiveContext.php @@ -69,7 +69,7 @@ public function with( ?PaginatorConfig $paginatorConfig = null, ?string $formName = null, ?string $pageParam = null, - ): static { + ): self { return new self( paginatorConfig: $paginatorConfig ?? $this->paginatorConfig, formName: $formName ?? $this->formName, diff --git a/src/EventListener/Contao/ElementDcaListener.php b/src/EventListener/Contao/ElementDcaListener.php index 2d8b0094..bf27708a 100644 --- a/src/EventListener/Contao/ElementDcaListener.php +++ b/src/EventListener/Contao/ElementDcaListener.php @@ -69,6 +69,9 @@ private function configure(string $table): void return; } + $type = ''; + $service = null; + if ($table === FilterModel::getTable()) { $filterModel = FilterModel::findByPk($id); diff --git a/tests/EventListener/NamedDispatch/ListBuildListenerTest.php b/tests/EventListener/NamedDispatch/ListBuildListenerTest.php index 02b06ba1..bd5d6f1c 100644 --- a/tests/EventListener/NamedDispatch/ListBuildListenerTest.php +++ b/tests/EventListener/NamedDispatch/ListBuildListenerTest.php @@ -9,6 +9,7 @@ use HeimrichHannot\FlareBundle\EventListener\NamedDispatch\ListBuildListener; use HeimrichHannot\FlareBundle\List\Factory\ListSpecFactory; 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\AbstractListDriver; @@ -50,11 +51,16 @@ static function () use (&$names, $type): void { ); } + $registry = new ListDriverRegistry(); + $listDriverResolver = new ListDriverResolver($registry); + $builder = new ListSpecBuilder( + listDriverResolver: $listDriverResolver, specFactory: new ListSpecFactory( - new ListDriverRegistry(), + $registry, new ListOptionsResolver(new SchemaResolver()), new ListTransformerResolver($dispatcher), + $listDriverResolver, ), eventDispatcher: $dispatcher, driver: $driver, diff --git a/tests/List/ListSpecBuilderTest.php b/tests/List/ListSpecBuilderTest.php index e798b1bb..6daaf0c6 100644 --- a/tests/List/ListSpecBuilderTest.php +++ b/tests/List/ListSpecBuilderTest.php @@ -16,6 +16,7 @@ use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\List\Factory\ListSpecFactory; 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\AbstractListDriver; @@ -117,8 +118,12 @@ protected function transformListModel(ConfigBuilder $config, ListModel $model): public function testBuildFailsWithoutAnyDataContainer(): void { + $registry = new ListDriverRegistry(); + $listDriverResolver = new ListDriverResolver($registry); + $builder = new ListSpecBuilder( - specFactory: self::specFactory(), + listDriverResolver: $listDriverResolver, + specFactory: self::specFactory($registry, $listDriverResolver), eventDispatcher: new EventDispatcher(), driver: new class extends AbstractListDriver {}, source: 'tl_flare_list.9', @@ -144,12 +149,16 @@ public function testInvalidConfigThrowsWithSourceProvenance(): void } } - private static function specFactory(?EventDispatcher $dispatcher = null): ListSpecFactory - { + private static function specFactory( + ListDriverRegistry $registry, + ListDriverResolver $listDriverResolver, + ?EventDispatcher $dispatcher = null, + ): ListSpecFactory { return new ListSpecFactory( - new ListDriverRegistry(), + $registry, new ListOptionsResolver(new SchemaResolver()), new ListTransformerResolver($dispatcher ?? new EventDispatcher()), + $listDriverResolver, ); } @@ -158,8 +167,12 @@ private function createBuilder( ?ListDriverInterface $driver = null, ?ListModel $model = null, ): ListSpecBuilder { + $registry = new ListDriverRegistry(); + $listDriverResolver = new ListDriverResolver($registry); + return new ListSpecBuilder( - specFactory: self::specFactory($dispatcher), + listDriverResolver: $listDriverResolver, + specFactory: self::specFactory($registry, $listDriverResolver, $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 76889130..8aaa0ccb 100644 --- a/tests/List/ListSpecFactoryTest.php +++ b/tests/List/ListSpecFactoryTest.php @@ -7,6 +7,7 @@ use HeimrichHannot\FlareBundle\Config\SchemaResolver; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\List\Factory\ListSpecFactory; +use HeimrichHannot\FlareBundle\List\Resolver\ListDriverResolver; use HeimrichHannot\FlareBundle\List\Resolver\ListOptionsResolver; use HeimrichHannot\FlareBundle\List\Resolver\ListTransformerResolver; use HeimrichHannot\FlareBundle\List\Driver\AbstractListDriver; @@ -18,10 +19,13 @@ final class ListSpecFactoryTest extends TestCase { private function createFactory(?ListDriverRegistry $registry = null): ListSpecFactory { + $registry ??= new ListDriverRegistry(); + return new ListSpecFactory( - $registry ?? new ListDriverRegistry(), + $registry, new ListOptionsResolver(new SchemaResolver()), new ListTransformerResolver(new EventDispatcher()), + new ListDriverResolver($registry), ); } From 5d6655c34bf0062b68d027f117ec45ff99c420d8 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 2 Sep 2026 18:55:17 +0200 Subject: [PATCH 75/96] refactor: enable strict array index existence check in mago config --- mago.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mago.toml b/mago.toml index 4dedb4e4..83aa7274 100644 --- a/mago.toml +++ b/mago.toml @@ -41,5 +41,5 @@ find-unused-definitions = true find-unused-expressions = false analyze-dead-code = false check-throws = true -allow-possibly-undefined-array-keys = false +strict-array-index-existence = true perform-heuristic-checks = true From cb00935208687701c1b9e6627e49d87a258bac2e Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 2 Sep 2026 19:05:45 +0200 Subject: [PATCH 76/96] refactor: replace form action handling in `InteractiveContext` with `LazyPage` Replaced `formActionPage` with `LazyPage` for improved lazy-loading capabilities. Updated `FilterFormFactory` and associated interfaces to use `createFormActionUrl()`. Removed redundant `resolveFormAction()` method for cleaner code. Adjusted tests to reflect the changes. --- .../Factory/InteractiveContextFactory.php | 2 +- src/Engine/Context/InteractiveContext.php | 23 +++++++++++-------- .../Interface/FormContextInterface.php | 4 ++-- src/Filter/Factory/FilterFormFactory.php | 23 ++++--------------- tests/Form/FilterFormFactoryTest.php | 4 ++-- 5 files changed, 22 insertions(+), 34 deletions(-) diff --git a/src/Engine/Context/Factory/InteractiveContextFactory.php b/src/Engine/Context/Factory/InteractiveContextFactory.php index b020b547..b00ee22e 100644 --- a/src/Engine/Context/Factory/InteractiveContextFactory.php +++ b/src/Engine/Context/Factory/InteractiveContextFactory.php @@ -41,7 +41,7 @@ public function createFromContent(ContentModel $contentModel, ListSpec $list): I formName: $filterFormName, sortOrderSequence: $sortOrderSequence, contentModelId: (int) $contentModel->id, - formActionPage: (int) $contentModel->{ContentContainer::FIELD_JUMP_TO}, + formActionPageId: (int) $contentModel->{ContentContainer::FIELD_JUMP_TO}, jumpToReaderPageId: $jumpToReaderPageId, autoItemField: $fieldAutoItem, ); diff --git a/src/Engine/Context/InteractiveContext.php b/src/Engine/Context/InteractiveContext.php index d4e902aa..bb327281 100644 --- a/src/Engine/Context/InteractiveContext.php +++ b/src/Engine/Context/InteractiveContext.php @@ -17,6 +17,8 @@ class InteractiveContext implements { use ReaderUrlConfigCreatorTrait; + private readonly LazyPage $formActionPage; + public static function getContextType(): string { return 'interactive'; @@ -27,27 +29,28 @@ public function __construct( #[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 $formActionPageId = 0, #[Assert\PositiveOrZero] public int $jumpToReaderPageId = 0, #[Assert\NotBlank] public string $autoItemField = 'id', public ?string $pageParam = null, ) { + $this->formActionPage = new LazyPage($formActionPageId); $this->jumpToReaderPage = new LazyPage($jumpToReaderPageId); } - public function getFormName(): string + public function getPaginatorConfig(): PaginatorConfig { - return $this->formName; + return $this->paginatorConfig; } - public function getFormActionPage(): int + public function getFormName(): string { - return $this->formActionPage; + return $this->formName; } - public function getPaginatorConfig(): PaginatorConfig + public function getSortOrderSequence(): ?SortOrderSequence { - return $this->paginatorConfig; + return $this->sortOrderSequence; } public function getPaginatorQueryParameter(): ?string @@ -60,9 +63,9 @@ public function setPaginatorQueryParameter(?string $queryParameter): void $this->pageParam = $queryParameter; } - public function getSortOrderSequence(): ?SortOrderSequence + public function createFormActionUrl(): ?string { - return $this->sortOrderSequence; + return $this->formActionPage->get()?->getAbsoluteUrl(); } public function with( @@ -75,7 +78,7 @@ public function with( formName: $formName ?? $this->formName, sortOrderSequence: $this->sortOrderSequence, contentModelId: $this->contentModelId, - formActionPage: $this->formActionPage, + formActionPageId: $this->formActionPageId, jumpToReaderPageId: $this->jumpToReaderPageId, autoItemField: $this->autoItemField, pageParam: $pageParam ?? $this->pageParam, diff --git a/src/Engine/Context/Interface/FormContextInterface.php b/src/Engine/Context/Interface/FormContextInterface.php index baf22f82..06c15a5e 100644 --- a/src/Engine/Context/Interface/FormContextInterface.php +++ b/src/Engine/Context/Interface/FormContextInterface.php @@ -8,5 +8,5 @@ interface FormContextInterface { public function getFormName(): string; - public function getFormActionPage(): int; -} \ No newline at end of file + public function createFormActionUrl(): ?string; +} diff --git a/src/Filter/Factory/FilterFormFactory.php b/src/Filter/Factory/FilterFormFactory.php index 6ad5db51..ef141c1f 100644 --- a/src/Filter/Factory/FilterFormFactory.php +++ b/src/Filter/Factory/FilterFormFactory.php @@ -4,7 +4,6 @@ namespace HeimrichHannot\FlareBundle\Filter\Factory; -use Contao\PageModel; use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; use HeimrichHannot\FlareBundle\Engine\Context\Interface\FormContextInterface; use HeimrichHannot\FlareBundle\Event\FilterElementFormBuiltEvent; @@ -52,7 +51,7 @@ public function create(ListSpec $list, FormContextInterface $context): FormInter ], ]; - if ($action = $this->resolveFormAction($context)) { + if ($action = $context->createFormActionUrl()) { $formOptions['action'] = $action; } @@ -125,12 +124,11 @@ public function create(ListSpec $list, FormContextInterface $context): FormInter /* * **Always add submit buttons in templates, not in the form builder!** - * This is not advised: + * This is NOT advised: * ```php * if ($builder->count()) { - * $builder->add('submit', SubmitType::class, [ - * 'label' => 'submit', - * ]); + * $builder->add('submit', SubmitType::class, [ 'label' => 'submit']); + * } * ``` */ @@ -146,17 +144,4 @@ public function create(ListSpec $list, FormContextInterface $context): FormInter return $builder->getForm(); } - - private function resolveFormAction(FormContextInterface $config): ?string - { - if (!$jumpTo = $config->getFormActionPage()) { - return null; - } - - if (!$pageModel = PageModel::findByPk($jumpTo)) { - return null; - } - - return $pageModel->getAbsoluteUrl(); - } } diff --git a/tests/Form/FilterFormFactoryTest.php b/tests/Form/FilterFormFactoryTest.php index 9dbef1ff..d2423518 100644 --- a/tests/Form/FilterFormFactoryTest.php +++ b/tests/Form/FilterFormFactoryTest.php @@ -79,9 +79,9 @@ public function getFormName(): string return 'flare_test'; } - public function getFormActionPage(): int + public function createFormActionUrl(): ?string { - return 0; + return null; } }; From 3f4c3ca012f0f9e07fd89882167b6582527f8270 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 2 Sep 2026 19:19:38 +0200 Subject: [PATCH 77/96] refactor: remove redundant `Interface` namespace from context interfaces Moved `FormContextInterface`, `PaginatedContextInterface`, and `SortableContextInterface` from the `Interface` namespace to `Context` for consistency. Updated all associated imports and references across the codebase. --- src/Engine/Context/ContextInterface.php | 2 +- src/Engine/Context/{Interface => }/FormContextInterface.php | 2 +- src/Engine/Context/InteractiveContext.php | 6 +++--- .../Context/{Interface => }/PaginatedContextInterface.php | 4 ++-- .../Context/{Interface => }/SortableContextInterface.php | 4 ++-- src/Engine/Context/ValidationContext.php | 2 +- src/Engine/Mod/PageParamMod.php | 4 ++-- src/Engine/Projector/InteractiveProjector.php | 2 +- .../QueryStructModifier/OrderModifierListener.php | 4 ++-- .../QueryStructModifier/PageModifierListener.php | 4 ++-- src/Filter/Factory/FilterFormFactory.php | 2 +- .../ContaoCalendar/Loader/EventsInteractiveLoader.php | 4 ++-- tests/Form/FilterFormFactoryTest.php | 2 +- 13 files changed, 21 insertions(+), 21 deletions(-) rename src/Engine/Context/{Interface => }/FormContextInterface.php (72%) rename src/Engine/Context/{Interface => }/PaginatedContextInterface.php (83%) rename src/Engine/Context/{Interface => }/SortableContextInterface.php (74%) diff --git a/src/Engine/Context/ContextInterface.php b/src/Engine/Context/ContextInterface.php index bfdc5087..2aea7b82 100644 --- a/src/Engine/Context/ContextInterface.php +++ b/src/Engine/Context/ContextInterface.php @@ -10,4 +10,4 @@ interface ContextInterface * Returns the unique machine name of this context type (e.g., 'interactive'). */ public static function getContextType(): string; -} \ No newline at end of file +} diff --git a/src/Engine/Context/Interface/FormContextInterface.php b/src/Engine/Context/FormContextInterface.php similarity index 72% rename from src/Engine/Context/Interface/FormContextInterface.php rename to src/Engine/Context/FormContextInterface.php index 06c15a5e..bf957b0b 100644 --- a/src/Engine/Context/Interface/FormContextInterface.php +++ b/src/Engine/Context/FormContextInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Engine\Context\Interface; +namespace HeimrichHannot\FlareBundle\Engine\Context; interface FormContextInterface { diff --git a/src/Engine/Context/InteractiveContext.php b/src/Engine/Context/InteractiveContext.php index bb327281..e5efc7e1 100644 --- a/src/Engine/Context/InteractiveContext.php +++ b/src/Engine/Context/InteractiveContext.php @@ -11,9 +11,9 @@ class InteractiveContext implements ContextInterface, - Interface\FormContextInterface, - Interface\PaginatedContextInterface, - Interface\SortableContextInterface + FormContextInterface, + PaginatedContextInterface, + SortableContextInterface { use ReaderUrlConfigCreatorTrait; diff --git a/src/Engine/Context/Interface/PaginatedContextInterface.php b/src/Engine/Context/PaginatedContextInterface.php similarity index 83% rename from src/Engine/Context/Interface/PaginatedContextInterface.php rename to src/Engine/Context/PaginatedContextInterface.php index d2f74b2a..f580ab4a 100644 --- a/src/Engine/Context/Interface/PaginatedContextInterface.php +++ b/src/Engine/Context/PaginatedContextInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Engine\Context\Interface; +namespace HeimrichHannot\FlareBundle\Engine\Context; use HeimrichHannot\FlareBundle\Paginator\PaginatorConfig; @@ -13,4 +13,4 @@ public function getPaginatorConfig(): PaginatorConfig; public function getPaginatorQueryParameter(): ?string; public function setPaginatorQueryParameter(?string $queryParameter): void; -} \ No newline at end of file +} diff --git a/src/Engine/Context/Interface/SortableContextInterface.php b/src/Engine/Context/SortableContextInterface.php similarity index 74% rename from src/Engine/Context/Interface/SortableContextInterface.php rename to src/Engine/Context/SortableContextInterface.php index 702d8a47..cfd3b152 100644 --- a/src/Engine/Context/Interface/SortableContextInterface.php +++ b/src/Engine/Context/SortableContextInterface.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Engine\Context\Interface; +namespace HeimrichHannot\FlareBundle\Engine\Context; use HeimrichHannot\FlareBundle\Sort\SortOrderSequence; interface SortableContextInterface { public function getSortOrderSequence(): ?SortOrderSequence; -} \ No newline at end of file +} diff --git a/src/Engine/Context/ValidationContext.php b/src/Engine/Context/ValidationContext.php index c1fd6896..43d66b79 100644 --- a/src/Engine/Context/ValidationContext.php +++ b/src/Engine/Context/ValidationContext.php @@ -11,7 +11,7 @@ readonly class ValidationContext implements ContextInterface, - Interface\PaginatedContextInterface + PaginatedContextInterface { use ReaderUrlConfigCreatorTrait; diff --git a/src/Engine/Mod/PageParamMod.php b/src/Engine/Mod/PageParamMod.php index b12a0280..fa12acc8 100644 --- a/src/Engine/Mod/PageParamMod.php +++ b/src/Engine/Mod/PageParamMod.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\Engine\Mod; -use HeimrichHannot\FlareBundle\Engine\Context\Interface\PaginatedContextInterface; +use HeimrichHannot\FlareBundle\Engine\Context\PaginatedContextInterface; use HeimrichHannot\FlareBundle\Engine\Engine; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -39,4 +39,4 @@ public function configureOptions(OptionsResolver $resolver): void return $value; }); } -} \ No newline at end of file +} diff --git a/src/Engine/Projector/InteractiveProjector.php b/src/Engine/Projector/InteractiveProjector.php index a12536a2..6b0d719f 100644 --- a/src/Engine/Projector/InteractiveProjector.php +++ b/src/Engine/Projector/InteractiveProjector.php @@ -7,7 +7,7 @@ use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; use HeimrichHannot\FlareBundle\Engine\Context\Factory\AggregationContextFactory; use HeimrichHannot\FlareBundle\Engine\Context\InteractiveContext; -use HeimrichHannot\FlareBundle\Engine\Context\Interface\PaginatedContextInterface; +use HeimrichHannot\FlareBundle\Engine\Context\PaginatedContextInterface; use HeimrichHannot\FlareBundle\Engine\Loader\InteractiveEmptyLoader; use HeimrichHannot\FlareBundle\Engine\Loader\InteractiveLoaderConfig; use HeimrichHannot\FlareBundle\Engine\Loader\InteractiveLoaderInterface; diff --git a/src/EventListener/QueryStructModifier/OrderModifierListener.php b/src/EventListener/QueryStructModifier/OrderModifierListener.php index a045b3b0..ca8345db 100644 --- a/src/EventListener/QueryStructModifier/OrderModifierListener.php +++ b/src/EventListener/QueryStructModifier/OrderModifierListener.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\EventListener\QueryStructModifier; -use HeimrichHannot\FlareBundle\Engine\Context\Interface\SortableContextInterface; +use HeimrichHannot\FlareBundle\Engine\Context\SortableContextInterface; use HeimrichHannot\FlareBundle\Event\ModifyListQueryStructEvent; use HeimrichHannot\FlareBundle\Sort\SortOrder; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; @@ -34,4 +34,4 @@ public function __invoke(ModifyListQueryStructEvent $event): void $event->queryStruct->setOrderBy($order); } -} \ No newline at end of file +} diff --git a/src/EventListener/QueryStructModifier/PageModifierListener.php b/src/EventListener/QueryStructModifier/PageModifierListener.php index 3bae4443..4cf8377c 100644 --- a/src/EventListener/QueryStructModifier/PageModifierListener.php +++ b/src/EventListener/QueryStructModifier/PageModifierListener.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\EventListener\QueryStructModifier; -use HeimrichHannot\FlareBundle\Engine\Context\Interface\PaginatedContextInterface; +use HeimrichHannot\FlareBundle\Engine\Context\PaginatedContextInterface; use HeimrichHannot\FlareBundle\Event\ModifyListQueryStructEvent; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; @@ -26,4 +26,4 @@ public function __invoke(ModifyListQueryStructEvent $event): void $event->queryStruct->setLimit($paginator->getItemsPerPage() ?: null); // unlimited: 0 -> null $event->queryStruct->setOffset($paginator->getOffset()); } -} \ No newline at end of file +} diff --git a/src/Filter/Factory/FilterFormFactory.php b/src/Filter/Factory/FilterFormFactory.php index ef141c1f..45e539f9 100644 --- a/src/Filter/Factory/FilterFormFactory.php +++ b/src/Filter/Factory/FilterFormFactory.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\Filter\Factory; use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; -use HeimrichHannot\FlareBundle\Engine\Context\Interface\FormContextInterface; +use HeimrichHannot\FlareBundle\Engine\Context\FormContextInterface; use HeimrichHannot\FlareBundle\Event\FilterElementFormBuiltEvent; use HeimrichHannot\FlareBundle\Event\FilterFormBuildEvent; use HeimrichHannot\FlareBundle\Exception\FlareException; diff --git a/src/Integration/ContaoCalendar/Loader/EventsInteractiveLoader.php b/src/Integration/ContaoCalendar/Loader/EventsInteractiveLoader.php index d2baba72..fb8b73c4 100644 --- a/src/Integration/ContaoCalendar/Loader/EventsInteractiveLoader.php +++ b/src/Integration/ContaoCalendar/Loader/EventsInteractiveLoader.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\Integration\ContaoCalendar\Loader; -use HeimrichHannot\FlareBundle\Engine\Context\Interface\PaginatedContextInterface; +use HeimrichHannot\FlareBundle\Engine\Context\PaginatedContextInterface; use HeimrichHannot\FlareBundle\Engine\Loader\InteractiveLoaderConfig; use HeimrichHannot\FlareBundle\Engine\Loader\InteractiveLoaderInterface; use HeimrichHannot\FlareBundle\Integration\ContaoCalendar\GroupsEntriesTrait; @@ -85,4 +85,4 @@ public function fetchEntries(): array return $out; } -} \ No newline at end of file +} diff --git a/tests/Form/FilterFormFactoryTest.php b/tests/Form/FilterFormFactoryTest.php index d2423518..b50b4650 100644 --- a/tests/Form/FilterFormFactoryTest.php +++ b/tests/Form/FilterFormFactoryTest.php @@ -6,7 +6,7 @@ use HeimrichHannot\FlareBundle\Config\SchemaResolver; use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; -use HeimrichHannot\FlareBundle\Engine\Context\Interface\FormContextInterface; +use HeimrichHannot\FlareBundle\Engine\Context\FormContextInterface; use HeimrichHannot\FlareBundle\Event\FilterElementFormBuiltEvent; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Factory\FilterContextFactory; From 02eb1fc25c2c3ef96d2a341a0a64ce26885049e4 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Mon, 7 Sep 2026 17:09:07 +0200 Subject: [PATCH 78/96] fix: restore `IN`/`NOT_IN` operator arms in `SimpleEquationFilterType` The fix from #35 added these arms to `SimpleEquationElement::__invoke()` on `main`. This branch had already moved that `match` into `SimpleEquationFilterType::buildQuery()`, so the rebase dropped them. --- src/Filter/Type/SimpleEquationFilterType.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Filter/Type/SimpleEquationFilterType.php b/src/Filter/Type/SimpleEquationFilterType.php index fefffdd7..87ce6b59 100644 --- a/src/Filter/Type/SimpleEquationFilterType.php +++ b/src/Filter/Type/SimpleEquationFilterType.php @@ -58,7 +58,11 @@ public function buildQuery(FilterQueryBuilder $builder, array $options): void 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::IN => $builder->expr()->in($operandLeft, ':eq_right'), + SqlEquationOperator::NOT_IN => $builder->expr()->notIn($operandLeft, ':eq_right'), SqlEquationOperator::IS_NULL => $builder->expr()->isNull($operandLeft), + // the default arm below is a runtime safety net for operators added to the enum later + // @phpstan-ignore match.alwaysTrue SqlEquationOperator::IS_NOT_NULL => $builder->expr()->isNotNull($operandLeft), default => null, }; From 4263611cc9a5fd7e4a6afb2d22a709a35563cc49 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Mon, 7 Sep 2026 18:24:49 +0200 Subject: [PATCH 79/96] feat: introduce immutable `FilterData` class to standardize filter runtime data handling Added `FilterData` to encapsulate filter runtime data as an immutable object, replacing array-based values. Updated `buildFilter()` across filter elements and contexts to require `FilterData` instead of raw arrays. Refactored methods in `InteractiveProjector`, `AggregationContext`, and `ValidationContext` to align with the new filter data model. Enhanced tests for compatibility with `FilterData` and ensured backward compatibility where appropriate. --- src/Engine/Context/AggregationContext.php | 11 ++ src/Engine/Context/ValidationContext.php | 7 + src/Engine/Loader/AggregationLoaderConfig.php | 4 + src/Engine/Loader/InteractiveLoaderConfig.php | 4 + src/Engine/Projector/InteractiveProjector.php | 16 ++- src/Event/FilterElementBuildingEvent.php | 6 +- src/Event/FilterElementBuiltEvent.php | 6 +- src/Filter/Element/AbstractFilterElement.php | 5 +- src/Filter/Element/ArchiveFilterElement.php | 5 +- .../BelongsToRelationFilterElement.php | 3 +- src/Filter/Element/BooleanFilterElement.php | 5 +- .../Element/CalendarCurrentFilterElement.php | 27 ++-- src/Filter/Element/DateRangeFilterElement.php | 7 +- .../Element/DcaSelectFieldFilterElement.php | 5 +- .../Element/FieldValueChoiceFilterElement.php | 5 +- src/Filter/Element/FilterElementInterface.php | 16 ++- src/Filter/Element/PublishedFilterElement.php | 3 +- .../Element/SearchKeywordsFilterElement.php | 5 +- .../Element/SimpleEquationFilterElement.php | 3 +- src/Filter/Factory/FilterFactory.php | 4 +- src/Filter/Factory/FilterFormFactory.php | 25 ++-- src/Filter/Filter.php | 14 +- src/Filter/FilterContext.php | 6 - src/Filter/FilterData.php | 127 ++++++++++++++++++ src/Filter/FilterFormBuilderInterface.php | 12 +- .../CodefogTagsChoiceFilterElement.php | 5 +- src/Query/Executor/FilterExecutor.php | 7 +- src/Query/ListQueryConfig.php | 9 ++ .../Projector/InteractiveProjectorTest.php | 56 +++++--- tests/Filter/FilterDataTest.php | 115 ++++++++++++++++ tests/Filter/FilterFactoryTest.php | 7 +- tests/Filter/FilterOptionsResolverTest.php | 5 +- tests/Filter/FilterTest.php | 11 +- .../Filter/FilterTransformerResolverTest.php | 5 +- tests/Form/FilterFormFactoryTest.php | 22 +-- tests/List/ListSpecBuilderTest.php | 7 +- tests/List/ListSpecTest.php | 7 +- tests/List/StubFilterElement.php | 3 +- tests/Query/Executor/FilterExecutorTest.php | 121 +++++++++++++++++ tests/Registry/FilterElementRegistryTest.php | 3 +- 40 files changed, 580 insertions(+), 134 deletions(-) create mode 100644 src/Filter/FilterData.php create mode 100644 tests/Filter/FilterDataTest.php create mode 100644 tests/Query/Executor/FilterExecutorTest.php diff --git a/src/Engine/Context/AggregationContext.php b/src/Engine/Context/AggregationContext.php index c3051e79..6c923e3a 100644 --- a/src/Engine/Context/AggregationContext.php +++ b/src/Engine/Context/AggregationContext.php @@ -4,6 +4,8 @@ namespace HeimrichHannot\FlareBundle\Engine\Context; +use HeimrichHannot\FlareBundle\Filter\FilterData; + class AggregationContext implements ContextInterface { public static function getContextType(): string @@ -11,15 +13,24 @@ public static function getContextType(): string return 'aggregation'; } + /** + * @param array $filterValues + */ public function __construct( private array $filterValues = [], ) {} + /** + * @return array + */ public function getFilterValues(): array { return $this->filterValues; } + /** + * @param array $values + */ public function withFilterValues(array $values): self { $clone = clone $this; diff --git a/src/Engine/Context/ValidationContext.php b/src/Engine/Context/ValidationContext.php index 43d66b79..54157faa 100644 --- a/src/Engine/Context/ValidationContext.php +++ b/src/Engine/Context/ValidationContext.php @@ -4,6 +4,7 @@ namespace HeimrichHannot\FlareBundle\Engine\Context; +use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Paginator\PaginatorConfig; use HeimrichHannot\FlareBundle\Reader\BackLink; use HeimrichHannot\FlareBundle\Util\LazyPage; @@ -43,6 +44,9 @@ public function createBackLink(): ?BackLink return BackLink::fromPage($pageModel); } + /** + * @return array + */ public function getFilterValues(): array { return $this->filterValues; @@ -63,6 +67,9 @@ public function setPaginatorQueryParameter(?string $queryParameter): void // ignore } + /** + * @param array $values + */ public function withFilterValues(array $values): self { return new self( diff --git a/src/Engine/Loader/AggregationLoaderConfig.php b/src/Engine/Loader/AggregationLoaderConfig.php index 4dfa9611..2a86d647 100644 --- a/src/Engine/Loader/AggregationLoaderConfig.php +++ b/src/Engine/Loader/AggregationLoaderConfig.php @@ -5,10 +5,14 @@ namespace HeimrichHannot\FlareBundle\Engine\Loader; use HeimrichHannot\FlareBundle\Engine\Context\AggregationContext; +use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\List\ListSpec; readonly class AggregationLoaderConfig { + /** + * @param array $filterValues + */ public function __construct( public ListSpec $list, public AggregationContext $context, diff --git a/src/Engine/Loader/InteractiveLoaderConfig.php b/src/Engine/Loader/InteractiveLoaderConfig.php index 53eb1c51..b74f5682 100644 --- a/src/Engine/Loader/InteractiveLoaderConfig.php +++ b/src/Engine/Loader/InteractiveLoaderConfig.php @@ -5,10 +5,14 @@ namespace HeimrichHannot\FlareBundle\Engine\Loader; use HeimrichHannot\FlareBundle\Engine\Context\InteractiveContext; +use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\List\ListSpec; readonly class InteractiveLoaderConfig { + /** + * @param array $filterValues + */ public function __construct( public ListSpec $list, public InteractiveContext $context, diff --git a/src/Engine/Projector/InteractiveProjector.php b/src/Engine/Projector/InteractiveProjector.php index 6b0d719f..ce7a231f 100644 --- a/src/Engine/Projector/InteractiveProjector.php +++ b/src/Engine/Projector/InteractiveProjector.php @@ -16,6 +16,7 @@ use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\Factory\FilterFormFactory; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Paginator\Factory\PaginatorFactory; use HeimrichHannot\FlareBundle\Paginator\Paginator; @@ -117,10 +118,11 @@ 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::SINGLE_VALUE => value]` that buildFilter() consumes. * - * @return array> + * Filters that contribute nothing stay absent from the map, so a filter's programmatically + * set data can take over downstream. + * + * @return array */ protected function collectFilterData(ListSpec $list, FormInterface $form): array { @@ -141,7 +143,7 @@ protected function collectFilterData(ListSpec $list, FormInterface $form): array $value = $child->getData(); if ($form->isSubmitted() || !\is_null($value)) { - $data[$key] = [FilterContext::SINGLE_VALUE => $value]; + $data[$key] = FilterData::single($value); } continue; @@ -149,7 +151,7 @@ protected function collectFilterData(ListSpec $list, FormInterface $form): array if ($form->isSubmitted()) { - $data[$key] = (array) $child->getData(); + $data[$key] = FilterData::of((array) $child->getData()); continue; } @@ -162,7 +164,7 @@ protected function collectFilterData(ListSpec $list, FormInterface $form): array ); if ($values) { - $data[$key] = $values; + $data[$key] = FilterData::of($values); } } @@ -170,6 +172,8 @@ protected function collectFilterData(ListSpec $list, FormInterface $form): array } /** + * @param array $filterValues + * * @throws FlareException */ protected function createAggregationView( diff --git a/src/Event/FilterElementBuildingEvent.php b/src/Event/FilterElementBuildingEvent.php index 7d4e7076..51431340 100644 --- a/src/Event/FilterElementBuildingEvent.php +++ b/src/Event/FilterElementBuildingEvent.php @@ -6,17 +6,15 @@ use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterData; use Symfony\Contracts\EventDispatcher\Event; class FilterElementBuildingEvent extends Event { - /** - * @param array $data - */ public function __construct( public readonly FilterContext $context, public readonly FilterBuilderInterface $builder, - public readonly array $data = [], + public readonly FilterData $data, public bool $shouldBuild = true, ) {} } diff --git a/src/Event/FilterElementBuiltEvent.php b/src/Event/FilterElementBuiltEvent.php index 1fed5bd3..30fdfb46 100644 --- a/src/Event/FilterElementBuiltEvent.php +++ b/src/Event/FilterElementBuiltEvent.php @@ -6,16 +6,14 @@ use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterData; use Symfony\Contracts\EventDispatcher\Event; class FilterElementBuiltEvent extends Event { - /** - * @param array $data - */ public function __construct( public readonly FilterContext $context, public readonly FilterBuilderInterface $builder, - public readonly array $data = [], + public readonly FilterData $data, ) {} } diff --git a/src/Filter/Element/AbstractFilterElement.php b/src/Filter/Element/AbstractFilterElement.php index 337979d3..33c5db01 100644 --- a/src/Filter/Element/AbstractFilterElement.php +++ b/src/Filter/Element/AbstractFilterElement.php @@ -17,6 +17,7 @@ use HeimrichHannot\FlareBundle\Filter\CallbackFilterModelTransformer; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\Form\ChoicesBuilder; use HeimrichHannot\FlareBundle\Form\Factory\ChoicesBuilderFactory; @@ -50,7 +51,7 @@ public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void {} public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void {} + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void {} public function isSupported(): bool { @@ -79,7 +80,7 @@ public function setConnection(Connection $connection): void $this->connection = $connection; } - public function getConnection(): Connection + protected function getConnection(): Connection { return $this->connection; } diff --git a/src/Filter/Element/ArchiveFilterElement.php b/src/Filter/Element/ArchiveFilterElement.php index 18b0592b..7e87db56 100644 --- a/src/Filter/Element/ArchiveFilterElement.php +++ b/src/Filter/Element/ArchiveFilterElement.php @@ -14,6 +14,7 @@ use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\Filter\Type\ArchiveFilterType; use HeimrichHannot\FlareBundle\Filter\Type\BelongsToRelationFilterType; @@ -170,14 +171,14 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co /** * @throws FilterException */ - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void { $config = $context->config; /** @var Model[] $selectedModels */ $selectedModels = $config['intrinsic'] ? $this->getWhitelistedParents($context->list, $config) - : $this->processRuntimeValue($values[FilterContext::SINGLE_VALUE] ?? null, $context->list, $config); + : $this->processRuntimeValue($data->getSingleValue(), $context->list, $config); $inferrer = $this->getPtableInferrer($context->list); diff --git a/src/Filter/Element/BelongsToRelationFilterElement.php b/src/Filter/Element/BelongsToRelationFilterElement.php index 51866899..00d3515b 100644 --- a/src/Filter/Element/BelongsToRelationFilterElement.php +++ b/src/Filter/Element/BelongsToRelationFilterElement.php @@ -14,6 +14,7 @@ use HeimrichHannot\FlareBundle\Exception\InferenceException; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\Type\BelongsToRelationFilterType; use HeimrichHannot\FlareBundle\InferPtable\Factory\PtableInferrableFactory; use HeimrichHannot\FlareBundle\InferPtable\PtableInferrer; @@ -60,7 +61,7 @@ protected function transformFilterModel(ConfigBuilder $config, FilterModel $mode /** * @throws FilterException */ - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void { $config = $context->config; diff --git a/src/Filter/Element/BooleanFilterElement.php b/src/Filter/Element/BooleanFilterElement.php index beb458b8..bcf08258 100644 --- a/src/Filter/Element/BooleanFilterElement.php +++ b/src/Filter/Element/BooleanFilterElement.php @@ -14,6 +14,7 @@ use HeimrichHannot\FlareBundle\Enum\BoolMode; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\Filter\Type\BooleanFilterType; use HeimrichHannot\FlareBundle\Model\FilterModel; @@ -58,7 +59,7 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co ]); } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void { $config = $context->config; @@ -68,7 +69,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont $value = $config['intrinsic'] ? $config['preselect'] - : $this->resolveRuntimeValue($values[FilterContext::SINGLE_VALUE] ?? null, $config); + : $this->resolveRuntimeValue($data->getSingleValue(), $config); if ($value === null) { return; diff --git a/src/Filter/Element/CalendarCurrentFilterElement.php b/src/Filter/Element/CalendarCurrentFilterElement.php index 19867e53..90c2729b 100644 --- a/src/Filter/Element/CalendarCurrentFilterElement.php +++ b/src/Filter/Element/CalendarCurrentFilterElement.php @@ -11,6 +11,7 @@ use HeimrichHannot\FlareBundle\Engine\Context\ValidationContext; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\Filter\Type\CalendarCurrentFilterType; use HeimrichHannot\FlareBundle\Model\FilterModel; @@ -95,7 +96,7 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co $builder->addEventListener(FormEvents::POST_SUBMIT, $this->validateRange(...)); } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void { $config = $context->config; @@ -103,7 +104,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont return; } - $value = $this->processRuntimeValue($values) ?? []; + $value = $this->processRuntimeValue($data) ?? []; $from = $value['from'] ?? null; $to = $value['to'] ?? null; @@ -181,30 +182,30 @@ private function resolveFormLimits(array $config): array } /** - * @param array $value - * * @return array{from: ?\DateTimeInterface, to: ?\DateTimeInterface}|null */ - private function processRuntimeValue(array $value): ?array + private function processRuntimeValue(FilterData $data): ?array { - if (!\array_key_exists('from', $value) && !\array_key_exists('to', $value)) + if (!$data->has('from') && !$data->has('to')) + // Programmatically set data may carry the bounds positionally instead of by name. { - if (\count($value) !== 2) - { + $values = $data->all(); + + if (\count($values) !== 2) { return null; } - $value = \array_values($value); + $values = \array_values($values); return [ - 'from' => $this->mixedToDateTime($value[0] ?? null), - 'to' => $this->mixedToDateTime($value[1] ?? null), + 'from' => $this->mixedToDateTime($values[0] ?? null), + 'to' => $this->mixedToDateTime($values[1] ?? null), ]; } return [ - 'from' => $this->mixedToDateTime($value['from'] ?? null), - 'to' => $this->mixedToDateTime($value['to'] ?? null), + 'from' => $this->mixedToDateTime($data->get('from')), + 'to' => $this->mixedToDateTime($data->get('to')), ]; } diff --git a/src/Filter/Element/DateRangeFilterElement.php b/src/Filter/Element/DateRangeFilterElement.php index cb4f95b5..7d66a26c 100644 --- a/src/Filter/Element/DateRangeFilterElement.php +++ b/src/Filter/Element/DateRangeFilterElement.php @@ -11,6 +11,7 @@ use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\Filter\Type\DateRangeFilterType; use HeimrichHannot\FlareBundle\Model\FilterModel; @@ -75,7 +76,7 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co /** * @throws FilterException */ - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void { if (!$field = $context->config['field']) { throw new FilterException('Set fieldGeneric in filter model.'); @@ -83,8 +84,8 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont $builder->add(DateRangeFilterType::class, [ 'field' => $field, - 'from' => $values['from'] ?? null, - 'to' => $values['to'] ?? null, + 'from' => $data->get('from'), + 'to' => $data->get('to'), ]); } diff --git a/src/Filter/Element/DcaSelectFieldFilterElement.php b/src/Filter/Element/DcaSelectFieldFilterElement.php index 5639fec4..85855870 100644 --- a/src/Filter/Element/DcaSelectFieldFilterElement.php +++ b/src/Filter/Element/DcaSelectFieldFilterElement.php @@ -14,6 +14,7 @@ use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\Filter\Type\DcaSelectFilterType; use HeimrichHannot\FlareBundle\Model\FilterModel; @@ -95,14 +96,14 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co $builder->single(ChoiceType::class, $formOptions); } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void { $config = $context->config; $options = $this->getOptions($context->list->dc, $config['field']) ?? []; $selected = $config['intrinsic'] ? $config['preselect'] - : $this->normalizeSubmittedValue($values[FilterContext::SINGLE_VALUE] ?? null, $options); + : $this->normalizeSubmittedValue($data->getSingleValue(), $options); if (!$selected) { return; diff --git a/src/Filter/Element/FieldValueChoiceFilterElement.php b/src/Filter/Element/FieldValueChoiceFilterElement.php index 2249a873..c5b7b2ea 100644 --- a/src/Filter/Element/FieldValueChoiceFilterElement.php +++ b/src/Filter/Element/FieldValueChoiceFilterElement.php @@ -15,6 +15,7 @@ use HeimrichHannot\FlareBundle\Engine\Context\ValidationContext; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\Filter\Type\FieldValueChoiceFilterType; use HeimrichHannot\FlareBundle\Form\ChoicesBuilder; @@ -83,7 +84,7 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co $builder->setAttribute('flare.choices_builder', $choicesBuilder); } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void { if ($context->engineContext instanceof ValidationContext) { return; @@ -97,7 +98,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont $value = $config['intrinsic'] ? $config['preselect'] - : $this->normalizeRuntimeValue($values[FilterContext::SINGLE_VALUE] ?? null, $context); + : $this->normalizeRuntimeValue($data->getSingleValue(), $context); if (!$value) { return; diff --git a/src/Filter/Element/FilterElementInterface.php b/src/Filter/Element/FilterElementInterface.php index 2c2dd894..1f53435c 100644 --- a/src/Filter/Element/FilterElementInterface.php +++ b/src/Filter/Element/FilterElementInterface.php @@ -6,6 +6,7 @@ use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; interface FilterElementInterface @@ -15,8 +16,9 @@ interface FilterElementInterface * * 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 + * buildFilter() as {@see FilterData::getSingleValue()}. Multi-field elements add() + * children with local names, which mount as a compound sub-form. Declaring both at once is + * not supported and fails when the form is built. 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. @@ -26,10 +28,10 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co /** * Translates canonical config and runtime data into filter type calls. * - * @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). + * @param FilterData $data Submitted form data of this filter — {@see FilterData::get()} by + * the local field names declared in buildForm(), or {@see FilterData::getSingleValue()} + * for a single() field — or the programmatically set {@see \HeimrichHannot\FlareBundle\Filter\Filter::$data}; + * {@see FilterData::none()} when neither exists (e.g. non-interactive contexts). */ - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void; + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void; } diff --git a/src/Filter/Element/PublishedFilterElement.php b/src/Filter/Element/PublishedFilterElement.php index 9038e632..81b273b4 100644 --- a/src/Filter/Element/PublishedFilterElement.php +++ b/src/Filter/Element/PublishedFilterElement.php @@ -10,6 +10,7 @@ use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\Type\PublishedFilterType; use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -50,7 +51,7 @@ protected function transformFilterModel(ConfigBuilder $config, FilterModel $mode ->set('invert', (bool) $model->invertPublished); } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void { $config = $context->config; diff --git a/src/Filter/Element/SearchKeywordsFilterElement.php b/src/Filter/Element/SearchKeywordsFilterElement.php index f78ab9dc..c6a6c008 100644 --- a/src/Filter/Element/SearchKeywordsFilterElement.php +++ b/src/Filter/Element/SearchKeywordsFilterElement.php @@ -11,6 +11,7 @@ use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\Filter\Type\SearchKeywordsFilterType; use HeimrichHannot\FlareBundle\Model\FilterModel; @@ -61,13 +62,13 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co $builder->single(TextType::class, $options); } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void { $config = $context->config; $value = $config['intrinsic'] ? $config['prefill'] - : ($values[FilterContext::SINGLE_VALUE] ?? null); + : $data->getSingleValue(); if (!$value || !\is_string($value)) { return; diff --git a/src/Filter/Element/SimpleEquationFilterElement.php b/src/Filter/Element/SimpleEquationFilterElement.php index 80bb4387..9a20f493 100644 --- a/src/Filter/Element/SimpleEquationFilterElement.php +++ b/src/Filter/Element/SimpleEquationFilterElement.php @@ -12,6 +12,7 @@ use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\Type\SimpleEquationFilterType; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Util\DcaHelper; @@ -47,7 +48,7 @@ protected function transformFilterModel(ConfigBuilder $config, FilterModel $mode /** * @throws FilterException */ - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void { $config = $context->config; diff --git a/src/Filter/Factory/FilterFactory.php b/src/Filter/Factory/FilterFactory.php index 4fd50312..09ef32df 100644 --- a/src/Filter/Factory/FilterFactory.php +++ b/src/Filter/Factory/FilterFactory.php @@ -7,6 +7,7 @@ use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Filter; +use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterTransformerResolver; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; @@ -24,7 +25,6 @@ public function __construct( /** * @param FilterElementInterface|string $element Filter element instance or registered type alias. * @param array $config - * @param array|null $data * * @throws FlareException In case no filter element is registered under the given type alias. * @@ -33,7 +33,7 @@ public function __construct( public function create( FilterElementInterface|string $element, array $config = [], - ?array $data = null, + ?FilterData $data = null, ?string $alias = null, ?string $targetAlias = null, bool $targetingForced = false, diff --git a/src/Filter/Factory/FilterFormFactory.php b/src/Filter/Factory/FilterFormFactory.php index 45e539f9..deb46b8a 100644 --- a/src/Filter/Factory/FilterFormFactory.php +++ b/src/Filter/Factory/FilterFormFactory.php @@ -77,24 +77,35 @@ public function create(ListSpec $list, FormContextInterface $context): FormInter /** @var FilterElementFormBuiltEvent $event */ $event = $this->eventDispatcher->dispatch(new FilterElementFormBuiltEvent($wrapper, $filterContext)); + if ($event->isCancelled()) + // Filters can be skipped by event listeners. + { + continue; + } + $single = $wrapper->getSingle(); - if ($event->isCancelled() || (!$single && $wrapper->count() === 0)) + if (!$single && $wrapper->count() === 0) // Filters without any form representation are never mounted. { continue; } - if ($single && $wrapper->count() === 0) - // Flat mount: the field lives at the root under the filter's alias. + if ($single && $wrapper->count() > 0) + { + throw new FlareException( + 'Filter element cannot declare a single field and add children at the same time.', + method: __METHOD__, + ); + } + + if ($single) { $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, @@ -102,10 +113,6 @@ public function create(ListSpec $list, FormContextInterface $context): FormInter 'required' => false, ]); - if ($single) { - $mount->add(FilterContext::SINGLE_VALUE, $single['type'], $single['options']); - } - foreach ($wrapper->all() as $childBuilder) { $mount->add($childBuilder); } diff --git a/src/Filter/Filter.php b/src/Filter/Filter.php index b1d698c7..e787fd11 100644 --- a/src/Filter/Filter.php +++ b/src/Filter/Filter.php @@ -25,9 +25,8 @@ * @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 - * data takes precedence over this bag. + * @param FilterData|null $data Programmatically set runtime data, same as buildFilter() + * receives. Submitted form data takes precedence over it. * @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. @@ -40,17 +39,14 @@ public function __construct( public FilterElementInterface $element, public string $type, public array $config = [], - public ?array $data = null, + public ?FilterData $data = null, public ?string $alias = null, public ?string $targetAlias = null, public bool $targetingForced = false, public ?string $source = null, ) {} - /** - * @param array|null $data - */ - public function withData(?array $data): self + public function withData(?FilterData $data): self { return new self( element: $this->element, @@ -101,7 +97,7 @@ public function fingerprint(): array 'element' => \get_class($this->element), 'type' => $this->type, 'config' => $this->config, - 'data' => $this->data, + 'data' => $this->data?->toArray(), 'alias' => $this->alias, 'targetAlias' => $this->targetAlias, 'targetingForced' => $this->targetingForced, diff --git a/src/Filter/FilterContext.php b/src/Filter/FilterContext.php index 88d5045b..8a634bda 100644 --- a/src/Filter/FilterContext.php +++ b/src/Filter/FilterContext.php @@ -19,12 +19,6 @@ /** 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. * @param string|int|null $key Key of the filter within {@see ListSpec::$filters}. diff --git a/src/Filter/FilterData.php b/src/Filter/FilterData.php new file mode 100644 index 00000000..f0bffa91 --- /dev/null +++ b/src/Filter/FilterData.php @@ -0,0 +1,127 @@ + $values + */ + private function __construct( + private mixed $single = null, + private bool $hasSingle = false, + private array $values = [], + ) {} + + /** + * No runtime data at all, e.g., in non-interactive contexts. + */ + public static function none(): self + { + return new self(); + } + + /** + * Data of a single-field filter. A `null` value is still "supplied" — see {@see hasSingle()}. + */ + public static function single(mixed $value): self + { + return new self(single: $value, hasSingle: true); + } + + /** + * Data of a compound filter, keyed by the local field names declared in buildForm(). + * + * @param array $values + */ + public static function of(array $values): self + { + return new self(values: $values); + } + + /** + * Whether a single value was supplied at all, which distinguishes a submitted `null` + * from a filter that was never submitted. + */ + public function hasSingle(): bool + { + return $this->hasSingle; + } + + public function getSingleValue(mixed $default = null): mixed + { + return $this->hasSingle ? $this->single : $default; + } + + /** + * Whether the named field was supplied at all, which distinguishes a submitted `null` + * from a field that was never submitted. + */ + public function has(string $name): bool + { + return \array_key_exists($name, $this->values); + } + + public function get(string $name, mixed $default = null): mixed + { + return $this->has($name) ? $this->values[$name] : $default; + } + + /** + * @return array The named field values; always empty for single-field data. + */ + public function all(): array + { + return $this->values; + } + + public function isEmpty(): bool + { + return !$this->hasSingle && !$this->values; + } + + /** + * Iterates the named field values only. + * + * @return \Traversable + */ + public function getIterator(): \Traversable + { + return new \ArrayIterator($this->values); + } + + /** + * Counts the named field values only. + */ + public function count(): int + { + return \count($this->values); + } + + /** + * Stable representation for hashing/caching. Keeps the single value in its own slot so a + * named field called "single" cannot collide with it. + * + * @return array{hasSingle: bool, single: mixed, values: array} + */ + public function toArray(): array + { + return [ + 'hasSingle' => $this->hasSingle, + 'single' => $this->single, + 'values' => $this->values, + ]; + } +} diff --git a/src/Filter/FilterFormBuilderInterface.php b/src/Filter/FilterFormBuilderInterface.php index c9069d8d..ec108625 100644 --- a/src/Filter/FilterFormBuilderInterface.php +++ b/src/Filter/FilterFormBuilderInterface.php @@ -11,9 +11,9 @@ * * Besides the regular Symfony builder API for multi-field filters, it lets an element declare * 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 FilterContext::SINGLE_VALUE}. + * root filter form under the filter's alias (query parameter `form[alias]=x`), and their + * submitted value is handed back to buildFilter() as + * {@see \HeimrichHannot\FlareBundle\Filter\FilterData::getSingleValue()}. */ interface FilterFormBuilderInterface extends FormBuilderInterface { @@ -21,10 +21,8 @@ interface FilterFormBuilderInterface extends FormBuilderInterface * Declares this filter as a single-field filter of the given form type. * * The field is not added as a child; the form factory mounts it under the filter's alias. - * Calling this method again overwrites the previous declaration. If children are added - * alongside, the single field is materialized under - * {@see \HeimrichHannot\FlareBundle\Filter\FilterContext::SINGLE_VALUE} within the - * compound filter form instead. + * Calling this method again overwrites the previous declaration. Declaring a single field + * and adding children at the same time is not supported and fails when the form is built. * * @param class-string $type Form type class of the field. * @param array $options Form options of the field. diff --git a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php index f96ce720..a51c1d75 100644 --- a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php +++ b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php @@ -12,6 +12,7 @@ use HeimrichHannot\FlareBundle\Filter\Element\AbstractFilterElement; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\Filter\Type\IntegerIdChoiceFilterType; use HeimrichHannot\FlareBundle\Integration\CodefogTags\Registry\CfgTagsJoinsRegistry; @@ -103,7 +104,7 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co $builder->single(ChoiceType::class, $formOptions); } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void { $config = $context->config; @@ -112,7 +113,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont /** @var ?array $tagIds */ $tagIds = $config['intrinsic'] ? $preselect - : $this->processRuntimeValue($values[FilterContext::SINGLE_VALUE] ?? null); + : $this->processRuntimeValue($data->getSingleValue()); if (!$tagIds) { return; diff --git a/src/Query/Executor/FilterExecutor.php b/src/Query/Executor/FilterExecutor.php index 0dafecf1..c6f3549b 100644 --- a/src/Query/Executor/FilterExecutor.php +++ b/src/Query/Executor/FilterExecutor.php @@ -14,6 +14,7 @@ use HeimrichHannot\FlareBundle\Filter\FilterBuilder; use HeimrichHannot\FlareBundle\Filter\FilterCall; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Query\Factory\FilterQueryBuilderFactory; use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; use HeimrichHannot\FlareBundle\Query\ListQueryConfig; @@ -50,7 +51,7 @@ public function invokeFilters(ListQueryConfig $options): array { $context = $this->filterContextFactory->create($list, $filter, $options->context, $key); - $data = (array) ($options->filterValues[$key] ?? $filter->data ?? []); + $data = $options->filterValues[$key] ?? $filter->data ?? FilterData::none(); if (!$builders = $this->invokeFilter($filter, $context, $data)) { continue; @@ -63,15 +64,13 @@ public function invokeFilters(ListQueryConfig $options): array } /** - * @param array $data - * * @return FilterQueryBuilder[] * * @throws AbortFilteringException * @throws FilterException * @throws FlareException */ - public function invokeFilter(Filter $filter, FilterContext $context, array $data = []): array + public function invokeFilter(Filter $filter, FilterContext $context, FilterData $data): array { if (!Str::isValidSqlName($table = $context->list->dc)) { diff --git a/src/Query/ListQueryConfig.php b/src/Query/ListQueryConfig.php index d27c876f..a5dd774c 100644 --- a/src/Query/ListQueryConfig.php +++ b/src/Query/ListQueryConfig.php @@ -5,10 +5,15 @@ namespace HeimrichHannot\FlareBundle\Query; use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; +use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\List\ListSpec; readonly class ListQueryConfig { + /** + * @param array $filterValues + * @param array $attributes + */ public function __construct( public ListSpec $list, public ContextInterface $context, @@ -18,6 +23,10 @@ public function __construct( public array $attributes = [], ) {} + /** + * @param array|null $filterValues + * @param array|null $attributes + */ public function with( ?array $filterValues = null, ?bool $isCounting = null, diff --git a/tests/Engine/Projector/InteractiveProjectorTest.php b/tests/Engine/Projector/InteractiveProjectorTest.php index 841da951..8af77238 100644 --- a/tests/Engine/Projector/InteractiveProjectorTest.php +++ b/tests/Engine/Projector/InteractiveProjectorTest.php @@ -9,6 +9,7 @@ use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; @@ -24,6 +25,8 @@ final class InteractiveProjectorTest extends TestCase /** * collectFilterData() touches no constructor dependencies, so the test double * skips the parent constructor entirely. + * + * @return array */ private function collect(ListSpec $list, FormInterface $form): array { @@ -63,7 +66,11 @@ public function resolveDcTable(string $type, array $config, array $attributes): $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 buildFilter( + FilterBuilderInterface $builder, + FilterContext $context, + FilterData $data, + ): void {} }; return new ListSpec(driver: $driver, type: 'test_list', dc: 'tl_test', filters: [ @@ -71,7 +78,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont ]); } - public function testFlatSubmittedValueIsKeyedCanonically(): void + public function testFlatSubmittedValueBecomesSingleData(): void { $root = $this->createRootBuilder(); $this->addFlatChild($root, 'suche'); @@ -79,10 +86,12 @@ public function testFlatSubmittedValueIsKeyedCanonically(): void $form->submit(['suche' => 'term']); - $this->assertSame( - ['sucheKey' => [FilterContext::SINGLE_VALUE => 'term']], - $this->collect($this->listWithFilter('sucheKey', 'suche'), $form), - ); + $data = $this->collect($this->listWithFilter('sucheKey', 'suche'), $form); + + $this->assertSame(['sucheKey'], \array_keys($data)); + $this->assertTrue($data['sucheKey']->hasSingle()); + $this->assertSame('term', $data['sucheKey']->getSingleValue()); + $this->assertSame([], $data['sucheKey']->all()); } public function testFlatUnsubmittedDefaultIsCollected(): void @@ -91,10 +100,10 @@ public function testFlatUnsubmittedDefaultIsCollected(): void $this->addFlatChild($root, 'suche', ['data' => 'preset']); $form = $root->getForm(); - $this->assertSame( - ['sucheKey' => [FilterContext::SINGLE_VALUE => 'preset']], - $this->collect($this->listWithFilter('sucheKey', 'suche'), $form), - ); + $data = $this->collect($this->listWithFilter('sucheKey', 'suche'), $form); + + $this->assertSame(['sucheKey'], \array_keys($data)); + $this->assertSame('preset', $data['sucheKey']->getSingleValue()); } public function testFlatUnsubmittedWithoutDefaultStaysUnset(): void @@ -114,10 +123,12 @@ public function testFlatSubmittedEmptyValueIsKeptSoItOverridesDataBags(): void $form->submit(['suche' => '']); - $this->assertSame( - ['sucheKey' => [FilterContext::SINGLE_VALUE => null]], - $this->collect($this->listWithFilter('sucheKey', 'suche'), $form), - ); + $data = $this->collect($this->listWithFilter('sucheKey', 'suche'), $form); + + $this->assertSame(['sucheKey'], \array_keys($data)); + // The submitted null must stay distinguishable from "never submitted". + $this->assertTrue($data['sucheKey']->hasSingle()); + $this->assertNull($data['sucheKey']->getSingleValue()); } public function testCompoundSubmittedDataIsCollected(): void @@ -132,10 +143,11 @@ public function testCompoundSubmittedDataIsCollected(): void $form->submit(['range' => ['from' => 'a', 'to' => 'b']]); - $this->assertSame( - ['rangeKey' => ['from' => 'a', 'to' => 'b']], - $this->collect($this->listWithFilter('rangeKey', 'range'), $form), - ); + $data = $this->collect($this->listWithFilter('rangeKey', 'range'), $form); + + $this->assertSame(['rangeKey'], \array_keys($data)); + $this->assertFalse($data['rangeKey']->hasSingle()); + $this->assertSame(['from' => 'a', 'to' => 'b'], $data['rangeKey']->all()); } public function testCompoundUnsubmittedFieldDefaultsAreCollected(): void @@ -148,10 +160,10 @@ public function testCompoundUnsubmittedFieldDefaultsAreCollected(): void ); $form = $root->getForm(); - $this->assertSame( - ['rangeKey' => ['from' => 'a']], - $this->collect($this->listWithFilter('rangeKey', 'range'), $form), - ); + $data = $this->collect($this->listWithFilter('rangeKey', 'range'), $form); + + $this->assertSame(['rangeKey'], \array_keys($data)); + $this->assertSame(['from' => 'a'], $data['rangeKey']->all()); } public function testFilterWithoutMountedChildIsSkipped(): void diff --git a/tests/Filter/FilterDataTest.php b/tests/Filter/FilterDataTest.php new file mode 100644 index 00000000..6e0bc90b --- /dev/null +++ b/tests/Filter/FilterDataTest.php @@ -0,0 +1,115 @@ +isEmpty()); + self::assertFalse($data->hasSingle()); + self::assertNull($data->getSingleValue()); + self::assertSame([], $data->all()); + self::assertCount(0, $data); + } + + public function testSingleHoldsItsValue(): void + { + $data = FilterData::single('term'); + + self::assertFalse($data->isEmpty()); + self::assertTrue($data->hasSingle()); + self::assertSame('term', $data->getSingleValue()); + } + + /** + * A submitted null must stay distinguishable from a filter that was never submitted — + * the distinction the former array bag could not express. + */ + public function testSingleNullCountsAsSupplied(): void + { + $data = FilterData::single(null); + + self::assertTrue($data->hasSingle()); + self::assertNull($data->getSingleValue()); + self::assertNull($data->getSingleValue('fallback')); + self::assertFalse($data->isEmpty()); + } + + public function testGetSingleValueFallsBackWhenNoSingleWasSupplied(): void + { + self::assertSame('fallback', FilterData::none()->getSingleValue('fallback')); + self::assertSame('fallback', FilterData::of(['from' => 'a'])->getSingleValue('fallback')); + } + + public function testSingleCarriesNoNamedValues(): void + { + $data = FilterData::single('term'); + + self::assertSame([], $data->all()); + self::assertFalse($data->has('term')); + self::assertCount(0, $data); + } + + public function testOfHoldsNamedValues(): void + { + $data = FilterData::of(['from' => 'a', 'to' => null]); + + self::assertFalse($data->hasSingle()); + self::assertFalse($data->isEmpty()); + self::assertSame('a', $data->get('from')); + self::assertSame(['from' => 'a', 'to' => null], $data->all()); + self::assertCount(2, $data); + } + + public function testHasDistinguishesSubmittedNullFromMissingField(): void + { + $data = FilterData::of(['to' => null]); + + self::assertTrue($data->has('to')); + self::assertFalse($data->has('from')); + self::assertNull($data->get('to', 'fallback')); + self::assertSame('fallback', $data->get('from', 'fallback')); + } + + public function testOfEmptyArrayIsEmpty(): void + { + self::assertTrue(FilterData::of([])->isEmpty()); + } + + public function testIteratesNamedValuesOnly(): void + { + self::assertSame( + ['from' => 'a', 'to' => 'b'], + \iterator_to_array(FilterData::of(['from' => 'a', 'to' => 'b'])), + ); + + self::assertSame([], \iterator_to_array(FilterData::single('term'))); + } + + public function testToArrayKeepsTheSingleSlotSeparateFromNamedValues(): void + { + self::assertSame( + ['hasSingle' => true, 'single' => 'term', 'values' => []], + FilterData::single('term')->toArray(), + ); + + // A named field called "single" must not be mistaken for the single value. + self::assertSame( + ['hasSingle' => false, 'single' => null, 'values' => ['single' => 'named']], + FilterData::of(['single' => 'named'])->toArray(), + ); + + self::assertNotSame( + FilterData::single('x')->toArray(), + FilterData::of(['single' => 'x'])->toArray(), + ); + } +} diff --git a/tests/Filter/FilterFactoryTest.php b/tests/Filter/FilterFactoryTest.php index d996efb9..f0016705 100644 --- a/tests/Filter/FilterFactoryTest.php +++ b/tests/Filter/FilterFactoryTest.php @@ -9,6 +9,7 @@ use HeimrichHannot\FlareBundle\Filter\Factory\FilterFactory; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterTransformerResolver; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; @@ -30,7 +31,11 @@ private static function element(): FilterElementInterface return new class implements FilterElementInterface { public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void {} + public function buildFilter( + FilterBuilderInterface $builder, + FilterContext $context, + FilterData $data, + ): void {} }; } diff --git a/tests/Filter/FilterOptionsResolverTest.php b/tests/Filter/FilterOptionsResolverTest.php index 2309de63..ade48d73 100644 --- a/tests/Filter/FilterOptionsResolverTest.php +++ b/tests/Filter/FilterOptionsResolverTest.php @@ -11,6 +11,7 @@ use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterOptionsResolver; use PHPUnit\Framework\TestCase; use Symfony\Component\Form\FormBuilderInterface; @@ -70,7 +71,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) { } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void { } } @@ -81,7 +82,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) { } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void { } } diff --git a/tests/Filter/FilterTest.php b/tests/Filter/FilterTest.php index 22e61f0f..ec5e40a5 100644 --- a/tests/Filter/FilterTest.php +++ b/tests/Filter/FilterTest.php @@ -8,6 +8,7 @@ use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; use PHPUnit\Framework\TestCase; @@ -20,7 +21,11 @@ private static function element(): FilterElementInterface 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 buildFilter( + FilterBuilderInterface $builder, + FilterContext $context, + FilterData $data, + ): void {} }; } @@ -34,10 +39,10 @@ public function testWithersPreserveOtherFields(): void source: 'tl_flare_filter.1', ); - $withData = $filter->withData(['value' => 42]); + $withData = $filter->withData(FilterData::of(['value' => 42])); self::assertNull($filter->data); - self::assertSame(['value' => 42], $withData->data); + self::assertSame(['value' => 42], $withData->data?->all()); self::assertSame(self::element(), $withData->element); self::assertSame('test', $withData->type); self::assertSame('foo', $withData->alias); diff --git a/tests/Filter/FilterTransformerResolverTest.php b/tests/Filter/FilterTransformerResolverTest.php index e2ee82e3..e8953faf 100644 --- a/tests/Filter/FilterTransformerResolverTest.php +++ b/tests/Filter/FilterTransformerResolverTest.php @@ -11,6 +11,7 @@ use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterTransformerResolver; use PHPUnit\Framework\TestCase; use Symfony\Component\EventDispatcher\EventDispatcher; @@ -97,7 +98,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) { } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void { } } @@ -108,7 +109,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) { } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void { } } diff --git a/tests/Form/FilterFormFactoryTest.php b/tests/Form/FilterFormFactoryTest.php index b50b4650..a2559d05 100644 --- a/tests/Form/FilterFormFactoryTest.php +++ b/tests/Form/FilterFormFactoryTest.php @@ -8,12 +8,14 @@ use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; use HeimrichHannot\FlareBundle\Engine\Context\FormContextInterface; use HeimrichHannot\FlareBundle\Event\FilterElementFormBuiltEvent; +use HeimrichHannot\FlareBundle\Exception\FlareException; 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\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterOptionsResolver; use HeimrichHannot\FlareBundle\List\ListSpec; @@ -109,7 +111,11 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co ($this->buildForm)($builder, $context); } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void {} + public function buildFilter( + FilterBuilderInterface $builder, + FilterContext $context, + FilterData $data, + ): void {} }; } @@ -137,21 +143,19 @@ public function testSingleFieldMountsFlatUnderTheAlias(): void ); } - public function testSingleWithCompanionFieldMountsNestedCompound(): void + public function testSingleWithCompanionFieldIsRejected(): 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, type: 'test_element', alias: 'suche')]); - - $child = $form->get('suche'); + $this->expectException(FlareException::class); + $this->expectExceptionMessage( + 'Filter element cannot declare a single field and add children at the same time.', + ); - $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')); + $this->createForm(['suche' => new Filter(element: $element, type: 'test_element', alias: 'suche')]); } public function testMultiFieldElementMountsNestedCompound(): void diff --git a/tests/List/ListSpecBuilderTest.php b/tests/List/ListSpecBuilderTest.php index 6daaf0c6..8522a9d2 100644 --- a/tests/List/ListSpecBuilderTest.php +++ b/tests/List/ListSpecBuilderTest.php @@ -13,6 +13,7 @@ use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\List\Factory\ListSpecFactory; use HeimrichHannot\FlareBundle\List\ListSpecBuilder; @@ -35,7 +36,11 @@ public static function filter(string $type, ?string $alias = null): Filter $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 buildFilter( + FilterBuilderInterface $builder, + FilterContext $context, + FilterData $data, + ): void {} }; return new Filter(element: $element, type: $type, alias: $alias); diff --git a/tests/List/ListSpecTest.php b/tests/List/ListSpecTest.php index 610bbb2e..6c9c8d93 100644 --- a/tests/List/ListSpecTest.php +++ b/tests/List/ListSpecTest.php @@ -9,6 +9,7 @@ use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; @@ -35,7 +36,11 @@ private static function filter(string $type, ?string $alias = null): Filter $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 buildFilter( + FilterBuilderInterface $builder, + FilterContext $context, + FilterData $data, + ): void {} }; return new Filter(element: $element, type: $type, alias: $alias); diff --git a/tests/List/StubFilterElement.php b/tests/List/StubFilterElement.php index 416aba7a..fb2979c1 100644 --- a/tests/List/StubFilterElement.php +++ b/tests/List/StubFilterElement.php @@ -7,11 +7,12 @@ use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; class StubFilterElement implements FilterElementInterface { public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void {} + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void {} } diff --git a/tests/Query/Executor/FilterExecutorTest.php b/tests/Query/Executor/FilterExecutorTest.php new file mode 100644 index 00000000..a253fd33 --- /dev/null +++ b/tests/Query/Executor/FilterExecutorTest.php @@ -0,0 +1,121 @@ +createMock(Connection::class)), + filterTypeRegistry: new FilterTypeRegistry([]), + ); + } + + /** + * @param array $filterValues + */ + private function invoke(Filter $filter, array $filterValues): FilterData + { + $driver = new class implements ListDriverInterface { + public function resolveDcTable(string $type, array $config, array $attributes): string + { + return (string) ($config['dc'] ?? ''); + } + }; + + $list = new ListSpec(driver: $driver, type: 'test_list', dc: 'tl_test', filters: ['k' => $filter]); + + $this->createExecutor()->invokeFilters(new ListQueryConfig( + list: $list, + context: new AggregationContext(), + filterValues: $filterValues, + )); + + $element = $filter->element; + \assert($element instanceof RecordingFilterElement); + + self::assertNotNull($element->received, 'buildFilter() was never invoked'); + + return $element->received; + } + + public function testCollectedFormValuesWinOverProgrammaticData(): void + { + $filter = new Filter( + element: new RecordingFilterElement(), + type: 'test_element', + data: FilterData::single('programmatic'), + ); + + $received = $this->invoke($filter, ['k' => FilterData::single('runtime')]); + + self::assertSame('runtime', $received->getSingleValue()); + } + + public function testProgrammaticDataIsUsedWhenNoFormValuesWereCollected(): void + { + $filter = new Filter( + element: new RecordingFilterElement(), + type: 'test_element', + data: FilterData::of(['from' => 'a']), + ); + + $received = $this->invoke($filter, []); + + self::assertSame(['from' => 'a'], $received->all()); + } + + public function testEmptyDataIsPassedWhenNeitherSourceExists(): void + { + $filter = new Filter(element: new RecordingFilterElement(), type: 'test_element'); + + $received = $this->invoke($filter, []); + + self::assertTrue($received->isEmpty()); + self::assertFalse($received->hasSingle()); + } +} + +final class RecordingFilterElement implements FilterElementInterface +{ + public ?FilterData $received = null; + + public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} + + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void + { + $this->received = $data; + } +} diff --git a/tests/Registry/FilterElementRegistryTest.php b/tests/Registry/FilterElementRegistryTest.php index f701e9b4..9cd673c8 100644 --- a/tests/Registry/FilterElementRegistryTest.php +++ b/tests/Registry/FilterElementRegistryTest.php @@ -8,6 +8,7 @@ use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; use PHPUnit\Framework\TestCase; @@ -59,5 +60,5 @@ final class RegistryElementStub implements FilterElementInterface { public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void {} + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void {} } From 9d66878bd23df4ef47eea0a17edc4e59fc2a8fb2 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Mon, 7 Sep 2026 19:19:36 +0200 Subject: [PATCH 80/96] chore: remove outdated and redundant audit files from `.audit` directory Deleted combined audit files as they are no longer relevant or actively maintained. This cleanup reduces clutter and ensures the repository contains only actionable and current resources. --- .audit/260719012-combined/00-uebersicht.md | 56 ------- .audit/260719012-combined/10-architektur.md | 120 -------------- .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, 736 deletions(-) delete mode 100644 .audit/260719012-combined/00-uebersicht.md delete mode 100644 .audit/260719012-combined/10-architektur.md delete mode 100644 .audit/260719012-combined/20-korrektheit.md delete mode 100644 .audit/260719012-combined/30-sicherheit.md delete mode 100644 .audit/260719012-combined/40-performance-stabilitaet.md delete mode 100644 .audit/260719012-combined/50-tests-ci.md delete mode 100644 .audit/260719012-combined/60-contao-integration-doku.md delete 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 deleted file mode 100644 index d529d022..00000000 --- a/.audit/260719012-combined/00-uebersicht.md +++ /dev/null @@ -1,56 +0,0 @@ -# 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). - * 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). - -### 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 deleted file mode 100644 index 4d8a6b65..00000000 --- a/.audit/260719012-combined/10-architektur.md +++ /dev/null @@ -1,120 +0,0 @@ -# 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` -> -> **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) -> -> `$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) -> -> 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) -> -> `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** - -> ## 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-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.** - -> ## 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` -> -> **Nutzer-Antwort: Passt so.** - -> ## 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.** - -> ## 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.** - -> ## 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/.audit/260719012-combined/20-korrektheit.md b/.audit/260719012-combined/20-korrektheit.md deleted file mode 100644 index 6f1ea61d..00000000 --- a/.audit/260719012-combined/20-korrektheit.md +++ /dev/null @@ -1,116 +0,0 @@ -# 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 deleted file mode 100644 index c1710dac..00000000 --- a/.audit/260719012-combined/30-sicherheit.md +++ /dev/null @@ -1,33 +0,0 @@ -# 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 deleted file mode 100644 index edeea700..00000000 --- a/.audit/260719012-combined/40-performance-stabilitaet.md +++ /dev/null @@ -1,150 +0,0 @@ -# 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 deleted file mode 100644 index a5a6010c..00000000 --- a/.audit/260719012-combined/50-tests-ci.md +++ /dev/null @@ -1,81 +0,0 @@ -# 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 deleted file mode 100644 index f2e7171f..00000000 --- a/.audit/260719012-combined/60-contao-integration-doku.md +++ /dev/null @@ -1,111 +0,0 @@ -# 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 deleted file mode 100644 index 74bc83b7..00000000 --- a/.audit/260719012-combined/99-positive-punkte.md +++ /dev/null @@ -1,69 +0,0 @@ -# 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 8255195798cf5fe74aa2b9530403c7363232ae35 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Mon, 7 Sep 2026 19:32:35 +0200 Subject: [PATCH 81/96] feat: decouple filter forms from filter elements, introducing registry-based form handling Implemented a registry system to split filter elements' matching logic from their presentation logic. Added `FilterFormInterface` and revised `FilterElementInterface` to enable independent, per-instance form handling. Migrated intrinsic filters and related configuration to the new `formVariant` model, replacing the `intrinsic` boolean. Updated value handling with type-safe value objects and enhanced DCA palette composition for clearer backend structure. --- SPEC_FILTER_FORMS.md | 411 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 411 insertions(+) create mode 100644 SPEC_FILTER_FORMS.md diff --git a/SPEC_FILTER_FORMS.md b/SPEC_FILTER_FORMS.md new file mode 100644 index 00000000..dcbbe444 --- /dev/null +++ b/SPEC_FILTER_FORMS.md @@ -0,0 +1,411 @@ +# SPEC: Decoupling Filter Forms from Filter Elements + +**Status:** Draft / design agreed, not implemented +**Scope:** `src/Filter/`, `src/Form/`, `src/DataContainer/Builder/`, `contao/dca/tl_flare_filter.php`, +`src/EventListener/Contao/ElementDcaListener.php`, `src/Engine/Projector/InteractiveProjector.php` +**Breaking:** yes (`FilterElementInterface`, `Filter::$data`, `tl_flare_filter` schema) + +--- + +## 1. Motivation + +Today a filter element owns both *what it matches* and *how it is presented*: +`FilterElementInterface::buildForm()` and `::buildFilter()` live on the same class, and the +"no form" case is expressed by an `intrinsic` boolean checked at the top of nine `buildForm()` +implementations. + +Three concrete problems follow. + +**1.1 Presentation variants become config columns.** `BooleanFilterElement` carries `boolMode` and +`boolBinaryChoices` — two DB columns, a `__selector__` entry and a subpalette +(`contao/dca/tl_flare_filter.php`) — whose only job is to pick a rendering. Adding a toggle or a +segmented control means more enum columns. This grows combinatorially. + +**1.2 `buildForm()` and `buildFilter()` share an undeclared schema.** The coupling is real, not +incidental: + +- `FieldValueChoiceFilterElement::buildForm()` builds a `ChoicesBuilder`; `buildFilter()` **rebuilds + it** in `normalizeRuntimeValue()` to map submitted choices back to scalars. +- `DateRangeFilterElement::buildForm()` adds children `from`/`to`; `buildFilter()` reads + `$data->get('from')` by those exact names. +- The `single()` vs. compound mount decision (`FilterFormFactory`) determines whether `buildFilter()` + receives `getSingleValue()` or `get($name)`. + +Extracting forms without addressing this trades one coupling for a worse, invisible one. + +**1.3 `intrinsic` conflates three concepts.** It is simultaneously a backend checkbox, a +"value comes from config" semantic (`$config['intrinsic'] ? $config['preselect'] : $runtime`), and a +flag set programmatically where no backend row exists — `src/Engine/Loader/ValidationLoader.php:49,98`, +`src/Engine/Mod/SimpleEquationMod.php:29`, `src/List/Driver/NewsListDriver.php:58`, +`src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php:71`, +`src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php:140,152`. + +The smell is visible in `BooleanFilterElement::buildDca()`: + +```php +if ($intrinsic) { + unset($preselectOptions['null']); +} +``` + +`null` means "nothing preselected" (valid) or "no intrinsic value" (meaningless) depending on a mode +flag. Two concepts fighting over one column. + +--- + +## 2. Target model + +A filter's **presentation** becomes a separate, registrable, per-instance-selectable concern that +owns its own config and its own slice of the backend palette. + +``` +FilterElement — what rows match. Consumes a value object. +FilterForm — how the user supplies that value. Produces the value object. +Value object — the typed contract between the two. +``` + +Registration is **one-directional**: a form binds to a *value object*, never to an element type. +Selectable forms for an element = registry lookup by the element's declared value class. +A mismatch is therefore structurally impossible rather than validated. + +--- + +## 3. Contracts + +### 3.1 `FilterFormInterface` + +```php +interface FilterFormInterface +{ + public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void; + + /** + * Produces the element's canonical value from the mounted field, or null to contribute nothing. + */ + public function decode(FormInterface $field, FilterContext $context): ?object; +} +``` + +A form MAY additionally implement the existing contracts, which then apply to the form's own +config slice: `OptionsContract` (`configureOptions`), `TransformerContract` +(`configureTransformers`), `DcaContract` (`buildDca`). + +**`decode()` receives the mounted `FormInterface`, not a pre-flattened DTO.** Only the form knows +whether it wants `getData()`, `getNormData()` or `getViewData()`, and it needs access to the +attributes it set in `buildForm()` (e.g. `flare.choices_builder`). See §7. + +### 3.2 `FilterElementInterface` (revised) + +```php +interface FilterElementInterface +{ + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, ?object $value): void; +} +``` + +`buildForm()` is removed. Implementations narrow `$value` to their declared value class, so a +mismatched form is an immediate `TypeError` rather than a silently wrong query. + +`configureOptions`, `configureTransformers`, `buildDca` stay as they are. + +### 3.3 Attributes + +```php +#[AsFilterElement(type: self::TYPE, value: BoolValue::class)] +#[AsFilterForm(value: BoolValue::class, requires: [], default: false)] +``` + +- `AsFilterElement::$value` — the value class this element consumes. `null` means the element has no + runtime value at all (see §5.3). +- `AsFilterForm::$value` — the value class this form produces. This is the registry key. +- `AsFilterForm::$requires` — capability interfaces the form needs the element to implement (§4.3). +- `AsFilterForm::$default` — the fallback form for that value class. `AsFilterElement` MAY carry a + `defaultForm:` override. + +`AsFilterForm` is repeatable. One form class can serve every element sharing a value class without +repeated per-element declarations. + +### 3.4 Capability ports + +A form MUST NOT read element-owned config in `decode()`. Anything it needs *pulled* from the element +is an explicit interface: + +```php +interface ChoiceSourceContract +{ + public function buildChoices(FilterContext $context): ChoicesBuilder; +} +``` + +Registry filter for the backend select: value class matches **and** every entry in `requires` is +implemented by the element (plain `instanceof`, resolvable at compile time). This is not a second +pairing axis — the form still never names an element. + +--- + +## 4. Ownership rules + +### 4.1 Backend fields + +> A field belongs to the **element** if its value changes *which rows match*. +> It belongs to the **form** if its value changes only *what the user sees or can submit*. + +| field | owner | note | +|---|---|---| +| `fieldGeneric` | element | changes matching | +| `fieldPublished`, `invertPublished`, `startAt`, `stopAt` | element | changes matching | +| `targetAlias` | framework | stays in `__prefix__` | +| `label` | form | presentation only | +| `isMultiple`, `isExpanded` | form | value-shape change absorbed by `decode()` | +| `preselect` | form | initial hydration (§4.2) | +| `intrinsicValue` | element | *is* the matched value (§4.2) | +| `boolMode`, `boolBinaryChoices` | **removed** | these *are* the form choice; they become form variants | + +### 4.2 The three value concepts + +| concept | owner | meaning | +|---|---|---| +| `intrinsicValue` | element config | canonical value when no form is selected | +| `preselect` | form config | initial hydration → the field's native `data` option | +| `decode()` result | form | canonical value from submitted-or-hydrated data | + +`intrinsicValue` is **not** a framework-mandated key. It is declared per element in +`configureOptions()` only where meaningful. `SimpleEquationFilterElement`, +`BelongsToRelationFilterElement` and `PublishedFilterElement` have no single value — their config +*is* the value — and declare none. + +Because `InteractiveProjector::collectFilterData()` already collects field defaults for unsubmitted +forms, preselect-as-`data` flows through the normal path and every `?? $config['preselect']` +fallback disappears. + +The "user explicitly cleared" vs. "user never interacted" distinction is preserved: `decode()` +receives the `FormInterface` and can consult `isSubmitted()` / `getConfig()->getData()`. + +### 4.3 Value object semantics, not structure + +> Two elements share a value object only if every form registered for one is meaningful for the other. + +`ChoiceValue` for the three choice-based elements; `KeywordsValue` for `SearchKeywordsFilterElement` +— even though both are "a list of strings". Merging structurally identical value objects +reintroduces the silent mismatch one level up, because a choice form would be offered for a +free-text filter. + +--- + +## 5. Intrinsic filters + +### 5.1 Config shape + +The `intrinsic` boolean is replaced by a nullable `formVariant` slot in canonical config. +`null` = intrinsic. `$config['intrinsic']` becomes `$config['formVariant'] === null`. + +The programmatic call sites in §1.3 keep working by simply not setting a form. + +**Do not name the column `formAlias`** — that is taken; it is the query-parameter name +(`src/Model/FilterModel.php:91-102`). Use `formVariant`. + +### 5.2 Backend field + +`formVariant` is a `select` with `submitOnChange => true`. Its blank option *is* intrinsic. +Options come from the registry, keyed by the element's declared value class and filtered by +`requires`. `ElementDcaListener` reads it alongside `$filterModel->type` to resolve the form service +before calling the form's `buildDca()`. + +### 5.3 `IntrinsicContract` is deleted + +`#[AsFilterElement(value: null)]` ⇒ no value object ⇒ no forms ⇒ intrinsic-only. This is more +precise than the interface it replaces, and it removes: + +- `src/Contract/FilterElement/IntrinsicContract.php` +- `FieldsLoadAndSaveCallbacks::onLoadField_intrinsic()` and `::onSaveField_intrinsic()` + (the force-check-and-disable trickery) +- `AbstractFilterElement::isOnlyIntrinsic()` and its three overrides + +--- + +## 6. DCA composition + +### 6.1 Palette segments + +`DcaBuilder::palette()` currently *replaces*, and `apply()` writes one slot: + +```php +$dca['palettes'][$type] = Str::mergePalettes($prefix, $this->palette, $suffix); +``` + +A third segment is required, with fixed order: + +``` +__prefix__ + element palette + form palette + __suffix__ +``` + +`ElementDcaListener` passes a scoped sub-builder to the form (e.g. `$dca->scope('form')`) whose +`palette()` lands in the form segment. `field()` stays **shared** — it already returns a shared +`DcaFieldBuilder`, and a form occasionally needs to tweak an element field's `eval`. + +`apply()` keeps keying `palettes[$type]`; the palette is recomputed per record load, so no key +change is needed. + +### 6.2 Legend convention + +Pair the mechanism with distinct legends so the split is visible to the editor: +element contributes `{filter_legend},fieldGeneric`, form contributes +`{form_legend},label,isMultiple,isExpanded`. + +### 6.3 Known gaps + +- **`__selector__` is static** in `contao/dca/tl_flare_filter.php`. A form contributing a subpalette + needs a `DcaBuilder::selector()` API. This gap exists today. +- **Columns are shared** — `tl_flare_filter` is one wide table, so form-owned columns need globally + unique names, exactly as `boolBinaryChoices` does now. No namespacing. + +--- + +## 7. The `FieldValueChoice` round-trip + +### 7.1 The current defect + +`FieldValueChoiceFilterElement::createChoices()` does: + +```php +$choices->add((string) $id, (string) $label, $id); // alias=id, choice=label, value=id +``` + +so `ChoicesBuilder::buildChoices()` returns `[id => labelString]`, the form's **model data** is +label strings, and `buildChoiceValueCallback()` reverse-maps via +`array_search($label, $this->choices, true)`. Consequences: two rows with the same display label +silently collide onto one id, and `buildFilter()` must construct a second `ChoicesBuilder` to do the +lookup at all. + +### 7.2 Root cause and fix + +`InteractiveProjector::collectFilterData()` flattens the form to `$child->getData()` — model data, +which for a `ChoiceType` is the choice objects. A query filter wants the *value*, which Symfony has +already computed as **view data**: `$field->getViewData()` is `['5','7']`. + +The fix is therefore §3.1: `decode(FormInterface $field, ...)`. No reverse mapping, no second +`ChoicesBuilder`, no label collisions. + +Additionally, fix `add()` so `choice` carries identity rather than the display string. + +### 7.3 Division of labour after the change + +| stays on the element (via `ChoiceSourceContract`) | moves to the form | +|---|---| +| `createChoices()` | `buildPreselectData()` | +| `getForeignValues()` | `normalizeRuntimeValue()` | +| `getLocalValues()` | `extractSubmittedData()` | + +Choice provision is element knowledge (the target field's `foreignKey` relation). The three form-side +methods largely evaporate, since view data is already the scalar. + +--- + +## 8. Programmatic data and `FilterData` + +`Filter::$data` (typed `?FilterData`, read at `src/Query/Executor/FilterExecutor.php:54`) becomes +`Filter::$value`, typed as the element's value object. A programmatic caller constructs the value +directly instead of fabricating form-shaped data. + +**Consequence: `FilterData` largely disappears** (introduced in `4263611`; partly unwound here). +Both of its jobs relocate: + +- `hasSingle()`'s submitted-vs-untouched distinction → answerable from the `FormInterface`. +- `toArray()`'s hashing role → the value object (§9). + +`FilterFormBuilder`'s `single()` vs. compound mount decision is unaffected — that is genuinely a +form concern and stays where it is. + +--- + +## 9. Value objects and hashing + +`ListSpec::hash()` is `sha1(serialize([...]))` over `Filter::fingerprint()`. `serialize()` handles +readonly objects natively and includes the class name, so **a value object of scalars and arrays +needs no hashing interface at all** — it drops into the existing fingerprint. + +`spl_object_id` is explicitly **not** usable: it is identity, not value. It is unstable across +requests (so the cache never hits), handles are recycled after GC (so distinct values can collide +into a wrong cache hit), and two equal values are always two objects. `readonly` guarantees contents +cannot drift after hashing; it does not make identity coincide with value. + +What is required instead is a **containment rule**: + +> A filter value object is `final readonly`; every property is a scalar, `null`, an enum, a nested +> filter value object, or an array of those. Anything with external identity is stored as its id; +> anything with multiple equal representations is normalized in the constructor. + +Rationale per excluded type: + +- **`\DateTimeInterface`** — bites immediately (`DateRangeFilterElement` deals in from/to). + `serialize()` embeds `date`/`timezone_type`/`timezone`, so the same instant as `+02:00` vs. + `Europe/Berlin` hashes differently. Store a timestamp or normalize the timezone. +- **Contao `Model`** — `ArchiveFilterElement` works with selected models; serializing them drags + whole rows plus `$arrModified` into the hash, so the fingerprint changes when an unrelated column + changes. Store ids. +- **Closures** — `serialize()` throws. +- **Array order** — arrays serialize in insertion order, so `['a','b']` and `['b','a']` differ. Sort + in the constructor where order is irrelevant to the query. + +**Enforcement:** one reflection-based test over every class named in an +`#[AsFilterElement(value: ...)]`. This cannot drift from the real properties the way hand-written +`hashKey()` methods would. Add an opt-out `fingerprint(): array` interface only if a future value +object legitimately must hold something non-serializable. + +--- + +## 10. Enforcement summary + +| risk | mechanism | when it fires | +|---|---|---| +| form produces the wrong shape | registry keyed by value class | structurally impossible | +| element receives the wrong type | native param type on `buildFilter()` | `TypeError`, immediately | +| form needs something the element can't supply | `requires` + `instanceof` check | container build | +| value object not hashable | reflection test over declared value classes | test suite | + +--- + +## 11. Migration + +- New column `formVariant` (`varchar`), plus form-owned columns as they move. +- Drop `intrinsic`, `boolMode`, `boolBinaryChoices` and the `boolMode_binary` subpalette / + `boolMode` selector entry. +- Contao migration: `intrinsic = 0` → the default form for the element's value class; + `intrinsic = 1` → `''`. +- `preselect` stays a column but becomes form-owned; its semantics narrow to hydration only, and + the `unset($preselectOptions['null'])` hack in `BooleanFilterElement::buildDca()` goes away. + +--- + +## 12. Sequencing + +The contract set is the entire risk; the remaining elements are mechanical. + +1. **Contracts + pilot.** Value objects, `FilterFormInterface`, `AsFilterForm`, + `AsFilterElement::$value`, `decode(FormInterface)`, `buildFilter(?object)`, registry lookup by + value class, `requires` check in the compiler pass. Pilot on `BooleanFilterElement` — it has the + most presentation-config rot (`boolMode`, `boolBinaryChoices`, `label`, the `preselect` overload). +2. **DCA composition.** `DcaBuilder` palette segments, `scope()`, `selector()`; `ElementDcaListener` + resolves and invokes the form's `buildDca()`. +3. **Migrate the rest.** Nine remaining elements + `CodefogTagsChoiceFilterElement`. + `FieldValueChoiceFilterElement` carries the §7 fix. `CodefogTagsSearchElement` declares neither + `buildForm()` nor `buildFilter()` (only `buildDca()`) — it needs `value:` set and nothing else. +4. **Schema migration** per §11. +5. **Fold `FilterData`** out per §8. + +--- + +## 13. Open questions + +1. **`ArchiveFilterElement`** is the largest element (658 lines) and mixes choice provision, + preselect handling and ptable inference. Confirm it fits `ChoiceSourceContract` or needs a second + capability port. It is the most likely place for this design to need an escape hatch. +2. **Multi-field forms and `requires`** — a compound form (`DateRange`) needs no capability today. + Confirm no compound case needs per-child capability negotiation. +3. **Form-contributed translations** — form-owned fields need `tl_flare_filter` labels; decide + whether forms declare them or they stay in the central language files. +4. **Element-owned `{form_legend}` fields** — `CalendarCurrentFilterElement::buildDca()` already + appends `{form_legend},isLimited` only when not intrinsic (line 142-144). Under §6 that branch + disappears and `isLimited` becomes form-owned config. Confirm the same holds for + `SearchKeywordsFilterElement::buildDca()` and `DcaSelectFieldFilterElement::buildDca()`, which + branch on `intrinsic` the same way. From 2ab8e80fc95bbccd047b39673edb6ad3062e17c6 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Mon, 7 Sep 2026 19:39:58 +0200 Subject: [PATCH 82/96] docs: update filter form spec with revised capability port and ownership rules Refined documentation with a clearer `ChoiceSourceContract` design, introducing a second method (`valueFromChoiceKeys()`) to handle element-defined choice keys. Documented `ArchiveFilterElement` adaptations, config re-ownership details, and resolved decisions for intrinsic branching, shared translations, and value object usage. Added sections on decode divisions, module-specific behaviors, and open design questions. --- SPEC_FILTER_FORMS.md | 131 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 111 insertions(+), 20 deletions(-) diff --git a/SPEC_FILTER_FORMS.md b/SPEC_FILTER_FORMS.md index dcbbe444..be1dc1cf 100644 --- a/SPEC_FILTER_FORMS.md +++ b/SPEC_FILTER_FORMS.md @@ -128,12 +128,21 @@ repeated per-element declarations. ### 3.4 Capability ports A form MUST NOT read element-owned config in `decode()`. Anything it needs *pulled* from the element -is an explicit interface: +is an explicit interface. For choice-based filters there is exactly one such port: ```php interface ChoiceSourceContract { + /** + * @throws FilterException On invalid filter configuration (no whitelist, no inferrable ptable, …). + */ public function buildChoices(FilterContext $context): ChoicesBuilder; + + /** + * @param list $keys Selected choice keys, verbatim — MAY include + * {@see ChoicesBuilder::EMPTY_CHOICE}, whose meaning is element-defined. + */ + public function valueFromChoiceKeys(array $keys, FilterContext $context): ?object; } ``` @@ -141,6 +150,33 @@ Registry filter for the backend select: value class matches **and** every entry implemented by the element (plain `instanceof`, resolvable at compile time). This is not a second pairing axis — the form still never names an element. +#### The decode division + +> The **form** decodes the *widget*: which choice keys did the user pick. +> The **element** decodes the *domain*: what do those keys mean. + +So a generic choice form's `decode()` is: + +```php +$keys = (array) $field->getViewData(); // widget → keys +return $element->valueFromChoiceKeys($keys, $ctx); // keys → domain value +``` + +This is why the port needs a second method. Choice keys are element-defined: `FieldValueChoice` uses +bare ids, `ArchiveFilterElement` uses `"
."` in dynamic-ptable mode (§7.4). A form that had +to parse them would be reading element knowledge through the back door — the very thing `requires` +exists to prevent. + +Two consequences worth stating: + +- **The empty-option sentinel is passed through, not swallowed.** It looks like a widget artifact but + can carry domain meaning: `ArchiveFilterElement::normalizeFilterValue()` treats a selected empty + option as "use the full whitelist" (unless `use_whitelist_for_options_only`). The form therefore + forwards `ChoicesBuilder::EMPTY_CHOICE` verbatim and lets the element decide. +- **Labelling is split.** `buildChoices()` MAY set labels it owns — `setLabelForTable()` fed from + element-owned whitelist rows is the real case. The form owns only global overrides (`setLabel()`, + `setModelSuffix()`, `setEmptyOption()`) and `applyFormOptions()`. + --- ## 4. Ownership rules @@ -292,12 +328,44 @@ Additionally, fix `add()` so `choice` carries identity rather than the display s | stays on the element (via `ChoiceSourceContract`) | moves to the form | |---|---| -| `createChoices()` | `buildPreselectData()` | -| `getForeignValues()` | `normalizeRuntimeValue()` | -| `getLocalValues()` | `extractSubmittedData()` | +| `createChoices()` → `buildChoices()` | `buildPreselectData()` | +| `getForeignValues()` | `extractSubmittedData()` | +| `getLocalValues()` | | +| `normalizeRuntimeValue()` → `valueFromChoiceKeys()` | | + +Choice provision is element knowledge (the target field's `foreignKey` relation), and so is key +interpretation. `normalizeRuntimeValue()` does **not** move to the form — it becomes +`valueFromChoiceKeys()` on the port and loses its `ChoicesBuilder` rebuild entirely, because view +data already carries the keys. The two form-side methods largely evaporate. + +### 7.4 `ArchiveFilterElement` under the port + +Archive is the stress test for this design and it fits, with three notes. + +**One value class spans both ptable modes.** `PtableInferrer` selects at runtime between a static +main ptable (choices keyed `""`, filter `ArchiveFilterType(field: 'pid', parent_ids: [...])`) and +a dynamic ptable (choices keyed `"
."`, filter `BelongsToRelationFilterType(parent_groups:, +submitted_data: )`). The value shape therefore depends on runtime config, not +on a static declaration — which would break the one-element-one-value-class rule of §3.3 if the two +modes got separate classes. They do not: `ParentRefValue` holds `array $ids>` +and the static mode is the single-table degenerate case. `buildFilter()` re-derives the flat vs. +grouped call from the inferrer exactly as it does today. + +**Config re-ownership** per the §4.1 rule: -Choice provision is element knowledge (the target field's `foreignKey` relation). The three form-side -methods largely evaporate, since view data is already the scalar. +| config | owner | why | +|---|---|---| +| `whitelist_parents`, `group_whitelist_parents` | element | changes matching | +| `use_whitelist_for_options_only` | element | changes matching (empty selection ⇒ `return` vs. `abort()`) | +| `format_label`, `format_empty_option`, `has_empty_option` | form | presentation only | +| `is_mandatory`, `is_multiple`, `is_expanded` | form | presentation only | +| `preselect` | form | hydration (§4.2) | + +**Models leave the form data path.** Today the form's model data is `Model` instances, which +`processRuntimeValue()` filters against the whitelist. Under keys-based decode the element receives id +strings and validates them against `getWhitelistedParentIds()` — which it already computes. That +removes the Model round-trip through the form, simplifies `normalizeFilterValue()`, and keeps Models +out of value objects as §9 requires. --- @@ -395,17 +463,40 @@ The contract set is the entire risk; the remaining elements are mechanical. --- -## 13. Open questions - -1. **`ArchiveFilterElement`** is the largest element (658 lines) and mixes choice provision, - preselect handling and ptable inference. Confirm it fits `ChoiceSourceContract` or needs a second - capability port. It is the most likely place for this design to need an escape hatch. -2. **Multi-field forms and `requires`** — a compound form (`DateRange`) needs no capability today. - Confirm no compound case needs per-child capability negotiation. -3. **Form-contributed translations** — form-owned fields need `tl_flare_filter` labels; decide - whether forms declare them or they stay in the central language files. -4. **Element-owned `{form_legend}` fields** — `CalendarCurrentFilterElement::buildDca()` already - appends `{form_legend},isLimited` only when not intrinsic (line 142-144). Under §6 that branch - disappears and `isLimited` becomes form-owned config. Confirm the same holds for - `SearchKeywordsFilterElement::buildDca()` and `DcaSelectFieldFilterElement::buildDca()`, which - branch on `intrinsic` the same way. +## 13. Resolved decisions + +Recorded here because each one closes a branch the design could otherwise have taken. + +1. **`ArchiveFilterElement` gets a capability port** — and it drove the port's final shape. + `ChoiceSourceContract` carries two methods, not one (§3.4), because choice keys are + element-defined; Archive's `"
."` keys made that explicit. The port is shared by + `FieldValueChoiceFilterElement`, `DcaSelectFieldFilterElement`, `CodefogTagsChoiceFilterElement` + and `ArchiveFilterElement`, all served by one generic choice form. No second port is needed — + see §7.4 for the mode-spanning value class and the config re-ownership table. +2. **No compound case needs per-child capability negotiation.** `requires` is declared per form, not + per child field. `DateRangeFilterElement` needs no capability at all. +3. **Form-owned field labels stay in the central language files** + (`contao/languages/{en,de}/tl_flare_filter.php`). Forms declare fields and palette segments; they + do not declare translations. This keeps `tl_flare_filter`'s label surface in one place, consistent + with the shared-column reality of §6.3. +4. **`intrinsic`-branching `buildDca()` methods collapse into the §6 palette segments.** + `CalendarCurrentFilterElement::buildDca()` (lines 142-144) already appends + `{form_legend},isLimited` only when not intrinsic — hand-rolling exactly the split §6 formalises. + Under the target model that branch disappears and `isLimited` becomes form-owned config. The same + applies to `SearchKeywordsFilterElement::buildDca()`, `DcaSelectFieldFilterElement::buildDca()` + and `BooleanFilterElement::buildDca()`. + +--- + +## 14. Remaining unknowns + +Not blockers, but unverified at spec time. + +1. **`serialize()` stability for readonly value objects** is asserted from language semantics in §9, + not measured against `ListSpec::hash()`. Write a throwaway test before committing to "no hashing + interface". +2. **`DcaBuilder::selector()`** (§6.3) has no consumer yet. Confirm whether any form in the initial + migration actually contributes a subpalette; if none does, defer the API. +3. **`ArchiveFilterElement::buildPreselectData()`** is currently `ListSpec`-aware. Confirm it reduces + to a generic key-lookup against `buildChoices()` once preselect is stored as choice keys, or + whether preselect hydration needs its own port method. From ce2ae7b938f4303acc951e00ac6e126d58b7045b Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 8 Sep 2026 10:01:33 +0200 Subject: [PATCH 83/96] docs: establish filter form nomenclature and add rename phase to spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the `FilterForm` name collision: the per-filter presentation strategy takes the name, and the whole-form machinery becomes `FilterSet`. The naming axis is multiplicity within *Filter*, not filter-versus-list — a form belongs to filtering, the list is exclusively output. - §2.1 defines the four layers (`FilterSet`, `FilterForm`, `FilterFormBuilder`, mount) and the `src/Filter/Form/` vs. `src/Form/` split - §2.2 carries the rename table and the rejected names with their reasons - §12 gains Phase 0: rename plus the new `FilterSet` object, no behaviour or schema change, so step 1 contains only design - §8 moves the decode loop from `InteractiveProjector::collectFilterData()` to `FilterSet::decode()`, which is why the aggregate is an object - `$field` becomes `$mount` throughout (§1.2, §3.1, §3.4, §7.2) — the mounted node is a group, not a field, in the compound case - §14.4 records the unresolved tension between `FilterSet` and `ListSpec::$filters` --- SPEC_FILTER_FORMS.md | 155 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 141 insertions(+), 14 deletions(-) diff --git a/SPEC_FILTER_FORMS.md b/SPEC_FILTER_FORMS.md index be1dc1cf..c4f8777b 100644 --- a/SPEC_FILTER_FORMS.md +++ b/SPEC_FILTER_FORMS.md @@ -1,9 +1,12 @@ # SPEC: Decoupling Filter Forms from Filter Elements **Status:** Draft / design agreed, not implemented -**Scope:** `src/Filter/`, `src/Form/`, `src/DataContainer/Builder/`, `contao/dca/tl_flare_filter.php`, -`src/EventListener/Contao/ElementDcaListener.php`, `src/Engine/Projector/InteractiveProjector.php` -**Breaking:** yes (`FilterElementInterface`, `Filter::$data`, `tl_flare_filter` schema) +**Scope:** `src/Filter/`, `src/Form/`, `src/Event/`, `src/EventListener/NamedDispatch/`, +`src/Registry/`, `src/DependencyInjection/`, `src/DataContainer/Builder/`, +`contao/dca/tl_flare_filter.php`, `src/EventListener/Contao/ElementDcaListener.php`, +`src/Engine/Projector/InteractiveProjector.php` +**Breaking:** yes (`FilterElementInterface`, `Filter::$data`, `tl_flare_filter` schema, plus the +Phase 0 renames of §2.2 — service ids, event classes and the `flare.form.*` dispatch alias) --- @@ -28,7 +31,7 @@ incidental: it** in `normalizeRuntimeValue()` to map submitted choices back to scalars. - `DateRangeFilterElement::buildForm()` adds children `from`/`to`; `buildFilter()` reads `$data->get('from')` by those exact names. -- The `single()` vs. compound mount decision (`FilterFormFactory`) determines whether `buildFilter()` +- The `single()` vs. compound mount decision (`FilterSetFactory`, §2.2) determines whether `buildFilter()` receives `getSingleValue()` or `get($name)`. Extracting forms without addressing this trades one coupling for a worse, invisible one. @@ -68,6 +71,80 @@ Registration is **one-directional**: a form binds to a *value object*, never to Selectable forms for an element = registry lookup by the element's declared value class. A mismatch is therefore structurally impossible rather than validated. +### 2.1 Nomenclature + +`FilterForm` is the *per-filter* strategy. That name is currently occupied by the whole-form +machinery (`FilterFormFactory`, `FilterFormBuildEvent`, `flare.form.{name}.build`), which must +therefore be renamed before anything else — Phase 0 in §12. + +The renaming axis is **not** filter-versus-list. A form belongs to filtering; the list is +exclusively output. The three concerns are peers: + +| concern | meaning | +|---|---| +| **Filter** | what matches — the predicate, query side | +| **List** | what comes out — output | +| **Form** | what goes in — input | + +The aggregate is therefore not list-scoped but simply the *plural* of the singular. Four layers, +one word each: + +| term | multiplicity | what it is | +|---|---|---| +| `FilterSet` | one per list × form context | the filters, their root form, and the mount↔filter map | +| `FilterForm` | one per filter | registrable presentation strategy (`buildForm()`, `decode()`) | +| `FilterFormBuilder` | one per filter, transient | collect-only builder handed to `buildForm()` | +| **mount** | one per filter that has a form | the node mounted into the root form — flat field or compound group | + +`FilterSet` is an **object**, not a bare `FormInterface` returned by a factory: it owns the +`decode()` loop (§8), which otherwise has no home but `InteractiveProjector` — where its +predecessor already landed wrongly (§7.2). + +**"Mount"** is the codebase's own word; `FilterFormFactory` already names the variable `$mount`. +It replaces "field" throughout this spec, because in the compound case the mounted node is a +`FormType` group with children and not a field at all. + +Directory split, unchanged by the rename: `src/Filter/Form/` holds FLARE `FilterForm` +implementations (peers of `src/Filter/Element/`); `src/Form/` keeps Symfony-level building blocks +(`ChoicesBuilder`, `Form/Type/DateRangeFormType`). Mixing the two is the ambiguity this section +exists to remove. + +### 2.2 Renaming (Phase 0) + +| today | new | scope | +|---|---|---| +| `Filter\Factory\FilterFormFactory` | `Filter\Factory\FilterSetFactory` | filter set | +| — | `Filter\FilterSet` (new) | filter set | +| `Event\FilterFormBuildEvent` | `Event\FilterSetBuildEvent` | filter set | +| `EventListener\NamedDispatch\FilterFormListener` | `…\FilterSetListener` | filter set | +| `flare.form.{name}.build` | `flare.filter_set.{name}.build` | filter set | +| `Event\FilterElementFormBuiltEvent` | `Event\FilterFormBuiltEvent` | filter | +| `Filter\FilterFormBuilder`, `…Interface` | unchanged | filter | + +`FilterSetFactory` builds a `FilterSet`, not a form, so the `Form` infix drops out; the root form +is `FilterSet::getForm()`. `FilterElementFormBuiltEvent` loses its detour over the element: it was +named after the element only because `FilterForm*` was taken. + +`FilterFormBuilder` keeps its name deliberately. Symfony's own +`FormTypeInterface::buildForm(FormBuilderInterface)` names the builder after what it builds, not +after who receives it, so `FilterForm::buildForm(FilterFormBuilderInterface)` reads as the familiar +pattern. `single()` is moreover a statement about the form, not about fields, which rules out +`FilterFieldsBuilder`. + +Rejected names, recorded so the branches stay closed: + +- **`ListForm…`** — puts the form on the output side of the model, contradicting the table above. +- **`ListFilterForm…`** — contains `FilterForm` as a substring, so every search for the per-filter + concept also hits the aggregate. +- **`FilterFormType`** — `FilterType` already means the SQL predicate (`src/Filter/Type/`), and the + suffix falsely promises a Symfony `AbstractType`. +- **`FilterBar` / `FilterPanel`** — commit to a layout no template is obliged to honour, and + understate an object that also owns `decode()`. +- **`QueryForm`** — `src/Query/` is the SQL layer. +- **`FilterWidget` for the per-filter strategy** (leaving `FilterForm` on the aggregate) — the + strategy has `decode()` and its own DCA slice, so "widget" promises rendering and hides half the + contract; the term is also already occupied by Contao. + --- ## 3. Contracts @@ -80,9 +157,9 @@ interface FilterFormInterface public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void; /** - * Produces the element's canonical value from the mounted field, or null to contribute nothing. + * Produces the element's canonical value from the mount, or null to contribute nothing. */ - public function decode(FormInterface $field, FilterContext $context): ?object; + public function decode(FormInterface $mount, FilterContext $context): ?object; } ``` @@ -90,7 +167,7 @@ A form MAY additionally implement the existing contracts, which then apply to th config slice: `OptionsContract` (`configureOptions`), `TransformerContract` (`configureTransformers`), `DcaContract` (`buildDca`). -**`decode()` receives the mounted `FormInterface`, not a pre-flattened DTO.** Only the form knows +**`decode()` receives the mount (§2.1), not a pre-flattened DTO.** Only the form knows whether it wants `getData()`, `getNormData()` or `getViewData()`, and it needs access to the attributes it set in `buildForm()` (e.g. `flare.choices_builder`). See §7. @@ -158,7 +235,7 @@ pairing axis — the form still never names an element. So a generic choice form's `decode()` is: ```php -$keys = (array) $field->getViewData(); // widget → keys +$keys = (array) $mount->getViewData(); // widget → keys return $element->valueFromChoiceKeys($keys, $ctx); // keys → domain value ``` @@ -238,7 +315,9 @@ The `intrinsic` boolean is replaced by a nullable `formVariant` slot in canonica The programmatic call sites in §1.3 keep working by simply not setting a form. **Do not name the column `formAlias`** — that is taken; it is the query-parameter name -(`src/Model/FilterModel.php:91-102`). Use `formVariant`. +(`src/Model/FilterModel.php:91-102`). Use `formVariant`. Bare `form` was rejected as too generic +for a column in the one wide shared table (§6.3), even though `FilterForm` is now the noun it +selects. ### 5.2 Backend field @@ -317,9 +396,9 @@ lookup at all. `InteractiveProjector::collectFilterData()` flattens the form to `$child->getData()` — model data, which for a `ChoiceType` is the choice objects. A query filter wants the *value*, which Symfony has -already computed as **view data**: `$field->getViewData()` is `['5','7']`. +already computed as **view data**: `$mount->getViewData()` is `['5','7']`. -The fix is therefore §3.1: `decode(FormInterface $field, ...)`. No reverse mapping, no second +The fix is therefore §3.1: `decode(FormInterface $mount, ...)`. No reverse mapping, no second `ChoicesBuilder`, no label collisions. Additionally, fix `add()` so `choice` carries identity rather than the display string. @@ -384,6 +463,14 @@ Both of its jobs relocate: `FilterFormBuilder`'s `single()` vs. compound mount decision is unaffected — that is genuinely a form concern and stays where it is. +**The decode loop lives on `FilterSet`.** `InteractiveProjector::collectFilterData()` today walks +the root form and flattens every child to `$child->getData()` — the flattening §7.2 identifies as +the defect. Under the target model that walk becomes `FilterSet::decode()`: for each filter that +has a mount, call the filter form's `decode($mount, $context)` and set the resulting value on the +filter. The projector asks the filter set for values instead of reconstructing them from a form +tree it does not own. This is the reason the aggregate is an object rather than a bare +`FormInterface` (§2.1). + --- ## 9. Value objects and hashing @@ -435,6 +522,8 @@ object legitimately must hold something non-serializable. ## 11. Migration +Phase 0 (§2.2) is rename-only and touches no schema; everything below belongs to steps 1-4. + - New column `formVariant` (`varchar`), plus form-owned columns as they move. - Drop `intrinsic`, `boolMode`, `boolBinaryChoices` and the `boolMode_binary` subpalette / `boolMode` selector entry. @@ -447,11 +536,35 @@ object legitimately must hold something non-serializable. ## 12. Sequencing -The contract set is the entire risk; the remaining elements are mechanical. +Phase 0 clears the vocabulary so the rest can be written in the target words. After that the +contract set is the entire risk; the remaining elements are mechanical. + +0. **Nomenclature refactor.** Pure rename plus one new object. No behaviour change, no schema + change, no new contract — landed on its own so the diff of step 1 contains only design. + - Apply the §2.2 table. + - Introduce `Filter\FilterSet`: + `FilterSetFactory::create(ListSpec, FormContextInterface): FilterSet`, holding the root + `FormInterface` and the mount↔filter map. `getForm()` returns the root form; callers that + only need the form (`InteractiveProjector`, the list-view template data) go through it. The + `decode()` loop (§8) arrives in step 1 — Phase 0 only builds its home. + - Align the factory's local variables with the vocabulary: root builder `$root`, collect-only + per-filter builder `$builder`, mounted node `$mount` (today `$builder` / `$wrapper` / + `$mount`, where `$builder` denotes the root and the per-filter collector is a "wrapper"). + - Move the tests next to their subjects: `tests/Form/FilterFormFactoryTest.php` → + `tests/Filter/FilterSetFactoryTest.php`, `tests/Form/FilterFormBuilderTest.php` → + `tests/Filter/FilterFormBuilderTest.php`. Add coverage for `FilterSet::getForm()` and the + mount↔filter map, which has no test today. + - Update `AGENTS.md`: the "Event system" paragraph names `flare.form.{name}.build`, and + "Notable subsystems" describes `src/Form/` as "filter form building (FilterFormFactory etc.)". + Both become wrong. State the §2.1 directory split there. + - Grep gate for the rename being complete: `FilterFormFactory`, `FilterFormBuildEvent`, + `FilterElementFormBuiltEvent` and `flare.form.` must have no hits left outside this spec's + history. 1. **Contracts + pilot.** Value objects, `FilterFormInterface`, `AsFilterForm`, - `AsFilterElement::$value`, `decode(FormInterface)`, `buildFilter(?object)`, registry lookup by - value class, `requires` check in the compiler pass. Pilot on `BooleanFilterElement` — it has the + `AsFilterElement::$value`, `decode(FormInterface $mount)`, `buildFilter(?object)`, + `FilterSet::decode()` replacing `InteractiveProjector::collectFilterData()` (§8), registry + lookup by value class, `requires` check in the compiler pass. Pilot on `BooleanFilterElement` — it has the most presentation-config rot (`boolMode`, `boolBinaryChoices`, `label`, the `preselect` overload). 2. **DCA composition.** `DcaBuilder` palette segments, `scope()`, `selector()`; `ElementDcaListener` resolves and invokes the form's `buildDca()`. @@ -485,6 +598,13 @@ Recorded here because each one closes a branch the design could otherwise have t Under the target model that branch disappears and `isLimited` becomes form-owned config. The same applies to `SearchKeywordsFilterElement::buildDca()`, `DcaSelectFieldFilterElement::buildDca()` and `BooleanFilterElement::buildDca()`. +5. **The aggregate is called `FilterSet` and is an object.** The naming axis is multiplicity within + *Filter*, not filter-versus-list (§2.1) — the list is exclusively output, so no `List…` name can + be right for a form. `FilterSet` won over the invented alternatives because it names something + that already exists unnamed: `ListSpec::$filters` is a bare `array` with + hand-rolled `_generated_{$index}` keying and a hand-rolled `array_map` fingerprint loop in + `hash()`. Making it an object rather than a factory return value is what gives `decode()` a home + (§8). Rejected names and their reasons are in §2.2. --- @@ -500,3 +620,10 @@ Not blockers, but unverified at spec time. 3. **`ArchiveFilterElement::buildPreselectData()`** is currently `ListSpec`-aware. Confirm it reduces to a generic key-lookup against `buildChoices()` once preselect is stored as choice keys, or whether preselect hydration needs its own port method. +4. **Whether `FilterSet` should also absorb `ListSpec::$filters`.** The name fits the bare + `array` at least as well as it fits the form aggregate, which is a tension the + rename introduces rather than resolves. Phase 0 deliberately keeps them apart: `ListSpec` is + built in validation and aggregation contexts that never produce a form, so a form-carrying + `FilterSet` cannot simply replace the array. Revisit once the decode loop exists — either + `FilterSet` splits into a plain collection plus a form-bearing wrapper, or the two stay separate + and the form-side object needs a distinguishing name after all. From 1490eb8e5e88c869015de37694c4cdeb0414d121 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 8 Sep 2026 10:28:49 +0200 Subject: [PATCH 84/96] refactor!: rename the filter form aggregate to FilterSet and introduce FilterMount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements step 0 of SPEC_FILTER_FORMS.md §12 (§2.1/§2.2). `FilterForm` is the name the upcoming per-filter presentation strategy needs, but it was occupied by the whole-form machinery. This frees it and gives the decode loop of step 1 a home to move into. Renames per §2.2: - Filter\Factory\FilterFormFactory -> Filter\Factory\FilterSetFactory - Event\FilterFormBuildEvent -> Event\FilterSetBuildEvent - Event\FilterElementFormBuiltEvent -> Event\FilterFormBuiltEvent - NamedDispatch\FilterFormListener -> NamedDispatch\FilterSetListener - flare.form.{name}.build -> flare.filter_set.{name}.build - flare.filter_element.{type}.form_built -> flare.filter_form.{type}.built The second alias is not in §2.2's table (the spec renames the event class but is silent on its alias); it is recorded there now. Its handler moves out of FilterElementListener into a new FilterFormListener, occupying the path the FilterSetListener rename frees. `{type}` there is still the element type — the alias names the concern, not the key. FilterSetFactory::create() now returns a FilterSet holding the root form plus a mount<->filter map of FilterMount (filter, alias, FilterContext), keyed by the filter's key within ListSpec::$filters. Only filters that actually mount get an entry. Mounts resolve lazily against the root form, because form children may legally be added or removed by a PRE_SUBMIT listener while the request is handled. Local variables follow §12.0: root builder `$root`, per-filter collector `$builder`, mounted node `$mount`. FilterMount::$filter and $context overlap deliberately; only $alias carries information the context does not — the valid-form-name invariant established before mounting. InteractiveProjector gains a protected createFilterSet() that hands the request to the root form; createForm() keeps its signature and delegates. collectFilterData() is untouched — step 1 replaces it with FilterSet::decode(). InteractiveView still takes a FormInterface, so the list-view template is unaffected. No behaviour change beyond the two dispatch aliases; no schema change; no new contract. BREAKING CHANGE: the service id of the renamed factory, the four event/listener class names, and the `flare.form.{name}.build` and `flare.filter_element.{type}.form_built` dispatch aliases. The `flare.form.date_range.*` validator translation keys are unrelated and unchanged. --- AGENTS.md | 18 +- SPEC_FILTER_FORMS.md | 40 ++++- src/Engine/Projector/InteractiveProjector.php | 25 ++- src/Event/FilterFormBuildEvent.php | 18 -- ...uiltEvent.php => FilterFormBuiltEvent.php} | 9 +- src/Event/FilterSetBuildEvent.php | 28 +++ .../NamedDispatch/FilterElementListener.php | 11 -- .../NamedDispatch/FilterFormListener.php | 10 +- .../NamedDispatch/FilterSetListener.php | 24 +++ ...erFormFactory.php => FilterSetFactory.php} | 65 ++++--- src/Filter/FilterData.php | 2 +- src/Filter/FilterFormBuilder.php | 4 +- src/Filter/FilterMount.php | 34 ++++ src/Filter/FilterSet.php | 72 ++++++++ .../NamedDispatch/FilterFormListenerTest.php | 83 +++++++++ .../NamedDispatch/FilterSetListenerTest.php | 65 +++++++ .../FilterFormBuilderTest.php | 2 +- .../FilterSetFactoryTest.php} | 168 +++++++++++++++++- 18 files changed, 581 insertions(+), 97 deletions(-) delete mode 100644 src/Event/FilterFormBuildEvent.php rename src/Event/{FilterElementFormBuiltEvent.php => FilterFormBuiltEvent.php} (73%) create mode 100644 src/Event/FilterSetBuildEvent.php create mode 100644 src/EventListener/NamedDispatch/FilterSetListener.php rename src/Filter/Factory/{FilterFormFactory.php => FilterSetFactory.php} (68%) create mode 100644 src/Filter/FilterMount.php create mode 100644 src/Filter/FilterSet.php create mode 100644 tests/EventListener/NamedDispatch/FilterFormListenerTest.php create mode 100644 tests/EventListener/NamedDispatch/FilterSetListenerTest.php rename tests/{Form => Filter}/FilterFormBuilderTest.php (98%) rename tests/{Form/FilterFormFactoryTest.php => Filter/FilterSetFactoryTest.php} (54%) diff --git a/AGENTS.md b/AGENTS.md index 6038a324..729d3fff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,10 +40,13 @@ The bundle follows standard Symfony Bundle architecture with deep Contao integra (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` + (`FilterOptionsResolver`, `FilterTransformerResolver`, `FilterElementResolver`), `FilterContextFactory`, + and the form aggregate: `FilterSetFactory` builds a `FilterSet` (root form + mount↔filter map of + `FilterMount`s) per list × form context - `src/Config/` — `ConfigBuilder` (fluent canonical-config accumulator; no cast helpers — transformers cast declaratively off the typed model) and `TransformerResolver` (source class → transformer map) -- `src/Form/` — filter form building (FilterFormFactory etc.) +- `src/Filter/Form/` — FLARE filter form implementations (peers of `src/Filter/Element/`); + `src/Form/` — Symfony-level building blocks only (`ChoicesBuilder`, `Form/Type/DateRangeFormType`) - `src/Reader/` — reader/detail-page URL generation (`ReaderUrlGenerator`) - `src/InferPtable/` — parent-table inference for DCAs - `src/Integration/` — optional integrations (Codefog Tags, Terminal42 ChangeLanguage), wired via `config/integrations/*.yaml` @@ -57,16 +60,17 @@ The bundle follows standard Symfony Bundle architecture with deep Contao integra **Extensibility via PHP 8 attributes** (compiler passes auto-register tagged services): - `#[AsFilterElement(type: '...', isTargeted: ...)]` — register a filter element -- `#[AsListType(type: '...', dataContainer: '...')]` — register a list type +- `#[AsListDriver(type: '...', dataContainer: '...')]` — register a list driver 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`, -`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. +**Event system** — Events, some with aliased dispatch for targeted listening +(`flare.filter_set.{name}.build`, `flare.filter_form.{type}.built`, `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`, `FilterTypeRegistry`, `ProjectorRegistry`, `EngineModRegistry`. diff --git a/SPEC_FILTER_FORMS.md b/SPEC_FILTER_FORMS.md index c4f8777b..35e6dc04 100644 --- a/SPEC_FILTER_FORMS.md +++ b/SPEC_FILTER_FORMS.md @@ -1,12 +1,13 @@ # SPEC: Decoupling Filter Forms from Filter Elements -**Status:** Draft / design agreed, not implemented +**Status:** Step 0 (§12) implemented; steps 1-5 not started **Scope:** `src/Filter/`, `src/Form/`, `src/Event/`, `src/EventListener/NamedDispatch/`, `src/Registry/`, `src/DependencyInjection/`, `src/DataContainer/Builder/`, `contao/dca/tl_flare_filter.php`, `src/EventListener/Contao/ElementDcaListener.php`, `src/Engine/Projector/InteractiveProjector.php` **Breaking:** yes (`FilterElementInterface`, `Filter::$data`, `tl_flare_filter` schema, plus the -Phase 0 renames of §2.2 — service ids, event classes and the `flare.form.*` dispatch alias) +Phase 0 renames of §2.2 — service ids, event classes and two dispatch aliases: +`flare.form.{name}.build` and `flare.filter_element.{type}.form_built`) --- @@ -115,15 +116,21 @@ exists to remove. |---|---|---| | `Filter\Factory\FilterFormFactory` | `Filter\Factory\FilterSetFactory` | filter set | | — | `Filter\FilterSet` (new) | filter set | +| — | `Filter\FilterMount` (new) | filter set | | `Event\FilterFormBuildEvent` | `Event\FilterSetBuildEvent` | filter set | | `EventListener\NamedDispatch\FilterFormListener` | `…\FilterSetListener` | filter set | | `flare.form.{name}.build` | `flare.filter_set.{name}.build` | filter set | | `Event\FilterElementFormBuiltEvent` | `Event\FilterFormBuiltEvent` | filter | +| `flare.filter_element.{type}.form_built` | `flare.filter_form.{type}.built` | filter | +| — | `EventListener\NamedDispatch\FilterFormListener` (new) | filter | | `Filter\FilterFormBuilder`, `…Interface` | unchanged | filter | `FilterSetFactory` builds a `FilterSet`, not a form, so the `Form` infix drops out; the root form is `FilterSet::getForm()`. `FilterElementFormBuiltEvent` loses its detour over the element: it was -named after the element only because `FilterForm*` was taken. +named after the element only because `FilterForm*` was taken — and its dispatch alias follows the +class, moving out of `FilterElementListener` into the `FilterFormListener` whose name the +`FilterFormListener` → `FilterSetListener` rename frees. `{type}` there remains the *element* type, +which is what listeners target; the alias names the concern, not the key. `FilterFormBuilder` keeps its name deliberately. Symfony's own `FormTypeInterface::buildForm(FormBuilderInterface)` names the builder after what it builds, not @@ -539,8 +546,8 @@ Phase 0 (§2.2) is rename-only and touches no schema; everything below belongs t Phase 0 clears the vocabulary so the rest can be written in the target words. After that the contract set is the entire risk; the remaining elements are mechanical. -0. **Nomenclature refactor.** Pure rename plus one new object. No behaviour change, no schema - change, no new contract — landed on its own so the diff of step 1 contains only design. +0. **Nomenclature refactor.** ✅ **Done.** Pure rename plus one new object. No behaviour change, + no schema change, no new contract — landed on its own so the diff of step 1 contains only design. - Apply the §2.2 table. - Introduce `Filter\FilterSet`: `FilterSetFactory::create(ListSpec, FormContextInterface): FilterSet`, holding the root @@ -612,9 +619,26 @@ Recorded here because each one closes a branch the design could otherwise have t Not blockers, but unverified at spec time. -1. **`serialize()` stability for readonly value objects** is asserted from language semantics in §9, - not measured against `ListSpec::hash()`. Write a throwaway test before committing to "no hashing - interface". +1. ~~**`serialize()` stability for readonly value objects**~~ — **measured** in step 0. + `tests/Filter/ValueObjectSerializeProbeTest.php` (throwaway, `@group probe`) exercises the real + `ListSpec::hash()` path via `Filter::$data`. Results: + - §9's core claim **holds**: two separately constructed, equal `final readonly` value objects of + scalars, arrays, enums and nested value objects hash identically, and survive a + `serialize()`/`unserialize()` round trip. Enums are value stable. + - Every hazard §9 names is **confirmed**: `\DateTimeImmutable` hashes differently for the same + instant as `+01:00` (`timezone_type` 1) vs. `Europe/Berlin` (`timezone_type` 3); array order + changes the hash for both list and string keys; a mutation-state-carrying model-like object + drags unrelated state in; a closure makes `serialize()` throw. + - **New finding not anticipated by §9:** `serialize()` is not a pure value function over an + object *graph*. A repeated object is emitted as a back-reference (`r:N;`), so a hash over two + filters differs depending on whether they **share one value instance** or hold two equal ones. + Today's code is immune only because `Filter::fingerprint()` flattens through + `FilterData::toArray()`; §8's plan to move the hashing role onto the value object removes that + flattening. **Step 1 must therefore either keep a flattening step (the opt-in + `fingerprint(): array` §9 mentions) or accept the resulting cache miss.** Note the blast radius + is small: `ListSpec::hash()`'s only consumer is an in-request memoization array in + `ArchiveFilterElement`, so instability costs a cache miss, not correctness — a *collision* + would be the correctness bug, and none was observed. 2. **`DcaBuilder::selector()`** (§6.3) has no consumer yet. Confirm whether any form in the initial migration actually contributes a subpalette; if none does, defer the API. 3. **`ArchiveFilterElement::buildPreselectData()`** is currently `ListSpec`-aware. Confirm it reduces diff --git a/src/Engine/Projector/InteractiveProjector.php b/src/Engine/Projector/InteractiveProjector.php index ce7a231f..2bfe5da9 100644 --- a/src/Engine/Projector/InteractiveProjector.php +++ b/src/Engine/Projector/InteractiveProjector.php @@ -14,9 +14,10 @@ use HeimrichHannot\FlareBundle\Engine\View\AggregationView; use HeimrichHannot\FlareBundle\Engine\View\InteractiveView; use HeimrichHannot\FlareBundle\Exception\FlareException; -use HeimrichHannot\FlareBundle\Filter\Factory\FilterFormFactory; +use HeimrichHannot\FlareBundle\Filter\Factory\FilterSetFactory; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; +use HeimrichHannot\FlareBundle\Filter\FilterSet; use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Paginator\Factory\PaginatorFactory; use HeimrichHannot\FlareBundle\Paginator\Paginator; @@ -30,7 +31,7 @@ class InteractiveProjector extends AbstractProjector { public function __construct( private readonly AggregationContextFactory $aggregationConfigFactory, - private readonly FilterFormFactory $filterFormFactory, + private readonly FilterSetFactory $filterSetFactory, private readonly PaginatorFactory $paginatorFactory, ) {} @@ -44,7 +45,8 @@ public function project(ListSpec $list, ContextInterface $context): InteractiveV \assert($context instanceof InteractiveContext, '$config must be an instance of InteractiveConfig'); // collect filter values from form data - $form = $this->createForm($list, $context); + $filterSet = $this->createFilterSet($list, $context); + $form = $filterSet->getForm(); $filterValues = $this->collectFilterData($list, $form); // pagination setup @@ -110,10 +112,21 @@ protected function createView( */ public function createForm(ListSpec $list, InteractiveContext $context): FormInterface { - $form = $this->filterFormFactory->create($list, $context); - $form->handleRequest($this->getCurrentRequest()); + return $this->createFilterSet($list, $context)->getForm(); + } + + /** + * Builds the list's filter set and hands the current request to its root form. + * + * @throws FlareException + */ + protected function createFilterSet(ListSpec $list, InteractiveContext $context): FilterSet + { + $filterSet = $this->filterSetFactory->create($list, $context); + + $filterSet->getForm()->handleRequest($this->getCurrentRequest()); - return $form; + return $filterSet; } /** diff --git a/src/Event/FilterFormBuildEvent.php b/src/Event/FilterFormBuildEvent.php deleted file mode 100644 index aef09e0c..00000000 --- a/src/Event/FilterFormBuildEvent.php +++ /dev/null @@ -1,18 +0,0 @@ -eventDispatcher->dispatch(event: $event, eventName: "flare.filter_element.{$type}.building"); } - - #[AsEventListener(priority: -200)] - public function onFilterElementFormBuiltEvent(FilterElementFormBuiltEvent $event): void - { - if (!$type = $event->context->filter->type) { - 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 index 85c12989..7931be09 100644 --- a/src/EventListener/NamedDispatch/FilterFormListener.php +++ b/src/EventListener/NamedDispatch/FilterFormListener.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\EventListener\NamedDispatch; -use HeimrichHannot\FlareBundle\Event\FilterFormBuildEvent; +use HeimrichHannot\FlareBundle\Event\FilterFormBuiltEvent; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -15,10 +15,12 @@ public function __construct( ) {} #[AsEventListener(priority: -200)] - public function onFilterFormBuildEvent(FilterFormBuildEvent $event): void + public function onFilterFormBuiltEvent(FilterFormBuiltEvent $event): void { - $eventName = "flare.form.{$event->formName}.build"; + if (!$type = $event->context->filter->type) { + return; + } - $this->eventDispatcher->dispatch(event: $event, eventName: $eventName); + $this->eventDispatcher->dispatch(event: $event, eventName: "flare.filter_form.{$type}.built"); } } diff --git a/src/EventListener/NamedDispatch/FilterSetListener.php b/src/EventListener/NamedDispatch/FilterSetListener.php new file mode 100644 index 00000000..5cdbc419 --- /dev/null +++ b/src/EventListener/NamedDispatch/FilterSetListener.php @@ -0,0 +1,24 @@ +formName}.build"; + + $this->eventDispatcher->dispatch(event: $event, eventName: $eventName); + } +} diff --git a/src/Filter/Factory/FilterFormFactory.php b/src/Filter/Factory/FilterSetFactory.php similarity index 68% rename from src/Filter/Factory/FilterFormFactory.php rename to src/Filter/Factory/FilterSetFactory.php index deb46b8a..4685dfbf 100644 --- a/src/Filter/Factory/FilterFormFactory.php +++ b/src/Filter/Factory/FilterSetFactory.php @@ -6,21 +6,22 @@ use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; use HeimrichHannot\FlareBundle\Engine\Context\FormContextInterface; -use HeimrichHannot\FlareBundle\Event\FilterElementFormBuiltEvent; -use HeimrichHannot\FlareBundle\Event\FilterFormBuildEvent; +use HeimrichHannot\FlareBundle\Event\FilterFormBuiltEvent; +use HeimrichHannot\FlareBundle\Event\FilterSetBuildEvent; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilder; +use HeimrichHannot\FlareBundle\Filter\FilterMount; +use HeimrichHannot\FlareBundle\Filter\FilterSet; 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\FormBuilder; use Symfony\Component\Form\FormFactoryInterface; -use Symfony\Component\Form\FormInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; -final readonly class FilterFormFactory +final readonly class FilterSetFactory { public function __construct( private EventDispatcherInterface $eventDispatcher, @@ -29,9 +30,12 @@ public function __construct( ) {} /** + * Builds the list's filter set: the root form with every mountable filter mounted onto it, + * plus the mount↔filter map. + * * @throws FlareException If the form could not be built */ - public function create(ListSpec $list, FormContextInterface $context): FormInterface + public function create(ListSpec $list, FormContextInterface $context): FilterSet { if (!$context instanceof ContextInterface) { throw new FlareException( @@ -55,9 +59,12 @@ public function create(ListSpec $list, FormContextInterface $context): FormInter $formOptions['action'] = $action; } - $builder = $this->formFactory->createNamedBuilder($name, FormType::class, null, $formOptions); - $builder->setAttribute('flare.list', $list); - $builder->setAttribute('flare.engine_context', $context); + $root = $this->formFactory->createNamedBuilder($name, FormType::class, null, $formOptions); + $root->setAttribute('flare.list', $list); + $root->setAttribute('flare.engine_context', $context); + + /** @var array $mounts */ + $mounts = []; foreach ($list->filters as $key => $filter) { @@ -69,13 +76,13 @@ public function create(ListSpec $list, FormContextInterface $context): FormInter // 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); + $builder = new FilterFormBuilder($filter->alias, null, new EventDispatcher(), $this->formFactory); + $builder->setAttribute(FilterContext::ATTR_SELF, $filterContext); - $filter->element->buildForm($wrapper, $filterContext); + $filter->element->buildForm($builder, $filterContext); - /** @var FilterElementFormBuiltEvent $event */ - $event = $this->eventDispatcher->dispatch(new FilterElementFormBuiltEvent($wrapper, $filterContext)); + /** @var FilterFormBuiltEvent $event */ + $event = $this->eventDispatcher->dispatch(new FilterFormBuiltEvent($builder, $filterContext)); if ($event->isCancelled()) // Filters can be skipped by event listeners. @@ -83,15 +90,15 @@ public function create(ListSpec $list, FormContextInterface $context): FormInter continue; } - $single = $wrapper->getSingle(); + $single = $builder->getSingle(); - if (!$single && $wrapper->count() === 0) + if (!$single && $builder->count() === 0) // Filters without any form representation are never mounted. { continue; } - if ($single && $wrapper->count() > 0) + if ($single && $builder->count() > 0) { throw new FlareException( 'Filter element cannot declare a single field and add children at the same time.', @@ -101,32 +108,34 @@ public function create(ListSpec $list, FormContextInterface $context): FormInter if ($single) { - $mount = $builder->create($filter->alias, $single['type'], $single['options']); + $mount = $root->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 { - $mount = $builder->create($filter->alias, FormType::class, [ + $mount = $root->create($filter->alias, FormType::class, [ 'inherit_data' => false, 'label' => false, 'required' => false, ]); - foreach ($wrapper->all() as $childBuilder) { + foreach ($builder->all() as $childBuilder) { $mount->add($childBuilder); } } - foreach ($wrapper->getAttributes() as $attrName => $attrValue) { + foreach ($builder->getAttributes() as $attrName => $attrValue) { $mount->setAttribute($attrName, $attrValue); } - foreach ($wrapper->getDeferredListeners() as [$eventName, $listener, $priority]) { + foreach ($builder->getDeferredListeners() as [$eventName, $listener, $priority]) { $mount->addEventListener($eventName, $listener, $priority); } - $builder->add($mount); + $mounts[$key] = new FilterMount($filter, $filter->alias, $filterContext); + + $root->add($mount); } /* @@ -139,16 +148,16 @@ public function create(ListSpec $list, FormContextInterface $context): FormInter * ``` */ - /** @var FilterFormBuildEvent $formBuildEvent */ - $formBuildEvent = $this->eventDispatcher->dispatch(new FilterFormBuildEvent( + /** @var FilterSetBuildEvent $formBuildEvent */ + $formBuildEvent = $this->eventDispatcher->dispatch(new FilterSetBuildEvent( list: $list, formName: $name, - formBuilder: $builder, + formBuilder: $root, )); - /** @var FormBuilder $builder */ - $builder = $formBuildEvent->formBuilder; + /** @var FormBuilder $root */ + $root = $formBuildEvent->formBuilder; - return $builder->getForm(); + return new FilterSet($root->getForm(), $mounts); } } diff --git a/src/Filter/FilterData.php b/src/Filter/FilterData.php index f0bffa91..362bc2c4 100644 --- a/src/Filter/FilterData.php +++ b/src/Filter/FilterData.php @@ -8,7 +8,7 @@ * Runtime data of one filter invocation. * * Holds either a single field's value or a compound filter's named field values — never both, - * mirroring the mount decision in {@see Factory\FilterFormFactory}: an element that declares + * mirroring the mount decision in {@see Factory\FilterSetFactory}: an element that declares * {@see FilterFormBuilderInterface::single()} mounts flat under the filter's alias, while an * element adding children mounts as a compound sub-form. * diff --git a/src/Filter/FilterFormBuilder.php b/src/Filter/FilterFormBuilder.php index d0bad94f..a76eefc8 100644 --- a/src/Filter/FilterFormBuilder.php +++ b/src/Filter/FilterFormBuilder.php @@ -9,7 +9,7 @@ /** * Collect-only builder for a single filter's form fields. * - * Constructed manually by {@see Factory\FilterFormFactory} outside Symfony's form-type system, + * Constructed manually by {@see Factory\FilterSetFactory} outside Symfony's form-type system, * so it carries no resolved type, options, or data mapper and must never be mounted into a form * tree — the factory transfers its children, attributes, single-field spec, and deferred event * listeners onto a real builder. Children created through add()/create() are real, factory-built @@ -75,7 +75,7 @@ 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.', + . ' FilterSetFactory transfers its fields onto a real builder.', self::class, )); } diff --git a/src/Filter/FilterMount.php b/src/Filter/FilterMount.php new file mode 100644 index 00000000..241156fc --- /dev/null +++ b/src/Filter/FilterMount.php @@ -0,0 +1,34 @@ +filter`: it is the field consumers + * reach for, and going through the context would be a hop through an unrelated concern. Only + * {@see $alias} carries information the context does not — the non-empty, valid-form-name + * invariant that {@see \HeimrichHannot\FlareBundle\Util\Str::isValidFormName()} established + * before the filter was mounted. + * + * @api + */ +final readonly class FilterMount +{ + /** + * @param Filter $filter The filter this mount belongs to. + * @param string $alias Form name of the mounted node on the root form. + * @param FilterContext $context Invocation context the filter's form was built with. + */ + public function __construct( + public Filter $filter, + public string $alias, + public FilterContext $context, + ) {} +} diff --git a/src/Filter/FilterSet.php b/src/Filter/FilterSet.php new file mode 100644 index 00000000..927eab1e --- /dev/null +++ b/src/Filter/FilterSet.php @@ -0,0 +1,72 @@ + $mounts Mounts keyed by the filter's key within + * {@see \HeimrichHannot\FlareBundle\List\ListSpec::$filters}. + * + * @internal Use {@see Factory\FilterSetFactory} to create instances. + */ + public function __construct( + private FormInterface $form, + private array $mounts = [], + ) {} + + public function getForm(): FormInterface + { + return $this->form; + } + + /** + * @return array Mounts keyed by the filter's list-specification key. + */ + public function getMounts(): array + { + return $this->mounts; + } + + public function getFilterMount(string|int $key): ?FilterMount + { + return $this->mounts[$key] ?? null; + } + + /** + * The mounted form child of the given filter, or null when the root form has no such child. + * + * Null covers three cases, none of them an error: the filter never mounted (invalid alias, no + * declared fields, cancelled build), a listener removed the child, or a listener replaced the + * root builder wholesale ({@see \HeimrichHannot\FlareBundle\Event\FilterSetBuildEvent::$formBuilder}). + * + * Resolution is deliberately lazy: form children may legally be added or removed by a + * PRE_SUBMIT listener while the request is being handled, so the mount is looked up on every + * call instead of being captured when the set was built. + */ + public function getMount(string|int $key): ?FormInterface + { + // Compared against null, not truthiness: Str::isValidFormName() permits "0" as an alias. + $alias = ($this->mounts[$key] ?? null)?->alias; + + if ($alias === null) { + return null; + } + + return $this->form->has($alias) ? $this->form->get($alias) : null; + } +} diff --git a/tests/EventListener/NamedDispatch/FilterFormListenerTest.php b/tests/EventListener/NamedDispatch/FilterFormListenerTest.php new file mode 100644 index 00000000..931d8e82 --- /dev/null +++ b/tests/EventListener/NamedDispatch/FilterFormListenerTest.php @@ -0,0 +1,83 @@ +dispatchedNames('a')); + } + + public function testUntypedFilterTriggersNoNamedDispatch(): void + { + self::assertSame([], $this->dispatchedNames('')); + } + + /** + * @return list + */ + private function dispatchedNames(string $type): array + { + $names = []; + + $dispatcher = new EventDispatcher(); + + foreach (['a', 'b'] as $candidate) + { + $dispatcher->addListener( + "flare.filter_form.{$candidate}.built", + static function () use (&$names, $candidate): void { + $names[] = "flare.filter_form.{$candidate}.built"; + }, + ); + } + + $driver = new class implements ListDriverInterface { + public function resolveDcTable(string $type, array $config, array $attributes): string + { + return 'tl_test'; + } + }; + + $engineContext = new class implements ContextInterface { + public static function getContextType(): string + { + return 'test'; + } + }; + + $list = new ListSpec(driver: $driver, type: 'test_list', dc: 'tl_test'); + $filter = new Filter(element: new StubFilterElement(), type: $type, alias: 'suche'); + + $context = new FilterContext( + list: $list, + filter: $filter, + config: [], + engineContext: $engineContext, + key: 'suche', + ); + + $builder = new FilterFormBuilder('suche', null, new EventDispatcher(), Forms::createFormFactory()); + + $listener = new FilterFormListener($dispatcher); + $listener->onFilterFormBuiltEvent(new FilterFormBuiltEvent($builder, $context)); + + return $names; + } +} diff --git a/tests/EventListener/NamedDispatch/FilterSetListenerTest.php b/tests/EventListener/NamedDispatch/FilterSetListenerTest.php new file mode 100644 index 00000000..7e1ae977 --- /dev/null +++ b/tests/EventListener/NamedDispatch/FilterSetListenerTest.php @@ -0,0 +1,65 @@ +dispatchedNames('flare_a')); + } + + public function testNamedEventIsScopedToTheFormName(): void + { + self::assertSame([], $this->dispatchedNames('flare_other')); + } + + /** + * @return list + */ + private function dispatchedNames(string $formName): array + { + $names = []; + + $dispatcher = new EventDispatcher(); + + foreach (['flare_a', 'flare_b'] as $name) + { + $dispatcher->addListener( + "flare.filter_set.{$name}.build", + static function () use (&$names, $name): void { + $names[] = "flare.filter_set.{$name}.build"; + }, + ); + } + + $driver = new class implements ListDriverInterface { + public function resolveDcTable(string $type, array $config, array $attributes): string + { + return 'tl_test'; + } + }; + + $formBuilder = Forms::createFormFactory()->createNamedBuilder($formName, FormType::class); + + $listener = new FilterSetListener($dispatcher); + $listener->onFilterSetBuildEvent(new FilterSetBuildEvent( + list: new ListSpec(driver: $driver, type: 'test_list', dc: 'tl_test'), + formName: $formName, + formBuilder: $formBuilder, + )); + + return $names; + } +} diff --git a/tests/Form/FilterFormBuilderTest.php b/tests/Filter/FilterFormBuilderTest.php similarity index 98% rename from tests/Form/FilterFormBuilderTest.php rename to tests/Filter/FilterFormBuilderTest.php index 1fda7c45..4bb1771c 100644 --- a/tests/Form/FilterFormBuilderTest.php +++ b/tests/Filter/FilterFormBuilderTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Tests\Form; +namespace HeimrichHannot\FlareBundle\Tests\Filter; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilder; use PHPUnit\Framework\TestCase; diff --git a/tests/Form/FilterFormFactoryTest.php b/tests/Filter/FilterSetFactoryTest.php similarity index 54% rename from tests/Form/FilterFormFactoryTest.php rename to tests/Filter/FilterSetFactoryTest.php index a2559d05..9e8fac58 100644 --- a/tests/Form/FilterFormFactoryTest.php +++ b/tests/Filter/FilterSetFactoryTest.php @@ -2,21 +2,24 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Tests\Form; +namespace HeimrichHannot\FlareBundle\Tests\Filter; use HeimrichHannot\FlareBundle\Config\SchemaResolver; use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; use HeimrichHannot\FlareBundle\Engine\Context\FormContextInterface; -use HeimrichHannot\FlareBundle\Event\FilterElementFormBuiltEvent; +use HeimrichHannot\FlareBundle\Event\FilterFormBuiltEvent; +use HeimrichHannot\FlareBundle\Event\FilterSetBuildEvent; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Factory\FilterContextFactory; -use HeimrichHannot\FlareBundle\Filter\Factory\FilterFormFactory; +use HeimrichHannot\FlareBundle\Filter\Factory\FilterSetFactory; use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\FilterMount; +use HeimrichHannot\FlareBundle\Filter\FilterSet; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterOptionsResolver; use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; @@ -30,7 +33,7 @@ use Symfony\Component\Form\Forms; use Symfony\Component\Security\Csrf\CsrfTokenManager; -final class FilterFormFactoryTest extends TestCase +final class FilterSetFactoryTest extends TestCase { private EventDispatcher $eventDispatcher; @@ -39,7 +42,7 @@ protected function setUp(): void $this->eventDispatcher = new EventDispatcher(); } - private function createFactory(): FilterFormFactory + private function createFactory(): FilterSetFactory { // The CSRF extension only needs to define the "csrf_protection" option; the factory // always disables it, so the token manager is never used. @@ -47,7 +50,7 @@ private function createFactory(): FilterFormFactory ->addExtension(new CsrfExtension(new CsrfTokenManager())) ->getFormFactory(); - return new FilterFormFactory( + return new FilterSetFactory( eventDispatcher: $this->eventDispatcher, filterContextFactory: new FilterContextFactory(new FilterOptionsResolver(new SchemaResolver())), formFactory: $formFactory, @@ -55,6 +58,11 @@ private function createFactory(): FilterFormFactory } private function createForm(array $filters): FormInterface + { + return $this->createFilterSet($filters)->getForm(); + } + + private function createFilterSet(array $filters): FilterSet { $driver = new class implements ListDriverInterface { public function resolveDcTable(string $type, array $config, array $attributes): string @@ -202,8 +210,8 @@ public function testInvalidAliasIsSkipped(): void public function testCancelledEventPreventsMounting(): void { $this->eventDispatcher->addListener( - FilterElementFormBuiltEvent::class, - static fn (FilterElementFormBuiltEvent $event) => $event->cancel(), + FilterFormBuiltEvent::class, + static fn (FilterFormBuiltEvent $event) => $event->cancel(), ); $element = $this->element(static function (FilterFormBuilderInterface $builder): void { @@ -214,4 +222,148 @@ public function testCancelledEventPreventsMounting(): void $this->assertFalse($form->has('suche')); } + + public function testGetFormReturnsTheRootForm(): void + { + $element = $this->element(static function (FilterFormBuilderInterface $builder): void { + $builder->single(TextType::class); + }); + + $filterSet = $this->createFilterSet([ + 'suche' => new Filter(element: $element, type: 'test_element', alias: 'suche'), + ]); + + $form = $filterSet->getForm(); + + $this->assertSame('flare_test', $form->getName()); + $this->assertTrue($form->has('suche')); + $this->assertSame($form, $filterSet->getForm(), 'The root form is not rebuilt per call'); + } + + public function testMountMapRecordsSingleAndCompoundFiltersUnderTheirListSpecKey(): void + { + $single = $this->element(static function (FilterFormBuilderInterface $builder): void { + $builder->single(TextType::class); + }); + + $compound = $this->element(static function (FilterFormBuilderInterface $builder): void { + $builder->add('from', TextType::class); + $builder->add('to', TextType::class); + }); + + $singleFilter = new Filter(element: $single, type: 'test_element', alias: 'suche'); + $compoundFilter = new Filter(element: $compound, type: 'test_element', alias: 'range'); + + $filterSet = $this->createFilterSet(['k_single' => $singleFilter, 'k_compound' => $compoundFilter]); + + $this->assertSame(['k_single', 'k_compound'], \array_keys($filterSet->getMounts())); + + $singleMount = $filterSet->getFilterMount('k_single'); + $this->assertInstanceOf(FilterMount::class, $singleMount); + $this->assertSame($singleFilter, $singleMount->filter); + $this->assertSame('suche', $singleMount->alias); + $this->assertSame($singleFilter, $singleMount->context->filter); + $this->assertSame('k_single', $singleMount->context->key); + + $compoundMount = $filterSet->getFilterMount('k_compound'); + $this->assertInstanceOf(FilterMount::class, $compoundMount); + $this->assertSame('range', $compoundMount->alias); + $this->assertSame('k_compound', $compoundMount->context->key); + } + + public function testGetMountResolvesTheSameChildAsTheRootForm(): void + { + $element = $this->element(static function (FilterFormBuilderInterface $builder): void { + $builder->single(TextType::class); + }); + + $filterSet = $this->createFilterSet([ + 'k' => new Filter(element: $element, type: 'test_element', alias: 'suche'), + ]); + + $this->assertSame($filterSet->getForm()->get('suche'), $filterSet->getMount('k')); + } + + public function testGetMountToleratesALeadingDigitAlias(): void + { + // Str::isValidFormName() permits a leading digit, which is the one alias shape where PHP + // array-key coercion could make the root form and the mount map disagree. + $element = $this->element(static function (FilterFormBuilderInterface $builder): void { + $builder->single(TextType::class); + }); + + $filterSet = $this->createFilterSet([ + 'k' => new Filter(element: $element, type: 'test_element', alias: '0'), + ]); + + $this->assertSame('0', $filterSet->getFilterMount('k')?->alias); + $this->assertSame($filterSet->getForm()->get('0'), $filterSet->getMount('k')); + } + + /** + * @dataProvider provideUnmountedFilters + */ + public function testUnmountedFiltersAreAbsentFromTheMountMap(string $alias, bool $cancel, bool $addField): void + { + if ($cancel) { + $this->eventDispatcher->addListener( + FilterFormBuiltEvent::class, + static fn (FilterFormBuiltEvent $event) => $event->cancel(), + ); + } + + $element = $this->element(static function (FilterFormBuilderInterface $builder) use ($addField): void { + if ($addField) { + $builder->single(TextType::class); + } + }); + + $filterSet = $this->createFilterSet([ + 'k' => new Filter(element: $element, type: 'test_element', alias: $alias), + ]); + + $this->assertSame([], $filterSet->getMounts()); + $this->assertNull($filterSet->getFilterMount('k')); + $this->assertNull($filterSet->getMount('k')); + } + + /** + * @return iterable + */ + public static function provideUnmountedFilters(): iterable + { + yield 'invalid alias' => ['_.tl_flare_filter.1', false, true]; + yield 'no declared fields' => ['suche', false, false]; + yield 'cancelled build' => ['suche', true, true]; + } + + public function testGetMountIsNullWhenAListenerRemovedTheMountedChild(): void + { + // A FilterSetBuildEvent listener may drop children. The map still lists the filter — the + // mount is resolved against the root form on every call, so it simply reports null. + $this->eventDispatcher->addListener( + FilterSetBuildEvent::class, + static function (FilterSetBuildEvent $event): void { + $event->formBuilder->remove('suche'); + }, + ); + + $element = $this->element(static function (FilterFormBuilderInterface $builder): void { + $builder->single(TextType::class); + }); + + $filterSet = $this->createFilterSet([ + 'k' => new Filter(element: $element, type: 'test_element', alias: 'suche'), + ]); + + $this->assertFalse($filterSet->getForm()->has('suche')); + $this->assertSame('suche', $filterSet->getFilterMount('k')?->alias); + $this->assertNull($filterSet->getMount('k')); + } + + public function testGetMountIsNullForAnUnknownKey(): void + { + $this->assertNull($this->createFilterSet([])->getMount('nope')); + $this->assertNull($this->createFilterSet([])->getFilterMount('nope')); + } } From 06fc013ccee501233c13a5285c0515672af97f13 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 8 Sep 2026 10:29:03 +0200 Subject: [PATCH 85/96] test: probe serialize() stability of readonly value objects (throwaway) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers SPEC_FILTER_FORMS.md §14.1, which asked for §9's "value objects need no hashing interface" claim to be measured against ListSpec::hash() before step 1 commits to it. Reaches the real hashing path with no new production code, via Filter::$data. §9's core claim holds: equal-but-distinct readonly value objects of scalars, arrays, enums and nested value objects hash identically and survive a serialize() round trip. Every hazard §9 names is confirmed (DateTimeImmutable timezone representation, array order, model-like mutation state, closures throwing). One finding §9 does not anticipate: serialize() is not a pure value function over an object graph. A repeated object is emitted as a back-reference (r:N;), so the hash differs depending on whether two filters share one value instance or hold two equal ones. Today's code is immune only because Filter::fingerprint() flattens through FilterData::toArray(); §8's plan to move the hashing role onto the value object removes that flattening. Recorded in §14.1. Committed separately so deleting the probe after step 1 is a clean revert. The finding in §14.1 is meant to outlive it. --- .../Filter/ValueObjectSerializeProbeTest.php | 245 ++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 tests/Filter/ValueObjectSerializeProbeTest.php diff --git a/tests/Filter/ValueObjectSerializeProbeTest.php b/tests/Filter/ValueObjectSerializeProbeTest.php new file mode 100644 index 00000000..3019ebab --- /dev/null +++ b/tests/Filter/ValueObjectSerializeProbeTest.php @@ -0,0 +1,245 @@ +hashOf(new ProbeScalarValue('news', [1, 2, 3], true)); + $b = $this->hashOf(new ProbeScalarValue('news', [1, 2, 3], true)); + + $this->assertSame($a, $b); + $this->assertNotSame($a, $this->hashOf(new ProbeScalarValue('news', [1, 2, 4], true))); + } + + public function testHashSurvivesASerializeRoundTrip(): void + { + $value = new ProbeScalarValue('news', [1, 2, 3], true); + $restored = \unserialize(\serialize($value)); + + $this->assertSame($this->hashOf($value), $this->hashOf($restored)); + } + + /** + * The finding §9 does not anticipate: `serialize()` is not a pure value function over an + * object *graph*. A repeated object is emitted as a back-reference (`r:N;`), so a hash over + * two filters differs depending on whether they share one value instance or hold two equal + * ones. Today's code is immune because `Filter::fingerprint()` flattens through + * `FilterData::toArray()`; §8's plan to move the hashing role onto the value object removes + * that flattening. Conclusion for step 1: either keep a flattening step (§9's opt-in + * `fingerprint(): array`) or accept the cache miss. + */ + public function testHashDependsOnValueObjectInstanceSharing(): void + { + $shared = new ProbeScalarValue('news', [1], false); + + $sharedHash = $this->hashOfMany(['a' => $shared, 'b' => $shared]); + $distinctHash = $this->hashOfMany([ + 'a' => new ProbeScalarValue('news', [1], false), + 'b' => new ProbeScalarValue('news', [1], false), + ]); + + $this->assertNotSame($sharedHash, $distinctHash); + + // The mechanism, not just the symptom. + $this->assertStringContainsString('r:', \serialize([$shared, $shared])); + $this->assertStringNotContainsString( + 'r:', + \serialize([new ProbeScalarValue('news', [1], false), new ProbeScalarValue('news', [1], false)]), + ); + } + + /** + * Why §9 bans `\DateTimeInterface`: the same instant hashes differently depending on how its + * timezone is expressed, because `DateTime*` serializes `date`, `timezone_type` (1 vs. 3) and + * `timezone`. `DateRangeFilterElement`'s from/to is the live site. + */ + public function testDateTimeTimezoneRepresentationChangesTheHash(): void + { + $offset = new \DateTimeImmutable('2026-01-01 12:00:00', new \DateTimeZone('+01:00')); + $named = new \DateTimeImmutable('2026-01-01 12:00:00', new \DateTimeZone('Europe/Berlin')); + + $this->assertSame( + $offset->getTimestamp(), + $named->getTimestamp(), + 'Precondition: the two instances describe the same instant', + ); + $this->assertNotSame($this->hashOf($offset), $this->hashOf($named)); + + // Control: the same timezone identifier is stable. + $this->assertSame( + $this->hashOf(new \DateTimeImmutable('2026-01-01 12:00:00', new \DateTimeZone('Europe/Berlin'))), + $this->hashOf($named), + ); + } + + /** + * Why §9 requires normalising in the constructor: arrays serialize in insertion order, for + * both list and string keys. Note this hazard is already live for `Filter::$config` and + * `ListSpec::$config`, which `hash()` serializes directly. + */ + public function testArrayOrderChangesTheHash(): void + { + $this->assertNotSame($this->hashOf(['a', 'b']), $this->hashOf(['b', 'a'])); + $this->assertNotSame($this->hashOf(['a' => 1, 'b' => 2]), $this->hashOf(['b' => 2, 'a' => 1])); + } + + /** + * Why §9 requires storing ids rather than Contao models: a model carries mutation state + * alongside its row, so an unrelated change to that state moves the hash. `ProbeModelLike` + * stands in for `Model::$arrData` / `Model::$arrModified` without needing a database. + */ + public function testModelLikeValueDragsMutationStateIntoTheHash(): void + { + $pristine = new ProbeModelLike(['id' => 7, 'title' => 'News'], []); + $touched = new ProbeModelLike(['id' => 7, 'title' => 'News'], ['title' => true]); + + $this->assertSame($pristine->row, $touched->row, 'Precondition: the logical row is identical'); + $this->assertNotSame($this->hashOf($pristine), $this->hashOf($touched)); + + // Storing the id instead, as §9 requires, is stable. + $this->assertSame($this->hashOf(['id' => 7]), $this->hashOf(['id' => 7])); + } + + /** + * Why §9 bans closures. This also surfaces a pre-existing hazard: `Filter::$data` already + * accepts a closure through `mixed`, and any consumer calling `ListSpec::hash()` would throw. + */ + public function testClosureInAValueMakesHashingThrow(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage("Serialization of 'Closure' is not allowed"); + + $this->hashOf(static fn (): null => null); + } + + /** + * The positive control for the containment rule: enums and nested value objects are value + * stable. Enum cases are singletons, so the instance-sharing caveat above does not apply. + */ + public function testEnumsAndNestedValueObjectsAreValueStable(): void + { + $make = static fn (): ProbeNestedValue => new ProbeNestedValue( + SqlEquationOperator::EQUALS, + new ProbeScalarValue('news', [1], true), + ); + + $this->assertSame($this->hashOf($make()), $this->hashOf($make())); + $this->assertNotSame( + $this->hashOf($make()), + $this->hashOf(new ProbeNestedValue( + SqlEquationOperator::NOT_EQUALS, + new ProbeScalarValue('news', [1], true), + )), + ); + } + + private function hashOf(mixed $value): string + { + return $this->hashOfMany(['probe' => $value]); + } + + /** + * @param array $values One filter per entry, each carrying the value as its + * programmatic runtime data. + */ + private function hashOfMany(array $values): string + { + $driver = new class implements ListDriverInterface { + public function resolveDcTable(string $type, array $config, array $attributes): string + { + return 'tl_test'; + } + }; + + $list = new ListSpec(driver: $driver, type: 'test_list', dc: 'tl_test'); + + foreach ($values as $alias => $value) + { + $list = $list->withFilter(new Filter( + element: new StubFilterElement(), + type: 'test_element', + data: FilterData::single($value), + alias: $alias, + )); + } + + return $list->hash(); + } +} + +/** + * Named, not anonymous: an anonymous class name embeds a null byte plus the defining file and + * line, which `serialize()` would include and which would make every assertion here + * file-position-dependent. + */ +final readonly class ProbeScalarValue +{ + /** + * @param list $ids + */ + public function __construct( + public string $table, + public array $ids, + public bool $inverted, + ) {} +} + +final readonly class ProbeNestedValue +{ + public function __construct( + public SqlEquationOperator $operator, + public ProbeScalarValue $value, + ) {} +} + +/** + * Stands in for a Contao model: a row plus mutation state, as `Model::$arrData` / `$arrModified`. + */ +final readonly class ProbeModelLike +{ + /** + * @param array $row + * @param array $modified + */ + public function __construct( + public array $row, + public array $modified, + ) {} +} From cbbad85b4dc762acaaedf6c804457dce6e9f0fd1 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 8 Sep 2026 10:30:17 +0200 Subject: [PATCH 86/96] fix: correct template formatting and clarify doc comment in FilterSet --- src/Event/ListViewRenderEvent.php | 2 +- src/Filter/FilterSet.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Event/ListViewRenderEvent.php b/src/Event/ListViewRenderEvent.php index 03f65346..94f89c94 100644 --- a/src/Event/ListViewRenderEvent.php +++ b/src/Event/ListViewRenderEvent.php @@ -18,7 +18,7 @@ public function __construct( public readonly ContentModel $contentModel, public readonly Engine $engine, public readonly ListModel $listModel, - private Template $template, + private Template $template, ) {} public function getTemplate(): Template diff --git a/src/Filter/FilterSet.php b/src/Filter/FilterSet.php index 927eab1e..30904ea6 100644 --- a/src/Filter/FilterSet.php +++ b/src/Filter/FilterSet.php @@ -7,7 +7,7 @@ use Symfony\Component\Form\FormInterface; /** - * The filters of one list within one form context: their root form and the mount↔filter map. + * The filters of one list within one form context: their root form and the mount <-> filter map. * * Created by {@see Factory\FilterSetFactory}. Callers that only need the form go through * {@see getForm()}; callers that need to relate a mounted node back to its filter go through From 0babe62daa9f5a43f1fbcfcf980a99d9494ee984 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 8 Sep 2026 12:22:40 +0200 Subject: [PATCH 87/96] feat: add type-safe, immutable filter value objects and enhance form handling Introduced `FilterFormInterface` for registry-based form management, decoupling filter elements from their forms. Added immutable filter value objects (`BoolValue`, `ChoiceValue`, `DateRangeValue`, etc.) to ensure consistency and enforce type safety. Enhanced palette configuration with the new `formVariant` model for better backend clarity. Implemented comprehensive tests to validate flattening, normalization, and runtime compliance of value objects. --- .gitignore | 1 + AGENTS.md | 18 +- config/services.yaml | 7 + contao/dca/tl_flare_filter.php | 13 + contao/languages/de/tl_flare_filter.php | 1 + contao/languages/en/tl_flare_filter.php | 1 + .../FilterElement/ChoiceSourceContract.php | 58 +++ .../Attribute/AsFilterElement.php | 12 +- .../Attribute/AsFilterForm.php | 53 +++ .../Compiler/RegisterFilterElementsPass.php | 6 +- .../Compiler/RegisterFilterFormsPass.php | 298 ++++++++++++++ .../Factory/TypeNameFactory.php | 5 + .../HeimrichHannotFlareExtension.php | 2 + src/Filter/Form/FilterFormInterface.php | 53 +++ src/Filter/Value/BoolValue.php | 50 +++ src/Filter/Value/ChoiceValue.php | 69 ++++ src/Filter/Value/DateRangeValue.php | 77 ++++ src/Filter/Value/KeywordsValue.php | 46 +++ src/Filter/Value/ParentRefValue.php | 96 +++++ src/HeimrichHannotFlareBundle.php | 3 + src/Registry/FilterFormRegistry.php | 199 ++++++++++ src/Util/Fingerprint.php | 110 ++++++ .../Compiler/RegisterFilterFormsPassTest.php | 327 ++++++++++++++++ .../Factory/TypeNameFactoryTest.php | 77 ++++ tests/Filter/Value/BoolValueTest.php | 92 +++++ tests/Filter/Value/ChoiceValueTest.php | 61 +++ tests/Filter/Value/DateRangeValueTest.php | 94 +++++ tests/Filter/Value/KeywordsValueTest.php | 48 +++ tests/Filter/Value/ParentRefValueTest.php | 78 ++++ .../Value/ValueObjectContainmentTest.php | 370 ++++++++++++++++++ tests/Form/ChoicesBuilderTest.php | 284 ++++++++++++++ tests/Registry/FilterFormRegistryTest.php | 251 ++++++++++++ tests/Util/FingerprintTest.php | 146 +++++++ 33 files changed, 2999 insertions(+), 7 deletions(-) create mode 100644 src/Contract/FilterElement/ChoiceSourceContract.php create mode 100644 src/DependencyInjection/Attribute/AsFilterForm.php create mode 100644 src/DependencyInjection/Compiler/RegisterFilterFormsPass.php create mode 100644 src/Filter/Form/FilterFormInterface.php create mode 100644 src/Filter/Value/BoolValue.php create mode 100644 src/Filter/Value/ChoiceValue.php create mode 100644 src/Filter/Value/DateRangeValue.php create mode 100644 src/Filter/Value/KeywordsValue.php create mode 100644 src/Filter/Value/ParentRefValue.php create mode 100644 src/Registry/FilterFormRegistry.php create mode 100644 src/Util/Fingerprint.php create mode 100644 tests/DependencyInjection/Compiler/RegisterFilterFormsPassTest.php create mode 100644 tests/DependencyInjection/Factory/TypeNameFactoryTest.php create mode 100644 tests/Filter/Value/BoolValueTest.php create mode 100644 tests/Filter/Value/ChoiceValueTest.php create mode 100644 tests/Filter/Value/DateRangeValueTest.php create mode 100644 tests/Filter/Value/KeywordsValueTest.php create mode 100644 tests/Filter/Value/ParentRefValueTest.php create mode 100644 tests/Filter/Value/ValueObjectContainmentTest.php create mode 100644 tests/Form/ChoicesBuilderTest.php create mode 100644 tests/Registry/FilterFormRegistryTest.php create mode 100644 tests/Util/FingerprintTest.php diff --git a/.gitignore b/.gitignore index 31ac8837..9762c589 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,5 @@ docs/build ###> Docs ### docs/ +todo.* ###< Docs ### diff --git a/AGENTS.md b/AGENTS.md index 729d3fff..7683e9fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,8 +45,15 @@ The bundle follows standard Symfony Bundle architecture with deep Contao integra `FilterMount`s) per list × form context - `src/Config/` — `ConfigBuilder` (fluent canonical-config accumulator; no cast helpers — transformers cast declaratively off the typed model) and `TransformerResolver` (source class → transformer map) -- `src/Filter/Form/` — FLARE filter form implementations (peers of `src/Filter/Element/`); - `src/Form/` — Symfony-level building blocks only (`ChoicesBuilder`, `Form/Type/DateRangeFormType`) +- `src/Filter/Form/` — FLARE filter form implementations (peers of `src/Filter/Element/`) behind + `FilterFormInterface` (`buildForm()` + `decode()`); `src/Form/` — Symfony-level building blocks only + (`ChoicesBuilder`, `Form/Type/DateRangeFormType`) +- `src/Filter/Value/` — immutable filter value objects (`BoolValue`, `ChoiceValue`, `KeywordsValue`, + `DateRangeValue`, `ParentRefValue`), the typed channel between a form's `decode()` and an element's + `buildFilter()`. Each is `final readonly` with only public scalar/enum/nested-VO/array properties — + the containment rule `tests/Filter/Value/ValueObjectContainmentTest.php` enforces by reflection, so + `Util\Fingerprint::flatten()` can hash them without object identity leaking in. Deliberately not + services (excluded in `config/services.yaml`) - `src/Reader/` — reader/detail-page URL generation (`ReaderUrlGenerator`) - `src/InferPtable/` — parent-table inference for DCAs - `src/Integration/` — optional integrations (Codefog Tags, Terminal42 ChangeLanguage), wired via `config/integrations/*.yaml` @@ -60,6 +67,8 @@ The bundle follows standard Symfony Bundle architecture with deep Contao integra **Extensibility via PHP 8 attributes** (compiler passes auto-register tagged services): - `#[AsFilterElement(type: '...', isTargeted: ...)]` — register a filter element +- `#[AsFilterForm(name: '...', value: '...', requires: [...], default: ...)]` — register a filter form, + bound to a value class rather than to an element type (repeatable) - `#[AsListDriver(type: '...', dataContainer: '...')]` — register a list driver Attributes are in `src/DependencyInjection/Attribute/`, compiler passes in `src/DependencyInjection/Compiler/`. @@ -72,7 +81,10 @@ tl_flare_filter and tl_flare_list). 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`, `FilterTypeRegistry`, `ProjectorRegistry`, `EngineModRegistry`. +**Registry pattern** — Registries in `src/Registry/` map type names to implementations: `FilterElementRegistry`, `FilterFormRegistry`, `ListDriverRegistry`, `FilterTypeRegistry`, `ProjectorRegistry`, `EngineModRegistry`. `FilterFormRegistry` differs from the others: it holds +compile-time metadata as plain arrays and resolves the form services through a lazy +`container.service_locator`, so reading metadata (the `formVariant` options, the form election) +instantiates nothing. **Query safety** — `FilterQueryBuilder` (`src/Query/FilterQueryBuilder.php`) enforces parameterized queries. `TableAliasRegistry` (`src/Query/TableAliasRegistry.php`) manages table aliases and JOINs safely. diff --git a/config/services.yaml b/config/services.yaml index 660150a1..9353485a 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -13,6 +13,7 @@ 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/Filter/Value HeimrichHannot\FlareBundle\Engine\: resource: ../src/Engine @@ -26,6 +27,12 @@ services: HeimrichHannot\FlareBundle\Util\Env: ~ HeimrichHannot\FlareBundle\Util\Str: ~ + # Arguments pinned so autowiring can never substitute the full container for the locator + HeimrichHannot\FlareBundle\Registry\FilterFormRegistry: + arguments: + $formLocator: null # populated by RegisterFilterFormsPass + $forms: [] # populated by RegisterFilterFormsPass + # Not shared: each consumer keeps its own per-key schema memoization space HeimrichHannot\FlareBundle\Config\SchemaResolver: shared: false diff --git a/contao/dca/tl_flare_filter.php b/contao/dca/tl_flare_filter.php index dd61f211..c726f588 100644 --- a/contao/dca/tl_flare_filter.php +++ b/contao/dca/tl_flare_filter.php @@ -148,6 +148,19 @@ ], 'sql' => ['type' => 'boolean', 'default' => false], ], + 'formVariant' => [ + 'exclude' => true, + 'inputType' => 'select', + 'eval' => [ + 'mandatory' => false, + 'includeBlankOption' => true, + 'submitOnChange' => true, + 'alwaysSave' => true, + 'chosen' => true, + 'tl_class' => 'w50', + ], + 'sql' => ['type' => 'string', 'length' => 128, 'default' => '', 'notnull' => true], + ], 'targetAlias' => [ 'inputType' => 'select', 'exclude' => true, diff --git a/contao/languages/de/tl_flare_filter.php b/contao/languages/de/tl_flare_filter.php index 4b50a35f..09577c2d 100644 --- a/contao/languages/de/tl_flare_filter.php +++ b/contao/languages/de/tl_flare_filter.php @@ -9,6 +9,7 @@ $lang['title'] = ['Titel', 'Bitte geben Sie einen Titel für diesen Listenfilter ein.']; $lang['type'] = ['Typ', 'Bitte wählen Sie den Typ der Liste aus.']; $lang['intrinsic'] = ['Intrinsisch', 'Diesen Filter immer anwenden und vor dem Benutzer verstecken.']; +$lang['formVariant'] = ['Formular', 'Legt fest, wie dieser Filter dargestellt wird. Leer lassen, um ihn unsichtbar anzuwenden (intrinsisch).']; $lang['targetAlias'] = ['Anwenden auf', 'Wählen Sie, auf welche Relation dieser Filter angewendet werden soll.']; ###< title_legend ### diff --git a/contao/languages/en/tl_flare_filter.php b/contao/languages/en/tl_flare_filter.php index 58c52751..9fc0991c 100644 --- a/contao/languages/en/tl_flare_filter.php +++ b/contao/languages/en/tl_flare_filter.php @@ -9,6 +9,7 @@ $lang['title'] = ['Title', 'Please enter a title for this list filter.']; $lang['type'] = ['Type', 'Please select the type of the filter.']; $lang['intrinsic'] = ['Intrinsic', 'Always apply this filter and hide it from the user.']; +$lang['formVariant'] = ['Form', 'Choose how this filter is presented. Leave empty to apply it silently (intrinsic).']; $lang['targetAlias'] = ['Apply to', 'Select the relation this filter should be applied to.']; ###< title_legend ### diff --git a/src/Contract/FilterElement/ChoiceSourceContract.php b/src/Contract/FilterElement/ChoiceSourceContract.php new file mode 100644 index 00000000..0757c06b --- /dev/null +++ b/src/Contract/FilterElement/ChoiceSourceContract.php @@ -0,0 +1,58 @@ + The **form** decodes the *widget*: which choice keys did the user pick. + * > The **element** decodes the *domain*: what do those keys mean. + * + * Two methods, not one, because choice keys are element-defined: `FieldValueChoice` uses bare field + * values, `CodefogTagsChoice` tag ids, `ArchiveFilterElement` `"
."` in dynamic-ptable + * mode (§7.4). A form that parsed them would be reading element knowledge through the back door — + * the very thing `requires` exists to prevent. + * + * Labelling is split: buildChoices() MAY set labels the element owns (setLabelForTable() fed from + * element-owned whitelist rows is the real case); the form owns only global overrides (setLabel(), + * setModelSuffix(), setEmptyOption()) and applyFormOptions(). + * + * @api + */ +interface ChoiceSourceContract +{ + /** + * Builds the element's choices for the current filter invocation. + * + * Doubles as the single source of truth for the choice set: the same builder serves the form's + * field, the choice value callback and — via §7.2's view-data decode — the reverse mapping, + * which therefore no longer needs a second, rebuilt ChoicesBuilder. + * + * @throws FilterException On invalid filter configuration (no whitelist, no inferrable ptable, + * no valid target field, …). + */ + public function buildChoices(FilterContext $context): ChoicesBuilder; + + /** + * Interprets submitted choice keys as this element's domain value. + * + * @param list $keys Selected choice keys, verbatim — MAY include + * {@see ChoicesBuilder::EMPTY_CHOICE}, whose meaning is element-defined. The sentinel is + * passed through rather than swallowed because it can carry domain meaning: + * ArchiveFilterElement treats a selected empty option as "use the full whitelist" unless + * `use_whitelist_for_options_only`. + * + * @return object|null A value object of the class this element declares in + * `AsFilterElement::$value`; null when the keys carry no query information. + */ + public function valueFromChoiceKeys(array $keys, FilterContext $context): ?object; +} diff --git a/src/DependencyInjection/Attribute/AsFilterElement.php b/src/DependencyInjection/Attribute/AsFilterElement.php index 7e85a561..1bef0752 100644 --- a/src/DependencyInjection/Attribute/AsFilterElement.php +++ b/src/DependencyInjection/Attribute/AsFilterElement.php @@ -12,15 +12,21 @@ class AsFilterElement public ?string $type; public array $attributes; + /** + * @param class-string|null $value The value class this element consumes in buildFilter(). + * Null means the element has no runtime value at all, i.e. it is intrinsic-only. + */ public function __construct( - ?string $type = null, - public ?bool $isTargeted = null, - mixed ...$attributes + ?string $type = null, + public ?bool $isTargeted = null, + public ?string $value = null, + mixed ...$attributes ) { $this->type = $type ?? $attributes['alias'] ?? null; $attributes['type'] = $this->type; $attributes['isTargeted'] = $isTargeted; + $attributes['value'] = $value; $this->attributes = $attributes; } diff --git a/src/DependencyInjection/Attribute/AsFilterForm.php b/src/DependencyInjection/Attribute/AsFilterForm.php new file mode 100644 index 00000000..c72799c9 --- /dev/null +++ b/src/DependencyInjection/Attribute/AsFilterForm.php @@ -0,0 +1,53 @@ + */ + public array $attributes; + + /** + * @param string|null $name Stable identifier persisted in `tl_flare_filter.formVariant`. + * Defaults to {@see TypeNameFactory::createFilterFormType()} over the service class. Prefix + * third-party names; the empty string is reserved for "no form" (intrinsic). + * @param class-string|null $value The value class this form produces; the registry key. + * @param list $requires Capability interfaces the element must implement for this + * form to be offered for it (plain `instanceof`). + * @param bool $default Whether this is the fallback form for $value. At most one default per + * value class. + */ + public function __construct( + ?string $name = null, + public ?string $value = null, + public array $requires = [], + public bool $default = false, + mixed ...$attributes + ) { + $this->name = $name; + + $attributes['name'] = $this->name; + $attributes['value'] = $this->value; + $attributes['requires'] = $this->requires; + $attributes['default'] = $this->default; + + $this->attributes = $attributes; + } +} diff --git a/src/DependencyInjection/Compiler/RegisterFilterElementsPass.php b/src/DependencyInjection/Compiler/RegisterFilterElementsPass.php index 9b3644f0..43abca11 100644 --- a/src/DependencyInjection/Compiler/RegisterFilterElementsPass.php +++ b/src/DependencyInjection/Compiler/RegisterFilterElementsPass.php @@ -36,7 +36,11 @@ public function process(ContainerBuilder $container): void $type = $this->getFilterElementType($definition, $attributes); /** @see AsFilterElement::__construct */ - $attribute = new Definition(AsFilterElement::class, [$type, $attributes['isTargeted'] ?? null]); + $attribute = new Definition(AsFilterElement::class, [ + $type, + $attributes['isTargeted'] ?? null, + $attributes['value'] ?? null, + ]); /** @see FilterElementRegistry::add() */ $registry->addMethodCall('add', [$reference, $attribute, $type]); diff --git a/src/DependencyInjection/Compiler/RegisterFilterFormsPass.php b/src/DependencyInjection/Compiler/RegisterFilterFormsPass.php new file mode 100644 index 00000000..3e389212 --- /dev/null +++ b/src/DependencyInjection/Compiler/RegisterFilterFormsPass.php @@ -0,0 +1,298 @@ +, + * default: bool, + * service: string + * } + */ +final class RegisterFilterFormsPass implements CompilerPassInterface +{ + use PriorityTaggedServiceTrait; + + public function process(ContainerBuilder $container): void + { + if (!$container->hasDefinition(FilterFormRegistry::class)) { + return; + } + + $tag = AsFilterForm::TAG; + + /** @var array $forms */ + $forms = []; + /** @var array $locations */ + $locations = []; + + foreach ($this->findAndSortTaggedServices($tag, $container) as $reference) + { + $serviceId = (string) $reference; + $definition = $container->findDefinition($serviceId); + $tags = $definition->getTag($tag); + $definition->clearTag($tag); + + $this->assertIsFilterForm($definition, $serviceId); + + foreach ($tags as $attributes) + { + $name = $this->getFilterFormName($definition, $attributes); + + if (isset($forms[$name])) + { + throw new \InvalidArgumentException(\sprintf( + 'The filter form name "%s" is already registered by service "%s"; service "%s" must' + . ' declare a different name. Form names are persisted in "%s.formVariant" and must be unique.', + $name, + $forms[$name]['service'], + $serviceId, + FilterContainer::TABLE_NAME, + )); + } + + $forms[$name] = [ + 'value' => ((string) ($attributes['value'] ?? '')) ?: null, + 'requires' => $this->getRequires($serviceId, $name, $attributes), + 'default' => (bool) ($attributes['default'] ?? false), + 'service' => $serviceId, + ]; + + $locations[$name] = new Reference($serviceId); + + $container + ->setAlias('flare.filter_form.' . $name, $serviceId) + ->setPublic(true); + } + } + + $this->assertOneDefaultPerValueClass($forms); + $this->assertEveryElementValueIsServed($container, $forms); + + $registry = $container->findDefinition(FilterFormRegistry::class); + $registry->setArgument('$forms', $forms); + $registry->setArgument( + '$formLocator', + (new Definition(ServiceLocator::class, [$locations]))->addTag('container.service_locator'), + ); + } + + /** + * @param array $attributes + */ + protected function getFilterFormName(Definition $definition, array $attributes): string + { + if ($name = (string) ($attributes['name'] ?? '')) + { + if ($name === 'default') { + throw new \InvalidArgumentException( + 'The filter form name "default" is reserved and cannot be used. Choose a different name.', + ); + } + + return $name; + } + + if (!$class = $definition->getClass()) { + throw new \InvalidArgumentException( + 'A filter form service without a class must declare an explicit name.', + ); + } + + $name = TypeNameFactory::createFilterFormType($class); + + // "FooFilterForm" reduces to "foo", but a class named exactly "FilterForm" reduces to "", + // and the empty string is the intrinsic sentinel in tl_flare_filter.formVariant. + if ($name === '' || $name === 'default') + { + throw new \InvalidArgumentException(\sprintf( + 'Cannot derive a filter form name from class "%s" (derived "%s"). Declare an explicit name.', + $class, + $name, + )); + } + + return $name; + } + + private function assertIsFilterForm(Definition $definition, string $serviceId): void + { + $class = $definition->getClass(); + + if ($class === null || !\class_exists($class)) { + return; + } + + if (!\is_a($class, FilterFormInterface::class, true)) + { + throw new \InvalidArgumentException(\sprintf( + 'Service "%s" is tagged "%s" but "%s" does not implement %s.', + $serviceId, + AsFilterForm::TAG, + $class, + FilterFormInterface::class, + )); + } + } + + /** + * §10, row 3 (first half): every `requires` entry must be an existing interface. + * + * @param array $attributes + * @return list + */ + private function getRequires(string $serviceId, string $name, array $attributes): array + { + $requires = $attributes['requires'] ?? []; + + if (!\is_array($requires)) + { + throw new \InvalidArgumentException(\sprintf( + 'The "requires" attribute of filter form "%s" (service "%s") must be a list of interface names.', + $name, + $serviceId, + )); + } + + $resolved = []; + + foreach ($requires as $interface) + { + if (!\is_string($interface) || !\interface_exists($interface)) + { + throw new \InvalidArgumentException(\sprintf( + 'Filter form "%s" (service "%s") requires "%s", which is not an existing interface.', + $name, + $serviceId, + \is_string($interface) ? $interface : \get_debug_type($interface), + )); + } + + $resolved[] = $interface; + } + + return $resolved; + } + + /** + * §3.3: `default` names *the* fallback form for a value class. + * + * @param array $forms + */ + private function assertOneDefaultPerValueClass(array $forms): void + { + $defaults = []; + + foreach ($forms as $name => $meta) + { + if (!$meta['default'] || $meta['value'] === null) { + continue; + } + + if (isset($defaults[$meta['value']])) + { + throw new \InvalidArgumentException(\sprintf( + 'Filter forms "%s" and "%s" are both declared as the default for value class "%s".' + . ' Exactly one default per value class is allowed.', + $defaults[$meta['value']], + $name, + $meta['value'], + )); + } + + $defaults[$meta['value']] = $name; + } + } + + /** + * §10, row 3 (second half): every element value class must be served by at least one form whose + * `requires` that element satisfies. Reads `flare.filter_element` tags, hence the pass ordering. + * + * @param array $forms + */ + private function assertEveryElementValueIsServed(ContainerBuilder $container, array $forms): void + { + foreach ($container->findTaggedServiceIds(AsFilterElement::TAG) as $serviceId => $tags) + { + $elementClass = $container->findDefinition($serviceId)->getClass(); + + if ($elementClass === null || !\class_exists($elementClass)) { + continue; + } + + foreach ($tags as $attributes) + { + $value = ((string) ($attributes['value'] ?? '')) ?: null; + + if ($value === null || $this->hasEligibleForm($elementClass, $value, $forms)) { + continue; + } + + throw new \InvalidArgumentException(\sprintf( + 'Filter element "%s" declares value class "%s", but no filter form produces that value for an' + . ' element of type "%s". Register a form with #[AsFilterForm(value: %s::class)] whose' + . ' "requires" the element satisfies, or drop the "value" declaration to make the element' + . ' intrinsic-only.', + (string) ($attributes['type'] ?? $serviceId), + $value, + $elementClass, + $value, + )); + } + } + } + + /** + * @param class-string $elementClass + * @param array $forms + */ + private function hasEligibleForm(string $elementClass, string $valueClass, array $forms): bool + { + foreach ($forms as $meta) + { + if ($meta['value'] !== $valueClass) { + continue; + } + + $satisfied = true; + + foreach ($meta['requires'] as $interface) + { + if (!\is_a($elementClass, $interface, true)) + { + $satisfied = false; + + break; + } + } + + if ($satisfied) { + return true; + } + } + + return false; + } +} diff --git a/src/DependencyInjection/Factory/TypeNameFactory.php b/src/DependencyInjection/Factory/TypeNameFactory.php index 0dac04d2..dc6893d9 100644 --- a/src/DependencyInjection/Factory/TypeNameFactory.php +++ b/src/DependencyInjection/Factory/TypeNameFactory.php @@ -22,6 +22,11 @@ public static function createFilterElementType(string $className): string return self::createType($className, ['Controller', 'FilterElement', 'Element']); } + public static function createFilterFormType(string $className): string + { + return self::createType($className, ['Controller', 'FilterForm', 'Form']); + } + public static function createListDriverType(string $className): string { return self::createType($className, ['Controller', 'ListDriver', 'Driver']); diff --git a/src/DependencyInjection/HeimrichHannotFlareExtension.php b/src/DependencyInjection/HeimrichHannotFlareExtension.php index ba08ea9c..0852e9fa 100644 --- a/src/DependencyInjection/HeimrichHannotFlareExtension.php +++ b/src/DependencyInjection/HeimrichHannotFlareExtension.php @@ -5,6 +5,7 @@ namespace HeimrichHannot\FlareBundle\DependencyInjection; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; +use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterForm; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListDriver; use HeimrichHannot\FlareBundle\Util\Env; use Symfony\Component\Config\FileLocator; @@ -53,6 +54,7 @@ public function load(array $configs, ContainerBuilder $container): void $attributesForAutoconfiguration = [ AsListDriver::class => AsListDriver::TAG, AsFilterElement::class => AsFilterElement::TAG, + AsFilterForm::class => AsFilterForm::TAG, ]; foreach ($attributesForAutoconfiguration as $attributeClass => $tag) diff --git a/src/Filter/Form/FilterFormInterface.php b/src/Filter/Form/FilterFormInterface.php new file mode 100644 index 00000000..f36d9625 --- /dev/null +++ b/src/Filter/Form/FilterFormInterface.php @@ -0,0 +1,53 @@ +getData(). + * + * @return object|null A value object from `src/Filter/Value/`, of the class this form is + * registered for. Null contributes nothing, which lets the element fall back to its own + * config (§3.2, §4.2). + */ + public function decode(FormInterface $mount, FilterContext $context): ?object; +} diff --git a/src/Filter/Value/BoolValue.php b/src/Filter/Value/BoolValue.php new file mode 100644 index 00000000..1841782c --- /dev/null +++ b/src/Filter/Value/BoolValue.php @@ -0,0 +1,50 @@ + Non-empty, deduplicated, ascending by string comparison. */ + public array $keys; + + /** + * @param array $keys Submitted choice keys. Entries with no string + * representation, and empty strings, are dropped. + */ + public function __construct(array $keys) + { + $strings = []; + + foreach ($keys as $key) + { + if ($key === null || \is_bool($key) || \is_array($key)) { + continue; + } + + if (\is_object($key) && !$key instanceof \Stringable) { + continue; + } + + if (($key = (string) $key) !== '') { + $strings[] = $key; + } + } + + $strings = \array_values(\array_unique($strings, \SORT_STRING)); + + \sort($strings, \SORT_STRING); + + $this->keys = $strings; + } + + /** + * @param array $keys + */ + public static function tryFrom(array $keys): ?self + { + $value = new self($keys); + + return $value->keys === [] ? null : $value; + } +} diff --git a/src/Filter/Value/DateRangeValue.php b/src/Filter/Value/DateRangeValue.php new file mode 100644 index 00000000..f737cb4e --- /dev/null +++ b/src/Filter/Value/DateRangeValue.php @@ -0,0 +1,77 @@ + to` is *not* corrected here. It is a legal, empty-result range; the form validates it + * separately (a POST_SUBMIT FormError today), and silently swapping would change behaviour. + * + * `0` is a meaningful timestamp (the epoch), expressible because "absent" is `null` — a deliberate + * divergence from `CalendarCurrentFilterElement::mixedToDateTime()`, whose `if (!$input)` guard + * discards `0` and `'0'`. + */ +final readonly class DateRangeValue +{ + public function __construct( + public ?int $from = null, + public ?int $to = null, + ) {} + + /** + * @param mixed $from `\DateTimeInterface`, an int/float timestamp, a numeric string, or null. + * @param mixed $to Likewise. + * + * Free-form date strings are deliberately rejected: they throw on malformed input and are + * non-deterministic for relative expressions such as `'now'`. Programmatic callers pre-resolve + * them with {@see \HeimrichHannot\FlareBundle\Util\DateTimeHelper::toTimestamp()}, which also + * understands the span keywords and needs no framework boot. + */ + public static function tryFrom(mixed $from, mixed $to): ?self + { + $from = self::toTimestamp($from); + $to = self::toTimestamp($to); + + if ($from === null && $to === null) { + return null; + } + + return new self($from, $to); + } + + private static function toTimestamp(mixed $value): ?int + { + if ($value instanceof \DateTimeInterface) { + return $value->getTimestamp(); + } + + if (\is_int($value)) { + return $value; + } + + if (\is_float($value)) { + return \is_finite($value) ? (int) $value : null; + } + + if (\is_string($value) && \is_numeric($value = \trim($value))) { + return (int) $value; + } + + return null; + } +} diff --git a/src/Filter/Value/KeywordsValue.php b/src/Filter/Value/KeywordsValue.php new file mode 100644 index 00000000..de365450 --- /dev/null +++ b/src/Filter/Value/KeywordsValue.php @@ -0,0 +1,46 @@ +keywords = \trim((string) \preg_replace('/\s+/', ' ', $keywords)); + } + + public static function tryFrom(mixed $keywords): ?self + { + if ($keywords instanceof \Stringable) { + $keywords = (string) $keywords; + } + + if (!\is_string($keywords)) { + return null; + } + + $value = new self($keywords); + + return $value->keywords === '' ? null : $value; + } +} diff --git a/src/Filter/Value/ParentRefValue.php b/src/Filter/Value/ParentRefValue.php new file mode 100644 index 00000000..9be87ee2 --- /dev/null +++ b/src/Filter/Value/ParentRefValue.php @@ -0,0 +1,96 @@ +> + */ + public array $parents; + + /** + * @param array $parents Table name => iterable of ids. Non-string keys, + * non-iterable values, non-numeric ids and ids <= 0 are dropped. + */ + public function __construct(array $parents) + { + /** @var array> $seen */ + $seen = []; + + foreach ($parents as $table => $ids) + { + // A numeric-looking array key would have been cast to int by PHP, and no table name + // can look like that, so a non-string key is garbage. + if (!\is_string($table) || ($table = \trim($table)) === '') { + continue; + } + + if (!\is_iterable($ids)) { + continue; + } + + foreach ($ids as $id) + { + if (!\is_int($id) && !\is_float($id) && !(\is_string($id) && \is_numeric($id))) { + continue; + } + + if (($id = (int) $id) > 0) { + $seen[$table][$id] = true; + } + } + } + + $normalized = []; + + foreach ($seen as $table => $ids) + { + $ids = \array_keys($ids); + + \sort($ids, \SORT_NUMERIC); + + $normalized[$table] = $ids; + } + + \ksort($normalized, \SORT_STRING); + + $this->parents = $normalized; + } + + /** + * @param array $parents + */ + public static function tryFrom(array $parents): ?self + { + $value = new self($parents); + + return $value->parents === [] ? null : $value; + } +} diff --git a/src/HeimrichHannotFlareBundle.php b/src/HeimrichHannotFlareBundle.php index 21764e7a..efeacbde 100644 --- a/src/HeimrichHannotFlareBundle.php +++ b/src/HeimrichHannotFlareBundle.php @@ -46,6 +46,9 @@ public function build(ContainerBuilder $container): void ###< Integrations ### ###> Fill Registries ### + // Must precede RegisterFilterElementsPass: that pass clears the `flare.filter_element` + // tags this one reads to verify every element value class is served by a form. + $container->addCompilerPass(new DependencyInjection\Compiler\RegisterFilterFormsPass()); $container->addCompilerPass(new DependencyInjection\Compiler\RegisterFilterElementsPass()); $container->addCompilerPass(new DependencyInjection\Compiler\RegisterListDriversPass()); ###< Fill Registries ### diff --git a/src/Registry/FilterFormRegistry.php b/src/Registry/FilterFormRegistry.php new file mode 100644 index 00000000..9927fb1c --- /dev/null +++ b/src/Registry/FilterFormRegistry.php @@ -0,0 +1,199 @@ +, + * default: bool, + * service: string + * } + */ +final class FilterFormRegistry +{ + /** + * @param ContainerInterface|null $formLocator Locator keyed by form name. + * @param array $forms Keyed by form name, in registration order. + */ + public function __construct( + private readonly ?ContainerInterface $formLocator = null, + private readonly array $forms = [], + ) {} + + public function has(string $name): bool + { + return isset($this->forms[$name]); + } + + /** + * Registered form names, in compile-time registration order. + * + * @return list + * @api + */ + public function keys(): array + { + return \array_keys($this->forms); + } + + /** + * Resolves the form service. Returns null for an unknown name — a `formVariant` value whose + * form was renamed or uninstalled is a data condition, not a programming error, so the caller + * decides whether that degrades to intrinsic or aborts. + * + * @throws \LogicException If metadata was registered without a locator (mis-wired container). + * @api + */ + public function getService(?string $name): ?FilterFormInterface + { + if ($name === null || !isset($this->forms[$name])) { + return null; + } + + if ($this->formLocator === null) { + throw new \LogicException(\sprintf( + 'Filter form "%s" is registered but no form locator was injected into %s. Did %s run?', + $name, + self::class, + RegisterFilterFormsPass::class, + )); + } + + if (!$this->formLocator->has($name)) { + return null; + } + + $service = $this->formLocator->get($name); + + return $service instanceof FilterFormInterface ? $service : null; + } + + /** + * The value class the form produces, or null if the form declares none. + * + * @return class-string|null + * @api + */ + public function getValueClass(?string $name): ?string + { + return $name !== null ? ($this->forms[$name]['value'] ?? null) : null; + } + + /** + * @return list + * @api + */ + public function getRequires(?string $name): array + { + return $name !== null ? ($this->forms[$name]['requires'] ?? []) : []; + } + + /** @api */ + public function isDefault(?string $name): bool + { + return $name !== null && ($this->forms[$name]['default'] ?? false); + } + + /** + * The service id behind a form name. For diagnostics and error messages only. + * + * @api + */ + public function getServiceId(?string $name): ?string + { + return $name !== null ? ($this->forms[$name]['service'] ?? null) : null; + } + + /** + * Elects the forms usable for an element: the value class must match and every entry in the + * form's `requires` must be implemented by the element (SPEC_FILTER_FORMS.md §3.4). + * + * The element is required rather than optional: an election without one cannot honour + * `requires`, and returning every form for the value class would feed a backend select + * directly. + * + * @param class-string|null $valueClass The element's declared value class. + * @param object|class-string $element The element service or its class. + * @return list Form names, in registration order. + * @api + */ + public function findNames(?string $valueClass, object|string $element): array + { + if ($valueClass === null) { + return []; + } + + $names = []; + + foreach ($this->forms as $name => $meta) + { + if ($meta['value'] !== $valueClass) { + continue; + } + + if (!$this->satisfies($element, $meta['requires'])) { + continue; + } + + $names[] = $name; + } + + return $names; + } + + /** + * The fallback form for a value class: the eligible form flagged `default`, else the first + * eligible one, else null. A default form the element cannot satisfy is skipped. + * + * @param class-string|null $valueClass + * @param object|class-string $element + * @api + */ + public function findDefaultName(?string $valueClass, object|string $element): ?string + { + $names = $this->findNames($valueClass, $element); + + foreach ($names as $name) + { + if ($this->forms[$name]['default']) { + return $name; + } + } + + return $names[0] ?? null; + } + + /** + * @param object|class-string $element + * @param list $requires + */ + private function satisfies(object|string $element, array $requires): bool + { + foreach ($requires as $interface) + { + if (!\is_a($element, $interface, true)) { + return false; + } + } + + return true; + } +} diff --git a/src/Util/Fingerprint.php b/src/Util/Fingerprint.php new file mode 100644 index 00000000..d04d175d --- /dev/null +++ b/src/Util/Fingerprint.php @@ -0,0 +1,110 @@ +value : $value->name]; + } + + if ($depth <= 0) { + return [self::MARK_DEPTH, \get_debug_type($value)]; + } + + if (\is_array($value)) + { + $flattened = []; + + foreach ($value as $key => $item) { + $flattened[$key] = self::walk($item, $depth - 1); + } + + return $flattened; + } + + if ($value instanceof \Closure) + { + // A closure is not serializable at all. Its definition site is the closest thing to a + // pure value it has: deterministic, free of object identity, and it distinguishes + // closures declared at different code positions — the realistic case. Two closures + // from the same line with different bound state collide, which is a cache miss's worth + // of wrongness where the unflattened behaviour is an uncaught exception. + $reflection = new \ReflectionFunction($value); + + return [self::MARK_OPAQUE, \Closure::class, $reflection->getFileName(), $reflection->getStartLine()]; + } + + if (\is_object($value)) + { + // get_object_vars() from outside the class returns *public* properties only, in + // declaration order (stable per class). That is precisely the surface the §9 + // containment rule constrains — see ValueObjectContainmentTest, which asserts every + // value-object property is public so nothing can hide from this walk. + $flattened = [self::KEY_CLASS => $value::class]; + + foreach (\get_object_vars($value) as $name => $property) { + $flattened[$name] = self::walk($property, $depth - 1); + } + + return $flattened; + } + + // Resources and anything else with no value semantics. + return [self::MARK_OPAQUE, \get_debug_type($value)]; + } +} diff --git a/tests/DependencyInjection/Compiler/RegisterFilterFormsPassTest.php b/tests/DependencyInjection/Compiler/RegisterFilterFormsPassTest.php new file mode 100644 index 00000000..a828cf93 --- /dev/null +++ b/tests/DependencyInjection/Compiler/RegisterFilterFormsPassTest.php @@ -0,0 +1,327 @@ + 'flare_choice', + 'value' => PassStubValue::class, + 'requires' => [PassStubCapability::class], + 'default' => true, + ]); + + (new RegisterFilterFormsPass())->process($container); + + $registry = $container->getDefinition(FilterFormRegistry::class); + + self::assertSame([ + 'flare_choice' => [ + 'value' => PassStubValue::class, + 'requires' => [PassStubCapability::class], + 'default' => true, + 'service' => 'test.choice_form', + ], + ], $registry->getArgument('$forms')); + + $locator = $registry->getArgument('$formLocator'); + + self::assertInstanceOf(Definition::class, $locator); + self::assertSame(ServiceLocator::class, $locator->getClass()); + self::assertArrayHasKey('container.service_locator', $locator->getTags()); + self::assertSame(['flare_choice'], \array_keys($locator->getArgument(0))); + self::assertSame('test.choice_form', (string) $locator->getArgument(0)['flare_choice']); + + self::assertTrue($container->hasAlias('flare.filter_form.flare_choice')); + self::assertTrue($container->getAlias('flare.filter_form.flare_choice')->isPublic()); + self::assertSame([], $container->getDefinition('test.choice_form')->getTag(AsFilterForm::TAG)); + } + + public function testDerivesTheNameFromTheClassWhenNotDeclared(): void + { + $container = self::container(); + self::form($container, 'test.derived', PassDerivedFilterForm::class, ['value' => PassStubValue::class]); + + (new RegisterFilterFormsPass())->process($container); + + $forms = $container->getDefinition(FilterFormRegistry::class)->getArgument('$forms'); + + self::assertSame(['pass_derived'], \array_keys($forms)); + } + + /** The attribute is repeatable, so one class may serve several value classes. */ + public function testOneServiceMayRegisterSeveralForms(): void + { + $container = self::container(); + $container->setDefinition('test.multi', (new Definition(PassChoiceForm::class)) + ->addTag(AsFilterForm::TAG, ['name' => 'a', 'value' => PassStubValue::class]) + ->addTag(AsFilterForm::TAG, ['name' => 'b', 'value' => PassOtherValue::class])); + + (new RegisterFilterFormsPass())->process($container); + + $registry = $container->getDefinition(FilterFormRegistry::class); + + self::assertSame(['a', 'b'], \array_keys($registry->getArgument('$forms'))); + self::assertSame(['a', 'b'], \array_keys($registry->getArgument('$formLocator')->getArgument(0))); + self::assertTrue($container->hasAlias('flare.filter_form.a')); + self::assertTrue($container->hasAlias('flare.filter_form.b')); + } + + public function testReturnsEarlyWithoutTheRegistryDefinition(): void + { + $container = new ContainerBuilder(); + self::form($container, 'test.choice_form', PassChoiceForm::class, ['name' => 'flare_choice']); + + (new RegisterFilterFormsPass())->process($container); + + // Proof of the early return: the tag is untouched. + self::assertNotSame([], $container->getDefinition('test.choice_form')->getTag(AsFilterForm::TAG)); + } + + /** + * The state of the tree before any form exists: twelve elements tagged, none declaring a value + * class, no forms at all. Every §10 check must be a no-op rather than failing the build. + */ + public function testEmptyFormSetWithValuelessElementsIsANoOp(): void + { + $container = self::container(); + + for ($i = 0; $i < 12; ++$i) + { + $container->setDefinition('test.element.' . $i, (new Definition(PassCapableElement::class)) + ->addTag(AsFilterElement::TAG, ['type' => 'flare_element_' . $i, 'value' => null])); + } + + (new RegisterFilterFormsPass())->process($container); + + $registry = $container->getDefinition(FilterFormRegistry::class); + + self::assertSame([], $registry->getArgument('$forms')); + self::assertSame([], $registry->getArgument('$formLocator')->getArgument(0)); + } + + /** §10 row 3, element side: a declared value class with no form fails the container build. */ + public function testElementValueClassWithoutAnyFormFailsTheBuild(): void + { + $container = self::container(); + $container->setDefinition('test.element', (new Definition(PassCapableElement::class)) + ->addTag(AsFilterElement::TAG, ['type' => 'flare_thing', 'value' => PassStubValue::class])); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/no filter form produces that value/'); + + (new RegisterFilterFormsPass())->process($container); + } + + /** §10 row 3: a form exists for the value class, but this element fails its `requires`. */ + public function testElementFailingTheFormsRequiresFailsTheBuild(): void + { + $container = self::container(); + self::form($container, 'test.choice_form', PassChoiceForm::class, [ + 'name' => 'flare_choice', + 'value' => PassStubValue::class, + 'requires' => [PassStubCapability::class], + ]); + $container->setDefinition('test.element', (new Definition(PassPlainElement::class)) + ->addTag(AsFilterElement::TAG, ['type' => 'flare_thing', 'value' => PassStubValue::class])); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/no filter form produces that value/'); + + (new RegisterFilterFormsPass())->process($container); + } + + public function testElementSatisfyingTheRequiresPasses(): void + { + $container = self::container(); + self::form($container, 'test.choice_form', PassChoiceForm::class, [ + 'name' => 'flare_choice', + 'value' => PassStubValue::class, + 'requires' => [PassStubCapability::class], + ]); + $container->setDefinition('test.element', (new Definition(PassCapableElement::class)) + ->addTag(AsFilterElement::TAG, ['type' => 'flare_thing', 'value' => PassStubValue::class])); + + (new RegisterFilterFormsPass())->process($container); + + self::assertSame( + ['flare_choice'], + \array_keys($container->getDefinition(FilterFormRegistry::class)->getArgument('$forms')), + ); + } + + /** + * @dataProvider provideInvalidDeclarations + * + * @param array $tag + */ + public function testInvalidDeclarationsFailTheBuild(string $class, array $tag, string $messagePattern): void + { + $container = self::container(); + self::form($container, 'test.bad_form', $class, $tag); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches($messagePattern); + + (new RegisterFilterFormsPass())->process($container); + } + + /** + * @return iterable, string}> + */ + public static function provideInvalidDeclarations(): iterable + { + // §10 row 3, form side. The FQCN must genuinely not exist — interface_exists() autoloads. + yield 'requires a non-existent interface' => [ + PassChoiceForm::class, + ['name' => 'a', 'requires' => ['Acme\\NoSuchInterface']], + '/not an existing interface/', + ]; + + yield 'requires a class rather than an interface' => [ + PassChoiceForm::class, + ['name' => 'a', 'requires' => [PassChoiceForm::class]], + '/not an existing interface/', + ]; + + yield 'requires is not an array' => [ + PassChoiceForm::class, + ['name' => 'a', 'requires' => 'nope'], + '/must be a list of interface names/', + ]; + + yield 'reserved name' => [ + PassChoiceForm::class, + ['name' => 'default'], + '/reserved/', + ]; + + yield 'degenerate derived name' => [ + FilterForm::class, + [], + '/Cannot derive a filter form name/', + ]; + + yield 'class does not implement the interface' => [ + PassPlainElement::class, + ['name' => 'a'], + '/does not implement/', + ]; + } + + public function testDuplicateNameAcrossServicesFailsTheBuild(): void + { + $container = self::container(); + self::form($container, 'test.one', PassChoiceForm::class, ['name' => 'flare_choice']); + self::form($container, 'test.two', PassDerivedFilterForm::class, ['name' => 'flare_choice']); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/already registered by service/'); + + (new RegisterFilterFormsPass())->process($container); + } + + public function testTwoDefaultsForOneValueClassFailTheBuild(): void + { + $container = self::container(); + self::form($container, 'test.one', PassChoiceForm::class, [ + 'name' => 'a', + 'value' => PassStubValue::class, + 'default' => true, + ]); + self::form($container, 'test.two', PassDerivedFilterForm::class, [ + 'name' => 'b', + 'value' => PassStubValue::class, + 'default' => true, + ]); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/both declared as the default/'); + + (new RegisterFilterFormsPass())->process($container); + } + + private static function container(): ContainerBuilder + { + $container = new ContainerBuilder(); + $container->setDefinition(FilterFormRegistry::class, new Definition(FilterFormRegistry::class)); + + return $container; + } + + /** + * @param array $tag + */ + private static function form(ContainerBuilder $container, string $id, string $class, array $tag): void + { + $container->setDefinition($id, (new Definition($class))->addTag(AsFilterForm::TAG, $tag)); + } +} + +interface PassStubCapability +{ +} + +final readonly class PassStubValue +{ +} + +final readonly class PassOtherValue +{ +} + +final class PassCapableElement implements PassStubCapability +{ +} + +final class PassPlainElement +{ +} + +class PassFilterFormBase implements FilterFormInterface +{ + public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void + { + } + + public function decode(FormInterface $mount, FilterContext $context): ?object + { + return null; + } +} + +final class PassChoiceForm extends PassFilterFormBase +{ +} + +final class PassDerivedFilterForm extends PassFilterFormBase +{ +} + +/** Named exactly `FilterForm`, so TypeNameFactory derives the empty string from it. */ +final class FilterForm extends PassFilterFormBase +{ +} diff --git a/tests/DependencyInjection/Factory/TypeNameFactoryTest.php b/tests/DependencyInjection/Factory/TypeNameFactoryTest.php new file mode 100644 index 00000000..fca54e1c --- /dev/null +++ b/tests/DependencyInjection/Factory/TypeNameFactoryTest.php @@ -0,0 +1,77 @@ + + */ + public static function provideFilterFormClassNames(): iterable + { + $ns = 'HeimrichHannot\\FlareBundle\\Filter\\Form\\'; + + yield 'choice' => [$ns . 'ChoiceFilterForm', 'choice']; + yield 'checkbox' => [$ns . 'CheckboxFilterForm', 'checkbox']; + yield 'choice bool snake-cases' => [$ns . 'ChoiceBoolFilterForm', 'choice_bool']; + yield 'date range snake-cases' => [$ns . 'DateRangeFilterForm', 'date_range']; + yield 'keywords' => [$ns . 'KeywordsFilterForm', 'keywords']; + yield 'bare Form suffix' => [$ns . 'ChoiceForm', 'choice']; + yield 'Controller strips first' => [$ns . 'ChoiceFormController', 'choice']; + yield 'no suffix at all' => [$ns . 'Choice', 'choice']; + yield 'degenerate empty name' => [$ns . 'FilterForm', '']; + } + + /** + * The suffix order is load-bearing: `Str::trimSubstrings()` strips each entry at most once, in + * order, so `'FilterForm'` must be tried before `'Form'`. With the order reversed, + * `ChoiceFilterForm` would reduce to `choice_filter`. + */ + public function testFilterFormSuffixOrderDoesNotLeaveTheFilterWordBehind(): void + { + $name = TypeNameFactory::createFilterFormType('Acme\\ChoiceFilterForm'); + + self::assertSame('choice', $name); + self::assertNotSame('choice_filter', $name); + } + + /** + * A class named exactly `FilterForm` reduces to the empty string, because `trimSubstrings()` + * only early-returns on empty *input*. The empty string is the intrinsic sentinel in + * `tl_flare_filter.formVariant`, so `RegisterFilterFormsPass` rejects it rather than publishing + * a registry key that means "no form". Pinned here so nobody "fixes" the factory without + * noticing what depends on this. + */ + public function testDegenerateNameIsEmptyAndMustBeGuardedByTheCaller(): void + { + self::assertSame('', TypeNameFactory::createFilterFormType('Acme\\FilterForm')); + } + + /** + * Form and element names may coincide. Only the separate registries and the distinct + * `flare.filter_form.` / `flare.filter_element.` alias prefixes keep them apart. + */ + public function testFormAndElementNamesMayCoincide(): void + { + self::assertSame( + TypeNameFactory::createFilterElementType('Acme\\ChoiceFilterElement'), + TypeNameFactory::createFilterFormType('Acme\\ChoiceFilterForm'), + ); + } +} diff --git a/tests/Filter/Value/BoolValueTest.php b/tests/Filter/Value/BoolValueTest.php new file mode 100644 index 00000000..7aa97b70 --- /dev/null +++ b/tests/Filter/Value/BoolValueTest.php @@ -0,0 +1,92 @@ +normalizeValue($raw, $choices), + BoolValue::tryFrom($raw, $choices)?->state, + 'BoolValue::tryFrom() diverged from BooleanFilterElement::normalizeValue().', + ); + } + + /** + * @return iterable + */ + public static function provideRawValues(): iterable + { + $inputs = [ + null, '', ' ', 'null', 'NULL', '0', '1', 'true', 'FALSE', 'yes', 'no', 'on', 'off', + 'garbage', 0, 1, -1, 0.0, true, false, + ]; + + foreach ($inputs as $input) + { + foreach ([null, ...BoolBinaryChoices::cases()] as $choices) + { + $label = \sprintf( + '%s %s / %s', + \get_debug_type($input), + \var_export($input, true), + $choices?->value ?? 'no choices', + ); + + yield $label => [$input, $choices]; + } + } + } + + /** + * The §9 consequence: "no opinion" is the absence of a value, never an instance carrying null, + * because two representations of one state cannot be normalised away by a constructor. + */ + public function testNoOpinionIsTheAbsenceOfAValue(): void + { + self::assertNull(BoolValue::tryFrom(null)); + self::assertNull(BoolValue::tryFrom('')); + self::assertNull(BoolValue::tryFrom(' NULL ')); + self::assertNull(BoolValue::tryFrom('garbage')); + } + + /** Under NULL_TRUE an unchecked box means "no opinion"; under the others it means false. */ + public function testBinaryChoicesDecideWhetherFalsyCollapses(): void + { + self::assertNull(BoolValue::tryFrom(false, BoolBinaryChoices::NULL_TRUE)); + self::assertFalse(BoolValue::tryFrom(false, BoolBinaryChoices::NULL_FALSE)?->state); + self::assertFalse(BoolValue::tryFrom(false, BoolBinaryChoices::TRUE_FALSE)?->state); + + // Without a choice set — the `preselect` transformer's case — nothing collapses. + self::assertFalse(BoolValue::tryFrom(false)?->state); + } + + public function testTruthyStringsAndNumbersBecomeTrue(): void + { + self::assertTrue(BoolValue::tryFrom('1')?->state); + self::assertTrue(BoolValue::tryFrom('true')?->state); + self::assertTrue(BoolValue::tryFrom('YES')?->state); + self::assertTrue(BoolValue::tryFrom(1)?->state); + } +} diff --git a/tests/Filter/Value/ChoiceValueTest.php b/tests/Filter/Value/ChoiceValueTest.php new file mode 100644 index 00000000..9c4473c2 --- /dev/null +++ b/tests/Filter/Value/ChoiceValueTest.php @@ -0,0 +1,61 @@ +` is really a holed array. + */ + public function testKeysAreCastSortedDedupedAndReindexed(): void + { + self::assertSame(['a', 'b', 'c'], (new ChoiceValue(['c', 'b', 'a', 'b']))->keys); + self::assertSame(['1', '2'], (new ChoiceValue([2, 1, '2']))->keys); + } + + /** + * Dropping the empty string retires `DcaSelectFieldFilterElement`'s `''`-on-miss artifact, + * which today reaches `DcaSelectFilterType` as a real search value. + */ + public function testEmptyStringsAndUnrepresentableEntriesAreDropped(): void + { + self::assertSame(['a'], (new ChoiceValue(['a', '', null, true, false, [], new \stdClass()]))->keys); + } + + public function testStringableEntriesAreAccepted(): void + { + $stringable = new class implements \Stringable { + public function __toString(): string + { + return 'x'; + } + }; + + self::assertSame(['x'], (new ChoiceValue([$stringable]))->keys); + } + + public function testTryFromReturnsNullWhenNothingSurvives(): void + { + self::assertNull(ChoiceValue::tryFrom([])); + self::assertNull(ChoiceValue::tryFrom(['', null, []])); + self::assertSame(['a'], ChoiceValue::tryFrom(['a'])?->keys); + } + + /** + * Case is deliberately preserved. Lowercasing is coupled to `FieldValueChoiceFilterType`'s + * `LOWER(TRIM())` and would break `DcaSelectFilterType`'s case-sensitive lookup of DCA option + * keys, so it stays in the element that needs it. + */ + public function testCaseIsPreserved(): void + { + self::assertSame(['A', 'a'], (new ChoiceValue(['a', 'A']))->keys); + } +} diff --git a/tests/Filter/Value/DateRangeValueTest.php b/tests/Filter/Value/DateRangeValueTest.php new file mode 100644 index 00000000..abdf2e9b --- /dev/null +++ b/tests/Filter/Value/DateRangeValueTest.php @@ -0,0 +1,94 @@ +getTimestamp(), $named->getTimestamp(), 'precondition: same instant'); + + // The hazard, still live for the raw objects. + self::assertNotSame(\serialize($offset), \serialize($named)); + + // Gone once reduced to a timestamp. + self::assertSame( + DateRangeValue::tryFrom($offset, null)?->from, + DateRangeValue::tryFrom($named, null)?->from, + ); + } + + public function testTryFromAcceptsDateTimeIntFloatAndNumericString(): void + { + self::assertSame(1_767_225_600, DateRangeValue::tryFrom(1_767_225_600, null)?->from); + self::assertSame(1_767_225_600, DateRangeValue::tryFrom('1767225600', null)?->from); + self::assertSame(1_767_225_600, DateRangeValue::tryFrom(' 1767225600 ', null)?->from); + self::assertSame(1_767_225_600, DateRangeValue::tryFrom(1_767_225_600.9, null)?->from); + self::assertSame( + 1_767_225_600, + DateRangeValue::tryFrom((new \DateTimeImmutable())->setTimestamp(1_767_225_600), null)?->from, + ); + } + + /** + * Free-form date strings are rejected deliberately: `new \DateTimeImmutable($input)` throws on + * malformed input and is non-deterministic for relative expressions such as 'now'. Programmatic + * callers pre-resolve with DateTimeHelper::toTimestamp(). + */ + public function testTryFromRejectsFreeFormStringsAndOtherJunk(): void + { + self::assertNull(DateRangeValue::tryFrom('2026-01-01', null)); + self::assertNull(DateRangeValue::tryFrom('now', null)); + self::assertNull(DateRangeValue::tryFrom('garbage', null)); + self::assertNull(DateRangeValue::tryFrom(\NAN, null)); + self::assertNull(DateRangeValue::tryFrom([], null)); + } + + public function testTryFromReturnsNullOnlyWhenBothBoundsAreAbsent(): void + { + self::assertNull(DateRangeValue::tryFrom(null, null)); + self::assertSame(5, DateRangeValue::tryFrom(5, null)?->from); + self::assertNull(DateRangeValue::tryFrom(5, null)?->to); + self::assertSame(9, DateRangeValue::tryFrom(null, 9)?->to); + } + + /** + * `0` is the epoch, not "absent" — expressible because absence is `null`. This diverges on + * purpose from `CalendarCurrentFilterElement::mixedToDateTime()`, whose `if (!$input)` guard + * discards `0` and `'0'`. + */ + public function testEpochIsAValueNotAnAbsence(): void + { + $value = DateRangeValue::tryFrom(0, null); + + self::assertNotNull($value); + self::assertSame(0, $value->from); + } + + /** + * An inverted range is legal and yields no rows; the form validates it separately with a + * POST_SUBMIT FormError. Silently swapping the bounds here would change behaviour. + */ + public function testInvertedRangeIsPreserved(): void + { + $value = DateRangeValue::tryFrom(100, 50); + + self::assertSame(100, $value?->from); + self::assertSame(50, $value?->to); + } +} diff --git a/tests/Filter/Value/KeywordsValueTest.php b/tests/Filter/Value/KeywordsValueTest.php new file mode 100644 index 00000000..29c9a3aa --- /dev/null +++ b/tests/Filter/Value/KeywordsValueTest.php @@ -0,0 +1,48 @@ +keywords); + } + + /** Case is folded by the filter type itself; folding here would discard the user's input. */ + public function testCaseIsPreserved(): void + { + self::assertSame('FooBar', (new KeywordsValue('FooBar'))->keywords); + } + + public function testTryFromReturnsNullForBlankOrNonStringInput(): void + { + self::assertNull(KeywordsValue::tryFrom(null)); + self::assertNull(KeywordsValue::tryFrom('')); + self::assertNull(KeywordsValue::tryFrom(" \t\n ")); + self::assertNull(KeywordsValue::tryFrom(['foo'])); + self::assertNull(KeywordsValue::tryFrom(42)); + } + + public function testTryFromAcceptsStringable(): void + { + $stringable = new class implements \Stringable { + public function __toString(): string + { + return ' foo bar '; + } + }; + + self::assertSame('foo bar', KeywordsValue::tryFrom($stringable)?->keywords); + } +} diff --git a/tests/Filter/Value/ParentRefValueTest.php b/tests/Filter/Value/ParentRefValueTest.php new file mode 100644 index 00000000..de24779b --- /dev/null +++ b/tests/Filter/Value/ParentRefValueTest.php @@ -0,0 +1,78 @@ + [5, 3, '3', 5], + 'tl_calendar' => ['7'], + ]); + + self::assertSame( + ['tl_calendar' => [7], 'tl_news_archive' => [3, 5]], + $value->parents, + ); + } + + public function testNonPositiveAndNonNumericIdsAreDropped(): void + { + $value = new ParentRefValue(['tl_a' => [0, -2, '', 'x', null, true, [], 4]]); + + self::assertSame(['tl_a' => [4]], $value->parents); + } + + public function testTablesWithNoSurvivingIdAreDroppedEntirely(): void + { + self::assertSame(['tl_b' => [1]], (new ParentRefValue(['tl_a' => [0], 'tl_b' => [1]]))->parents); + } + + public function testUnusableTableKeysAndValuesAreDropped(): void + { + // A numeric-looking key would have been cast to int by PHP, and no table name looks like + // that, so an int key is garbage. A non-iterable value cannot carry ids. + self::assertSame([], (new ParentRefValue([0 => [1], ' ' => [1], 'tl_a' => 5]))->parents); + } + + /** + * The §7.4 claim, pinned: one value class spans both ptable modes, with the static main-ptable + * case being the single-table degenerate form rather than a separate shape. + */ + public function testStaticPtableModeIsTheSingleTableCase(): void + { + self::assertSame(['tl_news_archive' => [1, 2]], (new ParentRefValue(['tl_news_archive' => [2, 1]]))->parents); + } + + /** + * "Use the full whitelist" is the absence of the value, not a state of it: today + * `ArchiveFilterElement::processRuntimeValue()` already treats nothing-submitted, empty-option + * and no-model-survived identically, so collapsing all three to null loses nothing — while a + * flag would let the form decide which rows match (§3.4, §4.1). + */ + public function testTryFromReturnsNullWhenNoParentSurvives(): void + { + self::assertNull(ParentRefValue::tryFrom([])); + self::assertNull(ParentRefValue::tryFrom(['tl_a' => []])); + self::assertNull(ParentRefValue::tryFrom(['tl_a' => [0, -1]])); + self::assertSame(['tl_a' => [1]], ParentRefValue::tryFrom(['tl_a' => [1]])?->parents); + } + + public function testAcceptsAnyIterableOfIds(): void + { + $value = new ParentRefValue(['tl_a' => new \ArrayIterator([2, 1])]); + + self::assertSame(['tl_a' => [1, 2]], $value->parents); + } +} diff --git a/tests/Filter/Value/ValueObjectContainmentTest.php b/tests/Filter/Value/ValueObjectContainmentTest.php new file mode 100644 index 00000000..0da307cf --- /dev/null +++ b/tests/Filter/Value/ValueObjectContainmentTest.php @@ -0,0 +1,370 @@ + Cycle guard: value-object *types* may be mutually recursive. */ + private array $checked = []; + + public function testEveryValueObjectSatisfiesTheContainmentRule(): void + { + $classes = self::discoverValueClasses(); + + self::assertNotSame([], $classes, 'No value objects found in src/Filter/Value/.'); + + foreach ($classes as $class) { + $this->assertConformingValueObject($class); + } + } + + /** + * Closes the reflection test's own blind spot. A property typed `array` could hold a + * `\DateTimeImmutable` or a Contao model without any signature saying so — the exact hazards §9 + * names. Every value object therefore contributes a representative instance, walked recursively + * at runtime. + */ + public function testEveryValueObjectHasASampleContainingOnlyConformingValues(): void + { + $samples = self::samples(); + + self::assertSame( + self::discoverValueClasses(), + \array_keys($samples), + 'Add a sample to self::samples() for every class in src/Filter/Value/.', + ); + + foreach ($samples as $class => $sample) { + self::assertConformingRuntimeValue($sample, $class); + } + } + + /** + * §10 row 4 proper. While no element declares `value:` this asserts the scan works and passes + * vacuously; once they do, it is the enforcement gate. + */ + public function testDeclaredElementValueClassesAreConformingValueObjects(): void + { + $elements = self::discoverElementClasses(); + + self::assertNotSame([], $elements, 'No filter elements found — the scan is broken.'); + + foreach ($elements as $element) + { + foreach ((new \ReflectionClass($element))->getAttributes(AsFilterElement::class) as $attribute) + { + $arguments = $attribute->getArguments(); + + if (!\array_key_exists('value', $arguments)) { + continue; + } + + $value = $arguments['value']; + + if ($value === null) { + continue; // §5.3: no value object => intrinsic-only. + } + + self::assertIsString($value, $element . ': AsFilterElement::$value must be a class name or null.'); + self::assertStringStartsWith( + self::VALUE_NAMESPACE, + $value, + \sprintf('%s declares value "%s", which is not in %s.', $element, $value, self::VALUE_NAMESPACE), + ); + + $this->assertConformingValueObject($value); + } + } + } + + // ------------------------------------------------------------------ assertions + + private function assertConformingValueObject(string $class): void + { + if (isset($this->checked[$class])) { + return; + } + + $this->checked[$class] = true; + + self::assertTrue(\class_exists($class), \sprintf('Value class "%s" does not exist.', $class)); + + $reflection = new \ReflectionClass($class); + + self::assertTrue($reflection->isFinal(), $class . ' must be final (SPEC §9).'); + self::assertTrue($reflection->isReadOnly(), $class . ' must be readonly (SPEC §9).'); + self::assertFalse($reflection->isAbstract(), $class . ' must be concrete.'); + + foreach ($reflection->getProperties() as $property) + { + $what = $class . '::$' . $property->getName(); + + self::assertFalse($property->isStatic(), $what . ' must not be static.'); + self::assertTrue( + $property->isPublic(), + $what . ' must be public: Fingerprint::flatten() reads public properties only, so a' + . ' private property would be invisible to the hash.', + ); + self::assertTrue($property->hasType(), $what . ' must declare a type (SPEC §9).'); + + $this->assertContainmentType($property->getType(), $what); + + if (self::declaresArray($property->getType())) { + self::assertArrayElementDocblock($property, $what); + } + } + } + + private function assertContainmentType(?\ReflectionType $type, string $what, int $depth = 0): void + { + self::assertNotNull($type, $what . ' must declare a type.'); + self::assertLessThan(4, $depth, $what . ': type nesting too deep to check.'); + + // Intersection types can only combine interfaces, which never conform. + self::assertNotInstanceOf( + \ReflectionIntersectionType::class, + $type, + $what . ' must not use an intersection type (SPEC §9).', + ); + + if ($type instanceof \ReflectionUnionType) + { + foreach ($type->getTypes() as $member) { + $this->assertContainmentType($member, $what, $depth + 1); + } + + return; + } + + self::assertInstanceOf(\ReflectionNamedType::class, $type, $what . ' has an unsupported type.'); + + $name = $type->getName(); + + if (\in_array($name, self::SCALARS, true) || $name === 'array') { + return; + } + + self::assertTrue( + \enum_exists($name) || \class_exists($name), + \sprintf('%s: type "%s" does not exist.', $what, $name), + ); + + if (\enum_exists($name)) { + return; + } + + self::assertStringStartsWith( + self::VALUE_NAMESPACE, + $name, + \sprintf( + '%s: type "%s" is neither a scalar, null, an enum, nor a nested filter value object' + . ' (SPEC_FILTER_FORMS.md §9 containment rule). Store its id or a timestamp instead.', + $what, + $name, + ), + ); + + $this->assertConformingValueObject($name); + } + + private static function assertArrayElementDocblock(\ReflectionProperty $property, string $what): void + { + // Normalising constructors preclude property promotion, so `@var` sits on the property + // itself and is reliably reflectable. + $doc = $property->getDocComment(); + + self::assertNotFalse( + $doc, + $what . ': an `array` property must document its element type with @var, because the' + . ' native type erases it (SPEC §9).', + ); + + $pattern = \sprintf('/@var\s+((?:list<%1$s>|array<%1$s,\s*(?:%1$s|list<%1$s>)>))/', self::LEAF); + + self::assertMatchesRegularExpression( + $pattern, + $doc, + $what . ': @var must match the containment grammar — list or' + . ' array>, where leaf is a scalar, an enum or a value object.', + ); + + // Scan the captured type expression only. The rest of the docblock is prose, and a + // capitalised word in it ("Non-empty, …") is not a type. + \preg_match($pattern, $doc, $captured); + \preg_match_all('/\\\\?[A-Z][A-Za-z0-9_\\\\]*/', $captured[1], $matches); + + foreach ($matches[0] as $leaf) + { + $leaf = \ltrim($leaf, '\\'); + + self::assertTrue( + \enum_exists($leaf) || \str_starts_with($leaf, self::VALUE_NAMESPACE), + \sprintf('%s: @var element type "%s" is neither an enum nor a value object.', $what, $leaf), + ); + } + } + + private static function assertConformingRuntimeValue(mixed $value, string $path): void + { + if ($value === null || \is_scalar($value) || $value instanceof \UnitEnum) { + return; + } + + if (\is_array($value)) + { + foreach ($value as $key => $item) + { + self::assertTrue(\is_int($key) || \is_string($key), $path . ': array keys must be int or string.'); + self::assertConformingRuntimeValue($item, \sprintf('%s[%s]', $path, $key)); + } + + return; + } + + self::assertTrue( + \is_object($value) && \str_starts_with($value::class, self::VALUE_NAMESPACE), + \sprintf( + '%s holds a %s, which is neither a scalar, null, an enum, nor a nested filter value' + . ' object (SPEC §9). Store its id or a timestamp.', + $path, + \get_debug_type($value), + ), + ); + + foreach (\get_object_vars($value) as $name => $property) { + self::assertConformingRuntimeValue($property, $path . '->' . $name); + } + } + + // ------------------------------------------------------------------ discovery + + private static function declaresArray(?\ReflectionType $type): bool + { + if ($type instanceof \ReflectionNamedType) { + return $type->getName() === 'array'; + } + + if ($type instanceof \ReflectionUnionType) + { + foreach ($type->getTypes() as $member) + { + if (self::declaresArray($member)) { + return true; + } + } + } + + return false; + } + + /** + * @return list + */ + private static function discoverValueClasses(): array + { + $classes = []; + + foreach (\glob(self::projectDir() . '/src/Filter/Value/*.php') ?: [] as $file) { + $classes[] = self::VALUE_NAMESPACE . \basename($file, '.php'); + } + + \sort($classes, \SORT_STRING); + + return $classes; + } + + /** + * @return list + */ + private static function discoverElementClasses(): array + { + $classes = []; + + foreach (self::ELEMENT_DIRS as $dir) + { + foreach (\glob(self::projectDir() . '/' . $dir . '/*.php') ?: [] as $file) + { + $class = 'HeimrichHannot\\FlareBundle\\' + . \str_replace('/', '\\', \substr($dir, \strlen('src/'))) + . '\\' . \basename($file, '.php'); + + // An element whose optional vendor dependency is absent must not fail the scan. + try + { + if (!\class_exists($class)) { + continue; + } + } + catch (\Throwable) + { + continue; + } + + if ((new \ReflectionClass($class))->getAttributes(AsFilterElement::class)) { + $classes[] = $class; + } + } + } + + \sort($classes, \SORT_STRING); + + return $classes; + } + + private static function projectDir(): string + { + return \dirname(__DIR__, 3); + } + + /** + * One representative, fully populated instance per value object. Keyed and ordered to match + * self::discoverValueClasses(), which the test above asserts. + * + * @return array + */ + private static function samples(): array + { + return [ + BoolValue::class => new BoolValue(true), + ChoiceValue::class => new ChoiceValue(['b', 'a', 'a', '']), + DateRangeValue::class => new DateRangeValue(1_767_225_600, 1_767_312_000), + KeywordsValue::class => new KeywordsValue(' foo OR bar '), + ParentRefValue::class => new ParentRefValue(['tl_news_archive' => [5, 3, 3, 0]]), + ]; + } +} diff --git a/tests/Form/ChoicesBuilderTest.php b/tests/Form/ChoicesBuilderTest.php new file mode 100644 index 00000000..2dd1480d --- /dev/null +++ b/tests/Form/ChoicesBuilderTest.php @@ -0,0 +1,284 @@ +add('b', 'Beta')->add('a', 'Alpha'); + + self::assertSame(['b' => 'Beta', 'a' => 'Alpha'], $builder->buildChoices()); + self::assertFalse($builder->hasEmptyOption()); + } + + /** The sentinel is prepended, so its position in the rendered widget is first. */ + public function testEmptyOptionIsPrependedAsTheFirstKey(): void + { + $builder = self::builder()->add('a', 'Alpha')->setEmptyOption(true); + + self::assertSame( + [ChoicesBuilder::EMPTY_CHOICE, 'a'], + \array_keys($builder->buildChoices()), + ); + self::assertSame(ChoicesBuilder::EMPTY_CHOICE, $builder->buildChoices()[ChoicesBuilder::EMPTY_CHOICE]); + } + + /** + * `count()` counts the real choices only, unlike `buildChoices()`. `ArchiveFilterElement` relies + * on exactly this to decide whether any whitelisted parent survived. + */ + public function testCountExcludesTheEmptyOption(): void + { + $builder = self::builder()->add('a', 'Alpha')->add('b', 'Beta')->setEmptyOption(true); + + self::assertSame(2, $builder->count()); + self::assertCount(3, $builder->buildChoices()); + } + + /** Plain array re-assignment, so a re-added alias keeps its original position. */ + public function testReAddingAnAliasOverwritesInPlace(): void + { + $builder = self::builder()->add('a', 'Alpha')->add('b', 'Beta')->add('a', 'Alpha2'); + + self::assertSame(['a' => 'Alpha2', 'b' => 'Beta'], $builder->buildChoices()); + self::assertSame(2, $builder->count()); + } + + public function testGetChoiceRoundTripsAndReturnsNullForUnknownKeys(): void + { + $builder = self::builder()->add('a', 'Alpha'); + + self::assertSame('Alpha', $builder->getChoice('a')); + self::assertNull($builder->getChoice('nope')); + } + + public function testChoiceValueCallbackPrefersAnExplicitValueOverTheAlias(): void + { + $toValue = self::builder()->add('a', 'Alpha')->add('b', 'Beta', 42)->buildChoiceValueCallback(); + + self::assertSame('a', $toValue('Alpha')); + self::assertSame('42', $toValue('Beta')); + } + + public function testChoiceValueCallbackMapsTheSentinelToTheEmptyOptionValue(): void + { + $default = self::builder()->setEmptyOption(true)->buildChoiceValueCallback(); + + self::assertSame(ChoicesBuilder::EMPTY_CHOICE_VALUE_DEFAULT, $default(ChoicesBuilder::EMPTY_CHOICE)); + + $alternative = self::builder() + ->setEmptyOption(true, ChoicesBuilder::EMPTY_CHOICE_VALUE_ALTERNATIVE) + ->buildChoiceValueCallback(); + + self::assertSame( + ChoicesBuilder::EMPTY_CHOICE_VALUE_ALTERNATIVE, + $alternative(ChoicesBuilder::EMPTY_CHOICE), + ); + } + + public function testChoiceValueCallbackReturnsAnEmptyStringForUnknownChoices(): void + { + self::assertSame('', (self::builder()->add('a', 'Alpha')->buildChoiceValueCallback())('Missing')); + } + + /** + * The reverse lookup is a strict `array_search`, so an int `1` does not match the string `'1'`. + * This strictness is what lets `ArchiveFilterElement` get Contao model *instances* back by + * identity — and it is also why a scalar type mismatch silently yields `''`. + */ + public function testChoiceValueCallbackIsStrictAboutTypes(): void + { + $toValue = self::builder()->add('a', '1')->buildChoiceValueCallback(); + + self::assertSame('a', $toValue('1')); + self::assertSame('', $toValue(1)); + } + + /** + * Two choices sharing a display string collapse onto the first alias, because the reverse + * lookup searches by the choice rather than by identity. This is the §7.1 defect; the assertion + * records the current behaviour so the fix is visible as a diff to this test. + */ + public function testDuplicateDisplayStringsCollapseOntoTheFirstAlias(): void + { + $toValue = self::builder()->add('a', 'Same')->add('b', 'Same')->buildChoiceValueCallback(); + + self::assertSame('a', $toValue('Same')); + } + + /** + * The deferral contract `ArchiveFilterElement` and `CodefogTagsChoiceFilterElement` depend on: + * both call `applyFormOptions()` *before* populating choices, which only works because the + * loader closes over `buildChoices(...)` rather than a snapshot. Breaking this makes the archive + * filter silently lose every option. + */ + public function testApplyFormOptionsInstallsADeferredChoiceLoader(): void + { + $builder = self::builder(); + $options = []; + + $builder->applyFormOptions($options); + + // Every choice is added only after the loader was installed. + $builder->add('late', 'Late'); + $builder->add('later', 'Later'); + + self::assertInstanceOf(CallbackChoiceLoader::class, $options['choice_loader']); + + $choices = $options['choice_loader']->loadChoiceList($options['choice_value'])->getChoices(); + + self::assertSame(['Late', 'Later'], \array_values($choices)); + } + + public function testApplyFormOptionsMutatesByReferenceAndIsFluent(): void + { + $builder = self::builder(); + $options = ['label' => 'Untouched']; + + $returned = $builder->applyFormOptions($options); + + self::assertSame($builder, $returned); + self::assertSame('Untouched', $options['label']); + self::assertSame( + ['label', 'choice_loader', 'choice_label', 'choice_value'], + \array_keys($options), + ); + } + + public function testBuildFormOptionsReturnsExactlyTheThreeChoiceKeys(): void + { + self::assertSame( + ['choice_loader', 'choice_label', 'choice_value'], + \array_keys(self::builder()->buildFormOptions()), + ); + } + + /** + * The empty option is keyed differently by the two builders: `EMPTY_CHOICE` for the Symfony + * choice list, `''` for the Contao options array. Pinned so nobody unifies them by accident. + */ + public function testEmptyOptionKeyDiffersBetweenSymfonyAndContaoOutput(): void + { + $builder = self::builder()->add('a', 'Alpha')->setEmptyOption(true); + + self::assertArrayHasKey(ChoicesBuilder::EMPTY_CHOICE, $builder->buildChoices()); + self::assertArrayNotHasKey('', $builder->buildChoices()); + + self::assertArrayHasKey('', $builder->buildContaoOptions()); + self::assertArrayNotHasKey(ChoicesBuilder::EMPTY_CHOICE, $builder->buildContaoOptions()); + } + + /** A string choice is translated as the message id itself, in the `flare_form` domain. */ + public function testStringChoicesAreTranslatedAsTheirOwnMessageId(): void + { + $recorded = []; + $builder = new ChoicesBuilder(self::translator($recorded), self::parameterBag()); + $label = $builder->buildChoiceLabelCallback(); + + self::assertSame('Alpha', $label('Alpha', 'a', 'a')); + self::assertSame([['Alpha', 'flare_form']], $recorded); + } + + public function testEmptyOptionLabelIsUsedForTheSentinelKey(): void + { + $builder = self::builder()->setEmptyOption('empty_option.prompt'); + $label = $builder->buildChoiceLabelCallback(); + + self::assertSame('empty_option.prompt', $label(null, ChoicesBuilder::EMPTY_CHOICE, '')); + } + + /** A null choice falls back to the ndash message rather than rendering as an empty label. */ + public function testNullChoiceFallsBackToTheNdashMessage(): void + { + self::assertSame('empty_option.ndash', self::builder()->buildChoiceLabel(null, '', '')); + } + + public function testLabelableParametersReachTheTranslator(): void + { + $recorded = []; + $builder = new ChoicesBuilder(self::translator($recorded), self::parameterBag()); + $builder->setLabel('label.custom'); + + $choice = new class implements LabelableInterface { + public function getLabelParameters(): array + { + return ['%name%' => 'Widget']; + } + }; + + self::assertSame('label.custom', $builder->buildChoiceLabel($choice, 'k', 'v')); + self::assertSame([['label.custom', 'flare_form']], $recorded); + } + + public function testSetModelSuffixRoundTrips(): void + { + self::assertSame('', self::builder()->getModelSuffix()); + self::assertSame('(%@name%)', self::builder()->setModelSuffix('(%@name%)')->getModelSuffix()); + } + + /** `setEmptyOption()` with a label turns the option on as a side effect. */ + public function testSetEmptyOptionWithALabelEnablesIt(): void + { + self::assertTrue(self::builder()->setEmptyOption('some.label')->hasEmptyOption()); + self::assertFalse(self::builder()->setEmptyOption(false)->hasEmptyOption()); + } + + private static function builder(): ChoicesBuilder + { + $recorded = []; + + return new ChoicesBuilder(self::translator($recorded), self::parameterBag()); + } + + /** + * Returns the message id verbatim, so assertions read as the key that would be translated. + * + * @param list $recorded Receives [id, domain] per call. + */ + private static function translator(array &$recorded): TranslatorInterface + { + return new class ($recorded) implements TranslatorInterface { + /** @param list $recorded */ + public function __construct(private array &$recorded) {} + + public function trans(?string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string + { + $this->recorded[] = [(string) $id, (string) $domain]; + + return (string) $id; + } + + public function getLocale(): string + { + return 'en'; + } + }; + } + + private static function parameterBag(): ParameterBag + { + // tryGetDefaultTypeLabel() reads this parameter unguarded, and the extension always sets + // it; an absent key would throw rather than fall back. + return new ParameterBag(['huh_flare.format_label_defaults' => []]); + } +} diff --git a/tests/Registry/FilterFormRegistryTest.php b/tests/Registry/FilterFormRegistryTest.php new file mode 100644 index 00000000..3f7076a1 --- /dev/null +++ b/tests/Registry/FilterFormRegistryTest.php @@ -0,0 +1,251 @@ + static function () use (&$built): FilterFormInterface { + ++$built; + + return new RegistryFormStub(); + }, + ]); + + $registry = new FilterFormRegistry($locator, [ + 'flare_choice' => [ + 'value' => RegistryStubValue::class, + 'requires' => [RegistryStubCapability::class], + 'default' => true, + 'service' => 'test.choice_form', + ], + ]); + + self::assertSame(['flare_choice'], $registry->keys()); + self::assertSame(RegistryStubValue::class, $registry->getValueClass('flare_choice')); + self::assertSame([RegistryStubCapability::class], $registry->getRequires('flare_choice')); + self::assertTrue($registry->isDefault('flare_choice')); + self::assertSame('test.choice_form', $registry->getServiceId('flare_choice')); + self::assertSame(['flare_choice'], $registry->findNames(RegistryStubValue::class, RegistryCapableElement::class)); + self::assertSame(0, $built, 'Metadata reads must not construct any form service.'); + + self::assertInstanceOf(RegistryFormStub::class, $registry->getService('flare_choice')); + self::assertSame(1, $built); + } + + public function testAccessorsTolerateNullAndUnknownNames(): void + { + $registry = self::registry(); + + self::assertFalse($registry->has('nope')); + self::assertNull($registry->getService(null)); + self::assertNull($registry->getService('nope')); + self::assertNull($registry->getValueClass(null)); + self::assertNull($registry->getValueClass('nope')); + self::assertSame([], $registry->getRequires('nope')); + self::assertFalse($registry->isDefault(null)); + self::assertNull($registry->getServiceId('nope')); + } + + public function testElectionRequiresAMatchingValueClass(): void + { + $registry = self::registry(); + + self::assertSame( + ['flare_choice', 'flare_choice_alt'], + $registry->findNames(RegistryStubValue::class, RegistryCapableElement::class), + ); + self::assertSame( + ['flare_other'], + $registry->findNames(RegistryOtherValue::class, RegistryCapableElement::class), + ); + } + + /** §3.4: the form never names an element; it names a capability the element must implement. */ + public function testElectionFiltersByRequiresUsingInstanceof(): void + { + $registry = self::registry(); + + // flare_choice requires the capability; flare_choice_alt does not. + self::assertSame( + ['flare_choice_alt'], + $registry->findNames(RegistryStubValue::class, RegistryPlainElement::class), + ); + + // Accepts an instance as well as a class-string. + self::assertSame( + ['flare_choice_alt'], + $registry->findNames(RegistryStubValue::class, new RegistryPlainElement()), + ); + self::assertSame( + ['flare_choice', 'flare_choice_alt'], + $registry->findNames(RegistryStubValue::class, new RegistryCapableElement()), + ); + } + + /** §5.3: an element declaring no value class has no forms, i.e. it is intrinsic-only. */ + public function testElectionYieldsNothingWithoutAValueClass(): void + { + self::assertSame([], self::registry()->findNames(null, RegistryCapableElement::class)); + self::assertNull(self::registry()->findDefaultName(null, RegistryCapableElement::class)); + } + + public function testDefaultElectionPrefersTheFlaggedFormRegardlessOfOrder(): void + { + $registry = self::registry(); + + // flare_choice_alt is flagged default but registered second. + self::assertSame( + 'flare_choice_alt', + $registry->findDefaultName(RegistryStubValue::class, RegistryCapableElement::class), + ); + } + + public function testDefaultElectionFallsBackToTheFirstEligibleForm(): void + { + $registry = new FilterFormRegistry(null, [ + 'a' => ['value' => RegistryStubValue::class, 'requires' => [], 'default' => false, 'service' => 's.a'], + 'b' => ['value' => RegistryStubValue::class, 'requires' => [], 'default' => false, 'service' => 's.b'], + ]); + + self::assertSame('a', $registry->findDefaultName(RegistryStubValue::class, RegistryPlainElement::class)); + } + + /** + * The sharp edge in the election rules: a `default` form the element cannot satisfy is skipped + * rather than winning and then failing, so the first *eligible* form takes over. + */ + public function testDefaultElectionSkipsAnIneligibleDefault(): void + { + $registry = new FilterFormRegistry(null, [ + 'needs_capability' => [ + 'value' => RegistryStubValue::class, + 'requires' => [RegistryStubCapability::class], + 'default' => true, + 'service' => 's.a', + ], + 'plain' => [ + 'value' => RegistryStubValue::class, + 'requires' => [], + 'default' => false, + 'service' => 's.b', + ], + ]); + + self::assertSame('plain', $registry->findDefaultName(RegistryStubValue::class, RegistryPlainElement::class)); + self::assertSame( + 'needs_capability', + $registry->findDefaultName(RegistryStubValue::class, RegistryCapableElement::class), + ); + } + + /** The state of the tree before any form is registered: empty, valid, and never throwing. */ + public function testEmptyRegistryIsUsableAndSilent(): void + { + $registry = new FilterFormRegistry(); + + self::assertSame([], $registry->keys()); + self::assertFalse($registry->has('anything')); + self::assertNull($registry->getService('anything')); + self::assertSame([], $registry->findNames(RegistryStubValue::class, RegistryCapableElement::class)); + self::assertNull($registry->findDefaultName(RegistryStubValue::class, RegistryCapableElement::class)); + } + + public function testGetServiceReturnsNullWhenTheLocatorYieldsTheWrongType(): void + { + $registry = new FilterFormRegistry( + new ServiceLocator(['a' => static fn (): \stdClass => new \stdClass()]), + ['a' => ['value' => null, 'requires' => [], 'default' => false, 'service' => 's.a']], + ); + + self::assertNull($registry->getService('a')); + } + + public function testGetServiceThrowsWhenMetadataExistsWithoutALocator(): void + { + $registry = new FilterFormRegistry(null, [ + 'a' => ['value' => null, 'requires' => [], 'default' => false, 'service' => 's.a'], + ]); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessageMatches('/no form locator was injected/'); + + $registry->getService('a'); + } + + private static function registry(): FilterFormRegistry + { + return new FilterFormRegistry(null, [ + 'flare_choice' => [ + 'value' => RegistryStubValue::class, + 'requires' => [RegistryStubCapability::class], + 'default' => false, + 'service' => 'test.choice_form', + ], + 'flare_choice_alt' => [ + 'value' => RegistryStubValue::class, + 'requires' => [], + 'default' => true, + 'service' => 'test.choice_form_alt', + ], + 'flare_other' => [ + 'value' => RegistryOtherValue::class, + 'requires' => [], + 'default' => false, + 'service' => 'test.other_form', + ], + ]); + } +} + +interface RegistryStubCapability +{ +} + +final readonly class RegistryStubValue +{ +} + +final readonly class RegistryOtherValue +{ +} + +final class RegistryCapableElement implements RegistryStubCapability +{ +} + +final class RegistryPlainElement +{ +} + +final class RegistryFormStub implements FilterFormInterface +{ + public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void + { + } + + public function decode(FormInterface $mount, FilterContext $context): ?object + { + return null; + } +} diff --git a/tests/Util/FingerprintTest.php b/tests/Util/FingerprintTest.php new file mode 100644 index 00000000..44d8dcfb --- /dev/null +++ b/tests/Util/FingerprintTest.php @@ -0,0 +1,146 @@ + $shared, 'b' => $shared]); + $distinctHash = self::hashOf(['a' => new BoolValue(true), 'b' => new BoolValue(true)]); + + self::assertSame($sharedHash, $distinctHash); + self::assertStringNotContainsString('r:', \serialize(Fingerprint::flatten([$shared, $shared]))); + + // The mechanism the probe recorded, still present without flattening. + self::assertStringContainsString('r:', \serialize([$shared, $shared])); + } + + /** Positive control: the flattener is still injective over unequal values. */ + public function testUnequalValuesStillHashDifferently(): void + { + self::assertNotSame(self::hashOf(new BoolValue(true)), self::hashOf(new BoolValue(false))); + self::assertNotSame(self::hashOf(new ChoiceValue(['a'])), self::hashOf(new ChoiceValue(['b']))); + + // Class identity is carried, so two structurally identical value objects do not collide. + self::assertNotSame(self::hashOf(new ChoiceValue(['a'])), self::hashOf(new KeywordsValue('a'))); + } + + public function testRoundTripsThroughSerializeUnchanged(): void + { + $flat = Fingerprint::flatten(new ChoiceValue(['b', 'a'])); + + self::assertSame($flat, \unserialize(\serialize($flat))); + } + + /** + * Probe finding #7: `Filter::$data` and `$config` accept a closure through `mixed`, and hashing + * then *threw*. Trading that crash for a documented same-line collision is the deliberate call + * — a collision here costs a cache miss, an exception costs the request. + */ + public function testClosuresNoLongerMakeHashingThrow(): void + { + $closure = static fn (): null => null; + + self::assertIsString(self::hashOf($closure)); + self::assertSame(self::hashOf($closure), self::hashOf($closure)); + self::assertNotSame(self::hashOf($closure), self::hashOf(static fn (): int => 1)); + } + + /** + * Probe findings #4 and #6, restated as the flattener's contract rather than a prohibition: a + * `\DateTimeInterface` or a Contao model still hashes badly, but it degrades to a marked array + * instead of corrupting the structure. Keeping them out is the containment rule's job, enforced + * by ValueObjectContainmentTest. + */ + public function testNonConformingValuesDegradeInsteadOfCorrupting(): void + { + $flat = Fingerprint::flatten(new \DateTimeImmutable('2026-01-01', new \DateTimeZone('UTC'))); + + self::assertIsArray($flat); + self::assertSame(\DateTimeImmutable::class, $flat['*class*']); + } + + /** Enums are value stable and must carry their class, or two enums sharing a case name collide. */ + public function testEnumsFlattenToClassAndBackingValue(): void + { + self::assertSame( + [BoolBinaryChoices::class, 'null_true'], + Fingerprint::flatten(BoolBinaryChoices::NULL_TRUE), + ); + } + + public function testScalarsAndNullPassThroughUntouched(): void + { + self::assertNull(Fingerprint::flatten(null)); + self::assertTrue(Fingerprint::flatten(true)); + self::assertSame(42, Fingerprint::flatten(42)); + self::assertSame('x', Fingerprint::flatten('x')); + self::assertSame(1.5, Fingerprint::flatten(1.5)); + } + + /** NAN !== NAN and -0.0 == 0.0; both would otherwise make the hash non-reflexive. */ + public function testNonReflexiveFloatsAreNormalised(): void + { + self::assertSame(self::hashOf(-0.0), self::hashOf(0.0)); + self::assertSame(self::hashOf(\NAN), self::hashOf(\NAN)); + } + + public function testArrayKeysAndNestingArePreserved(): void + { + self::assertSame( + ['a' => ['*class*' => BoolValue::class, 'state' => true], 7 => 'x'], + Fingerprint::flatten(['a' => new BoolValue(true), 7 => 'x']), + ); + } + + public function testDepthIsBounded(): void + { + $deep = []; + $cursor = &$deep; + + for ($i = 0; $i < 20; ++$i) + { + $cursor['next'] = []; + $cursor = &$cursor['next']; + } + + unset($cursor); + + // Returns rather than blowing the stack, and the marker is reachable. + self::assertIsArray(Fingerprint::flatten($deep)); + self::assertSame(['*depth-exceeded*', 'array'], Fingerprint::flatten([[1]], 1)[0]); + } + + private static function hashOf(mixed $value): string + { + return \sha1(\serialize(Fingerprint::flatten($value))); + } +} From e0c6950733a01e3a441b42386f0b5a7d715f2cb4 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 8 Sep 2026 12:33:53 +0200 Subject: [PATCH 88/96] docs: update SPEC_FILTER_FORMS.md with schema/data migration sequencing and step clarifications --- SPEC_FILTER_FORMS.md | 61 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/SPEC_FILTER_FORMS.md b/SPEC_FILTER_FORMS.md index 35e6dc04..0b3013ea 100644 --- a/SPEC_FILTER_FORMS.md +++ b/SPEC_FILTER_FORMS.md @@ -1,6 +1,7 @@ # SPEC: Decoupling Filter Forms from Filter Elements -**Status:** Step 0 (§12) implemented; steps 1-5 not started +**Status:** Step 0 (§12) implemented; step 1 partially implemented (contracts, value objects, +registry — see the Phase 1 plan); steps 2-6 not started **Scope:** `src/Filter/`, `src/Form/`, `src/Event/`, `src/EventListener/NamedDispatch/`, `src/Registry/`, `src/DependencyInjection/`, `src/DataContainer/Builder/`, `contao/dca/tl_flare_filter.php`, `src/EventListener/Contao/ElementDcaListener.php`, @@ -529,16 +530,61 @@ object legitimately must hold something non-serializable. ## 11. Migration -Phase 0 (§2.2) is rename-only and touches no schema; everything below belongs to steps 1-4. +Phase 0 (§2.2) is rename-only and touches no schema. Everything below belongs to steps 1-4, with +the sole exception of the data migration, which is written **last** — see "Why the data migration +comes last" below. + +Two separable things, deliberately not done together: + +**Schema** — tracked by the DCA, applied by Contao's schema diff. Columns in this bundle come only +from `sql` keys in `contao/dca/tl_flare_filter.php`; there is no schema listener. - New column `formVariant` (`varchar`), plus form-owned columns as they move. - Drop `intrinsic`, `boolMode`, `boolBinaryChoices` and the `boolMode_binary` subpalette / `boolMode` selector entry. -- Contao migration: `intrinsic = 0` → the default form for the element's value class; - `intrinsic = 1` → `''`. - `preselect` stays a column but becomes form-owned; its semantics narrow to hydration only, and the `unset($preselectOptions['null'])` hack in `BooleanFilterElement::buildDca()` goes away. +**Data** — one Contao migration: `intrinsic = 0` → the default form for the element's value class; +`intrinsic = 1` → `''`. + +### Why the data migration comes last + +The mapping cannot be frozen correctly before the forms exist. Three reasons, in order of severity: + +1. **The `flare_bool` arm is undecidable early.** `BooleanFilterElement::buildForm()` always mounts a + `CheckboxType` and never reads `boolMode`; `ternary` posts a backend error as unsupported. But + `boolBinaryChoices` *does* change matching, via `resolveRuntimeValue()` — under `NULL_TRUE` an + unchecked box means "no opinion", under `NULL_FALSE`/`TRUE_FALSE` it means `false`. A checkbox + form that owns no element config cannot express both, so which bool rows map to which bool form + is only answerable once those forms are written. +2. **A migration file auto-registers the moment it exists.** `src/Migration` is not excluded from + the PSR-4 service resource, and `autoconfigure: true` plus `AbstractMigration` earns Contao's + `contao.migration` tag — no manual tag. So the file's mere presence means `contao:migrate` runs + it, against form names that may not exist yet. It is also one-shot: once `shouldRun()` has been + satisfied it goes quiet, so wrong values stay committed and need a *second* corrective migration. +3. **Form names are permanent data.** The migration must hardcode `element type → form name` as + frozen literals rather than resolving through `FilterFormRegistry`, because a migration describes + historical rows — resolving names through live code would let a later rename retroactively change + what already-migrated rows meant. Freezing those literals before the names settle is the same + mistake one step earlier. + +**The ordering hazard this creates, and the constraint it imposes.** The backfill reads `intrinsic`, +which the schema step *drops*. Contao runs migrations before applying the schema diff, so a single +`contao:migrate` on a database still carrying `intrinsic` will backfill and then drop, in that order +— which is correct. But applying a schema update after the drop lands and before the migration +exists destroys the source column, and with it any chance of recovering the mapping. Therefore: + +- The data migration MUST be written before any environment holding real rows applies the schema + diff that drops `intrinsic`. +- `shouldRun()` MUST return `false` when `intrinsic` is already absent, so an environment that has + passed that point fails closed rather than backfilling from nothing. +- `shouldRun()` MUST NOT key on `formVariant` being absent — the column arrives with the schema step + and will already exist. Key on `intrinsic` being present plus a type-restricted count of rows + still needing work; without the type restriction, the element types that get no form at all + (`flare_published`, `flare_relation_belongsTo`, `flare_equation_simple`, `cfg_tags_search`) keep + the migration pending forever. + --- ## 12. Sequencing @@ -578,8 +624,13 @@ contract set is the entire risk; the remaining elements are mechanical. 3. **Migrate the rest.** Nine remaining elements + `CodefogTagsChoiceFilterElement`. `FieldValueChoiceFilterElement` carries the §7 fix. `CodefogTagsSearchElement` declares neither `buildForm()` nor `buildFilter()` (only `buildDca()`) — it needs `value:` set and nothing else. -4. **Schema migration** per §11. +4. **Schema changes** per §11 — drop `intrinsic`, `boolMode`, `boolBinaryChoices` and their + palette/selector entries. DCA only; no data migration yet. 5. **Fold `FilterData`** out per §8. +6. **Data migration** per §11. Written last, once the forms and their names exist and the + `flare_bool` mapping is decided. Subject to the ordering hazard §11 records: it reads + `intrinsic`, which step 4 removes from the DCA, so it must land before any environment with real + rows applies the schema diff. --- From 21e9459d4c25e03fa917ef67fe044978369a425e Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 8 Sep 2026 14:56:17 +0200 Subject: [PATCH 89/96] refactor!: rename `FilterType` to `FilterLogic` across the codebase Replaces all occurrences of `FilterType` with `FilterLogic` to better reflect its purpose of encapsulating logical filtering operations rather than type definitions. Updates dependencies, registry mappings, tests, and documentation to adhere to the new naming convention. Introduced `FilterConditionsBuilder` and `FilterConditions` to replace `FilterQueryBuilder` and `FilterQuery`, aligning with the broader terminology shift. Includes minor adjustments to exception handling and logging strings for clarity. --- AGENTS.md | 4 +- SPEC_FILTER_FORMS.md | 2 +- src/Event/ModifyListQueryStructEvent.php | 6 +-- src/Filter/Element/ArchiveFilterElement.php | 8 ++-- .../BelongsToRelationFilterElement.php | 8 ++-- src/Filter/Element/BooleanFilterElement.php | 4 +- .../Element/CalendarCurrentFilterElement.php | 4 +- src/Filter/Element/DateRangeFilterElement.php | 4 +- .../Element/DcaSelectFieldFilterElement.php | 4 +- .../Element/FieldValueChoiceFilterElement.php | 4 +- src/Filter/Element/PublishedFilterElement.php | 4 +- .../Element/SearchKeywordsFilterElement.php | 4 +- .../Element/SimpleEquationFilterElement.php | 4 +- src/Filter/Factory/FilterContextFactory.php | 8 ++-- src/Filter/FilterBuilder.php | 12 +++--- src/Filter/FilterBuilderInterface.php | 6 +-- src/Filter/FilterCall.php | 10 ++--- src/Filter/Logic/AbstractFilterLogic.php | 15 +++++++ .../ArchiveFilterLogic.php} | 8 ++-- .../BelongsToRelationFilterLogic.php} | 12 +++--- .../BooleanFilterLogic.php} | 10 ++--- .../CalendarCurrentFilterLogic.php} | 10 ++--- .../DateRangeFilterLogic.php} | 10 ++--- .../DcaSelectFilterLogic.php} | 8 ++-- .../FieldValueChoiceFilterLogic.php} | 10 ++--- src/Filter/Logic/FilterLogicInterface.php | 27 +++++++++++++ .../IntegerIdChoiceFilterLogic.php} | 10 ++--- .../PublishedFilterLogic.php} | 10 ++--- .../SearchKeywordsFilterLogic.php} | 8 ++-- .../SimpleEquationFilterLogic.php} | 8 ++-- src/Filter/Type/AbstractFilterType.php | 15 ------- src/Filter/Type/FilterTypeInterface.php | 27 ------------- src/Filter/Value/BoolValue.php | 2 +- src/Filter/Value/ChoiceValue.php | 2 +- src/Filter/Value/DateRangeValue.php | 4 +- src/Filter/Value/KeywordsValue.php | 2 +- src/Filter/Value/ParentRefValue.php | 2 +- src/Filter/Value/ValueInterface.php | 7 ++++ .../CodefogTagsChoiceFilterElement.php | 4 +- .../Collector/ListModelFilterCollector.php | 5 ++- src/Query/Executor/FilterExecutor.php | 14 +++---- src/Query/Executor/ListQueryDirector.php | 8 ++-- .../Factory/FilterQueryBuilderFactory.php | 8 ++-- .../{FilterQuery.php => FilterConditions.php} | 4 +- ...uilder.php => FilterConditionsBuilder.php} | 10 ++--- ...peRegistry.php => FilterLogicRegistry.php} | 22 +++++----- tests/Filter/FilterBuilderTest.php | 40 +++++++++---------- tests/Query/Executor/FilterExecutorTest.php | 4 +- 48 files changed, 215 insertions(+), 207 deletions(-) create mode 100644 src/Filter/Logic/AbstractFilterLogic.php rename src/Filter/{Type/ArchiveFilterType.php => Logic/ArchiveFilterLogic.php} (76%) rename src/Filter/{Type/BelongsToRelationFilterType.php => Logic/BelongsToRelationFilterLogic.php} (87%) rename src/Filter/{Type/BooleanFilterType.php => Logic/BooleanFilterLogic.php} (67%) rename src/Filter/{Type/CalendarCurrentFilterType.php => Logic/CalendarCurrentFilterLogic.php} (84%) rename src/Filter/{Type/DateRangeFilterType.php => Logic/DateRangeFilterLogic.php} (78%) rename src/Filter/{Type/DcaSelectFilterType.php => Logic/DcaSelectFilterLogic.php} (88%) rename src/Filter/{Type/FieldValueChoiceFilterType.php => Logic/FieldValueChoiceFilterLogic.php} (74%) create mode 100644 src/Filter/Logic/FilterLogicInterface.php rename src/Filter/{Type/IntegerIdChoiceFilterType.php => Logic/IntegerIdChoiceFilterLogic.php} (78%) rename src/Filter/{Type/PublishedFilterType.php => Logic/PublishedFilterLogic.php} (85%) rename src/Filter/{Type/SearchKeywordsFilterType.php => Logic/SearchKeywordsFilterLogic.php} (87%) rename src/Filter/{Type/SimpleEquationFilterType.php => Logic/SimpleEquationFilterLogic.php} (92%) delete mode 100644 src/Filter/Type/AbstractFilterType.php delete mode 100644 src/Filter/Type/FilterTypeInterface.php create mode 100644 src/Filter/Value/ValueInterface.php rename src/Query/{FilterQuery.php => FilterConditions.php} (94%) rename src/Query/{FilterQueryBuilder.php => FilterConditionsBuilder.php} (96%) rename src/Registry/{FilterTypeRegistry.php => FilterLogicRegistry.php} (60%) diff --git a/AGENTS.md b/AGENTS.md index 7683e9fb..71f65b5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,12 +81,12 @@ tl_flare_filter and tl_flare_list). 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`, `FilterFormRegistry`, `ListDriverRegistry`, `FilterTypeRegistry`, `ProjectorRegistry`, `EngineModRegistry`. `FilterFormRegistry` differs from the others: it holds +**Registry pattern** — Registries in `src/Registry/` map type names to implementations: `FilterElementRegistry`, `FilterFormRegistry`, `ListDriverRegistry`, `FilterLogicRegistry`, `ProjectorRegistry`, `EngineModRegistry`. `FilterFormRegistry` differs from the others: it holds compile-time metadata as plain arrays and resolves the form services through a lazy `container.service_locator`, so reading metadata (the `formVariant` options, the form election) instantiates nothing. -**Query safety** — `FilterQueryBuilder` (`src/Query/FilterQueryBuilder.php`) enforces parameterized queries. `TableAliasRegistry` (`src/Query/TableAliasRegistry.php`) manages table aliases and JOINs safely. +**Query safety** — `FilterConditionsBuilder` (`src/Query/FilterConditionsBuilder.php`) enforces parameterized queries. `TableAliasRegistry` (`src/Query/TableAliasRegistry.php`) manages table aliases and JOINs safely. **Contao DCA** — Backend form definitions in `contao/dca/tl_flare_*.php`. Templates in `contao/templates/`. Translations in `contao/languages/`. diff --git a/SPEC_FILTER_FORMS.md b/SPEC_FILTER_FORMS.md index 0b3013ea..28a0044d 100644 --- a/SPEC_FILTER_FORMS.md +++ b/SPEC_FILTER_FORMS.md @@ -144,7 +144,7 @@ Rejected names, recorded so the branches stay closed: - **`ListForm…`** — puts the form on the output side of the model, contradicting the table above. - **`ListFilterForm…`** — contains `FilterForm` as a substring, so every search for the per-filter concept also hits the aggregate. -- **`FilterFormType`** — `FilterType` already means the SQL predicate (`src/Filter/Type/`), and the +- **`FilterFormType`** — `FilterType` already means the SQL predicate (`src/Filter/Logic/`), and the suffix falsely promises a Symfony `AbstractType`. - **`FilterBar` / `FilterPanel`** — commit to a layout no template is obliged to honour, and understate an object that also owns `decode()`. diff --git a/src/Event/ModifyListQueryStructEvent.php b/src/Event/ModifyListQueryStructEvent.php index 86e63ff5..61bf7dd0 100644 --- a/src/Event/ModifyListQueryStructEvent.php +++ b/src/Event/ModifyListQueryStructEvent.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\Event; -use HeimrichHannot\FlareBundle\Query\FilterQuery; +use HeimrichHannot\FlareBundle\Query\FilterConditions; use HeimrichHannot\FlareBundle\Query\ListQueryConfig; use HeimrichHannot\FlareBundle\Query\SqlQueryStruct; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; @@ -13,7 +13,7 @@ class ModifyListQueryStructEvent extends Event { /** - * @param FilterQuery[] $filterQueries + * @param FilterConditions[] $filterQueries * @param ListQueryConfig $config * @param TableAliasRegistry $tableAliasRegistry * @param SqlQueryStruct $queryStruct @@ -24,4 +24,4 @@ public function __construct( public readonly TableAliasRegistry $tableAliasRegistry, public SqlQueryStruct $queryStruct, ) {} -} \ No newline at end of file +} diff --git a/src/Filter/Element/ArchiveFilterElement.php b/src/Filter/Element/ArchiveFilterElement.php index 7e87db56..19d3d185 100644 --- a/src/Filter/Element/ArchiveFilterElement.php +++ b/src/Filter/Element/ArchiveFilterElement.php @@ -16,8 +16,8 @@ use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\Type\ArchiveFilterType; -use HeimrichHannot\FlareBundle\Filter\Type\BelongsToRelationFilterType; +use HeimrichHannot\FlareBundle\Filter\Logic\ArchiveFilterLogic; +use HeimrichHannot\FlareBundle\Filter\Logic\BelongsToRelationFilterLogic; use HeimrichHannot\FlareBundle\Form\ChoicesBuilder; use HeimrichHannot\FlareBundle\InferPtable\Factory\PtableInferrableFactory; use HeimrichHannot\FlareBundle\InferPtable\PtableInferrer; @@ -197,7 +197,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont throw new FilterException('No valid parent archive ids extracted.', method: __METHOD__); } - $builder->add(ArchiveFilterType::class, [ + $builder->add(ArchiveFilterLogic::class, [ 'field' => 'pid', 'parent_ids' => $pids, ]); @@ -225,7 +225,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont } } - $builder->add(BelongsToRelationFilterType::class, [ + $builder->add(BelongsToRelationFilterLogic::class, [ 'field_pid' => 'pid', 'field_dynamic_ptable' => 'ptable', 'parent_groups' => $this->getDynamicParentGroups($config), diff --git a/src/Filter/Element/BelongsToRelationFilterElement.php b/src/Filter/Element/BelongsToRelationFilterElement.php index 00d3515b..75d31ef8 100644 --- a/src/Filter/Element/BelongsToRelationFilterElement.php +++ b/src/Filter/Element/BelongsToRelationFilterElement.php @@ -15,7 +15,7 @@ use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; -use HeimrichHannot\FlareBundle\Filter\Type\BelongsToRelationFilterType; +use HeimrichHannot\FlareBundle\Filter\Logic\BelongsToRelationFilterLogic; use HeimrichHannot\FlareBundle\InferPtable\Factory\PtableInferrableFactory; use HeimrichHannot\FlareBundle\InferPtable\PtableInferrer; use HeimrichHannot\FlareBundle\Model\FilterModel; @@ -85,7 +85,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont if (\is_string($fieldDynamicPtable)) { - $builder->add(BelongsToRelationFilterType::class, [ + $builder->add(BelongsToRelationFilterLogic::class, [ 'field_pid' => $fieldPid, 'field_dynamic_ptable' => $fieldDynamicPtable, 'parent_groups' => $this->getDynamicParentGroups($config['group_whitelist_parents']), @@ -98,7 +98,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont throw new FilterException('No whitelisted parents.'); } - $builder->add(BelongsToRelationFilterType::class, [ + $builder->add(BelongsToRelationFilterLogic::class, [ 'field_pid' => $fieldPid, 'whitelist' => $whitelistParents, ]); @@ -123,7 +123,7 @@ public function addDynamicPtableFilter( string $fieldPid, ?array $submittedData = null, ): void { - $builder->add(BelongsToRelationFilterType::class, [ + $builder->add(BelongsToRelationFilterLogic::class, [ 'field_pid' => $fieldPid, 'field_dynamic_ptable' => $fieldDynamicPtable, 'parent_groups' => $this->getDynamicParentGroups($groupWhitelistParents), diff --git a/src/Filter/Element/BooleanFilterElement.php b/src/Filter/Element/BooleanFilterElement.php index bcf08258..283aaa7b 100644 --- a/src/Filter/Element/BooleanFilterElement.php +++ b/src/Filter/Element/BooleanFilterElement.php @@ -16,7 +16,7 @@ use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\Type\BooleanFilterType; +use HeimrichHannot\FlareBundle\Filter\Logic\BooleanFilterLogic; use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -75,7 +75,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont return; } - $builder->add(BooleanFilterType::class, [ + $builder->add(BooleanFilterLogic::class, [ 'field' => $targetField, 'value' => $value, ]); diff --git a/src/Filter/Element/CalendarCurrentFilterElement.php b/src/Filter/Element/CalendarCurrentFilterElement.php index 90c2729b..9410d780 100644 --- a/src/Filter/Element/CalendarCurrentFilterElement.php +++ b/src/Filter/Element/CalendarCurrentFilterElement.php @@ -13,7 +13,7 @@ use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\Type\CalendarCurrentFilterType; +use HeimrichHannot\FlareBundle\Filter\Logic\CalendarCurrentFilterLogic; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Util\DateTimeHelper; use Symfony\Component\Form\Extension\Core\Type\DateType; @@ -129,7 +129,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont } } - $builder->add(CalendarCurrentFilterType::class, [ + $builder->add(CalendarCurrentFilterLogic::class, [ 'start' => $start, 'stop' => $stop, 'has_extended_events' => $config['has_extended_events'], diff --git a/src/Filter/Element/DateRangeFilterElement.php b/src/Filter/Element/DateRangeFilterElement.php index 7d66a26c..7123de4a 100644 --- a/src/Filter/Element/DateRangeFilterElement.php +++ b/src/Filter/Element/DateRangeFilterElement.php @@ -13,7 +13,7 @@ use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\Type\DateRangeFilterType; +use HeimrichHannot\FlareBundle\Filter\Logic\DateRangeFilterLogic; use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\Form\Extension\Core\Type\DateType; use Symfony\Component\Form\FormError; @@ -82,7 +82,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont throw new FilterException('Set fieldGeneric in filter model.'); } - $builder->add(DateRangeFilterType::class, [ + $builder->add(DateRangeFilterLogic::class, [ 'field' => $field, 'from' => $data->get('from'), 'to' => $data->get('to'), diff --git a/src/Filter/Element/DcaSelectFieldFilterElement.php b/src/Filter/Element/DcaSelectFieldFilterElement.php index 85855870..1a5118d3 100644 --- a/src/Filter/Element/DcaSelectFieldFilterElement.php +++ b/src/Filter/Element/DcaSelectFieldFilterElement.php @@ -16,7 +16,7 @@ use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\Type\DcaSelectFilterType; +use HeimrichHannot\FlareBundle\Filter\Logic\DcaSelectFilterLogic; use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -124,7 +124,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont $dcaOptionsField = $this->getOptionsField($context->list->dc, $config['field']) ?? []; $isMultiple = $dcaOptionsField['eval']['multiple'] ?? false; - $builder->add(DcaSelectFilterType::class, [ + $builder->add(DcaSelectFilterLogic::class, [ 'field' => $targetField, 'selected' => $selected, 'valid_options' => $options, diff --git a/src/Filter/Element/FieldValueChoiceFilterElement.php b/src/Filter/Element/FieldValueChoiceFilterElement.php index c5b7b2ea..7d0adf86 100644 --- a/src/Filter/Element/FieldValueChoiceFilterElement.php +++ b/src/Filter/Element/FieldValueChoiceFilterElement.php @@ -17,7 +17,7 @@ use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\Type\FieldValueChoiceFilterType; +use HeimrichHannot\FlareBundle\Filter\Logic\FieldValueChoiceFilterLogic; use HeimrichHannot\FlareBundle\Form\ChoicesBuilder; use HeimrichHannot\FlareBundle\Form\Factory\ChoicesBuilderFactory; use HeimrichHannot\FlareBundle\Model\FilterModel; @@ -104,7 +104,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont return; } - $builder->add(FieldValueChoiceFilterType::class, [ + $builder->add(FieldValueChoiceFilterLogic::class, [ 'field' => $field, 'values' => $value, ]); diff --git a/src/Filter/Element/PublishedFilterElement.php b/src/Filter/Element/PublishedFilterElement.php index 81b273b4..6dd958e0 100644 --- a/src/Filter/Element/PublishedFilterElement.php +++ b/src/Filter/Element/PublishedFilterElement.php @@ -11,7 +11,7 @@ use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; -use HeimrichHannot\FlareBundle\Filter\Type\PublishedFilterType; +use HeimrichHannot\FlareBundle\Filter\Logic\PublishedFilterLogic; use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -55,7 +55,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont { $config = $context->config; - $builder->add(PublishedFilterType::class, [ + $builder->add(PublishedFilterLogic::class, [ 'published_field' => $config['published_field'], 'start_field' => $config['start_field'], 'stop_field' => $config['stop_field'], diff --git a/src/Filter/Element/SearchKeywordsFilterElement.php b/src/Filter/Element/SearchKeywordsFilterElement.php index c6a6c008..b42af2fa 100644 --- a/src/Filter/Element/SearchKeywordsFilterElement.php +++ b/src/Filter/Element/SearchKeywordsFilterElement.php @@ -13,7 +13,7 @@ use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\Type\SearchKeywordsFilterType; +use HeimrichHannot\FlareBundle\Filter\Logic\SearchKeywordsFilterLogic; use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -78,7 +78,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont return; } - $builder->add(SearchKeywordsFilterType::class, [ + $builder->add(SearchKeywordsFilterLogic::class, [ 'value' => $value, 'columns' => $columns, ]); diff --git a/src/Filter/Element/SimpleEquationFilterElement.php b/src/Filter/Element/SimpleEquationFilterElement.php index 9a20f493..5f9b3f42 100644 --- a/src/Filter/Element/SimpleEquationFilterElement.php +++ b/src/Filter/Element/SimpleEquationFilterElement.php @@ -13,7 +13,7 @@ use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; -use HeimrichHannot\FlareBundle\Filter\Type\SimpleEquationFilterType; +use HeimrichHannot\FlareBundle\Filter\Logic\SimpleEquationFilterLogic; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Util\DcaHelper; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -56,7 +56,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont throw new FilterException('Invalid filter configuration.'); } - $builder->add(SimpleEquationFilterType::class, [ + $builder->add(SimpleEquationFilterLogic::class, [ 'operand_left' => $operand, 'operator' => $op, 'operand_right' => $config['right'], diff --git a/src/Filter/Factory/FilterContextFactory.php b/src/Filter/Factory/FilterContextFactory.php index daee5a40..25c05078 100644 --- a/src/Filter/Factory/FilterContextFactory.php +++ b/src/Filter/Factory/FilterContextFactory.php @@ -25,10 +25,10 @@ public function __construct( * @throws FilterException If the filter's config violates the element's schema */ public function create( - ListSpec $list, - Filter $filter, - ContextInterface $engineContext, - string|int|null $key = null, + ListSpec $list, + Filter $filter, + ContextInterface $engineContext, + string|int|null $key = null, ): FilterContext { return new FilterContext( list: $list, diff --git a/src/Filter/FilterBuilder.php b/src/Filter/FilterBuilder.php index 5d8863e7..f79183e0 100644 --- a/src/Filter/FilterBuilder.php +++ b/src/Filter/FilterBuilder.php @@ -6,14 +6,14 @@ use HeimrichHannot\FlareBundle\Exception\AbortFilteringException; use HeimrichHannot\FlareBundle\Exception\FilterException; -use HeimrichHannot\FlareBundle\Filter\Type\FilterTypeInterface; -use HeimrichHannot\FlareBundle\Registry\FilterTypeRegistry; +use HeimrichHannot\FlareBundle\Filter\Logic\FilterLogicInterface; +use HeimrichHannot\FlareBundle\Registry\FilterLogicRegistry; use Symfony\Component\OptionsResolver\OptionsResolver; final class FilterBuilder implements FilterBuilderInterface { /** - * @var array, OptionsResolver> + * @var array, OptionsResolver> */ private static array $optionsResolvers = []; @@ -23,12 +23,12 @@ final class FilterBuilder implements FilterBuilderInterface private array $calls = []; public function __construct( - private readonly FilterTypeRegistry $filterTypeRegistry, - private readonly string $defaultTargetAlias, + private readonly FilterLogicRegistry $filterTypeRegistry, + private readonly string $defaultTargetAlias, ) {} /** - * @param class-string $type + * @param class-string $type * @param array $options * * @throws FilterException diff --git a/src/Filter/FilterBuilderInterface.php b/src/Filter/FilterBuilderInterface.php index a3cb384f..c8523b71 100644 --- a/src/Filter/FilterBuilderInterface.php +++ b/src/Filter/FilterBuilderInterface.php @@ -4,12 +4,12 @@ namespace HeimrichHannot\FlareBundle\Filter; -use HeimrichHannot\FlareBundle\Filter\Type\FilterTypeInterface; +use HeimrichHannot\FlareBundle\Filter\Logic\FilterLogicInterface; interface FilterBuilderInterface { /** - * @param class-string $type + * @param class-string $type * @param array $options */ public function add(string $type, array $options = [], ?string $targetAlias = null): static; @@ -20,4 +20,4 @@ public function add(string $type, array $options = [], ?string $targetAlias = nu 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 index 6c721cc9..64d605d1 100644 --- a/src/Filter/FilterCall.php +++ b/src/Filter/FilterCall.php @@ -4,14 +4,14 @@ namespace HeimrichHannot\FlareBundle\Filter; -use HeimrichHannot\FlareBundle\Filter\Type\FilterTypeInterface; +use HeimrichHannot\FlareBundle\Filter\Logic\FilterLogicInterface; final readonly class FilterCall { public function __construct( - public FilterTypeInterface $type, - public string $typeClass, - public string $targetAlias, - public array $options, + public FilterLogicInterface $type, + public string $typeClass, + public string $targetAlias, + public array $options, ) {} } diff --git a/src/Filter/Logic/AbstractFilterLogic.php b/src/Filter/Logic/AbstractFilterLogic.php new file mode 100644 index 00000000..3f35a253 --- /dev/null +++ b/src/Filter/Logic/AbstractFilterLogic.php @@ -0,0 +1,15 @@ +define('parent_ids')->required()->allowedTypes('array'); } - public function buildQuery(FilterQueryBuilder $builder, array $options): void + public function buildConditions(FilterConditionsBuilder $builder, array $options): void { $ids = \array_values(\array_unique(\array_filter(\array_map('\intval', $options['parent_ids'])))); diff --git a/src/Filter/Type/BelongsToRelationFilterType.php b/src/Filter/Logic/BelongsToRelationFilterLogic.php similarity index 87% rename from src/Filter/Type/BelongsToRelationFilterType.php rename to src/Filter/Logic/BelongsToRelationFilterLogic.php index a433aed9..98dfd871 100644 --- a/src/Filter/Type/BelongsToRelationFilterType.php +++ b/src/Filter/Logic/BelongsToRelationFilterLogic.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Filter\Type; +namespace HeimrichHannot\FlareBundle\Filter\Logic; -use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; +use HeimrichHannot\FlareBundle\Query\FilterConditionsBuilder; use Symfony\Component\OptionsResolver\OptionsResolver; -class BelongsToRelationFilterType extends AbstractFilterType +class BelongsToRelationFilterLogic extends AbstractFilterLogic { public function configureOptions(OptionsResolver $resolver): void { @@ -18,7 +18,7 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('submitted_data')->default(null)->allowedTypes('null', 'array'); } - public function buildQuery(FilterQueryBuilder $builder, array $options): void + public function buildConditions(FilterConditionsBuilder $builder, array $options): void { if ($options['field_dynamic_ptable']) { $this->buildDynamicQuery($builder, $options); @@ -33,7 +33,7 @@ public function buildQuery(FilterQueryBuilder $builder, array $options): void ->setParameter('whitelist', $options['whitelist']); } - private function buildDynamicQuery(FilterQueryBuilder $builder, array $options): void + private function buildDynamicQuery(FilterConditionsBuilder $builder, array $options): void { $ors = []; $submittedData = $options['submitted_data']; @@ -92,4 +92,4 @@ private function buildDynamicQuery(FilterQueryBuilder $builder, array $options): $builder->whereOr(...$ors); } -} \ No newline at end of file +} diff --git a/src/Filter/Type/BooleanFilterType.php b/src/Filter/Logic/BooleanFilterLogic.php similarity index 67% rename from src/Filter/Type/BooleanFilterType.php rename to src/Filter/Logic/BooleanFilterLogic.php index d8c52206..6f41a377 100644 --- a/src/Filter/Type/BooleanFilterType.php +++ b/src/Filter/Logic/BooleanFilterLogic.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Filter\Type; +namespace HeimrichHannot\FlareBundle\Filter\Logic; use Doctrine\DBAL\ParameterType; -use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; +use HeimrichHannot\FlareBundle\Query\FilterConditionsBuilder; use Symfony\Component\OptionsResolver\OptionsResolver; -class BooleanFilterType extends AbstractFilterType +class BooleanFilterLogic extends AbstractFilterLogic { public function configureOptions(OptionsResolver $resolver): void { @@ -16,9 +16,9 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('value')->required()->allowedTypes('bool'); } - public function buildQuery(FilterQueryBuilder $builder, array $options): void + public function buildConditions(FilterConditionsBuilder $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/Logic/CalendarCurrentFilterLogic.php similarity index 84% rename from src/Filter/Type/CalendarCurrentFilterType.php rename to src/Filter/Logic/CalendarCurrentFilterLogic.php index bf24abbc..e60d8e95 100644 --- a/src/Filter/Type/CalendarCurrentFilterType.php +++ b/src/Filter/Logic/CalendarCurrentFilterLogic.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Filter\Type; +namespace HeimrichHannot\FlareBundle\Filter\Logic; -use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; +use HeimrichHannot\FlareBundle\Query\FilterConditionsBuilder; use Symfony\Component\OptionsResolver\OptionsResolver; -class CalendarCurrentFilterType extends AbstractFilterType +class CalendarCurrentFilterLogic extends AbstractFilterLogic { public function configureOptions(OptionsResolver $resolver): void { @@ -16,7 +16,7 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('has_extended_events')->default(false)->allowedTypes('bool'); } - public function buildQuery(FilterQueryBuilder $builder, array $options): void + public function buildConditions(FilterConditionsBuilder $builder, array $options): void { $colStartTime = $builder->column('startTime'); $colRepeatEnd = $builder->column('repeatEnd'); @@ -47,4 +47,4 @@ public function buildQuery(FilterQueryBuilder $builder, array $options): void $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/Logic/DateRangeFilterLogic.php similarity index 78% rename from src/Filter/Type/DateRangeFilterType.php rename to src/Filter/Logic/DateRangeFilterLogic.php index ad5a8414..90c0f781 100644 --- a/src/Filter/Type/DateRangeFilterType.php +++ b/src/Filter/Logic/DateRangeFilterLogic.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Filter\Type; +namespace HeimrichHannot\FlareBundle\Filter\Logic; -use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; +use HeimrichHannot\FlareBundle\Query\FilterConditionsBuilder; use Symfony\Component\OptionsResolver\OptionsResolver; -class DateRangeFilterType extends AbstractFilterType +class DateRangeFilterLogic extends AbstractFilterLogic { public function configureOptions(OptionsResolver $resolver): void { @@ -16,7 +16,7 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('to')->default(null)->allowedTypes('null', \DateTimeInterface::class); } - public function buildQuery(FilterQueryBuilder $builder, array $options): void + public function buildConditions(FilterConditionsBuilder $builder, array $options): void { $field = $builder->column($options['field']); @@ -30,4 +30,4 @@ public function buildQuery(FilterQueryBuilder $builder, array $options): void ->setParameter('to', $options['to']->getTimestamp()); } } -} \ No newline at end of file +} diff --git a/src/Filter/Type/DcaSelectFilterType.php b/src/Filter/Logic/DcaSelectFilterLogic.php similarity index 88% rename from src/Filter/Type/DcaSelectFilterType.php rename to src/Filter/Logic/DcaSelectFilterLogic.php index 4fa383b9..3da2d8a5 100644 --- a/src/Filter/Type/DcaSelectFilterType.php +++ b/src/Filter/Logic/DcaSelectFilterLogic.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Filter\Type; +namespace HeimrichHannot\FlareBundle\Filter\Logic; use HeimrichHannot\FlareBundle\Exception\FilterException; -use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; +use HeimrichHannot\FlareBundle\Query\FilterConditionsBuilder; use Symfony\Component\OptionsResolver\OptionsResolver; -class DcaSelectFilterType extends AbstractFilterType +class DcaSelectFilterLogic extends AbstractFilterLogic { public function configureOptions(OptionsResolver $resolver): void { @@ -18,7 +18,7 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('is_multiple_dca_field')->default(false)->allowedTypes('bool'); } - public function buildQuery(FilterQueryBuilder $builder, array $options): void + public function buildConditions(FilterConditionsBuilder $builder, array $options): void { $selected = \array_values($options['selected']); $validOptions = $options['valid_options']; diff --git a/src/Filter/Type/FieldValueChoiceFilterType.php b/src/Filter/Logic/FieldValueChoiceFilterLogic.php similarity index 74% rename from src/Filter/Type/FieldValueChoiceFilterType.php rename to src/Filter/Logic/FieldValueChoiceFilterLogic.php index 1f02fd23..1a5f03d0 100644 --- a/src/Filter/Type/FieldValueChoiceFilterType.php +++ b/src/Filter/Logic/FieldValueChoiceFilterLogic.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Filter\Type; +namespace HeimrichHannot\FlareBundle\Filter\Logic; -use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; +use HeimrichHannot\FlareBundle\Query\FilterConditionsBuilder; use Symfony\Component\OptionsResolver\OptionsResolver; -class FieldValueChoiceFilterType extends AbstractFilterType +class FieldValueChoiceFilterLogic extends AbstractFilterLogic { public function configureOptions(OptionsResolver $resolver): void { @@ -15,7 +15,7 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('values')->required()->allowedTypes('array'); } - public function buildQuery(FilterQueryBuilder $builder, array $options): void + public function buildConditions(FilterConditionsBuilder $builder, array $options): void { $values = $options['values']; @@ -35,4 +35,4 @@ public function buildQuery(FilterQueryBuilder $builder, array $options): void $builder->where("LOWER(TRIM({$field})) IN (:values)") ->setParameter('values', $values); } -} \ No newline at end of file +} diff --git a/src/Filter/Logic/FilterLogicInterface.php b/src/Filter/Logic/FilterLogicInterface.php new file mode 100644 index 00000000..967f6492 --- /dev/null +++ b/src/Filter/Logic/FilterLogicInterface.php @@ -0,0 +1,27 @@ + $options + */ + public function buildConditions(FilterConditionsBuilder $builder, array $options): void; +} diff --git a/src/Filter/Type/IntegerIdChoiceFilterType.php b/src/Filter/Logic/IntegerIdChoiceFilterLogic.php similarity index 78% rename from src/Filter/Type/IntegerIdChoiceFilterType.php rename to src/Filter/Logic/IntegerIdChoiceFilterLogic.php index 49afee3c..d1dadc6c 100644 --- a/src/Filter/Type/IntegerIdChoiceFilterType.php +++ b/src/Filter/Logic/IntegerIdChoiceFilterLogic.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Filter\Type; +namespace HeimrichHannot\FlareBundle\Filter\Logic; use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\ParameterType; -use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; +use HeimrichHannot\FlareBundle\Query\FilterConditionsBuilder; use Symfony\Component\OptionsResolver\OptionsResolver; -class IntegerIdChoiceFilterType extends AbstractFilterType +class IntegerIdChoiceFilterLogic extends AbstractFilterLogic { public function configureOptions(OptionsResolver $resolver): void { @@ -17,7 +17,7 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('ids')->required()->allowedTypes('array'); } - public function buildQuery(FilterQueryBuilder $builder, array $options): void + public function buildConditions(FilterConditionsBuilder $builder, array $options): void { $ids = \array_values(\array_unique(\array_filter(\array_map('\intval', $options['ids'])))); @@ -34,4 +34,4 @@ public function buildQuery(FilterQueryBuilder $builder, array $options): void $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/Logic/PublishedFilterLogic.php similarity index 85% rename from src/Filter/Type/PublishedFilterType.php rename to src/Filter/Logic/PublishedFilterLogic.php index 5d684dd9..947bc3df 100644 --- a/src/Filter/Type/PublishedFilterType.php +++ b/src/Filter/Logic/PublishedFilterLogic.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Filter\Type; +namespace HeimrichHannot\FlareBundle\Filter\Logic; -use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; +use HeimrichHannot\FlareBundle\Query\FilterConditionsBuilder; use Symfony\Component\OptionsResolver\OptionsResolver; -class PublishedFilterType extends AbstractFilterType +class PublishedFilterLogic extends AbstractFilterLogic { public function configureOptions(OptionsResolver $resolver): void { @@ -18,7 +18,7 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('now')->required()->allowedTypes('int'); } - public function buildQuery(FilterQueryBuilder $builder, array $options): void + public function buildConditions(FilterConditionsBuilder $builder, array $options): void { if ($options['published_field']) { @@ -45,4 +45,4 @@ public function buildQuery(FilterQueryBuilder $builder, array $options): void ->setParameter('stop', $options['now']); } } -} \ No newline at end of file +} diff --git a/src/Filter/Type/SearchKeywordsFilterType.php b/src/Filter/Logic/SearchKeywordsFilterLogic.php similarity index 87% rename from src/Filter/Type/SearchKeywordsFilterType.php rename to src/Filter/Logic/SearchKeywordsFilterLogic.php index 1db06f1d..7a7eac6b 100644 --- a/src/Filter/Type/SearchKeywordsFilterType.php +++ b/src/Filter/Logic/SearchKeywordsFilterLogic.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Filter\Type; +namespace HeimrichHannot\FlareBundle\Filter\Logic; use HeimrichHannot\FlareBundle\ConfigProvider; -use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; +use HeimrichHannot\FlareBundle\Query\FilterConditionsBuilder; use Symfony\Component\OptionsResolver\OptionsResolver; -class SearchKeywordsFilterType extends AbstractFilterType +class SearchKeywordsFilterLogic extends AbstractFilterLogic { public function __construct( private readonly ConfigProvider $configProvider, @@ -20,7 +20,7 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('columns')->required()->allowedTypes('array'); } - public function buildQuery(FilterQueryBuilder $builder, array $options): void + public function buildConditions(FilterConditionsBuilder $builder, array $options): void { $columns = \array_map($builder->column(...), $options['columns']); $searchTermGroups = \array_values(\preg_split('/\s+OR\s+/i', $options['value'])); diff --git a/src/Filter/Type/SimpleEquationFilterType.php b/src/Filter/Logic/SimpleEquationFilterLogic.php similarity index 92% rename from src/Filter/Type/SimpleEquationFilterType.php rename to src/Filter/Logic/SimpleEquationFilterLogic.php index 87ce6b59..f3720c8f 100644 --- a/src/Filter/Type/SimpleEquationFilterType.php +++ b/src/Filter/Logic/SimpleEquationFilterLogic.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Filter\Type; +namespace HeimrichHannot\FlareBundle\Filter\Logic; use HeimrichHannot\FlareBundle\Enum\SqlEquationOperator; use HeimrichHannot\FlareBundle\Exception\FilterException; -use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; +use HeimrichHannot\FlareBundle\Query\FilterConditionsBuilder; use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; -class SimpleEquationFilterType extends AbstractFilterType +class SimpleEquationFilterLogic extends AbstractFilterLogic { public function configureOptions(OptionsResolver $resolver): void { @@ -38,7 +38,7 @@ public function configureOptions(OptionsResolver $resolver): void /** * @throws FilterException */ - public function buildQuery(FilterQueryBuilder $builder, array $options): void + public function buildConditions(FilterConditionsBuilder $builder, array $options): void { $operandLeft = $options['operand_left']; $operator = SqlEquationOperator::match($options['operator']); diff --git a/src/Filter/Type/AbstractFilterType.php b/src/Filter/Type/AbstractFilterType.php deleted file mode 100644 index 2cf9eb3f..00000000 --- a/src/Filter/Type/AbstractFilterType.php +++ /dev/null @@ -1,15 +0,0 @@ - $options - */ - public function buildQuery(FilterQueryBuilder $builder, array $options): void; -} diff --git a/src/Filter/Value/BoolValue.php b/src/Filter/Value/BoolValue.php index 1841782c..db2a0d6d 100644 --- a/src/Filter/Value/BoolValue.php +++ b/src/Filter/Value/BoolValue.php @@ -18,7 +18,7 @@ * callers in the target model — the form's `decode()` and the form's `preselect` transformer * (§4.2) — which is why it lives here rather than on either of them. */ -final readonly class BoolValue +final readonly class BoolValue implements ValueInterface { public function __construct( public bool $state, diff --git a/src/Filter/Value/ChoiceValue.php b/src/Filter/Value/ChoiceValue.php index 851225db..94617514 100644 --- a/src/Filter/Value/ChoiceValue.php +++ b/src/Filter/Value/ChoiceValue.php @@ -22,7 +22,7 @@ * two elements share a value object only if every form registered for one is meaningful for the * other, and a choice form is not meaningful for a free-text search. */ -final readonly class ChoiceValue +final readonly class ChoiceValue implements ValueInterface { /** @var list Non-empty, deduplicated, ascending by string comparison. */ public array $keys; diff --git a/src/Filter/Value/DateRangeValue.php b/src/Filter/Value/DateRangeValue.php index f737cb4e..fd4ec990 100644 --- a/src/Filter/Value/DateRangeValue.php +++ b/src/Filter/Value/DateRangeValue.php @@ -26,9 +26,9 @@ * divergence from `CalendarCurrentFilterElement::mixedToDateTime()`, whose `if (!$input)` guard * discards `0` and `'0'`. */ -final readonly class DateRangeValue +final readonly class DateRangeValue implements ValueInterface { - public function __construct( + private function __construct( public ?int $from = null, public ?int $to = null, ) {} diff --git a/src/Filter/Value/KeywordsValue.php b/src/Filter/Value/KeywordsValue.php index de365450..ac4648ef 100644 --- a/src/Filter/Value/KeywordsValue.php +++ b/src/Filter/Value/KeywordsValue.php @@ -19,7 +19,7 @@ * * Distinct from {@see ChoiceValue} by semantics, not structure (§4.3). */ -final readonly class KeywordsValue +final readonly class KeywordsValue implements ValueInterface { /** Non-empty, trimmed, with internal whitespace runs collapsed to a single space. */ public string $keywords; diff --git a/src/Filter/Value/ParentRefValue.php b/src/Filter/Value/ParentRefValue.php index 9be87ee2..8e32a430 100644 --- a/src/Filter/Value/ParentRefValue.php +++ b/src/Filter/Value/ParentRefValue.php @@ -25,7 +25,7 @@ * decide which rows match (§3.4, §4.1) while creating a second representation of "nothing * selected" (§9). {@see tryFrom()} is the guard that keeps an empty instance from existing. */ -final readonly class ParentRefValue +final readonly class ParentRefValue implements ValueInterface { /** * Parent ids grouped by parent table. Tables sorted by name, ids sorted ascending, diff --git a/src/Filter/Value/ValueInterface.php b/src/Filter/Value/ValueInterface.php new file mode 100644 index 00000000..7a337939 --- /dev/null +++ b/src/Filter/Value/ValueInterface.php @@ -0,0 +1,7 @@ +add(IntegerIdChoiceFilterType::class, [ + $builder->add(IntegerIdChoiceFilterLogic::class, [ 'field' => 'id', 'ids' => $tagIds, ]); diff --git a/src/List/Collector/ListModelFilterCollector.php b/src/List/Collector/ListModelFilterCollector.php index 8396b5f5..2edcbaea 100644 --- a/src/List/Collector/ListModelFilterCollector.php +++ b/src/List/Collector/ListModelFilterCollector.php @@ -30,6 +30,7 @@ public function __construct( /** * @return array|null + * @throws FlareException */ public function collect(ListModel $listModel): ?array { @@ -38,7 +39,7 @@ public function collect(ListModel $listModel): ?array } if (!$this->listDriverRegistry->getService((string) $listModel->type)) { - return null; + throw new FlareException('No list driver found for type "' . $listModel->type . '"'); } Controller::loadDataContainer($table); @@ -60,7 +61,7 @@ public function collect(ListModel $listModel): ?array catch (FlareException $e) { $this->logger->warning(\sprintf( - '[FLARE] Error while creating Filter of type "%s" on [%s.%s] -- [Message] %e', + '[FLARE] Error while creating Filter of type "%s" on [%s.%s] -- [Message] %s', $model->getFilterElementType(), $listModel::getTable(), $listModel->id, diff --git a/src/Query/Executor/FilterExecutor.php b/src/Query/Executor/FilterExecutor.php index c6f3549b..8ab574ff 100644 --- a/src/Query/Executor/FilterExecutor.php +++ b/src/Query/Executor/FilterExecutor.php @@ -16,11 +16,11 @@ use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Query\Factory\FilterQueryBuilderFactory; -use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; +use HeimrichHannot\FlareBundle\Query\FilterConditionsBuilder; use HeimrichHannot\FlareBundle\Query\ListQueryConfig; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; -use HeimrichHannot\FlareBundle\Registry\FilterTypeRegistry; +use HeimrichHannot\FlareBundle\Registry\FilterLogicRegistry; use HeimrichHannot\FlareBundle\Util\Str; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -31,11 +31,11 @@ public function __construct( private FilterContextFactory $filterContextFactory, private FilterElementRegistry $filterElementRegistry, private FilterQueryBuilderFactory $filterQueryBuilderFactory, - private FilterTypeRegistry $filterTypeRegistry, + private FilterLogicRegistry $filterTypeRegistry, ) {} /** - * @return FilterQueryBuilder[] + * @return FilterConditionsBuilder[] * * @throws AbortFilteringException * @throws FilterException @@ -64,7 +64,7 @@ public function invokeFilters(ListQueryConfig $options): array } /** - * @return FilterQueryBuilder[] + * @return FilterConditionsBuilder[] * * @throws AbortFilteringException * @throws FilterException @@ -124,7 +124,7 @@ public function invokeFilter(Filter $filter, FilterContext $context, FilterData /** * @param FilterCall[] $calls - * @return FilterQueryBuilder[] + * @return FilterConditionsBuilder[] */ private function buildQueryBuilders(array $calls, Filter $filter): array { @@ -136,7 +136,7 @@ private function buildQueryBuilders(array $calls, Filter $filter): array try { - $call->type->buildQuery($filterQueryBuilder, $call->options); + $call->type->buildConditions($filterQueryBuilder, $call->options); } catch (AbortFilteringException $e) { diff --git a/src/Query/Executor/ListQueryDirector.php b/src/Query/Executor/ListQueryDirector.php index dfbfd53c..5e55865d 100644 --- a/src/Query/Executor/ListQueryDirector.php +++ b/src/Query/Executor/ListQueryDirector.php @@ -11,8 +11,8 @@ use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Query\Factory\ListExecutionContextFactory; use HeimrichHannot\FlareBundle\Query\Factory\QueryBuilderFactory; -use HeimrichHannot\FlareBundle\Query\FilterQuery; -use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; +use HeimrichHannot\FlareBundle\Query\FilterConditions; +use HeimrichHannot\FlareBundle\Query\FilterConditionsBuilder; use HeimrichHannot\FlareBundle\Query\ListQueryConfig; use Psr\Log\LoggerInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -71,8 +71,8 @@ public function createQueryBuilder(ListQueryConfig $config): ?QueryBuilder } /** - * @param FilterQueryBuilder[] $filterQueryBuilders - * @return FilterQuery[] + * @param FilterConditionsBuilder[] $filterQueryBuilders + * @return FilterConditions[] */ public function buildFilterQueries(array $filterQueryBuilders): array { diff --git a/src/Query/Factory/FilterQueryBuilderFactory.php b/src/Query/Factory/FilterQueryBuilderFactory.php index 9cecb9e5..4be0142d 100644 --- a/src/Query/Factory/FilterQueryBuilderFactory.php +++ b/src/Query/Factory/FilterQueryBuilderFactory.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\Query\Factory; use Doctrine\DBAL\Connection; -use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; +use HeimrichHannot\FlareBundle\Query\FilterConditionsBuilder; readonly class FilterQueryBuilderFactory { @@ -13,11 +13,11 @@ public function __construct( private Connection $connection, ) {} - public function create(string $alias): FilterQueryBuilder + public function create(string $alias): FilterConditionsBuilder { - return new FilterQueryBuilder( + return new FilterConditionsBuilder( connection: $this->connection, alias: $alias, ); } -} \ No newline at end of file +} diff --git a/src/Query/FilterQuery.php b/src/Query/FilterConditions.php similarity index 94% rename from src/Query/FilterQuery.php rename to src/Query/FilterConditions.php index 8c373351..8590b57b 100644 --- a/src/Query/FilterQuery.php +++ b/src/Query/FilterConditions.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\Query; -readonly class FilterQuery +readonly class FilterConditions { public function __construct( private string $targetAlias, @@ -32,4 +32,4 @@ public function getTypes(): array { return $this->types; } -} \ No newline at end of file +} diff --git a/src/Query/FilterQueryBuilder.php b/src/Query/FilterConditionsBuilder.php similarity index 96% rename from src/Query/FilterQueryBuilder.php rename to src/Query/FilterConditionsBuilder.php index 16243c56..3c6dc589 100644 --- a/src/Query/FilterQueryBuilder.php +++ b/src/Query/FilterConditionsBuilder.php @@ -13,7 +13,7 @@ use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Util\SqlHelper; -class FilterQueryBuilder +class FilterConditionsBuilder { private ExpressionBuilder $expr; private array $conditions = []; @@ -237,19 +237,19 @@ public function setParameters(array $parameters): void } } - public function build(?string $prefix): FilterQuery + public function build(?string $prefix): FilterConditions { $alias = $this->alias(); if (!$this->conditions) { - return new FilterQuery($alias, '', [], []); + return new FilterConditions($alias, '', [], []); } $cond = $this->expr()->and(...$this->conditions); $sql = (string) $cond; if ($prefix === null) { - return new FilterQuery($alias, $sql, $this->parameters, $this->types); + return new FilterConditions($alias, $sql, $this->parameters, $this->types); } if (!\preg_match('/^[a-zA-Z0-9_]+$/', $prefix)) { @@ -283,6 +283,6 @@ function (array $matches) use ($prefix, &$parameters, &$types): string $sql, ); - return new FilterQuery($alias, $sql, $parameters, $types); + return new FilterConditions($alias, $sql, $parameters, $types); } } diff --git a/src/Registry/FilterTypeRegistry.php b/src/Registry/FilterLogicRegistry.php similarity index 60% rename from src/Registry/FilterTypeRegistry.php rename to src/Registry/FilterLogicRegistry.php index 0d288ce2..80579a0a 100644 --- a/src/Registry/FilterTypeRegistry.php +++ b/src/Registry/FilterLogicRegistry.php @@ -4,31 +4,31 @@ namespace HeimrichHannot\FlareBundle\Registry; -use HeimrichHannot\FlareBundle\Filter\Type\FilterTypeInterface; +use HeimrichHannot\FlareBundle\Filter\Logic\FilterLogicInterface; use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; -class FilterTypeRegistry +class FilterLogicRegistry { /** - * @var array, FilterTypeInterface> + * @var array, FilterLogicInterface> */ private array $types; public function __construct( - #[TaggedIterator(FilterTypeInterface::FLARE_FILTER_TYPE_TAG)] + #[TaggedIterator(FilterLogicInterface::FLARE_FILTER_LOGIC_TAG)] private readonly iterable $filterTypes, ) {} /** - * @param class-string $class + * @param class-string $class */ - public function get(string $class): ?FilterTypeInterface + public function get(string $class): ?FilterLogicInterface { return $this->resolve()[$class] ?? null; } /** - * @return array, FilterTypeInterface> + * @return array, FilterLogicInterface> */ public function all(): array { @@ -41,12 +41,12 @@ private function resolve(): array $this->types = []; foreach ($this->filterTypes as $filterType) { - if (!$filterType instanceof FilterTypeInterface) { + if (!$filterType instanceof FilterLogicInterface) { throw new \LogicException(\sprintf( 'Service "%s" is tagged "%s" but does not implement %s.', $filterType::class, - FilterTypeInterface::FLARE_FILTER_TYPE_TAG, - FilterTypeInterface::class, + FilterLogicInterface::FLARE_FILTER_LOGIC_TAG, + FilterLogicInterface::class, )); } @@ -56,4 +56,4 @@ private function resolve(): array return $this->types; } -} \ No newline at end of file +} diff --git a/tests/Filter/FilterBuilderTest.php b/tests/Filter/FilterBuilderTest.php index cabe4fca..5bfe8182 100644 --- a/tests/Filter/FilterBuilderTest.php +++ b/tests/Filter/FilterBuilderTest.php @@ -7,9 +7,9 @@ use HeimrichHannot\FlareBundle\Exception\AbortFilteringException; use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Filter\FilterBuilder; -use HeimrichHannot\FlareBundle\Filter\Type\AbstractFilterType; -use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; -use HeimrichHannot\FlareBundle\Registry\FilterTypeRegistry; +use HeimrichHannot\FlareBundle\Filter\Logic\AbstractFilterLogic; +use HeimrichHannot\FlareBundle\Query\FilterConditionsBuilder; +use HeimrichHannot\FlareBundle\Registry\FilterLogicRegistry; use PHPUnit\Framework\TestCase; use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -18,24 +18,24 @@ final class FilterBuilderTest extends TestCase { public function testRegistryLooksUpFilterTypesByClassName(): void { - $type = new TestFilterType(); - $registry = new FilterTypeRegistry([$type]); + $type = new TestFilterLogic(); + $registry = new FilterLogicRegistry([$type]); - self::assertSame($type, $registry->get(TestFilterType::class)); - self::assertSame([TestFilterType::class => $type], $registry->all()); - self::assertNull($registry->get(UnknownFilterType::class)); + self::assertSame($type, $registry->get(TestFilterLogic::class)); + self::assertSame([TestFilterLogic::class => $type], $registry->all()); + self::assertNull($registry->get(UnknownFilterLogic::class)); } public function testBuilderResolvesOptionsAndRecordsTargetedCalls(): void { $builder = new FilterBuilder( - new FilterTypeRegistry([new TestFilterType()]), + new FilterLogicRegistry([new TestFilterLogic()]), 'main', ); $builder - ->add(TestFilterType::class, ['value' => 'first']) - ->add(TestFilterType::class, ['value' => 'second', 'enabled' => true], 'translation'); + ->add(TestFilterLogic::class, ['value' => 'first']) + ->add(TestFilterLogic::class, ['value' => 'second', 'enabled' => true], 'translation'); $calls = $builder->all(); @@ -50,33 +50,33 @@ public function testBuilderResolvesOptionsAndRecordsTargetedCalls(): void public function testBuilderRejectsUnknownFilterTypes(): void { - $builder = new FilterBuilder(new FilterTypeRegistry([]), 'main'); + $builder = new FilterBuilder(new FilterLogicRegistry([]), 'main'); $this->expectException(FilterException::class); - $builder->add(TestFilterType::class, ['value' => 'test']); + $builder->add(TestFilterLogic::class, ['value' => 'test']); } public function testBuilderLetsOptionsResolverValidateRequiredOptions(): void { $builder = new FilterBuilder( - new FilterTypeRegistry([new TestFilterType()]), + new FilterLogicRegistry([new TestFilterLogic()]), 'main', ); $this->expectException(MissingOptionsException::class); - $builder->add(TestFilterType::class); + $builder->add(TestFilterLogic::class); } public function testBuilderAbortThrowsAbortFilteringException(): void { - $builder = new FilterBuilder(new FilterTypeRegistry([]), 'main'); + $builder = new FilterBuilder(new FilterLogicRegistry([]), 'main'); $this->expectException(AbortFilteringException::class); $builder->abort(); } } -final class TestFilterType extends AbstractFilterType +final class TestFilterLogic extends AbstractFilterLogic { public function configureOptions(OptionsResolver $resolver): void { @@ -84,14 +84,14 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->define('enabled')->default(false)->allowedTypes('bool'); } - public function buildQuery(FilterQueryBuilder $builder, array $options): void + public function buildConditions(FilterConditionsBuilder $builder, array $options): void { } } -final class UnknownFilterType extends AbstractFilterType +final class UnknownFilterLogic extends AbstractFilterLogic { - public function buildQuery(FilterQueryBuilder $builder, array $options): void + public function buildConditions(FilterConditionsBuilder $builder, array $options): void { } } diff --git a/tests/Query/Executor/FilterExecutorTest.php b/tests/Query/Executor/FilterExecutorTest.php index a253fd33..f653b847 100644 --- a/tests/Query/Executor/FilterExecutorTest.php +++ b/tests/Query/Executor/FilterExecutorTest.php @@ -21,7 +21,7 @@ use HeimrichHannot\FlareBundle\Query\Factory\FilterQueryBuilderFactory; use HeimrichHannot\FlareBundle\Query\ListQueryConfig; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; -use HeimrichHannot\FlareBundle\Registry\FilterTypeRegistry; +use HeimrichHannot\FlareBundle\Registry\FilterLogicRegistry; use PHPUnit\Framework\TestCase; use Symfony\Component\EventDispatcher\EventDispatcher; @@ -39,7 +39,7 @@ private function createExecutor(): FilterExecutor filterContextFactory: new FilterContextFactory(new FilterOptionsResolver(new SchemaResolver())), filterElementRegistry: new FilterElementRegistry(), filterQueryBuilderFactory: new FilterQueryBuilderFactory($this->createMock(Connection::class)), - filterTypeRegistry: new FilterTypeRegistry([]), + filterTypeRegistry: new FilterLogicRegistry([]), ); } From 7bf470f9b531ff153ca23653c6d85c29ece089eb Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 8 Sep 2026 15:14:20 +0200 Subject: [PATCH 90/96] refactor!: rename `FilterBuilder` to `LogicSequencer` and `FilterLogic` to `Logic` Replaced all occurrences of `FilterBuilder` with `LogicSequencer` to improve conceptual clarity, aligning it with its purpose of sequencing logical operations. Similarly, renamed `FilterLogic` and related classes to `Logic` with corresponding updates in tests, events, registry mappings, and documentation. Includes necessary refactors in method and property names to reflect this change across the codebase. --- AGENTS.md | 2 +- src/Event/FilterElementBuildingEvent.php | 10 +++--- src/Event/FilterElementBuiltEvent.php | 8 ++--- src/Filter/Element/AbstractFilterElement.php | 4 +-- src/Filter/Element/ArchiveFilterElement.php | 12 +++---- .../BelongsToRelationFilterElement.php | 22 ++++++------ src/Filter/Element/BooleanFilterElement.php | 8 ++--- .../Element/CalendarCurrentFilterElement.php | 8 ++--- src/Filter/Element/DateRangeFilterElement.php | 8 ++--- .../Element/DcaSelectFieldFilterElement.php | 8 ++--- .../Element/FieldValueChoiceFilterElement.php | 8 ++--- src/Filter/Element/FilterElementInterface.php | 4 +-- src/Filter/Element/PublishedFilterElement.php | 8 ++--- .../Element/SearchKeywordsFilterElement.php | 8 ++--- .../Element/SimpleEquationFilterElement.php | 8 ++--- src/Filter/FilterCall.php | 17 --------- ...tractFilterLogic.php => AbstractLogic.php} | 2 +- ...rchiveFilterLogic.php => ArchiveLogic.php} | 2 +- ...erLogic.php => BelongsToRelationLogic.php} | 2 +- ...ooleanFilterLogic.php => BooleanLogic.php} | 2 +- ...lterLogic.php => CalendarCurrentLogic.php} | 2 +- ...angeFilterLogic.php => DateRangeLogic.php} | 2 +- ...lectFilterLogic.php => DcaSelectLogic.php} | 2 +- ...terLogic.php => FieldValueChoiceLogic.php} | 2 +- ...lterLogic.php => IntegerIdChoiceLogic.php} | 2 +- ...rLogicInterface.php => LogicInterface.php} | 2 +- ...shedFilterLogic.php => PublishedLogic.php} | 2 +- ...ilterLogic.php => SearchKeywordsLogic.php} | 2 +- ...ilterLogic.php => SimpleEquationLogic.php} | 2 +- .../{FilterBuilder.php => LogicSequencer.php} | 19 +++++----- ...erface.php => LogicSequencerInterface.php} | 8 ++--- src/Filter/LogicStep.php | 17 +++++++++ .../CodefogTagsChoiceFilterElement.php | 8 ++--- src/Query/Executor/FilterExecutor.php | 10 +++--- src/Registry/FilterLogicRegistry.php | 18 +++++----- .../Projector/InteractiveProjectorTest.php | 10 +++--- tests/Filter/FilterBuilderTest.php | 36 +++++++++---------- tests/Filter/FilterFactoryTest.php | 10 +++--- tests/Filter/FilterOptionsResolverTest.php | 6 ++-- tests/Filter/FilterSetFactoryTest.php | 10 +++--- tests/Filter/FilterTest.php | 10 +++--- .../Filter/FilterTransformerResolverTest.php | 6 ++-- tests/List/ListSpecBuilderTest.php | 10 +++--- tests/List/ListSpecTest.php | 10 +++--- tests/List/StubFilterElement.php | 4 +-- tests/Query/Executor/FilterExecutorTest.php | 4 +-- tests/Registry/FilterElementRegistryTest.php | 4 +-- 47 files changed, 185 insertions(+), 184 deletions(-) delete mode 100644 src/Filter/FilterCall.php rename src/Filter/Logic/{AbstractFilterLogic.php => AbstractLogic.php} (85%) rename src/Filter/Logic/{ArchiveFilterLogic.php => ArchiveLogic.php} (95%) rename src/Filter/Logic/{BelongsToRelationFilterLogic.php => BelongsToRelationLogic.php} (97%) rename src/Filter/Logic/{BooleanFilterLogic.php => BooleanLogic.php} (93%) rename src/Filter/Logic/{CalendarCurrentFilterLogic.php => CalendarCurrentLogic.php} (96%) rename src/Filter/Logic/{DateRangeFilterLogic.php => DateRangeLogic.php} (95%) rename src/Filter/Logic/{DcaSelectFilterLogic.php => DcaSelectLogic.php} (97%) rename src/Filter/Logic/{FieldValueChoiceFilterLogic.php => FieldValueChoiceLogic.php} (94%) rename src/Filter/Logic/{IntegerIdChoiceFilterLogic.php => IntegerIdChoiceLogic.php} (94%) rename src/Filter/Logic/{FilterLogicInterface.php => LogicInterface.php} (95%) rename src/Filter/Logic/{PublishedFilterLogic.php => PublishedLogic.php} (96%) rename src/Filter/Logic/{SearchKeywordsFilterLogic.php => SearchKeywordsLogic.php} (97%) rename src/Filter/Logic/{SimpleEquationFilterLogic.php => SimpleEquationLogic.php} (98%) rename src/Filter/{FilterBuilder.php => LogicSequencer.php} (81%) rename src/Filter/{FilterBuilderInterface.php => LogicSequencerInterface.php} (64%) create mode 100644 src/Filter/LogicStep.php diff --git a/AGENTS.md b/AGENTS.md index 71f65b5e..6dc98b69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ The bundle follows standard Symfony Bundle architecture with deep Contao integra **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`). +(`buildDca`, `buildForm`, `buildLogic`, `buildList`, `buildTableRegistry`/`buildBaseQuery`). **Notable subsystems** (beyond the flow above): - `src/List/` — `ListSpec` (immutable list DTO: type, dc, filters, canonical config, source), `ListBuilder` diff --git a/src/Event/FilterElementBuildingEvent.php b/src/Event/FilterElementBuildingEvent.php index 51431340..8b35bd96 100644 --- a/src/Event/FilterElementBuildingEvent.php +++ b/src/Event/FilterElementBuildingEvent.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\Event; -use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use Symfony\Contracts\EventDispatcher\Event; @@ -12,9 +12,9 @@ class FilterElementBuildingEvent extends Event { public function __construct( - public readonly FilterContext $context, - public readonly FilterBuilderInterface $builder, - public readonly FilterData $data, - public bool $shouldBuild = true, + public readonly FilterContext $context, + public readonly LogicSequencerInterface $builder, + public readonly FilterData $data, + public bool $shouldBuild = true, ) {} } diff --git a/src/Event/FilterElementBuiltEvent.php b/src/Event/FilterElementBuiltEvent.php index 30fdfb46..2166051e 100644 --- a/src/Event/FilterElementBuiltEvent.php +++ b/src/Event/FilterElementBuiltEvent.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\Event; -use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use Symfony\Contracts\EventDispatcher\Event; @@ -12,8 +12,8 @@ class FilterElementBuiltEvent extends Event { public function __construct( - public readonly FilterContext $context, - public readonly FilterBuilderInterface $builder, - public readonly FilterData $data, + public readonly FilterContext $context, + public readonly LogicSequencerInterface $builder, + public readonly FilterData $data, ) {} } diff --git a/src/Filter/Element/AbstractFilterElement.php b/src/Filter/Element/AbstractFilterElement.php index 33c5db01..a2bed222 100644 --- a/src/Filter/Element/AbstractFilterElement.php +++ b/src/Filter/Element/AbstractFilterElement.php @@ -15,7 +15,7 @@ use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\Filter\CallbackFilterModelTransformer; -use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; @@ -51,7 +51,7 @@ public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void {} public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void {} + public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void {} public function isSupported(): bool { diff --git a/src/Filter/Element/ArchiveFilterElement.php b/src/Filter/Element/ArchiveFilterElement.php index 19d3d185..b0abc806 100644 --- a/src/Filter/Element/ArchiveFilterElement.php +++ b/src/Filter/Element/ArchiveFilterElement.php @@ -12,12 +12,12 @@ use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Exception\FilterException; -use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\Logic\ArchiveFilterLogic; -use HeimrichHannot\FlareBundle\Filter\Logic\BelongsToRelationFilterLogic; +use HeimrichHannot\FlareBundle\Filter\Logic\ArchiveLogic; +use HeimrichHannot\FlareBundle\Filter\Logic\BelongsToRelationLogic; use HeimrichHannot\FlareBundle\Form\ChoicesBuilder; use HeimrichHannot\FlareBundle\InferPtable\Factory\PtableInferrableFactory; use HeimrichHannot\FlareBundle\InferPtable\PtableInferrer; @@ -171,7 +171,7 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co /** * @throws FilterException */ - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void + public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void { $config = $context->config; @@ -197,7 +197,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont throw new FilterException('No valid parent archive ids extracted.', method: __METHOD__); } - $builder->add(ArchiveFilterLogic::class, [ + $builder->add(ArchiveLogic::class, [ 'field' => 'pid', 'parent_ids' => $pids, ]); @@ -225,7 +225,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont } } - $builder->add(BelongsToRelationFilterLogic::class, [ + $builder->add(BelongsToRelationLogic::class, [ 'field_pid' => 'pid', 'field_dynamic_ptable' => 'ptable', 'parent_groups' => $this->getDynamicParentGroups($config), diff --git a/src/Filter/Element/BelongsToRelationFilterElement.php b/src/Filter/Element/BelongsToRelationFilterElement.php index 75d31ef8..be55f365 100644 --- a/src/Filter/Element/BelongsToRelationFilterElement.php +++ b/src/Filter/Element/BelongsToRelationFilterElement.php @@ -12,10 +12,10 @@ 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\LogicSequencerInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; -use HeimrichHannot\FlareBundle\Filter\Logic\BelongsToRelationFilterLogic; +use HeimrichHannot\FlareBundle\Filter\Logic\BelongsToRelationLogic; use HeimrichHannot\FlareBundle\InferPtable\Factory\PtableInferrableFactory; use HeimrichHannot\FlareBundle\InferPtable\PtableInferrer; use HeimrichHannot\FlareBundle\Model\FilterModel; @@ -61,7 +61,7 @@ protected function transformFilterModel(ConfigBuilder $config, FilterModel $mode /** * @throws FilterException */ - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void + public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void { $config = $context->config; @@ -85,7 +85,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont if (\is_string($fieldDynamicPtable)) { - $builder->add(BelongsToRelationFilterLogic::class, [ + $builder->add(BelongsToRelationLogic::class, [ 'field_pid' => $fieldPid, 'field_dynamic_ptable' => $fieldDynamicPtable, 'parent_groups' => $this->getDynamicParentGroups($config['group_whitelist_parents']), @@ -98,7 +98,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont throw new FilterException('No whitelisted parents.'); } - $builder->add(BelongsToRelationFilterLogic::class, [ + $builder->add(BelongsToRelationLogic::class, [ 'field_pid' => $fieldPid, 'whitelist' => $whitelistParents, ]); @@ -117,13 +117,13 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont * `group_whitelist_parents` config key. */ public function addDynamicPtableFilter( - FilterBuilderInterface $builder, - array $groupWhitelistParents, - string $fieldDynamicPtable, - string $fieldPid, - ?array $submittedData = null, + LogicSequencerInterface $builder, + array $groupWhitelistParents, + string $fieldDynamicPtable, + string $fieldPid, + ?array $submittedData = null, ): void { - $builder->add(BelongsToRelationFilterLogic::class, [ + $builder->add(BelongsToRelationLogic::class, [ 'field_pid' => $fieldPid, 'field_dynamic_ptable' => $fieldDynamicPtable, 'parent_groups' => $this->getDynamicParentGroups($groupWhitelistParents), diff --git a/src/Filter/Element/BooleanFilterElement.php b/src/Filter/Element/BooleanFilterElement.php index 283aaa7b..0cedbe75 100644 --- a/src/Filter/Element/BooleanFilterElement.php +++ b/src/Filter/Element/BooleanFilterElement.php @@ -12,11 +12,11 @@ use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Enum\BoolBinaryChoices; use HeimrichHannot\FlareBundle\Enum\BoolMode; -use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\Logic\BooleanFilterLogic; +use HeimrichHannot\FlareBundle\Filter\Logic\BooleanLogic; use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -59,7 +59,7 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co ]); } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void + public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void { $config = $context->config; @@ -75,7 +75,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont return; } - $builder->add(BooleanFilterLogic::class, [ + $builder->add(BooleanLogic::class, [ 'field' => $targetField, 'value' => $value, ]); diff --git a/src/Filter/Element/CalendarCurrentFilterElement.php b/src/Filter/Element/CalendarCurrentFilterElement.php index 9410d780..e0b20458 100644 --- a/src/Filter/Element/CalendarCurrentFilterElement.php +++ b/src/Filter/Element/CalendarCurrentFilterElement.php @@ -9,11 +9,11 @@ use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Engine\Context\ValidationContext; -use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\Logic\CalendarCurrentFilterLogic; +use HeimrichHannot\FlareBundle\Filter\Logic\CalendarCurrentLogic; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Util\DateTimeHelper; use Symfony\Component\Form\Extension\Core\Type\DateType; @@ -96,7 +96,7 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co $builder->addEventListener(FormEvents::POST_SUBMIT, $this->validateRange(...)); } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void + public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void { $config = $context->config; @@ -129,7 +129,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont } } - $builder->add(CalendarCurrentFilterLogic::class, [ + $builder->add(CalendarCurrentLogic::class, [ 'start' => $start, 'stop' => $stop, 'has_extended_events' => $config['has_extended_events'], diff --git a/src/Filter/Element/DateRangeFilterElement.php b/src/Filter/Element/DateRangeFilterElement.php index 7123de4a..d9dacd3c 100644 --- a/src/Filter/Element/DateRangeFilterElement.php +++ b/src/Filter/Element/DateRangeFilterElement.php @@ -9,11 +9,11 @@ use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Exception\FilterException; -use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\Logic\DateRangeFilterLogic; +use HeimrichHannot\FlareBundle\Filter\Logic\DateRangeLogic; use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\Form\Extension\Core\Type\DateType; use Symfony\Component\Form\FormError; @@ -76,13 +76,13 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co /** * @throws FilterException */ - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void + public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void { if (!$field = $context->config['field']) { throw new FilterException('Set fieldGeneric in filter model.'); } - $builder->add(DateRangeFilterLogic::class, [ + $builder->add(DateRangeLogic::class, [ 'field' => $field, 'from' => $data->get('from'), 'to' => $data->get('to'), diff --git a/src/Filter/Element/DcaSelectFieldFilterElement.php b/src/Filter/Element/DcaSelectFieldFilterElement.php index 1a5118d3..2299f854 100644 --- a/src/Filter/Element/DcaSelectFieldFilterElement.php +++ b/src/Filter/Element/DcaSelectFieldFilterElement.php @@ -12,11 +12,11 @@ use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; -use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\Logic\DcaSelectFilterLogic; +use HeimrichHannot\FlareBundle\Filter\Logic\DcaSelectLogic; use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -96,7 +96,7 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co $builder->single(ChoiceType::class, $formOptions); } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void + public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void { $config = $context->config; $options = $this->getOptions($context->list->dc, $config['field']) ?? []; @@ -124,7 +124,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont $dcaOptionsField = $this->getOptionsField($context->list->dc, $config['field']) ?? []; $isMultiple = $dcaOptionsField['eval']['multiple'] ?? false; - $builder->add(DcaSelectFilterLogic::class, [ + $builder->add(DcaSelectLogic::class, [ 'field' => $targetField, 'selected' => $selected, 'valid_options' => $options, diff --git a/src/Filter/Element/FieldValueChoiceFilterElement.php b/src/Filter/Element/FieldValueChoiceFilterElement.php index 7d0adf86..dff7f054 100644 --- a/src/Filter/Element/FieldValueChoiceFilterElement.php +++ b/src/Filter/Element/FieldValueChoiceFilterElement.php @@ -13,11 +13,11 @@ use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Engine\Context\ValidationContext; -use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\Logic\FieldValueChoiceFilterLogic; +use HeimrichHannot\FlareBundle\Filter\Logic\FieldValueChoiceLogic; use HeimrichHannot\FlareBundle\Form\ChoicesBuilder; use HeimrichHannot\FlareBundle\Form\Factory\ChoicesBuilderFactory; use HeimrichHannot\FlareBundle\Model\FilterModel; @@ -84,7 +84,7 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co $builder->setAttribute('flare.choices_builder', $choicesBuilder); } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void + public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void { if ($context->engineContext instanceof ValidationContext) { return; @@ -104,7 +104,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont return; } - $builder->add(FieldValueChoiceFilterLogic::class, [ + $builder->add(FieldValueChoiceLogic::class, [ 'field' => $field, 'values' => $value, ]); diff --git a/src/Filter/Element/FilterElementInterface.php b/src/Filter/Element/FilterElementInterface.php index 1f53435c..65b6a59e 100644 --- a/src/Filter/Element/FilterElementInterface.php +++ b/src/Filter/Element/FilterElementInterface.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\Filter\Element; -use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; @@ -33,5 +33,5 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co * for a single() field — or the programmatically set {@see \HeimrichHannot\FlareBundle\Filter\Filter::$data}; * {@see FilterData::none()} when neither exists (e.g. non-interactive contexts). */ - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void; + public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void; } diff --git a/src/Filter/Element/PublishedFilterElement.php b/src/Filter/Element/PublishedFilterElement.php index 6dd958e0..685c1d50 100644 --- a/src/Filter/Element/PublishedFilterElement.php +++ b/src/Filter/Element/PublishedFilterElement.php @@ -8,10 +8,10 @@ use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; -use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; -use HeimrichHannot\FlareBundle\Filter\Logic\PublishedFilterLogic; +use HeimrichHannot\FlareBundle\Filter\Logic\PublishedLogic; use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -51,11 +51,11 @@ protected function transformFilterModel(ConfigBuilder $config, FilterModel $mode ->set('invert', (bool) $model->invertPublished); } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void + public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void { $config = $context->config; - $builder->add(PublishedFilterLogic::class, [ + $builder->add(PublishedLogic::class, [ 'published_field' => $config['published_field'], 'start_field' => $config['start_field'], 'stop_field' => $config['stop_field'], diff --git a/src/Filter/Element/SearchKeywordsFilterElement.php b/src/Filter/Element/SearchKeywordsFilterElement.php index b42af2fa..3d192c55 100644 --- a/src/Filter/Element/SearchKeywordsFilterElement.php +++ b/src/Filter/Element/SearchKeywordsFilterElement.php @@ -9,11 +9,11 @@ use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; -use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\Logic\SearchKeywordsFilterLogic; +use HeimrichHannot\FlareBundle\Filter\Logic\SearchKeywordsLogic; use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -62,7 +62,7 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co $builder->single(TextType::class, $options); } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void + public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void { $config = $context->config; @@ -78,7 +78,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont return; } - $builder->add(SearchKeywordsFilterLogic::class, [ + $builder->add(SearchKeywordsLogic::class, [ 'value' => $value, 'columns' => $columns, ]); diff --git a/src/Filter/Element/SimpleEquationFilterElement.php b/src/Filter/Element/SimpleEquationFilterElement.php index 5f9b3f42..778fb867 100644 --- a/src/Filter/Element/SimpleEquationFilterElement.php +++ b/src/Filter/Element/SimpleEquationFilterElement.php @@ -10,10 +10,10 @@ use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Enum\SqlEquationOperator; use HeimrichHannot\FlareBundle\Exception\FilterException; -use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; -use HeimrichHannot\FlareBundle\Filter\Logic\SimpleEquationFilterLogic; +use HeimrichHannot\FlareBundle\Filter\Logic\SimpleEquationLogic; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Util\DcaHelper; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -48,7 +48,7 @@ protected function transformFilterModel(ConfigBuilder $config, FilterModel $mode /** * @throws FilterException */ - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void + public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void { $config = $context->config; @@ -56,7 +56,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont throw new FilterException('Invalid filter configuration.'); } - $builder->add(SimpleEquationFilterLogic::class, [ + $builder->add(SimpleEquationLogic::class, [ 'operand_left' => $operand, 'operator' => $op, 'operand_right' => $config['right'], diff --git a/src/Filter/FilterCall.php b/src/Filter/FilterCall.php deleted file mode 100644 index 64d605d1..00000000 --- a/src/Filter/FilterCall.php +++ /dev/null @@ -1,17 +0,0 @@ -, OptionsResolver> + * @var array, OptionsResolver> */ private static array $optionsResolvers = []; /** - * @var FilterCall[] + * @var LogicStep[] */ - private array $calls = []; + private array $steps = []; public function __construct( private readonly FilterLogicRegistry $filterTypeRegistry, @@ -28,14 +28,15 @@ public function __construct( ) {} /** - * @param class-string $type + * @param class-string $type * @param array $options * * @throws FilterException */ public function add(string $type, array $options = [], ?string $targetAlias = null): static { - if (!$filterType = $this->filterTypeRegistry->get($type)) { + if (!$filterType = $this->filterTypeRegistry->get($type)) + { throw new FilterException( \sprintf('No FLARE filter type service registered for "%s".', $type), method: __METHOD__, @@ -49,7 +50,7 @@ public function add(string $type, array $options = [], ?string $targetAlias = nu self::$optionsResolvers[$type] = $resolver; } - $this->calls[] = new FilterCall( + $this->steps[] = new LogicStep( type: $filterType, typeClass: $type, targetAlias: $targetAlias ?: $this->defaultTargetAlias, @@ -61,7 +62,7 @@ public function add(string $type, array $options = [], ?string $targetAlias = nu public function all(): array { - return $this->calls; + return $this->steps; } public function abort(): never diff --git a/src/Filter/FilterBuilderInterface.php b/src/Filter/LogicSequencerInterface.php similarity index 64% rename from src/Filter/FilterBuilderInterface.php rename to src/Filter/LogicSequencerInterface.php index c8523b71..c87dc8ce 100644 --- a/src/Filter/FilterBuilderInterface.php +++ b/src/Filter/LogicSequencerInterface.php @@ -4,18 +4,18 @@ namespace HeimrichHannot\FlareBundle\Filter; -use HeimrichHannot\FlareBundle\Filter\Logic\FilterLogicInterface; +use HeimrichHannot\FlareBundle\Filter\Logic\LogicInterface; -interface FilterBuilderInterface +interface LogicSequencerInterface { /** - * @param class-string $type + * @param class-string $type * @param array $options */ public function add(string $type, array $options = [], ?string $targetAlias = null): static; /** - * @return FilterCall[] + * @return LogicStep[] */ public function all(): array; diff --git a/src/Filter/LogicStep.php b/src/Filter/LogicStep.php new file mode 100644 index 00000000..0be5149e --- /dev/null +++ b/src/Filter/LogicStep.php @@ -0,0 +1,17 @@ +single(ChoiceType::class, $formOptions); } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void + public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void { $config = $context->config; @@ -119,7 +119,7 @@ public function buildFilter(FilterBuilderInterface $builder, FilterContext $cont return; } - $builder->add(IntegerIdChoiceFilterLogic::class, [ + $builder->add(IntegerIdChoiceLogic::class, [ 'field' => 'id', 'ids' => $tagIds, ]); diff --git a/src/Query/Executor/FilterExecutor.php b/src/Query/Executor/FilterExecutor.php index 8ab574ff..3d756e52 100644 --- a/src/Query/Executor/FilterExecutor.php +++ b/src/Query/Executor/FilterExecutor.php @@ -11,8 +11,8 @@ 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\LogicSequencer; +use HeimrichHannot\FlareBundle\Filter\LogicStep; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Query\Factory\FilterQueryBuilderFactory; @@ -87,7 +87,7 @@ public function invokeFilter(Filter $filter, FilterContext $context, FilterData $targetAlias = $filter->targetAlias ?: TableAliasRegistry::ALIAS_MAIN; } - $builder = new FilterBuilder($this->filterTypeRegistry, $targetAlias); + $builder = new LogicSequencer($this->filterTypeRegistry, $targetAlias); $event = $this->eventDispatcher->dispatch(new FilterElementBuildingEvent( context: $context, @@ -101,7 +101,7 @@ public function invokeFilter(Filter $filter, FilterContext $context, FilterData try { - $filter->element->buildFilter($builder, $context, $data); + $filter->element->buildLogic($builder, $context, $data); } catch (AbortFilteringException $e) { @@ -123,7 +123,7 @@ public function invokeFilter(Filter $filter, FilterContext $context, FilterData } /** - * @param FilterCall[] $calls + * @param LogicStep[] $calls * @return FilterConditionsBuilder[] */ private function buildQueryBuilders(array $calls, Filter $filter): array diff --git a/src/Registry/FilterLogicRegistry.php b/src/Registry/FilterLogicRegistry.php index 80579a0a..3d1fe9cf 100644 --- a/src/Registry/FilterLogicRegistry.php +++ b/src/Registry/FilterLogicRegistry.php @@ -4,31 +4,31 @@ namespace HeimrichHannot\FlareBundle\Registry; -use HeimrichHannot\FlareBundle\Filter\Logic\FilterLogicInterface; +use HeimrichHannot\FlareBundle\Filter\Logic\LogicInterface; use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; class FilterLogicRegistry { /** - * @var array, FilterLogicInterface> + * @var array, LogicInterface> */ private array $types; public function __construct( - #[TaggedIterator(FilterLogicInterface::FLARE_FILTER_LOGIC_TAG)] + #[TaggedIterator(LogicInterface::FLARE_FILTER_LOGIC_TAG)] private readonly iterable $filterTypes, ) {} /** - * @param class-string $class + * @param class-string $class */ - public function get(string $class): ?FilterLogicInterface + public function get(string $class): ?LogicInterface { return $this->resolve()[$class] ?? null; } /** - * @return array, FilterLogicInterface> + * @return array, LogicInterface> */ public function all(): array { @@ -41,12 +41,12 @@ private function resolve(): array $this->types = []; foreach ($this->filterTypes as $filterType) { - if (!$filterType instanceof FilterLogicInterface) { + if (!$filterType instanceof LogicInterface) { throw new \LogicException(\sprintf( 'Service "%s" is tagged "%s" but does not implement %s.', $filterType::class, - FilterLogicInterface::FLARE_FILTER_LOGIC_TAG, - FilterLogicInterface::class, + LogicInterface::FLARE_FILTER_LOGIC_TAG, + LogicInterface::class, )); } diff --git a/tests/Engine/Projector/InteractiveProjectorTest.php b/tests/Engine/Projector/InteractiveProjectorTest.php index 8af77238..eb9fd21b 100644 --- a/tests/Engine/Projector/InteractiveProjectorTest.php +++ b/tests/Engine/Projector/InteractiveProjectorTest.php @@ -7,7 +7,7 @@ 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\LogicSequencerInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; @@ -66,10 +66,10 @@ public function resolveDcTable(string $type, array $config, array $attributes): $element = new class implements FilterElementInterface { public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} - public function buildFilter( - FilterBuilderInterface $builder, - FilterContext $context, - FilterData $data, + public function buildLogic( + LogicSequencerInterface $builder, + FilterContext $context, + FilterData $data, ): void {} }; diff --git a/tests/Filter/FilterBuilderTest.php b/tests/Filter/FilterBuilderTest.php index 5bfe8182..3cf2c65a 100644 --- a/tests/Filter/FilterBuilderTest.php +++ b/tests/Filter/FilterBuilderTest.php @@ -6,8 +6,8 @@ use HeimrichHannot\FlareBundle\Exception\AbortFilteringException; use HeimrichHannot\FlareBundle\Exception\FilterException; -use HeimrichHannot\FlareBundle\Filter\FilterBuilder; -use HeimrichHannot\FlareBundle\Filter\Logic\AbstractFilterLogic; +use HeimrichHannot\FlareBundle\Filter\LogicSequencer; +use HeimrichHannot\FlareBundle\Filter\Logic\AbstractLogic; use HeimrichHannot\FlareBundle\Query\FilterConditionsBuilder; use HeimrichHannot\FlareBundle\Registry\FilterLogicRegistry; use PHPUnit\Framework\TestCase; @@ -18,24 +18,24 @@ final class FilterBuilderTest extends TestCase { public function testRegistryLooksUpFilterTypesByClassName(): void { - $type = new TestFilterLogic(); + $type = new TestLogic(); $registry = new FilterLogicRegistry([$type]); - self::assertSame($type, $registry->get(TestFilterLogic::class)); - self::assertSame([TestFilterLogic::class => $type], $registry->all()); - self::assertNull($registry->get(UnknownFilterLogic::class)); + self::assertSame($type, $registry->get(TestLogic::class)); + self::assertSame([TestLogic::class => $type], $registry->all()); + self::assertNull($registry->get(UnknownLogic::class)); } public function testBuilderResolvesOptionsAndRecordsTargetedCalls(): void { - $builder = new FilterBuilder( - new FilterLogicRegistry([new TestFilterLogic()]), + $builder = new LogicSequencer( + new FilterLogicRegistry([new TestLogic()]), 'main', ); $builder - ->add(TestFilterLogic::class, ['value' => 'first']) - ->add(TestFilterLogic::class, ['value' => 'second', 'enabled' => true], 'translation'); + ->add(TestLogic::class, ['value' => 'first']) + ->add(TestLogic::class, ['value' => 'second', 'enabled' => true], 'translation'); $calls = $builder->all(); @@ -50,33 +50,33 @@ public function testBuilderResolvesOptionsAndRecordsTargetedCalls(): void public function testBuilderRejectsUnknownFilterTypes(): void { - $builder = new FilterBuilder(new FilterLogicRegistry([]), 'main'); + $builder = new LogicSequencer(new FilterLogicRegistry([]), 'main'); $this->expectException(FilterException::class); - $builder->add(TestFilterLogic::class, ['value' => 'test']); + $builder->add(TestLogic::class, ['value' => 'test']); } public function testBuilderLetsOptionsResolverValidateRequiredOptions(): void { - $builder = new FilterBuilder( - new FilterLogicRegistry([new TestFilterLogic()]), + $builder = new LogicSequencer( + new FilterLogicRegistry([new TestLogic()]), 'main', ); $this->expectException(MissingOptionsException::class); - $builder->add(TestFilterLogic::class); + $builder->add(TestLogic::class); } public function testBuilderAbortThrowsAbortFilteringException(): void { - $builder = new FilterBuilder(new FilterLogicRegistry([]), 'main'); + $builder = new LogicSequencer(new FilterLogicRegistry([]), 'main'); $this->expectException(AbortFilteringException::class); $builder->abort(); } } -final class TestFilterLogic extends AbstractFilterLogic +final class TestLogic extends AbstractLogic { public function configureOptions(OptionsResolver $resolver): void { @@ -89,7 +89,7 @@ public function buildConditions(FilterConditionsBuilder $builder, array $options } } -final class UnknownFilterLogic extends AbstractFilterLogic +final class UnknownLogic extends AbstractLogic { public function buildConditions(FilterConditionsBuilder $builder, array $options): void { diff --git a/tests/Filter/FilterFactoryTest.php b/tests/Filter/FilterFactoryTest.php index f0016705..854c33ce 100644 --- a/tests/Filter/FilterFactoryTest.php +++ b/tests/Filter/FilterFactoryTest.php @@ -7,7 +7,7 @@ use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Factory\FilterFactory; -use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; @@ -31,10 +31,10 @@ private static function element(): FilterElementInterface return new class implements FilterElementInterface { public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} - public function buildFilter( - FilterBuilderInterface $builder, - FilterContext $context, - FilterData $data, + public function buildLogic( + LogicSequencerInterface $builder, + FilterContext $context, + FilterData $data, ): void {} }; } diff --git a/tests/Filter/FilterOptionsResolverTest.php b/tests/Filter/FilterOptionsResolverTest.php index ade48d73..652c02df 100644 --- a/tests/Filter/FilterOptionsResolverTest.php +++ b/tests/Filter/FilterOptionsResolverTest.php @@ -9,7 +9,7 @@ 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\LogicSequencerInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterOptionsResolver; @@ -71,7 +71,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) { } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void + public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void { } } @@ -82,7 +82,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) { } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void + public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void { } } diff --git a/tests/Filter/FilterSetFactoryTest.php b/tests/Filter/FilterSetFactoryTest.php index 9e8fac58..a0a39e7c 100644 --- a/tests/Filter/FilterSetFactoryTest.php +++ b/tests/Filter/FilterSetFactoryTest.php @@ -14,7 +14,7 @@ use HeimrichHannot\FlareBundle\Filter\Factory\FilterContextFactory; use HeimrichHannot\FlareBundle\Filter\Factory\FilterSetFactory; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; @@ -119,10 +119,10 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co ($this->buildForm)($builder, $context); } - public function buildFilter( - FilterBuilderInterface $builder, - FilterContext $context, - FilterData $data, + public function buildLogic( + LogicSequencerInterface $builder, + FilterContext $context, + FilterData $data, ): void {} }; } diff --git a/tests/Filter/FilterTest.php b/tests/Filter/FilterTest.php index ec5e40a5..7eda73f7 100644 --- a/tests/Filter/FilterTest.php +++ b/tests/Filter/FilterTest.php @@ -6,7 +6,7 @@ use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; @@ -21,10 +21,10 @@ private static function element(): FilterElementInterface return $element ??= new class implements FilterElementInterface { public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} - public function buildFilter( - FilterBuilderInterface $builder, - FilterContext $context, - FilterData $data, + public function buildLogic( + LogicSequencerInterface $builder, + FilterContext $context, + FilterData $data, ): void {} }; } diff --git a/tests/Filter/FilterTransformerResolverTest.php b/tests/Filter/FilterTransformerResolverTest.php index e8953faf..a80ee675 100644 --- a/tests/Filter/FilterTransformerResolverTest.php +++ b/tests/Filter/FilterTransformerResolverTest.php @@ -9,7 +9,7 @@ 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\LogicSequencerInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterTransformerResolver; @@ -98,7 +98,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) { } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void + public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void { } } @@ -109,7 +109,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) { } - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void + public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void { } } diff --git a/tests/List/ListSpecBuilderTest.php b/tests/List/ListSpecBuilderTest.php index 8522a9d2..c8a75d06 100644 --- a/tests/List/ListSpecBuilderTest.php +++ b/tests/List/ListSpecBuilderTest.php @@ -11,7 +11,7 @@ use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; @@ -36,10 +36,10 @@ public static function filter(string $type, ?string $alias = null): Filter $element ??= new class implements FilterElementInterface { public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} - public function buildFilter( - FilterBuilderInterface $builder, - FilterContext $context, - FilterData $data, + public function buildLogic( + LogicSequencerInterface $builder, + FilterContext $context, + FilterData $data, ): void {} }; diff --git a/tests/List/ListSpecTest.php b/tests/List/ListSpecTest.php index 6c9c8d93..3ddd7987 100644 --- a/tests/List/ListSpecTest.php +++ b/tests/List/ListSpecTest.php @@ -7,7 +7,7 @@ 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\LogicSequencerInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; @@ -36,10 +36,10 @@ private static function filter(string $type, ?string $alias = null): Filter $element ??= new class implements FilterElementInterface { public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} - public function buildFilter( - FilterBuilderInterface $builder, - FilterContext $context, - FilterData $data, + public function buildLogic( + LogicSequencerInterface $builder, + FilterContext $context, + FilterData $data, ): void {} }; diff --git a/tests/List/StubFilterElement.php b/tests/List/StubFilterElement.php index fb2979c1..3db2ceca 100644 --- a/tests/List/StubFilterElement.php +++ b/tests/List/StubFilterElement.php @@ -5,7 +5,7 @@ namespace HeimrichHannot\FlareBundle\Tests\List; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; -use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; @@ -14,5 +14,5 @@ class StubFilterElement implements FilterElementInterface { public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void {} + public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void {} } diff --git a/tests/Query/Executor/FilterExecutorTest.php b/tests/Query/Executor/FilterExecutorTest.php index f653b847..17d4a09b 100644 --- a/tests/Query/Executor/FilterExecutorTest.php +++ b/tests/Query/Executor/FilterExecutorTest.php @@ -10,7 +10,7 @@ use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Factory\FilterContextFactory; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; @@ -114,7 +114,7 @@ final class RecordingFilterElement implements FilterElementInterface public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void + public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void { $this->received = $data; } diff --git a/tests/Registry/FilterElementRegistryTest.php b/tests/Registry/FilterElementRegistryTest.php index 9cd673c8..5488be02 100644 --- a/tests/Registry/FilterElementRegistryTest.php +++ b/tests/Registry/FilterElementRegistryTest.php @@ -6,7 +6,7 @@ use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; -use HeimrichHannot\FlareBundle\Filter\FilterBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; @@ -60,5 +60,5 @@ final class RegistryElementStub implements FilterElementInterface { public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} - public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, FilterData $data): void {} + public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void {} } From 97e2931c7d576819f4cc74d46f81369195474ea8 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 8 Sep 2026 15:41:43 +0200 Subject: [PATCH 91/96] refactor!: rename `FilterSet` and related elements to `FormHarness` Updated terminology to improve alignment with evolving functional purposes. Renamed `FilterSet` to `FormHarness` across the codebase, including factories, events, namespaces, and documentation. Adjusted method signatures and type declarations where relevant. Ensured all references to `FilterSetFactory`, `FilterSetBuildEvent`, and `FilterMount` now reflect the updated `FormHarness` naming convention. Includes updates to specs, schema references, and tests. --- AGENTS.md | 4 +- PLAN_FILTER_FORMS.md | 525 ++++++++++++++++++ SPEC_FILTER_FORMS.md | 26 +- src/Engine/Projector/InteractiveProjector.php | 8 +- src/Event/FilterFormBuiltEvent.php | 2 +- ...ildEvent.php => FormHarnessBuildEvent.php} | 8 +- ...etListener.php => FormHarnessListener.php} | 8 +- src/Filter/FilterData.php | 2 +- src/Filter/FilterFormBuilder.php | 2 +- src/Form/ChoicesBuilder.php | 2 +- .../Factory/FormHarnessFactory.php} | 16 +- src/{Filter => Form}/FilterMount.php | 9 +- .../FilterSet.php => Form/FormHarness.php} | 14 +- .../NamedDispatch/FilterSetListenerTest.php | 14 +- tests/Filter/FilterSetFactoryTest.php | 36 +- 15 files changed, 602 insertions(+), 74 deletions(-) create mode 100644 PLAN_FILTER_FORMS.md rename src/Event/{FilterSetBuildEvent.php => FormHarnessBuildEvent.php} (75%) rename src/EventListener/NamedDispatch/{FilterSetListener.php => FormHarnessListener.php} (66%) rename src/{Filter/Factory/FilterSetFactory.php => Form/Factory/FormHarnessFactory.php} (93%) rename src/{Filter => Form}/FilterMount.php (83%) rename src/{Filter/FilterSet.php => Form/FormHarness.php} (82%) diff --git a/AGENTS.md b/AGENTS.md index 6dc98b69..2d94d326 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ The bundle follows standard Symfony Bundle architecture with deep Contao integra `BaseListOptions` (framework-owned base schema for tl_flare_list columns) - `src/Filter/` — `Filter` DTO, elements (`Element/`), types (`Type/`), collector, resolvers (`FilterOptionsResolver`, `FilterTransformerResolver`, `FilterElementResolver`), `FilterContextFactory`, - and the form aggregate: `FilterSetFactory` builds a `FilterSet` (root form + mount↔filter map of + and the form aggregate: `FormHarnessFactory` builds a `FormHarness` (root form + mount↔filter map of `FilterMount`s) per list × form context - `src/Config/` — `ConfigBuilder` (fluent canonical-config accumulator; no cast helpers — transformers cast declaratively off the typed model) and `TransformerResolver` (source class → transformer map) @@ -76,7 +76,7 @@ Backend palettes/fields are declared in code via `DcaContract::buildDca(DcaBuild tl_flare_filter and tl_flare_list). **Event system** — Events, some with aliased dispatch for targeted listening -(`flare.filter_set.{name}.build`, `flare.filter_form.{type}.built`, `flare.list.{type}.build`, +(`flare.form.{name}.build`, `flare.filter_form.{type}.built`, `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. diff --git a/PLAN_FILTER_FORMS.md b/PLAN_FILTER_FORMS.md new file mode 100644 index 00000000..61f6bfbb --- /dev/null +++ b/PLAN_FILTER_FORMS.md @@ -0,0 +1,525 @@ +# Implementation Plan — Decoupling Filter Forms from Filter Elements + +Implements `SPEC_FILTER_FORMS.md` (repo root). §n references point at that spec. + +**Decisions taken for this plan:** atomic cut (no legacy adapter) · break public API freely (Beta/WIP, +no deprecation layer) · fix the choice round-trip defect in **both** `FieldValueChoiceFilterElement` +and `DcaSelectFieldFilterElement`. + +**Last reconciled with the tree:** after Phase 0 landed (`1490eb8`, `06fc013` on `feat/filter-types`; +`9518aa3` on `docs/main`). Line numbers below are from that tree. + +--- + +## Status + +| plan phase | spec step | state | +|---|---|---| +| **Phase 0** — nomenclature refactor | §12 step 0 | ✅ **done** — see below | +| **Phase 1** — contracts, value objects, registry | part of §12 step 1 | ✅ **done** — see the Phase 1 plan | +| Phase 2 — the cut | rest of §12 step 1 + step 3 | not started | +| Phase 3 — DCA composition | §12 step 2 | not started | +| Phase 4 — drop the legacy columns | §12 step 4 | not started | +| Phase 5 — fold out `FilterData` | §12 step 5 | not started | +| **Phase 6** — the data migration | §12 step 6 | not started — deliberately last | + +**This plan deliberately diverges from §12's sequencing.** The spec pilots step 1 on +`BooleanFilterElement` and migrates the remaining ten in step 3. That is not achievable without a +legacy adapter: step 1 changes `FilterElementInterface::buildFilter()`'s signature and removes +`buildForm()`, which every element must follow in the same commit. Given the atomic-cut decision, +Phase 2 migrates all elements at once and there is no pilot. Everything else maps 1:1. + +### Phase 0 (done) — what changed under you + +The vocabulary is now the spec's (§2.1/§2.2), so the names below are what the rest of this plan uses: + +| old | new | +|---|---| +| `Filter\Factory\FilterFormFactory` | `Filter\Factory\FilterSetFactory` | +| — | `Filter\FilterSet`, `Filter\FilterMount` (new) | +| `Event\FilterFormBuildEvent` | `Event\FilterSetBuildEvent` | +| `Event\FilterElementFormBuiltEvent` | `Event\FilterFormBuiltEvent` | +| `EventListener\NamedDispatch\FilterFormListener` | `…\FilterSetListener` | +| — | `EventListener\NamedDispatch\FilterFormListener` (new) | +| `flare.form.{name}.build` | `flare.filter_set.{name}.build` | +| `flare.filter_element.{type}.form_built` | `flare.filter_form.{type}.built` | +| `tests/Form/FilterFormFactoryTest.php` | `tests/Filter/FilterSetFactoryTest.php` | +| `tests/Form/FilterFormBuilderTest.php` | `tests/Filter/FilterFormBuilderTest.php` | + +Three consequences for the phases below: + +1. **`FilterSetFactory::create()` returns a `FilterSet`**, not a `FormInterface` — the root form plus + `array` keyed by the `ListSpec::$filters` key, where + `FilterMount{Filter $filter, string $alias, FilterContext $context}`. Only filters that actually + mount get an entry. `getMount($key)` resolves the child lazily against the root form and returns + `null` when it is absent. +2. **`FilterSet` is where the decode loop goes** (§8), not a rewritten + `InteractiveProjector::collectFilterData()`. The mount map already carries the `Filter` and the + `FilterContext` each `decode()` call needs, so Phase 2 needs no `FilterContextFactory` in the + projector. `InteractiveProjector::createFilterSet()` (`:123`) is the hook. +3. **`FilterSet::getMount()`/`getMounts()` have no production consumer until Phase 2.** `mago.toml` + sets `find-unused-definitions = true`, so a local `mago analyze` reports them; CI only runs + `mago lint`, so nothing is red. + +Phase 0 also answered §14.1 — see the box in Phase 1. + +--- + +## Context + +A filter element currently owns both *what rows it matches* and *how the user supplies the value*. +`FilterElementInterface` declares `buildForm()` and `buildFilter()` on the same class, and "this +filter has no form" is expressed by an `intrinsic` boolean that 11 of 12 elements read. + +The inventory confirms the three costs §1 predicts, and one more: + +- **Presentation leaks into the schema.** `boolMode` + `boolBinaryChoices` are two DB columns + (`contao/dca/tl_flare_filter.php:616,630`), a `__selector__` entry (`:643`) and a subpalette + (`:664`) whose only job is picking a rendering. Neither has a language entry in + `contao/languages/{en,de}/tl_flare_filter.php`, so dropping them is cheap. +- **`buildForm()`/`buildFilter()` share an undeclared schema.** `FieldValueChoiceFilterElement` + builds a `ChoicesBuilder` in `buildForm()` (`:70`) and **rebuilds it** in `normalizeRuntimeValue()` + (`:207`) to map submitted choices back to ids — `createChoices()` runs three times per request + (`:69`, `:137`, `:207`), each time hitting `loadDataContainer()` and the DB. + `DcaSelectFieldFilterElement::normalizeSubmittedValue()` (`:175-200`) hand-rolls the identical + `array_search(..., true)` reverse mapping — spec §7 names only the former. +- **`intrinsic` conflates three concepts** across 12 `configureOptions()` declarations, 11 + `transformFilterModel()` writes, 8 verbatim `if ($config['intrinsic']) return;` guards at the top + of `buildForm()`, 6 copies of the `$config['intrinsic'] ? preselect : normalize(submitted)` ternary + in `buildFilter()`, 5 palette branches, a load/save callback pair and an `isOnlyIntrinsic()` + contract. +- **New finding:** the three elements overriding `isOnlyIntrinsic() => true` + (`SimpleEquationFilterElement:26`, `PublishedFilterElement:23`, `BelongsToRelationFilterElement:34`) + are *exactly* the three with no `buildForm()` that ignore `$data`. `isOnlyIntrinsic` is fully + derivable from "declares no value object", which is what §5.3 replaces it with. + (`CodefogTagsSearchElement` has neither — it is an `isSupported(): false` stub with only + `buildDca()`; §12 step 3 says it needs `value:` set and nothing else.) + +Outcome: presentation becomes a separately registered, per-instance-selectable concern bound to a +**value object**, so a form/element mismatch is structurally impossible rather than validated. + +### Spec gaps and conflicts this plan resolves + +1. **`AsFilterForm` needs a storable identifier.** §3.3 shows `#[AsFilterForm(value:, requires:, + default:)]` — but `tl_flare_filter.formVariant` must store *which* form, and the registry key + (the value class) does not discriminate between several forms sharing it. This plan adds + `name:`, defaulting to a **new** `TypeNameFactory::createFilterFormType($class)` — that method + does not exist yet; `TypeNameFactory` currently has only `createFilterElementType()` and + `createListDriverType()`, both delegating to a private `createType($class, $suffixes)` with the + most specific suffix first. Follow that shape with `['Controller', 'FilterForm', 'Form']`. FQCNs + are deliberately not stored — a class rename would orphan every row. +2. **`FilterOptionsResolver` keys the schema cache on `$element::class` alone.** It holds no cache + itself; it passes that string as the `$key` to `SchemaResolver::resolve()` + (`src/Filter/Resolver/FilterOptionsResolver.php:37`), which memoises one `OptionsResolver` per + key. Once a form also contributes `configureOptions()`, the first-seen form's schema is reused + for every other form on the same element, silently rejecting valid options. The key must become + composite. `FilterTransformerResolver:35` already uses `sprintf('%s@%s', $type, $element::class)` + and needs only the form appended. Note `SchemaResolver` is `shared: false` + (`config/services.yaml:30-31`), so a *separate* resolver service for form config could not + collide with the element one — but a composite key is still required for two forms on one element. +3. **§3.1 and this plan disagree about `flare.choices_builder`.** §3.1 justifies passing the mount to + `decode()` because the form "needs access to the attributes it set in `buildForm()` (e.g. + `flare.choices_builder`)" — while the attribute is provably write-only today (set at + `ArchiveFilterElement:102`, `DcaSelectFieldFilterElement:89`, `FieldValueChoiceFilterElement:84`, + `CodefogTagsChoiceFilterElement:101`; zero `getAttribute()` anywhere). Under §3.4's two-method + port `decode()` reads `$mount->getViewData()` and hands the keys to + `$element->valueFromChoiceKeys()`, so it needs no `ChoicesBuilder` at all. **Resolve in Phase 2: + the four `setAttribute()` calls move onto the choice form (which owns `applyFormOptions()` and + the empty-option config) or disappear — decide once the form is written, and update §3.1's + parenthetical either way.** Do not simply delete them: `FilterSetFactory:128-131` copies wrapper + attributes onto the mount, so the attribute reaches `form.vars` and is semi-public surface for + custom form themes. Removal needs a line in the release notes. + +--- + +## Phase 1 — Contracts, value objects, registry (additive, stays green) + +New files plus one additive column. Nothing existing changes behaviour. + +> **§14.1 is answered — and it constrains this phase.** Phase 0's throwaway probe +> (`tests/Filter/ValueObjectSerializeProbeTest.php`, `@group probe`) measured `serialize()` against +> `ListSpec::hash()`. §9's core claim holds: equal-but-distinct `final readonly` value objects of +> scalars, arrays, enums and nested value objects hash identically and survive a round trip, and +> every hazard §9 names is confirmed. **But `serialize()` is not a pure value function over an +> object graph:** a repeated object is emitted as a back-reference (`r:N;`), so the hash differs +> depending on whether two filters share one value instance or hold two equal ones. Today's code is +> immune only because `Filter::fingerprint():100` flattens through `FilterData::toArray()` — the +> flattening Phase 5 removes. So **either keep a flattening step (§9's opt-in `fingerprint(): array`, +> which then is not optional) or accept the cache miss.** Blast radius is one in-request memo array +> in `ArchiveFilterElement:401-408`, so instability costs a miss, not correctness; no collision was +> observed. Decide here, before `Filter::$value` exists. Full write-up in §14.1. + +**Contracts** — `src/Filter/Form/`: +- `FilterFormInterface` — `buildForm(FilterFormBuilderInterface, FilterContext): void` and + `decode(FormInterface $mount, FilterContext): ?object` per §3.1. +- `src/Contract/FilterElement/ChoiceSourceContract` — the two-method port from §3.4 + (`buildChoices()`, `valueFromChoiceKeys()`). Place it beside the existing + `src/Contract/FilterElement/IntrinsicContract.php`, which Phase 2 deletes. + +**Value objects** — `src/Filter/Value/`, one per §4.3 *semantic* group (not per structure): +`BoolValue`, `ChoiceValue` (the 4 choice elements), `KeywordsValue`, `DateRangeValue`, +`ParentRefValue` (§7.4 — `array $ids>`, spanning both ptable modes). +Each `final readonly`, obeying the §9 containment rule. + +**Registration** — copy the established pattern exactly: +- `src/DependencyInjection/Attribute/AsFilterForm.php` — mirror `AsFilterElement.php`'s shape + (`const TAG`, manually-assigned `$type`/`$name`, promoted public props for the typed args, and + every named arg copied back into `public array $attributes`). Ctor: + `(?string $name = null, ?string $value = null, array $requires = [], bool $default = false, mixed ...$attributes)`. +- `AsFilterElement` gains `?string $value` — **and `RegisterFilterElementsPass:39` must be updated + in the same commit**, because it reconstructs the attribute as + `new Definition(AsFilterElement::class, [$type, $attributes['isTargeted'] ?? null])` — positional + args only, so a new property is silently dropped otherwise. The autoconfiguration closure keeps + everything in the raw tag, but the pass calls `clearTag()` at `:32`, so anything not replayed is + gone. +- `src/Registry/FilterFormRegistry.php` — auto-registered (`src/Registry/` is in no exclude list in + `config/services.yaml`). Model the class-string keying on `FilterTypeRegistry` (uninitialised + typed property + `!isset()` guard, so "resolved but empty" is representable). + **There is no existing `default:`/`requires:` election to copy** — `ProjectorRegistry::getProjectorFor()` + is a `supports()` + highest-`priority()` tournament with an exclusion set, a different shape + entirely; write the §3.4 election (value class matches **and** every `requires` entry is + `instanceof`-satisfied by the element) fresh. + Prefer the **`CodefogTagsPass` ServiceLocator shape** (`:25-58` — `array` + inlined as a `Definition(ServiceLocator::class)` tagged `container.service_locator`, injected by + named argument) over `RegisterFilterElementsPass`'s eager `addMethodCall('add', [Reference, ...])`, + which instantiates every element on first registry use — a list uses a handful of forms, not all + of them. +- `src/DependencyInjection/Compiler/RegisterFilterFormsPass.php` — use `hasDefinition()` (the + correct guard; `RegisterFilterElementsPass:21` uses `has()`, which also resolves aliases), pass + ctor args positionally, and do the §10 compile-time checks here: every `requires` entry is an + existing interface, and every element value class has ≥1 form whose `requires` that element + satisfies. **Register it before `RegisterFilterElementsPass`** in + `src/HeimrichHannotFlareBundle.php:49` if it needs to read `flare.filter_element` tags — that pass + clears them. +- Add the attribute to the `$attributesForAutoconfiguration` map in + `HeimrichHannotFlareExtension.php:53` — a one-line entry; the closure is already generic over + `public array $attributes`. + +**Additive schema, no backfill** — the column lands here so records can carry a form from Phase 2 +on; the data migration is deferred to **Phase 6**, after every other phase. +- `contao/dca/tl_flare_filter.php`: add `formVariant` (`select`, `submitOnChange => true`, blank + option = intrinsic, `sql` `['type' => 'string', 'length' => 128, 'default' => '', 'notnull' => true]`). + `intrinsic` (`:138`) stays for now. +- **No `src/Migration/BackfillFilterFormVariantMigration.php` yet.** Its `element type → form name` + map cannot be frozen before the forms exist — the `flare_bool` arm in particular is undecidable + until `ChoiceBoolFilterForm`'s semantics are settled (Phase 2). And a migration file + auto-registers the moment it exists (`src/Migration` is not excluded from the PSR-4 resource, and + `autoconfigure` + `AbstractMigration` earns `contao.migration`), so its mere presence means + `contao:migrate` would run it against names that do not exist — one-shot, with `shouldRun()` + going quiet afterwards. See Phase 6 and spec §11. + +**Tests:** a reflection test over every class named in `#[AsFilterElement(value: …)]` asserting the +§9 containment rule (§10, row 4). The §14.1 probe already exists; extend it rather than duplicating +it if the flattening decision needs more evidence. Existing suite is plain PHPUnit with no kernel, +so this is idiomatic. + +--- + +## Phase 2 — The cut (atomic) + +One commit. The seam is a single call site plus one data path, so it cannot be split without an +adapter. + +**Signatures:** +- `FilterElementInterface`: `buildForm()` removed; `buildFilter(FilterBuilderInterface, FilterContext, ?object $value)`. + Each element narrows `$value` to its declared class — a mismatched form is then a native `TypeError`. +- `AbstractFilterElement`: drop the `buildForm()` no-op (`:52`) and `isOnlyIntrinsic()` (`:61`); drop + the `IntrinsicContract` from its `implements` clause. While here: `$choicesBuilderFactory` is + `private` (`:31`), so `FieldValueChoiceFilterElement` promotes its own copy (`:37`) to read it + directly at `:149`. Consolidate on the protected + `createChoicesBuilder()` accessor. +- `Filter`: `$data` → `$value`, plus a `?FilterFormInterface $form`. **All three `with*()` methods + (`:49`, `:63`, `:77`) enumerate every ctor arg explicitly and will silently drop new properties** — + update them and `fingerprint():94`, adding the form as a class-string beside `'element'`, or + `ListSpec::hash():113` collides across form variants. `withAlias():63` has zero call sites anywhere, + including tests — delete it rather than maintain it. +- `FilterElementBuildingEvent` / `FilterElementBuiltEvent`: `$data` → `$value`. + +**Wiring:** +- `FilterSetFactory:82` — `$filter->element->buildForm($builder, $filterContext)` becomes + `$filter->form?->buildForm(...)`, skipping the mount when there is no form. Everything at + `:84-138` (cancel, single/compound election, `ATTR_SINGLE_FIELD`, attribute transfer, + deferred-listener replay, `$mounts[$key]` recording) is unchanged. +- `FilterFactory::createFromFilterModel():60` — resolve the form from `$filterModel->formVariant` + via the registry, run its transformers, and merge element + form config through a `ConfigBuilder` + following `ListSpecFactory:75-89`. Read `formVariant` off the raw model, not from canonical + config — the form's own transformers have not run yet. `FilterFactory::create():33` gains + `?FilterFormInterface $form = null`, so the seven programmatic call sites + (`ValidationLoader:46,95`, `SimpleEquationMod:26`, `NewsListDriver:55`, `EventsListDriver:68`, + `ChangelanguageListener:137,149`) keep working by passing nothing. +- `FilterOptionsResolver:37` — combined `$configure` closure (element slice + form slice) in the + `ListOptionsResolver:34-40` style, **with a composite `SchemaResolver` key** per gap 2 above. + `FilterTransformerResolver:35` — append the form class to the existing key. +- **`FilterSet::decode()`** — the §8 loop lands here, not in the projector. Walk `getMounts()`, and + for each `FilterMount` call `$mount->filter->form->decode($this->getMount($key), $mount->context)`, + keeping only non-null results. `InteractiveProjector::collectFilterData():140-184` is then deleted + and `project():50` calls `$filterSet->decode()`. This is the §7.2 fix: `decode()` reads + `getViewData()`, so no reverse mapping and no label collisions. +- `FilterExecutor:54` — `$options->filterValues[$key] ?? $filter->value` (no `FilterData::none()`). +- Delete `IntrinsicContract`, `FieldsLoadAndSaveCallbacks::onLoadField_intrinsic()`/`::onSaveField_intrinsic()` + (its only consumers, `:85` and `:106`) and their `#[AsCallback]` attributes, and the three + `isOnlyIntrinsic()` overrides. +- Settle `flare.choices_builder` per gap 3 — move onto the form or drop with a release note; do not + delete silently. + +**Element → form migration.** 11 `buildForm()` bodies consolidate into ~5 forms: + +| form | value | serves | notes | +|---|---|---|---| +| `ChoiceFilterForm` | `ChoiceValue` | FieldValueChoice, DcaSelectField, CodefogTagsChoice | `requires: [ChoiceSourceContract::class]`; `decode()` is the two-line §3.4 body | +| `CheckboxFilterForm` | `BoolValue` | Boolean | replaces `boolMode`/`boolBinaryChoices` | +| `ChoiceBoolFilterForm` | `BoolValue` | Boolean | the variant those two columns used to select | +| `DateRangeFilterForm` | `DateRangeValue` | DateRange, CalendarCurrent | absorbs the byte-identical `validateRange()` bodies (`DateRangeFilterElement:100-112` ≡ `CalendarCurrentFilterElement:236-248`, docblock included) and the only deferred-listener use | +| `KeywordsFilterForm` | `KeywordsValue` | SearchKeywords | | + +`Published`, `BelongsToRelation`, `SimpleEquation` and `CodefogTagsSearchElement` get +`#[AsFilterElement(value: null)]` and no form. `Archive` uses `ParentRefValue` (§7.4) and is also +served by `ChoiceFilterForm` via the port; its `buildForm()` throws `FilterException` at four sites, +and the form must keep that failure mode or the archive filter degrades silently. Note +`ArchiveFilterElement:101-104` installs `applyFormOptions()` and `single(ChoiceType::class, …)` +*before* the choices are added (`:161-168`), working only because `buildCallbackChoiceLoader()` +defers — preserve the deferral when the form takes over. + +**Both choice defects** (per the decision): `createChoices()` → `buildChoices()`; +`normalizeRuntimeValue()` and `normalizeSubmittedValue()` → `valueFromChoiceKeys()` on the port; and +`ChoicesBuilder::add():139` fixed so `choice` carries identity rather than the display string, which +removes the `array_search($choice, $this->choices, true)` reverse lookup at `ChoicesBuilder:259` +inside `buildChoiceValueCallback():251-268`. Fixing `add()` also lets +`FieldValueChoiceFilterElement`'s three `createChoices()` calls collapse to one. +`ChoicesBuilder` has real dead API — verified zero call sites for `setLabelForModel`, +`setLabelForClass`, `setEmptyOptionValue`, `getChoice`, `hasEmptyOption`, `buildFormOptions`, +`addGroup`, `removeGroup`, plus the write-only `$groups`/`$choiceGroupMap` fields; leave it alone +unless the port makes deletion obvious. (`setLabel` and `count()` are **live** — `ArchiveFilterElement:117` +and `:164`. Every one of these carries `/** @api */`, so deletions need a release note.) + +**Do not carry over:** `CalendarCurrentFilterElement`'s positional `$data->all()` fallback +(`processRuntimeValue():187-210`, exactly-two-element) — the value object replaces it, and +`DateRangeFilterElement:87-88` never had an equivalent, so the two siblings disagree on the runtime +contract today. `DateRangeFilterElement`'s `from_enabled`/`to_enabled` (`:38-39`, read at `:55,:64`) +are dead knobs — defined, never written by `transformFilterModel():42-47`, absent from every palette +and language file, so both branches are permanently true — either surface them as real form config +or drop them and add the fields unconditionally. + +--- + +## Phase 3 — DCA composition + +- `DcaBuilder` — third palette segment with fixed order `__prefix__ + element + form + __suffix__`. + `Str::mergePalettes()` is already variadic and drops empty segments, so `apply():89` needs one + extra argument. Add `scope('form')` returning a sub-builder whose `palette()` lands in the form + segment; `field()` stays shared (it already returns a memoized shared `DcaFieldBuilder` at `:68`, + and forms occasionally tweak an element field's `eval`). `DcaBuilder` is `final` and deliberately + **not** a service (excluded wholesale at `config/services.yaml:15`) — it is `new`ed once, at + `ElementDcaListener:108` — and `ElementDcaEvent` holds the concrete class, so widening it is a + local change. + While here: `Str::mergePalettes(?string ...$palettes)` maps a non-nullable `string $palette` + closure over its args *before* filtering (`src/Util/Str.php:97-100`), under `strict_types=1`, + while `DcaBuilder::apply()` passes a `?string` and `ArchiveFilterElement:444` passes `null` + explicitly. Adding a third nullable segment makes this worth settling — filter first, or widen the + closure param. +- `ElementDcaListener:79,108-118` — resolve the form service alongside `$filterModel->type` and call + its `buildDca()` into the form scope. +- `FieldsOptionsCallbacks` — new `#[AsCallback(self::TABLE_NAME, 'fields.formVariant.options')]`: + registry entries for the element's declared value class, filtered by `requires`. Follow the + existing `fields.type.options` callback at `:43-63`. +- Split the 5 `intrinsic`-branching palettes (`BooleanFilterElement:110-126`, + `SearchKeywordsFilterElement:87-94`, `DcaSelectFieldFilterElement:202-212`, + `ArchiveFilterElement:411-444`, `CalendarCurrentFilterElement:139-147`) into element + form + segments per the §6.2 legend convention. `FieldValueChoiceFilterElement:113-115` and + `CodefogTagsChoiceFilterElement:128-130` currently show form-only fields even when intrinsic — + this fixes that inconsistency too. +- §14.2: confirm whether any first-wave form contributes a subpalette. `__selector__` is static in + `contao/dca/tl_flare_filter.php:641-644`, and **`DcaBuilder::selector()` does not exist at all** — + neither producer nor consumer, and `DcaBuilderInterface` has only `palette`, `getPalette`, + `prefix`, `suffix`, `field`, `apply`. `apply()` never touches `palettes.__selector__` or + `subpalettes`. So this is an API to *write*, not one to find a consumer for — **defer it if no + first-wave form needs it.** +- Form-owned field labels stay in `contao/languages/{en,de}/tl_flare_filter.php` (§13.3). +- Add a `DcaBuilder` unit test asserting segment order — new but idiomatic (`$GLOBALS['TL_DCA']` set + up by hand; no kernel). There is no `tests/DataContainer/` or `tests/Util/` today, so + `mergePalettes()` is untested as well. + +--- + +## Phase 4 — Drop the legacy columns + +Everything here is deletion, and it must land after Phase 3 so the backend never references a +dropped column. + +> **Do not apply the schema diff on a database holding real rows until Phase 6 has landed.** This +> phase removes `intrinsic` from the DCA, and Phase 6's backfill reads it. Contao runs migrations +> before applying the diff, so a single `contao:migrate` with both phases in place backfills and +> then drops, in that order — which is correct. Updating the schema in between destroys the source +> column and with it the mapping, unrecoverably. + +- `contao/dca/tl_flare_filter.php`: remove the `intrinsic` (`:138`), `boolMode` (`:616`) and + `boolBinaryChoices` (`:630`) fields, the `boolMode_binary` subpalette (`:664`), the `boolMode` + entry in `__selector__` (`:643`), and `intrinsic` from + `'__prefix__' => '{title_legend},title,type,intrinsic'` (`:645`). + Columns come only from these `sql` keys (no schema listener), so Contao's diff drops them. +- **`AddTargetAliasFieldCallback:50` anchors on the dropped column** — + `PaletteManipulator::create()->addField('targetAlias', 'intrinsic')` must re-anchor on + `formVariant`, or `targetAlias` silently stops being inserted for every targeted element. +- `FilterModel:34` `isFilterIntrinsic()` and the `'intrinsic'` arm of `__get():114`; the + `@property bool $intrinsic` in `DocumentsFilterModelTrait:15`. +- `ListCallbacks:34,51` — `$row['intrinsic']` becomes `($row['formVariant'] ?? '') === ''`. The + `is_intrinsic` flag in `templates/backend/be_filter_info.html.twig` is purely cosmetic (icon + + opacity) and needs no change beyond that. Keep the `filter.info.intrinsic.yes/no` keys in + `translations/flare.{en,de}.yaml:31-33`, or rename together with the template. (Unrelated but + adjacent: that template's `form_alias` / `duplicate_filter_aliases` are the `formAlias` *query + parameter* column, which §5.1 explicitly reserves — do not touch.) +- Remove the `'intrinsic'` key from the 12 `configureOptions()` and 11 `transformFilterModel()` + declarations, and the `unset($preselectOptions['null'])` hack at `BooleanFilterElement:124-126`. +- `preselect` stays a column, re-owned by the form, semantics narrowed to hydration (§4.2). Because + the decode path already harvests field defaults for unsubmitted forms, preselect-as-data flows + through the normal path and every `?? $config['preselect']` fallback disappears. + +--- + +## Phase 5 — Fold out `FilterData` + +`FilterData` was introduced in `4263611`; this partly unwinds it. Both of its jobs relocate: +`hasSingle()`'s submitted-vs-untouched distinction is answerable from the `FormInterface` +(`decode()` receives the mount), and `toArray()`'s hashing role moves to the value object — subject +to the §14.1 flattening decision recorded in Phase 1, which is precisely the constraint on removing +`toArray()`. + +Mechanically small — the `array` map is typed in **docblocks only**, so the +15 transport hops (`AggregationContext`, `ValidationContext`, `InteractiveLoaderConfig`, +`AggregationLoaderConfig`, `ListQueryConfig`, the two Calendar loaders, …) need annotation updates, +not signature changes. `count()` and `getIterator()` (and therefore the +`\IteratorAggregate, \Countable` clause) have zero call sites anywhere; `hasSingle()` and `isEmpty()` +are dead in `src/` but covered by tests. Delete the class and `tests/Filter/FilterDataTest.php`. + +`FilterFormBuilder`'s `single()`-vs-compound decision is unaffected — that is genuinely a form +concern and stays. + +--- + +## Phase 6 — The data migration + +Deliberately last: everything above must be in place before the mapping can be written correctly. + +`src/Migration/BackfillFilterFormVariantMigration.php`, modelled on +`RenameContentListColumnMigration` (`SHOW TABLES` / `SHOW COLUMNS` guards, raw `executeStatement`, +`createResult`; not `final`, not a `readonly` class, `private readonly Connection`). It needs no +registration — `src/Migration` is not excluded from the PSR-4 service resource, and `autoconfigure` +plus `AbstractMigration` earns Contao's `contao.migration` tag. + +**Why it could not be written earlier.** Three reasons, in order of severity: + +1. **The `flare_bool` arm was undecidable.** `BooleanFilterElement::buildForm()` always mounts a + `CheckboxType` and never reads `boolMode`; `ternary` posts a backend error as unsupported. But + `boolBinaryChoices` *does* change matching via `resolveRuntimeValue():84-89` — under `NULL_TRUE` + an unchecked box means "no opinion", under `NULL_FALSE`/`TRUE_FALSE` it means `false`. A checkbox + form owning no element config cannot express both, so which bool rows map to which bool form is + only answerable once Phase 2's `ChoiceBoolFilterForm` exists. Decide it there, record it here. +2. **The file auto-registers on sight.** Its mere presence means `contao:migrate` runs it, against + form names that may not exist. It is one-shot — once `shouldRun()` is satisfied it goes quiet, so + wrong values stay committed and need a *second* corrective migration. +3. **Form names are permanent data.** Hardcode `element type → form name` as frozen literals, not + `Element\ArchiveFilterElement::TYPE` and not a `FilterFormRegistry` lookup: a migration describes + historical rows, so binding it to live code would let a later rename retroactively change what + already-migrated rows meant. This is a deliberate divergence from + `RenameContentListColumnMigration`, which references `ContentContainer::FIELD_LIST` — there the + constant *is* the target schema; here the literal *is* historical data. + +**The mapping**, pinned from Phase 2's form table. Element types with no form are absent and keep +`''` — they never rendered a widget: + +| element `type` (frozen DB value) | form name | +|---|---| +| `flare_archive`, `flare_fieldValueChoice`, `flare_dcaSelectField`, `cfg_tags_choice` | `flare_choice` | +| `flare_dateRange`, `flare_calendar_current` | `flare_date_range` | +| `flare_search_keywords` | `flare_keywords` | +| `flare_bool` | `flare_checkbox` / `flare_choice_bool` per row — see reason 1 | +| `flare_published`, `flare_relation_belongsTo`, `flare_equation_simple`, `cfg_tags_search` | *(absent — stays `''`)* | + +**`shouldRun()` constraints**, each one a trap: + +- **Do not key on `formVariant` being absent.** Phase 1 landed the column via the DCA `sql` key, so + the schema diff has already created it. Key on `intrinsic` being *present* plus a type-restricted + count of rows still needing work. +- **Restrict that count to the mapped types.** Without it, the four unmapped types' `intrinsic = 0` + rows keep the count non-zero forever and Contao offers a pending migration that never completes. +- **Return `false` when `intrinsic` is already absent**, so an environment past the Phase 4 drop + fails closed rather than backfilling from nothing. + +**The ordering hazard.** The backfill reads `intrinsic`, which Phase 4 removes from the DCA. Contao +runs migrations before applying the schema diff, so one `contao:migrate` with both phases in place +backfills and *then* drops — correct. But applying a schema update after Phase 4 lands and before +this phase exists destroys the source column and the mapping with it, unrecoverably. So this phase +must land before any environment holding real rows updates its schema. + +The 11 programmatic `'intrinsic' => true` sites (`ValidationLoader:49,98`, `SimpleEquationMod:29`, +`EventsListDriver:71`, `ChangelanguageListener:140,152`, `NewsListDriver:58`) are config array +literals, not DB rows. The migration touches only `tl_flare_filter` and cannot see them. + +--- + +## Files that change most + +- `src/Filter/Element/*` (12 elements) — `buildForm()` out, `buildFilter(?object)` in +- new `src/Filter/Form/*`, `src/Filter/Value/*`, `src/Contract/FilterElement/ChoiceSourceContract.php` +- `src/Filter/Factory/{FilterFactory,FilterSetFactory,FilterContextFactory}.php` +- `src/Filter/Resolver/{FilterOptionsResolver,FilterTransformerResolver}.php` +- `src/Filter/{Filter,FilterData,FilterSet}.php`, `src/Engine/Projector/InteractiveProjector.php`, + `src/Query/Executor/FilterExecutor.php` +- `src/DataContainer/Builder/DcaBuilder.php`, `src/EventListener/Contao/ElementDcaListener.php` +- `contao/dca/tl_flare_filter.php`, `src/EventListener/DataContainer/FlareFilter/*` +- `src/Form/ChoicesBuilder.php` +- `src/Migration/BackfillFilterFormVariantMigration.php` (Phase 6 only) + +--- + +## Verification + +Per phase, then end-to-end. The `php` binary is unavailable — use the Makefile's Docker targets. + +1. `make phpstan` (level 5, `src/` minus `src/Model/` and `src/Integration/Terminal42Languages/`) + after every phase. It will catch the `Filter::with*()` drop-through and the `buildFilter()` + signature fan-out. There is no baseline file, so nothing hides a new error. +2. `make test` after every phase. Existing suites that must be updated rather than deleted: + `tests/Filter/FilterTest.php` (fingerprint), `tests/Engine/Projector/InteractiveProjectorTest.php` + (its `collectFilterData` coverage moves to `FilterSet::decode()`), + `tests/Query/Executor/FilterExecutorTest.php`, `tests/Filter/FilterSetFactoryTest.php`, + `tests/Filter/FilterFactoryTest.php`, `tests/Filter/FilterOptionsResolverTest.php` (asserts + `intrinsic`), `tests/Filter/Element/{ArchiveFilterElementTest,SimpleEquationFilterElementTest}.php` + (both assert `intrinsic`), `tests/Config/ConfigBuilderTest.php` (asserts `intrinsic`), + `tests/List/StubFilterElement.php` (the shared double — anything touching + `FilterElementInterface` touches it). + Coverage gap to be aware of before Phase 2: only `Archive` and `SimpleEquation` have + element-level tests, and both only assert `transformFilterModel()` output. `DateRange`, + `CalendarCurrent`, `DcaSelectField`, `FieldValueChoice`, `Boolean`, `SearchKeywords` and + `ChoicesBuilder` have **none**, so the choice-defect fix and the `validateRange()` extraction have + no safety net. Write `ChoicesBuilderTest` first. +3. New tests: value-object containment (reflection over `#[AsFilterElement(value: …)]`), + `DcaBuilder` segment order, and a `ChoiceFilterForm::decode()` case with **two rows sharing a + display label** — the collision the old `array_search` reverse lookup produced. The `serialize()` + stability probe already exists from Phase 0. +4. Container build: `make php bin/console debug:container flare.filter_form.choice` and + `debug:container --tag=flare.filter_form`. Deliberately mis-declare a `requires` entry once and + confirm the compiler pass fails the build (§10, row 3). **Note this repo has no `bin/console`** — + it is a bundle with no app skeleton, so container checks need a host Contao install. +5. Backend, on a Contao install with existing filter rows. **Phases 1-5 have no migration to run** — + `formVariant` is uniformly `''` and `intrinsic` stays authoritative until Phase 4; set + `formVariant` by hand to exercise a form. From **Phase 6** on, run the migration + (`make php bin/console contao:migrate --dry-run` first), then confirm for a `flare_bool` filter + that the `formVariant` select lists both bool forms, that switching it swaps the palette segment + via `submitOnChange`, that the blank option hides the form fields, and that a previously + `intrinsic = 1` row came through as blank. Check `targetAlias` still appears after the + `AddTargetAliasFieldCallback` re-anchor. +6. Frontend: a list with an Archive filter in **both** ptable modes (static and dynamic — the + `PtableInferrer` branch), plus a `FieldValueChoice` filter on a `foreignKey` field with duplicate + labels. Verify submitted values narrow the result set, that the empty option still means "use the + full whitelist" (`ChoicesBuilder::EMPTY_CHOICE` passed through verbatim, §3.4), and that an + unsubmitted form applies its `preselect`. +7. `make semgrep-sec` before finishing — `FilterQueryBuilder` parameterisation is untouched, but the + filter path is where it matters. Mago runs in CI only (`mago lint`, never `mago analyze`); there + is no make target. + +**Docs are a separate worktree.** `docs/` is a git worktree on branch `docs/main`, so every phase +that changes public API needs its own commit there — `docs/docs/dev/events.md`, +`docs/docs/spec/filtering.md`, `docs/docs/dev/filter-elements/index.md`, +`docs/docs/migrating-from-v0.1.md` and `docs/docs/removed-in-v0.2.md` all describe this surface. +`docs/versioned_docs/version-0.1/**` snapshots the shipped 0.1 API and must stay frozen. diff --git a/SPEC_FILTER_FORMS.md b/SPEC_FILTER_FORMS.md index 28a0044d..dd55bfbf 100644 --- a/SPEC_FILTER_FORMS.md +++ b/SPEC_FILTER_FORMS.md @@ -33,7 +33,7 @@ incidental: it** in `normalizeRuntimeValue()` to map submitted choices back to scalars. - `DateRangeFilterElement::buildForm()` adds children `from`/`to`; `buildFilter()` reads `$data->get('from')` by those exact names. -- The `single()` vs. compound mount decision (`FilterSetFactory`, §2.2) determines whether `buildFilter()` +- The `single()` vs. compound mount decision (`FormHarnessFactory`, §2.2) determines whether `buildFilter()` receives `getSingleValue()` or `get($name)`. Extracting forms without addressing this trades one coupling for a worse, invisible one. @@ -93,12 +93,12 @@ one word each: | term | multiplicity | what it is | |---|---|---| -| `FilterSet` | one per list × form context | the filters, their root form, and the mount↔filter map | +| `FormHarness` | one per list × form context | the filters, their root form, and the mount↔filter map | | `FilterForm` | one per filter | registrable presentation strategy (`buildForm()`, `decode()`) | | `FilterFormBuilder` | one per filter, transient | collect-only builder handed to `buildForm()` | | **mount** | one per filter that has a form | the node mounted into the root form — flat field or compound group | -`FilterSet` is an **object**, not a bare `FormInterface` returned by a factory: it owns the +`FormHarness` is an **object**, not a bare `FormInterface` returned by a factory: it owns the `decode()` loop (§8), which otherwise has no home but `InteractiveProjector` — where its predecessor already landed wrongly (§7.2). @@ -117,20 +117,20 @@ exists to remove. |---|---|---| | `Filter\Factory\FilterFormFactory` | `Filter\Factory\FilterSetFactory` | filter set | | — | `Filter\FilterSet` (new) | filter set | -| — | `Filter\FilterMount` (new) | filter set | +| — | `Form\FilterMount` (new) | filter set | | `Event\FilterFormBuildEvent` | `Event\FilterSetBuildEvent` | filter set | | `EventListener\NamedDispatch\FilterFormListener` | `…\FilterSetListener` | filter set | -| `flare.form.{name}.build` | `flare.filter_set.{name}.build` | filter set | +| `flare.form.{name}.build` | `flare.form.{name}.build` | filter set | | `Event\FilterElementFormBuiltEvent` | `Event\FilterFormBuiltEvent` | filter | | `flare.filter_element.{type}.form_built` | `flare.filter_form.{type}.built` | filter | | — | `EventListener\NamedDispatch\FilterFormListener` (new) | filter | | `Filter\FilterFormBuilder`, `…Interface` | unchanged | filter | -`FilterSetFactory` builds a `FilterSet`, not a form, so the `Form` infix drops out; the root form +`FormHarnessFactory` builds a `FormHarness`, not a form, so the `Form` infix drops out; the root form is `FilterSet::getForm()`. `FilterElementFormBuiltEvent` loses its detour over the element: it was named after the element only because `FilterForm*` was taken — and its dispatch alias follows the class, moving out of `FilterElementListener` into the `FilterFormListener` whose name the -`FilterFormListener` → `FilterSetListener` rename frees. `{type}` there remains the *element* type, +`FilterFormListener` → `FormHarnessListener` rename frees. `{type}` there remains the *element* type, which is what listeners target; the alias names the concern, not the key. `FilterFormBuilder` keeps its name deliberately. Symfony's own @@ -471,7 +471,7 @@ Both of its jobs relocate: `FilterFormBuilder`'s `single()` vs. compound mount decision is unaffected — that is genuinely a form concern and stays where it is. -**The decode loop lives on `FilterSet`.** `InteractiveProjector::collectFilterData()` today walks +**The decode loop lives on `FormHarness`.** `InteractiveProjector::collectFilterData()` today walks the root form and flattens every child to `$child->getData()` — the flattening §7.2 identifies as the defect. Under the target model that walk becomes `FilterSet::decode()`: for each filter that has a mount, call the filter form's `decode($mount, $context)` and set the resulting value on the @@ -656,9 +656,9 @@ Recorded here because each one closes a branch the design could otherwise have t Under the target model that branch disappears and `isLimited` becomes form-owned config. The same applies to `SearchKeywordsFilterElement::buildDca()`, `DcaSelectFieldFilterElement::buildDca()` and `BooleanFilterElement::buildDca()`. -5. **The aggregate is called `FilterSet` and is an object.** The naming axis is multiplicity within +5. **The aggregate is called `FormHarness` and is an object.** The naming axis is multiplicity within *Filter*, not filter-versus-list (§2.1) — the list is exclusively output, so no `List…` name can - be right for a form. `FilterSet` won over the invented alternatives because it names something + be right for a form. `FormHarness` won over the invented alternatives because it names something that already exists unnamed: `ListSpec::$filters` is a bare `array` with hand-rolled `_generated_{$index}` keying and a hand-rolled `array_map` fingerprint loop in `hash()`. Making it an object rather than a factory return value is what gives `decode()` a home @@ -695,10 +695,10 @@ Not blockers, but unverified at spec time. 3. **`ArchiveFilterElement::buildPreselectData()`** is currently `ListSpec`-aware. Confirm it reduces to a generic key-lookup against `buildChoices()` once preselect is stored as choice keys, or whether preselect hydration needs its own port method. -4. **Whether `FilterSet` should also absorb `ListSpec::$filters`.** The name fits the bare +4. **Whether `FormHarness` should also absorb `ListSpec::$filters`.** The name fits the bare `array` at least as well as it fits the form aggregate, which is a tension the rename introduces rather than resolves. Phase 0 deliberately keeps them apart: `ListSpec` is built in validation and aggregation contexts that never produce a form, so a form-carrying - `FilterSet` cannot simply replace the array. Revisit once the decode loop exists — either - `FilterSet` splits into a plain collection plus a form-bearing wrapper, or the two stay separate + `FormHarness` cannot simply replace the array. Revisit once the decode loop exists — either + `FormHarness` splits into a plain collection plus a form-bearing wrapper, or the two stay separate and the form-side object needs a distinguishing name after all. diff --git a/src/Engine/Projector/InteractiveProjector.php b/src/Engine/Projector/InteractiveProjector.php index 2bfe5da9..37859555 100644 --- a/src/Engine/Projector/InteractiveProjector.php +++ b/src/Engine/Projector/InteractiveProjector.php @@ -14,10 +14,10 @@ use HeimrichHannot\FlareBundle\Engine\View\AggregationView; use HeimrichHannot\FlareBundle\Engine\View\InteractiveView; use HeimrichHannot\FlareBundle\Exception\FlareException; -use HeimrichHannot\FlareBundle\Filter\Factory\FilterSetFactory; +use HeimrichHannot\FlareBundle\Filter\Factory\FormHarnessFactory; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; -use HeimrichHannot\FlareBundle\Filter\FilterSet; +use HeimrichHannot\FlareBundle\Form\FormHarness; use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Paginator\Factory\PaginatorFactory; use HeimrichHannot\FlareBundle\Paginator\Paginator; @@ -31,7 +31,7 @@ class InteractiveProjector extends AbstractProjector { public function __construct( private readonly AggregationContextFactory $aggregationConfigFactory, - private readonly FilterSetFactory $filterSetFactory, + private readonly FormHarnessFactory $filterSetFactory, private readonly PaginatorFactory $paginatorFactory, ) {} @@ -120,7 +120,7 @@ public function createForm(ListSpec $list, InteractiveContext $context): FormInt * * @throws FlareException */ - protected function createFilterSet(ListSpec $list, InteractiveContext $context): FilterSet + protected function createFilterSet(ListSpec $list, InteractiveContext $context): FormHarness { $filterSet = $this->filterSetFactory->create($list, $context); diff --git a/src/Event/FilterFormBuiltEvent.php b/src/Event/FilterFormBuiltEvent.php index 44f77bdd..ae0fb3a5 100644 --- a/src/Event/FilterFormBuiltEvent.php +++ b/src/Event/FilterFormBuiltEvent.php @@ -10,7 +10,7 @@ /** * Dispatched after a filter element built its fields on the collect-only per-filter builder, - * before {@see \HeimrichHannot\FlareBundle\Filter\Factory\FilterSetFactory} mounts them onto the + * before {@see \HeimrichHannot\FlareBundle\Filter\Factory\FormHarnessFactory} mounts them onto the * root 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 diff --git a/src/Event/FilterSetBuildEvent.php b/src/Event/FormHarnessBuildEvent.php similarity index 75% rename from src/Event/FilterSetBuildEvent.php rename to src/Event/FormHarnessBuildEvent.php index ac152ec0..9840dcd1 100644 --- a/src/Event/FilterSetBuildEvent.php +++ b/src/Event/FormHarnessBuildEvent.php @@ -12,13 +12,13 @@ * Dispatched after every filter mounted onto the root form, before the form is built. * * Listeners may modify {@see $formBuilder} or replace it wholesale; a replacement that drops - * mounted children makes {@see \HeimrichHannot\FlareBundle\Filter\FilterSet::getMount()} return + * mounted children makes {@see \HeimrichHannot\FlareBundle\Form\FormHarness::getChild()} return * null for the affected filters. * - * Also dispatched under the name `flare.filter_set.{formName}.build` - * ({@see \HeimrichHannot\FlareBundle\EventListener\NamedDispatch\FilterSetListener}). + * Also dispatched under the name `flare.form.{formName}.build` + * ({@see \HeimrichHannot\FlareBundle\EventListener\NamedDispatch\FormHarnessListener}). */ -class FilterSetBuildEvent extends Event +class FormHarnessBuildEvent extends Event { public function __construct( public readonly ListSpec $list, diff --git a/src/EventListener/NamedDispatch/FilterSetListener.php b/src/EventListener/NamedDispatch/FormHarnessListener.php similarity index 66% rename from src/EventListener/NamedDispatch/FilterSetListener.php rename to src/EventListener/NamedDispatch/FormHarnessListener.php index 5cdbc419..2bb4c53b 100644 --- a/src/EventListener/NamedDispatch/FilterSetListener.php +++ b/src/EventListener/NamedDispatch/FormHarnessListener.php @@ -4,20 +4,20 @@ namespace HeimrichHannot\FlareBundle\EventListener\NamedDispatch; -use HeimrichHannot\FlareBundle\Event\FilterSetBuildEvent; +use HeimrichHannot\FlareBundle\Event\FormHarnessBuildEvent; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; -readonly class FilterSetListener +readonly class FormHarnessListener { public function __construct( private EventDispatcherInterface $eventDispatcher, ) {} #[AsEventListener(priority: -200)] - public function onFilterSetBuildEvent(FilterSetBuildEvent $event): void + public function onFormHarnessBuildEvent(FormHarnessBuildEvent $event): void { - $eventName = "flare.filter_set.{$event->formName}.build"; + $eventName = "flare.form.{$event->formName}.build"; $this->eventDispatcher->dispatch(event: $event, eventName: $eventName); } diff --git a/src/Filter/FilterData.php b/src/Filter/FilterData.php index 362bc2c4..7303c51c 100644 --- a/src/Filter/FilterData.php +++ b/src/Filter/FilterData.php @@ -8,7 +8,7 @@ * Runtime data of one filter invocation. * * Holds either a single field's value or a compound filter's named field values — never both, - * mirroring the mount decision in {@see Factory\FilterSetFactory}: an element that declares + * mirroring the mount decision in {@see Factory\FormHarnessFactory}: an element that declares * {@see FilterFormBuilderInterface::single()} mounts flat under the filter's alias, while an * element adding children mounts as a compound sub-form. * diff --git a/src/Filter/FilterFormBuilder.php b/src/Filter/FilterFormBuilder.php index a76eefc8..3781a4ca 100644 --- a/src/Filter/FilterFormBuilder.php +++ b/src/Filter/FilterFormBuilder.php @@ -9,7 +9,7 @@ /** * Collect-only builder for a single filter's form fields. * - * Constructed manually by {@see Factory\FilterSetFactory} outside Symfony's form-type system, + * Constructed manually by {@see Factory\FormHarnessFactory} outside Symfony's form-type system, * so it carries no resolved type, options, or data mapper and must never be mounted into a form * tree — the factory transfers its children, attributes, single-field spec, and deferred event * listeners onto a real builder. Children created through add()/create() are real, factory-built diff --git a/src/Form/ChoicesBuilder.php b/src/Form/ChoicesBuilder.php index 9b24eb5b..ac834411 100644 --- a/src/Form/ChoicesBuilder.php +++ b/src/Form/ChoicesBuilder.php @@ -47,7 +47,7 @@ * * @mago-expect lint:too-many-methods */ -class ChoicesBuilder +final class ChoicesBuilder { /** * @api This is the 'choice' property of the empty option. Use as a placeholder for a empty choice option. diff --git a/src/Filter/Factory/FilterSetFactory.php b/src/Form/Factory/FormHarnessFactory.php similarity index 93% rename from src/Filter/Factory/FilterSetFactory.php rename to src/Form/Factory/FormHarnessFactory.php index 4685dfbf..89fbc3ce 100644 --- a/src/Filter/Factory/FilterSetFactory.php +++ b/src/Form/Factory/FormHarnessFactory.php @@ -7,12 +7,12 @@ use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; use HeimrichHannot\FlareBundle\Engine\Context\FormContextInterface; use HeimrichHannot\FlareBundle\Event\FilterFormBuiltEvent; -use HeimrichHannot\FlareBundle\Event\FilterSetBuildEvent; +use HeimrichHannot\FlareBundle\Event\FormHarnessBuildEvent; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilder; -use HeimrichHannot\FlareBundle\Filter\FilterMount; -use HeimrichHannot\FlareBundle\Filter\FilterSet; +use HeimrichHannot\FlareBundle\Form\FilterMount; +use HeimrichHannot\FlareBundle\Form\FormHarness; use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Util\Str; use Symfony\Component\EventDispatcher\EventDispatcher; @@ -21,7 +21,7 @@ use Symfony\Component\Form\FormFactoryInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; -final readonly class FilterSetFactory +final readonly class FormHarnessFactory { public function __construct( private EventDispatcherInterface $eventDispatcher, @@ -35,7 +35,7 @@ public function __construct( * * @throws FlareException If the form could not be built */ - public function create(ListSpec $list, FormContextInterface $context): FilterSet + public function create(ListSpec $list, FormContextInterface $context): FormHarness { if (!$context instanceof ContextInterface) { throw new FlareException( @@ -148,8 +148,8 @@ public function create(ListSpec $list, FormContextInterface $context): FilterSet * ``` */ - /** @var FilterSetBuildEvent $formBuildEvent */ - $formBuildEvent = $this->eventDispatcher->dispatch(new FilterSetBuildEvent( + /** @var FormHarnessBuildEvent $formBuildEvent */ + $formBuildEvent = $this->eventDispatcher->dispatch(new FormHarnessBuildEvent( list: $list, formName: $name, formBuilder: $root, @@ -158,6 +158,6 @@ public function create(ListSpec $list, FormContextInterface $context): FilterSet /** @var FormBuilder $root */ $root = $formBuildEvent->formBuilder; - return new FilterSet($root->getForm(), $mounts); + return new FormHarness($root->getForm(), $mounts); } } diff --git a/src/Filter/FilterMount.php b/src/Form/FilterMount.php similarity index 83% rename from src/Filter/FilterMount.php rename to src/Form/FilterMount.php index 241156fc..c3c2655d 100644 --- a/src/Filter/FilterMount.php +++ b/src/Form/FilterMount.php @@ -2,14 +2,17 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Filter; +namespace HeimrichHannot\FlareBundle\Form; + +use HeimrichHannot\FlareBundle\Filter\Filter; +use HeimrichHannot\FlareBundle\Filter\FilterContext; /** - * One filter's node in the root filter form, as recorded by {@see Factory\FilterSetFactory}. + * One filter's node in the root filter form, as recorded by {@see Factory\FormHarnessFactory}. * * Only filters that actually mounted get an entry — filters skipped for an invalid alias, for * declaring no fields, or by a cancelled {@see \HeimrichHannot\FlareBundle\Event\FilterFormBuiltEvent} - * are absent from {@see FilterSet::getMounts()}. + * are absent from {@see FormHarness::getMounts()}. * * {@see $filter} is deliberately redundant with `$context->filter`: it is the field consumers * reach for, and going through the context would be a hop through an unrelated concern. Only diff --git a/src/Filter/FilterSet.php b/src/Form/FormHarness.php similarity index 82% rename from src/Filter/FilterSet.php rename to src/Form/FormHarness.php index 30904ea6..f2b4f860 100644 --- a/src/Filter/FilterSet.php +++ b/src/Form/FormHarness.php @@ -2,27 +2,27 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Filter; +namespace HeimrichHannot\FlareBundle\Form; use Symfony\Component\Form\FormInterface; /** * The filters of one list within one form context: their root form and the mount <-> filter map. * - * Created by {@see Factory\FilterSetFactory}. Callers that only need the form go through + * Created by {@see Factory\FormHarnessFactory}. Callers that only need the form go through * {@see getForm()}; callers that need to relate a mounted node back to its filter go through * {@see getMounts()}. * * @api */ -final readonly class FilterSet +final readonly class FormHarness { /** * @param FormInterface $form Root filter form holding every mounted node. * @param array $mounts Mounts keyed by the filter's key within * {@see \HeimrichHannot\FlareBundle\List\ListSpec::$filters}. * - * @internal Use {@see Factory\FilterSetFactory} to create instances. + * @internal Use {@see Factory\FormHarnessFactory} to create instances. */ public function __construct( private FormInterface $form, @@ -42,7 +42,7 @@ public function getMounts(): array return $this->mounts; } - public function getFilterMount(string|int $key): ?FilterMount + public function getMount(string|int $key): ?FilterMount { return $this->mounts[$key] ?? null; } @@ -52,13 +52,13 @@ public function getFilterMount(string|int $key): ?FilterMount * * Null covers three cases, none of them an error: the filter never mounted (invalid alias, no * declared fields, cancelled build), a listener removed the child, or a listener replaced the - * root builder wholesale ({@see \HeimrichHannot\FlareBundle\Event\FilterSetBuildEvent::$formBuilder}). + * root builder wholesale ({@see \HeimrichHannot\FlareBundle\Event\FormHarnessBuildEvent::$formBuilder}). * * Resolution is deliberately lazy: form children may legally be added or removed by a * PRE_SUBMIT listener while the request is being handled, so the mount is looked up on every * call instead of being captured when the set was built. */ - public function getMount(string|int $key): ?FormInterface + public function getChild(string|int $key): ?FormInterface { // Compared against null, not truthiness: Str::isValidFormName() permits "0" as an alias. $alias = ($this->mounts[$key] ?? null)?->alias; diff --git a/tests/EventListener/NamedDispatch/FilterSetListenerTest.php b/tests/EventListener/NamedDispatch/FilterSetListenerTest.php index 7e1ae977..673e0068 100644 --- a/tests/EventListener/NamedDispatch/FilterSetListenerTest.php +++ b/tests/EventListener/NamedDispatch/FilterSetListenerTest.php @@ -4,8 +4,8 @@ namespace HeimrichHannot\FlareBundle\Tests\EventListener\NamedDispatch; -use HeimrichHannot\FlareBundle\Event\FilterSetBuildEvent; -use HeimrichHannot\FlareBundle\EventListener\NamedDispatch\FilterSetListener; +use HeimrichHannot\FlareBundle\Event\FormHarnessBuildEvent; +use HeimrichHannot\FlareBundle\EventListener\NamedDispatch\FormHarnessListener; use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use HeimrichHannot\FlareBundle\List\ListSpec; use PHPUnit\Framework\TestCase; @@ -17,7 +17,7 @@ final class FilterSetListenerTest extends TestCase { public function testDispatchesNamedEventForTheFormName(): void { - self::assertSame(['flare.filter_set.flare_a.build'], $this->dispatchedNames('flare_a')); + self::assertSame(['flare.form.flare_a.build'], $this->dispatchedNames('flare_a')); } public function testNamedEventIsScopedToTheFormName(): void @@ -37,9 +37,9 @@ private function dispatchedNames(string $formName): array foreach (['flare_a', 'flare_b'] as $name) { $dispatcher->addListener( - "flare.filter_set.{$name}.build", + "flare.form.{$name}.build", static function () use (&$names, $name): void { - $names[] = "flare.filter_set.{$name}.build"; + $names[] = "flare.form.{$name}.build"; }, ); } @@ -53,8 +53,8 @@ public function resolveDcTable(string $type, array $config, array $attributes): $formBuilder = Forms::createFormFactory()->createNamedBuilder($formName, FormType::class); - $listener = new FilterSetListener($dispatcher); - $listener->onFilterSetBuildEvent(new FilterSetBuildEvent( + $listener = new FormHarnessListener($dispatcher); + $listener->onFormHarnessBuildEvent(new FormHarnessBuildEvent( list: new ListSpec(driver: $driver, type: 'test_list', dc: 'tl_test'), formName: $formName, formBuilder: $formBuilder, diff --git a/tests/Filter/FilterSetFactoryTest.php b/tests/Filter/FilterSetFactoryTest.php index a0a39e7c..f88fdf74 100644 --- a/tests/Filter/FilterSetFactoryTest.php +++ b/tests/Filter/FilterSetFactoryTest.php @@ -8,18 +8,18 @@ use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; use HeimrichHannot\FlareBundle\Engine\Context\FormContextInterface; use HeimrichHannot\FlareBundle\Event\FilterFormBuiltEvent; -use HeimrichHannot\FlareBundle\Event\FilterSetBuildEvent; +use HeimrichHannot\FlareBundle\Event\FormHarnessBuildEvent; use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Factory\FilterContextFactory; -use HeimrichHannot\FlareBundle\Filter\Factory\FilterSetFactory; +use HeimrichHannot\FlareBundle\Filter\Factory\FormHarnessFactory; use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\FilterMount; -use HeimrichHannot\FlareBundle\Filter\FilterSet; +use HeimrichHannot\FlareBundle\Form\FilterMount; +use HeimrichHannot\FlareBundle\Form\FormHarness; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterOptionsResolver; use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; @@ -42,7 +42,7 @@ protected function setUp(): void $this->eventDispatcher = new EventDispatcher(); } - private function createFactory(): FilterSetFactory + private function createFactory(): FormHarnessFactory { // The CSRF extension only needs to define the "csrf_protection" option; the factory // always disables it, so the token manager is never used. @@ -50,7 +50,7 @@ private function createFactory(): FilterSetFactory ->addExtension(new CsrfExtension(new CsrfTokenManager())) ->getFormFactory(); - return new FilterSetFactory( + return new FormHarnessFactory( eventDispatcher: $this->eventDispatcher, filterContextFactory: new FilterContextFactory(new FilterOptionsResolver(new SchemaResolver())), formFactory: $formFactory, @@ -62,7 +62,7 @@ private function createForm(array $filters): FormInterface return $this->createFilterSet($filters)->getForm(); } - private function createFilterSet(array $filters): FilterSet + private function createFilterSet(array $filters): FormHarness { $driver = new class implements ListDriverInterface { public function resolveDcTable(string $type, array $config, array $attributes): string @@ -258,14 +258,14 @@ public function testMountMapRecordsSingleAndCompoundFiltersUnderTheirListSpecKey $this->assertSame(['k_single', 'k_compound'], \array_keys($filterSet->getMounts())); - $singleMount = $filterSet->getFilterMount('k_single'); + $singleMount = $filterSet->getMount('k_single'); $this->assertInstanceOf(FilterMount::class, $singleMount); $this->assertSame($singleFilter, $singleMount->filter); $this->assertSame('suche', $singleMount->alias); $this->assertSame($singleFilter, $singleMount->context->filter); $this->assertSame('k_single', $singleMount->context->key); - $compoundMount = $filterSet->getFilterMount('k_compound'); + $compoundMount = $filterSet->getMount('k_compound'); $this->assertInstanceOf(FilterMount::class, $compoundMount); $this->assertSame('range', $compoundMount->alias); $this->assertSame('k_compound', $compoundMount->context->key); @@ -281,7 +281,7 @@ public function testGetMountResolvesTheSameChildAsTheRootForm(): void 'k' => new Filter(element: $element, type: 'test_element', alias: 'suche'), ]); - $this->assertSame($filterSet->getForm()->get('suche'), $filterSet->getMount('k')); + $this->assertSame($filterSet->getForm()->get('suche'), $filterSet->getChild('k')); } public function testGetMountToleratesALeadingDigitAlias(): void @@ -296,8 +296,8 @@ public function testGetMountToleratesALeadingDigitAlias(): void 'k' => new Filter(element: $element, type: 'test_element', alias: '0'), ]); - $this->assertSame('0', $filterSet->getFilterMount('k')?->alias); - $this->assertSame($filterSet->getForm()->get('0'), $filterSet->getMount('k')); + $this->assertSame('0', $filterSet->getMount('k')?->alias); + $this->assertSame($filterSet->getForm()->get('0'), $filterSet->getChild('k')); } /** @@ -323,8 +323,8 @@ public function testUnmountedFiltersAreAbsentFromTheMountMap(string $alias, bool ]); $this->assertSame([], $filterSet->getMounts()); - $this->assertNull($filterSet->getFilterMount('k')); $this->assertNull($filterSet->getMount('k')); + $this->assertNull($filterSet->getChild('k')); } /** @@ -342,8 +342,8 @@ public function testGetMountIsNullWhenAListenerRemovedTheMountedChild(): void // A FilterSetBuildEvent listener may drop children. The map still lists the filter — the // mount is resolved against the root form on every call, so it simply reports null. $this->eventDispatcher->addListener( - FilterSetBuildEvent::class, - static function (FilterSetBuildEvent $event): void { + FormHarnessBuildEvent::class, + static function (FormHarnessBuildEvent $event): void { $event->formBuilder->remove('suche'); }, ); @@ -357,13 +357,13 @@ static function (FilterSetBuildEvent $event): void { ]); $this->assertFalse($filterSet->getForm()->has('suche')); - $this->assertSame('suche', $filterSet->getFilterMount('k')?->alias); - $this->assertNull($filterSet->getMount('k')); + $this->assertSame('suche', $filterSet->getMount('k')?->alias); + $this->assertNull($filterSet->getChild('k')); } public function testGetMountIsNullForAnUnknownKey(): void { + $this->assertNull($this->createFilterSet([])->getChild('nope')); $this->assertNull($this->createFilterSet([])->getMount('nope')); - $this->assertNull($this->createFilterSet([])->getFilterMount('nope')); } } From ad7181943e8e8217098a836184425b4c07152ad1 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 8 Sep 2026 15:58:27 +0200 Subject: [PATCH 92/96] refactor!: introduce `AbstractFilterForm` base class and update `FilterFormInterface` doc comments Added `AbstractFilterForm` as a base class for filter forms aligning with `FilterFormInterface`. Refined `FilterFormInterface` PHPDoc for clarity and simplified method parameters (`$mount` to `$form`). Adjusted tests to reflect changes in method signatures (`decode`). --- src/Filter/Form/AbstractFilterForm.php | 8 +++++ src/Filter/Form/FilterFormInterface.php | 33 +++++++++---------- .../Compiler/RegisterFilterFormsPassTest.php | 2 +- tests/Registry/FilterFormRegistryTest.php | 2 +- 4 files changed, 26 insertions(+), 19 deletions(-) create mode 100644 src/Filter/Form/AbstractFilterForm.php diff --git a/src/Filter/Form/AbstractFilterForm.php b/src/Filter/Form/AbstractFilterForm.php new file mode 100644 index 00000000..c019235a --- /dev/null +++ b/src/Filter/Form/AbstractFilterForm.php @@ -0,0 +1,8 @@ +getData(). + * - Receives the *mount*: the node this form mounted into the root form. Either a flat field or a + * compound group. + * - Only the form knows whether it wants `getData()`, `getNormData()` or `getViewData()`, and it may + * need attributes set in {@see self::buildForm()}. + * - The "user explicitly cleared" vs. "user never interacted" distinction is answerable here via + * `isSubmitted()` / `getConfig()->getData()`. * - * @return object|null A value object from `src/Filter/Value/`, of the class this form is - * registered for. Null contributes nothing, which lets the element fall back to its own - * config (§3.2, §4.2). + * @return ValueInterface|null A value object of the class this form is registered for. + * Null contributes nothing, which lets the element fall back to its own config. */ - public function decode(FormInterface $mount, FilterContext $context): ?object; + public function decode(FormInterface $form, FilterContext $context): ?ValueInterface; } diff --git a/tests/DependencyInjection/Compiler/RegisterFilterFormsPassTest.php b/tests/DependencyInjection/Compiler/RegisterFilterFormsPassTest.php index a828cf93..fc6d8afd 100644 --- a/tests/DependencyInjection/Compiler/RegisterFilterFormsPassTest.php +++ b/tests/DependencyInjection/Compiler/RegisterFilterFormsPassTest.php @@ -307,7 +307,7 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co { } - public function decode(FormInterface $mount, FilterContext $context): ?object + public function decode(FormInterface $form, FilterContext $context): ?object { return null; } diff --git a/tests/Registry/FilterFormRegistryTest.php b/tests/Registry/FilterFormRegistryTest.php index 3f7076a1..a0cbb949 100644 --- a/tests/Registry/FilterFormRegistryTest.php +++ b/tests/Registry/FilterFormRegistryTest.php @@ -244,7 +244,7 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co { } - public function decode(FormInterface $mount, FilterContext $context): ?object + public function decode(FormInterface $form, FilterContext $context): ?object { return null; } From 311db8b2f09d11d991425cbe710079b795cffceb Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Tue, 8 Sep 2026 19:26:34 +0200 Subject: [PATCH 93/96] refactor!: migrate from `Logic` terminology to `Predicate`, and deprecate `LogicStep` and `FilterExecutor` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebranded all `Logic` classes and interfaces as `Predicate` to improve naming consistency and clarity across the codebase. Updated dependencies, tests, events, and registry mappings accordingly. Removed `LogicStep` and `FilterExecutor` entirely, along with their usages, as part of a broader simplification. Adjusted `ListSpec` filters to omit alias generation, enforcing cleaner filter registration logic. Includes fixes and updates to relevant PHPDoc, method signatures, and event dispatch aliasing (`LogicSequencerInterface` → `FormulaBuilderInterface`). --- AGENTS.md | 4 +- SPEC_FILTER_FORMS.md | 2 +- src/Engine/Loader/ValidationLoader.php | 2 + src/Engine/Mod/SimpleEquationMod.php | 4 +- src/Engine/Projector/InteractiveProjector.php | 4 +- src/Event/FilterElementBuildingEvent.php | 4 +- src/Event/FilterElementBuiltEvent.php | 4 +- src/Filter/Element/AbstractFilterElement.php | 6 +- src/Filter/Element/ArchiveFilterElement.php | 16 +- .../BelongsToRelationFilterElement.php | 16 +- src/Filter/Element/BooleanFilterElement.php | 12 +- .../Element/CalendarCurrentFilterElement.php | 12 +- src/Filter/Element/DateRangeFilterElement.php | 14 +- .../Element/DcaSelectFieldFilterElement.php | 12 +- .../Element/FieldValueChoiceFilterElement.php | 12 +- src/Filter/Element/FilterElementInterface.php | 12 +- src/Filter/Element/PublishedFilterElement.php | 10 +- .../Element/SearchKeywordsFilterElement.php | 12 +- .../Element/SimpleEquationFilterElement.php | 10 +- .../Factory/FilterContextBuilderFactory.php | 30 +++ src/Filter/Factory/FilterContextFactory.php | 39 ++-- src/Filter/Factory/FilterFactory.php | 6 +- src/Filter/Factory/FormulaBuilderFactory.php | 21 +++ src/Filter/Filter.php | 14 +- src/Filter/FilterContext.php | 3 +- src/Filter/FilterContextBuilder.php | 58 ++++++ src/Filter/Formula.php | 17 ++ src/Filter/FormulaBuilder.php | 72 ++++++++ src/Filter/FormulaBuilderInterface.php | 20 ++ src/Filter/LogicSequencer.php | 72 -------- src/Filter/LogicSequencerInterface.php | 23 --- src/Filter/LogicStep.php | 17 -- .../AbstractPredicate.php} | 4 +- .../ArchivePredicate.php} | 4 +- .../BelongsToRelationPredicate.php} | 4 +- .../BooleanPredicate.php} | 4 +- .../CalendarCurrentPredicate.php} | 4 +- .../DateRangePredicate.php} | 4 +- .../DcaSelectPredicate.php} | 4 +- .../FieldValueChoicePredicate.php} | 4 +- .../IntegerIdChoicePredicate.php} | 4 +- .../PredicateInterface.php} | 8 +- .../PublishedPredicate.php} | 4 +- .../SearchKeywordsPredicate.php} | 4 +- .../SimpleEquationPredicate.php} | 4 +- src/Filter/Proposition.php | 17 ++ src/Form/Factory/FormHarnessFactory.php | 4 +- .../CodefogTagsChoiceFilterElement.php | 12 +- src/List/ListSpec.php | 26 +-- src/Query/Executor/FilterExecutor.php | 171 ------------------ src/Query/Executor/ListQueryDirector.php | 87 ++++++++- src/Query/ListQueryConfig.php | 2 +- ...gistry.php => FilterPredicateRegistry.php} | 20 +- src/Util/CreatesFilterExceptionTrait.php | 23 +++ .../Projector/InteractiveProjectorTest.php | 11 +- tests/Filter/FilterBuilderTest.php | 40 ++-- tests/Filter/FilterFactoryTest.php | 11 +- tests/Filter/FilterOptionsResolverTest.php | 8 +- tests/Filter/FilterSetFactoryTest.php | 11 +- tests/Filter/FilterTest.php | 11 +- .../Filter/FilterTransformerResolverTest.php | 8 +- tests/List/ListSpecBuilderTest.php | 11 +- tests/List/ListSpecTest.php | 11 +- tests/List/StubFilterElement.php | 6 +- tests/Query/Executor/FilterExecutorTest.php | 12 +- tests/Registry/FilterElementRegistryTest.php | 6 +- 66 files changed, 611 insertions(+), 513 deletions(-) create mode 100644 src/Filter/Factory/FilterContextBuilderFactory.php create mode 100644 src/Filter/Factory/FormulaBuilderFactory.php create mode 100644 src/Filter/FilterContextBuilder.php create mode 100644 src/Filter/Formula.php create mode 100644 src/Filter/FormulaBuilder.php create mode 100644 src/Filter/FormulaBuilderInterface.php delete mode 100644 src/Filter/LogicSequencer.php delete mode 100644 src/Filter/LogicSequencerInterface.php delete mode 100644 src/Filter/LogicStep.php rename src/Filter/{Logic/AbstractLogic.php => Predicate/AbstractPredicate.php} (73%) rename src/Filter/{Logic/ArchiveLogic.php => Predicate/ArchivePredicate.php} (90%) rename src/Filter/{Logic/BelongsToRelationLogic.php => Predicate/BelongsToRelationPredicate.php} (96%) rename src/Filter/{Logic/BooleanLogic.php => Predicate/BooleanPredicate.php} (87%) rename src/Filter/{Logic/CalendarCurrentLogic.php => Predicate/CalendarCurrentPredicate.php} (93%) rename src/Filter/{Logic/DateRangeLogic.php => Predicate/DateRangePredicate.php} (91%) rename src/Filter/{Logic/DcaSelectLogic.php => Predicate/DcaSelectPredicate.php} (95%) rename src/Filter/{Logic/FieldValueChoiceLogic.php => Predicate/FieldValueChoicePredicate.php} (89%) rename src/Filter/{Logic/IntegerIdChoiceLogic.php => Predicate/IntegerIdChoicePredicate.php} (90%) rename src/Filter/{Logic/LogicInterface.php => Predicate/PredicateInterface.php} (72%) rename src/Filter/{Logic/PublishedLogic.php => Predicate/PublishedPredicate.php} (94%) rename src/Filter/{Logic/SearchKeywordsLogic.php => Predicate/SearchKeywordsPredicate.php} (94%) rename src/Filter/{Logic/SimpleEquationLogic.php => Predicate/SimpleEquationPredicate.php} (96%) create mode 100644 src/Filter/Proposition.php delete mode 100644 src/Query/Executor/FilterExecutor.php rename src/Registry/{FilterLogicRegistry.php => FilterPredicateRegistry.php} (61%) create mode 100644 src/Util/CreatesFilterExceptionTrait.php diff --git a/AGENTS.md b/AGENTS.md index 2d94d326..0a2c4a03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ The bundle follows standard Symfony Bundle architecture with deep Contao integra **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`, `buildLogic`, `buildList`, `buildTableRegistry`/`buildBaseQuery`). +(`buildDca`, `buildForm`, `buildContext`, `buildList`, `buildTableRegistry`/`buildBaseQuery`). **Notable subsystems** (beyond the flow above): - `src/List/` — `ListSpec` (immutable list DTO: type, dc, filters, canonical config, source), `ListBuilder` @@ -81,7 +81,7 @@ tl_flare_filter and tl_flare_list). 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`, `FilterFormRegistry`, `ListDriverRegistry`, `FilterLogicRegistry`, `ProjectorRegistry`, `EngineModRegistry`. `FilterFormRegistry` differs from the others: it holds +**Registry pattern** — Registries in `src/Registry/` map type names to implementations: `FilterElementRegistry`, `FilterFormRegistry`, `ListDriverRegistry`, `FilterPredicateRegistry`, `ProjectorRegistry`, `EngineModRegistry`. `FilterFormRegistry` differs from the others: it holds compile-time metadata as plain arrays and resolves the form services through a lazy `container.service_locator`, so reading metadata (the `formVariant` options, the form election) instantiates nothing. diff --git a/SPEC_FILTER_FORMS.md b/SPEC_FILTER_FORMS.md index dd55bfbf..f630acb4 100644 --- a/SPEC_FILTER_FORMS.md +++ b/SPEC_FILTER_FORMS.md @@ -353,7 +353,7 @@ precise than the interface it replaces, and it removes: `DcaBuilder::palette()` currently *replaces*, and `apply()` writes one slot: ```php -$dca['palettes'][$type] = Str::mergePalettes($prefix, $this->palette, $suffix); +$dca['palettes'][$predicate] = Str::mergePalettes($prefix, $this->palette, $suffix); ``` A third segment is required, with fixed order: diff --git a/src/Engine/Loader/ValidationLoader.php b/src/Engine/Loader/ValidationLoader.php index 3f8284a3..4b518841 100644 --- a/src/Engine/Loader/ValidationLoader.php +++ b/src/Engine/Loader/ValidationLoader.php @@ -45,6 +45,7 @@ public function fetchEntryById(int $id): ?array { $idDefinition = $this->filterFactory->create( element: SimpleEquationFilterElement::TYPE, + alias: '_.id', config: [ 'intrinsic' => true, 'left' => 'id', @@ -94,6 +95,7 @@ public function fetchEntryByAutoItem(string $autoItem): ?array { $autoItemDefinition = $this->filterFactory->create( element: SimpleEquationFilterElement::TYPE, + alias: '_.autoItem', config: [ 'intrinsic' => true, 'left' => $this->config->autoItemField, diff --git a/src/Engine/Mod/SimpleEquationMod.php b/src/Engine/Mod/SimpleEquationMod.php index 1a447e2d..f3215a27 100644 --- a/src/Engine/Mod/SimpleEquationMod.php +++ b/src/Engine/Mod/SimpleEquationMod.php @@ -8,6 +8,7 @@ use HeimrichHannot\FlareBundle\Enum\SqlEquationOperator; use HeimrichHannot\FlareBundle\Filter\Element\SimpleEquationFilterElement; use HeimrichHannot\FlareBundle\Filter\Factory\FilterFactory; +use HeimrichHannot\FlareBundle\Util\Str; use Symfony\Component\OptionsResolver\OptionsResolver; class SimpleEquationMod extends AbstractMod @@ -25,6 +26,7 @@ public function __invoke(Engine $engine, array $options): void { $filter = $this->filterFactory->create( element: SimpleEquationFilterElement::TYPE, + alias: $options['name'] ?: ('_.equation_' . Str::random(8)), config: [ 'intrinsic' => true, 'left' => $options['operand1'], @@ -33,7 +35,7 @@ public function __invoke(Engine $engine, array $options): void ], ); - $engine->setList($engine->getList()->withFilter($filter, $options['name'] ?: null)); + $engine->setList($engine->getList()->withFilter($filter)); } public function configureOptions(OptionsResolver $resolver): void diff --git a/src/Engine/Projector/InteractiveProjector.php b/src/Engine/Projector/InteractiveProjector.php index 37859555..cdd57e2b 100644 --- a/src/Engine/Projector/InteractiveProjector.php +++ b/src/Engine/Projector/InteractiveProjector.php @@ -143,11 +143,11 @@ protected function collectFilterData(ListSpec $list, FormInterface $form): array foreach ($list->filters as $key => $filter) { - if (!$filter->alias || !$form->has($filter->alias)) { + if (!$form->has($key)) { continue; } - $child = $form->get($filter->alias); + $child = $form->get($key); if ($child->getConfig()->getAttribute(FilterContext::ATTR_SINGLE_FIELD)) { diff --git a/src/Event/FilterElementBuildingEvent.php b/src/Event/FilterElementBuildingEvent.php index 8b35bd96..e596f89c 100644 --- a/src/Event/FilterElementBuildingEvent.php +++ b/src/Event/FilterElementBuildingEvent.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\Event; -use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; +use HeimrichHannot\FlareBundle\Filter\FormulaBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use Symfony\Contracts\EventDispatcher\Event; @@ -13,7 +13,7 @@ class FilterElementBuildingEvent extends Event { public function __construct( public readonly FilterContext $context, - public readonly LogicSequencerInterface $builder, + public readonly FormulaBuilderInterface $builder, public readonly FilterData $data, public bool $shouldBuild = true, ) {} diff --git a/src/Event/FilterElementBuiltEvent.php b/src/Event/FilterElementBuiltEvent.php index 2166051e..3b23daeb 100644 --- a/src/Event/FilterElementBuiltEvent.php +++ b/src/Event/FilterElementBuiltEvent.php @@ -4,7 +4,7 @@ namespace HeimrichHannot\FlareBundle\Event; -use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; +use HeimrichHannot\FlareBundle\Filter\FormulaBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use Symfony\Contracts\EventDispatcher\Event; @@ -13,7 +13,7 @@ class FilterElementBuiltEvent extends Event { public function __construct( public readonly FilterContext $context, - public readonly LogicSequencerInterface $builder, + public readonly FormulaBuilderInterface $builder, public readonly FilterData $data, ) {} } diff --git a/src/Filter/Element/AbstractFilterElement.php b/src/Filter/Element/AbstractFilterElement.php index a2bed222..beaba43e 100644 --- a/src/Filter/Element/AbstractFilterElement.php +++ b/src/Filter/Element/AbstractFilterElement.php @@ -15,10 +15,12 @@ use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\Filter\CallbackFilterModelTransformer; -use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; +use HeimrichHannot\FlareBundle\Filter\FilterContextBuilder; +use HeimrichHannot\FlareBundle\Filter\FormulaBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\Value\ValueInterface; use HeimrichHannot\FlareBundle\Form\ChoicesBuilder; use HeimrichHannot\FlareBundle\Form\Factory\ChoicesBuilderFactory; use HeimrichHannot\FlareBundle\Model\FilterModel; @@ -51,7 +53,7 @@ public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void {} public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} - public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void {} + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void {} public function isSupported(): bool { diff --git a/src/Filter/Element/ArchiveFilterElement.php b/src/Filter/Element/ArchiveFilterElement.php index b0abc806..9ba9eec5 100644 --- a/src/Filter/Element/ArchiveFilterElement.php +++ b/src/Filter/Element/ArchiveFilterElement.php @@ -12,12 +12,14 @@ use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Exception\FilterException; -use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; +use HeimrichHannot\FlareBundle\Filter\FilterContextBuilder; +use HeimrichHannot\FlareBundle\Filter\FormulaBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\Logic\ArchiveLogic; -use HeimrichHannot\FlareBundle\Filter\Logic\BelongsToRelationLogic; +use HeimrichHannot\FlareBundle\Filter\Predicate\ArchivePredicate; +use HeimrichHannot\FlareBundle\Filter\Predicate\BelongsToRelationPredicate; +use HeimrichHannot\FlareBundle\Filter\Value\ValueInterface; use HeimrichHannot\FlareBundle\Form\ChoicesBuilder; use HeimrichHannot\FlareBundle\InferPtable\Factory\PtableInferrableFactory; use HeimrichHannot\FlareBundle\InferPtable\PtableInferrer; @@ -171,14 +173,14 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co /** * @throws FilterException */ - public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void { $config = $context->config; /** @var Model[] $selectedModels */ $selectedModels = $config['intrinsic'] ? $this->getWhitelistedParents($context->list, $config) - : $this->processRuntimeValue($data->getSingleValue(), $context->list, $config); + : $this->processRuntimeValue($value->getSingleValue(), $context->list, $config); $inferrer = $this->getPtableInferrer($context->list); @@ -197,7 +199,7 @@ public function buildLogic(LogicSequencerInterface $builder, FilterContext $cont throw new FilterException('No valid parent archive ids extracted.', method: __METHOD__); } - $builder->add(ArchiveLogic::class, [ + $builder->addPredicate(ArchivePredicate::class, [ 'field' => 'pid', 'parent_ids' => $pids, ]); @@ -225,7 +227,7 @@ public function buildLogic(LogicSequencerInterface $builder, FilterContext $cont } } - $builder->add(BelongsToRelationLogic::class, [ + $builder->add(BelongsToRelationPredicate::class, [ 'field_pid' => 'pid', 'field_dynamic_ptable' => 'ptable', 'parent_groups' => $this->getDynamicParentGroups($config), diff --git a/src/Filter/Element/BelongsToRelationFilterElement.php b/src/Filter/Element/BelongsToRelationFilterElement.php index be55f365..61e82ca0 100644 --- a/src/Filter/Element/BelongsToRelationFilterElement.php +++ b/src/Filter/Element/BelongsToRelationFilterElement.php @@ -12,10 +12,12 @@ use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Exception\InferenceException; -use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; +use HeimrichHannot\FlareBundle\Filter\FilterContextBuilder; +use HeimrichHannot\FlareBundle\Filter\FormulaBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; -use HeimrichHannot\FlareBundle\Filter\Logic\BelongsToRelationLogic; +use HeimrichHannot\FlareBundle\Filter\Predicate\BelongsToRelationPredicate; +use HeimrichHannot\FlareBundle\Filter\Value\ValueInterface; use HeimrichHannot\FlareBundle\InferPtable\Factory\PtableInferrableFactory; use HeimrichHannot\FlareBundle\InferPtable\PtableInferrer; use HeimrichHannot\FlareBundle\Model\FilterModel; @@ -61,7 +63,7 @@ protected function transformFilterModel(ConfigBuilder $config, FilterModel $mode /** * @throws FilterException */ - public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void { $config = $context->config; @@ -85,7 +87,7 @@ public function buildLogic(LogicSequencerInterface $builder, FilterContext $cont if (\is_string($fieldDynamicPtable)) { - $builder->add(BelongsToRelationLogic::class, [ + $builder->add(BelongsToRelationPredicate::class, [ 'field_pid' => $fieldPid, 'field_dynamic_ptable' => $fieldDynamicPtable, 'parent_groups' => $this->getDynamicParentGroups($config['group_whitelist_parents']), @@ -98,7 +100,7 @@ public function buildLogic(LogicSequencerInterface $builder, FilterContext $cont throw new FilterException('No whitelisted parents.'); } - $builder->add(BelongsToRelationLogic::class, [ + $builder->add(BelongsToRelationPredicate::class, [ 'field_pid' => $fieldPid, 'whitelist' => $whitelistParents, ]); @@ -117,13 +119,13 @@ public function buildLogic(LogicSequencerInterface $builder, FilterContext $cont * `group_whitelist_parents` config key. */ public function addDynamicPtableFilter( - LogicSequencerInterface $builder, + FormulaBuilderInterface $builder, array $groupWhitelistParents, string $fieldDynamicPtable, string $fieldPid, ?array $submittedData = null, ): void { - $builder->add(BelongsToRelationLogic::class, [ + $builder->add(BelongsToRelationPredicate::class, [ 'field_pid' => $fieldPid, 'field_dynamic_ptable' => $fieldDynamicPtable, 'parent_groups' => $this->getDynamicParentGroups($groupWhitelistParents), diff --git a/src/Filter/Element/BooleanFilterElement.php b/src/Filter/Element/BooleanFilterElement.php index 0cedbe75..6a228715 100644 --- a/src/Filter/Element/BooleanFilterElement.php +++ b/src/Filter/Element/BooleanFilterElement.php @@ -12,11 +12,13 @@ use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Enum\BoolBinaryChoices; use HeimrichHannot\FlareBundle\Enum\BoolMode; -use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; +use HeimrichHannot\FlareBundle\Filter\FilterContextBuilder; +use HeimrichHannot\FlareBundle\Filter\FormulaBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\Logic\BooleanLogic; +use HeimrichHannot\FlareBundle\Filter\Predicate\BooleanPredicate; +use HeimrichHannot\FlareBundle\Filter\Value\ValueInterface; use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -59,7 +61,7 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co ]); } - public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void { $config = $context->config; @@ -69,13 +71,13 @@ public function buildLogic(LogicSequencerInterface $builder, FilterContext $cont $value = $config['intrinsic'] ? $config['preselect'] - : $this->resolveRuntimeValue($data->getSingleValue(), $config); + : $this->resolveRuntimeValue($value->getSingleValue(), $config); if ($value === null) { return; } - $builder->add(BooleanLogic::class, [ + $builder->add(BooleanPredicate::class, [ 'field' => $targetField, 'value' => $value, ]); diff --git a/src/Filter/Element/CalendarCurrentFilterElement.php b/src/Filter/Element/CalendarCurrentFilterElement.php index e0b20458..b1e7400d 100644 --- a/src/Filter/Element/CalendarCurrentFilterElement.php +++ b/src/Filter/Element/CalendarCurrentFilterElement.php @@ -9,11 +9,13 @@ use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Engine\Context\ValidationContext; -use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; +use HeimrichHannot\FlareBundle\Filter\FilterContextBuilder; +use HeimrichHannot\FlareBundle\Filter\FormulaBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\Logic\CalendarCurrentLogic; +use HeimrichHannot\FlareBundle\Filter\Predicate\CalendarCurrentPredicate; +use HeimrichHannot\FlareBundle\Filter\Value\ValueInterface; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Util\DateTimeHelper; use Symfony\Component\Form\Extension\Core\Type\DateType; @@ -96,7 +98,7 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co $builder->addEventListener(FormEvents::POST_SUBMIT, $this->validateRange(...)); } - public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void { $config = $context->config; @@ -104,7 +106,7 @@ public function buildLogic(LogicSequencerInterface $builder, FilterContext $cont return; } - $value = $this->processRuntimeValue($data) ?? []; + $value = $this->processRuntimeValue($value) ?? []; $from = $value['from'] ?? null; $to = $value['to'] ?? null; @@ -129,7 +131,7 @@ public function buildLogic(LogicSequencerInterface $builder, FilterContext $cont } } - $builder->add(CalendarCurrentLogic::class, [ + $builder->add(CalendarCurrentPredicate::class, [ 'start' => $start, 'stop' => $stop, 'has_extended_events' => $config['has_extended_events'], diff --git a/src/Filter/Element/DateRangeFilterElement.php b/src/Filter/Element/DateRangeFilterElement.php index d9dacd3c..5a065c89 100644 --- a/src/Filter/Element/DateRangeFilterElement.php +++ b/src/Filter/Element/DateRangeFilterElement.php @@ -9,11 +9,13 @@ use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Exception\FilterException; -use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; +use HeimrichHannot\FlareBundle\Filter\FilterContextBuilder; +use HeimrichHannot\FlareBundle\Filter\FormulaBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\Logic\DateRangeLogic; +use HeimrichHannot\FlareBundle\Filter\Predicate\DateRangePredicate; +use HeimrichHannot\FlareBundle\Filter\Value\ValueInterface; use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\Form\Extension\Core\Type\DateType; use Symfony\Component\Form\FormError; @@ -76,16 +78,16 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co /** * @throws FilterException */ - public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void { if (!$field = $context->config['field']) { throw new FilterException('Set fieldGeneric in filter model.'); } - $builder->add(DateRangeLogic::class, [ + $builder->add(DateRangePredicate::class, [ 'field' => $field, - 'from' => $data->get('from'), - 'to' => $data->get('to'), + 'from' => $value->get('from'), + 'to' => $value->get('to'), ]); } diff --git a/src/Filter/Element/DcaSelectFieldFilterElement.php b/src/Filter/Element/DcaSelectFieldFilterElement.php index 2299f854..8bd5a5e3 100644 --- a/src/Filter/Element/DcaSelectFieldFilterElement.php +++ b/src/Filter/Element/DcaSelectFieldFilterElement.php @@ -12,11 +12,13 @@ use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; -use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; +use HeimrichHannot\FlareBundle\Filter\FilterContextBuilder; +use HeimrichHannot\FlareBundle\Filter\FormulaBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\Logic\DcaSelectLogic; +use HeimrichHannot\FlareBundle\Filter\Predicate\DcaSelectPredicate; +use HeimrichHannot\FlareBundle\Filter\Value\ValueInterface; use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -96,14 +98,14 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co $builder->single(ChoiceType::class, $formOptions); } - public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void { $config = $context->config; $options = $this->getOptions($context->list->dc, $config['field']) ?? []; $selected = $config['intrinsic'] ? $config['preselect'] - : $this->normalizeSubmittedValue($data->getSingleValue(), $options); + : $this->normalizeSubmittedValue($value->getSingleValue(), $options); if (!$selected) { return; @@ -124,7 +126,7 @@ public function buildLogic(LogicSequencerInterface $builder, FilterContext $cont $dcaOptionsField = $this->getOptionsField($context->list->dc, $config['field']) ?? []; $isMultiple = $dcaOptionsField['eval']['multiple'] ?? false; - $builder->add(DcaSelectLogic::class, [ + $builder->add(DcaSelectPredicate::class, [ 'field' => $targetField, 'selected' => $selected, 'valid_options' => $options, diff --git a/src/Filter/Element/FieldValueChoiceFilterElement.php b/src/Filter/Element/FieldValueChoiceFilterElement.php index dff7f054..5bd1d4b2 100644 --- a/src/Filter/Element/FieldValueChoiceFilterElement.php +++ b/src/Filter/Element/FieldValueChoiceFilterElement.php @@ -13,11 +13,13 @@ use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Engine\Context\ValidationContext; -use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; +use HeimrichHannot\FlareBundle\Filter\FilterContextBuilder; +use HeimrichHannot\FlareBundle\Filter\FormulaBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\Logic\FieldValueChoiceLogic; +use HeimrichHannot\FlareBundle\Filter\Predicate\FieldValueChoicePredicate; +use HeimrichHannot\FlareBundle\Filter\Value\ValueInterface; use HeimrichHannot\FlareBundle\Form\ChoicesBuilder; use HeimrichHannot\FlareBundle\Form\Factory\ChoicesBuilderFactory; use HeimrichHannot\FlareBundle\Model\FilterModel; @@ -84,7 +86,7 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co $builder->setAttribute('flare.choices_builder', $choicesBuilder); } - public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void { if ($context->engineContext instanceof ValidationContext) { return; @@ -98,13 +100,13 @@ public function buildLogic(LogicSequencerInterface $builder, FilterContext $cont $value = $config['intrinsic'] ? $config['preselect'] - : $this->normalizeRuntimeValue($data->getSingleValue(), $context); + : $this->normalizeRuntimeValue($value->getSingleValue(), $context); if (!$value) { return; } - $builder->add(FieldValueChoiceLogic::class, [ + $builder->add(FieldValueChoicePredicate::class, [ 'field' => $field, 'values' => $value, ]); diff --git a/src/Filter/Element/FilterElementInterface.php b/src/Filter/Element/FilterElementInterface.php index 65b6a59e..e7e491b6 100644 --- a/src/Filter/Element/FilterElementInterface.php +++ b/src/Filter/Element/FilterElementInterface.php @@ -4,10 +4,12 @@ namespace HeimrichHannot\FlareBundle\Filter\Element; -use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; +use HeimrichHannot\FlareBundle\Filter\FilterContextBuilder; +use HeimrichHannot\FlareBundle\Filter\FormulaBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\Value\ValueInterface; interface FilterElementInterface { @@ -28,10 +30,8 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co /** * Translates canonical config and runtime data into filter type calls. * - * @param FilterData $data Submitted form data of this filter — {@see FilterData::get()} by - * the local field names declared in buildForm(), or {@see FilterData::getSingleValue()} - * for a single() field — or the programmatically set {@see \HeimrichHannot\FlareBundle\Filter\Filter::$data}; - * {@see FilterData::none()} when neither exists (e.g. non-interactive contexts). + * @param FilterContextBuilder $builder + * @param ValueInterface|null $value */ - public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void; + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void; } diff --git a/src/Filter/Element/PublishedFilterElement.php b/src/Filter/Element/PublishedFilterElement.php index 685c1d50..fb6745b0 100644 --- a/src/Filter/Element/PublishedFilterElement.php +++ b/src/Filter/Element/PublishedFilterElement.php @@ -8,10 +8,12 @@ use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; -use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; +use HeimrichHannot\FlareBundle\Filter\FilterContextBuilder; +use HeimrichHannot\FlareBundle\Filter\FormulaBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; -use HeimrichHannot\FlareBundle\Filter\Logic\PublishedLogic; +use HeimrichHannot\FlareBundle\Filter\Predicate\PublishedPredicate; +use HeimrichHannot\FlareBundle\Filter\Value\ValueInterface; use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -51,11 +53,11 @@ protected function transformFilterModel(ConfigBuilder $config, FilterModel $mode ->set('invert', (bool) $model->invertPublished); } - public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void { $config = $context->config; - $builder->add(PublishedLogic::class, [ + $builder->add(PublishedPredicate::class, [ 'published_field' => $config['published_field'], 'start_field' => $config['start_field'], 'stop_field' => $config['stop_field'], diff --git a/src/Filter/Element/SearchKeywordsFilterElement.php b/src/Filter/Element/SearchKeywordsFilterElement.php index 3d192c55..8dc205af 100644 --- a/src/Filter/Element/SearchKeywordsFilterElement.php +++ b/src/Filter/Element/SearchKeywordsFilterElement.php @@ -9,11 +9,13 @@ use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; -use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; +use HeimrichHannot\FlareBundle\Filter\FilterContextBuilder; +use HeimrichHannot\FlareBundle\Filter\FormulaBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\Logic\SearchKeywordsLogic; +use HeimrichHannot\FlareBundle\Filter\Predicate\SearchKeywordsPredicate; +use HeimrichHannot\FlareBundle\Filter\Value\ValueInterface; use HeimrichHannot\FlareBundle\Model\FilterModel; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -62,13 +64,13 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co $builder->single(TextType::class, $options); } - public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void { $config = $context->config; $value = $config['intrinsic'] ? $config['prefill'] - : $data->getSingleValue(); + : $value->getSingleValue(); if (!$value || !\is_string($value)) { return; @@ -78,7 +80,7 @@ public function buildLogic(LogicSequencerInterface $builder, FilterContext $cont return; } - $builder->add(SearchKeywordsLogic::class, [ + $builder->add(SearchKeywordsPredicate::class, [ 'value' => $value, 'columns' => $columns, ]); diff --git a/src/Filter/Element/SimpleEquationFilterElement.php b/src/Filter/Element/SimpleEquationFilterElement.php index 778fb867..44f7011a 100644 --- a/src/Filter/Element/SimpleEquationFilterElement.php +++ b/src/Filter/Element/SimpleEquationFilterElement.php @@ -10,10 +10,12 @@ use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Enum\SqlEquationOperator; use HeimrichHannot\FlareBundle\Exception\FilterException; -use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; +use HeimrichHannot\FlareBundle\Filter\FilterContextBuilder; +use HeimrichHannot\FlareBundle\Filter\FormulaBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; -use HeimrichHannot\FlareBundle\Filter\Logic\SimpleEquationLogic; +use HeimrichHannot\FlareBundle\Filter\Predicate\SimpleEquationPredicate; +use HeimrichHannot\FlareBundle\Filter\Value\ValueInterface; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Util\DcaHelper; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -48,7 +50,7 @@ protected function transformFilterModel(ConfigBuilder $config, FilterModel $mode /** * @throws FilterException */ - public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void { $config = $context->config; @@ -56,7 +58,7 @@ public function buildLogic(LogicSequencerInterface $builder, FilterContext $cont throw new FilterException('Invalid filter configuration.'); } - $builder->add(SimpleEquationLogic::class, [ + $builder->add(SimpleEquationPredicate::class, [ 'operand_left' => $operand, 'operator' => $op, 'operand_right' => $config['right'], diff --git a/src/Filter/Factory/FilterContextBuilderFactory.php b/src/Filter/Factory/FilterContextBuilderFactory.php new file mode 100644 index 00000000..b4bb4fdb --- /dev/null +++ b/src/Filter/Factory/FilterContextBuilderFactory.php @@ -0,0 +1,30 @@ +filterOptionsResolver->resolve($filter); + + return new FilterContextBuilder( + formulaBuilderFactory: $this->formulaBuilderFactory, + list: $list, + filter: $filter, + engineContext: $engineContext, + config: $config, + ); + } +} diff --git a/src/Filter/Factory/FilterContextFactory.php b/src/Filter/Factory/FilterContextFactory.php index 25c05078..9851c027 100644 --- a/src/Filter/Factory/FilterContextFactory.php +++ b/src/Filter/Factory/FilterContextFactory.php @@ -5,11 +5,14 @@ namespace HeimrichHannot\FlareBundle\Filter\Factory; use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; +use HeimrichHannot\FlareBundle\Exception\AbortFilteringException; use HeimrichHannot\FlareBundle\Exception\FilterException; +use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\Filter; use HeimrichHannot\FlareBundle\Filter\FilterContext; -use HeimrichHannot\FlareBundle\Filter\Resolver\FilterOptionsResolver; use HeimrichHannot\FlareBundle\List\ListSpec; +use HeimrichHannot\FlareBundle\Util\CreatesFilterExceptionTrait; +use HeimrichHannot\FlareBundle\Util\Str; /** * Builds the invocation context handed to filter elements, resolving the filter's @@ -18,24 +21,30 @@ final readonly class FilterContextFactory { public function __construct( - private FilterOptionsResolver $filterOptionsResolver, + private FilterContextBuilderFactory $filterContextBuilderFactory, ) {} /** * @throws FilterException If the filter's config violates the element's schema + * @throws FlareException */ - public function create( - ListSpec $list, - Filter $filter, - ContextInterface $engineContext, - string|int|null $key = null, - ): FilterContext { - return new FilterContext( - list: $list, - filter: $filter, - config: $this->filterOptionsResolver->resolve($filter), - engineContext: $engineContext, - key: $key, - ); + public function create(ListSpec $list, Filter $filter, ContextInterface $engineContext): FilterContext + { + if (!Str::isValidSqlName($table = $list->dc)) + { + throw new FlareException(\sprintf( + '[FLARE] ListSpec data container cannot be used as SQL table identifier: "%s"', + $table + ), method: __METHOD__, source: $filter->source ?: 'filter inlined'); + } + + $contextBuilder = $this->filterContextBuilderFactory->create($list, $filter, $engineContext); + + // $data = $options->filterValues[$key] ?? $filter->data ?? FilterData::none(); + // todo: pass filter data to buildContext() so that elements can access it when building the filter + + $filter->element->buildContext($contextBuilder, null); + + return $contextBuilder->build(); } } diff --git a/src/Filter/Factory/FilterFactory.php b/src/Filter/Factory/FilterFactory.php index 09ef32df..1176696c 100644 --- a/src/Filter/Factory/FilterFactory.php +++ b/src/Filter/Factory/FilterFactory.php @@ -32,9 +32,9 @@ public function __construct( */ public function create( FilterElementInterface|string $element, + string $alias, array $config = [], ?FilterData $data = null, - ?string $alias = null, ?string $targetAlias = null, bool $targetingForced = false, ?string $source = null, @@ -45,9 +45,9 @@ public function create( return new Filter( element: $element, type: $type, + alias: $alias, config: $config, data: $data, - alias: $alias, targetAlias: $targetAlias, targetingForced: $targetingForced, source: $source, @@ -69,8 +69,8 @@ public function createFromFilterModel( return new Filter( element: $element, type: $type, + alias: $filterModel->getFilterFormName(), config: $config, - alias: $filterModel->getFilterFormName() ?: "_.{$source}", targetAlias: $filterModel->getFilterTargetAlias() ?: null, source: $source, ); diff --git a/src/Filter/Factory/FormulaBuilderFactory.php b/src/Filter/Factory/FormulaBuilderFactory.php new file mode 100644 index 00000000..5c10ed6e --- /dev/null +++ b/src/Filter/Factory/FormulaBuilderFactory.php @@ -0,0 +1,21 @@ +filterPredicateRegistry, + defaultTargetAlias: $defaultTargetAlias, + ); + } +} diff --git a/src/Filter/Filter.php b/src/Filter/Filter.php index e787fd11..c5997148 100644 --- a/src/Filter/Filter.php +++ b/src/Filter/Filter.php @@ -24,11 +24,11 @@ * @param FilterElementInterface $element Filter element service (registered or inline). * @param string $type Registered element type alias. Only used for named event dispatch * (`flare.filter_element.{type}.*`) and targeting lookups. + * @param string $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 array $config Canonical config (element-defined schema); scalars, arrays, and enums only. * @param FilterData|null $data Programmatically set runtime data, same as buildFilter() * receives. Submitted form data takes precedence over it. - * @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". @@ -38,9 +38,9 @@ public function __construct( public FilterElementInterface $element, public string $type, + public string $alias, public array $config = [], public ?FilterData $data = null, - public ?string $alias = null, public ?string $targetAlias = null, public bool $targetingForced = false, public ?string $source = null, @@ -51,23 +51,23 @@ public function withData(?FilterData $data): self return new self( element: $this->element, type: $this->type, + alias: $this->alias, config: $this->config, data: $data, - alias: $this->alias, targetAlias: $this->targetAlias, targetingForced: $this->targetingForced, source: $this->source, ); } - public function withAlias(?string $alias): self + public function withAlias(string $alias): self { return new self( element: $this->element, type: $this->type, + alias: $alias, config: $this->config, data: $this->data, - alias: $alias, targetAlias: $this->targetAlias, targetingForced: $this->targetingForced, source: $this->source, @@ -79,9 +79,9 @@ public function withTargetAlias(?string $targetAlias, bool $forced = true): self return new self( element: $this->element, type: $this->type, + alias: $this->alias, config: $this->config, data: $this->data, - alias: $this->alias, targetAlias: $targetAlias, targetingForced: !\is_null($targetAlias) && $forced, source: $this->source, diff --git a/src/Filter/FilterContext.php b/src/Filter/FilterContext.php index 8a634bda..ce9fe3fc 100644 --- a/src/Filter/FilterContext.php +++ b/src/Filter/FilterContext.php @@ -21,13 +21,12 @@ /** * @param array $config Resolved canonical config of the filter. - * @param string|int|null $key Key of the filter within {@see ListSpec::$filters}. */ public function __construct( public ListSpec $list, public Filter $filter, public array $config, + public Formula $formula, public ContextInterface $engineContext, - public string|int|null $key = null, ) {} } diff --git a/src/Filter/FilterContextBuilder.php b/src/Filter/FilterContextBuilder.php new file mode 100644 index 00000000..1f754f5e --- /dev/null +++ b/src/Filter/FilterContextBuilder.php @@ -0,0 +1,58 @@ +predicates[] = [$type, $options, $targetAlias]; + + return $this; + } + + public function setPredicates(array $predicates): self + { + $this->predicates = $predicates; + return $this; + } + + public function build(): FilterContext + { + $formulaBuilder = $this->formulaBuilderFactory->create($this->filter->targetAlias); + + foreach ($this->predicates as [$type, $options, $targetAlias]) { + $formulaBuilder->add($type, $options, $targetAlias); + } + + $formula = $formulaBuilder->build(); + + return new FilterContext( + list: $this->list, + filter: $this->filter, + config: $this->config, + formula: $formula, + engineContext: $this->engineContext, + ); + } + + public function abort(): never + { + throw new AbortFilteringException(); + } +} diff --git a/src/Filter/Formula.php b/src/Filter/Formula.php new file mode 100644 index 00000000..cfad0534 --- /dev/null +++ b/src/Filter/Formula.php @@ -0,0 +1,17 @@ +propositions as $proposition) { + if (!$proposition instanceof Proposition) { + throw new \InvalidArgumentException('Propositions must be instances of Proposition'); + } + } + } +} diff --git a/src/Filter/FormulaBuilder.php b/src/Filter/FormulaBuilder.php new file mode 100644 index 00000000..6709c90d --- /dev/null +++ b/src/Filter/FormulaBuilder.php @@ -0,0 +1,72 @@ +, OptionsResolver> + */ + private static array $optionsResolvers = []; + + /** + * @var Proposition[] + */ + private array $propositions = []; + + public function __construct( + private readonly FilterPredicateRegistry $filterPredicateRegistry, + private readonly string $defaultTargetAlias, + ) {} + + /** + * @param class-string $predicateClass + * @param array $options + * + * @throws FilterException + */ + public function add(string $predicateClass, array $options = [], ?string $targetAlias = null): static + { + if (!$predicate = $this->filterPredicateRegistry->get($predicateClass)) + { + throw new FilterException( + \sprintf('No FLARE filter predicate service registered for "%s".', $predicateClass), + method: __METHOD__, + ); + } + + if (!isset(self::$optionsResolvers[$predicateClass])) + { + $resolver = new OptionsResolver(); + $predicate->configureOptions($resolver); + self::$optionsResolvers[$predicateClass] = $resolver; + } + + $this->propositions[] = new Proposition( + predicate: $predicate, + predicateClass: $predicateClass, + targetAlias: $targetAlias ?: $this->defaultTargetAlias, + options: self::$optionsResolvers[$predicateClass]->resolve($options), + ); + + return $this; + } + + public function abort(): never + { + throw new AbortFilteringException(); + } + + public function build(): Formula + { + return new Formula($this->propositions); + } +} diff --git a/src/Filter/FormulaBuilderInterface.php b/src/Filter/FormulaBuilderInterface.php new file mode 100644 index 00000000..27fd0c77 --- /dev/null +++ b/src/Filter/FormulaBuilderInterface.php @@ -0,0 +1,20 @@ + $predicateClass + * @param array $options + */ + public function add(string $predicateClass, array $options = [], ?string $targetAlias = null): static; + + public function abort(): never; + + public function build(): Formula; +} diff --git a/src/Filter/LogicSequencer.php b/src/Filter/LogicSequencer.php deleted file mode 100644 index 16e6c7a9..00000000 --- a/src/Filter/LogicSequencer.php +++ /dev/null @@ -1,72 +0,0 @@ -, OptionsResolver> - */ - private static array $optionsResolvers = []; - - /** - * @var LogicStep[] - */ - private array $steps = []; - - public function __construct( - private readonly FilterLogicRegistry $filterTypeRegistry, - private readonly string $defaultTargetAlias, - ) {} - - /** - * @param class-string $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), - method: __METHOD__, - ); - } - - if (!isset(self::$optionsResolvers[$type])) - { - $resolver = new OptionsResolver(); - $filterType->configureOptions($resolver); - self::$optionsResolvers[$type] = $resolver; - } - - $this->steps[] = new LogicStep( - type: $filterType, - typeClass: $type, - targetAlias: $targetAlias ?: $this->defaultTargetAlias, - options: self::$optionsResolvers[$type]->resolve($options), - ); - - return $this; - } - - public function all(): array - { - return $this->steps; - } - - public function abort(): never - { - throw new AbortFilteringException(); - } -} diff --git a/src/Filter/LogicSequencerInterface.php b/src/Filter/LogicSequencerInterface.php deleted file mode 100644 index c87dc8ce..00000000 --- a/src/Filter/LogicSequencerInterface.php +++ /dev/null @@ -1,23 +0,0 @@ - $type - * @param array $options - */ - public function add(string $type, array $options = [], ?string $targetAlias = null): static; - - /** - * @return LogicStep[] - */ - public function all(): array; - - public function abort(): never; -} diff --git a/src/Filter/LogicStep.php b/src/Filter/LogicStep.php deleted file mode 100644 index 0be5149e..00000000 --- a/src/Filter/LogicStep.php +++ /dev/null @@ -1,17 +0,0 @@ - $mounts */ $mounts = []; - foreach ($list->filters as $key => $filter) + foreach ($list->filters as $filter) { if (!Str::isValidFormName($filter->alias)) { continue; } - $filterContext = $this->filterContextFactory->create($list, $filter, $context, $key); + $filterContext = $this->filterContextFactory->create($list, $filter, $context); // Collect-only builder: never mounted itself; its single-field spec, children, // attributes, and deferred listeners are transferred onto the mounted builder below. diff --git a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php index affa5864..f0564ffc 100644 --- a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php +++ b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php @@ -10,11 +10,13 @@ use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Filter\Element\AbstractFilterElement; -use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; +use HeimrichHannot\FlareBundle\Filter\FilterContextBuilder; +use HeimrichHannot\FlareBundle\Filter\FormulaBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; -use HeimrichHannot\FlareBundle\Filter\Logic\IntegerIdChoiceLogic; +use HeimrichHannot\FlareBundle\Filter\Predicate\IntegerIdChoicePredicate; +use HeimrichHannot\FlareBundle\Filter\Value\ValueInterface; use HeimrichHannot\FlareBundle\Integration\CodefogTags\Registry\CfgTagsJoinsRegistry; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Query\Factory\ListExecutionContextFactory; @@ -104,7 +106,7 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co $builder->single(ChoiceType::class, $formOptions); } - public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void { $config = $context->config; @@ -113,13 +115,13 @@ public function buildLogic(LogicSequencerInterface $builder, FilterContext $cont /** @var ?array $tagIds */ $tagIds = $config['intrinsic'] ? $preselect - : $this->processRuntimeValue($data->getSingleValue()); + : $this->processRuntimeValue($value->getSingleValue()); if (!$tagIds) { return; } - $builder->add(IntegerIdChoiceLogic::class, [ + $builder->add(IntegerIdChoicePredicate::class, [ 'field' => 'id', 'ids' => $tagIds, ]); diff --git a/src/List/ListSpec.php b/src/List/ListSpec.php index 7bb8f537..89ae9741 100644 --- a/src/List/ListSpec.php +++ b/src/List/ListSpec.php @@ -45,26 +45,20 @@ public function __construct( /** * 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 + public function withFilter(Filter $filter): 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]); + return $this->withFilters([...$this->filters, $filter]); } - public function withoutFilter(string $key): self + public function withoutFilter(Filter|string $filter_or_class_or_alias): self { - $filters = $this->filters; - unset($filters[$key]); + $filters = \array_filter( + $this->filters, + static fn (Filter $existingFilter): bool => + $existingFilter !== $filter_or_class_or_alias + && $existingFilter->alias !== $filter_or_class_or_alias + && \get_class($existingFilter->element) !== $filter_or_class_or_alias, + ); return $this->withFilters($filters); } diff --git a/src/Query/Executor/FilterExecutor.php b/src/Query/Executor/FilterExecutor.php deleted file mode 100644 index 3d756e52..00000000 --- a/src/Query/Executor/FilterExecutor.php +++ /dev/null @@ -1,171 +0,0 @@ -list; - - $filterQueryBuilders = []; - - foreach ($list->filters as $key => $filter) - { - $context = $this->filterContextFactory->create($list, $filter, $options->context, $key); - - $data = $options->filterValues[$key] ?? $filter->data ?? FilterData::none(); - - if (!$builders = $this->invokeFilter($filter, $context, $data)) { - continue; - } - - \array_push($filterQueryBuilders, ...$builders); - } - - return $filterQueryBuilders; - } - - /** - * @return FilterConditionsBuilder[] - * - * @throws AbortFilteringException - * @throws FilterException - * @throws FlareException - */ - public function invokeFilter(Filter $filter, FilterContext $context, FilterData $data): array - { - if (!Str::isValidSqlName($table = $context->list->dc)) - { - throw new FlareException(\sprintf( - '[FLARE] ListSpec data container cannot be used as SQL table identifier: "%s"', - $table - ), method: __METHOD__, source: $filter->source ?: 'filter inlined'); - } - - $isTargeted = $this->filterElementRegistry->getAttribute($filter->type)?->isTargeted; - - $targetAlias = TableAliasRegistry::ALIAS_MAIN; - if ($isTargeted || $filter->targetingForced) { - $targetAlias = $filter->targetAlias ?: TableAliasRegistry::ALIAS_MAIN; - } - - $builder = new LogicSequencer($this->filterTypeRegistry, $targetAlias); - - $event = $this->eventDispatcher->dispatch(new FilterElementBuildingEvent( - context: $context, - builder: $builder, - data: $data, - )); - - if (!$event->shouldBuild) { - return []; - } - - try - { - $filter->element->buildLogic($builder, $context, $data); - } - catch (AbortFilteringException $e) - { - throw $e; - } - catch (FilterException $e) - { - throw $this->createFilterException($e, $filter, $filter->element::class . '::buildFilter'); - } - catch (\Throwable $e) - { - 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)); - - return $this->buildQueryBuilders($builder->all(), $filter); - } - - /** - * @param LogicStep[] $calls - * @return FilterConditionsBuilder[] - */ - private function buildQueryBuilders(array $calls, Filter $filter): array - { - $filterQueryBuilders = []; - - foreach ($calls as $call) - { - $filterQueryBuilder = $this->filterQueryBuilderFactory->create($call->targetAlias); - - try - { - $call->type->buildConditions($filterQueryBuilder, $call->options); - } - catch (AbortFilteringException $e) - { - throw $e; - } - catch (FilterException $e) - { - throw $this->createFilterException($e, $filter, $call->typeClass . '::buildQuery'); - } - catch (\Throwable $e) - { - throw new FilterException($e->getMessage(), code: $e->getCode(), previous: $e, - method: $call->typeClass, source: $filter->source ?: 'filter inlined'); - } - - $filterQueryBuilders[] = $filterQueryBuilder; - } - - return $filterQueryBuilders; - } - - 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: $filter->source ?: 'filter inlined', - ); - } -} diff --git a/src/Query/Executor/ListQueryDirector.php b/src/Query/Executor/ListQueryDirector.php index 5e55865d..00dc7504 100644 --- a/src/Query/Executor/ListQueryDirector.php +++ b/src/Query/Executor/ListQueryDirector.php @@ -9,22 +9,30 @@ 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\FilterContext; +use HeimrichHannot\FlareBundle\Query\Factory\FilterQueryBuilderFactory; use HeimrichHannot\FlareBundle\Query\Factory\ListExecutionContextFactory; use HeimrichHannot\FlareBundle\Query\Factory\QueryBuilderFactory; use HeimrichHannot\FlareBundle\Query\FilterConditions; use HeimrichHannot\FlareBundle\Query\FilterConditionsBuilder; use HeimrichHannot\FlareBundle\Query\ListQueryConfig; +use HeimrichHannot\FlareBundle\Util\CreatesFilterExceptionTrait; use Psr\Log\LoggerInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; readonly class ListQueryDirector { + use CreatesFilterExceptionTrait; + public function __construct( private EventDispatcherInterface $eventDispatcher, - private FilterExecutor $filterExecutor, + private FilterContextFactory $filterContextFactory, + private FilterQueryBuilderFactory $filterQueryBuilderFactory, private ListExecutionContextFactory $listExecutionContextFactory, private QueryBuilderFactory $queryBuilderFactory, - private LoggerInterface $logger + private LoggerInterface $logger, ) {} /** @@ -45,8 +53,8 @@ public function createQueryBuilder(ListQueryConfig $config): ?QueryBuilder $registry = $executionContext->tableAliasRegistry; $struct = $executionContext->queryStruct; - $filterQueryBuilders = $this->filterExecutor->invokeFilters($config); - $filterQueries = $this->buildFilterQueries($filterQueryBuilders); + $filterQueryBuilders = $this->invokeFilters($config); + $filterQueries = $this->buildFilterConditions($filterQueryBuilders); $event = new ModifyListQueryStructEvent( filterQueries: $filterQueries, @@ -69,12 +77,81 @@ public function createQueryBuilder(ListQueryConfig $config): ?QueryBuilder return null; } } + /** + * @return FilterConditionsBuilder[] + * + * @throws AbortFilteringException + * @throws FilterException + * @throws FlareException + */ + public function invokeFilters(ListQueryConfig $options): array + { + $filterQueryBuilders = []; + $list = $options->list; + + foreach ($list->filters as $filter) + { + $context = $this->filterContextFactory->create( + list: $list, + filter: $filter, + engineContext: $options->context + ); + + if (!$builders = $this->buildFilterConditionsBuilders($context)) { + continue; + } + + \array_push($filterQueryBuilders, ...$builders); + } + + return $filterQueryBuilders; + } + + /** + * @return FilterConditionsBuilder[] + * @throws AbortFilteringException + * @throws FilterException + */ + private function buildFilterConditionsBuilders(FilterContext $context): array + { + $filterQueryBuilders = []; + + foreach ($context->formula->propositions as $proposition) + // todo: this should be simplified, either FilterConditionsBuilderFactory like FilterContextFactory + // or skip collecting the builders and build right away + // in any case: we need to handle the AbortFilteringException and FilterException properly + { + $filterQueryBuilder = $this->filterQueryBuilderFactory->create($proposition->targetAlias); + + try + { + $proposition->predicate->buildConditions($filterQueryBuilder, $proposition->options); + } + catch (AbortFilteringException $e) + { + throw $e; + } + catch (FilterException $e) + { + throw $this->createFilterException($e, $context->filter, $proposition->predicateClass . '::buildConditions'); + } + catch (\Throwable $e) + { + throw new FilterException($e->getMessage(), code: $e->getCode(), previous: $e, + method: $proposition->predicateClass, source: $context->filter->source ?: 'filter inlined'); + } + + $filterQueryBuilders[] = $filterQueryBuilder; + } + + return $filterQueryBuilders; + } /** * @param FilterConditionsBuilder[] $filterQueryBuilders * @return FilterConditions[] */ - public function buildFilterQueries(array $filterQueryBuilders): array + private function buildFilterConditions(array $filterQueryBuilders): array { $filterQueries = []; diff --git a/src/Query/ListQueryConfig.php b/src/Query/ListQueryConfig.php index a5dd774c..b542b2a8 100644 --- a/src/Query/ListQueryConfig.php +++ b/src/Query/ListQueryConfig.php @@ -42,4 +42,4 @@ public function with( attributes: $attributes ?? $this->attributes, ); } -} \ No newline at end of file +} diff --git a/src/Registry/FilterLogicRegistry.php b/src/Registry/FilterPredicateRegistry.php similarity index 61% rename from src/Registry/FilterLogicRegistry.php rename to src/Registry/FilterPredicateRegistry.php index 3d1fe9cf..56b3f9fc 100644 --- a/src/Registry/FilterLogicRegistry.php +++ b/src/Registry/FilterPredicateRegistry.php @@ -4,31 +4,31 @@ namespace HeimrichHannot\FlareBundle\Registry; -use HeimrichHannot\FlareBundle\Filter\Logic\LogicInterface; +use HeimrichHannot\FlareBundle\Filter\Predicate\PredicateInterface; use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; -class FilterLogicRegistry +class FilterPredicateRegistry { /** - * @var array, LogicInterface> + * @var array, PredicateInterface> */ private array $types; public function __construct( - #[TaggedIterator(LogicInterface::FLARE_FILTER_LOGIC_TAG)] + #[TaggedIterator(PredicateInterface::FLARE_FILTER_PREDICATE_TAG)] private readonly iterable $filterTypes, ) {} /** - * @param class-string $class + * @param class-string $class */ - public function get(string $class): ?LogicInterface + public function get(string $class): ?PredicateInterface { return $this->resolve()[$class] ?? null; } /** - * @return array, LogicInterface> + * @return array, PredicateInterface> */ public function all(): array { @@ -41,12 +41,12 @@ private function resolve(): array $this->types = []; foreach ($this->filterTypes as $filterType) { - if (!$filterType instanceof LogicInterface) { + if (!$filterType instanceof PredicateInterface) { throw new \LogicException(\sprintf( 'Service "%s" is tagged "%s" but does not implement %s.', $filterType::class, - LogicInterface::FLARE_FILTER_LOGIC_TAG, - LogicInterface::class, + PredicateInterface::FLARE_FILTER_PREDICATE_TAG, + PredicateInterface::class, )); } diff --git a/src/Util/CreatesFilterExceptionTrait.php b/src/Util/CreatesFilterExceptionTrait.php new file mode 100644 index 00000000..0a93e2be --- /dev/null +++ b/src/Util/CreatesFilterExceptionTrait.php @@ -0,0 +1,23 @@ +getMethod() ?: $fallbackMethod; + + return new FilterException( + \sprintf('[FLARE] Query denied: %s / Callback: %s', $e->getMessage(), $errorMethod), + code: $e->getCode(), previous: $e, method: $errorMethod, + source: $filter->source ?: 'filter inlined', + ); + } +} diff --git a/tests/Engine/Projector/InteractiveProjectorTest.php b/tests/Engine/Projector/InteractiveProjectorTest.php index eb9fd21b..ecf31e82 100644 --- a/tests/Engine/Projector/InteractiveProjectorTest.php +++ b/tests/Engine/Projector/InteractiveProjectorTest.php @@ -7,10 +7,12 @@ use HeimrichHannot\FlareBundle\Engine\Projector\InteractiveProjector; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; +use HeimrichHannot\FlareBundle\Filter\FilterContextBuilder; +use HeimrichHannot\FlareBundle\Filter\FormulaBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\Value\ValueInterface; use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use PHPUnit\Framework\TestCase; @@ -66,10 +68,9 @@ public function resolveDcTable(string $type, array $config, array $attributes): $element = new class implements FilterElementInterface { public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} - public function buildLogic( - LogicSequencerInterface $builder, - FilterContext $context, - FilterData $data, + public function buildContext( + FilterContextBuilder $builder, + ?ValueInterface $value, ): void {} }; diff --git a/tests/Filter/FilterBuilderTest.php b/tests/Filter/FilterBuilderTest.php index 3cf2c65a..2fd4831a 100644 --- a/tests/Filter/FilterBuilderTest.php +++ b/tests/Filter/FilterBuilderTest.php @@ -6,10 +6,10 @@ use HeimrichHannot\FlareBundle\Exception\AbortFilteringException; use HeimrichHannot\FlareBundle\Exception\FilterException; -use HeimrichHannot\FlareBundle\Filter\LogicSequencer; -use HeimrichHannot\FlareBundle\Filter\Logic\AbstractLogic; +use HeimrichHannot\FlareBundle\Filter\FormulaBuilder; +use HeimrichHannot\FlareBundle\Filter\Predicate\AbstractPredicate; use HeimrichHannot\FlareBundle\Query\FilterConditionsBuilder; -use HeimrichHannot\FlareBundle\Registry\FilterLogicRegistry; +use HeimrichHannot\FlareBundle\Registry\FilterPredicateRegistry; use PHPUnit\Framework\TestCase; use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -18,24 +18,24 @@ final class FilterBuilderTest extends TestCase { public function testRegistryLooksUpFilterTypesByClassName(): void { - $type = new TestLogic(); - $registry = new FilterLogicRegistry([$type]); + $type = new TestPredicate(); + $registry = new FilterPredicateRegistry([$type]); - self::assertSame($type, $registry->get(TestLogic::class)); - self::assertSame([TestLogic::class => $type], $registry->all()); - self::assertNull($registry->get(UnknownLogic::class)); + self::assertSame($type, $registry->get(TestPredicate::class)); + self::assertSame([TestPredicate::class => $type], $registry->all()); + self::assertNull($registry->get(UnknownPredicate::class)); } public function testBuilderResolvesOptionsAndRecordsTargetedCalls(): void { - $builder = new LogicSequencer( - new FilterLogicRegistry([new TestLogic()]), + $builder = new FormulaBuilder( + new FilterPredicateRegistry([new TestPredicate()]), 'main', ); $builder - ->add(TestLogic::class, ['value' => 'first']) - ->add(TestLogic::class, ['value' => 'second', 'enabled' => true], 'translation'); + ->add(TestPredicate::class, ['value' => 'first']) + ->add(TestPredicate::class, ['value' => 'second', 'enabled' => true], 'translation'); $calls = $builder->all(); @@ -50,33 +50,33 @@ public function testBuilderResolvesOptionsAndRecordsTargetedCalls(): void public function testBuilderRejectsUnknownFilterTypes(): void { - $builder = new LogicSequencer(new FilterLogicRegistry([]), 'main'); + $builder = new FormulaBuilder(new FilterPredicateRegistry([]), 'main'); $this->expectException(FilterException::class); - $builder->add(TestLogic::class, ['value' => 'test']); + $builder->add(TestPredicate::class, ['value' => 'test']); } public function testBuilderLetsOptionsResolverValidateRequiredOptions(): void { - $builder = new LogicSequencer( - new FilterLogicRegistry([new TestLogic()]), + $builder = new FormulaBuilder( + new FilterPredicateRegistry([new TestPredicate()]), 'main', ); $this->expectException(MissingOptionsException::class); - $builder->add(TestLogic::class); + $builder->add(TestPredicate::class); } public function testBuilderAbortThrowsAbortFilteringException(): void { - $builder = new LogicSequencer(new FilterLogicRegistry([]), 'main'); + $builder = new FormulaBuilder(new FilterPredicateRegistry([]), 'main'); $this->expectException(AbortFilteringException::class); $builder->abort(); } } -final class TestLogic extends AbstractLogic +final class TestPredicate extends AbstractPredicate { public function configureOptions(OptionsResolver $resolver): void { @@ -89,7 +89,7 @@ public function buildConditions(FilterConditionsBuilder $builder, array $options } } -final class UnknownLogic extends AbstractLogic +final class UnknownPredicate extends AbstractPredicate { public function buildConditions(FilterConditionsBuilder $builder, array $options): void { diff --git a/tests/Filter/FilterFactoryTest.php b/tests/Filter/FilterFactoryTest.php index 854c33ce..e15dcb13 100644 --- a/tests/Filter/FilterFactoryTest.php +++ b/tests/Filter/FilterFactoryTest.php @@ -7,11 +7,13 @@ use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Factory\FilterFactory; -use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; +use HeimrichHannot\FlareBundle\Filter\FilterContextBuilder; +use HeimrichHannot\FlareBundle\Filter\FormulaBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterTransformerResolver; +use HeimrichHannot\FlareBundle\Filter\Value\ValueInterface; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; use PHPUnit\Framework\TestCase; use Symfony\Component\EventDispatcher\EventDispatcher; @@ -31,10 +33,9 @@ private static function element(): FilterElementInterface return new class implements FilterElementInterface { public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} - public function buildLogic( - LogicSequencerInterface $builder, - FilterContext $context, - FilterData $data, + public function buildContext( + FilterContextBuilder $builder, + ?ValueInterface $value, ): void {} }; } diff --git a/tests/Filter/FilterOptionsResolverTest.php b/tests/Filter/FilterOptionsResolverTest.php index 652c02df..1fcf94a6 100644 --- a/tests/Filter/FilterOptionsResolverTest.php +++ b/tests/Filter/FilterOptionsResolverTest.php @@ -9,10 +9,12 @@ use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; +use HeimrichHannot\FlareBundle\Filter\FilterContextBuilder; +use HeimrichHannot\FlareBundle\Filter\FormulaBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterOptionsResolver; +use HeimrichHannot\FlareBundle\Filter\Value\ValueInterface; use PHPUnit\Framework\TestCase; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -71,7 +73,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) { } - public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void { } } @@ -82,7 +84,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) { } - public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void { } } diff --git a/tests/Filter/FilterSetFactoryTest.php b/tests/Filter/FilterSetFactoryTest.php index f88fdf74..db54f37e 100644 --- a/tests/Filter/FilterSetFactoryTest.php +++ b/tests/Filter/FilterSetFactoryTest.php @@ -14,10 +14,12 @@ use HeimrichHannot\FlareBundle\Filter\Factory\FilterContextFactory; use HeimrichHannot\FlareBundle\Filter\Factory\FormHarnessFactory; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; +use HeimrichHannot\FlareBundle\Filter\FilterContextBuilder; +use HeimrichHannot\FlareBundle\Filter\FormulaBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\Value\ValueInterface; use HeimrichHannot\FlareBundle\Form\FilterMount; use HeimrichHannot\FlareBundle\Form\FormHarness; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterOptionsResolver; @@ -119,10 +121,9 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co ($this->buildForm)($builder, $context); } - public function buildLogic( - LogicSequencerInterface $builder, - FilterContext $context, - FilterData $data, + public function buildContext( + FilterContextBuilder $builder, + ?ValueInterface $value, ): void {} }; } diff --git a/tests/Filter/FilterTest.php b/tests/Filter/FilterTest.php index 7eda73f7..1f53379e 100644 --- a/tests/Filter/FilterTest.php +++ b/tests/Filter/FilterTest.php @@ -6,10 +6,12 @@ use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; +use HeimrichHannot\FlareBundle\Filter\FilterContextBuilder; +use HeimrichHannot\FlareBundle\Filter\FormulaBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\Value\ValueInterface; use PHPUnit\Framework\TestCase; final class FilterTest extends TestCase @@ -21,10 +23,9 @@ private static function element(): FilterElementInterface return $element ??= new class implements FilterElementInterface { public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} - public function buildLogic( - LogicSequencerInterface $builder, - FilterContext $context, - FilterData $data, + public function buildContext( + FilterContextBuilder $builder, + ?ValueInterface $value, ): void {} }; } diff --git a/tests/Filter/FilterTransformerResolverTest.php b/tests/Filter/FilterTransformerResolverTest.php index a80ee675..0115b499 100644 --- a/tests/Filter/FilterTransformerResolverTest.php +++ b/tests/Filter/FilterTransformerResolverTest.php @@ -9,10 +9,12 @@ use HeimrichHannot\FlareBundle\Contract\TransformerContract; use HeimrichHannot\FlareBundle\Event\FilterTransformerEvent; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; -use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; +use HeimrichHannot\FlareBundle\Filter\FilterContextBuilder; +use HeimrichHannot\FlareBundle\Filter\FormulaBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterTransformerResolver; +use HeimrichHannot\FlareBundle\Filter\Value\ValueInterface; use PHPUnit\Framework\TestCase; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\Form\FormBuilderInterface; @@ -98,7 +100,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) { } - public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void { } } @@ -109,7 +111,7 @@ public function buildForm(FormBuilderInterface $builder, FilterContext $context) { } - public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void { } } diff --git a/tests/List/ListSpecBuilderTest.php b/tests/List/ListSpecBuilderTest.php index c8a75d06..33066810 100644 --- a/tests/List/ListSpecBuilderTest.php +++ b/tests/List/ListSpecBuilderTest.php @@ -11,10 +11,12 @@ use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; +use HeimrichHannot\FlareBundle\Filter\FilterContextBuilder; +use HeimrichHannot\FlareBundle\Filter\FormulaBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\Value\ValueInterface; use HeimrichHannot\FlareBundle\List\Factory\ListSpecFactory; use HeimrichHannot\FlareBundle\List\ListSpecBuilder; use HeimrichHannot\FlareBundle\List\Resolver\ListDriverResolver; @@ -36,10 +38,9 @@ public static function filter(string $type, ?string $alias = null): Filter $element ??= new class implements FilterElementInterface { public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} - public function buildLogic( - LogicSequencerInterface $builder, - FilterContext $context, - FilterData $data, + public function buildContext( + FilterContextBuilder $builder, + ?ValueInterface $value, ): void {} }; diff --git a/tests/List/ListSpecTest.php b/tests/List/ListSpecTest.php index 3ddd7987..b309a343 100644 --- a/tests/List/ListSpecTest.php +++ b/tests/List/ListSpecTest.php @@ -7,10 +7,12 @@ use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Element\PublishedFilterElement; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; +use HeimrichHannot\FlareBundle\Filter\FilterContextBuilder; +use HeimrichHannot\FlareBundle\Filter\FormulaBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\Value\ValueInterface; use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use PHPUnit\Framework\TestCase; @@ -36,10 +38,9 @@ private static function filter(string $type, ?string $alias = null): Filter $element ??= new class implements FilterElementInterface { public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} - public function buildLogic( - LogicSequencerInterface $builder, - FilterContext $context, - FilterData $data, + public function buildContext( + FilterContextBuilder $builder, + ?ValueInterface $value, ): void {} }; diff --git a/tests/List/StubFilterElement.php b/tests/List/StubFilterElement.php index 3db2ceca..50a59f7c 100644 --- a/tests/List/StubFilterElement.php +++ b/tests/List/StubFilterElement.php @@ -5,14 +5,16 @@ namespace HeimrichHannot\FlareBundle\Tests\List; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; -use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; +use HeimrichHannot\FlareBundle\Filter\FilterContextBuilder; +use HeimrichHannot\FlareBundle\Filter\FormulaBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\Value\ValueInterface; class StubFilterElement implements FilterElementInterface { public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} - public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void {} + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void {} } diff --git a/tests/Query/Executor/FilterExecutorTest.php b/tests/Query/Executor/FilterExecutorTest.php index 17d4a09b..10971547 100644 --- a/tests/Query/Executor/FilterExecutorTest.php +++ b/tests/Query/Executor/FilterExecutorTest.php @@ -10,18 +10,20 @@ use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Factory\FilterContextFactory; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; +use HeimrichHannot\FlareBundle\Filter\FilterContextBuilder; +use HeimrichHannot\FlareBundle\Filter\FormulaBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterOptionsResolver; +use HeimrichHannot\FlareBundle\Filter\Value\ValueInterface; use HeimrichHannot\FlareBundle\List\Driver\ListDriverInterface; use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Query\Executor\FilterExecutor; use HeimrichHannot\FlareBundle\Query\Factory\FilterQueryBuilderFactory; use HeimrichHannot\FlareBundle\Query\ListQueryConfig; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; -use HeimrichHannot\FlareBundle\Registry\FilterLogicRegistry; +use HeimrichHannot\FlareBundle\Registry\FilterPredicateRegistry; use PHPUnit\Framework\TestCase; use Symfony\Component\EventDispatcher\EventDispatcher; @@ -39,7 +41,7 @@ private function createExecutor(): FilterExecutor filterContextFactory: new FilterContextFactory(new FilterOptionsResolver(new SchemaResolver())), filterElementRegistry: new FilterElementRegistry(), filterQueryBuilderFactory: new FilterQueryBuilderFactory($this->createMock(Connection::class)), - filterTypeRegistry: new FilterLogicRegistry([]), + filterTypeRegistry: new FilterPredicateRegistry([]), ); } @@ -114,8 +116,8 @@ final class RecordingFilterElement implements FilterElementInterface public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} - public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void { - $this->received = $data; + $this->received = $value; } } diff --git a/tests/Registry/FilterElementRegistryTest.php b/tests/Registry/FilterElementRegistryTest.php index 5488be02..99ea7aa4 100644 --- a/tests/Registry/FilterElementRegistryTest.php +++ b/tests/Registry/FilterElementRegistryTest.php @@ -6,10 +6,12 @@ use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; -use HeimrichHannot\FlareBundle\Filter\LogicSequencerInterface; +use HeimrichHannot\FlareBundle\Filter\FilterContextBuilder; +use HeimrichHannot\FlareBundle\Filter\FormulaBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\Value\ValueInterface; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; use PHPUnit\Framework\TestCase; @@ -60,5 +62,5 @@ final class RegistryElementStub implements FilterElementInterface { public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} - public function buildLogic(LogicSequencerInterface $builder, FilterContext $context, FilterData $data): void {} + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void {} } From b2915d98934ad53f5b1f1861db00b9fa2860660b Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 9 Sep 2026 11:51:23 +0200 Subject: [PATCH 94/96] refactor!: simplify filter form handling and value classes, enhance normalization Removed outdated attributes and validations (`default`, redundant PHPDoc) from filter forms, improving clarity. Updated constructors and methods of value classes (`BoolValue`, `DateRangeValue`, etc.) for consistency, immutability, and type safety. Introduced `normalizeTimestamp()` in `CalendarCurrentPredicate` and improved `DateTimeImmutable` usage in `DateRangeValue`. Adjusted references and registry mappings for streamlined behavior across filter forms. --- .../Attribute/AsFilterForm.php | 10 -- .../Compiler/RegisterFilterFormsPass.php | 104 ------------------ src/Enum/BoolBinaryChoices.php | 2 +- .../Element/CalendarCurrentFilterElement.php | 34 ++---- src/Filter/Form/AbstractFilterForm.php | 9 ++ src/Filter/Form/FilterFormInterface.php | 7 ++ .../Predicate/CalendarCurrentPredicate.php | 26 ++++- src/Filter/Value/BoolValue.php | 15 +-- src/Filter/Value/ChoiceValue.php | 21 +--- src/Filter/Value/DateRangeValue.php | 75 +++++++------ src/Filter/Value/KeywordsValue.php | 15 +-- src/Filter/Value/ParentRefValue.php | 24 +--- 12 files changed, 98 insertions(+), 244 deletions(-) diff --git a/src/DependencyInjection/Attribute/AsFilterForm.php b/src/DependencyInjection/Attribute/AsFilterForm.php index c72799c9..40288e18 100644 --- a/src/DependencyInjection/Attribute/AsFilterForm.php +++ b/src/DependencyInjection/Attribute/AsFilterForm.php @@ -10,9 +10,6 @@ /** * Registers a filter form and declares which value class it produces. * - * Repeatable: one form class may serve every element sharing a value class, and may serve several - * value classes, without ever naming an element. - * * @see RegisterFilterFormsPass The compile-time consumer. */ #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] @@ -28,25 +25,18 @@ class AsFilterForm * @param string|null $name Stable identifier persisted in `tl_flare_filter.formVariant`. * Defaults to {@see TypeNameFactory::createFilterFormType()} over the service class. Prefix * third-party names; the empty string is reserved for "no form" (intrinsic). - * @param class-string|null $value The value class this form produces; the registry key. * @param list $requires Capability interfaces the element must implement for this * form to be offered for it (plain `instanceof`). - * @param bool $default Whether this is the fallback form for $value. At most one default per - * value class. */ public function __construct( ?string $name = null, - public ?string $value = null, public array $requires = [], - public bool $default = false, mixed ...$attributes ) { $this->name = $name; $attributes['name'] = $this->name; - $attributes['value'] = $this->value; $attributes['requires'] = $this->requires; - $attributes['default'] = $this->default; $this->attributes = $attributes; } diff --git a/src/DependencyInjection/Compiler/RegisterFilterFormsPass.php b/src/DependencyInjection/Compiler/RegisterFilterFormsPass.php index 3e389212..32f1cf8b 100644 --- a/src/DependencyInjection/Compiler/RegisterFilterFormsPass.php +++ b/src/DependencyInjection/Compiler/RegisterFilterFormsPass.php @@ -76,7 +76,6 @@ public function process(ContainerBuilder $container): void $forms[$name] = [ 'value' => ((string) ($attributes['value'] ?? '')) ?: null, 'requires' => $this->getRequires($serviceId, $name, $attributes), - 'default' => (bool) ($attributes['default'] ?? false), 'service' => $serviceId, ]; @@ -88,9 +87,6 @@ public function process(ContainerBuilder $container): void } } - $this->assertOneDefaultPerValueClass($forms); - $this->assertEveryElementValueIsServed($container, $forms); - $registry = $container->findDefinition(FilterFormRegistry::class); $registry->setArgument('$forms', $forms); $registry->setArgument( @@ -195,104 +191,4 @@ private function getRequires(string $serviceId, string $name, array $attributes) return $resolved; } - - /** - * §3.3: `default` names *the* fallback form for a value class. - * - * @param array $forms - */ - private function assertOneDefaultPerValueClass(array $forms): void - { - $defaults = []; - - foreach ($forms as $name => $meta) - { - if (!$meta['default'] || $meta['value'] === null) { - continue; - } - - if (isset($defaults[$meta['value']])) - { - throw new \InvalidArgumentException(\sprintf( - 'Filter forms "%s" and "%s" are both declared as the default for value class "%s".' - . ' Exactly one default per value class is allowed.', - $defaults[$meta['value']], - $name, - $meta['value'], - )); - } - - $defaults[$meta['value']] = $name; - } - } - - /** - * §10, row 3 (second half): every element value class must be served by at least one form whose - * `requires` that element satisfies. Reads `flare.filter_element` tags, hence the pass ordering. - * - * @param array $forms - */ - private function assertEveryElementValueIsServed(ContainerBuilder $container, array $forms): void - { - foreach ($container->findTaggedServiceIds(AsFilterElement::TAG) as $serviceId => $tags) - { - $elementClass = $container->findDefinition($serviceId)->getClass(); - - if ($elementClass === null || !\class_exists($elementClass)) { - continue; - } - - foreach ($tags as $attributes) - { - $value = ((string) ($attributes['value'] ?? '')) ?: null; - - if ($value === null || $this->hasEligibleForm($elementClass, $value, $forms)) { - continue; - } - - throw new \InvalidArgumentException(\sprintf( - 'Filter element "%s" declares value class "%s", but no filter form produces that value for an' - . ' element of type "%s". Register a form with #[AsFilterForm(value: %s::class)] whose' - . ' "requires" the element satisfies, or drop the "value" declaration to make the element' - . ' intrinsic-only.', - (string) ($attributes['type'] ?? $serviceId), - $value, - $elementClass, - $value, - )); - } - } - } - - /** - * @param class-string $elementClass - * @param array $forms - */ - private function hasEligibleForm(string $elementClass, string $valueClass, array $forms): bool - { - foreach ($forms as $meta) - { - if ($meta['value'] !== $valueClass) { - continue; - } - - $satisfied = true; - - foreach ($meta['requires'] as $interface) - { - if (!\is_a($elementClass, $interface, true)) - { - $satisfied = false; - - break; - } - } - - if ($satisfied) { - return true; - } - } - - return false; - } } diff --git a/src/Enum/BoolBinaryChoices.php b/src/Enum/BoolBinaryChoices.php index f16ea072..832f3b66 100644 --- a/src/Enum/BoolBinaryChoices.php +++ b/src/Enum/BoolBinaryChoices.php @@ -33,4 +33,4 @@ public static function asOptions(): array self::TRUE_FALSE->value => 'flare.bool_binary_choices.true_false', ]; } -} \ No newline at end of file +} diff --git a/src/Filter/Element/CalendarCurrentFilterElement.php b/src/Filter/Element/CalendarCurrentFilterElement.php index b1e7400d..c5fe3f42 100644 --- a/src/Filter/Element/CalendarCurrentFilterElement.php +++ b/src/Filter/Element/CalendarCurrentFilterElement.php @@ -10,11 +10,10 @@ use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterElement; use HeimrichHannot\FlareBundle\Engine\Context\ValidationContext; use HeimrichHannot\FlareBundle\Filter\FilterContextBuilder; -use HeimrichHannot\FlareBundle\Filter\FormulaBuilderInterface; use HeimrichHannot\FlareBundle\Filter\FilterContext; -use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; use HeimrichHannot\FlareBundle\Filter\Predicate\CalendarCurrentPredicate; +use HeimrichHannot\FlareBundle\Filter\Value\DateRangeValue; use HeimrichHannot\FlareBundle\Filter\Value\ValueInterface; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Util\DateTimeHelper; @@ -25,7 +24,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Contracts\Translation\TranslatorInterface; -#[AsFilterElement(type: self::TYPE)] +#[AsFilterElement(type: self::TYPE, value: DateRangeValue::class)] class CalendarCurrentFilterElement extends AbstractFilterElement { public const TYPE = 'flare_calendar_current'; @@ -100,9 +99,9 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void { - $config = $context->config; + $config = $builder->config; - if (!$config['is_limited'] && $context->engineContext instanceof ValidationContext) { + if (!$config['is_limited'] && $builder->engineContext instanceof ValidationContext) { return; } @@ -131,7 +130,7 @@ public function buildContext(FilterContextBuilder $builder, ?ValueInterface $val } } - $builder->add(CalendarCurrentPredicate::class, [ + $builder->addPredicate(CalendarCurrentPredicate::class, [ 'start' => $start, 'stop' => $stop, 'has_extended_events' => $config['has_extended_events'], @@ -186,28 +185,15 @@ private function resolveFormLimits(array $config): array /** * @return array{from: ?\DateTimeInterface, to: ?\DateTimeInterface}|null */ - private function processRuntimeValue(FilterData $data): ?array + private function processRuntimeValue(?ValueInterface $data): ?array { - if (!$data->has('from') && !$data->has('to')) - // Programmatically set data may carry the bounds positionally instead of by name. - { - $values = $data->all(); - - if (\count($values) !== 2) { - return null; - } - - $values = \array_values($values); - - return [ - 'from' => $this->mixedToDateTime($values[0] ?? null), - 'to' => $this->mixedToDateTime($values[1] ?? null), - ]; + if (!$data instanceof DateRangeValue) { + return null; } return [ - 'from' => $this->mixedToDateTime($data->get('from')), - 'to' => $this->mixedToDateTime($data->get('to')), + 'from' => $this->mixedToDateTime($data->from), + 'to' => $this->mixedToDateTime($data->to), ]; } diff --git a/src/Filter/Form/AbstractFilterForm.php b/src/Filter/Form/AbstractFilterForm.php index c019235a..1f6960b7 100644 --- a/src/Filter/Form/AbstractFilterForm.php +++ b/src/Filter/Form/AbstractFilterForm.php @@ -2,7 +2,16 @@ namespace HeimrichHannot\FlareBundle\Filter\Form; +use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterFormBuilderInterface; +use HeimrichHannot\FlareBundle\Filter\Value\ValueInterface; +use Symfony\Component\Form\FormInterface; + abstract class AbstractFilterForm implements FilterFormInterface { + abstract public function getValueClass(): string; + + abstract public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void; + abstract public function decode(FormInterface $form, FilterContext $context): ?ValueInterface; } diff --git a/src/Filter/Form/FilterFormInterface.php b/src/Filter/Form/FilterFormInterface.php index e669ac83..9fe96ba9 100644 --- a/src/Filter/Form/FilterFormInterface.php +++ b/src/Filter/Form/FilterFormInterface.php @@ -23,6 +23,13 @@ */ interface FilterFormInterface { + /** + * Returns the fully qualified class name of the value object this form produces. + * + * @return class-string + */ + public function getValueClass(): string; + /** * Declares the filter's form fields on the collect-only per-filter builder. * diff --git a/src/Filter/Predicate/CalendarCurrentPredicate.php b/src/Filter/Predicate/CalendarCurrentPredicate.php index 9164a1bd..8ab4ef63 100644 --- a/src/Filter/Predicate/CalendarCurrentPredicate.php +++ b/src/Filter/Predicate/CalendarCurrentPredicate.php @@ -4,6 +4,7 @@ namespace HeimrichHannot\FlareBundle\Filter\Predicate; +use DateTimeInterface; use HeimrichHannot\FlareBundle\Query\FilterConditionsBuilder; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -11,9 +12,19 @@ class CalendarCurrentPredicate extends AbstractPredicate { public function configureOptions(OptionsResolver $resolver): void { - $resolver->define('start')->required()->allowedTypes('int'); - $resolver->define('stop')->required()->allowedTypes('int'); - $resolver->define('has_extended_events')->default(false)->allowedTypes('bool'); + $resolver->define('start') + ->required() + ->allowedTypes('int', DateTimeInterface::class) + ->normalize($this->normalizeTimestamp(...)); + + $resolver->define('stop') + ->required() + ->allowedTypes('int', DateTimeInterface::class) + ->normalize($this->normalizeTimestamp(...)); + + $resolver->define('has_extended_events') + ->default(false) + ->allowedTypes('bool'); } public function buildConditions(FilterConditionsBuilder $builder, array $options): void @@ -47,4 +58,13 @@ public function buildConditions(FilterConditionsBuilder $builder, array $options $builder->setParameter('start', $options['start']); $builder->setParameter('end', $options['stop']); } + + private function normalizeTimestamp($value): int + { + if ($value instanceof DateTimeInterface) { + return $value->getTimestamp(); + } + + return (int) $value; + } } diff --git a/src/Filter/Value/BoolValue.php b/src/Filter/Value/BoolValue.php index db2a0d6d..617999d1 100644 --- a/src/Filter/Value/BoolValue.php +++ b/src/Filter/Value/BoolValue.php @@ -6,18 +6,6 @@ use HeimrichHannot\FlareBundle\Enum\BoolBinaryChoices; -/** - * A submitted boolean: the value consumed by `BooleanFilterElement`. - * - * Two states, not three. "No opinion" — no submission, an empty submission, or a falsy submission - * under {@see BoolBinaryChoices::NULL_TRUE} — is the *absence* of this value (a plain `null`), - * never an instance carrying `null`. SPEC_FILTER_FORMS.md §9 requires a single representation per - * state, and a constructor cannot normalise itself away. - * - * {@see tryFrom()} is the lifted, pure form of `BooleanFilterElement::normalizeValue()`. It has two - * callers in the target model — the form's `decode()` and the form's `preselect` transformer - * (§4.2) — which is why it lives here rather than on either of them. - */ final readonly class BoolValue implements ValueInterface { public function __construct( @@ -26,8 +14,7 @@ public function __construct( /** * @param mixed $value Raw submitted or configured value. - * @param BoolBinaryChoices|null $choices Binary-choice variant, or null for no collapse (the - * `preselect` transformer's case, which must not fold a falsy value to "no opinion"). + * @param BoolBinaryChoices|null $choices Binary-choice variant, or null for no collapse. */ public static function tryFrom(mixed $value, ?BoolBinaryChoices $choices = null): ?self { diff --git a/src/Filter/Value/ChoiceValue.php b/src/Filter/Value/ChoiceValue.php index 94617514..16b1b049 100644 --- a/src/Filter/Value/ChoiceValue.php +++ b/src/Filter/Value/ChoiceValue.php @@ -4,23 +4,14 @@ namespace HeimrichHannot\FlareBundle\Filter\Value; +use HeimrichHannot\FlareBundle\Contract\FilterElement\ChoiceSourceContract; + /** - * A selection from a server-provided option set: the value consumed by - * `FieldValueChoiceFilterElement`, `DcaSelectFieldFilterElement` and - * `CodefogTagsChoiceFilterElement`. + * A selection from an option set. * * Keys, not domain values. Choice keys are strings by construction (Symfony view data always is) - * and their *meaning* is element-defined, so any further interpretation — the `(int)` cast for tag - * ids, the `LOWER(TRIM())` folding `FieldValueChoiceFilterType` expects, dropping - * {@see \HeimrichHannot\FlareBundle\Form\ChoicesBuilder::EMPTY_CHOICE} — belongs in the element's - * `valueFromChoiceKeys()`, not here (SPEC_FILTER_FORMS.md §3.4). In particular the lowercasing is - * coupled to that one filter type and would break `DcaSelectFilterType`'s case-sensitive lookup of - * DCA option keys. - * - * Sorted, because all three consuming filter types emit `IN()` and therefore do not care about - * order (§9). Deliberately distinct from {@see KeywordsValue} despite the identical shape: §4.3 — - * two elements share a value object only if every form registered for one is meaningful for the - * other, and a choice form is not meaningful for a free-text search. + * and their *meaning* is element-defined, so any further interpretation belongs in the element's + * {@see ChoiceSourceContract::valueFromChoiceKeys()}. */ final readonly class ChoiceValue implements ValueInterface { @@ -50,7 +41,7 @@ public function __construct(array $keys) } } - $strings = \array_values(\array_unique($strings, \SORT_STRING)); + $strings = \array_values(\array_unique($strings)); \sort($strings, \SORT_STRING); diff --git a/src/Filter/Value/DateRangeValue.php b/src/Filter/Value/DateRangeValue.php index fd4ec990..0d36fcc3 100644 --- a/src/Filter/Value/DateRangeValue.php +++ b/src/Filter/Value/DateRangeValue.php @@ -4,48 +4,33 @@ namespace HeimrichHannot\FlareBundle\Filter\Value; +use DateTimeImmutable; +use DateTimeInterface; +use DateTimeZone; +use HeimrichHannot\FlareBundle\Util\DateTimeHelper; + /** - * An inclusive date/time range as unix timestamps: the value consumed by `DateRangeFilterElement` - * and `CalendarCurrentFilterElement`. - * - * Timestamps, not `\DateTimeInterface`, for three reasons: - * 1. `serialize()` embeds `date`/`timezone_type`/`timezone`, so the same instant hashes - * differently as `+01:00` (type 1) and `Europe/Berlin` (type 3) — measured in - * tests/Filter/ValueObjectSerializeProbeTest.php. SPEC_FILTER_FORMS.md §9 allows either a - * timestamp or a normalised timezone, but normalising needs - * {@see \HeimrichHannot\FlareBundle\Util\DateTimeHelper::getTimeZone()}, which reads - * `Contao\Config` and would make this class unconstructible without a booted framework. - * 2. Nothing is lost: `DateRangeFilterType::buildQuery()` and - * `CalendarCurrentFilterElement::buildFilter()` both unwrap to `getTimestamp()` at once. - * 3. One representation per instant, comparable and sortable. - * - * `from > to` is *not* corrected here. It is a legal, empty-result range; the form validates it - * separately (a POST_SUBMIT FormError today), and silently swapping would change behaviour. - * - * `0` is a meaningful timestamp (the epoch), expressible because "absent" is `null` — a deliberate - * divergence from `CalendarCurrentFilterElement::mixedToDateTime()`, whose `if (!$input)` guard - * discards `0` and `'0'`. + * Represents a date range with optional start and end dates for filtering. + * - `from > to` is *not* corrected here. It is a legal, empty-result range; forms validate it separately. + * - `0` is a meaningful timestamp (the epoch), expressible because "absent" is `null`. */ final readonly class DateRangeValue implements ValueInterface { private function __construct( - public ?int $from = null, - public ?int $to = null, + public ?DateTimeImmutable $from = null, + public ?DateTimeImmutable $to = null, ) {} /** - * @param mixed $from `\DateTimeInterface`, an int/float timestamp, a numeric string, or null. - * @param mixed $to Likewise. - * - * Free-form date strings are deliberately rejected: they throw on malformed input and are - * non-deterministic for relative expressions such as `'now'`. Programmatic callers pre-resolve - * them with {@see \HeimrichHannot\FlareBundle\Util\DateTimeHelper::toTimestamp()}, which also - * understands the span keywords and needs no framework boot. + * @param mixed $from + * @param mixed $to */ - public static function tryFrom(mixed $from, mixed $to): ?self + public static function tryFrom(mixed $from, mixed $to, ?DateTimeZone $timezone = null): ?self { - $from = self::toTimestamp($from); - $to = self::toTimestamp($to); + $timezone ??= DateTimeHelper::getTimeZone(); + + $from = self::toDateTime($from, $timezone); + $to = self::toDateTime($to, $timezone); if ($from === null && $to === null) { return null; @@ -54,24 +39,38 @@ public static function tryFrom(mixed $from, mixed $to): ?self return new self($from, $to); } - private static function toTimestamp(mixed $value): ?int + private static function toDateTime(mixed $value, DateTimeZone $timezone): ?DateTimeImmutable { - if ($value instanceof \DateTimeInterface) { - return $value->getTimestamp(); + if ($value instanceof DateTimeInterface) { + return DateTimeImmutable::createFromInterface($value) + ->setTimezone($timezone); } if (\is_int($value)) { - return $value; + return self::createDateTimeFromTimestamp(\sprintf('%d.000000', $value), $timezone); } if (\is_float($value)) { - return \is_finite($value) ? (int) $value : null; + if (!\is_finite($value)) { + return null; + } + + return self::createDateTimeFromTimestamp(\sprintf('%.6F', $value), $timezone); } if (\is_string($value) && \is_numeric($value = \trim($value))) { - return (int) $value; + return self::createDateTimeFromTimestamp(\sprintf('%.6F', (float) $value), $timezone); } return null; } + + private static function createDateTimeFromTimestamp(string $timestamp, DateTimeZone $timezone): ?DateTimeImmutable + { + $dateTime = DateTimeImmutable::createFromFormat('U.u', $timestamp); + + return $dateTime instanceof DateTimeImmutable + ? $dateTime->setTimezone($timezone) + : null; + } } diff --git a/src/Filter/Value/KeywordsValue.php b/src/Filter/Value/KeywordsValue.php index ac4648ef..49c4b7f1 100644 --- a/src/Filter/Value/KeywordsValue.php +++ b/src/Filter/Value/KeywordsValue.php @@ -5,23 +5,10 @@ namespace HeimrichHannot\FlareBundle\Filter\Value; /** - * A free-text search phrase: the value consumed by `SearchKeywordsFilterElement`. - * - * The searched *columns* are deliberately absent. They change which rows match, so they are element - * config (SPEC_FILTER_FORMS.md §4.1) — a form must not be able to redirect the search — and - * `Filter::fingerprint()` already carries `$config`, so restating them here would duplicate them - * into the hash under an arbitrary order. - * - * Normalised to a single-spaced, trimmed phrase: `SearchKeywordsFilterType` splits on - * `/\s+OR\s+/i` and then lowercases and re-tokenises each group, so whitespace variants are - * multiple representations of one query (§9). Case is *not* folded — the filter type does that - * itself, and folding here would discard the user's input verbatim for no query benefit. - * - * Distinct from {@see ChoiceValue} by semantics, not structure (§4.3). + * A free-text search phrase, normalized to a single-spaced, trimmed phrase. */ final readonly class KeywordsValue implements ValueInterface { - /** Non-empty, trimmed, with internal whitespace runs collapsed to a single space. */ public string $keywords; public function __construct(string $keywords) diff --git a/src/Filter/Value/ParentRefValue.php b/src/Filter/Value/ParentRefValue.php index 8e32a430..f67f33eb 100644 --- a/src/Filter/Value/ParentRefValue.php +++ b/src/Filter/Value/ParentRefValue.php @@ -5,31 +5,13 @@ namespace HeimrichHannot\FlareBundle\Filter\Value; /** - * A selection of parent records, grouped by parent table: the value consumed by - * `ArchiveFilterElement`. - * - * The shape is `BelongsToRelationFilterType`'s `submitted_data`, documented verbatim at - * `BelongsToRelationFilterElement.php:107-118`. One value class spans both ptable modes - * (SPEC_FILTER_FORMS.md §7.4): the static main-ptable mode is the single-table degenerate case, and - * `buildFilter()` re-derives the flat vs. grouped filter-type call from `PtableInferrer` exactly as - * it does today. - * - * Ids, never `Contao\Model` instances: a model carries `$arrData` *and* `$arrModified`, so an - * unrelated mutation moves the hash (§9, measured in - * tests/Filter/ValueObjectSerializeProbeTest.php). - * - * **"Use the full whitelist" is the absence of this value, not a state of it.** Today - * `ArchiveFilterElement::processRuntimeValue()` already treats "nothing submitted" (null), "the - * empty option was chosen" (true) and "no model survived" ([]) identically, so nothing is lost by - * collapsing them to `null` — and the alternative, a `wholeWhitelist` flag, would let the form - * decide which rows match (§3.4, §4.1) while creating a second representation of "nothing - * selected" (§9). {@see tryFrom()} is the guard that keeps an empty instance from existing. + * A selection of parent records, grouped by parent table. */ final readonly class ParentRefValue implements ValueInterface { /** - * Parent ids grouped by parent table. Tables sorted by name, ids sorted ascending, - * deduplicated and positive-only; a table with no surviving id is dropped entirely. + * Parent ids grouped by parent table. Tables are sorted by name, ids sorted ascending, + * and are deduplicated and positive-only; a table with no surviving id is dropped entirely. * * @var array> */ From 2fb1a803b891af68e3718e37097b68bdf28d3628 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 9 Sep 2026 12:19:49 +0200 Subject: [PATCH 95/96] refactor!: integrate FilterOptionsResolver into FilterFactory, streamline config handling Decoupled alias generation, moved config resolution logic from FilterContextBuilderFactory and FilterContext into FilterFactory using FilterOptionsResolver. Updated constructors and methods across affected classes for improved consistency. Enhanced PHPDoc with templates and refined method signatures for type safety and clarity. --- src/Engine/Mod/SimpleEquationMod.php | 2 +- .../Element/CalendarCurrentFilterElement.php | 2 +- .../Factory/FilterContextBuilderFactory.php | 4 ---- src/Filter/Factory/FilterFactory.php | 15 +++++++++---- src/Filter/FilterContext.php | 4 ---- src/Filter/FilterContextBuilder.php | 2 -- src/Filter/Form/AbstractFilterForm.php | 15 +++++++++++++ src/Filter/Form/FilterFormInterface.php | 22 +++++++++++-------- src/Filter/Resolver/FilterOptionsResolver.php | 10 ++++----- 9 files changed, 45 insertions(+), 31 deletions(-) diff --git a/src/Engine/Mod/SimpleEquationMod.php b/src/Engine/Mod/SimpleEquationMod.php index f3215a27..5815253c 100644 --- a/src/Engine/Mod/SimpleEquationMod.php +++ b/src/Engine/Mod/SimpleEquationMod.php @@ -26,7 +26,7 @@ public function __invoke(Engine $engine, array $options): void { $filter = $this->filterFactory->create( element: SimpleEquationFilterElement::TYPE, - alias: $options['name'] ?: ('_.equation_' . Str::random(8)), + alias: $options['name'] ?: null, config: [ 'intrinsic' => true, 'left' => $options['operand1'], diff --git a/src/Filter/Element/CalendarCurrentFilterElement.php b/src/Filter/Element/CalendarCurrentFilterElement.php index c5fe3f42..281f0500 100644 --- a/src/Filter/Element/CalendarCurrentFilterElement.php +++ b/src/Filter/Element/CalendarCurrentFilterElement.php @@ -99,7 +99,7 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void { - $config = $builder->config; + $config = $builder->filter->config; if (!$config['is_limited'] && $builder->engineContext instanceof ValidationContext) { return; diff --git a/src/Filter/Factory/FilterContextBuilderFactory.php b/src/Filter/Factory/FilterContextBuilderFactory.php index b4bb4fdb..b033e5f4 100644 --- a/src/Filter/Factory/FilterContextBuilderFactory.php +++ b/src/Filter/Factory/FilterContextBuilderFactory.php @@ -11,20 +11,16 @@ final readonly class FilterContextBuilderFactory { public function __construct( - private FilterOptionsResolver $filterOptionsResolver, private FormulaBuilderFactory $formulaBuilderFactory, ) {} public function create(ListSpec $list, Filter $filter, ContextInterface $engineContext): FilterContextBuilder { - $config = $this->filterOptionsResolver->resolve($filter); - return new FilterContextBuilder( formulaBuilderFactory: $this->formulaBuilderFactory, list: $list, filter: $filter, engineContext: $engineContext, - config: $config, ); } } diff --git a/src/Filter/Factory/FilterFactory.php b/src/Filter/Factory/FilterFactory.php index 1176696c..3aa11f6d 100644 --- a/src/Filter/Factory/FilterFactory.php +++ b/src/Filter/Factory/FilterFactory.php @@ -7,10 +7,11 @@ use HeimrichHannot\FlareBundle\Exception\FlareException; use HeimrichHannot\FlareBundle\Filter\Element\FilterElementInterface; use HeimrichHannot\FlareBundle\Filter\Filter; -use HeimrichHannot\FlareBundle\Filter\FilterData; +use HeimrichHannot\FlareBundle\Filter\Resolver\FilterOptionsResolver; use HeimrichHannot\FlareBundle\Filter\Resolver\FilterTransformerResolver; use HeimrichHannot\FlareBundle\Model\FilterModel; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; +use HeimrichHannot\FlareBundle\Util\Str; /** * Creates {@see Filter} DTOs, resolving registered type aliases to their element services. @@ -19,6 +20,7 @@ { public function __construct( private FilterElementRegistry $filterElementRegistry, + private FilterOptionsResolver $filterOptionsResolver, private FilterTransformerResolver $filterTransformerResolver, ) {} @@ -32,9 +34,8 @@ public function __construct( */ public function create( FilterElementInterface|string $element, - string $alias, + ?string $alias = null, array $config = [], - ?FilterData $data = null, ?string $targetAlias = null, bool $targetingForced = false, ?string $source = null, @@ -42,12 +43,17 @@ public function create( $type = $this->resolveType($element, $source); $element = $this->resolveElement($element, $source); + if (!$alias) { + $alias = \sprintf('_.%s_%s', $type, Str::random(8)); + } + + $config = $this->filterOptionsResolver->resolve($element, $config); + return new Filter( element: $element, type: $type, alias: $alias, config: $config, - data: $data, targetAlias: $targetAlias, targetingForced: $targetingForced, source: $source, @@ -65,6 +71,7 @@ public function createFromFilterModel( $element = $this->resolveElement($type, $source); $config = $this->filterTransformerResolver->transform($element, $type, $filterModel) ?? []; + $config = $this->filterOptionsResolver->resolve($element, $config); return new Filter( element: $element, diff --git a/src/Filter/FilterContext.php b/src/Filter/FilterContext.php index ce9fe3fc..7db3d093 100644 --- a/src/Filter/FilterContext.php +++ b/src/Filter/FilterContext.php @@ -19,13 +19,9 @@ /** Attribute-bag key marking a root form child as a flat-mounted single field. */ public const ATTR_SINGLE_FIELD = 'flare.single_field'; - /** - * @param array $config Resolved canonical config of the filter. - */ public function __construct( public ListSpec $list, public Filter $filter, - public array $config, public Formula $formula, public ContextInterface $engineContext, ) {} diff --git a/src/Filter/FilterContextBuilder.php b/src/Filter/FilterContextBuilder.php index 1f754f5e..c0e2a1b9 100644 --- a/src/Filter/FilterContextBuilder.php +++ b/src/Filter/FilterContextBuilder.php @@ -16,7 +16,6 @@ public function __construct( public readonly ListSpec $list, public readonly Filter $filter, public readonly ContextInterface $engineContext, - public readonly array $config, ) {} public function addPredicate(string $type, array $options = [], ?string $targetAlias = null): self @@ -45,7 +44,6 @@ public function build(): FilterContext return new FilterContext( list: $this->list, filter: $this->filter, - config: $this->config, formula: $formula, engineContext: $this->engineContext, ); diff --git a/src/Filter/Form/AbstractFilterForm.php b/src/Filter/Form/AbstractFilterForm.php index 1f6960b7..66a58887 100644 --- a/src/Filter/Form/AbstractFilterForm.php +++ b/src/Filter/Form/AbstractFilterForm.php @@ -7,11 +7,26 @@ use HeimrichHannot\FlareBundle\Filter\Value\ValueInterface; use Symfony\Component\Form\FormInterface; +/** + * @template T of ValueInterface + * @api + */ abstract class AbstractFilterForm implements FilterFormInterface { + /** + * {@inheritdoc} + * + * @return class-string + */ abstract public function getValueClass(): string; abstract public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void; + /** + * {@inheritdoc} + * + * @return T|null A value object of the class this form is registered for. + * Null contributes nothing, which lets the element fall back to its own config. + */ abstract public function decode(FormInterface $form, FilterContext $context): ?ValueInterface; } diff --git a/src/Filter/Form/FilterFormInterface.php b/src/Filter/Form/FilterFormInterface.php index 9fe96ba9..24a2e69f 100644 --- a/src/Filter/Form/FilterFormInterface.php +++ b/src/Filter/Form/FilterFormInterface.php @@ -19,6 +19,7 @@ * Anything the form needs *pulled* from the element is an explicit capability port, * e.g. {@see \HeimrichHannot\FlareBundle\Contract\FilterElement\ChoiceSourceContract}. * + * @template T of ValueInterface * @api */ interface FilterFormInterface @@ -26,19 +27,22 @@ interface FilterFormInterface /** * Returns the fully qualified class name of the value object this form produces. * - * @return class-string + * @return class-string */ public function getValueClass(): string; /** * Declares the filter's form fields on the collect-only per-filter builder. * - * Single-field forms declare their field via {@see FilterFormBuilderInterface::single()}; it is - * mounted flat on the root form under the filter's alias. Multi-field forms add() children with - * local names, which mount as a compound sub-form. Declaring both at once is not supported and - * fails when the form is built. Pre-submission defaults belong in the fields' native `data` - * option (§4.2). Event listeners registered on the builder are replayed onto the mounted form; - * event subscribers are not supported. + * - **Single-field forms** declare their field via {@see FilterFormBuilderInterface::single()}; + * it is mounted flat on the root form under the filter's alias. + * - **Multi-field forms** use {@see FilterFormBuilderInterface::add()} to add children with local + * names, which mount as a compound sub-form. + * - Declaring both `single()` and `add()` at once is not supported and fails when the form is built. + * --- + * - 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. */ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void; @@ -50,9 +54,9 @@ public function buildForm(FilterFormBuilderInterface $builder, FilterContext $co * - Only the form knows whether it wants `getData()`, `getNormData()` or `getViewData()`, and it may * need attributes set in {@see self::buildForm()}. * - The "user explicitly cleared" vs. "user never interacted" distinction is answerable here via - * `isSubmitted()` / `getConfig()->getData()`. + * `$form->isSubmitted()` / `$context->engineContext`. * - * @return ValueInterface|null A value object of the class this form is registered for. + * @return T|null A value object of the class this form is registered for. * Null contributes nothing, which lets the element fall back to its own config. */ public function decode(FormInterface $form, FilterContext $context): ?ValueInterface; diff --git a/src/Filter/Resolver/FilterOptionsResolver.php b/src/Filter/Resolver/FilterOptionsResolver.php index f9427486..d2ccfd08 100644 --- a/src/Filter/Resolver/FilterOptionsResolver.php +++ b/src/Filter/Resolver/FilterOptionsResolver.php @@ -7,6 +7,7 @@ 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; /** @@ -24,17 +25,15 @@ public function __construct( * * @throws FilterException If the config does not satisfy the element's schema. */ - public function resolve(Filter $filter): array + public function resolve(FilterElementInterface $element, array $config): array { - $element = $filter->element; - if (!$element instanceof OptionsContract) { - return $filter->config; + return $config; } try { - return $this->schemaResolver->resolve($element::class, $element->configureOptions(...), $filter->config); + return $this->schemaResolver->resolve($element::class, $element->configureOptions(...), $config); } catch (\Throwable $e) { @@ -42,7 +41,6 @@ public function resolve(Filter $filter): array \sprintf('[FLARE] Invalid filter config for element "%s": %s', $element::class, $e->getMessage()), previous: $e, method: $element::class . '::configureOptions', - source: $filter->source, ); } } From 6ed5586b6077bec1326d89554814fe92e31bcf88 Mon Sep 17 00:00:00 2001 From: Eric Gesemann Date: Wed, 9 Sep 2026 17:40:22 +0200 Subject: [PATCH 96/96] refactor!: rename `FormHarnessFactory` namespace and refine alias handling in filter forms --- src/Engine/Projector/InteractiveProjector.php | 2 +- src/Event/FilterFormBuiltEvent.php | 2 +- src/Form/Factory/FormHarnessFactory.php | 19 +++++++++++-------- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/Engine/Projector/InteractiveProjector.php b/src/Engine/Projector/InteractiveProjector.php index cdd57e2b..c8ffef21 100644 --- a/src/Engine/Projector/InteractiveProjector.php +++ b/src/Engine/Projector/InteractiveProjector.php @@ -14,7 +14,7 @@ use HeimrichHannot\FlareBundle\Engine\View\AggregationView; use HeimrichHannot\FlareBundle\Engine\View\InteractiveView; use HeimrichHannot\FlareBundle\Exception\FlareException; -use HeimrichHannot\FlareBundle\Filter\Factory\FormHarnessFactory; +use HeimrichHannot\FlareBundle\Form\Factory\FormHarnessFactory; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterData; use HeimrichHannot\FlareBundle\Form\FormHarness; diff --git a/src/Event/FilterFormBuiltEvent.php b/src/Event/FilterFormBuiltEvent.php index ae0fb3a5..9eda6db2 100644 --- a/src/Event/FilterFormBuiltEvent.php +++ b/src/Event/FilterFormBuiltEvent.php @@ -10,7 +10,7 @@ /** * Dispatched after a filter element built its fields on the collect-only per-filter builder, - * before {@see \HeimrichHannot\FlareBundle\Filter\Factory\FormHarnessFactory} mounts them onto the + * before {@see \HeimrichHannot\FlareBundle\Form\Factory\FormHarnessFactory} mounts them onto the * root 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 diff --git a/src/Form/Factory/FormHarnessFactory.php b/src/Form/Factory/FormHarnessFactory.php index f14111de..27553c6e 100644 --- a/src/Form/Factory/FormHarnessFactory.php +++ b/src/Form/Factory/FormHarnessFactory.php @@ -2,13 +2,14 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Filter\Factory; +namespace HeimrichHannot\FlareBundle\Form\Factory; use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; use HeimrichHannot\FlareBundle\Engine\Context\FormContextInterface; use HeimrichHannot\FlareBundle\Event\FilterFormBuiltEvent; use HeimrichHannot\FlareBundle\Event\FormHarnessBuildEvent; use HeimrichHannot\FlareBundle\Exception\FlareException; +use HeimrichHannot\FlareBundle\Filter\Factory\FilterContextFactory; use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Filter\FilterFormBuilder; use HeimrichHannot\FlareBundle\Form\FilterMount; @@ -47,8 +48,8 @@ public function create(ListSpec $list, FormContextInterface $context): FormHarne $name = $context->getFormName(); $formOptions = [ - 'method' => 'GET', - 'csrf_protection' => false, + 'method' => 'GET', + 'csrf_protection' => false, 'translation_domain' => 'flare_form', 'attr' => [ 'data-flare-form' => 'keep-query', @@ -68,7 +69,9 @@ public function create(ListSpec $list, FormContextInterface $context): FormHarne foreach ($list->filters as $filter) { - if (!Str::isValidFormName($filter->alias)) { + $alias = $filter->alias; + + if (!Str::isValidFormName($alias)) { continue; } @@ -76,7 +79,7 @@ public function create(ListSpec $list, FormContextInterface $context): FormHarne // Collect-only builder: never mounted itself; its single-field spec, children, // attributes, and deferred listeners are transferred onto the mounted builder below. - $builder = new FilterFormBuilder($filter->alias, null, new EventDispatcher(), $this->formFactory); + $builder = new FilterFormBuilder($alias, null, new EventDispatcher(), $this->formFactory); $builder->setAttribute(FilterContext::ATTR_SELF, $filterContext); $filter->element->buildForm($builder, $filterContext); @@ -108,13 +111,13 @@ public function create(ListSpec $list, FormContextInterface $context): FormHarne if ($single) { - $mount = $root->create($filter->alias, $single['type'], $single['options']); + $mount = $root->create($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 { - $mount = $root->create($filter->alias, FormType::class, [ + $mount = $root->create($alias, FormType::class, [ 'inherit_data' => false, 'label' => false, 'required' => false, @@ -133,7 +136,7 @@ public function create(ListSpec $list, FormContextInterface $context): FormHarne $mount->addEventListener($eventName, $listener, $priority); } - $mounts[$key] = new FilterMount($filter, $filter->alias, $filterContext); + $mounts[$alias] = new FilterMount($filter, $alias, $filterContext); $root->add($mount); }