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/.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 230ae474..0a2c4a03 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/List/) + → 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,10 +30,30 @@ 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`, `buildContext`, `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/Form/` — filter form building (FilterFormFactory etc.) +- `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 + (`FilterOptionsResolver`, `FilterTransformerResolver`, `FilterElementResolver`), `FilterContextFactory`, + 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) +- `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` @@ -46,21 +66,27 @@ 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: '...', 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/`. +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.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`, `FilterInvokerRegistry`, `ProjectorRegistry`, `FilterCollectorRegistry`, `FlareCallbackRegistry`, `EngineModRegistry`. +**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. -**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/`. @@ -91,8 +117,9 @@ 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, 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 * `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/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."; \ 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/README.md b/README.md index 0fc34cf6..b4afda42 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,14 @@ [![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) +[![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. +**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. diff --git a/SPEC_FILTER_FORMS.md b/SPEC_FILTER_FORMS.md new file mode 100644 index 00000000..f630acb4 --- /dev/null +++ b/SPEC_FILTER_FORMS.md @@ -0,0 +1,704 @@ +# SPEC: Decoupling Filter Forms from Filter Elements + +**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`, +`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 two dispatch aliases: +`flare.form.{name}.build` and `flare.filter_element.{type}.form_built`) + +--- + +## 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 (`FormHarnessFactory`, §2.2) 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. + +### 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 | +|---|---|---| +| `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 | + +`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). + +**"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 | +| — | `Form\FilterMount` (new) | filter set | +| `Event\FilterFormBuildEvent` | `Event\FilterSetBuildEvent` | filter set | +| `EventListener\NamedDispatch\FilterFormListener` | `…\FilterSetListener` | 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 | + +`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` → `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 +`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/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()`. +- **`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 + +### 3.1 `FilterFormInterface` + +```php +interface FilterFormInterface +{ + public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void; + + /** + * Produces the element's canonical value from the mount, or null to contribute nothing. + */ + public function decode(FormInterface $mount, 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 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. + +### 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. 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; +} +``` + +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. + +#### 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) $mount->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 + +### 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`. 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 + +`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'][$predicate] = 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**: `$mount->getViewData()` is `['5','7']`. + +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. + +### 7.3 Division of labour after the change + +| stays on the element (via `ChoiceSourceContract`) | moves to the form | +|---|---| +| `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: + +| 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. + +--- + +## 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. + +**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 +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 + +`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 + +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. +- `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 + +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.** ✅ **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 + `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 $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()`. +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 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. + +--- + +## 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()`. +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. `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 + (§8). Rejected names and their reasons are in §2.2. + +--- + +## 14. Remaining unknowns + +Not blockers, but unverified at spec time. + +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 + 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 `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 + `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/composer.json b/composer.json index 71985cc8..d92c8439 100644 --- a/composer.json +++ b/composer.json @@ -9,14 +9,16 @@ "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", "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", + "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 +26,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" }, diff --git a/config/services.yaml b/config/services.yaml index b9a91c1e..9353485a 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 @@ -9,9 +10,10 @@ services: HeimrichHannot\FlareBundle\: resource: ../src exclude: - - ../src/{Collection,Contao,ContaoManager,Contract,DependencyInjection,Dto,Engine,Event,Integration,Model,Trait,Util} - - ../src/{Filter,Form,InferPtable,List,Paginator,Query,Sort,Specification}/*.php - - ../src/Registry/Descriptor + - ../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 @@ -21,14 +23,20 @@ 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: ~ + # 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 + # bind: # $projectDir: '%kernel.project_dir%' # $csrfTokenName: '%contao.csrf_token_name%' 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/contao/templates/content_element/flare_listview.html.twig b/contao/templates/content_element/flare_listview.html.twig index fbd3b35a..c39f4132 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 %} @@ -48,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/mago.toml b/mago.toml index c089d01c..83aa7274 100644 --- a/mago.toml +++ b/mago.toml @@ -3,7 +3,7 @@ php-version = "8.2" [source] -paths = ["src/", "tests/"] +paths = ["src/"] includes = ["vendor"] excludes = [] @@ -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 } @@ -40,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 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 + + + 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/FilterDefinitionCollection.php b/src/Collection/FilterDefinitionCollection.php deleted file mode 100644 index 6534a35a..00000000 --- a/src/Collection/FilterDefinitionCollection.php +++ /dev/null @@ -1,163 +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 FilterDefinitionCollection 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): ?FilterDefinition - { - 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, FilterDefinition $filter): bool => $carry || $filter->getType() === $type, - false - ); - } - - public function add(FilterDefinition ...$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, FilterDefinition $filter): void - { - $this->items[$key] = $filter; - } - - /** - * @param FilterDefinition|string $item The item to remove or its key. - */ - public function remove(FilterDefinition|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 (FilterDefinition $filter): bool => $filter !== $item - ); - - $this->items = $filtered; - - 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."); - } - - $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 = []; - $this->initItems($data); - } - - public function __clone(): void - { - $this->items = \array_map(static fn (FilterDefinition $item): FilterDefinition => clone $item, $this->items); - } - - public function hash(): string - { - return \sha1(\serialize(\array_map( - static fn (FilterDefinition $filter): string => $filter->hash(), - $this->items - ))); - } -} \ No newline at end of file diff --git a/src/Config/ConfigBuilder.php b/src/Config/ConfigBuilder.php new file mode 100644 index 00000000..53d2d4eb --- /dev/null +++ b/src/Config/ConfigBuilder.php @@ -0,0 +1,54 @@ + $config + */ + public function __construct(private array $config = []) {} + + public function set(string $key, mixed $value): self + { + $this->config[$key] = $value; + + 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; + } + + /** + * 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/ConfigBuilderInterface.php b/src/Config/ConfigBuilderInterface.php new file mode 100644 index 00000000..1e5a7524 --- /dev/null +++ b/src/Config/ConfigBuilderInterface.php @@ -0,0 +1,14 @@ + + */ + 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/Config/TransformerInterface.php b/src/Config/TransformerInterface.php new file mode 100644 index 00000000..517c1609 --- /dev/null +++ b/src/Config/TransformerInterface.php @@ -0,0 +1,10 @@ + + */ + 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 TransformerInterface|callable(ConfigBuilder $config, object $source): void $transformer + */ + public function for(string $sourceClass, TransformerInterface|callable $transformer): self + { + $this->transformers[$sourceClass] = $transformer; + + return $this; + } + + /** + * 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 TransformerInterface|(callable(ConfigBuilder $config, object $source): void)|null + */ + public function resolve(object $source): TransformerInterface|callable|null + { + if ($transformer = $this->transformers[$source::class] ?? null) { + return $transformer; + } + + foreach ($this->transformers as $sourceClass => $transformer) + { + if ($source instanceof $sourceClass) { + return $transformer; + } + } + + return null; + } +} 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/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..7fd90a54 --- /dev/null +++ b/src/Contract/DcaContract.php @@ -0,0 +1,19 @@ + 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/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, FilterDefinition $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 abab4d6b..00000000 --- a/src/Contract/FilterElement/RuntimeValueContract.php +++ /dev/null @@ -1,23 +0,0 @@ -getValue()`. - */ - public function processRuntimeValue(mixed $value, ListSpecification $list, FilterDefinition $filter): mixed; -} \ No newline at end of file diff --git a/src/Contract/ListDriver/BuildListContract.php b/src/Contract/ListDriver/BuildListContract.php new file mode 100644 index 00000000..bf801773 --- /dev/null +++ b/src/Contract/ListDriver/BuildListContract.php @@ -0,0 +1,16 @@ +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) @@ -101,13 +100,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) @@ -116,13 +115,17 @@ 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); } - $this->responseTagger->addTags(['contao.db.' . $listModel->dc]); + $this->responseTagger->addTags(['contao.db.' . $engine->getList()->dc]); $event = $this->eventDispatcher->dispatch( new ListViewRenderEvent( @@ -212,17 +215,20 @@ 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); + 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]', - $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) )); } -} \ No newline at end of file +} diff --git a/src/Controller/ContentElement/ReaderController.php b/src/Controller/ContentElement/ReaderController.php index ebf4e9d5..7d007465 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\ListSpecBuilderFactory; use HeimrichHannot\FlareBundle\Model\ListModel; -use HeimrichHannot\FlareBundle\Reader\Resolver\ReaderRequestAttributeResolver; +use HeimrichHannot\FlareBundle\Reader\Factory\ReaderRequestAttributeFactory; use HeimrichHannot\FlareBundle\Reader\ReaderPageMeta; -use HeimrichHannot\FlareBundle\Reader\ReaderRequestAttribute; -use HeimrichHannot\FlareBundle\Specification\Factory\ListSpecificationFactory; +use HeimrichHannot\FlareBundle\Reader\Resolver\ReaderRequestAttributeResolver; use HeimrichHannot\FlareBundle\Util\Str; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; @@ -48,8 +48,9 @@ public function __construct( private readonly EngineFactory $engineFactory, private readonly EntityCacheTags $entityCacheTags, private readonly KernelInterface $kernel, - private readonly ListSpecificationFactory $listSpecificationFactory, + private readonly ListSpecBuilderFactory $listFactory, private readonly LoggerInterface $logger, + private readonly ReaderRequestAttributeFactory $attributeFactory, private readonly ReaderRequestAttributeResolver $attributeResolver, private readonly ResponseContextAccessor $responseContextAccessor, private readonly ScopeMatcher $scopeMatcher, @@ -94,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) @@ -112,19 +113,19 @@ protected function getFrontendResponse(Template $template, ContentModel $content try { - $listSpec = $this->listSpecificationFactory->create(dataSource: $listModel); + $list = $this->listFactory->createFromListModel($listModel)->build(); $validationContext = $this->validationContextFactory->createFromContent( contentModel: $contentModel, - listModel: $listModel + list: $list, ); - $engine = $this->engineFactory->createEngine($validationContext, $listSpec); + $engine = $this->engineFactory->createEngine($validationContext, $list); $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)) { @@ -133,16 +134,17 @@ protected function getFrontendResponse(Template $template, ContentModel $content $errData[] = "{$autoItemModel::getTable()}.id={$autoItemModel->id}"; - $this->attributeResolver->store(new ReaderRequestAttribute($autoItemModel, $listSpec), $request); + $attribute = $this->attributeFactory->createFromModels($autoItemModel, $listModel); + $this->attributeResolver->store($attribute, $request); $this->entityCacheTags->tagWith($autoItemModel); /** @var ReaderPageMetaEvent $pageMetaEvent $pageMetaEvent */ $pageMetaEvent = $this->eventDispatcher->dispatch(new ReaderPageMetaEvent( contentModel: $contentModel, displayModel: $autoItemModel, - listSpecification: $listSpec, + list: $list, )); - $pageMeta = $pageMetaEvent->getPageMeta(); + $pageMeta = $pageMetaEvent->pageMeta; } catch (FlareException $e) { @@ -157,7 +159,7 @@ protected function getFrontendResponse(Template $template, ContentModel $content contentModel: $contentModel, context: $validationContext, displayModel: $autoItemModel, - listSpecification: $listSpec, + list: $list, pageMeta: $pageMeta, template: $template, ) @@ -172,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 { @@ -224,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), @@ -232,4 +242,4 @@ protected function getBackendResponse(Template $template, ContentModel $model, R $listModel->dc, )); } -} \ No newline at end of file +} diff --git a/src/DataContainer/Builder/DcaBuilder.php b/src/DataContainer/Builder/DcaBuilder.php new file mode 100644 index 00000000..dc3ca3d5 --- /dev/null +++ b/src/DataContainer/Builder/DcaBuilder.php @@ -0,0 +1,110 @@ + + */ + 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/DcaBuilderInterface.php b/src/DataContainer/Builder/DcaBuilderInterface.php new file mode 100644 index 00000000..11933fee --- /dev/null +++ b/src/DataContainer/Builder/DcaBuilderInterface.php @@ -0,0 +1,20 @@ +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..ac3edb89 --- /dev/null +++ b/src/DataContainer/Builder/DcaFieldBuilder.php @@ -0,0 +1,137 @@ + + */ + 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']); + } + /** @mago-expect lint:no-else-clause This else clause is fine. */ + 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/Builder/DcaFieldBuilderInterface.php b/src/DataContainer/Builder/DcaFieldBuilderInterface.php new file mode 100644 index 00000000..72708138 --- /dev/null +++ b/src/DataContainer/Builder/DcaFieldBuilderInterface.php @@ -0,0 +1,22 @@ + - - 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); - - $filterDefinition = $this->filterDefinitionFactory->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, - FilterDefinition::class => $filterDefinition, - 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]; } - - // -} \ No newline at end of file +} 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 + public function hasFilterConfigured(ListModel $listModel, string $filterType): bool { - 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, - ]); + $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(); } - /** - * @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 * * ============================= */ @@ -131,23 +60,26 @@ public function onSubmitConfig(DataContainer $dc): void return; } - if (!$listTypeConfig = $this->listTypeRegistry->get($type)) { + if (!$service = $this->listDriverRegistry->getService($type)) { return; } - $service = $listTypeConfig->getService(); + $expectedDataContainer = null; - if (($service instanceof DataContainerContract) - && !$expectedDataContainer = $service->getDataContainerName($row, $dc)) + 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 - $expectedDataContainer ??= $listTypeConfig->getDataContainer(); + if (!$expectedDataContainer) { + $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)) @@ -177,4 +109,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|null $isTargeted - * @param mixed ...$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, - ?string $palette = null, - ?string $formType = null, - ?string $method = null, - ?bool $isTargeted = null, - mixed ...$attributes + ?string $type = null, + public ?bool $isTargeted = null, + public ?string $value = null, + mixed ...$attributes ) { - $attributes['type'] = $type ?? $attributes['alias'] ?? null; - $attributes['palette'] = $palette; - $attributes['formType'] = $formType; - $attributes['method'] = $method; + $this->type = $type ?? $attributes['alias'] ?? null; + + $attributes['type'] = $this->type; $attributes['isTargeted'] = $isTargeted; + $attributes['value'] = $value; $this->attributes = $attributes; } -} \ No newline at end of file +} diff --git a/src/DependencyInjection/Attribute/AsFilterForm.php b/src/DependencyInjection/Attribute/AsFilterForm.php new file mode 100644 index 00000000..40288e18 --- /dev/null +++ b/src/DependencyInjection/Attribute/AsFilterForm.php @@ -0,0 +1,43 @@ + */ + 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 list $requires Capability interfaces the element must implement for this + * form to be offered for it (plain `instanceof`). + */ + public function __construct( + ?string $name = null, + public array $requires = [], + mixed ...$attributes + ) { + $this->name = $name; + + $attributes['name'] = $this->name; + $attributes['requires'] = $this->requires; + + $this->attributes = $attributes; + } +} 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 @@ -type = $type ?? $attributes['alias'] ?? null; + + $attributes['type'] = $this->type; + $attributes['dataContainer'] = $dataContainer; + + $this->attributes = $attributes; + } +} diff --git a/src/DependencyInjection/Attribute/AsListType.php b/src/DependencyInjection/Attribute/AsListType.php deleted file mode 100644 index a56db621..00000000 --- a/src/DependencyInjection/Attribute/AsListType.php +++ /dev/null @@ -1,26 +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..43abca11 100644 --- a/src/DependencyInjection/Compiler/RegisterFilterElementsPass.php +++ b/src/DependencyInjection/Compiler/RegisterFilterElementsPass.php @@ -6,14 +6,11 @@ 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 +27,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,45 +34,26 @@ 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, + $attributes['value'] ?? null, + ]); /** @see FilterElementRegistry::add() */ - $registry->addMethodCall('add', [$type, $config]); + $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); } } } - 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['palette'] ?? null, - $attributes['formType'] ?? null, - $attributes['method'] ?? null, - $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)) @@ -93,4 +67,4 @@ protected function getFilterElementType(Definition $definition, array $attribute return TypeNameFactory::createFilterElementType($definition->getClass()); } -} \ No newline at end of file +} diff --git a/src/DependencyInjection/Compiler/RegisterFilterFormsPass.php b/src/DependencyInjection/Compiler/RegisterFilterFormsPass.php new file mode 100644 index 00000000..32f1cf8b --- /dev/null +++ b/src/DependencyInjection/Compiler/RegisterFilterFormsPass.php @@ -0,0 +1,194 @@ +, + * 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), + 'service' => $serviceId, + ]; + + $locations[$name] = new Reference($serviceId); + + $container + ->setAlias('flare.filter_form.' . $name, $serviceId) + ->setPublic(true); + } + } + + $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; + } +} diff --git a/src/DependencyInjection/Compiler/RegisterFilterInvokersPass.php b/src/DependencyInjection/Compiler/RegisterFilterInvokersPass.php deleted file mode 100644 index ddfd10d3..00000000 --- a/src/DependencyInjection/Compiler/RegisterFilterInvokersPass.php +++ /dev/null @@ -1,114 +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/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/RegisterListDriversPass.php b/src/DependencyInjection/Compiler/RegisterListDriversPass.php new file mode 100644 index 00000000..98f63c06 --- /dev/null +++ b/src/DependencyInjection/Compiler/RegisterListDriversPass.php @@ -0,0 +1,66 @@ +hasDefinition(ListDriverRegistry::class)) { + return; + } + + $tag = AsListDriver::TAG; + $registry = $container->findDefinition(ListDriverRegistry::class); + + foreach ($this->findAndSortTaggedServices($tag, $container) as $reference) + { + $definition = $container->findDefinition((string) $reference); + $tags = $definition->getTag($tag); + $definition->clearTag($tag); + + foreach ($tags as $attributes) + { + $type = $this->getListDriverName($definition, $attributes); + + /** @see AsListDriver::__construct */ + $attribute = new Definition(AsListDriver::class, [$type, $attributes['dataContainer'] ?? null]); + + /** @see ListDriverRegistry::add() */ + $registry->addMethodCall('add', [$reference, $attribute, $type]); + + $serviceId = 'flare.list_driver.' . $type; + + $container + ->setAlias($serviceId, (string) $reference) + ->setPublic(true); + } + } + } + + protected function getListDriverName(Definition $definition, array $attributes): string + { + if ($type = (string) ($attributes['type'] ?? '')) + { + if ($type === 'default') { + throw new \InvalidArgumentException('The list type name "default" is a reserved keyword.'); + } + + return $type; + } + + return TypeNameFactory::createListDriverType($definition->getClass()); + } +} diff --git a/src/DependencyInjection/Compiler/RegisterListTypesPass.php b/src/DependencyInjection/Compiler/RegisterListTypesPass.php deleted file mode 100644 index 109c88c7..00000000 --- a/src/DependencyInjection/Compiler/RegisterListTypesPass.php +++ /dev/null @@ -1,101 +0,0 @@ -hasDefinition(ListTypeRegistry::class)) { - return; - } - - $tag = AsListType::TAG; - $registry = $container->findDefinition(ListTypeRegistry::class); - - 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); - - 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 FilterElementRegistry::add() */ - $registry->addMethodCall('add', [$type, $config]); - - $childDefinition->setTags($definition->getTags()); - $container->setDefinition($serviceId, $childDefinition); - } - } - } - - protected function getListTypeConfig( - ContainerBuilder $container, - Reference $reference, - array $attributes - ): Reference { - /** @see ListTypeDescriptor::__construct */ - $definition = new Definition(ListTypeDescriptor::class, [ - $reference, - $attributes, - $attributes['dataContainer'] ?? null, - $attributes['palette'] ?? null, - $attributes['method'] ?? 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'] ?? '')) - { - if ($type === 'default') { - throw new \InvalidArgumentException('The list type name "default" is a reserved keyword.'); - } - - return $type; - } - - $className = $definition->getClass(); - $className = \ltrim(\strrchr($className, '\\'), '\\'); - $className = Str::trimSubstrings($className, suffix: ['ListType', 'Type']); - - return Container::underscore($className); - } -} \ No newline at end of file diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 592ad2d7..997d7286 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -14,6 +14,9 @@ public function getConfigTreeBuilder(): TreeBuilder $treeBuilder = new TreeBuilder('huh_flare'); $rootNode = $treeBuilder->getRootNode(); + // 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') diff --git a/src/DependencyInjection/Factory/TypeNameFactory.php b/src/DependencyInjection/Factory/TypeNameFactory.php index 8d696646..dc6893d9 100644 --- a/src/DependencyInjection/Factory/TypeNameFactory.php +++ b/src/DependencyInjection/Factory/TypeNameFactory.php @@ -7,13 +7,28 @@ 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 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 ebb5d8f6..0852e9fa 100644 --- a/src/DependencyInjection/HeimrichHannotFlareExtension.php +++ b/src/DependencyInjection/HeimrichHannotFlareExtension.php @@ -4,13 +4,9 @@ namespace HeimrichHannot\FlareBundle\DependencyInjection; -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; -use HeimrichHannot\FlareBundle\Registry\Descriptor\FlareCallbackDescriptor; +use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsFilterForm; +use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListDriver; use HeimrichHannot\FlareBundle\Util\Env; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ChildDefinition; @@ -56,40 +52,20 @@ 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, - 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, - AsListCallback::class => FlareCallbackDescriptor::TAG_LIST_CALLBACK, + AsFilterForm::class => AsFilterForm::TAG, ]; 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); } ); @@ -106,4 +82,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/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/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 7146db32..00000000 --- a/src/DependencyInjection/Registry/ServiceDescriptorInterface.php +++ /dev/null @@ -1,14 +0,0 @@ - $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/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/Factory/InteractiveContextFactory.php b/src/Engine/Context/Factory/InteractiveContextFactory.php index a4238815..b00ee22e 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\List\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,30 +20,28 @@ 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' . ($contentModel->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, + formName: $filterFormName, sortOrderSequence: $sortOrderSequence, contentModelId: (int) $contentModel->id, - formActionPage: (int) $contentModel->{ContentContainer::FIELD_JUMP_TO}, - formName: $filterFormName, + formActionPageId: (int) $contentModel->{ContentContainer::FIELD_JUMP_TO}, jumpToReaderPageId: $jumpToReaderPageId, autoItemField: $fieldAutoItem, ); @@ -57,4 +54,4 @@ public function createFromContent(ContentModel $contentModel, ListModel $listMod return $config; } -} \ No newline at end of file +} diff --git a/src/Engine/Context/Factory/ValidationContextFactory.php b/src/Engine/Context/Factory/ValidationContextFactory.php index b5dda3b4..7465dff4 100644 --- a/src/Engine/Context/Factory/ValidationContextFactory.php +++ b/src/Engine/Context/Factory/ValidationContextFactory.php @@ -7,9 +7,7 @@ use Contao\ContentModel; 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\List\ListSpec; use Symfony\Component\Validator\Exception\ValidationFailedException; use Symfony\Component\Validator\Validator\ValidatorInterface; @@ -19,16 +17,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, @@ -44,21 +40,4 @@ public function createFromContent(ContentModel $contentModel, ListModel $listMod 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/Interface/FormContextInterface.php b/src/Engine/Context/FormContextInterface.php similarity index 50% rename from src/Engine/Context/Interface/FormContextInterface.php rename to src/Engine/Context/FormContextInterface.php index baf22f82..bf957b0b 100644 --- a/src/Engine/Context/Interface/FormContextInterface.php +++ b/src/Engine/Context/FormContextInterface.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\Engine\Context\Interface; +namespace HeimrichHannot\FlareBundle\Engine\Context; interface FormContextInterface { public function getFormName(): string; - public function getFormActionPage(): int; -} \ No newline at end of file + public function createFormActionUrl(): ?string; +} diff --git a/src/Engine/Context/InteractiveContext.php b/src/Engine/Context/InteractiveContext.php index 2a3fdfc6..e5efc7e1 100644 --- a/src/Engine/Context/InteractiveContext.php +++ b/src/Engine/Context/InteractiveContext.php @@ -4,42 +4,43 @@ namespace HeimrichHannot\FlareBundle\Engine\Context; -use Contao\ContentModel; 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 ContextInterface, - Interface\FormContextInterface, - Interface\PaginatedContextInterface, - Interface\SortableContextInterface + FormContextInterface, + PaginatedContextInterface, + SortableContextInterface { use ReaderUrlConfigCreatorTrait; + private readonly LazyPage $formActionPage; + public static function getContextType(): string { return 'interactive'; } 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, + #[Assert\NotBlank] public string $formName, + public ?SortOrderSequence $sortOrderSequence = null, + #[Assert\PositiveOrZero] public int $contentModelId = 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 getContentModel(): ?ContentModel + public function getPaginatorConfig(): PaginatorConfig { - if ($this->contentModelId === 0) { - return null; - } - - return ContentModel::findByPk($this->contentModelId); + return $this->paginatorConfig; } public function getFormName(): string @@ -47,14 +48,9 @@ public function getFormName(): string return $this->formName; } - public function getFormActionPage(): int - { - return $this->formActionPage; - } - - public function getPaginatorConfig(): PaginatorConfig + public function getSortOrderSequence(): ?SortOrderSequence { - return $this->paginatorConfig; + return $this->sortOrderSequence; } public function getPaginatorQueryParameter(): ?string @@ -67,30 +63,25 @@ 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( ?PaginatorConfig $paginatorConfig = null, ?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; + ): self { + return new self( + paginatorConfig: $paginatorConfig ?? $this->paginatorConfig, + formName: $formName ?? $this->formName, + sortOrderSequence: $this->sortOrderSequence, + contentModelId: $this->contentModelId, + formActionPageId: $this->formActionPageId, + jumpToReaderPageId: $this->jumpToReaderPageId, + autoItemField: $this->autoItemField, + pageParam: $pageParam ?? $this->pageParam, + ); } -} \ No newline at end of file +} 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/ReaderUrlConfigCreatorTrait.php b/src/Engine/Context/ReaderUrlConfigCreatorTrait.php index 490fdcc6..424873ad 100644 --- a/src/Engine/Context/ReaderUrlConfigCreatorTrait.php +++ b/src/Engine/Context/ReaderUrlConfigCreatorTrait.php @@ -6,19 +6,24 @@ use Contao\PageModel; use HeimrichHannot\FlareBundle\Reader\ReaderUrlConfig; +use HeimrichHannot\FlareBundle\Util\LazyPage; trait ReaderUrlConfigCreatorTrait { - public function createReaderUrlConfig(): ?ReaderUrlConfig + /** Must be initialized by the using class' constructor. */ + private readonly LazyPage $jumpToReaderPage; + + protected function getJumpToReaderPage(): ?PageModel { - if (!$this->jumpToReaderPageId) { - return null; - } + return $this->jumpToReaderPage->get(); + } - if (!$pageModel = PageModel::findByPk($this->jumpToReaderPageId)) { + 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/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 de51a4eb..54157faa 100644 --- a/src/Engine/Context/ValidationContext.php +++ b/src/Engine/Context/ValidationContext.php @@ -4,66 +4,49 @@ namespace HeimrichHannot\FlareBundle\Engine\Context; -use Contao\PageModel; +use HeimrichHannot\FlareBundle\Filter\FilterData; 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 ContextInterface, - Interface\PaginatedContextInterface + PaginatedContextInterface { use ReaderUrlConfigCreatorTrait; private PaginatorConfig $paginatorConfig; + private LazyPage $jumpToListViewPage; 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] private string $autoItemField = 'id', + #[Assert\NotBlank] public string $autoItemField = 'id', private array $filterValues = [], ) { $this->paginatorConfig = new PaginatorConfig(itemsPerPage: 1); + $this->jumpToReaderPage = new LazyPage($jumpToReaderPageId); + $this->jumpToListViewPage = new LazyPage($jumpToListViewPageId); } public function createBackLink(): ?BackLink { - if (!$this->jumpToListViewPageId) { - return null; - } - - if (!$pageModel = PageModel::findByPk($this->jumpToListViewPageId)) { + if (!$pageModel = $this->jumpToListViewPage->get()) { return null; } return BackLink::fromPage($pageModel); } - public function getAutoItemField(): string - { - return $this->autoItemField; - } - - 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 : []; - } - + /** + * @return array + */ public function getFilterValues(): array { return $this->filterValues; @@ -84,14 +67,16 @@ public function setPaginatorQueryParameter(?string $queryParameter): void // ignore } + /** + * @param array $values + */ public function withFilterValues(array $values): self { return new self( - entryCache: $this->entryCache, jumpToReaderPageId: $this->jumpToReaderPageId, jumpToListViewPageId: $this->jumpToListViewPageId, autoItemField: $this->autoItemField, filterValues: $values, ); } -} \ No newline at end of file +} diff --git a/src/Engine/Engine.php b/src/Engine/Engine.php index e569e3ee..9a84fe49 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\Specification\ListSpecification; 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 */ @@ -43,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); } @@ -94,13 +104,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 +123,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..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\Specification\ListSpecification; final readonly class EngineFactory { @@ -17,17 +17,14 @@ public function __construct( private ProjectorRegistry $projectorRegistry, ) {} - public function createEngine( - ContextInterface $context, - ListSpecification $listSpecification, - array $mods = [], - ): Engine { + public function createEngine(ContextInterface $context, ListSpec $list, array $mods = []): Engine + { return new Engine( engineModRegistry: $this->engineModRegistry, projectorRegistry: $this->projectorRegistry, context: $context, - list: $listSpecification, + list: $list, mods: $mods, ); } -} \ No newline at end of file +} diff --git a/src/Engine/Factory/LoaderFactory.php b/src/Engine/Factory/LoaderFactory.php index e07c522f..6993480e 100644 --- a/src/Engine/Factory/LoaderFactory.php +++ b/src/Engine/Factory/LoaderFactory.php @@ -10,14 +10,14 @@ 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\Filter\Factory\FilterFactory; use HeimrichHannot\FlareBundle\Query\Executor\ListQueryDirector; final readonly class LoaderFactory { public function __construct( - private FilterValueResolver $filterValueResolver, - private ListQueryDirector $listQueryDirector, + private FilterFactory $filterFactory, + private ListQueryDirector $listQueryDirector, ) {} public function createAggregationLoader(AggregationLoaderConfig $config): AggregationLoader @@ -39,9 +39,9 @@ public function createInteractiveLoader(InteractiveLoaderConfig $config): Intera public function createValidationLoader(ValidationLoaderConfig $config): ValidationLoader { return new ValidationLoader( - filterValueResolver: $this->filterValueResolver, - listQueryDirector: $this->listQueryDirector, config: $config, + filterFactory: $this->filterFactory, + listQueryDirector: $this->listQueryDirector, ); } } \ No newline at end of file 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/Loader/AggregationLoaderConfig.php b/src/Engine/Loader/AggregationLoaderConfig.php index ad884679..2a86d647 100644 --- a/src/Engine/Loader/AggregationLoaderConfig.php +++ b/src/Engine/Loader/AggregationLoaderConfig.php @@ -5,13 +5,17 @@ namespace HeimrichHannot\FlareBundle\Engine\Loader; use HeimrichHannot\FlareBundle\Engine\Context\AggregationContext; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Filter\FilterData; +use HeimrichHannot\FlareBundle\List\ListSpec; readonly class AggregationLoaderConfig { + /** + * @param array $filterValues + */ public function __construct( - public ListSpecification $list, + public ListSpec $list, public AggregationContext $context, public array $filterValues, ) {} -} \ No newline at end of file +} diff --git a/src/Engine/Loader/InteractiveLoaderConfig.php b/src/Engine/Loader/InteractiveLoaderConfig.php index 81eb5306..b74f5682 100644 --- a/src/Engine/Loader/InteractiveLoaderConfig.php +++ b/src/Engine/Loader/InteractiveLoaderConfig.php @@ -5,12 +5,16 @@ namespace HeimrichHannot\FlareBundle\Engine\Loader; use HeimrichHannot\FlareBundle\Engine\Context\InteractiveContext; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Filter\FilterData; +use HeimrichHannot\FlareBundle\List\ListSpec; readonly class InteractiveLoaderConfig { + /** + * @param array $filterValues + */ 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 e405b335..4b518841 100644 --- a/src/Engine/Loader/ValidationLoader.php +++ b/src/Engine/Loader/ValidationLoader.php @@ -7,45 +7,66 @@ 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\Filter\Element\SimpleEquationFilterElement; +use HeimrichHannot\FlareBundle\Filter\Factory\FilterFactory; +use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Query\Executor\ListQueryDirector; use HeimrichHannot\FlareBundle\Query\ListQueryConfig; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Util\EntryCache; readonly class ValidationLoader implements ValidationLoaderInterface { + protected EntryCache $entryCache; + public function __construct( - private FilterValueResolver $filterValueResolver, - private ListQueryDirector $listQueryDirector, 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 { - // IMPORTANT: clone the spec to not modify the original, i.e., when adding the id filter - $list = clone $this->config->list; - - $idDefinition = SimpleEquationElement::define( - equationLeft: 'id', - equationOperator: SqlEquationOperator::EQUALS, - equationRight: $id, + $idDefinition = $this->filterFactory->create( + element: SimpleEquationFilterElement::TYPE, + alias: '_.id', + config: [ + 'intrinsic' => true, + 'left' => 'id', + 'operator' => SqlEquationOperator::EQUALS, + 'right' => $id, + ], ); - $list->getFilters()->add($idDefinition); + $list = $this->config->list->withFilter($idDefinition); + + $entry = $this->executeQuery($list, $this->config->context); - return $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) { @@ -53,7 +74,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__); } } @@ -66,20 +87,34 @@ public function fetchEntryByAutoItem(string $autoItem): ?array return null; } + if ($entry = $this->entryCache->get('autoItem:' . $autoItem)) { + return $entry; + } + try { - // IMPORTANT: clone the spec to not modify the original - $list = clone $this->config->list; - - $autoItemDefinition = SimpleEquationElement::define( - equationLeft: $this->config->autoItemField, - equationOperator: SqlEquationOperator::EQUALS, - equationRight: $autoItem, + $autoItemDefinition = $this->filterFactory->create( + element: SimpleEquationFilterElement::TYPE, + alias: '_.autoItem', + config: [ + 'intrinsic' => true, + 'left' => $this->config->autoItemField, + 'operator' => SqlEquationOperator::EQUALS, + 'right' => $autoItem, + ], ); - $list->getFilters()->add($autoItemDefinition); + $list = $this->config->list->withFilter($autoItemDefinition); + + $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 $this->executeQuery($list, $this->config->context); + return $entry; } catch (FlareException $e) { @@ -87,19 +122,19 @@ 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__); } } /** * @throws \Exception */ - private function executeQuery(ListSpecification $spec, ValidationContext $config): ?array + private function executeQuery(ListSpec $list, ValidationContext $context): array { $qb = $this->listQueryDirector->createQueryBuilder(new ListQueryConfig( - list: $spec, - context: $config, - filterValues: $this->filterValueResolver->resolve($spec, $config->getFilterValues()), + list: $list, + context: $context, + filterValues: $context->getFilterValues(), )); if (!$qb) { @@ -112,6 +147,6 @@ private function executeQuery(ListSpecification $spec, ValidationContext $config $result->free(); - return $entry ?: null; + return $entry ?: []; } -} \ No newline at end of file +} diff --git a/src/Engine/Loader/ValidationLoaderConfig.php b/src/Engine/Loader/ValidationLoaderConfig.php index 62cc9085..dcc754e4 100644 --- a/src/Engine/Loader/ValidationLoaderConfig.php +++ b/src/Engine/Loader/ValidationLoaderConfig.php @@ -5,13 +5,13 @@ namespace HeimrichHannot\FlareBundle\Engine\Loader; use HeimrichHannot\FlareBundle\Engine\Context\ValidationContext; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\List\ListSpec; readonly class ValidationLoaderConfig { public function __construct( - public ListSpecification $list, + public ListSpec $list, public ValidationContext $context, public string $autoItemField, ) {} -} \ 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/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/Mod/SimpleEquationMod.php b/src/Engine/Mod/SimpleEquationMod.php index 8d866618..5815253c 100644 --- a/src/Engine/Mod/SimpleEquationMod.php +++ b/src/Engine/Mod/SimpleEquationMod.php @@ -6,11 +6,17 @@ use HeimrichHannot\FlareBundle\Engine\Engine; use HeimrichHannot\FlareBundle\Enum\SqlEquationOperator; -use HeimrichHannot\FlareBundle\FilterElement\SimpleEquationElement; +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 { + public function __construct( + private readonly FilterFactory $filterFactory, + ) {} + public static function getType(): string { return 'equation'; @@ -18,23 +24,18 @@ 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 = SimpleEquationElement::define( - equationLeft: $options['operand1'], - equationOperator: $operator, - equationRight: $options['operand2'], + $filter = $this->filterFactory->create( + element: SimpleEquationFilterElement::TYPE, + alias: $options['name'] ?: null, + config: [ + 'intrinsic' => true, + 'left' => $options['operand1'], + 'operator' => $options['operator'], + 'right' => $options['operand2'], + ], ); - $filters = $engine->getList()->getFilters(); - - if ($name = $options['name']) { - $filters->set($name, $filter); - return; - } - - $filters->add($filter); + $engine->setList($engine->getList()->withFilter($filter)); } public function configureOptions(OptionsResolver $resolver): void diff --git a/src/Engine/Projector/AbstractProjector.php b/src/Engine/Projector/AbstractProjector.php index 5388e1ec..82c52dfc 100644 --- a/src/Engine/Projector/AbstractProjector.php +++ b/src/Engine/Projector/AbstractProjector.php @@ -6,15 +6,15 @@ 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\Filter\Resolver\FilterValueResolver; +use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Query\Executor\ListQueryDirector; use HeimrichHannot\FlareBundle\Query\ListQueryConfig; -use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; +use HeimrichHannot\FlareBundle\Reader\Factory\ReaderUrlGeneratorFactory; use HeimrichHannot\FlareBundle\Registry\ProjectorRegistry; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; @@ -36,10 +36,10 @@ public function setContainer(ContainerInterface $container): void public static function getSubscribedServices(): array { return [ - FilterElementRegistry::class, - FilterValueResolver::class, ListQueryDirector::class, + LoaderFactory::class, ProjectorRegistry::class, + ReaderUrlGeneratorFactory::class, RequestStack::class, ]; } @@ -47,14 +47,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; } @@ -64,33 +64,28 @@ 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; - public function resolveFilterValues(ListSpecification $spec, array $runtimeValues): array - { - return $this->getFilterValueResolver()->resolve($spec, $runtimeValues); - } - - protected function getFilterElementRegistry(): FilterElementRegistry + protected function getListQueryDirector(): ListQueryDirector { - return $this->container->get(FilterElementRegistry::class); + return $this->container->get(ListQueryDirector::class); } - protected function getFilterValueResolver(): FilterValueResolver + protected function getLoaderFactory(): LoaderFactory { - return $this->container->get(FilterValueResolver::class); + return $this->container->get(LoaderFactory::class); } - protected function getListQueryDirector(): ListQueryDirector + protected function getReaderUrlGeneratorFactory(): ReaderUrlGeneratorFactory { - return $this->container->get(ListQueryDirector::class); + return $this->container->get(ReaderUrlGeneratorFactory::class); } /** * @throws FlareException */ protected function getProjectorFor( - ListSpecification $spec, + ListSpec $spec, ContextInterface $config, ?array $exclude = null, ): ProjectorInterface { @@ -101,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__); } } @@ -126,9 +121,9 @@ 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; } -} \ No newline at end of file +} diff --git a/src/Engine/Projector/AggregationProjector.php b/src/Engine/Projector/AggregationProjector.php index dd9c2cc5..f24dc852 100644 --- a/src/Engine/Projector/AggregationProjector.php +++ b/src/Engine/Projector/AggregationProjector.php @@ -6,34 +6,29 @@ 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; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\List\ListSpec; /** * @implements ProjectorInterface */ class AggregationProjector extends AbstractProjector { - 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'); $loader = $this->createLoader(new AggregationLoaderConfig( list: $list, context: $context, - filterValues: $this->resolveFilterValues($list, $context->getFilterValues()), + filterValues: $context->getFilterValues(), )); return $this->createView($loader); @@ -41,11 +36,11 @@ public function project(ListSpecification $list, ContextInterface $context): Agg 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/ExportProjector.php b/src/Engine/Projector/ExportProjector.php index b4ae7b64..eb2056c1 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\List\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 29edf78b..c8ffef21 100644 --- a/src/Engine/Projector/InteractiveProjector.php +++ b/src/Engine/Projector/InteractiveProjector.php @@ -4,26 +4,24 @@ 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; -use HeimrichHannot\FlareBundle\Engine\Context\Interface\PaginatedContextInterface; -use HeimrichHannot\FlareBundle\Engine\Factory\LoaderFactory; +use HeimrichHannot\FlareBundle\Engine\Context\PaginatedContextInterface; use HeimrichHannot\FlareBundle\Engine\Loader\InteractiveEmptyLoader; use HeimrichHannot\FlareBundle\Engine\Loader\InteractiveLoaderConfig; use HeimrichHannot\FlareBundle\Engine\Loader\InteractiveLoaderInterface; use HeimrichHannot\FlareBundle\Engine\View\AggregationView; use HeimrichHannot\FlareBundle\Engine\View\InteractiveView; use HeimrichHannot\FlareBundle\Exception\FlareException; -use HeimrichHannot\FlareBundle\Form\Factory\FilterFormFactory; +use HeimrichHannot\FlareBundle\Form\Factory\FormHarnessFactory; +use HeimrichHannot\FlareBundle\Filter\FilterContext; +use HeimrichHannot\FlareBundle\Filter\FilterData; +use HeimrichHannot\FlareBundle\Form\FormHarness; +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\Specification\ListSpecification; -use Symfony\Component\Form\Exception\OutOfBoundsException; use Symfony\Component\Form\FormInterface; /** @@ -33,25 +31,23 @@ class InteractiveProjector extends AbstractProjector { public function __construct( private readonly AggregationContextFactory $aggregationConfigFactory, - private readonly FilterFormFactory $filterFormFactory, - private readonly LoaderFactory $loaderFactory, + private readonly FormHarnessFactory $filterSetFactory, private readonly PaginatorFactory $paginatorFactory, - 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'); // collect filter values from form data - $form = $this->createForm($list, $context); - $runtimeValues = $this->mapFormDataToFilterKeys($list, $form); - $filterValues = $this->resolveFilterValues($list, $runtimeValues); + $filterSet = $this->createFilterSet($list, $context); + $form = $filterSet->getForm(); + $filterValues = $this->collectFilterData($list, $form); // pagination setup $totalItems = $this->createAggregationView($list, $context, $filterValues)->getCount(); @@ -75,7 +71,8 @@ public function project(ListSpecification $list, ContextInterface $context): Int $loader = $this->createLoader($config); } - $readerUrlGenerator = $this->readerUrlGeneratorFactory->create($context->createReaderUrlConfig()); + $readerUrlConfig = $context->createReaderUrlConfig(); + $readerUrlGenerator = $this->getReaderUrlGeneratorFactory()->create($readerUrlConfig); return $this->createView( loader: $loader, @@ -89,7 +86,7 @@ public function project(ListSpecification $list, ContextInterface $context): Int protected function createLoader(InteractiveLoaderConfig $config): InteractiveLoaderInterface { - return $this->loaderFactory->createInteractiveLoader($config); + return $this->getLoaderFactory()->createInteractiveLoader($config); } protected function createView( @@ -113,107 +110,87 @@ 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()); - - $this->hydrateForm($form, $list); - - return $form; + return $this->createFilterSet($list, $context)->getForm(); } /** - * @throws FlareException If the form does not contain the filter field. + * Builds the list's filter set and hands the current request to its root form. + * + * @throws FlareException */ - private function hydrateForm(FormInterface $form, ListSpecification $list): void + protected function createFilterSet(ListSpec $list, InteractiveContext $context): FormHarness { - if ($form->isSubmitted()) { - return; - } + $filterSet = $this->filterSetFactory->create($list, $context); - $filterElementRegistry = $this->getFilterElementRegistry(); + $filterSet->getForm()->handleRequest($this->getCurrentRequest()); - $data = []; - foreach ($list->getFilters()->getIterator() as $filterDefinition) - { - if (!$filterElement = $filterElementRegistry->get($filterDefinition->getType())?->getService()) { - continue; - } + return $filterSet; + } - if (!$filterElement instanceof HydrateFormContract) { - continue; - } + /** + * Collects each filter's form data, keyed by the filter's list-specification key. + * + * 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 + { + $data = []; - if ($filterDefinition->isIntrinsic()) { + foreach ($list->filters as $key => $filter) + { + if (!$form->has($key)) { continue; } - if (!$filterName = $filterDefinition->getAlias()) { - throw new FlareException(message: 'Non-intrinsic filter must provide a form field name.'); - } + $child = $form->get($key); - if (!$form->has($filterName)) { - continue; - } - - try + if ($child->getConfig()->getAttribute(FilterContext::ATTR_SINGLE_FIELD)) { - $field = $form->get($filterName); - } - catch (OutOfBoundsException $exception) - { - throw new FlareException( - message: 'Filter form does not contain field: ' . $filterName, - previous: $exception, - method: __METHOD__, - source: $filterDefinition->getDataSource()?->getFilterIdentifier() ?? 'filter inlined' - ); - } + // 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(); - $filterElement->hydrateForm($field, $list, $filterDefinition); - - $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)); - } + if ($form->isSubmitted() || !\is_null($value)) { + $data[$key] = FilterData::single($value); + } - protected function mapFormDataToFilterKeys(ListSpecification $list, FormInterface $form): array - { - $values = []; - - $filterElementRegistry = $this->getFilterElementRegistry(); - - foreach ($list->getFilters()->all() as $key => $definition) - { - $alias = $definition->getAlias(); - - if (\is_null($alias)) { continue; } - if (!$form->has($alias)) { + if ($form->isSubmitted()) + { + $data[$key] = FilterData::of((array) $child->getData()); continue; } - $field = $form->get($alias); - $filterElement = $filterElementRegistry->get($definition->getType())?->getService(); + // 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), + ); - $values[$key] = $filterElement instanceof FormDataContract - ? $filterElement->extractFormData($field) - : $field->getData(); + if ($values) { + $data[$key] = FilterData::of($values); + } } - return $values; + return $data; } /** + * @param array $filterValues + * * @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..3080f85d 100644 --- a/src/Engine/Projector/ProjectorInterface.php +++ b/src/Engine/Projector/ProjectorInterface.php @@ -6,32 +6,34 @@ use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; use HeimrichHannot\FlareBundle\Engine\View\ViewInterface; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\List\ListSpec; use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; /** * @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. */ - 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; -} \ No newline at end of file + public function project(ListSpec $list, ContextInterface $context): ViewInterface; +} diff --git a/src/Engine/Projector/ValidationProjector.php b/src/Engine/Projector/ValidationProjector.php index 50f72808..e48c2e0e 100644 --- a/src/Engine/Projector/ValidationProjector.php +++ b/src/Engine/Projector/ValidationProjector.php @@ -6,36 +6,29 @@ 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; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; /** * @implements ProjectorInterface */ class ValidationProjector extends AbstractProjector { - public function __construct( - private readonly LoaderFactory $loaderFactory, - 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'); $readerUrlConfig = $context->createReaderUrlConfig(); - $autoItemField = $readerUrlConfig->autoItemField ?? $context->getAutoItemField(); + $autoItemField = $readerUrlConfig->autoItemField ?? $context->autoItemField; $loader = $this->createLoader(new ValidationLoaderConfig( list: $list, @@ -43,7 +36,7 @@ public function project(ListSpecification $list, ContextInterface $context): Val autoItemField: $autoItemField, )); - $readerUrlGenerator = $this->readerUrlGeneratorFactory->create($readerUrlConfig); + $readerUrlGenerator = $this->getReaderUrlGeneratorFactory()->create($readerUrlConfig); return $this->createView( loader: $loader, @@ -56,7 +49,7 @@ public function project(ListSpecification $list, ContextInterface $context): Val protected function createLoader(ValidationLoaderConfig $config): ValidationLoaderInterface { - return $this->loaderFactory->createValidationLoader($config); + return $this->getLoaderFactory()->createValidationLoader($config); } protected function createView( @@ -74,4 +67,4 @@ protected function createView( backLink: $backLink, ); } -} \ No newline at end of file +} diff --git a/src/Engine/View/HandlesModelsTrait.php b/src/Engine/View/HandlesModelsTrait.php index dbed48e8..5c14ae7b 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,12 +21,16 @@ 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); 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)) { @@ -34,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); @@ -49,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(); @@ -58,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)) @@ -67,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); @@ -78,4 +83,4 @@ public function createModelsFromEntries(string $table, array $entries): array return $models; } -} \ No newline at end of file +} 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 +} 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/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/ElementDcaEvent.php b/src/Event/ElementDcaEvent.php new file mode 100644 index 00000000..75c9cd92 --- /dev/null +++ b/src/Event/ElementDcaEvent.php @@ -0,0 +1,22 @@ +queryBuilder; - } - - public function getInvocation(): FilterInvocation - { - return $this->invocation; - } -} \ 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/FilterFormBuildEvent.php b/src/Event/FilterFormBuildEvent.php deleted file mode 100644 index 0c13d470..00000000 --- a/src/Event/FilterFormBuildEvent.php +++ /dev/null @@ -1,18 +0,0 @@ -cancelled = true; + } + + public function isCancelled(): bool + { + return $this->cancelled; + } +} diff --git a/src/Event/FilterFormChildOptionsEvent.php b/src/Event/FilterFormChildOptionsEvent.php deleted file mode 100644 index dceb6d8f..00000000 --- a/src/Event/FilterFormChildOptionsEvent.php +++ /dev/null @@ -1,20 +0,0 @@ -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/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/Event/PaletteEvent.php b/src/Event/PaletteEvent.php deleted file mode 100644 index ed7470d4..00000000 --- a/src/Event/PaletteEvent.php +++ /dev/null @@ -1,47 +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/Event/QueryBaseInitializedEvent.php b/src/Event/QueryBaseInitializedEvent.php index 79340052..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\Specification\ListSpecification; use Symfony\Contracts\EventDispatcher\Event; class QueryBaseInitializedEvent extends Event { public function __construct( - public readonly ListSpecification $listSpecification, + 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 1af9be23..7b552367 100644 --- a/src/Event/ReaderPageMetaEvent.php +++ b/src/Event/ReaderPageMetaEvent.php @@ -6,44 +6,19 @@ use Contao\ContentModel; use Contao\Model; +use HeimrichHannot\FlareBundle\List\ListSpec; use HeimrichHannot\FlareBundle\Reader\ReaderPageMeta; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; class ReaderPageMetaEvent { - private ReaderPageMeta $pageMeta; + public ReaderPageMeta $pageMeta; public function __construct( - private readonly ContentModel $contentModel, - private readonly Model $displayModel, - private readonly ListSpecification $listSpecification, - ?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 getListSpecification(): ListSpecification - { - return $this->listSpecification; - } - - public function getPageMeta(): ReaderPageMeta - { - return $this->pageMeta; - } - - 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 1513d5a5..5ecd7429 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\List\ListSpec; use HeimrichHannot\FlareBundle\Reader\ReaderPageMeta; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; use Symfony\Contracts\EventDispatcher\Event; class ReaderRenderEvent extends Event @@ -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 ListSpecification $listSpecification, - 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 getListSpecification(): ListSpecification - { - return $this->listSpecification; - } - - 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; @@ -68,4 +36,4 @@ public function setTemplate(Template $template): self return $this; } -} \ No newline at end of file +} diff --git a/src/Event/ReaderSchemaOrgEvent.php b/src/Event/ReaderSchemaOrgEvent.php index 9a2a8b98..4340a015 100644 --- a/src/Event/ReaderSchemaOrgEvent.php +++ b/src/Event/ReaderSchemaOrgEvent.php @@ -5,14 +5,14 @@ namespace HeimrichHannot\FlareBundle\Event; use Contao\Model; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\List\ListSpec; use Symfony\Contracts\EventDispatcher\Event; class ReaderSchemaOrgEvent extends Event { public function __construct( - public readonly ListSpecification $listSpecification, - public readonly Model $model, - public array $data = [], + public readonly ListSpec $list, + public readonly Model $model, + public array $data = [], ) {} -} \ No newline at end of file +} diff --git a/src/EventListener/Contao/BreadcrumbListener.php b/src/EventListener/Contao/BreadcrumbListener.php index 1b32a9bc..c895d993 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\ListSpecBuilderFactory; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Registry\ProjectorRegistry; -use HeimrichHannot\FlareBundle\Specification\Factory\ListSpecificationFactory; 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 ListSpecBuilderFactory $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,10 +115,10 @@ 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(); + $title = $pageMetaEvent->pageMeta->getTitle(); $item = &$items[\count($items) - 1]; if ($title && $item) @@ -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 new file mode 100644 index 00000000..bf27708a --- /dev/null +++ b/src/EventListener/Contao/ElementDcaListener.php @@ -0,0 +1,171 @@ +configure($table); + } + + private function configure(string $table): void + { + if (!$id = Input::get('id')) { + return; + } + + $type = ''; + $service = null; + + if ($table === FilterModel::getTable()) + { + $filterModel = FilterModel::findByPk($id); + $listModel = $filterModel?->getRelated('pid'); + $type = (string) ($filterModel->type ?? ''); + $service = $this->filterElementRegistry->getService($type); + } + /** @mago-expect lint:no-else-clause This else clause is fine. */ + else + { + $filterModel = null; + + 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, '__')) { + 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->buildDca($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->listFactory->createFromListModel($listModel)->build(); + + return $this->listExecutionContextFactory->create($specification); + } + catch (\Throwable) {} + + 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/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/AddTargetAliasFieldCallback.php b/src/EventListener/DataContainer/FlareFilter/AddTargetAliasFieldCallback.php index fd10b8f2..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. */ @@ -42,11 +40,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 +50,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 be134bf2..c178a821 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)?->isIntrinsicRequired()) + $filterElement = $this->filterElementRegistry->getService($row['type'] ?? null); + + 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)?->isIntrinsicRequired()) { + $element = $this->filterElementRegistry->getService($row['type'] ?? null); + + 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/EventListener/DataContainer/FlareFilter/FieldsOptionsCallbacks.php b/src/EventListener/DataContainer/FlareFilter/FieldsOptionsCallbacks.php index c66caa56..58b99f5a 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\ListSpecBuilderFactory; 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\Specification\Factory\ListSpecificationFactory; 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 ListSpecBuilderFactory $listFactory, private ListExecutionContextFactory $listExecutionContextFactory, ) {} @@ -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()) { @@ -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 = []; @@ -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/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/EventListener/DataContainer/FlareList/FieldsOptionsCallbacks.php b/src/EventListener/DataContainer/FlareList/FieldsOptionsCallbacks.php index 528f6456..6c862c6e 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 $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'); } @@ -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/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 73bbde4e..2c553c5a 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\FilterElementBuildingEvent; +use HeimrichHannot\FlareBundle\Event\FilterElementBuiltEvent; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -16,20 +16,22 @@ 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"; + if (!$type = $event->context->filter->type) { + return; + } - $this->eventDispatcher->dispatch(event: $event, eventName: $eventName); + $this->eventDispatcher->dispatch(event: $event, eventName: "flare.filter_element.{$type}.built"); } #[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"; + if (!$type = $event->context->filter->type) { + 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 +} diff --git a/src/EventListener/NamedDispatch/FilterFormListener.php b/src/EventListener/NamedDispatch/FilterFormListener.php index e43fde7e..7931be09 100644 --- a/src/EventListener/NamedDispatch/FilterFormListener.php +++ b/src/EventListener/NamedDispatch/FilterFormListener.php @@ -4,8 +4,7 @@ namespace HeimrichHannot\FlareBundle\EventListener\NamedDispatch; -use HeimrichHannot\FlareBundle\Event\FilterFormBuildEvent; -use HeimrichHannot\FlareBundle\Event\FilterFormChildOptionsEvent; +use HeimrichHannot\FlareBundle\Event\FilterFormBuiltEvent; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -16,18 +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"); } - - #[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/FilterTransformerListener.php b/src/EventListener/NamedDispatch/FilterTransformerListener.php new file mode 100644 index 00000000..5ce43f47 --- /dev/null +++ b/src/EventListener/NamedDispatch/FilterTransformerListener.php @@ -0,0 +1,22 @@ +eventDispatcher->dispatch(event: $event, eventName: "flare.filter_element.{$event->type}.transformers"); + } +} diff --git a/src/EventListener/NamedDispatch/ListSpecificationListener.php b/src/EventListener/NamedDispatch/FormHarnessListener.php similarity index 60% rename from src/EventListener/NamedDispatch/ListSpecificationListener.php rename to src/EventListener/NamedDispatch/FormHarnessListener.php index 706cb8c1..2bb4c53b 100644 --- a/src/EventListener/NamedDispatch/ListSpecificationListener.php +++ b/src/EventListener/NamedDispatch/FormHarnessListener.php @@ -4,21 +4,21 @@ namespace HeimrichHannot\FlareBundle\EventListener\NamedDispatch; -use HeimrichHannot\FlareBundle\Event\ListSpecificationCreatedEvent; +use HeimrichHannot\FlareBundle\Event\FormHarnessBuildEvent; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; -readonly class ListSpecificationListener +readonly class FormHarnessListener { public function __construct( private EventDispatcherInterface $eventDispatcher, ) {} #[AsEventListener(priority: -200)] - public function onListSpecificationCreated(ListSpecificationCreatedEvent $event): void + public function onFormHarnessBuildEvent(FormHarnessBuildEvent $event): void { - $eventName = "flare.list_type.{$event->listSpecification->type}.list_specification_created"; + $eventName = "flare.form.{$event->formName}.build"; $this->eventDispatcher->dispatch(event: $event, eventName: $eventName); } -} \ No newline at end of file +} diff --git a/src/EventListener/NamedDispatch/ListBuildListener.php b/src/EventListener/NamedDispatch/ListBuildListener.php new file mode 100644 index 00000000..1fbae64b --- /dev/null +++ b/src/EventListener/NamedDispatch/ListBuildListener.php @@ -0,0 +1,28 @@ +builder->getDriver(); + + 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 new file mode 100644 index 00000000..58bb40d4 --- /dev/null +++ b/src/EventListener/NamedDispatch/ListTransformerListener.php @@ -0,0 +1,26 @@ +type) { + return; + } + + $this->eventDispatcher->dispatch(event: $event, eventName: "flare.list.{$event->type}.transformers"); + } +} 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/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/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..7f9cc807 100644 --- a/src/EventListener/Reader/GenericReaderPageMetaListener.php +++ b/src/EventListener/Reader/GenericReaderPageMetaListener.php @@ -22,19 +22,17 @@ public function __construct( public function __invoke(ReaderPageMetaEvent $event): void { - $list = $event->getListSpecification(); - $contentModel = $event->getContentModel(); - $model = $event->getDisplayModel(); + $list = $event->list; - if (!$list->getProperty('eval_generic_page_meta')) { + if (!($list->config['genericPageMeta'] ?? false)) { return; } - $pageMeta = $event->getPageMeta(); + $pageMeta = $event->pageMeta; - $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,13 +40,13 @@ public function __invoke(ReaderPageMetaEvent $event): void } $tokens = [ - 'list.type' => $list->type, + 'list.driver_class' => \get_class($list->driver), 'list.dc' => $list->dc, ]; - $this->addTokensFromProperties($tokens, $list->getProperties(), prefix: 'list'); - $this->addTokensFromProperties($tokens, $contentModel->row(), prefix: 'ce'); - $this->addTokensFromProperties($tokens, $model->row()); + $this->addTokensFromProperties($tokens, $list->config, prefix: 'list'); + $this->addTokensFromProperties($tokens, $event->contentModel->row(), prefix: 'ce'); + $this->addTokensFromProperties($tokens, $event->displayModel->row()); if ($titleFormat) { @@ -78,11 +76,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; @@ -96,4 +104,4 @@ private function addTokensFromProperties(array &$tokens, array $properties, ?str } } } -} \ No newline at end of file +} 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/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/CallbackFilterModelTransformer.php b/src/Filter/CallbackFilterModelTransformer.php new file mode 100644 index 00000000..e4bd8a9d --- /dev/null +++ b/src/Filter/CallbackFilterModelTransformer.php @@ -0,0 +1,28 @@ +transform)($config, $source); + } +} diff --git a/src/Filter/Element/AbstractFilterElement.php b/src/Filter/Element/AbstractFilterElement.php new file mode 100644 index 00000000..beaba43e --- /dev/null +++ b/src/Filter/Element/AbstractFilterElement.php @@ -0,0 +1,89 @@ +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(ConfigBuilder $config, FilterModel $model): void; + + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void {} + + public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} + + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void {} + + public function isSupported(): bool + { + return true; + } + + 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; + } + + protected function getConnection(): Connection + { + return $this->connection; + } +} diff --git a/src/Filter/Element/ArchiveFilterElement.php b/src/Filter/Element/ArchiveFilterElement.php new file mode 100644 index 00000000..9ba9eec5 --- /dev/null +++ b/src/Filter/Element/ArchiveFilterElement.php @@ -0,0 +1,660 @@ +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'); + } + + protected function transformFilterModel(ConfigBuilder $config, FilterModel $model): void + { + $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)); + } + + /** + * @throws FilterException + */ + public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void + { + $config = $context->config; + + if ($config['intrinsic']) { + return; + } + + $inferrer = $this->getPtableInferrer($context->list); + + $formOptions = [ + 'label' => false, + 'required' => $config['is_mandatory'], + 'multiple' => $config['is_multiple'], + 'expanded' => $config['is_expanded'], + ]; + + $data = $this->buildPreselectData($context->list, $config['preselect']); + if (!\is_null($data) && \count($data)) { + $formOptions['data'] = $data; + } + + $choices = $this->createChoicesBuilder()->applyFormOptions($formOptions); + $builder->setAttribute('flare.choices_builder', $choices); + + $builder->single(ChoiceType::class, $formOptions); + + 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.', + method: __METHOD__, + ); + } + + 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.', method: __METHOD__); + } + + /** + * ## We are dealing with a _dynamic ptable_ henceforth. + */ + + if (!$groups = $config['group_whitelist_parents']) + { + throw new FilterException('No whitelisted parents defined.', method: __METHOD__); + } + + foreach ($groups as $group) + { + $table = $group['table']; + + $parents = $this->fetchParents($table, $group['ids'])?->getModels() ?? []; + + foreach ($parents 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.', method: __METHOD__); + } + + $choices->setModelSuffix('(%@name%)'); + } + + /** + * @throws FilterException + */ + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void + { + $config = $context->config; + + /** @var Model[] $selectedModels */ + $selectedModels = $config['intrinsic'] + ? $this->getWhitelistedParents($context->list, $config) + : $this->processRuntimeValue($value->getSingleValue(), $context->list, $config); + + $inferrer = $this->getPtableInferrer($context->list); + + if (!$selectedModels) + { + if ($config['use_whitelist_for_options_only']) { + return; + } + + $builder->abort(); + } + + if ($inferrer->getDcaMainPtable()) + { + if (!$pids = \array_column($selectedModels, 'id')) { + throw new FilterException('No valid parent archive ids extracted.', method: __METHOD__); + } + + $builder->addPredicate(ArchivePredicate::class, [ + 'field' => 'pid', + 'parent_ids' => $pids, + ]); + + return; + } + + if (!$inferrer->isDcaDynamicPtable()) + // no valid ptable available + { + throw new FilterException('No valid ptable found.', method: __METHOD__); + } + + /** + * ## = We are dealing with a _dynamic ptable_. ⇒ + */ + + $grouped = []; + $selectedModels = \array_filter((array) $selectedModels); + + foreach ($selectedModels as $item) + { + if ($item instanceof Model) { + $grouped[$item::getTable()][] = $item->id; + } + } + + $builder->add(BelongsToRelationPredicate::class, [ + 'field_pid' => 'pid', + 'field_dynamic_ptable' => 'ptable', + 'parent_groups' => $this->getDynamicParentGroups($config), + 'submitted_data' => $grouped, + ]); + } + + /** + * @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(ListSpec $list, array $config): array + { + $inferrer = $this->getPtableInferrer($list); + + if ($inferrer->getDcaMainPtable()) + { + return $config['whitelist_parents']; + } + + if (!$inferrer->isDcaDynamicPtable()) + // no valid ptable available + { + return []; + } + + $tableToParentIds = []; + + foreach ($config['group_whitelist_parents'] as $group) + { + $tableToParentIds[$group['table']] ??= []; + \array_push($tableToParentIds[$group['table']], ...$group['ids']); + } + + return $tableToParentIds; + } + + /** + * @return Model[] + */ + protected function getWhitelistedParents(ListSpec $list, array $config): array + { + $inferrer = $this->getPtableInferrer($list); + + if ($ptable = $inferrer->getDcaMainPtable()) + { + $parents = $this->fetchParents($ptable, $config['whitelist_parents']); + return $parents?->getModels() ?? []; + } + + if (!$inferrer->isDcaDynamicPtable()) + // no valid ptable available + { + return []; + } + + $allParents = []; + + 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, ListSpec $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) && !$config['use_whitelist_for_options_only']; + + if ($useFullWhitelist) { + return $this->getWhitelistedParents($list, $config); + } + + if (!$values || $values === true) { + return []; + } + + if (!$allowedParentIds = $this->getWhitelistedParentIds($list, $config)) { + return []; + } + + if (\array_is_list($allowedParentIds)) + { + $allowedLookup = \array_flip($allowedParentIds); + + return \array_values(\array_filter( + $values, + static fn (Model $model): bool => isset($allowedLookup[$model->id]), + )); + } + + \array_walk($allowedParentIds, static fn (array &$ids): array => $ids = \array_flip($ids)); + /** + * @var array> $allowedParentIds 2D array mapping table names to parent IDs as keys. + * I.e., flips the nested arrays to be lookup tables for efficient filtering. + * @example $allowedParentIds = array{ + * 'tl_news_archive': [ + * 5: 0, // where 5 is the ID of the news archive + * 8: 1, // ID 8 + * 12: 2, // ID 12 + * ] + * } + */ + return \array_values(\array_filter( + $values, + static fn (Model $model): bool => isset($allowedParentIds[$model::getTable()][$model->id]), + )); + } + + /** + * @return Model[]|true|null Returns true if the empty option is selected, null if no value is selected. + */ + protected function normalizeFilterValue(mixed $value): array|true|null + { + if (!$value) { + return null; + } + + if (!\is_iterable($value)) { + $value = [$value]; + } + + $arr = []; + + foreach ($value as $v) { + if ($v === ChoicesBuilder::EMPTY_CHOICE) { + return true; + } + + if ($v instanceof Model) { + $arr[] = $v; + } + } + + return $arr; + } + + private function getPtableInferrer(ListSpec $list): PtableInferrer + { + $cacheKey = $list->hash(); + + if (isset($this->_inferrer[$cacheKey])) { + return $this->_inferrer[$cacheKey]; + } + + $inferrable = PtableInferrableFactory::createFromConfig($list->config); + return $this->_inferrer[$cacheKey] = new PtableInferrer($inferrable, $list->dc); + } + + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void + { + if (!$filterModel = $context->filterModel) { + return; + } + + $inferrer = new PtableInferrer($filterModel, $context->listModel->dc); + + $palettes = []; + + if ($inferrer->getDcaMainPtable()) + { + $palettes[] = '{archive_legend},whitelistParents,formatLabel,useWhitelistForOptionsOnly'; + } + /** @mago-expect lint:no-else-clause This else clause is fine. */ + elseif ($inferrer->isDcaDynamicPtable()) + { + $palettes[] = '{archive_legend},groupWhitelistParents,useWhitelistForOptionsOnly'; + } + + if (!$filterModel->intrinsic) + { + $palette = '{form_legend},isMandatory,isMultiple,isExpanded,hasEmptyOption,'; + + if ($filterModel->hasEmptyOption) { + $palette .= 'formatEmptyOption,'; + } + + $palette .= 'preselect,'; + + $palettes[] = $palette; + } + + $dca->palette($palettes ? Str::mergePalettes(...$palettes) : null); + + $dca->field('preselect') + ->inputType('select') + ->eval([ + 'multiple' => (bool) $filterModel->isMultiple, + 'chosen' => true, + 'includeBlankOption' => true, + ]) + ->options(fn (): array => $this->getPreselectOptions($inferrer, $filterModel->row())); + } + + /** + * Builds the backend options for the preselect field from the whitelisted parents. + * + * @param array $row + */ + private function getPreselectOptions(PtableInferrer $inferrer, array $row): array + { + $choices = $this->createChoicesBuilder()->setModelSuffix('[%id%]'); + + if ($ptable = $inferrer->getDcaMainPtable()) + { + if (!$parents = $this->fetchParents($ptable, $this->normalizeIds($row['whitelistParents'] ?? null))) { + return $choices->buildContaoOptions(); + } + + foreach ($parents as $parent) + { + $choices->add(\sprintf('%s.%s', $ptable, $parent->id), $parent); + } + + return $choices->buildContaoOptions(); + } + + if ($inferrer->isDcaDynamicPtable()) + { + $choices->setModelSuffix('[%@table%.id=%id%]'); + + foreach ($this->normalizeGroups($row['groupWhitelistParents'] ?? null) as $group) + { + if (!$parents = $this->fetchParents($group['table'], $group['ids'])) { + continue; + } + + foreach ($parents as $parent) + { + $choices->add(\sprintf('%s.%s', $group['table'], $parent->id), $parent); + } + } + } + + return $choices->buildContaoOptions(); + } + + /** + * 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 + */ + private function buildPreselectData(ListSpec $list, array $preselect): ?array + { + if (!$preselect) { + return null; + } + + $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 = (string) $ptableInferrer()->getDcaMainPtable(); + $ptable = static fn (): string => $pt; + return $pt; + }; + + $data = []; + $fetch = []; + + foreach ($preselect as $entity) + { + if ($entity instanceof Model) { + $data[] = $entity; + continue; + } + + if (\is_numeric($entity)) + { + if (!$ptable() || !$modelClass = Model::getClassFromTable($ptable())) { + continue; + } + + if (!\class_exists($modelClass)) { + continue; + } + + if (!$model = $modelClass::findByPk($entity)) { + continue; + } + + $data[] = $model; + continue; + } + + if (!\is_string($entity) || !\str_contains($entity, '.')) { + continue; + } + + [$table, $id] = \explode('.', $entity, 2); + + $fetch[$table] ??= []; + $fetch[$table][] = (int) $id; + } + + foreach ($fetch as $table => $ids) + { + if (!$ids = \array_unique($ids)) { + continue; + } + + if (!$modelClass = Model::getClassFromTable($table)) { + continue; + } + + if (!\class_exists($modelClass)) { + continue; + } + + if (!$models = $modelClass::findMultipleByIds($ids)?->getModels()) { + continue; + } + + \array_push($data, ...$models); + } + + 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/Filter/Element/BelongsToRelationFilterElement.php b/src/Filter/Element/BelongsToRelationFilterElement.php new file mode 100644 index 00000000..61e82ca0 --- /dev/null +++ b/src/Filter/Element/BelongsToRelationFilterElement.php @@ -0,0 +1,234 @@ +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'); + } + + protected function transformFilterModel(ConfigBuilder $config, FilterModel $model): void + { + $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 : []); + } + + /** + * @throws FilterException + */ + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void + { + $config = $context->config; + + if (!$fieldPid = $config['field_pid']) + { + throw new FilterException('No parent field defined.'); + } + + $inferrable = PtableInferrableFactory::createFromConfig($context->list->config); + $inferrer = new PtableInferrer($inferrable, $context->list->dc); + + try + { + $ptable = $inferrer->getInferredPtable(); + $fieldDynamicPtable = $inferrer->tryGetDynamicPtableField(); + } + catch (InferenceException) + { + $builder->abort(); + } + + if (\is_string($fieldDynamicPtable)) + { + $builder->add(BelongsToRelationPredicate::class, [ + 'field_pid' => $fieldPid, + 'field_dynamic_ptable' => $fieldDynamicPtable, + 'parent_groups' => $this->getDynamicParentGroups($config['group_whitelist_parents']), + ]); + + return; + } + + if (!$ptable || !$whitelistParents = $config['whitelist_parents']) { + throw new FilterException('No whitelisted parents.'); + } + + $builder->add(BelongsToRelationPredicate::class, [ + 'field_pid' => $fieldPid, + 'whitelist' => $whitelistParents, + ]); + } + + /** + * Expected format: + * ```php + * $submittedData = [ + * 'tl_article' => [1, 5, 35, ...], + * 'tl_news' => [2, 3, 4, ...], + * ]; + * ``` + * + * @param array $groupWhitelistParents Deserialized group whitelist, as stored in the + * `group_whitelist_parents` config key. + */ + public function addDynamicPtableFilter( + FormulaBuilderInterface $builder, + array $groupWhitelistParents, + string $fieldDynamicPtable, + string $fieldPid, + ?array $submittedData = null, + ): void { + $builder->add(BelongsToRelationPredicate::class, [ + 'field_pid' => $fieldPid, + 'field_dynamic_ptable' => $fieldDynamicPtable, + 'parent_groups' => $this->getDynamicParentGroups($groupWhitelistParents), + 'submitted_data' => $submittedData, + ]); + } + + /** + * @param array $parentGroups Deserialized group whitelist, as stored in the + * `group_whitelist_parents` config key. + */ + public function getDynamicParentGroups(array $parentGroups): array + { + $groups = []; + + foreach ($parentGroups as $group) + { + if (!($g_tablePtable = $group['tablePtable'] ?? null) + || !($g_whitelistParents = $group['whitelistParents'] ?? null) + || !\is_array($g_whitelistParents = StringUtil::deserialize($g_whitelistParents))) + { + continue; + } + + $g_whitelistParents = \array_values(\array_filter($g_whitelistParents)); + + if (!$g_whitelistParents) { + continue; + } + + $groups[] = [ + 'table' => $g_tablePtable, + 'ids' => $g_whitelistParents, + ]; + } + + return $groups; + } + + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void + { + $listModel = $context->listModel; + $filterModel = $context->filterModel; + + if (!$filterModel) + { + Message::addError($this->trans->trans('errors.missing_model', [], 'flare')); + $dca->palette(''); + return; + } + + if (!$listModel->dc) + { + Message::addError($this->trans->trans('errors.missing_datacontainer', ['%id%' => $listModel->id], 'flare')); + $dca->palette(''); + return; + } + + $palette = '{filter_legend},fieldPid,whichPtable'; + + $inferrer = new PtableInferrer($filterModel, $listModel->dc); + $table = $inferrer->getEntityTable(); + $fieldPid = $inferrer->getPidField(); + + try + { + $ptable = $inferrer->getInferredPtable(); + + Message::addInfo(match (true) { + $inferrer->isAutoInferable() && $ptable => $this->trans->trans('infer_ptable.auto', [ + '%table%' => $table, + '%field%' => $fieldPid, + '%ptable%' => $ptable, + ], 'flare'), + $inferrer->isAutoDynamicPtable() => $this->trans->trans('infer_ptable.dynamic', [ + '%table%' => $table, + ], 'flare'), + default => $this->trans->trans('infer_ptable.invalid', [ + '%table%' => $table, + '%field%' => $fieldPid, + ], 'flare') + }); + } + catch (InferenceException $e) + { + Message::addError($e->getMessage()); + } + + if (!$inferrer->isAutoInferable()) + { + $filterModel->whichPtable_disableAutoOption(); + } + + if ($filterModel->whichPtable === 'dynamic') + { + $palette .= ';{archive_legend},groupWhitelistParents'; + } + /** @mago-expect lint:no-else-clause This else clause is fine. */ + elseif ($ptable ?? null) + { + $palette .= ',whitelistParents'; + } + + $dca->palette($palette); + } +} diff --git a/src/Filter/Element/BooleanFilterElement.php b/src/Filter/Element/BooleanFilterElement.php new file mode 100644 index 00000000..6a228715 --- /dev/null +++ b/src/Filter/Element/BooleanFilterElement.php @@ -0,0 +1,170 @@ +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'); + } + + protected function transformFilterModel(ConfigBuilder $config, FilterModel $model): void + { + $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(FilterFormBuilderInterface $builder, FilterContext $context): void + { + if ($context->config['intrinsic']) { + return; + } + + $builder->single(CheckboxType::class, [ + 'label' => $context->config['label'] ?? 'CBX', + 'required' => false, + ]); + } + + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void + { + $config = $context->config; + + if (!$targetField = $config['field']) { + $builder->abort(); + } + + $value = $config['intrinsic'] + ? $config['preselect'] + : $this->resolveRuntimeValue($value->getSingleValue(), $config); + + if ($value === null) { + return; + } + + $builder->add(BooleanPredicate::class, [ + 'field' => $targetField, + 'value' => $value, + ]); + } + + private function resolveRuntimeValue(mixed $value, array $config): ?bool + { + $choices = $config['mode'] === BoolMode::BINARY ? $config['binary_choices'] : null; + + return $this->normalizeValue($value, $choices) ?? $config['preselect']; + } + + public function normalizeValue(mixed $value, ?BoolBinaryChoices $choices = null): ?bool + { + if (\is_string($value)) { + $value = \strtolower(\trim($value)); + } + + if ($value === null || $value === '' || $value === 'null') + { + return null; + } + + if ($choices === BoolBinaryChoices::NULL_TRUE && !$value) + { + return null; + } + + return \filter_var($value, \FILTER_VALIDATE_BOOLEAN, \FILTER_NULL_ON_FAILURE); + } + + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void + { + $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 ($intrinsic) { + unset($preselectOptions['null']); + } + + $dca->field('preselect') + ->inputType('select') + ->eval(['includeBlankOption' => false, 'chosen' => false]) + ->options($preselectOptions); + + $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.'); + } + } + + protected function getFieldGenericOptions(string $targetTable): array + { + Controller::loadDataContainer($targetTable); + + if (!isset($GLOBALS['TL_DCA'][$targetTable]['fields'])) { + return []; + } + + $cbx = 'Checkbox'; + $non = 'Non-Checkbox'; + + $options = [ + $cbx => [], // checkbox fields + $non => [], // non-checkbox fields + ]; + + foreach ($GLOBALS['TL_DCA'][$targetTable]['fields'] as $name => $field) + { + $group = ('checkbox' === ($field['inputType'] ?? null)) ? $cbx : $non; + $options[$group][$name] = $targetTable . '.' . $name; + } + + \asort($options[$cbx]); + \asort($options[$non]); + + return $options; + } +} diff --git a/src/Filter/Element/CalendarCurrentFilterElement.php b/src/Filter/Element/CalendarCurrentFilterElement.php new file mode 100644 index 00000000..281f0500 --- /dev/null +++ b/src/Filter/Element/CalendarCurrentFilterElement.php @@ -0,0 +1,237 @@ +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'); + } + + protected function transformFilterModel(ConfigBuilder $config, FilterModel $model): void + { + $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(FilterFormBuilderInterface $builder, FilterContext $context): void + { + $config = $context->config; + + if ($config['intrinsic']) { + return; + } + + [$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 buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void + { + $config = $builder->filter->config; + + if (!$config['is_limited'] && $builder->engineContext instanceof ValidationContext) { + return; + } + + $value = $this->processRuntimeValue($value) ?? []; + $from = $value['from'] ?? null; + $to = $value['to'] ?? null; + + $start = \strtotime((string) $config['start_at']) ?: 0; + $stop = \strtotime((string) $config['stop_at']) ?: DateTimeHelper::maxTimestamp(); + + if ($from instanceof \DateTimeInterface) + { + $from = $from->getTimestamp(); + + if (!$config['is_limited'] || $from >= $start) { + $start = $from; + } + } + + if ($to instanceof \DateTimeInterface) + { + $to = $to->getTimestamp(); + + if (!$config['is_limited'] || $to <= $stop) { + $stop = $to; + } + } + + $builder->addPredicate(CalendarCurrentPredicate::class, [ + 'start' => $start, + 'stop' => $stop, + 'has_extended_events' => $config['has_extended_events'], + ]); + } + + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void + { + $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]; + } + + /** + * @return array{from: ?\DateTimeInterface, to: ?\DateTimeInterface}|null + */ + private function processRuntimeValue(?ValueInterface $data): ?array + { + if (!$data instanceof DateRangeValue) { + return null; + } + + return [ + 'from' => $this->mixedToDateTime($data->from), + 'to' => $this->mixedToDateTime($data->to), + ]; + } + + private function mixedToDateTime(mixed $input): ?\DateTimeInterface + { + if (!$input) { + return null; + } + + if ($input instanceof \DateTimeInterface) { + return $input; + } + + if (\is_numeric($input)) { + return \DateTimeImmutable::createFromFormat('U', (string) $input) ?: null; + } + + if (\is_string($input)) { + return new \DateTimeImmutable($input); + } + + return null; + } + + /** + * 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/Filter/Element/DateRangeFilterElement.php b/src/Filter/Element/DateRangeFilterElement.php new file mode 100644 index 00000000..5a065c89 --- /dev/null +++ b/src/Filter/Element/DateRangeFilterElement.php @@ -0,0 +1,115 @@ +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 + { + $config + ->set('intrinsic', (bool) $model->intrinsic) + ->set('field', $model->fieldGeneric ?: null); + } + + public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void + { + if ($context->config['intrinsic']) { + return; + } + + if ($context->config['from_enabled']) { + $builder->add('from', DateType::class, [ + 'widget' => 'single_text', + 'label' => 'label.date_range.from', + '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(...)); + } + + /** + * @throws FilterException + */ + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void + { + if (!$field = $context->config['field']) { + throw new FilterException('Set fieldGeneric in filter model.'); + } + + $builder->add(DateRangePredicate::class, [ + 'field' => $field, + 'from' => $value->get('from'), + 'to' => $value->get('to'), + ]); + } + + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void + { + $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/Filter/Element/DcaSelectFieldFilterElement.php b/src/Filter/Element/DcaSelectFieldFilterElement.php new file mode 100644 index 00000000..8bd5a5e3 --- /dev/null +++ b/src/Filter/Element/DcaSelectFieldFilterElement.php @@ -0,0 +1,376 @@ +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); + } + + protected function transformFilterModel(ConfigBuilder $config, FilterModel $model): void + { + $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); + } + + public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void + { + $config = $context->config; + + if ($config['intrinsic']) { + return; + } + + $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'] ?: $defaultPlaceholder, + ]; + + $options = $this->getOptions($context->list->dc, $config['field']); + + if (!\is_null($options)) + { + $choicesBuilder = $this->createChoicesBuilder(); + + foreach ($options as $value => $label) { + $choicesBuilder->add((string) $value, (string) $label); + } + + $choicesBuilder->applyFormOptions($formOptions); + + $builder->setAttribute('flare.choices_builder', $choicesBuilder); + } + + if (null !== $data = $this->buildPreselectData($config['preselect'], $options ?? [])) { + $formOptions['data'] = $data; + } + + $builder->single(ChoiceType::class, $formOptions); + } + + 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($value->getSingleValue(), $options); + + if (!$selected) { + return; + } + + if (!$selected = \array_values((array) $selected)) { + return; + } + + if (!$options) { + $builder->abort(); + } + + if (!$targetField = $config['field']) { + $builder->abort(); + } + + $dcaOptionsField = $this->getOptionsField($context->list->dc, $config['field']) ?? []; + $isMultiple = $dcaOptionsField['eval']['multiple'] ?? false; + + $builder->add(DcaSelectPredicate::class, [ + 'field' => $targetField, + 'selected' => $selected, + 'valid_options' => $options, + 'is_multiple_dca_field' => (bool) $isMultiple, + ]); + } + + /** + * 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 (!$preselect) { + return null; + } + + if (!\is_array($preselect)) + { + if (!\is_scalar($preselect)) { + return $preselect; + } + + if (!$option = $options[$preselect] ?? null) { + return null; + } + + return (string) $option; + } + + $data = []; + + foreach ($preselect as $value) + { + if (!\is_scalar($value)) { + $data[] = $value; + continue; + } + + if ($option = $options[$value] ?? null) { + $data[] = (string) $option; + } + } + + return $data; + } + + /** + * 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 + { + if (\is_null($value)) { + return null; + } + + $choices = []; + foreach ($options as $key => $label) { + $choices[(string) $key] = (string) $label; + } + + $toKey = static function (mixed $choice) use ($choices): string { + $key = \array_search($choice, $choices, true); + return ($key === false) ? '' : (string) $key; + }; + + if (\is_array($value)) { + return \array_map($toKey, $value); + } + + return $toKey($value); + } + + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void + { + $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) ?? []); + } + /** @mago-expect lint:no-else-clause This else clause is fine. */ + else + { + $preselect->options([]); + } + } + + public function getFieldGenericOptions(string $table): array + { + Controller::loadDataContainer($table); + + if (!isset($GLOBALS['TL_DCA'][$table]['fields'])) { + return []; + } + + // find all fields with a type of select + $options = []; + foreach ($GLOBALS['TL_DCA'][$table]['fields'] as $name => $field) + { + if ('select' === ($field['inputType'] ?? null)) { + $options[$name] = $table . '.' . $name; + } + } + + return $options; + } + + public function getOptions(string $table, ?string $field): ?array + { + $optionsField = $this->getOptionsField($table, $field) ?? []; + $options = $this->tryGetOptionsFromField($table, $optionsField); + + if (!\is_array($options)) + { + return null; + } + + if (\array_is_list($options)) + { + $options = \array_combine($options, $options); + } + + if ($reference = $optionsField['reference'] ?? []) + { + foreach ($options as $k => $v) + { + $options[$k] = $reference[$v] ?? $reference[$k] ?? $v; + } + } + + return $options; + } + + public function getOptionsField(string $table, ?string $field): ?array + { + if (!$table || !$field) { + return null; + } + + Controller::loadLanguageFile($table); + Controller::loadDataContainer($table); + + return $GLOBALS['TL_DCA'][$table]['fields'][$field] ?? null; + } + + protected function tryGetOptionsFromField(string $table, array $optionsField): ?array + { + if (\is_array($options = $optionsField['options'] ?? null)) + { + return $options; + } + + if ($optionsCallback = $optionsField['options_callback'] ?? null) + { + $dataContainer = $this->mockDataContainerObject($table); + + if (\is_string($optionsCallback) && \str_contains($optionsCallback, '::')) + { + [$class, $method] = \explode('::', $optionsCallback, 2); + $optionsCallback = [$class, $method]; + } + + if (\is_array($optionsCallback) && \count($optionsCallback) === 2) + { + $class = $optionsCallback[0] ?? null; + $method = $optionsCallback[1] ?? null; + + if (!\class_exists($class) || !\method_exists($class, $method)) { + return null; + } + + if (!$service = System::importStatic($class)) { + return null; + } + + $options = $service->{$method}($dataContainer); + } + + if (!\is_array($optionsCallback) && \is_callable($optionsCallback)) + { + $options = $optionsCallback($dataContainer); + } + } + + if (!\is_array($options)) { + return null; + } + + return $options; + } + + protected function mockDataContainerObject(string $table): DataContainer + { + return new class($table) extends DataContainer { + /** + * @noinspection MagicMethodsValidityInspection + * @noinspection PhpMissingParentConstructorInspection + */ + public function __construct(string $table) + { + if ($table) + { + $this->strTable = $table; + } + } + + public function getPalette(): string + { + return ''; + } + + protected function save($varValue): void + { + // do nothing + } + }; + } +} diff --git a/src/Filter/Element/FieldValueChoiceFilterElement.php b/src/Filter/Element/FieldValueChoiceFilterElement.php new file mode 100644 index 00000000..5bd1d4b2 --- /dev/null +++ b/src/Filter/Element/FieldValueChoiceFilterElement.php @@ -0,0 +1,330 @@ +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'); + } + + protected function transformFilterModel(ConfigBuilder $config, FilterModel $model): void + { + $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(FilterFormBuilderInterface $builder, FilterContext $context): void + { + $config = $context->config; + + if ($config['intrinsic']) { + return; + } + + $choicesBuilder = $this->createChoices($context->list->dc, (string) ($config['field'] ?? '')) + ->setEmptyOption(!$config['multiple']); + + $formOptions = [ + 'label' => false, + 'multiple' => $config['multiple'], + 'expanded' => $config['expanded'], + 'required' => false, + 'data' => $this->buildPreselectData($choicesBuilder, $config), + ]; + + $choicesBuilder->applyFormOptions($formOptions); + + $builder->single(ChoiceType::class, $formOptions); + + $builder->setAttribute('flare.choices_builder', $choicesBuilder); + } + + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void + { + if ($context->engineContext instanceof ValidationContext) { + return; + } + + $config = $context->config; + + if (!$field = $config['field']) { + return; + } + + $value = $config['intrinsic'] + ? $config['preselect'] + : $this->normalizeRuntimeValue($value->getSingleValue(), $context); + + if (!$value) { + return; + } + + $builder->add(FieldValueChoicePredicate::class, [ + 'field' => $field, + 'values' => $value, + ]); + } + + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void + { + $dca->palette('{filter_legend},fieldGeneric,isMultiple,isExpanded,preselect'); + + $dca->field('isMultiple')->eval(['submitOnChange' => true, 'tl_class' => 'cbx m12 w25']); + $dca->field('isExpanded')->eval(['submitOnChange' => false, 'tl_class' => 'cbx m12 w25']); + + $table = $context->listModel->dc; + $valueField = $context->filterModel?->fieldGeneric; + + if (!$table || !$valueField) { + return; + } + + $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%]') + ->buildContaoOptions(); + }); + } + + /** + * 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 = $this->choicesBuilderFactory->createChoicesBuilder(); + + if (!\is_null($foreignValues = $this->getForeignValues($table, $field))) + { + // TODO: display of frontend form values should be configurable + foreach ($foreignValues as $id => $label) { + $choices->add((string) $id, (string) $label, $id); + } + } + /** @mago-expect lint:no-else-clause This else clause is fine. */ + else + { + foreach ($this->getLocalValues($table, $field) as $value) { + $choices->add((string) $value, (string) $value, $value); + } + } + + return $choices; + } + + /** + * 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; + } + + $choices = $choicesBuilder->buildChoices(); + + $data = []; + foreach ($preselect as $alias) { + if ($choice = $choices[$alias] ?? null) { + $data[] = $choice; + } + } + + if (!$config['multiple']) { + return \reset($data) ?: null; + } + + return $data; + } + + /** + * 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(); + + $values = []; + + foreach ((array) $value as $choice) + { + 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; + } + } + + 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))) + { + return StringUtil::deserialize($preselect, true); + } + + return [$preselect]; + } + + /** + * @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 + { + if (isset($this->foreignValueCache[$table][$field])) { + return $this->foreignValueCache[$table][$field]; + } + + Controller::loadDataContainer($table); + + $dca = $GLOBALS['TL_DCA'][$table]['fields'][$field] ?? []; + + if (!$foreignKey = $dca['foreignKey'] ?? null) { + return null; + } + + [$foreignTable, $foreignDisplayColumn] = \explode('.', $foreignKey, 2); + + if (!$foreignTable || !$foreignDisplayColumn) { + return null; + } + + $foreignTable = $this->connection->quoteIdentifier($foreignTable); + $foreignDisplayColumn = $this->connection->quoteIdentifier($foreignDisplayColumn); + $foreignField = $this->connection->quoteIdentifier($dca['relation']['field'] ?? 'id'); + + // The string-concatenation happens directly in SQL, producing a key-value pair for each option in the format: + // `{id} => "{value} [{id}]"` (where `{value}` is the display column value, e.g., `tl_user.name`) + $sql = << 0 + ORDER BY `label` + SQL; + + return $this->foreignValueCache[$table][$field] = $this->connection->fetchAllKeyValue($sql); + } + + private function getLocalValues(string $table, string $field): array + { + if (isset($this->localValueCache[$table][$field])) { + return $this->localValueCache[$table][$field]; + } + + if (!$field || !$table) { + return []; + } + + $qTable = $this->connection->quoteIdentifier($table); + $qField = $this->connection->quoteIdentifier($field); + + $sql = << 0 + ORDER BY `value`; + SQL; + + $values = \array_values(\array_filter( + $this->connection->fetchFirstColumn($sql), + static fn (mixed $v): bool => (!\is_string($v) || \trim($v) !== '') + )); + + return $this->localValueCache[$table][$field] = $values; + } +} diff --git a/src/Filter/Element/FilterElementInterface.php b/src/Filter/Element/FilterElementInterface.php new file mode 100644 index 00000000..e7e491b6 --- /dev/null +++ b/src/Filter/Element/FilterElementInterface.php @@ -0,0 +1,37 @@ +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'); + } + + protected function transformFilterModel(ConfigBuilder $config, FilterModel $model): void + { + $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 ? $fieldPublished : null) + ->set('start_field', $useStart ? $fieldStart : null) + ->set('stop_field', $useStop ? $fieldStop : null) + ->set('invert', (bool) $model->invertPublished); + } + + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void + { + $config = $context->config; + + $builder->add(PublishedPredicate::class, [ + 'published_field' => $config['published_field'], + 'start_field' => $config['start_field'], + 'stop_field' => $config['stop_field'], + 'invert_published' => $config['invert'], + 'now' => \time(), + ]); + } + + 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 new file mode 100644 index 00000000..8dc205af --- /dev/null +++ b/src/Filter/Element/SearchKeywordsFilterElement.php @@ -0,0 +1,97 @@ +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'); + } + + protected function transformFilterModel(ConfigBuilder $config, FilterModel $model): void + { + $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(FilterFormBuilderInterface $builder, FilterContext $context): void + { + $config = $context->config; + + if ($config['intrinsic']) { + return; + } + + $options = [ + 'label' => $config['label'] ?? 'label.text', + 'required' => false, + ]; + + if ($config['placeholder']) { + $options['attr']['placeholder'] = $config['placeholder']; + } + + $builder->single(TextType::class, $options); + } + + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void + { + $config = $context->config; + + $value = $config['intrinsic'] + ? $config['prefill'] + : $value->getSingleValue(); + + if (!$value || !\is_string($value)) { + return; + } + + if (!$columns = $config['columns']) { + return; + } + + $builder->add(SearchKeywordsPredicate::class, [ + 'value' => $value, + 'columns' => $columns, + ]); + } + + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void + { + $palette = '{filter_legend},columnsGeneric'; + + $dca->palette($context->filterModel?->intrinsic + ? $palette . ',prefill' + : $palette . ';{form_legend},label,placeholder'); + } +} diff --git a/src/Filter/Element/SimpleEquationFilterElement.php b/src/Filter/Element/SimpleEquationFilterElement.php new file mode 100644 index 00000000..44f7011a --- /dev/null +++ b/src/Filter/Element/SimpleEquationFilterElement.php @@ -0,0 +1,81 @@ +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); + } + + protected function transformFilterModel(ConfigBuilder $config, FilterModel $model): void + { + $config + ->set('intrinsic', (bool) $model->intrinsic) + ->set('left', $model->equationLeft ?: null) + ->set('operator', $model->equationOperator ? SqlEquationOperator::match($model->equationOperator) : null) + ->set('right', $model->equationRight); + } + + /** + * @throws FilterException + */ + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void + { + $config = $context->config; + + if (!($operand = $config['left']) || !($op = $config['operator'])) { + throw new FilterException('Invalid filter configuration.'); + } + + $builder->add(SimpleEquationPredicate::class, [ + 'operand_left' => $operand, + 'operator' => $op, + 'operand_right' => $config['right'], + ]); + } + + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void + { + $operatorValue = $context->filterModel?->equationOperator; + $operator = $operatorValue ? SqlEquationOperator::match($operatorValue) : null; + + $dca->palette($operator?->isUnary() + ? '{flare_simple_equation_legend},equationLeft,equationOperator' + : '{flare_simple_equation_legend},equationLeft,equationOperator,equationRight'); + + $dca->field('equationLeft') + ->options(static fn (): array => DcaHelper::getFieldOptions($context->getTargetTable())); + } + +} diff --git a/src/Filter/Factory/FilterContextBuilderFactory.php b/src/Filter/Factory/FilterContextBuilderFactory.php new file mode 100644 index 00000000..b033e5f4 --- /dev/null +++ b/src/Filter/Factory/FilterContextBuilderFactory.php @@ -0,0 +1,26 @@ +formulaBuilderFactory, + list: $list, + filter: $filter, + engineContext: $engineContext, + ); + } +} diff --git a/src/Filter/Factory/FilterContextFactory.php b/src/Filter/Factory/FilterContextFactory.php new file mode 100644 index 00000000..9851c027 --- /dev/null +++ b/src/Filter/Factory/FilterContextFactory.php @@ -0,0 +1,50 @@ +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 new file mode 100644 index 00000000..3aa11f6d --- /dev/null +++ b/src/Filter/Factory/FilterFactory.php @@ -0,0 +1,120 @@ + $config + * + * @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, + ?string $alias = null, + array $config = [], + ?string $targetAlias = null, + bool $targetingForced = false, + ?string $source = null, + ): Filter { + $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, + targetAlias: $targetAlias, + targetingForced: $targetingForced, + 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) ?? []; + $config = $this->filterOptionsResolver->resolve($element, $config); + + return new Filter( + element: $element, + type: $type, + alias: $filterModel->getFilterFormName(), + config: $config, + 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/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 new file mode 100644 index 00000000..c5997148 --- /dev/null +++ b/src/Filter/Filter.php @@ -0,0 +1,106 @@ + $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 $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, + public string $alias, + public array $config = [], + public ?FilterData $data = null, + public ?string $targetAlias = null, + public bool $targetingForced = false, + public ?string $source = null, + ) {} + + public function withData(?FilterData $data): self + { + return new self( + element: $this->element, + type: $this->type, + alias: $this->alias, + config: $this->config, + data: $data, + targetAlias: $this->targetAlias, + targetingForced: $this->targetingForced, + source: $this->source, + ); + } + + public function withAlias(string $alias): self + { + return new self( + element: $this->element, + type: $this->type, + alias: $alias, + config: $this->config, + data: $this->data, + targetAlias: $this->targetAlias, + targetingForced: $this->targetingForced, + source: $this->source, + ); + } + + 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, + targetAlias: $targetAlias, + targetingForced: !\is_null($targetAlias) && $forced, + source: $this->source, + ); + } + + /** + * Stable representation for hashing/caching. + */ + public function fingerprint(): array + { + return [ + 'element' => \get_class($this->element), + 'type' => $this->type, + 'config' => $this->config, + '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 new file mode 100644 index 00000000..7db3d093 --- /dev/null +++ b/src/Filter/FilterContext.php @@ -0,0 +1,28 @@ +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, + formula: $formula, + engineContext: $this->engineContext, + ); + } + + public function abort(): never + { + throw new AbortFilteringException(); + } +} diff --git a/src/Filter/FilterData.php b/src/Filter/FilterData.php new file mode 100644 index 00000000..7303c51c --- /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/FilterFormBuilder.php b/src/Filter/FilterFormBuilder.php new file mode 100644 index 00000000..3781a4ca --- /dev/null +++ b/src/Filter/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.' + . ' FilterSetFactory transfers its fields onto a real builder.', + self::class, + )); + } +} diff --git a/src/Filter/FilterFormBuilderInterface.php b/src/Filter/FilterFormBuilderInterface.php new file mode 100644 index 00000000..ec108625 --- /dev/null +++ b/src/Filter/FilterFormBuilderInterface.php @@ -0,0 +1,36 @@ + $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/Filter/FilterInvocation.php b/src/Filter/FilterInvocation.php deleted file mode 100644 index c0c8ea64..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/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 @@ - + */ + 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 new file mode 100644 index 00000000..24a2e69f --- /dev/null +++ b/src/Filter/Form/FilterFormInterface.php @@ -0,0 +1,63 @@ + + */ + 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** 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; + + /** + * Produces the element's canonical value from the form mount, or null to contribute nothing. + * + * - 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 + * `$form->isSubmitted()` / `$context->engineContext`. + * + * @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/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/Predicate/AbstractPredicate.php b/src/Filter/Predicate/AbstractPredicate.php new file mode 100644 index 00000000..106b63b9 --- /dev/null +++ b/src/Filter/Predicate/AbstractPredicate.php @@ -0,0 +1,15 @@ +define('field')->default('pid')->allowedTypes('string'); + $resolver->define('parent_ids')->required()->allowedTypes('array'); + } + + public function buildConditions(FilterConditionsBuilder $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.', method: __METHOD__); + } + + $builder->where($builder->expr()->in($builder->column($options['field']), ':pids')) + ->setParameter('pids', $ids, ArrayParameterType::INTEGER); + } +} diff --git a/src/Filter/Predicate/BelongsToRelationPredicate.php b/src/Filter/Predicate/BelongsToRelationPredicate.php new file mode 100644 index 00000000..e4a100fb --- /dev/null +++ b/src/Filter/Predicate/BelongsToRelationPredicate.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 buildConditions(FilterConditionsBuilder $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(FilterConditionsBuilder $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); + } +} diff --git a/src/Filter/Predicate/BooleanPredicate.php b/src/Filter/Predicate/BooleanPredicate.php new file mode 100644 index 00000000..985fed0a --- /dev/null +++ b/src/Filter/Predicate/BooleanPredicate.php @@ -0,0 +1,24 @@ +define('field')->required()->allowedTypes('string'); + $resolver->define('value')->required()->allowedTypes('bool'); + } + + 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); + } +} diff --git a/src/Filter/Predicate/CalendarCurrentPredicate.php b/src/Filter/Predicate/CalendarCurrentPredicate.php new file mode 100644 index 00000000..8ab4ef63 --- /dev/null +++ b/src/Filter/Predicate/CalendarCurrentPredicate.php @@ -0,0 +1,70 @@ +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 + { + $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']); + } + + private function normalizeTimestamp($value): int + { + if ($value instanceof DateTimeInterface) { + return $value->getTimestamp(); + } + + return (int) $value; + } +} diff --git a/src/Filter/Predicate/DateRangePredicate.php b/src/Filter/Predicate/DateRangePredicate.php new file mode 100644 index 00000000..523e01ff --- /dev/null +++ b/src/Filter/Predicate/DateRangePredicate.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 buildConditions(FilterConditionsBuilder $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()); + } + } +} diff --git a/src/Filter/Predicate/DcaSelectPredicate.php b/src/Filter/Predicate/DcaSelectPredicate.php new file mode 100644 index 00000000..648c4a11 --- /dev/null +++ b/src/Filter/Predicate/DcaSelectPredicate.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 buildConditions(FilterConditionsBuilder $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('Options for the DCA select field must be unique.', method: __METHOD__); + } + + $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); + } +} diff --git a/src/Filter/Predicate/FieldValueChoicePredicate.php b/src/Filter/Predicate/FieldValueChoicePredicate.php new file mode 100644 index 00000000..25a690a5 --- /dev/null +++ b/src/Filter/Predicate/FieldValueChoicePredicate.php @@ -0,0 +1,38 @@ +define('field')->required()->allowedTypes('string'); + $resolver->define('values')->required()->allowedTypes('array'); + } + + public function buildConditions(FilterConditionsBuilder $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); + } +} diff --git a/src/Filter/Predicate/IntegerIdChoicePredicate.php b/src/Filter/Predicate/IntegerIdChoicePredicate.php new file mode 100644 index 00000000..e466757a --- /dev/null +++ b/src/Filter/Predicate/IntegerIdChoicePredicate.php @@ -0,0 +1,37 @@ +define('field')->default('id')->allowedTypes('string'); + $resolver->define('ids')->required()->allowedTypes('array'); + } + + public function buildConditions(FilterConditionsBuilder $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); + } +} diff --git a/src/Filter/Predicate/PredicateInterface.php b/src/Filter/Predicate/PredicateInterface.php new file mode 100644 index 00000000..22afdcc9 --- /dev/null +++ b/src/Filter/Predicate/PredicateInterface.php @@ -0,0 +1,27 @@ + $options + */ + public function buildConditions(FilterConditionsBuilder $builder, array $options): void; +} diff --git a/src/Filter/Predicate/PublishedPredicate.php b/src/Filter/Predicate/PublishedPredicate.php new file mode 100644 index 00000000..4c15475b --- /dev/null +++ b/src/Filter/Predicate/PublishedPredicate.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 buildConditions(FilterConditionsBuilder $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']); + } + } +} diff --git a/src/Filter/Predicate/SearchKeywordsPredicate.php b/src/Filter/Predicate/SearchKeywordsPredicate.php new file mode 100644 index 00000000..d11c496d --- /dev/null +++ b/src/Filter/Predicate/SearchKeywordsPredicate.php @@ -0,0 +1,65 @@ +define('value')->required()->allowedTypes('string'); + $resolver->define('columns')->required()->allowedTypes('array'); + } + + 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'])); + $or = []; + + foreach ($searchTermGroups ?: [] as $i => $searchTermGroup) + { + if (!$searchTerms = $this->makeTerms($searchTermGroup)) { + continue; + } + + $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; + } +} diff --git a/src/Filter/Predicate/SimpleEquationPredicate.php b/src/Filter/Predicate/SimpleEquationPredicate.php new file mode 100644 index 00000000..496d75d6 --- /dev/null +++ b/src/Filter/Predicate/SimpleEquationPredicate.php @@ -0,0 +1,84 @@ +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 buildConditions(FilterConditionsBuilder $builder, array $options): void + { + $operandLeft = $options['operand_left']; + $operator = SqlEquationOperator::match($options['operator']); + + if (!$operandLeft || !$operator instanceof SqlEquationOperator) { + throw new FilterException('Invalid filter configuration.', method: __METHOD__); + } + + $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::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, + }; + + if (!$where) { + throw new FilterException( + 'Invalid filter configuration: Operator not supported.', + method: __METHOD__, + ); + } + + $builder->where($where); + + if (!$operator->isUnary()) { + $operandRight = $options['operand_right']; + $builder->setParameter(':eq_right', $operandRight); + } + } +} diff --git a/src/Filter/Proposition.php b/src/Filter/Proposition.php new file mode 100644 index 00000000..9cb7ff1c --- /dev/null +++ b/src/Filter/Proposition.php @@ -0,0 +1,17 @@ +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/FilterOptionsResolver.php b/src/Filter/Resolver/FilterOptionsResolver.php new file mode 100644 index 00000000..d2ccfd08 --- /dev/null +++ b/src/Filter/Resolver/FilterOptionsResolver.php @@ -0,0 +1,47 @@ + + * + * @throws FilterException If the config does not satisfy the element's schema. + */ + public function resolve(FilterElementInterface $element, array $config): array + { + if (!$element instanceof OptionsContract) { + return $config; + } + + try + { + return $this->schemaResolver->resolve($element::class, $element->configureOptions(...), $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 . '::configureOptions', + ); + } + } +} diff --git a/src/Filter/Resolver/FilterTransformerResolver.php b/src/Filter/Resolver/FilterTransformerResolver.php new file mode 100644 index 00000000..57ded7a7 --- /dev/null +++ b/src/Filter/Resolver/FilterTransformerResolver.php @@ -0,0 +1,60 @@ + + */ + private array $resolvers = []; + + 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 $type, object $source): ?array + { + $cacheKey = \sprintf('%s@%s', $type, $element::class); + + if (!isset($this->resolvers[$cacheKey])) + { + $resolver = new TransformerResolver(); + + if ($element instanceof TransformerContract) { + $element->configureTransformers($resolver); + } + + $this->eventDispatcher->dispatch(new FilterTransformerEvent($resolver, $element, $type)); + + $this->resolvers[$cacheKey] = $resolver; + } + + if (!$transformer = $this->resolvers[$cacheKey]->resolve($source)) { + return null; + } + + $config = new ConfigBuilder(); + + $transformer($config, $source); + + return $config->all(); + } +} 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/Value/BoolValue.php b/src/Filter/Value/BoolValue.php new file mode 100644 index 00000000..617999d1 --- /dev/null +++ b/src/Filter/Value/BoolValue.php @@ -0,0 +1,37 @@ + 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($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..0d36fcc3 --- /dev/null +++ b/src/Filter/Value/DateRangeValue.php @@ -0,0 +1,76 @@ + 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 ?DateTimeImmutable $from = null, + public ?DateTimeImmutable $to = null, + ) {} + + /** + * @param mixed $from + * @param mixed $to + */ + public static function tryFrom(mixed $from, mixed $to, ?DateTimeZone $timezone = null): ?self + { + $timezone ??= DateTimeHelper::getTimeZone(); + + $from = self::toDateTime($from, $timezone); + $to = self::toDateTime($to, $timezone); + + if ($from === null && $to === null) { + return null; + } + + return new self($from, $to); + } + + private static function toDateTime(mixed $value, DateTimeZone $timezone): ?DateTimeImmutable + { + if ($value instanceof DateTimeInterface) { + return DateTimeImmutable::createFromInterface($value) + ->setTimezone($timezone); + } + + if (\is_int($value)) { + return self::createDateTimeFromTimestamp(\sprintf('%d.000000', $value), $timezone); + } + + if (\is_float($value)) { + if (!\is_finite($value)) { + return null; + } + + return self::createDateTimeFromTimestamp(\sprintf('%.6F', $value), $timezone); + } + + if (\is_string($value) && \is_numeric($value = \trim($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 new file mode 100644 index 00000000..49c4b7f1 --- /dev/null +++ b/src/Filter/Value/KeywordsValue.php @@ -0,0 +1,33 @@ +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..f67f33eb --- /dev/null +++ b/src/Filter/Value/ParentRefValue.php @@ -0,0 +1,78 @@ +> + */ + 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/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 @@ +id || !$table = $dataSource->getTable()) { - return null; - } - - if (!$this->listTypeRegistry->get($dataSource->getListType())?->getService()) { - return null; - } - - Controller::loadDataContainer($table); - - /** @var \Traversable $filterModels */ - $filterModels = FilterModel::findByPid($dataSource->id, published: true); - $collection = new FilterDefinitionCollection(); - - foreach ($filterModels as $filterModel) - // Collect filters defined in the backend - { - if (!$filterModel->published) { - continue; - } - - $filterDefinition = $this->filterDefinitionFactory->create($filterModel); - - $key = $filterDefinition->getAlias() - ?: "_.{$filterModel::getTable()}.{$filterModel->id}"; - - $collection->set($key, $filterDefinition); - } - - return $collection; - } -} \ No newline at end of file diff --git a/src/FilterElement/AbstractFilterElement.php b/src/FilterElement/AbstractFilterElement.php deleted file mode 100644 index 4acad00f..00000000 --- a/src/FilterElement/AbstractFilterElement.php +++ /dev/null @@ -1,133 +0,0 @@ - 'isMultiple', - 'expanded' => 'isExpanded', - 'required' => 'isMandatory', - 'mandatory' => 'isMandatory', - 'label' => 'label', - '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 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( - FilterDefinition $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 extractFormData(FormInterface $form): mixed - { - return $form->getData(); - } - - 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 - { - return $value; - } - - public static function define(): FilterDefinition - { - throw new \LogicException('Not implemented.'); - } -} \ No newline at end of file diff --git a/src/FilterElement/ArchiveElement.php b/src/FilterElement/ArchiveElement.php deleted file mode 100644 index da176218..00000000 --- a/src/FilterElement/ArchiveElement.php +++ /dev/null @@ -1,636 +0,0 @@ -getValue() ?? []; - $inferrer = $this->getPtableInferrer($inv->list); - - if (!$selectedModels) - { - if ($inv->filter->useWhitelistForOptionsOnly) { - return; - } - - $qb::abort(); - } - - if ($inferrer->getDcaMainPtable()) - { - if (!$pids = \array_column($selectedModels, 'id')) { - throw new FilterException('No valid parent archive ids extracted.'); - } - - $qb->where($qb->expr()->in($qb->column('pid'), ':pids')) - ->setParameter('pids', $pids, ArrayParameterType::INTEGER); - - return; - } - - if (!$inferrer->isDcaDynamicPtable()) - // no valid ptable available - { - throw new FilterException('No valid ptable found.'); - } - - /** - * ## = We are dealing with a _dynamic ptable_. ⇒ - */ - - $grouped = []; - $selectedModels = \array_filter((array) $selectedModels); - - foreach ($selectedModels as $item) - { - if ($item instanceof Model) { - $grouped[$item::getTable()][] = $item->id; - } - } - - $this->relationElement->filterDynamicPtableField( - qb: $qb, - filter: $inv->filter, - fieldDynamicPtable: 'ptable', - fieldPid: 'pid', - submittedData: $grouped, - ); - } - - protected function getWhitelistedParentIds(ListSpecification $list, FilterDefinition $filter): ?array - { - $inferrer = $this->getPtableInferrer($list); - - if ($inferrer->getDcaMainPtable()) - { - return $this->getParentIdsFromWhitelistBlob($filter->whitelistParents); - } - - if (!$inferrer->isDcaDynamicPtable()) - // no valid ptable available - { - return []; - } - - return $this->getParentIdsFromGroupWhitelistBlob($filter->groupWhitelistParents); - } - - protected function getWhitelistedParents(ListSpecification $list, FilterDefinition $filter): array - { - $inferrer = $this->getPtableInferrer($list); - - if ($ptable = $inferrer->getDcaMainPtable()) - { - $parents = $this->getParentsFromWhitelistBlob($ptable, $filter->whitelistParents); - return $parents?->getModels() ?? []; - } - - if (!$inferrer->isDcaDynamicPtable()) - // no valid ptable available - { - return []; - } - - return $this->getParentsFromGroupWhitelistBlob($filter->groupWhitelistParents); - } - - /** - * @return Model[] - */ - public function getIntrinsicValue(ListSpecification $list, FilterDefinition $filter): array - { - return $this->getWhitelistedParents($list, $filter); - } - - /** - * @return Model[] - */ - public function processRuntimeValue(mixed $value, ListSpecification $list, FilterDefinition $filter): 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; - - if ($useFullWhitelist) { - return $this->getWhitelistedParents($list, $filter); - } - - if (!$values || $values === true) { - return []; - } - - if (!$allowedParentIds = $this->getWhitelistedParentIds($list, $filter)) { - return []; - } - - if (\array_is_list($allowedParentIds)) - { - $allowedLookup = \array_flip($allowedParentIds); - - return \array_values(\array_filter( - $values, - static fn (Model $model): bool => isset($allowedLookup[$model->id]), - )); - } - - \array_walk($allowedParentIds, static fn (array &$ids): array => $ids = \array_flip($ids)); - /** - * @var array> $allowedParentIds 2D array mapping table names to parent IDs as keys. - * I.e., flips the nested arrays to be lookup tables for efficient filtering. - * @example $allowedParentIds = array{ - * 'tl_news_archive': [ - * 5: 0, // where 5 is the ID of the news archive - * 8: 1, // ID 8 - * 12: 2, // ID 12 - * ] - * } - */ - return \array_values(\array_filter( - $values, - static fn (Model $model): bool => isset($allowedParentIds[$model::getTable()][$model->id]), - )); - } - - /** - * @return Model[]|true|null Returns true if the empty option is selected, null if no value is selected. - */ - protected function normalizeFilterValue(mixed $value): array|true|null - { - if (!$value) { - return null; - } - - if (!\is_iterable($value)) { - $value = [$value]; - } - - $arr = []; - - foreach ($value as $v) { - if ($v === ChoicesBuilder::EMPTY_CHOICE) { - return true; - } - - if ($v instanceof Model) { - $arr[] = $v; - } - } - - return $arr; - } - - private function getPtableInferrer(ListSpecification $list): PtableInferrer - { - $cacheKey = $list->hash(); - - if (isset($this->_inferrer[$cacheKey])) { - return $this->_inferrer[$cacheKey]; - } - - $inferrable = PtableInferrableFactory::createFromListModelLike($list); - return $this->_inferrer[$cacheKey] = new PtableInferrer($inferrable, $list->dc); - } - - public function getPalette(PaletteConfig $config): ?string - { - if (!$filterModel = $config->getFilterModel()) { - return null; - } - - $inferrer = new PtableInferrer($filterModel, $config->getListModel()->dc); - - $palettes = []; - - if ($inferrer->getDcaMainPtable()) - { - $palettes[] = '{archive_legend},whitelistParents,formatLabel,useWhitelistForOptionsOnly'; - } - /** @mago-expect lint:no-else-clause This else clause is fine. */ - elseif ($inferrer->isDcaDynamicPtable()) - { - $palettes[] = '{archive_legend},groupWhitelistParents,useWhitelistForOptionsOnly'; - } - - if (!$filterModel->intrinsic) - { - $palette = '{form_legend},isMandatory,isMultiple,isExpanded,hasEmptyOption,'; - - if ($filterModel->hasEmptyOption) { - $palette .= 'formatEmptyOption,'; - } - - $palette .= 'preselect,'; - - $palettes[] = $palette; - } - - if (!$palettes) { - return null; - } - - return Str::mergePalettes(...$palettes); - } - - /** - * @throws FilterException - */ - public function handleFormTypeOptions(FilterElementFormTypeOptionsEvent $event): void - { - $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; - } - - foreach ($parents as $parent) - { - $choices->add(\sprintf('%s.%s', $ptable, $parent->id), $parent); - } - - return $value; - } - - if ($inferrer->isDcaDynamicPtable()) - { - $choices->setModelSuffix('[%@table%.id=%id%]'); - - if (!$groupWhitelist = StringUtil::deserialize($filterModel->groupWhitelistParents)) { - return $value; - } - - foreach ($groupWhitelist as $group) - { - $parents = $this->getParentsFromWhitelistBlob( - table: $table = $group['tablePtable'] ?? null, - blob: $group['whitelistParents'] ?? null - ); - - if (!$parents) { - continue; - } - - foreach ($parents as $parent) - { - $choices->add(\sprintf('%s.%s', $table, $parent->id), $parent); - } - } - } - - return $value; - } - - /** - * @return int[]|null - */ - protected function getParentIdsFromWhitelistBlob(?string $blob): ?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)) { - 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, FilterDefinition $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 = static fn (): PtableInferrer => $inferrer; - return $inferrer; - }; - - $ptable = static function () use (&$ptable, $ptableInferrer): string { - $pt = $ptableInferrer()->getDcaMainPtable(); - $ptable = static fn (): string => $pt; - return $pt; - }; - - $data = []; - $fetch = []; - - foreach ($preselect as $entity) - { - if ($entity instanceof Model) { - $data[] = $entity; - continue; - } - - if (\is_numeric($entity)) - { - if (!$ptable() || !$modelClass = Model::getClassFromTable($ptable())) { - continue; - } - - if (!\class_exists($modelClass)) { - continue; - } - - if (!$model = $modelClass::findByPk($entity)) { - continue; - } - - $data[] = $model; - continue; - } - - if (!\is_string($entity) || !\str_contains($entity, '.')) { - continue; - } - - [$table, $id] = \explode('.', $entity, 2); - - $fetch[$table] ??= []; - $fetch[$table][] = (int) $id; - } - - foreach ($fetch as $table => $ids) - { - if (!$ids = \array_unique($ids)) { - continue; - } - - if (!$modelClass = Model::getClassFromTable($table)) { - continue; - } - - if (!\class_exists($modelClass)) { - continue; - } - - if (!$models = $modelClass::findMultipleByIds($ids)?->getModels()) { - continue; - } - - \array_push($data, ...$models); - } - - $field->setData($data); - } -} \ No newline at end of file diff --git a/src/FilterElement/BelongsToRelationElement.php b/src/FilterElement/BelongsToRelationElement.php deleted file mode 100644 index 2ddd9083..00000000 --- a/src/FilterElement/BelongsToRelationElement.php +++ /dev/null @@ -1,208 +0,0 @@ -filter->fieldPid) - { - throw new FilterException('No parent field defined.'); - } - - $inferrable = PtableInferrableFactory::createFromListModelLike($inv->list); - $inferrer = new PtableInferrer($inferrable, $inv->list->dc); - - try - { - $ptable = $inferrer->getInferredPtable(); - $fieldDynamicPtable = $inferrer->tryGetDynamicPtableField(); - } - catch (InferenceException) - { - $qb->abort(); - } - - if (\is_string($fieldDynamicPtable)) - { - $this->filterDynamicPtableField($qb, $inv->filter, $fieldDynamicPtable, $fieldPid); - return; - } - - if (!$ptable || !$whitelistParents = StringUtil::deserialize($inv->filter->whitelistParents)) { - throw new FilterException('No whitelisted parents.'); - } - - $qb->where($qb->expr()->in($qb->column($fieldPid), ":whitelist")) - ->setParameter('whitelist', $whitelistParents); - } - - /** - * Expected format: - * ```php - * $submittedData = [ - * 'tl_article' => [1, 5, 35, ...], - * 'tl_news' => [2, 3, 4, ...], - * ]; - * ``` - */ - public function filterDynamicPtableField( - FilterQueryBuilder $qb, - FilterDefinition $filter, - string $fieldDynamicPtable, - string $fieldPid, - ?array $submittedData = null, - ): void { - if (!$parentGroups = StringUtil::deserialize($filter->groupWhitelistParents)) - { - $qb->abort(); - } - - $ors = []; - - $colDynamicPtable = $qb->column($fieldDynamicPtable); - $colPid = $qb->column($fieldPid); - - foreach (\array_values($parentGroups) as $i => $group) - { - if (!($g_tablePtable = $group['tablePtable'] ?? null) - || !($g_whitelistParents = $group['whitelistParents'] ?? null) - || !\is_array($g_whitelistParents = StringUtil::deserialize($g_whitelistParents))) - { - 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; - } - - $qb->whereOr(...$ors); - } - - public function getPalette(PaletteConfig $config): ?string - { - $listModel = $config->getListModel(); - $filterModel = $config->getFilterModel(); - - if (!$filterModel) { - Message::addError($this->trans->trans('errors.missing_model', [], 'flare')); - return ''; - } - - if (!$listModel->dc) { - Message::addError($this->trans->trans('errors.missing_datacontainer', [ - '%id%' => $listModel->id, - ], 'flare')); - return ''; - } - - $palette = '{filter_legend},fieldPid,whichPtable'; - - $inferrer = new PtableInferrer($filterModel, $listModel->dc); - $table = $inferrer->getEntityTable(); - $fieldPid = $inferrer->getPidField(); - - try - { - $ptable = $inferrer->getInferredPtable(); - - Message::addInfo(match (true) { - $inferrer->isAutoInferable() && $ptable => $this->trans->trans('infer_ptable.auto', [ - '%table%' => $table, - '%field%' => $fieldPid, - '%ptable%' => $ptable, - ], 'flare'), - $inferrer->isAutoDynamicPtable() => $this->trans->trans('infer_ptable.dynamic', [ - '%table%' => $table, - ], 'flare'), - default => $this->trans->trans('infer_ptable.invalid', [ - '%table%' => $table, - '%field%' => $fieldPid, - ], 'flare') - }); - } - catch (InferenceException $e) - { - Message::addError($e->getMessage()); - } - - if (!$inferrer->isAutoInferable()) - { - $filterModel->whichPtable_disableAutoOption(); - } - - if ($filterModel->whichPtable === 'dynamic') - { - $palette .= ';{archive_legend},groupWhitelistParents'; - } - /** @mago-expect lint:no-else-clause This else clause is fine. */ - elseif ($ptable ?? null) - { - $palette .= ',whitelistParents'; - } - - return $palette; - } -} \ No newline at end of file diff --git a/src/FilterElement/BooleanElement.php b/src/FilterElement/BooleanElement.php deleted file mode 100644 index a1afc4c5..00000000 --- a/src/FilterElement/BooleanElement.php +++ /dev/null @@ -1,180 +0,0 @@ -filter->fieldGeneric) { - $qb->abort(); - } - - $value = $inv->getValue(); - - if ($value === null) { - return; - } - - $qb->where($qb->expr()->eq($qb->column($targetField), ':val')) - ->setParameter('val', $value ? '1' : '', ParameterType::STRING); - } - - public function getIntrinsicValue(ListSpecification $list, FilterDefinition $filter): bool - { - return (bool) $this->normalizeValue($filter->preselect); - } - - public function processRuntimeValue(mixed $value, ListSpecification $list, FilterDefinition $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); - } - - public function normalizeValue(mixed $value, ?BoolBinaryChoices $choices = null): ?bool - { - if (\is_string($value)) { - $value = \strtolower(\trim($value)); - } - - if ($value === null || $value === '' || $value === 'null') - { - return null; - } - - if ($choices === BoolBinaryChoices::NULL_TRUE && !$value) - { - return null; - } - - return \filter_var($value, \FILTER_VALIDATE_BOOLEAN, \FILTER_NULL_ON_FAILURE); - } - - #[AsFilterCallback(self::TYPE, 'config.onload')] - public function onLoadConfig(FilterModel $filterModel): 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'] = [ - 'null' => 'flare.bool_preselect.null', - 'true' => 'flare.bool_preselect.true', - 'false' => 'flare.bool_preselect.false', - ]; - - if ($filterModel->intrinsic) { - unset($field['options']['null']); - } - - ###< preselect - - if ($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); - - if (!isset($GLOBALS['TL_DCA'][$targetTable]['fields'])) { - return []; - } - - $cbx = 'Checkbox'; - $non = 'Non-Checkbox'; - - $options = [ - $cbx => [], // checkbox fields - $non => [], // non-checkbox fields - ]; - - foreach ($GLOBALS['TL_DCA'][$targetTable]['fields'] as $name => $field) - { - $group = ('checkbox' === ($field['inputType'] ?? null)) ? $cbx : $non; - $options[$group][$name] = $targetTable . '.' . $name; - } - - \asort($options[$cbx]); - \asort($options[$non]); - - 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, - ): FilterDefinition { - $definition = new FilterDefinition( - type: static::TYPE, - intrinsic: true, - ); - - $definition->fieldGeneric = $targetField; - $definition->preselect = (string) (bool) $expectedValue; - - return $definition; - } -} \ No newline at end of file diff --git a/src/FilterElement/CalendarCurrentElement.php b/src/FilterElement/CalendarCurrentElement.php deleted file mode 100644 index ff0ebd70..00000000 --- a/src/FilterElement/CalendarCurrentElement.php +++ /dev/null @@ -1,192 +0,0 @@ -getValue(); - $from = $value['from'] ?? null; - $to = $value['to'] ?? null; - - $start = \strtotime($inv->filter->startAt) ?: 0; - $stop = \strtotime($inv->filter->stopAt) ?: DateTimeHelper::maxTimestamp(); - - if ($from instanceof \DateTimeInterface) - { - $from = $from->getTimestamp(); - - if (!$inv->filter->isLimited || $from >= $start) { - $start = $from; - } - } - - if ($to instanceof \DateTimeInterface) - { - $to = $to->getTimestamp(); - - if (!$inv->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); - } - - public function processRuntimeValue(mixed $value, ListSpecification $list, FilterDefinition $filter): ?array - { - if (!\is_array($value)) { - return null; - } - - if (!\array_key_exists('from', $value) && !\array_key_exists('to', $value)) - { - if (\count($value) !== 2) - { - return null; - } - - $value = \array_values($value); - - return [ - 'from' => $this->mixedToDateTime($value[0] ?? null), - 'to' => $this->mixedToDateTime($value[1] ?? null), - ]; - } - - $from = $value['from'] ?? null; - $to = $value['to'] ?? null; - - return [ - 'from' => $this->mixedToDateTime($from), - 'to' => $this->mixedToDateTime($to), - ]; - } - - private function mixedToDateTime(mixed $input): ?\DateTimeInterface - { - if (!$input) { - return null; - } - - if ($input instanceof \DateTimeInterface) { - return $input; - } - - if (\is_numeric($input)) { - return \DateTimeImmutable::createFromFormat('U', $input); - } - - if (\is_string($input)) { - return new \DateTimeImmutable($input); - } - - 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(); - - $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 - { - $event->options['required'] = false; - - $filter = $event->filter; - - if (!$filter->isLimited) { - return; - } - - 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; - } - } -} \ No newline at end of file diff --git a/src/FilterElement/DateRangeElement.php b/src/FilterElement/DateRangeElement.php deleted file mode 100644 index 0626b415..00000000 --- a/src/FilterElement/DateRangeElement.php +++ /dev/null @@ -1,54 +0,0 @@ -getValue(); - - if (!$field = $inv->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()); - } - } - - 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 deleted file mode 100644 index d15fc350..00000000 --- a/src/FilterElement/DcaSelectFieldElement.php +++ /dev/null @@ -1,387 +0,0 @@ -getOptions($inv->list, $inv->filter) ?? []; - - if (!$selected = $inv->getValue()) { - return; - } - - if (!$selected = \array_values((array) $selected)) { - return; - } - - if (!$options) { - $qb->abort(); - } - - if (!$targetField = $inv->filter->fieldGeneric) { - $qb->abort(); - } - - $dcaOptionsField = $this->getOptionsField($inv->list, $inv->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); - } - - 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, FilterDefinition $filter): mixed - { - return $this->getPreselectValue($filter); - } - - public function getPreselectValue(FilterDefinition $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, FilterDefinition $filter): void - { - if ($field->isSubmitted()) { - return; - } - - if (!$preselect = $this->getPreselectValue($filter)) { - return; - } - - $options = $this->getOptions($list, $filter) ?? []; - - if (!\is_array($preselect)) - { - if (!\is_scalar($preselect)) { - $field->setData($preselect); - return; - } - - if (!$option = $options[$preselect] ?? null) { - return; - } - - $field->setData($option); - return; - } - - $data = []; - - foreach ($preselect as $value) - { - if (!\is_scalar($value)) { - $data[] = $value; - continue; - } - - if ($option = $options[$value] ?? null) { - $data[] = $option; - } - } - - $field->setData($data); - } - - public function handleFormTypeOptions(FilterElementFormTypeOptionsEvent $event): void - { - $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($options = $this->getOptions($list, $filter))) { - return; - } - - $choices = $event->choicesBuilder->enable(); - - foreach ($options as $value => $label) { - $choices->add((string) $value, (string) $label); - } - } - - #[AsFilterCallback(self::TYPE, 'config.onload')] - public function onLoadConfig(FilterModel $filterModel): 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 - } - - #[AsFilterCallback(self::TYPE, 'fields.fieldGeneric.options')] - public function getFieldGenericOptions(ListModel $listModel): array - { - Controller::loadDataContainer($listModel->dc); - - if (!isset($GLOBALS['TL_DCA'][$listModel->dc]['fields'])) { - return []; - } - - // find all fields with a type of select - $options = []; - foreach ($GLOBALS['TL_DCA'][$listModel->dc]['fields'] as $name => $field) - { - if ('select' === ($field['inputType'] ?? null)) { - $options[$name] = $listModel->dc . '.' . $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, FilterDefinition $filter): ?array - { - $optionsField = $this->getOptionsField($list, $filter) ?? []; - $options = $this->tryGetOptionsFromField($list, $optionsField); - - if (!\is_array($options)) - { - return null; - } - - if (\array_is_list($options)) - { - $options = \array_combine($options, $options); - } - - if ($reference = $optionsField['reference'] ?? []) - { - foreach ($options as $k => $v) - { - $options[$k] = $reference[$v] ?? $reference[$k] ?? $v; - } - } - - return $options; - } - - public function getOptionsField(ListModel|ListSpecification $list, FilterModel|FilterDefinition $filter): ?array - { - Controller::loadLanguageFile($list->dc); - Controller::loadDataContainer($list->dc); - - return $GLOBALS['TL_DCA'][$list->dc]['fields'][$filter->fieldGeneric] ?? null; - } - - protected function tryGetOptionsFromField(ListModel|ListSpecification $list, array $optionsField): ?array - { - if (\is_array($options = $optionsField['options'] ?? null)) - { - return $options; - } - - if ($optionsCallback = $optionsField['options_callback'] ?? null) - { - $dataContainer = $this->mockDataContainerObject($list->dc); - - if (\is_string($optionsCallback) && \str_contains($optionsCallback, '::')) - { - [$class, $method] = \explode('::', $optionsCallback, 2); - $optionsCallback = [$class, $method]; - } - - if (\is_array($optionsCallback) && \count($optionsCallback) === 2) - { - $class = $optionsCallback[0] ?? null; - $method = $optionsCallback[1] ?? null; - - if (!\class_exists($class) || !\method_exists($class, $method)) { - return null; - } - - if (!$service = System::importStatic($class)) { - return null; - } - - $options = $service->{$method}($dataContainer); - } - - if (!\is_array($optionsCallback) && \is_callable($optionsCallback)) - { - $options = $optionsCallback($dataContainer); - } - } - - if (!\is_array($options)) { - return null; - } - - return $options; - } - - protected function mockDataContainerObject(string $table): DataContainer - { - return new class($table) extends DataContainer { - /** - * @noinspection MagicMethodsValidityInspection - * @noinspection PhpMissingParentConstructorInspection - */ - public function __construct(string $table) - { - if ($table) - { - $this->strTable = $table; - } - } - - public function getPalette(): string - { - return ''; - } - - protected function save($varValue): void - { - // do nothing - } - }; - } -} \ No newline at end of file diff --git a/src/FilterElement/FieldValueChoiceElement.php b/src/FilterElement/FieldValueChoiceElement.php deleted file mode 100644 index 529c3b50..00000000 --- a/src/FilterElement/FieldValueChoiceElement.php +++ /dev/null @@ -1,310 +0,0 @@ -context instanceof ValidationContext) { - return; - } - - if (!($field = $inv->filter->fieldGeneric)) { - return; - } - - if (!$value = $inv->getValue()) { - return; - } - - $colField = $qb->column($field); - - 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); - } - } - - public function processRuntimeValue(mixed $value, ListSpecification $list, FilterDefinition $filter): ?array - { - return $this->extractSubmittedData((array) $value); - } - - public function getIntrinsicValue(ListSpecification $list, FilterDefinition $filter): ?array - { - return $this->extractPreselectData($filter); - } - - public function extractFormData(FormInterface $form): mixed - { - return $form->getViewData(); - } - - public function extractPreselectData(FilterDefinition $filter): ?array - { - if (!$preselect = $filter->preselect) { - return null; - } - - if (\is_array($preselect)) { - return $preselect; - } - - if ($filter->isMultiple - || (\is_string($preselect) && \preg_match('/^a:\d+:\{.*}$/', $preselect))) - { - return StringUtil::deserialize($preselect, true); - } - - return [$preselect]; - } - - public 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; - } - - public function hydrateForm(FormInterface $field, ListSpecification $list, FilterDefinition $filter): void - { - if ($field->isSubmitted()) { - return; - } - - if (!$preselect = $this->extractPreselectData($filter)) { - 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); - } - - public function handleFormTypeOptions(FilterElementFormTypeOptionsEvent $event): void - { - $choices = $event->choicesBuilder - ->enable() - ->setEmptyOption(!$event->filter->isMultiple); - - $table = $event->list->dc; - $field = $event->filter->fieldGeneric ?: ''; - - if (!\is_null($foreignValues = $this->getForeignValues($table, $field))) - { - // TODO: display of frontend form values should be configurable - foreach ($foreignValues as $id => $label) { - $choices->add((string) $id, (string) $label, $id); - } - } - /** @mago-expect lint:no-else-clause This else clause is fine. */ - else - { - foreach ($this->getLocalValues($table, $field) as $value) { - $choices->add((string) $value, (string) $value, $value); - } - } - - $event->options['multiple'] = (bool) $event->filter->isMultiple; - $event->options['expanded'] = (bool) $event->filter->isExpanded; - $event->options['required'] = false; - } - - #[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; - } - - $dca = &$GLOBALS['TL_DCA'][$dcTable]['fields'][$dcField]; - $dca['eval']['submitOnChange'] = $dcField === 'isMultiple'; - $dca['eval']['tl_class'] = 'cbx m12 w25'; - - return $value; - } - - #[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; - } - - $flareDca = &$GLOBALS['TL_DCA'][$dcTable]['fields'][$dcField]; - - $choices = $this->choicesBuilderFactory - ->createChoicesBuilder() - ->setModelSuffix('[%id%]') - ->enable(); - - Controller::loadDataContainer($table); - - if (!\is_null($foreignValues = $this->getForeignValues($table, $valueField))) - { - foreach ($foreignValues as $id => $label) { - $choices->add((string) $id, (string) $label, $id); - } - } - /** @mago-expect lint:no-else-clause This else clause is fine. */ - else - { - foreach ($this->getLocalValues($table, $valueField) as $option) { - $choices->add((string) $option, (string) $option, $option); - } - } - - $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 $value; - } - - private function getForeignValues(string $table, string $field): ?array - { - if (isset($this->foreignValueCache[$table][$field])) { - return $this->foreignValueCache[$table][$field]; - } - - $dca = $GLOBALS['TL_DCA'][$table]['fields'][$field] ?? []; - - if (!$foreignKey = $dca['foreignKey'] ?? null) { - return null; - } - - [$foreignTable, $foreignDisplayColumn] = \explode('.', $foreignKey, 2); - - if (!$foreignTable || !$foreignDisplayColumn) { - return null; - } - - $foreignTable = $this->connection->quoteIdentifier($foreignTable); - $foreignDisplayColumn = $this->connection->quoteIdentifier($foreignDisplayColumn); - $foreignField = $this->connection->quoteIdentifier($dca['relation']['field'] ?? 'id'); - - // The string-concatenation happens directly in SQL, producing a key-value pair for each option in the format: - // `{id} => "{value} [{id}]"` (where `{value}` is the display column value, e.g., `tl_user.name`) - $sql = << 0 - ORDER BY `label` - SQL; - - return $this->foreignValueCache[$table][$field] = $this->connection->fetchAllKeyValue($sql); - } - - private function getLocalValues(string $table, string $field): array - { - if (isset($this->localValueCache[$table][$field])) { - return $this->localValueCache[$table][$field]; - } - - if (!$field || !$table) { - return []; - } - - $qTable = $this->connection->quoteIdentifier($table); - $qField = $this->connection->quoteIdentifier($field); - - $sql = << 0 - ORDER BY `value`; - SQL; - - $values = \array_values(\array_filter( - $this->connection->fetchFirstColumn($sql), - static fn (mixed $v): bool => (!\is_string($v) || \trim($v) !== '') - )); - - return $this->localValueCache[$table][$field] = $values; - } -} diff --git a/src/FilterElement/PublishedElement.php b/src/FilterElement/PublishedElement.php deleted file mode 100644 index 93297722..00000000 --- a/src/FilterElement/PublishedElement.php +++ /dev/null @@ -1,95 +0,0 @@ -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()); - } - } - - public static function define( - string|false|null $published = null, - string|false|null $start = null, - string|false|null $stop = null, - bool|null $invertPublished = null, - ): FilterDefinition { - $published ??= 'published'; - $start ??= 'start'; - $stop ??= 'stop'; - $invertPublished ??= false; - - $definition = new FilterDefinition( - type: static::TYPE, - intrinsic: true, - ); - - 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; - } -} \ No newline at end of file diff --git a/src/FilterElement/SearchKeywordsElement.php b/src/FilterElement/SearchKeywordsElement.php deleted file mode 100644 index 5231d58c..00000000 --- a/src/FilterElement/SearchKeywordsElement.php +++ /dev/null @@ -1,114 +0,0 @@ -getValue(); - if (!$value || !\is_string($value)) { - return; - } - - if (!$columns = StringUtil::deserialize($inv->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; - } - - public function handleFormTypeOptions(FilterElementFormTypeOptionsEvent $event): void - { - $event->options['label'] = 'label.text'; - $event->options['required'] = false; - - if ($label = $event->filter->label) { - $event->options['label'] = $label; - } - - if ($placeholder = $event->filter->placeholder) { - $event->options['attr']['placeholder'] = $placeholder; - } - } - - public function getIntrinsicValue(ListSpecification $list, FilterDefinition $filter): ?string - { - return $filter->prefill ?: null; - } - - public function getPalette(PaletteConfig $config): ?string - { - $palette = '{filter_legend},columnsGeneric'; - - if ($config->getFilterModel()->intrinsic) { - return $palette . ',prefill'; - } - - return $palette . ';{form_legend},label,placeholder'; - } -} \ No newline at end of file diff --git a/src/FilterElement/SimpleEquationElement.php b/src/FilterElement/SimpleEquationElement.php deleted file mode 100644 index 76e71aec..00000000 --- a/src/FilterElement/SimpleEquationElement.php +++ /dev/null @@ -1,118 +0,0 @@ -filter->equationLeft) - || !$op = SqlEquationOperator::match($inv->filter->equationOperator)) - { - throw new FilterException('Invalid filter configuration.'); - } - - $operand = $qb->column($operand); - - $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 ?: ''); - } - } - - #[AsFilterCallback(self::TYPE, 'fields.equationLeft.options')] - 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(); - - if (SqlEquationOperator::match($filterModel?->equationOperator)?->isUnary()) { - return '{flare_simple_equation_legend},equationLeft,equationOperator'; - } - - return '{flare_simple_equation_legend},equationLeft,equationOperator,equationRight'; - } - - public static function define( - ?string $equationLeft = null, - ?SqlEquationOperator $equationOperator = null, - mixed $equationRight = null, - ): FilterDefinition { - $definition = new FilterDefinition( - type: static::TYPE, - intrinsic: true, - ); - - 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; - } -} diff --git a/src/Form/ChoicesBuilder.php b/src/Form/ChoicesBuilder.php index 2a0b5da8..ac834411 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; @@ -44,9 +45,9 @@ * * 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 +final class ChoicesBuilder { /** * @api This is the 'choice' property of the empty option. Use as a placeholder for a empty choice option. @@ -69,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; @@ -171,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; @@ -272,6 +242,11 @@ public function buildChoices(): array return $choices; } + public function buildCallbackChoiceLoader(): CallbackChoiceLoader + { + return new CallbackChoiceLoader($this->buildChoices(...)); + } + /** @api */ public function buildChoiceValueCallback(): callable { @@ -281,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 ''; } @@ -365,7 +342,7 @@ public function buildChoiceLabel(mixed $choice, string|int $key, mixed $value): * * @api */ - public function buildOptions(): array + public function buildContaoOptions(): array { $options = []; @@ -384,6 +361,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/Form/Factory/FilterFormFactory.php b/src/Form/Factory/FilterFormFactory.php deleted file mode 100644 index af1965de..00000000 --- a/src/Form/Factory/FilterFormFactory.php +++ /dev/null @@ -1,165 +0,0 @@ -getFormName(); - $filters = $list->getFilters(); - - $formOptions = [ - 'method' => 'GET', - 'csrf_protection' => false, - 'translation_domain' => 'flare_form', - 'attr' => [ - 'data-flare-form' => 'keep-query', - ], - ]; - - if ($action = $this->resolveFormAction($context)) { - $formOptions['action'] = $action; - } - - $builder = $this->formFactory->createNamedBuilder($name, FormType::class, null, $formOptions); - - foreach ($filters->getIterator() as $filterDefinition) - // Apply only non-intrinsic, published filters with a valid type - { - if (!$filterDefinition->getType() || $filterDefinition->isIntrinsic()) { - continue; - } - - if (!$formType = $this->filterElementRegistry->get($filterDefinition->getType())?->getFormType()) { - 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; - - $builder->add($childName, $formType, $options); - } - - /* - * **Always add submit buttons in templates, not in the form builder!** - * This is not advised: - * ```php - * if ($builder->count()) { - * $builder->add('submit', SubmitType::class, [ - * 'label' => 'submit', - * ]); - * ``` - */ - - /** @var FilterFormBuildEvent $formBuildEvent */ - $formBuildEvent = $this->eventDispatcher->dispatch(new FilterFormBuildEvent( - listSpecification: $list, - formName: $name, - formBuilder: $builder, - )); - - $builder = $formBuildEvent->formBuilder; - - 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()) { - return null; - } - - if (!$pageModel = PageModel::findByPk($jumpTo)) { - return null; - } - - return $pageModel->getAbsoluteUrl(); - } -} \ No newline at end of file diff --git a/src/Form/Factory/FormHarnessFactory.php b/src/Form/Factory/FormHarnessFactory.php new file mode 100644 index 00000000..27553c6e --- /dev/null +++ b/src/Form/Factory/FormHarnessFactory.php @@ -0,0 +1,166 @@ +getFormName(); + + $formOptions = [ + 'method' => 'GET', + 'csrf_protection' => false, + 'translation_domain' => 'flare_form', + 'attr' => [ + 'data-flare-form' => 'keep-query', + ], + ]; + + if ($action = $context->createFormActionUrl()) { + $formOptions['action'] = $action; + } + + $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 $filter) + { + $alias = $filter->alias; + + if (!Str::isValidFormName($alias)) { + continue; + } + + $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. + $builder = new FilterFormBuilder($alias, null, new EventDispatcher(), $this->formFactory); + $builder->setAttribute(FilterContext::ATTR_SELF, $filterContext); + + $filter->element->buildForm($builder, $filterContext); + + /** @var FilterFormBuiltEvent $event */ + $event = $this->eventDispatcher->dispatch(new FilterFormBuiltEvent($builder, $filterContext)); + + if ($event->isCancelled()) + // Filters can be skipped by event listeners. + { + continue; + } + + $single = $builder->getSingle(); + + if (!$single && $builder->count() === 0) + // Filters without any form representation are never mounted. + { + continue; + } + + if ($single && $builder->count() > 0) + { + throw new FlareException( + 'Filter element cannot declare a single field and add children at the same time.', + method: __METHOD__, + ); + } + + if ($single) + { + $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($alias, FormType::class, [ + 'inherit_data' => false, + 'label' => false, + 'required' => false, + ]); + + foreach ($builder->all() as $childBuilder) { + $mount->add($childBuilder); + } + } + + foreach ($builder->getAttributes() as $attrName => $attrValue) { + $mount->setAttribute($attrName, $attrValue); + } + + foreach ($builder->getDeferredListeners() as [$eventName, $listener, $priority]) { + $mount->addEventListener($eventName, $listener, $priority); + } + + $mounts[$alias] = new FilterMount($filter, $alias, $filterContext); + + $root->add($mount); + } + + /* + * **Always add submit buttons in templates, not in the form builder!** + * This is NOT advised: + * ```php + * if ($builder->count()) { + * $builder->add('submit', SubmitType::class, [ 'label' => 'submit']); + * } + * ``` + */ + + /** @var FormHarnessBuildEvent $formBuildEvent */ + $formBuildEvent = $this->eventDispatcher->dispatch(new FormHarnessBuildEvent( + list: $list, + formName: $name, + formBuilder: $root, + )); + + /** @var FormBuilder $root */ + $root = $formBuildEvent->formBuilder; + + return new FormHarness($root->getForm(), $mounts); + } +} diff --git a/src/Form/FilterMount.php b/src/Form/FilterMount.php new file mode 100644 index 00000000..c3c2655d --- /dev/null +++ b/src/Form/FilterMount.php @@ -0,0 +1,37 @@ +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/Form/FormHarness.php b/src/Form/FormHarness.php new file mode 100644 index 00000000..f2b4f860 --- /dev/null +++ b/src/Form/FormHarness.php @@ -0,0 +1,72 @@ + filter map. + * + * 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 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\FormHarnessFactory} 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 getMount(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\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 getChild(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/src/Form/Type/DateRangeFilterType.php b/src/Form/Type/DateRangeFormType.php similarity index 98% rename from src/Form/Type/DateRangeFilterType.php rename to src/Form/Type/DateRangeFormType.php index 80920928..611f8ade 100644 --- a/src/Form/Type/DateRangeFilterType.php +++ b/src/Form/Type/DateRangeFormType.php @@ -11,7 +11,7 @@ use Symfony\Component\Validator\Constraints\Callback; use Symfony\Component\Validator\Context\ExecutionContextInterface; -class DateRangeFilterType extends AbstractType +class DateRangeFormType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options): void { @@ -112,4 +112,4 @@ public function validateRange(array $data, ExecutionContextInterface $context): ->addViolation(); } } -} \ No newline at end of file +} diff --git a/src/HeimrichHannotFlareBundle.php b/src/HeimrichHannotFlareBundle.php index 29025a30..efeacbde 100644 --- a/src/HeimrichHannotFlareBundle.php +++ b/src/HeimrichHannotFlareBundle.php @@ -46,11 +46,11 @@ public function build(ContainerBuilder $container): void ###< Integrations ### ###> Fill Registries ### - $container->addCompilerPass(new DependencyInjection\Compiler\RegisterFlareCallbacksPass()); - $container->addCompilerPass(new DependencyInjection\Compiler\RegisterFilterInvokersPass()); - // RegisterFilterInvokersPass MUST be added before RegisterFilterElementsPass + // 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\RegisterListTypesPass()); + $container->addCompilerPass(new DependencyInjection\Compiler\RegisterListDriversPass()); ###< Fill Registries ### } -} \ No newline at end of file +} diff --git a/src/InferPtable/Factory/PtableInferrableFactory.php b/src/InferPtable/Factory/PtableInferrableFactory.php index f81386f9..14fd66fc 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\List\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/InferPtable/PtableInferrer.php b/src/InferPtable/PtableInferrer.php index 376211d2..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; @@ -129,7 +135,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 +239,4 @@ public function tryGetDynamicPtableField(): ?string return null; } -} \ No newline at end of file +} diff --git a/src/Integration/CodefogTags/EventListener/RegisterTagsTablesListener.php b/src/Integration/CodefogTags/EventListener/RegisterTagsTablesListener.php index ba1fa389..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->listSpecification->dc; + $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/CodefogTags/FilterCallback/TargetAliasCallback.php b/src/Integration/CodefogTags/FilterCallback/TargetAliasCallback.php index 221e9d92..8a275c5b 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\Integration\CodefogTags\FilterElement\CodefogTagsChoiceElement; +use HeimrichHannot\FlareBundle\Event\ElementDcaEvent; +use HeimrichHannot\FlareBundle\Integration\CodefogTags\FilterElement\CodefogTagsChoiceFilterElement; 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.' . CodefogTagsChoiceFilterElement::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 deleted file mode 100644 index 35d1213d..00000000 --- a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceElement.php +++ /dev/null @@ -1,193 +0,0 @@ -getValue(); - if (!$tagIds) { - return; - } - - if (\count($tagIds) === 1) { - $qb->where($qb->expr()->eq($qb->column('id'), ':cfg_tag_id')) - ->setParameter('cfg_tag_id', \reset($tagIds), ParameterType::INTEGER); - return; - } - - $qb->where($qb->expr()->in($qb->column('id'), ':cfg_tag_ids')) - ->setParameter('cfg_tag_ids', $tagIds, ArrayParameterType::INTEGER); - } - - public function hydrateForm(FormInterface $field, ListSpecification $list, FilterDefinition $filter): void - { - if ($field->isSubmitted()) { - return; - } - - if (!$preselect = $this->getIntrinsicValue($list, $filter)) { - return; - } - - if (!$filter->isMultiple) { - $preselect = \reset($preselect); - } - - $field->setData($preselect); - } - - #[AsFilterCallback(self::TYPE, 'config.onload')] - public function onLoadConfig(FilterModel $filterModel): 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 - } - - 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 - { - return $this->normalizeValueArray( - StringUtil::deserialize($filter->preselect ?: null, true) - ) ?: null; - } - - public function processRuntimeValue(mixed $value, ListSpecification $list, FilterDefinition $filter): ?array - { - if (!$value = StringUtil::deserialize($value)) { - return null; - } - - if (\is_numeric($value)) { - $value = (int) $value; - return $value > 0 ? [$value] : null; - } - - if (\is_array($value)) { - return $this->normalizeValueArray($value); - } - - 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, FilterDefinition $filter, ListExecutionContext $context): ?array - { - $targetAlias = $filter->getTargetAlias(); - - $activeTagsAliases = \array_intersect_key( - $this->joinsRegistry->all(), - \array_flip($context->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, - )); - return null; - } - - $tableAlias = \array_key_first($activeTagsAliases); - $config = $this->joinsRegistry->get($tableAlias); - - $options = []; - - /** @var \Codefog\TagsBundle\Tag $tag */ - foreach ($config?->manager->getAllTags() ?? [] as $tag) { - $value = $tag->getValue(); - $options[$value] = "{$tag->getName()} [{$value}]"; - } - - return $options; - } -} \ No newline at end of file diff --git a/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php new file mode 100644 index 00000000..f0564ffc --- /dev/null +++ b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php @@ -0,0 +1,218 @@ +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'); + } + + protected function transformFilterModel(ConfigBuilder $config, FilterModel $model): void + { + $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(FilterFormBuilderInterface $builder, FilterContext $context): void + { + $config = $context->config; + + if ($config['intrinsic']) { + 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'] ?: $placeholderFallback, + ]; + + if ($preselect = $config['preselect']) { + $formOptions['data'] = $config['is_multiple'] ? $preselect : \reset($preselect); + } + + $executionContext = $this->listExecutionContextFactory->create($context->list); + + $optValues = $this->getOptions( + executionContext: $executionContext, + targetAlias: $context->filter->targetAlias, + 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'), + ); + + if (!\is_null($optValues)) + { + $choicesBuilder = $this->createChoicesBuilder()->applyFormOptions($formOptions); + + foreach ($optValues as $value => $label) { + $choicesBuilder->add((string) $value, (string) $label, (int) $value); + } + + $builder->setAttribute('flare.choices_builder', $choicesBuilder); + } + + $builder->single(ChoiceType::class, $formOptions); + } + + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void + { + $config = $context->config; + + $preselect = $config['preselect'] ?: null; + + /** @var ?array $tagIds */ + $tagIds = $config['intrinsic'] + ? $preselect + : $this->processRuntimeValue($value->getSingleValue()); + + if (!$tagIds) { + return; + } + + $builder->add(IntegerIdChoicePredicate::class, [ + 'field' => 'id', + 'ids' => $tagIds, + ]); + } + + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void + { + $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')), + ) ?? []; + }); + } + + private function normalizeValueArray(array $values): array + { + return \array_values(\array_unique(\array_filter(\array_map('\intval', $values)))); + } + + public function processRuntimeValue(mixed $value): ?array + { + if (!$value = StringUtil::deserialize($value)) { + return null; + } + + if (\is_numeric($value)) { + $value = (int) $value; + return $value > 0 ? [$value] : null; + } + + if (\is_array($value)) { + return $this->normalizeValueArray($value); + } + + return null; + } + + /** + * 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($executionContext->tableAliasRegistry->getAliases()), + ); + + if (\count($activeTagsAliases) !== 1) { + $this->logger->warning(\sprintf( + '[FLARE] Cannot determine single target table for tags filter on ' + . 'list %s, filter %s, targetAlias %s', + $listInfo, $filterInfo, $targetAlias, + )); + return null; + } + + $tableAlias = \array_key_first($activeTagsAliases); + $config = $this->joinsRegistry->get($tableAlias); + + $options = []; + + /** @var \Codefog\TagsBundle\Tag $tag */ + foreach ($config?->manager->getAllTags() ?? [] as $tag) { + $value = $tag->getValue(); + $options[$value] = "{$tag->getName()} [{$value}]"; + } + + return $options; + } +} diff --git a/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php b/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php index c62925bf..aa49b1ac 100644 --- a/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php +++ b/src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php @@ -4,29 +4,36 @@ namespace HeimrichHannot\FlareBundle\Integration\CodefogTags\FilterElement; +use HeimrichHannot\FlareBundle\Config\ConfigBuilder; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaBuilderInterface; +use HeimrichHannot\FlareBundle\DataContainer\Builder\DcaContext; 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( - type: self::TYPE, - palette: '{filter_legend},fieldGeneric,isMultiple,preselect', - formType: SearchType::class, - isTargeted: true, -)] +use HeimrichHannot\FlareBundle\Filter\Element\AbstractFilterElement; +use HeimrichHannot\FlareBundle\Model\FilterModel; +use Symfony\Component\OptionsResolver\OptionsResolver; + +#[AsFilterElement(type: self::TYPE, isTargeted: true)] class CodefogTagsSearchElement extends AbstractFilterElement { public const TYPE = 'cfg_tags_search'; - public function __invoke(FilterInvocation $inv, FilterQueryBuilder $qb): void + public function isSupported(): bool { - // TODO: Implement __invoke() method. + return false; } - public function isSupported(): bool + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void { - return false; + $dca->palette('{filter_legend},fieldGeneric,isMultiple,preselect'); + } + + public function configureOptions(OptionsResolver $resolver): void + { + // TODO: Implement configureOptions() method. + } + + protected function transformFilterModel(ConfigBuilder $config, FilterModel $model): void + { + // TODO: Implement transformFilterModel() method. } -} \ No newline at end of file +} 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/ContaoCalendar/ListDriver/EventsListDriver.php b/src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php new file mode 100644 index 00000000..4a9ba462 --- /dev/null +++ b/src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php @@ -0,0 +1,79 @@ +suffix(static function (string $suffix): string { + if (!$suffix) { + return $suffix; + } + + $suffix = (string) \str_replace('sortSettings', '', $suffix); + $suffix = \preg_replace('/(?:^|;)\{[^}]*},*(?:;|$)/', ';', $suffix); + $suffix = \preg_replace('/;{2,}/', ';', $suffix); + + return \trim($suffix, ';'); + }); + } + + public function buildTableRegistry(TableAliasRegistry $registry): void + { + $fromAlias = TableAliasRegistry::ALIAS_MAIN; + + $registry->registerJoin(new SqlJoinStruct( + fromAlias: $fromAlias, + joinType: JoinTypeEnum::INNER, + table: 'tl_calendar', + joinAlias: self::ALIAS_ARCHIVE, + condition: $registry->makeJoinOn(self::ALIAS_ARCHIVE, 'id', $fromAlias, 'pid') + )); + } + + public function buildList(ListSpecBuilder $builder): void + { + if ($builder->hasFilterInstance(PublishedFilterElement::class)) { + return; + } + + $builder->addFilter($this->filterFactory->create( + element: PublishedFilterElement::TYPE, + config: [ + 'intrinsic' => true, + 'published_field' => 'published', + 'start_field' => 'start', + 'stop_field' => 'stop', + 'invert' => false, + ], + )); + } +} diff --git a/src/Integration/ContaoCalendar/ListType/EventsListType.php b/src/Integration/ContaoCalendar/ListType/EventsListType.php deleted file mode 100644 index 18a384b9..00000000 --- a/src/Integration/ContaoCalendar/ListType/EventsListType.php +++ /dev/null @@ -1,64 +0,0 @@ -getSuffix()) - { - $suffix = \str_replace('sortSettings', '', $suffix); - $suffix = \preg_replace('/(?:^|;)\{[^}]*},*(?:;|$)/', ';', $suffix); - $suffix = \preg_replace('/;{2,}/', ';', $suffix); - $suffix = \trim($suffix, ';'); - $config->setSuffix($suffix); - } - - return null; - } - - public function configureTableRegistry(TableAliasRegistry $registry): void - { - $fromAlias = TableAliasRegistry::ALIAS_MAIN; - - $registry->registerJoin(new SqlJoinStruct( - fromAlias: $fromAlias, - joinType: JoinTypeEnum::INNER, - table: 'tl_calendar', - joinAlias: self::ALIAS_ARCHIVE, - condition: $registry->makeJoinOn(self::ALIAS_ARCHIVE, 'id', $fromAlias, 'pid') - )); - } - - #[AsEventListener(priority: 200)] - public function onListSpecificationCreated(ListSpecificationCreatedEvent $config): void - { - if ($config->listSpecification->type !== self::TYPE) { - return; - } - - $filters = $config->listSpecification->getFilters(); - - if (!$filters->hasType(PublishedElement::TYPE)) { - $filters->add(PublishedElement::define()); - } - } -} \ No newline at end of file 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/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/src/Integration/ContaoCalendar/Projector/EventsAggregationProjector.php b/src/Integration/ContaoCalendar/Projector/EventsAggregationProjector.php index c89599ab..0bf9c751 100644 --- a/src/Integration/ContaoCalendar/Projector/EventsAggregationProjector.php +++ b/src/Integration/ContaoCalendar/Projector/EventsAggregationProjector.php @@ -10,20 +10,20 @@ 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\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\List\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->driver instanceof EventsListDriver && $context instanceof AggregationContext; } - public function priority(ListSpecification $list, ContextInterface $context): int + public function priority(ListSpec $list, ContextInterface $context): int { return 100; } @@ -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 4ffae0c3..0daf97da 100644 --- a/src/Integration/ContaoCalendar/Projector/EventsInteractiveProjector.php +++ b/src/Integration/ContaoCalendar/Projector/EventsInteractiveProjector.php @@ -10,24 +10,24 @@ 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; use HeimrichHannot\FlareBundle\Paginator\Paginator; use HeimrichHannot\FlareBundle\Reader\ReaderUrlGeneratorInterface; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; 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->driver instanceof EventsListDriver && $context instanceof InteractiveContext; } - public function priority(ListSpecification $list, ContextInterface $context): int + public function priority(ListSpec $list, ContextInterface $context): int { return 100; } @@ -57,4 +57,4 @@ protected function createView( totalItems: $totalItems, ); } -} \ No newline at end of file +} diff --git a/src/Integration/ContaoComments/EventListener/ContaoCommentsListener.php b/src/Integration/ContaoComments/EventListener/ContaoCommentsListener.php index 159a4463..e15a8fdc 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; @@ -32,8 +32,7 @@ public function __construct( #[AsEventListener] public function onReaderBuilt(ReaderRenderEvent $event): void { - $list = $event->getListSpecification(); - if (!$list->comments_enabled) { + 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->comments_sendNativeEmails) + 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; @@ -103,18 +102,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())); } /** @@ -141,4 +140,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/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 2844c470..a52c4cfd 100644 --- a/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php +++ b/src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php @@ -15,11 +15,12 @@ use HeimrichHannot\FlareBundle\Event\FetchAutoItemEvent; use HeimrichHannot\FlareBundle\Event\FetchCountEvent; use HeimrichHannot\FlareBundle\Event\FetchListEntriesEvent; -use HeimrichHannot\FlareBundle\FilterElement\SimpleEquationElement; -use HeimrichHannot\FlareBundle\ListType\DcMultilingualListType; +use HeimrichHannot\FlareBundle\Filter\Element\SimpleEquationFilterElement; +use HeimrichHannot\FlareBundle\Filter\Factory\FilterFactory; +use HeimrichHannot\FlareBundle\Integration\Terminal42Languages\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; @@ -37,6 +38,7 @@ class ChangelanguageListener public function __construct( private readonly Connection $connection, + private readonly FilterFactory $filterFactory, private readonly ReaderRequestAttributeResolver $attributeResolver, private readonly RequestStack $requestStack, ) {} @@ -53,9 +55,9 @@ public function setMultilingualQueryBuilderFactory( #[AsEventListener] public function fetchAutoItem(FetchAutoItemEvent $event): void { - $list = $event->getListSpecification(); + $list = $event->getList(); - if ($list->type !== DcMultilingualListType::TYPE) { + if (!$list->driver instanceof DcMultilingualListType) { return; } @@ -127,27 +129,35 @@ 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( - equationLeft: DcMultilingualHelper::getPidColumn($table), - equationOperator: SqlEquationOperator::GREATER_THAN, - equationRight: '0' + $configuredFilter = $this->filterFactory->create( + element: SimpleEquationFilterElement::TYPE, + config: [ + 'intrinsic' => true, + 'left' => DcMultilingualHelper::getPidColumn($table), + 'operator' => SqlEquationOperator::GREATER_THAN, + 'right' => '0', + ], ); - $filterDefinition->forceTargetAlias('translation'); + $configuredFilter = $configuredFilter->withTargetAlias('translation'); } - $filterDefinition ??= SimpleEquationElement::define( - equationLeft: DcMultilingualHelper::getPidColumn($table), - equationOperator: SqlEquationOperator::EQUALS, - equationRight: '0' + $configuredFilter ??= $this->filterFactory->create( + element: SimpleEquationFilterElement::TYPE, + config: [ + 'intrinsic' => true, + 'left' => DcMultilingualHelper::getPidColumn($table), + 'operator' => SqlEquationOperator::EQUALS, + 'right' => '0', + ], ); // $filters->add($this->filterContextManager->definitionToContext( - // definition: $filterDefinition, + // filter: $configuredFilter, // listModel: $filters->getListModel(), // contentContext: $contentContext, // )); @@ -200,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 @@ -260,8 +268,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/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..76280a5d 100644 --- a/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php +++ b/src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php @@ -7,12 +7,14 @@ use Contao\CoreBundle\String\HtmlDecoder; use Contao\CoreBundle\String\SimpleTokenParser; use Contao\DataContainer; -use HeimrichHannot\FlareBundle\Contract\ListType\DataContainerContract; -use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListType; -use HeimrichHannot\FlareBundle\ListType\AbstractListType; - -#[AsListType(type: self::TYPE, palette: self::DEFAULT_PALETTE)] -class DcMultilingualListType extends AbstractListType implements DataContainerContract +use HeimrichHannot\FlareBundle\Config\ConfigBuilder; +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 OnSubmitDcContract { public const TYPE = 'flare_generic_dc_multilingual'; public const DEFAULT_PALETTE = <<<'PALETTE' @@ -35,8 +37,13 @@ protected function getSimpleTokenParser(): SimpleTokenParser return $this->simpleTokenParser; } - public function getDataContainerName(array $row, DataContainer $dc): string + public function resolveDcOnSubmit(array $row, DataContainer $dc): string { return $row['dc'] ?? ''; } -} \ No newline at end of file + + protected function transformListModel(ConfigBuilder $config, ListModel $model): void + { + $config->set('genericPageMeta', true); + } +} diff --git a/src/List/BaseListOptions.php b/src/List/BaseListOptions.php new file mode 100644 index 00000000..34da78d2 --- /dev/null +++ b/src/List/BaseListOptions.php @@ -0,0 +1,65 @@ +define('dc')->default('')->allowedTypes('string')->required(); + $resolver->define('title')->default('')->allowedTypes('string'); + $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(ConfigBuilder $config, ListModel $model): void + { + $config + ->set('dc', (string) $model->dc) + ->set('title', (string) $model->title) + ->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/List/CallbackListModelTransformer.php b/src/List/CallbackListModelTransformer.php new file mode 100644 index 00000000..5b5f5b27 --- /dev/null +++ b/src/List/CallbackListModelTransformer.php @@ -0,0 +1,28 @@ +transform)($config, $source); + } +} diff --git a/src/List/Collector/ListModelFilterCollector.php b/src/List/Collector/ListModelFilterCollector.php new file mode 100644 index 00000000..2edcbaea --- /dev/null +++ b/src/List/Collector/ListModelFilterCollector.php @@ -0,0 +1,81 @@ +|null + * @throws FlareException + */ + public function collect(ListModel $listModel): ?array + { + if (!$listModel->id || !$table = $listModel::getTable()) { + return null; + } + + if (!$this->listDriverRegistry->getService((string) $listModel->type)) { + throw new FlareException('No list driver found for type "' . $listModel->type . '"'); + } + + Controller::loadDataContainer($table); + + $filters = []; + + /** @var FilterModel $model */ + foreach (FilterModel::findByPid((int) $listModel->id, published: true) as $model) + // Collect filters defined in the backend + { + if (!$model->published) { + continue; + } + + try + { + $filter = $this->filterFactory->createFromFilterModel($model); + } + catch (FlareException $e) + { + $this->logger->warning(\sprintf( + '[FLARE] Error while creating Filter of type "%s" on [%s.%s] -- [Message] %s', + $model->getFilterElementType(), + $listModel::getTable(), + $listModel->id, + $e->getMessage(), + )); + + continue; + } + + $filter = $this->eventDispatcher->dispatch(new FilterCollectedEvent($filter, $model))->filter; + + $filters[$filter->alias] = $filter; + } + + return $filters; + } +} diff --git a/src/List/Driver/AbstractListDriver.php b/src/List/Driver/AbstractListDriver.php new file mode 100644 index 00000000..cb2b3c80 --- /dev/null +++ b/src/List/Driver/AbstractListDriver.php @@ -0,0 +1,57 @@ +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(ConfigBuilder $config, ListModel $model): 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/ListType/GenericDataContainerListType.php b/src/List/Driver/GenericDataContainerListDriver.php similarity index 54% rename from src/ListType/GenericDataContainerListType.php rename to src/List/Driver/GenericDataContainerListDriver.php index f3f40128..6e6968a8 100644 --- a/src/ListType/GenericDataContainerListType.php +++ b/src/List/Driver/GenericDataContainerListDriver.php @@ -2,22 +2,26 @@ declare(strict_types=1); -namespace HeimrichHannot\FlareBundle\ListType; +namespace HeimrichHannot\FlareBundle\List\Driver; +use Contao\Controller; use Contao\CoreBundle\DataContainer\PaletteManipulator; -use Contao\CoreBundle\String\HtmlDecoder; -use Contao\CoreBundle\String\SimpleTokenParser; use Contao\DataContainer; use Contao\Message; -use HeimrichHannot\FlareBundle\Contract\Config\PaletteConfig; -use HeimrichHannot\FlareBundle\Contract\ListType\DataContainerContract; -use HeimrichHannot\FlareBundle\DependencyInjection\Attribute\AsListType; +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\ListModel; use Symfony\Contracts\Translation\TranslatorInterface; -#[AsListType(type: self::TYPE, palette: self::DEFAULT_PALETTE)] -class GenericDataContainerListType extends AbstractListType implements DataContainerContract +#[AsListDriver(type: self::TYPE)] +class GenericDataContainerListDriver extends AbstractListDriver implements OnSubmitDcContract { public const TYPE = 'flare_generic_dc'; public const DEFAULT_PALETTE = <<<'PALETTE' @@ -26,32 +30,27 @@ class GenericDataContainerListType extends AbstractListType implements DataConta PALETTE; public function __construct( - private readonly HtmlDecoder $htmlDecoder, - private readonly SimpleTokenParser $simpleTokenParser, private readonly TranslatorInterface $trans, + private readonly ListContainer $listContainer, ) {} - protected function getHtmlDecoder(): HtmlDecoder + public function resolveDcOnSubmit(array $row, DataContainer $dc): string { - return $this->htmlDecoder; - } - - protected function getSimpleTokenParser(): SimpleTokenParser - { - return $this->simpleTokenParser; + return $row['dc'] ?? ''; } - public function getDataContainerName(array $row, DataContainer $dc): string + protected function transformListModel(ConfigBuilder $config, ListModel $model): void { - return $row['dc'] ?? ''; + $config->set('genericPageMeta', true); } - public function getPalette(PaletteConfig $config): ?string + public function buildDca(DcaBuilderInterface $dca, DcaContext $context): void { - $listModel = $config->getListModel(); + $listModel = $context->listModel; if (!$listModel->hasParent) { - return null; + $dca->palette(self::DEFAULT_PALETTE); + return; } $pm = PaletteManipulator::create() @@ -59,12 +58,11 @@ public function getPalette(PaletteConfig $config): ?string ->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) { @@ -92,6 +90,26 @@ public function getPalette(PaletteConfig $config): ?string $listModel->whichPtable_disableAutoOption(); } - return $pm->applyToString(self::DEFAULT_PALETTE); + $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')); } -} \ No newline at end of file +} diff --git a/src/List/Driver/ListDriverInterface.php b/src/List/Driver/ListDriverInterface.php new file mode 100644 index 00000000..c25daa97 --- /dev/null +++ b/src/List/Driver/ListDriverInterface.php @@ -0,0 +1,22 @@ + $config Canonical, resolved list config. + * @param array $attributes The registered attributes of the list driver. + */ + public function resolveDcTable(string $type, array $config, array $attributes): string; +} diff --git a/src/List/Driver/NewsListDriver.php b/src/List/Driver/NewsListDriver.php new file mode 100644 index 00000000..c5309c94 --- /dev/null +++ b/src/List/Driver/NewsListDriver.php @@ -0,0 +1,66 @@ +palette('{filter_legend},'); + } + + public function buildTableRegistry(TableAliasRegistry $registry): void + { + $registry->registerJoin(new SqlJoinStruct( + fromAlias: TableAliasRegistry::ALIAS_MAIN, + joinType: JoinTypeEnum::INNER, + table: 'tl_news_archive', + joinAlias: self::ALIAS_ARCHIVE, + condition: $registry->makeJoinOn(self::ALIAS_ARCHIVE, 'id', TableAliasRegistry::ALIAS_MAIN, 'pid') + )); + } + + public function buildList(ListSpecBuilder $builder): void + { + if ($builder->hasFilterInstance(PublishedFilterElement::class)) { + return; + } + + $builder->addFilter($this->filterFactory->create( + element: PublishedFilterElement::TYPE, + config: [ + 'intrinsic' => true, + 'published_field' => 'published', + 'start_field' => 'start', + 'stop_field' => 'stop', + 'invert' => false, + ], + )); + } +} diff --git a/src/List/Factory/ListSpecBuilderFactory.php b/src/List/Factory/ListSpecBuilderFactory.php new file mode 100644 index 00000000..e5f069d3 --- /dev/null +++ b/src/List/Factory/ListSpecBuilderFactory.php @@ -0,0 +1,60 @@ +listDriverResolver, + specFactory: $this->specFactory, + eventDispatcher: $this->eventDispatcher, + driver: $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, + 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/List/Factory/ListSpecFactory.php b/src/List/Factory/ListSpecFactory.php new file mode 100644 index 00000000..adffab34 --- /dev/null +++ b/src/List/Factory/ListSpecFactory.php @@ -0,0 +1,125 @@ + $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( + ResolvedListDriver|ListDriverInterface|string $driver, + array $filters = [], + array $config = [], + ?string $source = null, + ): ListSpec { + $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: $resolved->driver, + type: $resolved->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, + ResolvedListDriver|ListDriverInterface|string|null $driver = null, + array $filters = [], + array $config = [], + ?string $source = null, + ): ListSpec { + $driver ??= $listModel->getListDriverType(); + $resolved = $this->listDriverResolver->resolve($driver); + + $configBuilder = new ConfigBuilder(); + + BaseListOptions::transform($configBuilder, $listModel); + + $transformed = $this->transformerResolver->transform($resolved->driver, $resolved->type, $listModel); + + foreach ($transformed ?? [] as $key => $value) { + $configBuilder->set($key, $value); + } + + foreach ($config as $key => $value) { + $configBuilder->set($key, $value); + } + + $finalConfig = $this->listOptionsResolver->resolve($resolved->driver, $configBuilder->all(), $source); + + $dc = $this->resolveDataContainer($finalConfig, $resolved->driver, $resolved->type, $source); + + return new ListSpec( + driver: $resolved->driver, + type: $resolved->type, + dc: $dc, + filters: $filters, + config: $finalConfig, + source: $source, + ); + } + + /** + * @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 new file mode 100644 index 00000000..89ae9741 --- /dev/null +++ b/src/List/ListSpec.php @@ -0,0 +1,118 @@ + $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, + ) {} + + /** + * Adds a filter. The key defaults to the filter's alias; alias-less filters receive a generated key. + */ + public function withFilter(Filter $filter): self + { + return $this->withFilters([...$this->filters, $filter]); + } + + public function withoutFilter(Filter|string $filter_or_class_or_alias): self + { + $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); + } + + /** + * @param array $filters + */ + 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 class-string $class + */ + public function hasFilterInstance(string $class): bool + { + foreach ($this->filters as $filter) + { + if ($filter->element instanceof $class) { + return true; + } + } + + return false; + } + + public function getAutoItemField(): string + { + $dc = $this->dc; + + return DcaHelper::tryGetColumnName( + $dc, + (string) ($this->config['fieldAutoItem'] ?? ''), + DcaHelper::tryGetColumnName($dc, 'alias', 'id'), + ); + } + + 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 new file mode 100644 index 00000000..5752fa5d --- /dev/null +++ b/src/List/ListSpecBuilder.php @@ -0,0 +1,151 @@ + + */ + private array $filters = []; + + /** + * @var array + */ + private array $overrides = []; + + private int $generatedFilterKeys = 0; + + public function __construct( + private readonly ListDriverResolver $listDriverResolver, + 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|string + { + return $this->driver; + } + + public function getModel(): ?ListModel + { + return $this->model; + } + + public function getSource(): ?string + { + return $this->source; + } + + /** + * Sets a canonical config value, overriding base translation and driver 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 getFilter(string $key): ?Filter + { + return $this->filters[$key] ?? null; + } + + /** + * @param class-string $class + */ + public function hasFilterInstance(string $class): bool + { + foreach ($this->filters as $filter) + { + if ($filter->element instanceof $class) { + return true; + } + } + + return false; + } + + /** + * @throws FlareException If the resulting config does not satisfy the schema or no data + * container can be determined. + */ + public function build(): ListSpec + { + $driver = $this->listDriverResolver->resolve($this->driver); + if ($driver->driver instanceof BuildListContract) { + $driver->driver->buildList($this); + } + + $this->eventDispatcher->dispatch(new ListBuildEvent($this)); + + if ($this->model) + { + 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: $this->overrides, + source: $this->source, + ); + } +} diff --git a/src/List/ListSpecBuilderInterface.php b/src/List/ListSpecBuilderInterface.php new file mode 100644 index 00000000..fcdb6482 --- /dev/null +++ b/src/List/ListSpecBuilderInterface.php @@ -0,0 +1,36 @@ + $class + */ + public function hasFilterInstance(string $class): bool; + + public function getFilters(): array; + + public function getFilter(string $key): ?Filter; + + public function build(): ListSpec; +} diff --git a/src/List/ResolvedListDriver.php b/src/List/ResolvedListDriver.php new file mode 100644 index 00000000..72dc8a14 --- /dev/null +++ b/src/List/ResolvedListDriver.php @@ -0,0 +1,15 @@ +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/src/List/Resolver/ListOptionsResolver.php b/src/List/Resolver/ListOptionsResolver.php new file mode 100644 index 00000000..cda557ae --- /dev/null +++ b/src/List/Resolver/ListOptionsResolver.php @@ -0,0 +1,62 @@ + $config + * + * @return array + * + * @throws FlareException If the config does not satisfy the schema. + */ + public function resolve(?ListDriverInterface $driverService, array $config, ?string $source = null): array + { + $configure = static function (OptionsResolver $resolver) use ($driverService): void { + BaseListOptions::configureOptions($resolver); + + if ($driverService instanceof OptionsContract) { + $driverService->configureOptions($resolver); + } + }; + + $driverClass = $driverService ? $driverService::class : null; + + try + { + return $this->schemaResolver->resolve((string) $driverClass, $configure, $config); + } + catch (\Throwable $e) + { + throw new FlareException( + \sprintf( + '[FLARE] Invalid list config%s: %s', + $driverService ? ' for list type "' . $driverService::class . '"' : '', + $e->getMessage(), + ), + previous: $e, + method: ($driverClass ?? BaseListOptions::class) . '::configureOptions', + source: $source, + ); + } + } +} diff --git a/src/List/Resolver/ListTransformerResolver.php b/src/List/Resolver/ListTransformerResolver.php new file mode 100644 index 00000000..42c2d620 --- /dev/null +++ b/src/List/Resolver/ListTransformerResolver.php @@ -0,0 +1,58 @@ + + */ + private array $resolvers = []; + + public function __construct( + private readonly EventDispatcherInterface $eventDispatcher, + ) {} + + /** + * @return array|null Canonical config values, or null when no transformer matches the source. + */ + public function transform(ListDriverInterface $driver, string $type, object $source): ?array + { + $cacheKey = \sprintf('%s@%s', $type, $driver::class); + + if (!isset($this->resolvers[$cacheKey])) + { + $resolver = new TransformerResolver(); + + if ($driver instanceof TransformerContract) { + $driver->configureTransformers($resolver); + } + + $this->eventDispatcher->dispatch(new ListTransformerEvent($resolver, $driver, $type)); + + $this->resolvers[$cacheKey] = $resolver; + } + + if (!$transformer = $this->resolvers[$cacheKey]->resolve($source)) { + return null; + } + + $transformer($config = new ConfigBuilder(), $source); + + return $config->all(); + } +} diff --git a/src/ListType/AbstractListType.php b/src/ListType/AbstractListType.php deleted file mode 100644 index 76cbef03..00000000 --- a/src/ListType/AbstractListType.php +++ /dev/null @@ -1,24 +0,0 @@ -registerJoin(new SqlJoinStruct( - fromAlias: TableAliasRegistry::ALIAS_MAIN, - joinType: JoinTypeEnum::INNER, - table: 'tl_news_archive', - joinAlias: self::ALIAS_ARCHIVE, - condition: $registry->makeJoinOn(self::ALIAS_ARCHIVE, 'id', TableAliasRegistry::ALIAS_MAIN, 'pid') - )); - } - - #[AsEventListener(priority: 200)] - public function onListSpecificationCreated(ListSpecificationCreatedEvent $config): void - { - if ($config->listSpecification->type !== self::TYPE) { - return; - } - - $filters = $config->listSpecification->getFilters(); - - if (!$filters->hasType(PublishedElement::TYPE)) { - $filters->add(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/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/FilterModel.php b/src/Model/FilterModel.php index 1336431c..60903ae1 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; @@ -27,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; } @@ -64,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/Model/ListModel.php b/src/Model/ListModel.php index 979a8f8b..a1c34a36 100644 --- a/src/Model/ListModel.php +++ b/src/Model/ListModel.php @@ -7,42 +7,25 @@ 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 - { - return (string) $this->id; - } - - public function getListType(): string + public function getListDriverType(): ?string { return $this->type; } - public function getListTable(): string - { - return $this->dc; - } - - public function getListData(): array - { - return $this->arrData; - } - - public function getListProperty(string $name): mixed + public function getAutoItemField(): string { - return $this->{$name}; + return $this->fieldAutoItem ?: DcaHelper::tryGetColumnName($this->dc, 'alias', 'id'); } -} \ No newline at end of file +} 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/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/Query/Executor/FilterExecutor.php b/src/Query/Executor/FilterExecutor.php deleted file mode 100644 index 0fa014bb..00000000 --- a/src/Query/Executor/FilterExecutor.php +++ /dev/null @@ -1,183 +0,0 @@ -list; - $context = $options->context; - - $filterQueryBuilders = []; - - /** - * @var int|string $key - * @var FilterDefinition $filter - */ - foreach ($list->getFilters()->all() as $key => $filter) - { - $invocation = new FilterInvocation( - filter: $filter, - list: $list, - context: $context, - value: $options->filterValues[$key] ?? null, - ); - - if (!$filterQueryBuilder = $this->invokeFilter($invocation)) { - continue; - } - - $filterQueryBuilders[] = $filterQueryBuilder; - } - - return $filterQueryBuilders; - } - - /** - * @throws AbortFilteringException - * @throws FilterException - * @throws FlareException - */ - public function invokeFilter(FilterInvocation $invocation): ?FilterQueryBuilder - { - if (!Str::isValidSqlName($table = $invocation->list->dc)) - { - throw new FlareException(\sprintf( - '[FLARE] ListSpecification data container cannot be used as SQL table identifier: "%s"', - $table - ), method: __METHOD__); - } - - $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; - } - - $event = $this->eventDispatcher->dispatch(new FilterElementInvokingEvent( - invocation: $invocation, - context: $context, - invoker: $invoker, - shouldInvoke: true, - )); - - if (!$event->shouldInvoke()) { - return null; - } - - $invoker = $event->getInvoker(); - - $targetAlias = TableAliasRegistry::ALIAS_MAIN; - if ($filterElementDescriptor->isTargeted() || $filter->isTargetingForced()) { - $targetAlias = $filter->getTargetAlias() ?: TableAliasRegistry::ALIAS_MAIN; - } - - $filterQueryBuilder = $this->filterQueryBuilderFactory->create($targetAlias); - - try - { - $invoker($invocation, $filterQueryBuilder); - } - catch (AbortFilteringException $e) - { - throw $e; - } - catch (FilterException $e) - { - throw $this->createCallbackException($e, $filter, $invoker); - } - catch (\Throwable $e) - { - throw new FilterException($e->getMessage(), code: $e->getCode(), previous: $e, method: __METHOD__); - } - - $this->eventDispatcher->dispatch(new FilterElementInvokedEvent($invocation, $filterQueryBuilder)); - - return $filterQueryBuilder; - } - - private function createCallbackException( - FilterException $e, - FilterDefinition $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; - } - - 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', - ); - } -} diff --git a/src/Query/Executor/ListQueryDirector.php b/src/Query/Executor/ListQueryDirector.php index dfbfd53c..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\FilterQuery; -use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; +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 FilterQueryBuilder[] $filterQueryBuilders - * @return FilterQuery[] + * @param FilterConditionsBuilder[] $filterQueryBuilders + * @return FilterConditions[] */ - public function buildFilterQueries(array $filterQueryBuilders): array + private function buildFilterConditions(array $filterQueryBuilders): array { $filterQueries = []; 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/Factory/ListExecutionContextFactory.php b/src/Query/Factory/ListExecutionContextFactory.php index 5d86277c..633eb9c4 100644 --- a/src/Query/Factory/ListExecutionContextFactory.php +++ b/src/Query/Factory/ListExecutionContextFactory.php @@ -4,37 +4,34 @@ namespace HeimrichHannot\FlareBundle\Query\Factory; -use HeimrichHannot\FlareBundle\Contract\ListType\ConfigureQueryContract; +use HeimrichHannot\FlareBundle\Contract\ListDriver\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\Specification\ListSpecification; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; readonly class ListExecutionContextFactory { public function __construct( - private ListTypeRegistry $listTypeRegistry, private EventDispatcherInterface $eventDispatcher, ) {} /** * @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__); - } + $driver = $list->driver; - if (!$mainTable = $list->dc ?? $listTypeDescriptor->getDataContainer()) { - throw new FlareException('No data container table set.', method: __METHOD__); + if (!$mainTable = $list->dc) + { + throw new FlareException( + \sprintf('Failed to evaluate data container table of list "%s".', $list->source ?? \get_class($driver)), + method: __METHOD__, + ); } $registry = new TableAliasRegistry(); @@ -46,14 +43,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); + if ($driver instanceof BuildQueryContract) { + $driver->buildTableRegistry($registry); + $driver->buildBaseQuery($struct); } $this->eventDispatcher->dispatch(new QueryBaseInitializedEvent( - listSpecification: $list, + list: $list, registry: $registry, struct: $struct, )); @@ -65,4 +61,4 @@ public function create(ListSpecification $list): ListExecutionContext return new ListExecutionContext($registry, $struct); } -} \ 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 93% rename from src/Query/FilterQueryBuilder.php rename to src/Query/FilterConditionsBuilder.php index 5ef813d8..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 = []; @@ -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); @@ -234,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)) { @@ -280,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); } -} \ No newline at end of file +} diff --git a/src/Query/ListQueryConfig.php b/src/Query/ListQueryConfig.php index fd8a7dea..b542b2a8 100644 --- a/src/Query/ListQueryConfig.php +++ b/src/Query/ListQueryConfig.php @@ -5,12 +5,17 @@ namespace HeimrichHannot\FlareBundle\Query; use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\Filter\FilterData; +use HeimrichHannot\FlareBundle\List\ListSpec; readonly class ListQueryConfig { + /** + * @param array $filterValues + * @param array $attributes + */ public function __construct( - public ListSpecification $list, + public ListSpec $list, public ContextInterface $context, public array $filterValues, public bool $isCounting = false, @@ -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, @@ -33,4 +42,4 @@ public function with( attributes: $attributes ?? $this->attributes, ); } -} \ No newline at end of file +} diff --git a/src/Reader/Factory/ReaderRequestAttributeFactory.php b/src/Reader/Factory/ReaderRequestAttributeFactory.php index d4cfb379..ecb79747 100644 --- a/src/Reader/Factory/ReaderRequestAttributeFactory.php +++ b/src/Reader/Factory/ReaderRequestAttributeFactory.php @@ -7,13 +7,13 @@ use Contao\Model; use HeimrichHannot\FlareBundle\Model\ListModel; use HeimrichHannot\FlareBundle\Reader\ReaderRequestAttribute; -use HeimrichHannot\FlareBundle\Specification\Factory\ListSpecificationFactory; final readonly class ReaderRequestAttributeFactory { - public function __construct( - private ListSpecificationFactory $listSpecificationFactory, - ) {} + public function createFromModels(Model $displayModel, ListModel $listModel): ReaderRequestAttribute + { + return new ReaderRequestAttribute($displayModel, $listModel); + } public function createFromData(array $data): ?ReaderRequestAttribute { @@ -30,16 +30,18 @@ public function createFromData(array $data): ?ReaderRequestAttribute return null; } - /** @var Model $model */ - $model = $modelClass::findByPk($modelId); + if ($modelClass::getTable() !== $modelTable) { + return null; + } + + /** @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->listSpecificationFactory->create($listModel); - - return new ReaderRequestAttribute($model, $spec); + return new ReaderRequestAttribute($displayModel, $listModel); } -} \ No newline at end of file +} diff --git a/src/Reader/ReaderRequestAttribute.php b/src/Reader/ReaderRequestAttribute.php index 5e9c72bf..18f6466f 100644 --- a/src/Reader/ReaderRequestAttribute.php +++ b/src/Reader/ReaderRequestAttribute.php @@ -6,34 +6,21 @@ use Contao\Model; use HeimrichHannot\FlareBundle\Model\ListModel; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; readonly class ReaderRequestAttribute { public function __construct( - private Model $model, - private ListSpecification $listSpecification, + public Model $displayModel, + public ListModel $listModel, ) {} - public function getModel(): Model - { - return $this->model; - } - - public function getListSpecification(): ListSpecification - { - return $this->listSpecification; - } - 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, + '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 +} 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; } } diff --git a/src/Registry/Descriptor/FilterElementDescriptor.php b/src/Registry/Descriptor/FilterElementDescriptor.php deleted file mode 100644 index 5dc17d2c..00000000 --- a/src/Registry/Descriptor/FilterElementDescriptor.php +++ /dev/null @@ -1,99 +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 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 - { - return !$this->hasFormType(); - } -} \ 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 deleted file mode 100644 index ce8370e1..00000000 --- a/src/Registry/Descriptor/ListTypeDescriptor.php +++ /dev/null @@ -1,75 +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; - } - - 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/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/FilterCollectorRegistry.php b/src/Registry/FilterCollectorRegistry.php deleted file mode 100644 index 8de5c1d3..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; - } -} \ No newline at end of file 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/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/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/FilterPredicateRegistry.php b/src/Registry/FilterPredicateRegistry.php new file mode 100644 index 00000000..56b3f9fc --- /dev/null +++ b/src/Registry/FilterPredicateRegistry.php @@ -0,0 +1,59 @@ +, PredicateInterface> + */ + private array $types; + + public function __construct( + #[TaggedIterator(PredicateInterface::FLARE_FILTER_PREDICATE_TAG)] + private readonly iterable $filterTypes, + ) {} + + /** + * @param class-string $class + */ + public function get(string $class): ?PredicateInterface + { + return $this->resolve()[$class] ?? null; + } + + /** + * @return array, PredicateInterface> + */ + 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 PredicateInterface) { + throw new \LogicException(\sprintf( + 'Service "%s" is tagged "%s" but does not implement %s.', + $filterType::class, + PredicateInterface::FLARE_FILTER_PREDICATE_TAG, + PredicateInterface::class, + )); + } + + $this->types[$filterType::class] = $filterType; + } + } + + return $this->types; + } +} 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 @@ - + */ + 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 $type !== null ? ($this->drivers[$type]['attribute'] ?? null) : null; + } + + public function isInline(string $type): bool + { + 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]); + + return; + } + + $this->typesByClass[$class] = $types; + } +} diff --git a/src/Registry/ListTypeRegistry.php b/src/Registry/ListTypeRegistry.php deleted file mode 100644 index e4a6de0f..00000000 --- a/src/Registry/ListTypeRegistry.php +++ /dev/null @@ -1,32 +0,0 @@ - $projectors */ public function __construct( - #[TaggedIterator('flare.projector')] + #[TaggedIterator(ProjectorInterface::FLARE_PROJECTOR_TAG)] private iterable $projectors, ) {} @@ -26,9 +26,9 @@ public function __construct( * @throws FlareException If no projector is found. */ public function getProjectorFor( - ListSpecification $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,9 +56,9 @@ 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; } -} \ No newline at end of file +} diff --git a/src/Sort/Factory/SortOrderSequenceFactory.php b/src/Sort/Factory/SortOrderSequenceFactory.php index 83d2185a..dcd1f619 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\List\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; } @@ -43,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; @@ -71,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 +} 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/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 @@ -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/FilterDefinitionFactory.php b/src/Specification/Factory/FilterDefinitionFactory.php deleted file mode 100644 index e4dca038..00000000 --- a/src/Specification/Factory/FilterDefinitionFactory.php +++ /dev/null @@ -1,34 +0,0 @@ -getFilterType(), - intrinsic: $dataSource->isFilterIntrinsic(), - alias: $dataSource->getFilterFormName(), - targetAlias: $dataSource->getFilterTargetAlias(), - dataSource: $dataSource, - ); - - $definition->setProperties($dataSource->getFilterData()); - - $event = $this->eventDispatcher->dispatch(new FilterDefinitionCreatedEvent($definition)); - - return $event->filterDefinition; - } -} \ 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 1ab56217..00000000 --- a/src/Specification/Factory/ListSpecificationFactory.php +++ /dev/null @@ -1,54 +0,0 @@ -collectFilters($dataSource); - - $specification = new ListSpecification( - type: $dataSource->getListType(), - dc: $dataSource->getListTable(), - dataSource: $dataSource, - filters: $filterCollection, - ); - - $specification->setProperties($dataSource->getListData()); - - $event = $this->eventDispatcher->dispatch(new ListSpecificationCreatedEvent($specification)); - - return $event->listSpecification; - } - - private function collectFilters(ListDataSourceInterface $dataSource): FilterDefinitionCollection - { - $collector = $this->filterCollectors->match($dataSource); - - if (!$collector) { - return new FilterDefinitionCollection(); - } - - return $collector->collect($dataSource) ?? new FilterDefinitionCollection(); - } -} \ No newline at end of file diff --git a/src/Specification/FilterDefinition.php b/src/Specification/FilterDefinition.php deleted file mode 100644 index 6aa984e2..00000000 --- a/src/Specification/FilterDefinition.php +++ /dev/null @@ -1,163 +0,0 @@ -setAlias($alias); - } - } - - public function getType(): string - { - return $this->type; - } - - public function setType(string $type): static - { - $this->type = $type; - return $this; - } - - 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', 'intrinsic' => true, - 'alias', 'targetAlias', 'target_alias', 'sourceFilterModel' => $this->__get($name) !== null, - default => $this->issetProperty($name), - }; - } - - public function __set(string $name, mixed $value): void - { - match ($name) { - 'type' => $this->setType($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' => $this->getType(), - 'intrinsic' => $this->isIntrinsic(), - 'targetAlias', 'target_alias' => $this->getTargetAlias(), - 'dataSource', 'sourceFilterModel' => $this->getDataSource(), - default => $this->getProperty($name), - }; - } - - public function getRow(): array - { - return \array_merge($this->getProperties(), [ - 'type' => $this->type, - '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/ListSpecification.php b/src/Specification/ListSpecification.php deleted file mode 100644 index ae0b06b9..00000000 --- a/src/Specification/ListSpecification.php +++ /dev/null @@ -1,66 +0,0 @@ -filters ??= new FilterDefinitionCollection(); - } - - public function getDataSource(): ?ListDataSourceInterface - { - return $this->dataSource; - } - - public function setDataSource(?ListDataSourceInterface $dataSource): static - { - $this->dataSource = $dataSource; - return $this; - } - - public function getFilters(): FilterDefinitionCollection - { - return $this->filters; - } - - public function setFilters(FilterDefinitionCollection $filters): void - { - $this->filters = $filters; - } - - public function hash(): string - { - return \sha1(\serialize([ - $this->type, - $this->dc, - $this->filters->hash(), - 'model' => $this->dataSource ? [ - $this->dataSource->getListIdentifier(), - $this->dataSource->getListType(), - $this->dataSource->getListTable(), - ] : 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..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 b61c447b..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\List\ListSpec; use HeimrichHannot\FlareBundle\Registry\ProjectorRegistry; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; use HeimrichHannot\FlareBundle\Util\CallableWrapper; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Twig\Extension\RuntimeExtensionInterface; @@ -28,29 +27,11 @@ 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); } - /** - * @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(); @@ -121,7 +102,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) { @@ -157,4 +138,4 @@ private static function once(callable $callback): callable return $result; }; } -} \ No newline at end of file +} 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/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/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; diff --git a/src/Util/EntryCache.php b/src/Util/EntryCache.php new file mode 100644 index 00000000..4a64d632 --- /dev/null +++ b/src/Util/EntryCache.php @@ -0,0 +1,47 @@ +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); + } +} 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/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; + } +} 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..b5041a1e 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 @@ -104,6 +107,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)) { @@ -152,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; } @@ -172,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; } @@ -183,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/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/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/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/Config/TransformerResolverTest.php b/tests/Config/TransformerResolverTest.php new file mode 100644 index 00000000..32d22c19 --- /dev/null +++ b/tests/Config/TransformerResolverTest.php @@ -0,0 +1,78 @@ +for(SourceA::class, $transformer); + + self::assertSame($transformers, $result); + self::assertSame($transformer, $transformers->resolve(new SourceA())); + } + + public function testResolvesSubclassSources(): void + { + $transformers = new TransformerResolver(); + $transformer = static function (ConfigBuilder $config, object $source): void {}; + + $transformers->for(SourceA::class, $transformer); + + self::assertSame($transformer, $transformers->resolve(new SourceASub())); + } + + public function testReRegistrationOverrides(): void + { + $transformers = new TransformerResolver(); + $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); + + self::assertSame($second, $transformers->resolve(new SourceA())); + } + + public function testReturnsNullWithoutMatch(): void + { + $transformers = new TransformerResolver(); + $transformers->for(SourceA::class, static function (ConfigBuilder $config, object $source): void {}); + + self::assertNull($transformers->resolve(new SourceB())); + } + + public function testExactClassMatchWinsOverEarlierBaseClassRegistration(): void + { + $transformers = new TransformerResolver(); + $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); + + self::assertSame($specific, $transformers->resolve(new SourceASub())); + self::assertSame($base, $transformers->resolve(new SourceA())); + } +} + +class SourceA +{ +} + +final class SourceASub extends SourceA +{ +} + +final class SourceB +{ +} diff --git a/tests/DependencyInjection/Compiler/RegisterFilterFormsPassTest.php b/tests/DependencyInjection/Compiler/RegisterFilterFormsPassTest.php new file mode 100644 index 00000000..fc6d8afd --- /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 $form, 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/Engine/Projector/InteractiveProjectorTest.php b/tests/Engine/Projector/InteractiveProjectorTest.php new file mode 100644 index 00000000..ecf31e82 --- /dev/null +++ b/tests/Engine/Projector/InteractiveProjectorTest.php @@ -0,0 +1,176 @@ + + */ + private function collect(ListSpec $list, FormInterface $form): array + { + $projector = new class extends InteractiveProjector { + public function __construct() {} + + public function collect(ListSpec $list, FormInterface $form): array + { + return $this->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 + { + $driver = new class implements ListDriverInterface { + public function resolveDcTable(string $type, array $config, array $attributes): string + { + return (string) ($config['dc'] ?? ''); + } + }; + + $element = new class implements FilterElementInterface { + public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} + + public function buildContext( + FilterContextBuilder $builder, + ?ValueInterface $value, + ): void {} + }; + + return new ListSpec(driver: $driver, type: 'test_list', dc: 'tl_test', filters: [ + $key => new Filter(element: $element, type: 'test_element', alias: $alias), + ]); + } + + public function testFlatSubmittedValueBecomesSingleData(): void + { + $root = $this->createRootBuilder(); + $this->addFlatChild($root, 'suche'); + $form = $root->getForm(); + + $form->submit(['suche' => 'term']); + + $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 + { + $root = $this->createRootBuilder(); + $this->addFlatChild($root, 'suche', ['data' => 'preset']); + $form = $root->getForm(); + + $data = $this->collect($this->listWithFilter('sucheKey', 'suche'), $form); + + $this->assertSame(['sucheKey'], \array_keys($data)); + $this->assertSame('preset', $data['sucheKey']->getSingleValue()); + } + + 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' => '']); + + $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 + { + $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']]); + + $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 + { + $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(); + + $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 + { + $form = $this->createRootBuilder()->getForm(); + + $this->assertSame([], $this->collect($this->listWithFilter('key', 'missing'), $form)); + } +} 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..673e0068 --- /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.form.{$name}.build", + static function () use (&$names, $name): void { + $names[] = "flare.form.{$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 FormHarnessListener($dispatcher); + $listener->onFormHarnessBuildEvent(new FormHarnessBuildEvent( + list: new ListSpec(driver: $driver, type: 'test_list', dc: 'tl_test'), + formName: $formName, + formBuilder: $formBuilder, + )); + + return $names; + } +} diff --git a/tests/EventListener/NamedDispatch/ListBuildListenerTest.php b/tests/EventListener/NamedDispatch/ListBuildListenerTest.php new file mode 100644 index 00000000..bd5d6f1c --- /dev/null +++ b/tests/EventListener/NamedDispatch/ListBuildListenerTest.php @@ -0,0 +1,74 @@ +dispatchedNames('a')); + } + + public function testInstanceDriverTriggersNoNamedDispatch(): void + { + $driver = new class extends AbstractListDriver {}; + + self::assertSame([], $this->dispatchedNames($driver)); + } + + /** + * @return list + */ + private function dispatchedNames(ListDriverInterface|string $driver): 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"; + }, + ); + } + + $registry = new ListDriverRegistry(); + $listDriverResolver = new ListDriverResolver($registry); + + $builder = new ListSpecBuilder( + listDriverResolver: $listDriverResolver, + specFactory: new ListSpecFactory( + $registry, + new ListOptionsResolver(new SchemaResolver()), + new ListTransformerResolver($dispatcher), + $listDriverResolver, + ), + eventDispatcher: $dispatcher, + driver: $driver, + ); + + $listener = new ListBuildListener($dispatcher); + $listener(new ListBuildEvent($builder)); + + return $names; + } +} diff --git a/tests/Filter/Element/ArchiveFilterElementTest.php b/tests/Filter/Element/ArchiveFilterElementTest.php new file mode 100644 index 00000000..7d708eb9 --- /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 TransformerResolver(); + $element->configureTransformers($transformers); + + $transformer = $transformers->resolve($model = new FilterModelStub($row)); + self::assertNotNull($transformer); + + $transformer($config = new ConfigBuilder(), $model); + + return $config->all(); + } +} diff --git a/tests/Filter/Element/SimpleEquationFilterElementTest.php b/tests/Filter/Element/SimpleEquationFilterElementTest.php new file mode 100644 index 00000000..09fbfa5b --- /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 TransformerResolver(); + $element->configureTransformers($transformers); + + $transformer = $transformers->resolve($model = new FilterModelStub($row)); + self::assertNotNull($transformer); + + $transformer($config = new ConfigBuilder(), $model); + + return $config->all(); + } +} + +final class FilterModelStub extends FilterModel +{ + public function __construct(array $row = []) + { + $this->arrData = $row; + } +} diff --git a/tests/Filter/FilterBuilderTest.php b/tests/Filter/FilterBuilderTest.php new file mode 100644 index 00000000..2fd4831a --- /dev/null +++ b/tests/Filter/FilterBuilderTest.php @@ -0,0 +1,97 @@ +get(TestPredicate::class)); + self::assertSame([TestPredicate::class => $type], $registry->all()); + self::assertNull($registry->get(UnknownPredicate::class)); + } + + public function testBuilderResolvesOptionsAndRecordsTargetedCalls(): void + { + $builder = new FormulaBuilder( + new FilterPredicateRegistry([new TestPredicate()]), + 'main', + ); + + $builder + ->add(TestPredicate::class, ['value' => 'first']) + ->add(TestPredicate::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 FormulaBuilder(new FilterPredicateRegistry([]), 'main'); + + $this->expectException(FilterException::class); + $builder->add(TestPredicate::class, ['value' => 'test']); + } + + public function testBuilderLetsOptionsResolverValidateRequiredOptions(): void + { + $builder = new FormulaBuilder( + new FilterPredicateRegistry([new TestPredicate()]), + 'main', + ); + + $this->expectException(MissingOptionsException::class); + $builder->add(TestPredicate::class); + } + + public function testBuilderAbortThrowsAbortFilteringException(): void + { + $builder = new FormulaBuilder(new FilterPredicateRegistry([]), 'main'); + + $this->expectException(AbortFilteringException::class); + $builder->abort(); + } +} + +final class TestPredicate extends AbstractPredicate +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->define('value')->required()->allowedTypes('string'); + $resolver->define('enabled')->default(false)->allowedTypes('bool'); + } + + public function buildConditions(FilterConditionsBuilder $builder, array $options): void + { + } +} + +final class UnknownPredicate extends AbstractPredicate +{ + public function buildConditions(FilterConditionsBuilder $builder, array $options): 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 new file mode 100644 index 00000000..e15dcb13 --- /dev/null +++ b/tests/Filter/FilterFactoryTest.php @@ -0,0 +1,79 @@ +add($element, null, 'my_element'); + + $filter = self::factory($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 testCreatesFromInstanceUsingItsClassNameAsType(): void + { + $element = self::element(); + + $filter = self::factory()->create(element: $element); + + self::assertSame($element, $filter->element); + self::assertSame(\get_class($element), $filter->type); + } + + public function testThrowsForUnknownTypeAlias(): void + { + $this->expectException(FlareException::class); + $this->expectExceptionMessage('Filter element type "missing" not found'); + + self::factory()->create(element: 'missing'); + } +} diff --git a/tests/Filter/FilterFormBuilderTest.php b/tests/Filter/FilterFormBuilderTest.php new file mode 100644 index 00000000..4bb1771c --- /dev/null +++ b/tests/Filter/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/Filter/FilterOptionsResolverTest.php b/tests/Filter/FilterOptionsResolverTest.php new file mode 100644 index 00000000..1fcf94a6 --- /dev/null +++ b/tests/Filter/FilterOptionsResolverTest.php @@ -0,0 +1,90 @@ +resolve(new Filter(element: $element, type: 'test', config: ['field' => 'title'])); + + self::assertSame('title', $config['field']); + self::assertFalse($config['intrinsic']); + } + + public function testReturnsOptionsVerbatimWithoutOptionsContract(): void + { + $resolver = new FilterOptionsResolver(new SchemaResolver()); + $element = new PlainElement(); + + $config = ['anything' => 'goes', 'unvalidated' => true]; + + self::assertSame($config, $resolver->resolve(new Filter(element: $element, type: 'test', config: $config))); + } + + public function testWrapsSchemaViolationsInFilterException(): void + { + $resolver = new FilterOptionsResolver(new SchemaResolver()); + $element = new ElementConfigAwareElement(); + $filter = new Filter(element: $element, type: 'test', config: ['unknown_key' => 1], source: 'tl_flare_filter.42'); + + try + { + $resolver->resolve($filter); + 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, OptionsContract +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->define('intrinsic')->default(false)->allowedTypes('bool'); + $resolver->define('field')->default(null)->allowedTypes('string', 'null'); + } + + public function buildForm(FormBuilderInterface $builder, FilterContext $context): void + { + } + + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void + { + } +} + +final class PlainElement implements FilterElementInterface +{ + public function buildForm(FormBuilderInterface $builder, FilterContext $context): void + { + } + + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void + { + } +} diff --git a/tests/Filter/FilterSetFactoryTest.php b/tests/Filter/FilterSetFactoryTest.php new file mode 100644 index 00000000..db54f37e --- /dev/null +++ b/tests/Filter/FilterSetFactoryTest.php @@ -0,0 +1,370 @@ +eventDispatcher = new EventDispatcher(); + } + + 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. + $formFactory = Forms::createFormFactoryBuilder() + ->addExtension(new CsrfExtension(new CsrfTokenManager())) + ->getFormFactory(); + + return new FormHarnessFactory( + eventDispatcher: $this->eventDispatcher, + filterContextFactory: new FilterContextFactory(new FilterOptionsResolver(new SchemaResolver())), + formFactory: $formFactory, + ); + } + + private function createForm(array $filters): FormInterface + { + return $this->createFilterSet($filters)->getForm(); + } + + private function createFilterSet(array $filters): FormHarness + { + $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: $filters, + ); + + $context = new class implements ContextInterface, FormContextInterface { + public static function getContextType(): string + { + return 'test'; + } + + public function getFormName(): string + { + return 'flare_test'; + } + + public function createFormActionUrl(): ?string + { + return null; + } + }; + + return $this->createFactory()->create($list, $context); + } + + /** + * Creates an element building its form via the given callable. + * + * @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 buildContext( + FilterContextBuilder $builder, + ?ValueInterface $value, + ): 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, type: 'test_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 testSingleWithCompanionFieldIsRejected(): void + { + $element = $this->element(static function (FilterFormBuilderInterface $builder): void { + $builder->single(TextType::class, ['required' => false]); + $builder->add('extra', TextType::class, ['required' => false]); + }); + + $this->expectException(FlareException::class); + $this->expectExceptionMessage( + 'Filter element cannot declare a single field and add children at the same time.', + ); + + $this->createForm(['suche' => new Filter(element: $element, type: 'test_element', alias: 'suche')]); + } + + 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, type: 'test_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, type: 'test_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, type: 'test_element', alias: '_.tl_flare_filter.1')]); + + $this->assertSame(0, \count($form)); + } + + public function testCancelledEventPreventsMounting(): void + { + $this->eventDispatcher->addListener( + FilterFormBuiltEvent::class, + static fn (FilterFormBuiltEvent $event) => $event->cancel(), + ); + + $element = $this->element(static function (FilterFormBuilderInterface $builder): void { + $builder->single(TextType::class); + }); + + $form = $this->createForm(['suche' => new Filter(element: $element, type: 'test_element', alias: 'suche')]); + + $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->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->getMount('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->getChild('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->getMount('k')?->alias); + $this->assertSame($filterSet->getForm()->get('0'), $filterSet->getChild('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->getMount('k')); + $this->assertNull($filterSet->getChild('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( + FormHarnessBuildEvent::class, + static function (FormHarnessBuildEvent $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->getMount('k')?->alias); + $this->assertNull($filterSet->getChild('k')); + } + + public function testGetMountIsNullForAnUnknownKey(): void + { + $this->assertNull($this->createFilterSet([])->getChild('nope')); + $this->assertNull($this->createFilterSet([])->getMount('nope')); + } +} diff --git a/tests/Filter/FilterTest.php b/tests/Filter/FilterTest.php new file mode 100644 index 00000000..1f53379e --- /dev/null +++ b/tests/Filter/FilterTest.php @@ -0,0 +1,74 @@ + 1], + alias: 'foo', + source: 'tl_flare_filter.1', + ); + + $withData = $filter->withData(FilterData::of(['value' => 42])); + + self::assertNull($filter->data); + self::assertSame(['value' => 42], $withData->data?->all()); + 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); + + $targeted = $filter->withTargetAlias('translation'); + + self::assertSame('translation', $targeted->targetAlias); + self::assertTrue($targeted->targetingForced); + self::assertFalse($filter->targetingForced); + } + + public function testFingerprintReflectsIdentityAndContent(): void + { + $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']); + $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 new file mode 100644 index 00000000..0115b499 --- /dev/null +++ b/tests/Filter/FilterTransformerResolverTest.php @@ -0,0 +1,117 @@ +transform($element, 'transforming', new RowSource(['value' => 'x'])); + + self::assertSame(['value' => 'x'], $config); + } + + public function testReturnsNullWithoutMatchingTransformer(): void + { + $resolver = new FilterTransformerResolver(new EventDispatcher()); + + self::assertNull($resolver->transform(new TransformingElement(), 'transforming', new \stdClass())); + self::assertNull($resolver->transform(new PlainTransformerlessElement(), 'plain', 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, 'transforming', new RowSource([])); + $resolver->transform($element, 'transforming', 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 (ConfigBuilder $config, object $source) => $config->set('external', true), + ); + }, + ); + + $resolver = new FilterTransformerResolver($dispatcher); + + $config = $resolver->transform(new PlainTransformerlessElement(), 'plain', 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(TransformerResolver $resolver): void + { + $resolver->for(RowSource::class, static function (ConfigBuilder $config, RowSource $source): void { + foreach ($source->row as $key => $value) { + $config->set($key, $value); + } + }); + } + + public function buildForm(FormBuilderInterface $builder, FilterContext $context): void + { + } + + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void + { + } +} + +final class PlainTransformerlessElement implements FilterElementInterface +{ + public function buildForm(FormBuilderInterface $builder, FilterContext $context): void + { + } + + public function buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void + { + } +} 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/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, + ) {} +} 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/List/BaseListOptionsTest.php b/tests/List/BaseListOptionsTest.php new file mode 100644 index 00000000..5c531257 --- /dev/null +++ b/tests/List/BaseListOptionsTest.php @@ -0,0 +1,83 @@ + '5', + 'dc' => 'tl_news', + '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($config = new ConfigBuilder(), $model); + $all = $config->all(); + + self::assertArrayNotHasKey('id', $all); + self::assertArrayNotHasKey('published', $all); + self::assertSame('tl_news', $all['dc']); + self::assertSame('My List', $all['title']); + 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(new SchemaResolver()))->resolve(null, []); + + self::assertSame('', $resolved['dc']); + self::assertSame('', $resolved['title']); + 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($config = new ConfigBuilder(), $model); + + $resolved = (new ListOptionsResolver(new SchemaResolver()))->resolve(null, $config->all()); + + self::assertSame('x', $resolved['title']); + self::assertSame([], $resolved['sortSettings']); + } +} + +class ListModelStub extends ListModel +{ + /** @noinspection PhpMissingParentConstructorInspection */ + public function __construct(array $row = []) + { + $this->arrData = $row; + } +} diff --git a/tests/List/ListSpecBuilderTest.php b/tests/List/ListSpecBuilderTest.php new file mode 100644 index 00000000..33066810 --- /dev/null +++ b/tests/List/ListSpecBuilderTest.php @@ -0,0 +1,188 @@ +addListener(ListBuildEvent::class, static function (ListBuildEvent $event) use (&$dispatchedWith): void { + $dispatchedWith = $event->builder; + $event->builder->addFilter(self::filter('from_event', 'via_event')); + }); + + $driver = new class extends AbstractListDriver { + public int $buildListCalls = 0; + + public function buildList(ListSpecBuilder $builder): void + { + $this->buildListCalls++; + $builder->addFilter(ListSpecBuilderTest::filter('from_hook', 'via_hook')); + } + }; + + $builder = $this->createBuilder($dispatcher, driver: $driver); + $spec = $builder->build(); + + self::assertSame(1, $driver->buildListCalls); + self::assertSame($builder, $dispatchedWith); + self::assertArrayHasKey('via_hook', $spec->filters); + self::assertArrayHasKey('via_event', $spec->filters); + } + + public function testFiltersDcAndSourceCarryOverToTheSpec(): void + { + $builder = $this->createBuilder(new EventDispatcher()); + + $builder->addFilter(new Filter(element: new StubFilterElement(), type: 'stub', alias: 'x')); + $builder->addFilter(self::filter('b')); + $builder->removeFilter('x'); + + self::assertTrue($builder->hasFilterInstance(FilterElementInterface::class)); + self::assertFalse($builder->hasFilterInstance(StubFilterElement::class)); + + $spec = $builder->build(); + + self::assertSame($builder->getDriver(), $spec->driver); + 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); + self::assertArrayNotHasKey('x', $spec->filters); + } + + public function testModelTransformationAndOverridePrecedence(): void + { + $driver = new class extends AbstractListDriver { + protected function transformListModel(ConfigBuilder $config, ListModel $model): void + { + $config->set('genericPageMeta', true); + $config->set('title', 'from-transformer'); + } + }; + + $builder = $this->createBuilder( + new EventDispatcher(), + driver: $driver, + model: new ListModelStub(['dc' => 'tl_test', 'title' => 'from-model']), + ); + + $builder->set('title', 'from-override'); + + $config = $builder->build()->config; + + 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 + } + + public function testBuildFailsWithoutAnyDataContainer(): void + { + $registry = new ListDriverRegistry(); + $listDriverResolver = new ListDriverResolver($registry); + + $builder = new ListSpecBuilder( + listDriverResolver: $listDriverResolver, + specFactory: self::specFactory($registry, $listDriverResolver), + 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()); + $builder->set('unknown_key', 1); + + try + { + $builder->build(); + self::fail('Expected FlareException.'); + } + catch (FlareException $e) + { + self::assertSame('tl_flare_list.9', $e->getSource()); + } + } + + private static function specFactory( + ListDriverRegistry $registry, + ListDriverResolver $listDriverResolver, + ?EventDispatcher $dispatcher = null, + ): ListSpecFactory { + return new ListSpecFactory( + $registry, + new ListOptionsResolver(new SchemaResolver()), + new ListTransformerResolver($dispatcher ?? new EventDispatcher()), + $listDriverResolver, + ); + } + + private function createBuilder( + EventDispatcher $dispatcher, + ?ListDriverInterface $driver = null, + ?ListModel $model = null, + ): ListSpecBuilder { + $registry = new ListDriverRegistry(); + $listDriverResolver = new ListDriverResolver($registry); + + return new ListSpecBuilder( + listDriverResolver: $listDriverResolver, + specFactory: self::specFactory($registry, $listDriverResolver, $dispatcher), + eventDispatcher: $dispatcher, + driver: $driver ?? new class extends AbstractListDriver {}, + model: $model ?? new ListModelStub(['dc' => 'tl_test']), + source: 'tl_flare_list.9', + ); + } +} diff --git a/tests/List/ListSpecFactoryTest.php b/tests/List/ListSpecFactoryTest.php new file mode 100644 index 00000000..8aaa0ccb --- /dev/null +++ b/tests/List/ListSpecFactoryTest.php @@ -0,0 +1,92 @@ +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->dc); + self::assertSame('tl_test', $spec->config['dc']); + self::assertSame('My List', $spec->config['title']); + self::assertFalse($spec->config['genericPageMeta']); // 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 resolveDcTable(string $type, array $config, array $attributes): string + { + return 'tl_news'; + } + }; + + $spec = $this->createFactory()->create(driver: $driver); + + self::assertSame('tl_news', $spec->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 new file mode 100644 index 00000000..b309a343 --- /dev/null +++ b/tests/List/ListSpecTest.php @@ -0,0 +1,126 @@ +withFilter(self::filter('flare_bool', 'foo')); + + self::assertArrayHasKey('foo', $spec->filters); + } + + public function testWithFilterAcceptsExplicitKey(): void + { + $spec = self::spec()->withFilter(self::filter('flare_bool', 'foo'), 'custom'); + + self::assertArrayHasKey('custom', $spec->filters); + self::assertArrayNotHasKey('foo', $spec->filters); + } + + public function testWithFilterGeneratesCollisionFreeKeysForAliasLessFilters(): void + { + $spec = self::spec() + ->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(self::filter('c')); + + self::assertSame('c', $spec->filters['_generated_0']->type); + self::assertSame('b', $spec->filters['_generated_1']->type); + } + + public function testModifiersAreImmutable(): void + { + $original = self::spec(config: ['id' => 1]); + + $modified = $original->withFilter(self::filter('a', 'x')); + + self::assertSame([], $original->filters); + self::assertNotSame($original, $modified); + self::assertSame(['id' => 1], $modified->config); + self::assertArrayHasKey('x', $modified->filters); + } + + public function testHasFilterInstance(): void + { + $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)); + self::assertFalse($spec->hasFilterInstance(PublishedFilterElement::class)); + } + + public function testHashIsStableAndChangesWithContent(): void + { + $make = static fn (array $config = [], ?string $source = null): ListSpec => + self::spec(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(self::filter('a', 'x'))->hash(), + ); + } +} diff --git a/tests/List/ListTransformerResolverTest.php b/tests/List/ListTransformerResolverTest.php new file mode 100644 index 00000000..4019261d --- /dev/null +++ b/tests/List/ListTransformerResolverTest.php @@ -0,0 +1,111 @@ +transform(new TransformingDriver(), 'transforming', new SourceStub('from-source')); + + self::assertSame(['title' => 'from-source'], $values); + } + + public function testReturnsNullWithoutMatchingTransformer(): void + { + $resolver = new ListTransformerResolver(new EventDispatcher()); + + self::assertNull($resolver->transform(new TransformingDriver(), 'transforming', new \stdClass())); + self::assertNull($resolver->transform(new TransformerlessDriver(), 'plain', new SourceStub('x'))); + } + + public function testMemoizesMapAndDispatchesEventOncePerDriverClass(): 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(); + + $resolver->transform($driver, 'transforming', new SourceStub('a')); + $resolver->transform($driver, 'transforming', new SourceStub('b')); + + self::assertSame(1, $driver->configureCalls); + self::assertCount(1, $dispatchedWith); + self::assertSame($driver, $dispatchedWith[0]->driver); + } + + public function testEventListenersCanAddSourceCapabilities(): void + { + $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(), 'plain', new \stdClass()); + + self::assertSame(['external' => true], $values); + } +} + +final class SourceStub +{ + public function __construct( + public readonly string $title, + ) {} +} + +final class TransformingDriver implements ListDriverInterface, TransformerContract +{ + public int $configureCalls = 0; + + public function resolveDcTable(string $type, array $config, array $attributes): string + { + return (string) ($config['dc'] ?? ''); + } + + public function configureTransformers(TransformerResolver $resolver): void + { + $this->configureCalls++; + + $resolver->for(SourceStub::class, static function (ConfigBuilder $config, SourceStub $source): void { + $config->set('title', $source->title); + }); + } +} + +final class TransformerlessDriver implements ListDriverInterface +{ + public function resolveDcTable(string $type, array $config, array $attributes): string + { + return (string) ($config['dc'] ?? ''); + } +} diff --git a/tests/List/StubFilterElement.php b/tests/List/StubFilterElement.php new file mode 100644 index 00000000..50a59f7c --- /dev/null +++ b/tests/List/StubFilterElement.php @@ -0,0 +1,20 @@ +createMock(Connection::class)), + filterTypeRegistry: new FilterPredicateRegistry([]), + ); + } + + /** + * @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 buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void + { + $this->received = $value; + } +} diff --git a/tests/Registry/FilterElementRegistryTest.php b/tests/Registry/FilterElementRegistryTest.php new file mode 100644 index 00000000..99ea7aa4 --- /dev/null +++ b/tests/Registry/FilterElementRegistryTest.php @@ -0,0 +1,66 @@ +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 buildContext(FilterContextBuilder $builder, ?ValueInterface $value): void {} +} diff --git a/tests/Registry/FilterFormRegistryTest.php b/tests/Registry/FilterFormRegistryTest.php new file mode 100644 index 00000000..a0cbb949 --- /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 $form, FilterContext $context): ?object + { + return null; + } +} diff --git a/tests/Registry/ListDriverRegistryTest.php b/tests/Registry/ListDriverRegistryTest.php new file mode 100644 index 00000000..66462600 --- /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 resolveDcTable(string $type, array $config, array $attributes): string + { + return (string) ($config['dc'] ?? ''); + } +} + +final class OtherRegistryDriverStub extends RegistryDriverStub +{ +} 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))); + } +} diff --git a/translations/flare.de.yaml b/translations/flare.de.yaml index 9353165a..30c4b32c 100644 --- a/translations/flare.de.yaml +++ b/translations/flare.de.yaml @@ -3,12 +3,17 @@ reader: default_template: warning: "Standard-Template ausgewählt" description: "Bitte erstellen/wählen Sie ein benutzerdefiniertes Template für diesen FLARE-Reader." + invalid_list: "Keine gültige Liste ausgewählt" list: default_template: 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." + duplicate_filter_alias: "Duplizierte Filter-Aliasse: %alias%. Der letzte Filter überschreibt vorherige mit dem gleichen Alias." + filter: limited_scope: single: "Dieser Filter ist ausschließlich anwendbar auf: %scopes%" @@ -26,6 +31,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 7674dcda..bf305c04 100644 --- a/translations/flare.en.yaml +++ b/translations/flare.en.yaml @@ -3,12 +3,17 @@ reader: default_template: warning: "Default template selected" description: "Please create/select a custom template for this FLARE reader." + invalid_list: "No valid list selected" list: default_template: 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." + duplicate_filter_alias: "Duplicate filter aliases: %alias%. The last filter overwrites the previous ones with the same alias." + filter: limited_scope: single: "This filter is limited to the following scope: %scopes%" @@ -26,6 +31,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.' diff --git a/translations/flare_filter.de.php b/translations/flare_filter.de.php index d3ceb710..7fdd9485 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\ArchiveFilterElement::TYPE => 'Archiv', + Element\BelongsToRelationFilterElement::TYPE => 'Relation: Gehört zu', + Element\BooleanFilterElement::TYPE => 'Boolescher Eigenschaftswert', + Element\CalendarCurrentFilterElement::TYPE => 'Kalender-Zeitfenster', + 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', - 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..e024b645 100644 --- a/translations/flare_filter.en.php +++ b/translations/flare_filter.en.php @@ -1,19 +1,19 @@ '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', + 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', - CodefogTagsChoiceElement::TYPE => 'Tags [codefog/tags-bundle]', + CodefogTagsChoiceFilterElement::TYPE => 'Tags [codefog/tags-bundle]', ]; diff --git a/translations/flare_list.de.php b/translations/flare_list.de.php index cae22909..0c5c46e8 100644 --- a/translations/flare_list.de.php +++ b/translations/flare_list.de.php @@ -1,11 +1,11 @@ 'Data-Container', - ListType\NewsListType::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 8e13e1d5..e554a0c5 100644 --- a/translations/flare_list.en.php +++ b/translations/flare_list.en.php @@ -1,11 +1,11 @@ 'Data Container', - ListType\NewsListType::TYPE => 'News', + Driver\GenericDataContainerListDriver::TYPE => 'Data Container', + Driver\NewsListDriver::TYPE => 'News', - EventsListType::TYPE => 'Events', + EventsListDriver::TYPE => 'Events', ];