diff --git a/.audit/260719012-combined/00-uebersicht.md b/.audit/260719012-combined/00-uebersicht.md new file mode 100644 index 00000000..d529d022 --- /dev/null +++ b/.audit/260719012-combined/00-uebersicht.md @@ -0,0 +1,56 @@ +# Kombiniertes Audit: contao-flare-bundle — Branch `feat/filter-types` + +**Datum:** 2026-07-20 · **Stand:** Commit `5940ad6` +**Quellen:** `.audit/2607171801-claude/` und `.audit/2607171755-codex/` (beide vom 2026-07-17, Review-Commit `39065f73`) + +## Methodik + +Jeder Punkt beider Audits wurde gegen den aktuellen Code (`5940ad6`) verifiziert. Enthalten sind **ausschließlich weiterhin valide Punkte** mit aktuellen Datei-/Zeilen-Belegen; inzwischen behobene sowie widerlegte Punkte wurden entfernt, Duplikate beider Audits zusammengeführt. Positive Beobachtungen stehen separat in [99-positive-punkte.md](99-positive-punkte.md), damit die actionable Dateien schlank bleiben. + +Seit dem Audit-Datum wurden mehrere der ursprünglichen Top-Findings behoben — darunter der `?_preview`-Feld-Dump, die verlorene Listen-ID im `ListSpec`-Pfad, die Transformer-Memoization pro Klasse, das Suche-verwirft-sich-selbst-Kernproblem und der `'0'`-Verlust im `ChoicesBuilder`. Die Kapitel Tests/CI und Performance/Stabilität sind dagegen vollständig unverändert offen. + +## Die Dateien + +| Datei | Thema | Schwerste offene Findings | +|---|---|---| +| [10-architektur.md](10-architektur.md) | Architektur & Design | Terminal42 als toter, nicht kompilierbarer Code (A-01); stale AGENTS.md (A-02) | +| [20-korrektheit.md](20-korrektheit.md) | Korrektheit & Bugs | Boolean-Binary-Modi nicht implementiert (K-01), unabwählbarer Preselect (K-02), Kalender-Datumsgrenzen ignoriert (K-03), totes Stop-Word-Feature (K-07), `'0'`-Verluste (K-08) | +| [30-sicherheit.md](30-sicherheit.md) | Security & Query-Safety | Model-Registry umgeht `start`/`stop`-Fenster (SEC-01); Backend-Ausgabe teils unescaped (SEC-03) | +| [40-performance-stabilitaet.md](40-performance-stabilitaet.md) | Performance & Stabilität | 500er statt Degradierung im Render-Pfad (PS-01), positionaler Entry-Cache (PS-02), DBAL-Constraint (PS-03), Calendar-OOM-Potenzial (PS-15/16), doppelte Query-Pipeline (PS-14) | +| [50-tests-ci.md](50-tests-ci.md) | Tests, CI & Tooling | Query-Schicht/Filter-Types/Engine ungetestet (T-01), Compatibility-Gate durch `continue-on-error` entwertet (CI-02), PHPUnit nur PHP 8.2 (CI-01) | +| [60-contao-integration-doku.md](60-contao-integration-doku.md) | Contao-Integration, API, Doku & Kompatibilität | Doku-Beispiele mit Fatal Error (C-01), Intrinsic-DX-Falle (C-02), tote DB-Felder der Terminal42-Integration (C-03), rohe Backend-Labels (C-04) | +| [99-positive-punkte.md](99-positive-punkte.md) | Positivbefunde (nicht actionable) | — | + +## Priorisierung (konsolidiert, nur offene Punkte) + +### Vor dem Merge fixen + +1. **DBAL-Constraint `^2.13 || ^3.0` erlaubt Versionen ohne `ArrayParameterType`** → Fatal auf Contao 4.13; Fix ist eine Zeile: `^3.6 || ^4.0` (PS-03, C-05). + * Gefixt. +2. **Render-Pfad-Stabilität:** `createView()` läuft erst im Template; Laufzeitfehler eines kaputten Filters reißt die Seite in einen 500er — `createView()` in den Controller ziehen bzw. `FlareException` beim Rendern abfangen; dazu 200-vs-500-Inkonsistenz Listview/Reader (PS-01, PS-05). + * Nein: Dieses Verhalten ist exakt richtig. Unbehandelte Exceptions sorgen für Fehler 500, auch vom Template aus. +3. **Entry-Cache positional statt per ID indiziert** — falscher Datensatz im Reader-Pfad möglich, öffentliche API (PS-02). +4. **Doku-`buildDca()`-Beispiele erzeugen Fatal Error** (konkrete Klasse statt `DcaBuilderInterface`; C-01). + +### Zeitnah (Korrektheit der namensgebenden Features) + +5. **BooleanFilterElement:** `NULL_FALSE`/`TRUE_FALSE` nicht implementiert, unabwählbarer Preselect, „CBX"-Label, rohe Backend-Keys (K-01, K-02, K-11, C-04). +6. **CalendarCurrentFilterElement:** numerische Datumsgrenzen wirkungslos, kein Gating durch `configure_*`, ungefangene Exception (K-03, K-04, K-05). +7. **Suche:** nur-leere Suchgruppen → `ArgumentCountError` (K-06); Stop-Word-Feature komplett tot — Parameter existiert nie (K-07). +8. **`'0'`-/falsy-Verluste in den Choice-Pfaden** inkl. Label-Kollisionen (K-08, K-09). +9. **Sichtbarkeit:** still geskippte intrinsische Filter (PS-08), Registry-Shortcut umgeht `start`/`stop` (SEC-01), fehlende Generic-Driver-Warnung im No-Parent-Zweig (SEC-02), Preview-Modus ignoriert (PS-09). +10. **Intrinsic-Pflichtmuster** in Interface-Docblock/zentralem Guard verankern; Migrationsdoku um Alias-Skip + Intrinsic-Verlagerung ergänzen (C-02, C-06). + +### Vor dem ersten Stable-Release + +11. **Testabdeckung der Risikozonen:** `src/Query/` (SQL-Leitplanken!), Filter-Types, Engine-Pipeline, Paginator, ChoicesBuilder; Regressionstests für K-01–K-08 gleich mitnehmen; Stubs autoloadbar machen, Random-Order aktivieren (T-01, T-02, T-03). +12. **CI reparieren:** `continue-on-error` raus, PHPUnit-Matrix (lowest-deps + Contao 4.13), `pull_request`-Trigger, `composer audit` ohne `|| true`, Mago wieder inkl. `tests/` (CI-01–CI-05). +13. **Terminal42-Integration entscheiden:** portieren oder entfernen — toter, nicht kompilierbarer Code plus tote DB-Felder, unsichtbar nur dank PHPStan-Excludes (A-01, C-03). +14. **Public-API-/DX-Politur:** Registry-Vereinheitlichung, `#[TaggedIterator]`-Ablösung, `PaginatorConfig`-TypeErrors und Off-by-one, Alias-Kollisions-Warning, Übersetzungslücken/Waisen (A-03–A-16, K-12, K-13, C-07–C-19). + +### Performance-Backlog (kein Blocker, aber lohnend) + +- Filter-Pipeline-Ergebnis request-scoped teilen — Count + Entries + Partials rechnen bis zu 3× dasselbe (PS-14, PS-17, PS-21). +- Calendar-Integration: SQL-seitiges Zeitfenster + harte Occurrence-Obergrenze — Full-Fetch ×2 + unbegrenzte Expansion = OOM-Risiko durch Redakteurs-Eingabe (PS-15, PS-16). +- Shared-Service-Caches via `kernel.reset` leeren — sonst stale unter Worker-Runtimes (PS-07). +- Choices begrenzen (LIMIT/Suche/Ajax) und O(n²)-Wertauflösung beheben (PS-19, PS-20). diff --git a/.audit/260719012-combined/10-architektur.md b/.audit/260719012-combined/10-architektur.md new file mode 100644 index 00000000..4d8a6b65 --- /dev/null +++ b/.audit/260719012-combined/10-architektur.md @@ -0,0 +1,120 @@ +# Architektur & Design + +Kombinierte, am Stand `5940ad6` (2026-07-20) verifizierte Findings aus beiden Audits (claude 2607171801, codex 2607171755). Die ursprünglichen Top-Findings beider Audits — verlorene Listen-ID im `ListSpec`-Pfad (Formularnamen-Kollision) und Transformer-Memoization pro Klasse statt (Klasse, Typ) — sind inzwischen behoben und daher hier nicht mehr enthalten. Positive Beobachtungen: siehe [99-positive-punkte.md](99-positive-punkte.md). + +## A-01: Terminal42-ChangeLanguage-Integration ist toter, nicht kompilierbarer Code — Major (beide Audits) + +Der Listener importiert fünf nicht existierende Klassen (`Event\AbstractFetchEvent`, `Event\FetchAutoItemEvent`, `Event\FetchCountEvent`, `Event\FetchListEntriesEvent`, `Query\ListQueryBuilder`), abonniert nie dispatchte Event-Namen und benutzt die entfernte Fetch-API (`getListQueryBuilder()`, `getFilters()`, `getContentContext()`). Das Laden der Integration ist auskommentiert; PHPStan excludiert das Verzeichnis komplett und ignoriert `class.notFound` für `src/Integration/` — der Bruch bleibt systematisch unsichtbar. Nebenbefund: Klasse/Namespace `DcMultilingualListType` tragen als letzte Stelle das alte `ListType`-Vokabular (Attribut ist bereits `#[AsListDriver]`). + +**Entscheidung nötig: portieren oder entfernen.** + +- `src/Integration/Terminal42Languages/EventListener/ChangelanguageListener.php:13-17,22` (tote Imports), `:92,116` (nie dispatchte Events), `:70,100-101,108-109,119-129` (entfernte API) +- `src/DependencyInjection/HeimrichHannotFlareExtension.php:43-45` (auskommentierter Loader) +- `phpstan.neon:13,18-20` · `src/Integration/Terminal42Languages/ListType/DcMultilingualListType.php:16-19` + +## A-02: AGENTS.md/CLAUDE.md beschreiben nicht mehr existierende APIs — Minor (claude) + +Doku-Drift gegen den aktuellen Code: `ListBuilderFactory` heißt `ListSpecBuilderFactory`; `#[AsListType]` heißt `AsListDriver`; `ListTypeRegistry` heißt `ListDriverRegistry`; `FilterElementResolver` existiert nicht; „EngineFactory creates Engine with appropriate Context" ist falsch — die Factory erhält den Context als Parameter (`src/Engine/Factory/EngineFactory.php:21-29`). Dazu Doc-Drift im Code: Verweis auf `DcaContract::configureDca()`, die Methode heißt `buildDca()` (`src/EventListener/Contao/ElementDcaListener.php:24` vs. `src/Contract/DcaContract.php:18`). + +- `AGENTS.md:21,43,55,60,71` + +> ## A-03: Registry-Duplikation und heterogene Lookup-Semantik — Minor (claude) +> +> `FilterElementRegistry` und `ListDriverRegistry` sind strukturell nahezu identisch (gleiches `add`/`remove`/`prune`/`typesByClass`-Muster) — Kandidat für Basis/Trait. Daneben drei weitere Stile: `FilterTypeRegistry` (TaggedIterator, Key = Klassenname), `EngineModRegistry` (TaggedIterator, `defaultIndexMethod: 'getType'`), `ProjectorRegistry` (`supports()`/`priority()`-Scan). Fünf Registries, vier Lookup-Semantiken. +> +> - `src/Registry/FilterElementRegistry.php:39-57` vs. `src/Registry/ListDriverRegistry.php:34-52` · `src/Registry/FilterTypeRegistry.php:25-28,53` · `src/Registry/EngineModRegistry.php:15` · `src/Registry/ProjectorRegistry.php:28-63` +> +> **Nutzer-Antwort: Das ist kein Design-Fehler, sondern eine Konvention. Einzelne Klassen sorgen für Typsicherheit. Die Klassen sind atomar und benötigen künftig keiner Feature-Erweiterung, daher keine gemeinsame Basisklasse.** + +> ## A-04: `PaginatorConfig`: latenter `TypeError` in `count()` + deprecated `\Serializable` — Minor (claude) +> +> `count(): int` gibt `getLastPageNumber(): ?int` zurück — `TypeError` bei `itemsPerPage < 1` oder unbekanntem `totalItems`. Zusätzlich implementiert die Klasse das deprecated `\Serializable`-Interface mit `serialize()`/`unserialize()` neben `__serialize`/`__unserialize`. +> +> - `src/Paginator/PaginatorConfig.php:192-195` (`count()`), `:107-118` (`getLastPageNumber(): ?int`), `:7,197-205` (`\Serializable`) +> +> **Nutzer-Antwort: TypeError erledigt, \Serializable ist nicht deprecated, siehe folgende Notiz.** +> > As of PHP 8.1.0, a class which implements Serializable without also implementing __serialize() and __unserialize() will generate a deprecation warning. + +> ## A-05: `InteractiveProjector`: COUNT-Query läuft vor der Invalid-Form-Prüfung — Minor (claude) +> +> Die Aggregations-COUNT-Query (`src/Engine/Projector/InteractiveProjector.php:50`) wird ausgeführt, bevor geprüft wird, ob das Formular invalid submitted wurde (`:56-58`) — pro invalidem Submit eine unnötige Query. Zudem wird `totalItems` unverändert an die View durchgereicht, sodass diese `totalItems > 0` bei leerem `InteractiveEmptyLoader` meldet (`:74-81`). +> +> **Nutzer-Antwort: Das ist kein Fehler. Da sich das Formular nicht auf die Aggregation-COUNT-Query auswirkt, muss die totale Anzahl der Elemente trotzdem berechnet werden.** + +> ## A-06: Context-Verträge mit kleinen LSP/ISP-Brüchen — Minor (claude) +> +> `InteractiveContext::getPaginatorConfig(): PaginatorConfig` gibt das nullable Property ungeprüft zurück — `TypeError` bei programmatischer Konstruktion ohne Validator-Lauf (`src/Engine/Context/InteractiveContext.php:25,45-48`). Die readonly `ValidationContext` trägt einen No-op-Setter `setPaginatorQueryParameter()`, weil `PaginatedContextInterface` ihn erzwingt (`src/Engine/Context/ValidationContext.php:77-80`). +> +> **Nutzer-Antwort: PaginatorConfig nun korrekt null-safe, ValidationContext no-op-Setter ist korrekt für den Zweck.** + +> ## A-07: Stille Alias-Kollision im Filter-Collector — Minor (claude) +> +> `$filters[$filter->alias] = $filter;` — zwei publizierte Filter derselben Liste mit gleichem Formular-Alias überschreiben sich kommentarlos; nur der letzte wird angewendet. Ein Kollisions-Warning fehlt (das Factory-Fehler-Warning existiert dagegen). +> +> - `src/List/Collector/ListModelFilterCollector.php:75` +> +> **Nutzer-Antwort: Im Backend wird nun ein Fehler ausgegeben, wenn zwei Filter mit demselben Alias publiziert werden.** + +> ## A-08: `FlareException`: `method` vs. `source` inkonsistent — Minor (claude) +> +> Die Exception bietet beide Parameter (`src/Exception/FlareException.php:17-18`), der Code nutzt beide uneinheitlich mit demselben Inhalt (`__METHOD__`): Loader nutzen `method:` (`src/Engine/Loader/InteractiveLoader.php:52`, `AggregationLoader.php:52`), Projector/Views/Calendar-Integration `source:` (`src/Engine/Projector/AbstractProjector.php:124`, `src/Engine/View/HandlesModelsTrait.php:33,42,57,66,75`, `src/Integration/ContaoCalendar/Loader/EventsAggregationLoader.php:66`), `ValidationLoader` keins von beiden (`src/Engine/Loader/ValidationLoader.php:57,92`). +> +> **Nutzer-Antwort: Angeglichen -- method: __METHOD__, source, wenn verfügbar: table.id -- übertragen auf gesamte Codebase** + +> ## A-09: `symfony/event-dispatcher` nicht direkt deklariert — Minor (claude, reduzierter Umfang) +> +> `FilterFormFactory` instanziiert direkt `new EventDispatcher()` (`src/Filter/Factory/FilterFormFactory.php:17,70`), deklariert ist aber nur `symfony/event-dispatcher-contracts` (`composer.json:17`); das konkrete Paket kommt nur transitiv über `contao/core-bundle`. +> +> **Nutzer-Antwort: Required in composer.json** + +> ## A-10: `ValidationLoader::executeQuery()` liefert `[]` statt `null` bei abgebrochenem Query-Aufbau — Minor (claude) +> +> Bei `!$qb` wird `[]` zurückgegeben — harmlos (falsy), aber semantisch schief gegenüber dem `?array`-Vertrag, in dem `null` „nicht gefunden" bedeutet (`:117`: `return $entry ?: null;`). +> +> - `src/Engine/Loader/ValidationLoader.php:107-109` +> +> **Nutzer-Antwort: Return-type auf `array` angepasst.** + +> ## A-11: Query-Assemblierung lebt in Event-Listener-Prioritäten ohne zentrale Übersicht — Info (claude) +> +> Select@490, Conditions@470, Page@430, Order@420, Join@-450; Integrations-Listener dazwischen (250/220/200/190/100). Die Gesamtordnung ist nirgends zentral dokumentiert (kein Pipeline-Kommentar im `ListQueryDirector`). +> +> - `src/EventListener/QueryStructModifier/SelectModifierListener.php:13`, `ConditionsModifierListener.php:11`, `PageModifierListener.php:11`, `OrderModifierListener.php:12`, `JoinModifierListener.php:10` · `src/Integration/ContaoCalendar/EventListener/CountEventsModifierListener.php:14` u. a. +> +> **Nutzer-Antwort: Das muss in einem zukünftigen PR nochmal überarbeitet werden.** + +> ## A-12: `ViewInterface` ist leerer Marker; Aufrufer müssen downcasten — Info (claude) +> +> Das Interface ist leer (`src/Engine/View/ViewInterface.php:7-9`); `ReaderController` downcastet auf `ValidationView` (`src/Controller/ContentElement/ReaderController.php:127`). Die `@template`-Annotationen sind nur mit dem `generics.noParent`-Ignore in PHPStan haltbar. + +> ## A-13: `#[TaggedIterator]` ist seit Symfony 7.1 deprecated — Info (claude) +> +> Genutzt in drei Registries; relevant für Deprecation-Logs bei Support-Matrix ^5.4|^6|^7. Nachfolger `AutowireIterator` existiert erst ab 6.3 → für die Matrix ggf. `!tagged_iterator` in YAML. +> +> - `src/Registry/EngineModRegistry.php:15` · `src/Registry/ProjectorRegistry.php:19` · `src/Registry/FilterTypeRegistry.php:18` +> +> **Nutzer-Antwort: Passt so.** + +> ## A-14: Statische Contao-Aufrufe in Context-DTOs — Info (claude, reduzierter Umfang) +> +> `PageModel::findByPk` in wertartigen Context-Objekten — DB-Zugriffe, testfeindlich, aber Contao-idiomatisch. +> +> - `src/Engine/Context/ReaderUrlConfigCreatorTrait.php:18` · `src/Engine/Context/ValidationContext.php:44` +> +> **Nutzer-Antwort: Weiterhin statische Aufrufe, aber nun besser gekapselt.** + +> ## A-15: Backend-Responses ohne Null-Check auf `$listModel` — Info (claude) +> +> `getRelated()` kann `null` liefern; der Catch deckt nur Exceptions ab. Danach werden `$listModel->title` / `trans($listModel->type)` ungeprüft dereferenziert — in beiden Controllern. (Gelöschte/fehlende Liste → Backend-Crash; siehe auch SEC-03 in [30-sicherheit.md](30-sicherheit.md).) +> +> - `src/Controller/ContentElement/ReaderController.php:220-236` (Zugriff `:232-233`) · `src/Controller/ContentElement/ListViewController.php:154-168` (Zugriff `:166-167`) +> +> **Nutzer-Antwort: Good Catch! Ist jetzt mit einer entsprechenden Warnung gesichert.** + +> ## A-16: `Engine`-Mods-API mischt Semantiken — Info (claude) +> +> `addMod()` appendet numerisch, `setMod()`/`unsetMod()` arbeiten mit String-Keys im selben Array; `unsetMod()` kann appendete Mods nicht adressieren — öffentlicher `@api`-Punkt. +> +> - `src/Engine/Engine.php:66-93` +> +> **Nutzer-Antwort: Das ist kein Fehler sondern explizit so gewollt. Der Nutzer hat die Wahl, Filter für mehrfache veränderung überschreibbar zu machen, oder nicht. In den meisten Fällen wird das nicht gebraucht, daher reicht Listenindexierung ohne Möglichkeit zur Änderung.** diff --git a/.audit/260719012-combined/20-korrektheit.md b/.audit/260719012-combined/20-korrektheit.md new file mode 100644 index 00000000..6f1ea61d --- /dev/null +++ b/.audit/260719012-combined/20-korrektheit.md @@ -0,0 +1,116 @@ +# Korrektheit & Bugs + +Kombinierte, am Stand `5940ad6` (2026-07-20) verifizierte Findings aus beiden Audits (claude 2607171801, codex 2607171755). Mehrere ursprüngliche Top-Findings sind inzwischen behoben (u. a. Suche-verwirft-sich-selbst im Kern, `'0'`-Verlust im `ChoicesBuilder`, DCA-Laden im Frontend, halbiertes Paginator-Fenster, `mergePalettes`-No-Op) und daher nicht mehr enthalten. Positive Beobachtungen: siehe [99-positive-punkte.md](99-positive-punkte.md). + +## K-01: Boolean-Filter: `binary_choices` `NULL_FALSE`/`TRUE_FALSE` nicht implementiert — Major/Hoch (beide Audits) + +`normalizeValue()` behandelt ausschließlich `NULL_TRUE` speziell; `NULL_FALSE` und `TRUE_FALSE` laufen in `filter_var()`, wodurch z. B. bei `null_false` eine angehakte Checkbox auf `true` statt `false` filtert. Die Enum-Helper `hasNull()`/`hasTrue()`/`hasFalse()` bleiben ungenutzt. + +- `src/Filter/Element/BooleanFilterElement.php:90-107` (Sonderfall nur `:101`) + +## K-02: Boolean-Filter: Preselect nicht abwählbar, Formular zeigt ihn nicht an — Major/Hoch (beide Audits) + +`buildForm()` setzt kein `'data' => $preselect` (Checkbox rendert unangehakt trotz aktivem Filter). Abwählen + Submit ergibt `false` → `normalizeValue(false, NULL_TRUE)` → `null` → `?? $config['preselect']` reaktiviert den Filter — der Preselect ist unabwählbar. + +- `src/Filter/Element/BooleanFilterElement.php:55-58` (kein `data`), `:87` (Preselect-Fallback) + +## K-03: Calendar-Filter: numerisch gespeicherte Datumsgrenzen werden ignoriert — Major/Mittel (beide Audits) + +Im Modus `date` normalisiert der Load-/Save-Callback `startAt`/`stopAt` auf einen numerischen Timestamp (`src/EventListener/DataContainer/FlareFilter/FieldsLoadAndSaveCallbacks.php:169-173`), aber `buildFilter()` ruft `\strtotime((string) $config['start_at'])` auf — `strtotime('1750723200')` ist `false` → `$start = 0`, `$stop = maxTimestamp()`; ebenso fehlen die min/max-Formattribute. `DateTimeHelper::toTimestamp()` (`src/Util/DateTimeHelper.php:103`) wird nicht benutzt. + +- `src/Filter/Element/CalendarCurrentFilterElement.php:110-111,166-177` + +## K-04: Calendar-Filter: `configure_start`/`configure_stop` gaten den Filter nicht; Save-Callback räumt nicht auf — Minor (beide Audits) + +`buildFilter()` nutzt `start_at`/`stop_at` bedingungslos; der Save-Callback early-returnt bei Leerwahl (`if (!$value) return $value;`) und lässt den alten `startAt`-Wert stehen — der Filter filtert veraltet weiter. + +- `src/Filter/Element/CalendarCurrentFilterElement.php:110-111` · `src/EventListener/DataContainer/FlareFilter/FieldsLoadAndSaveCallbacks.php:115-119` + +## K-05: Calendar-Filter: ungefangene Exception bei Garbage-Strings — Minor (claude) + +`mixedToDateTime()` wirft bei unparsebaren Strings ungefangen (`new \DateTimeImmutable($input)`), erreichbar über programmatische `Filter::$data`. + +- `src/Filter/Element/CalendarCurrentFilterElement.php:225-227` + +## K-06: Suchfilter: nur-leere Suchgruppen führen zu `ArgumentCountError` — Minor, Rest eines Major-Findings (beide Audits) + +Der Kernbug (`return` mitten in der Schleife verwarf die gesamte Suche) ist behoben (`continue`). Die empfohlene Behandlung „gar keine valide Gruppe übrig" fehlt aber: Bei Suchtext nur aus Garbage/Stoppwörtern (z. B. `"!!!"` — erreichbar, da `SearchKeywordsFilterElement::buildFilter` jeden nicht-leeren String durchreicht, `src/Filter/Element/SearchKeywordsFilterElement.php:72-83`) bleibt `$or = []` und `$builder->expr()->or(...$or)` wird ohne Argumente aufgerufen — DBAL verlangt mindestens ein Argument → `ArgumentCountError` statt Ergebnisliste. + +- `src/Filter/Type/SearchKeywordsFilterType.php:29-52` (insb. `:52`) + +## K-07: Such-Stoppwörter erreichen den `ConfigProvider` nie — Mittel (codex) + +Die Extension setzt nur `huh_flare` (Gesamtarray) und `huh_flare.format_label_defaults`; `ConfigProvider` fragt `huh_flare.search_stop_words.` ab, das nirgends erzeugt wird — die ausgelieferten Stoppwortlisten (`config/config.yaml:17`) sind wirkungslos (totes Feature). + +- `src/DependencyInjection/HeimrichHannotFlareExtension.php:50-51` · `src/ConfigProvider.php:30-37` + +## K-08: Choice-Elemente: Wert `'0'` und falsy Labels gehen verloren — Minor–Mittel (beide Audits, Teilaspekt `ChoicesBuilder` behoben) + +Weiterhin valide Teilaspekte: + +- `FieldValueChoiceFilterElement::extractSubmittedData()`: erstes `\array_filter($submittedData)` ohne Callback entfernt `'0'`; zudem pauschales `array_map('strtolower', ...)`. — `src/Filter/Element/FieldValueChoiceFilterElement.php:251-252` +- `DcaSelectFieldFilterElement::buildFilter()`: `if (!$selected) { return; }` verwirft sowohl den intrinsischen Preselect `'0'` als auch eine Runtime-Einzelauswahl mit Key `'0'`. — `src/Filter/Element/DcaSelectFieldFilterElement.php:103-109` +- `DcaSelectFilterType` Multi-Pfad: `if ($validOptions[$value] ?? null)` filtert Keys mit falsy Label (`'0'`, `''`) aus → ggf. `$filtered` leer → `abort()` → ganze Liste leer; der Single-Pfad nutzt korrekt `array_key_exists` (inkonsistent). — `src/Filter/Type/DcaSelectFilterType.php:59` vs. `:38` + +## K-09: DcaSelect: Label→Key-Rückabbildung kollidiert bei doppelten Labels — Minor (beide Audits) + +`normalizeSubmittedValue()` mappt submittete Labels per `array_search` auf Keys — bei identischen (übersetzten) Labels gewinnt immer der erste Key, unmappbare Werte werden `''`. + +- `src/Filter/Element/DcaSelectFieldFilterElement.php:184-198` + +## K-10: `DateRangeFilterElement`: `intrinsic`-Modus ist funktionslos — Minor (claude) + +`intrinsic` ist über die Basis-Palette wählbar (`contao/dca/tl_flare_filter.php:645`), aber es gibt keine intrinsischen from/to-Konfigwerte; `buildFilter()` erhält leere `$values` → keinerlei Bedingung. + +- `src/Filter/Element/DateRangeFilterElement.php:33-46,78-89` + +## K-11: BooleanFilterElement: Debug-Platzhalter „CBX" als Frontend-Label — Minor (claude) + +- `src/Filter/Element/BooleanFilterElement.php:56` (`'label' => $context->config['label'] ?? 'CBX'`) + +## K-12: PaginatorFactory: `getTotalItems()` kann `null` liefern → TypeError — Minor (claude) + +`PaginatorConfig::getTotalItems(): ?int` liefert `null` bei Default `-1`; `Paginator::__construct(int $totalItems)` ist nicht nullable — jeder API-Konsument mit Default-Config crasht. + +- `src/Paginator/Factory/PaginatorFactory.php:39` · `src/Paginator/PaginatorConfig.php:57-60` · `src/Paginator/Paginator.php:17-21` + +## K-13: `PaginatorConfig::getCurrentPageItemCount`: Off-by-one — Minor (beide Audits) + +`getLastItemNumber() - getFirstItemNumber()` ohne `+1` — Seite mit Items 1–10 meldet 9. + +- `src/Paginator/PaginatorConfig.php:158-161` + +## K-14: `TableAliasRegistry`: aktivierter, aber nicht registrierter Alias wird still übersprungen — Minor (codex) + +`resolveActiveJoins()` überspringt aktivierte Aliasse ohne registrierten Join kommentarlos, während `ConditionsModifierListener` die zugehörige Filterbedingung trotzdem anhängt — die Query referenziert dann einen nicht existierenden SQL-Alias (SQL-Fehler statt klarer Exception). + +- `src/Query/TableAliasRegistry.php:150-152` · `src/EventListener/QueryStructModifier/ConditionsModifierListener.php:30-37` + +## K-15: `FilterQueryBuilder`: Parameter-Prefixer ersetzt Tokens auch in String-Literalen — Minor, theoretisch (claude) + +`build()` schreibt `:name`-Tokens per Regex über den gesamten SQL-String um, ohne String-Literale (z. B. gequotete REGEXP-Muster aus `SqlHelper`) auszunehmen. Nur ausgelöst, wenn ein gleichnamiger Parameter existiert — fragil, derzeit kaum erreichbar. + +- `src/Query/FilterQueryBuilder.php:262-281` + +## K-16: Expliziter `pageParam` gleich dem Formularnamen wird ungefragt suffigiert — Minor (claude, teilweise entschärft) + +Ein explizit konfigurierter `pageParam`, der dem Formularnamen entspricht, wird kommentarlos mit `_page` suffigiert. (Der Teilaspekt „Vergleich läuft vor der Sanitisierung" ist behoben.) + +- `src/Engine/Projector/InteractiveProjector.php:194-197` + +## Beobachtungen (kein unmittelbarer Fix, aber weiterhin zutreffend) + +- **`which_ptable` wird transformiert, aber nie gelesen** (claude O1): Runtime-Inferenz basiert auf der Listen-Config, `buildDca()` auf dem Filter-Model. — `src/Filter/Element/BelongsToRelationFilterElement.php:55` (gesetzt) vs. `:63-104` (ungelesen) +- **`genericPageMeta` im Schema definiert, aber in `transform()` nicht gemappt** (claude O2). — `src/List/BaseListOptions.php:41` vs. `:44-64` +- **`PtableInferrer`: `explode('.', $foreignKey)` ohne Limit/Guard** (claude O4) — foreignKey ohne Punkt erzeugt „Undefined array key 1". — `src/InferPtable/PtableInferrer.php:180` + +## Querverweise (in anderen Kapiteln behandelt) + +- DBAL-Constraint erlaubt inkompatibles 2.13/3.0–3.5 (codex STAB-01): PS-03 in [40-performance-stabilitaet.md](40-performance-stabilitaet.md) +- Entry-Cache positional statt per ID (codex STAB-02): PS-02 ebd. +- Laufzeitfehler erst im Twig-Rendern, 200-vs-500-Inkonsistenz (codex STAB-03): PS-01/PS-05 ebd. +- Backend-Vorschau dereferenziert gelöschte Liste (codex STAB-04): PS-04 ebd. / A-15 in [10-architektur.md](10-architektur.md) +- Alias-Kollision im Collector/Builder (beide, N8): A-07 in [10-architektur.md](10-architektur.md); zusätzlich betroffen: `src/List/ListSpecBuilder.php:77-78` +- `AggregationLoader::fetchCount()` ohne int-Cast (beide, N10): PS-12 ebd. +- `ValidationLoader` liefert `[]` statt `null` (beide, N13): A-10 in [10-architektur.md](10-architektur.md) +- Terminal42: tote Klassen (claude O3): A-01 in [10-architektur.md](10-architektur.md) diff --git a/.audit/260719012-combined/30-sicherheit.md b/.audit/260719012-combined/30-sicherheit.md new file mode 100644 index 00000000..c1710dac --- /dev/null +++ b/.audit/260719012-combined/30-sicherheit.md @@ -0,0 +1,33 @@ +# Security & Query-Safety + +Kombinierte, am Stand `5940ad6` (2026-07-20) verifizierte Findings aus beiden Audits (claude 2607171801, codex 2607171755). Bereits behobene Punkte (u. a. der `?_preview`-Feld-Dump und die fehlende `model_table`-Verifikation beim Unmarshal) sind nicht mehr enthalten. Positive Beobachtungen: siehe [99-positive-punkte.md](99-positive-punkte.md). + +## SEC-01: Contao-Model-Registry kann Reader-/Listenfilter teilweise umgehen — Niedrig (beide Audits, teilweise entschärft) + +`fetchModel()` bedient sich zuerst aus Contaos globaler Model-Registry, bevor die durch FLARE-Filter abgesicherte Query läuft. Seit dem Audit wurde eine Prüfung des `published`-Flags ergänzt (`src/Engine/View/HandlesModelsTrait.php:24-28`), die den ursprünglichen Kernfall abfängt. **Nicht abgedeckt bleiben:** die `start`/`stop`-Zeitfenster, die der `PublishedFilterType` in der Query erzwingt (`src/Filter/Type/PublishedFilterType.php:32-45`), sowie sämtliche anderen konfigurierten Filterbedingungen — ein im selben Request ungefiltert gecachtes Modell mit `published=1`, aber abgelaufenem `stop`-Datum (oder außerhalb anderer Filterkriterien) wird über den Registry-Shortcut ausgeliefert, ohne dass die gefilterte Query je läuft. + +Beleg: `src/Engine/View/HandlesModelsTrait.php:20-29` + +## SEC-02: Generischer List-Driver ohne Published-Filter — „secure by default" fehlt — Info (beide Audits, teilweise entschärft) + +`NewsListDriver` und `EventsListDriver` fügen automatisch einen intrinsischen `PublishedFilterElement` hinzu (`src/List/Driver/NewsListDriver.php:51-58`, `src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php:64-69`); der `GenericDataContainerListDriver` nicht — eine generische Liste ohne konfigurierten Published-Filter liefert unpublizierte Datensätze an anonyme Besucher aus. Die empfohlene Backend-Warnung wurde inzwischen implementiert (`checkPublishedFilter()`, `src/List/Driver/GenericDataContainerListDriver.php:98-114`), hat aber eine Lücke: Sie läuft nur im `hasParent`-Zweig — für Listen **ohne** Parent greift der Early-Return in `buildDca()` (`src/List/Driver/GenericDataContainerListDriver.php:51-54`) vor dem Aufruf in Zeile 93, dort erscheint keine Warnung. Zudem ist es nur `Message::addInfo`, keine Warnung. + +## SEC-03: Unescapte Ausgabe in Backend-Vorschau-Responses — Niedrig [BE-Admin] (beide Audits, teilweise entschärft) + +Teilfixes seit dem Audit: ListView schleust `title`, Typ-Übersetzung und `dc` durch `strip_tags()` (`src/Controller/ContentElement/ListViewController.php:166-168`); der Headline-Tag-Name wird gegen eine Whitelist geprüft (`src/Util/Str.php:236-242`). **Weiterhin offen:** + +- Der Headline-**Wert** wird in beiden Controllern unescaped in HTML interpoliert (`src/Controller/ContentElement/ListViewController.php:165`, `src/Controller/ContentElement/ReaderController.php:231` — `Str::formatHeadline()` escapet den Text nicht). +- Der `ReaderController` gibt `$listModel->title` und `$listModel->dc` komplett roh aus, ohne `strip_tags`/Escaping (`src/Controller/ContentElement/ReaderController.php:229-235`). +- Beide `catch`-Blöcke geben rohe Exception-Messages als Response aus (`src/Controller/ContentElement/ListViewController.php:160`, `src/Controller/ContentElement/ReaderController.php:226`). + +## SEC-04: Zentrale Query-Struktur validiert SQL-Identifier nicht — Niedrig (codex) + +`ListExecutionContextFactory::create()` setzt `ListSpec::$dc` ungeprüft als `FROM` (`src/Query/Factory/ListExecutionContextFactory.php:29-44`). `SqlQueryStruct` nimmt Select-/Join-/Group-/Order-/Having-Fragmente als rohe Strings entgegen (`src/Query/SqlQueryStruct.php:55-146`); die Validator-Constraints prüfen nur `NotNull`/`NotBlank`/`Count`, keine Identifier-Form. `QueryBuilderFactory::create()` reicht alle Fragmente ungequotet an den DBAL-QueryBuilder durch (`src/Query/Factory/QueryBuilderFactory.php:30-64`). Die `Str::isValidSqlName()`-Prüfung der Tabelle läuft nur pro Filter in `FilterExecutor::invokeFilter()` (`src/Query/Executor/FilterExecutor.php:76-82`) — bei einer Liste ohne Filter gar nicht. Kein anonymer Angriffspfad (Driver/Events sind Erweiterungscode), aber die Factory erzwingt ihre eigenen Invarianten nicht — Defense in Depth gegen fehlerhafte Driver/Events/Redakteursdaten fehlt. + +## SEC-05: `composer audit || true` im Security-Workflow — Niedrig, Prozess (codex) + +Ein zukünftiges Advisory kann den CI-Job nie fehlschlagen lassen. Beleg: `.github/workflows/security.yaml:56`. (Siehe auch CI-04 in [50-tests-ci.md](50-tests-ci.md).) + +## SEC-06: Keine Testabdeckung der Sicherheits-Leitplanken in `src/Query/` — Info (claude) + +`tests/` enthält keinerlei Tests für `src/Query/`. Regressionen an `FilterQueryBuilder::column()`/`setParameter()` blieben still und wären sicherheitsrelevant. (Siehe auch T-01 in [50-tests-ci.md](50-tests-ci.md).) diff --git a/.audit/260719012-combined/40-performance-stabilitaet.md b/.audit/260719012-combined/40-performance-stabilitaet.md new file mode 100644 index 00000000..edeea700 --- /dev/null +++ b/.audit/260719012-combined/40-performance-stabilitaet.md @@ -0,0 +1,150 @@ +# Performance & Stabilität + +Kombinierte, am Stand `5940ad6` (2026-07-20) verifizierte Findings aus beiden Audits (claude 2607171801, codex 2607171755). Keines der Findings dieses Kapitels wurde seit dem Audit-Datum behoben. Positive Beobachtungen: siehe [99-positive-punkte.md](99-positive-punkte.md). + +## Stabilität + +### PS-01: Frontend-500 statt Degradierung — Query-/Filterfehler schlagen erst beim Twig-Rendern zu — Major (claude) + +Der Graceful-Catch umschließt nur Spec-/Engine-Bau; `createView()` (Count + Entries + Formular) läuft erst im Template. Eine `FilterException` zur Laufzeit propagiert als Twig-`RuntimeError`; der Render-Catch rethrowt alles außer eingebetteter `ResponseException` — die ganze Seite wird zum 500er. Kaputte Filter-Konfiguration eines einzelnen Elements darf nicht die Seite reißen: `createView()` in den Controller ziehen bzw. `FlareException` beim Rendern abfangen. (`AbortFilteringException` ist dagegen sauber gelöst — leere Liste.) + +- `src/Controller/ContentElement/ListViewController.php:95-116` (Catch nur um Bau), `:131` (Engine ans Template), `:136-151` (Render-Catch rethrowt) · `contao/templates/content_element/flare_listview.html.twig:7` · sauber: `src/Query/Executor/ListQueryDirector.php:60-67` + +### PS-02: Latenter Korrektheitsbug: Entry-Cache positional statt per ID indiziert — Major (claude) + +`ValidationLoader::fetchEntryById()` greift per `getEntryCache()[$id]` zu; die Cache-Closure aus `createFromInteractiveView()` liefert aber `InteractiveView::getEntries()` = rohes, positionsindiziertes `fetchAllAssociative()`-Resultat. Der Lookup trifft den Datensatz an *Position* `$id` — falscher Entry oder wirkungsloser Cache. `createFromInteractiveView()` ist öffentliche API. + +- `src/Engine/Loader/ValidationLoader.php:29` · `src/Engine/Context/Factory/ValidationContextFactory.php:45-51` · `src/Engine/Loader/InteractiveLoader.php:40` · `src/Engine/View/InteractiveView.php:54-57` + +### PS-03: DBAL-Constraint erlaubt Versionen, mit denen der Code fatal scheitert — Major (claude) + +`composer.json:12` erlaubt `doctrine/dbal ^2.13 || ^3.0 || ^4.0`; der Code nutzt `Doctrine\DBAL\ArrayParameterType` (erst ab DBAL 3.6) und `executeQuery()` (ab 3.1). Contao 4.13 kann DBAL 3.3–3.5 auflösen → „Class not found" zur Laufzeit. **Fix ist eine Zeile: `^3.6 || ^4.0`.** + +- `composer.json:12` · `src/Query/FilterQueryBuilder.php:7,123,160,202,206` + +### PS-04: Backend-Vorschau crasht mit TypeError bei gelöschter Liste — Major (claude) + +Siehe A-15 in [10-architektur.md](10-architektur.md): beide Backend-Responses dereferenzieren `$listModel` ohne Null-Guard außerhalb jedes try/catch (`src/Controller/ContentElement/ListViewController.php:154-170`, `src/Controller/ContentElement/ReaderController.php:220-236`). + +### PS-05: Fehlerpfade uneinheitlich: Reader wirft 500, Listview antwortet cachebare 200 — Medium (claude) + +`ReaderController` liefert für Fehler Status 500 bzw. `InternalServerErrorHttpException` (`src/Controller/ContentElement/ReaderController.php:74-82,149-155`); `ListViewController::getErrorResponse()` gibt für denselben Fehlertyp eine 200er-Response mit Fehlertext zurück — ohne `Cache-Control: no-store` (`src/Controller/ContentElement/ListViewController.php:63-71`). + +### PS-06: Stiller Schlucker: ungültige `sortSettings` werden lautlos zu null — Medium (claude) + +`SortOrderSequenceFactory::createFromList()` fängt die `FlareException` aus `createFromSettings()` und gibt kommentarlos null zurück — Liste rendert unsortiert, keine Logzeile. + +- `src/Sort/Factory/SortOrderSequenceFactory.php:21-28` + +### PS-07: Zustand in Shared Services: Caches wachsen prozessweit, kein `ResetInterface` — Medium (beide Audits) + +Ohne Invalidierung/Reset: `ArchiveFilterElement::$_inferrer` (gekeyt per `ListSpec::hash()`, wächst unbegrenzt), `FieldValueChoiceFilterElement::$foreignValueCache`/`$localValueCache` (stale Choices unter Worker-Runtimes), `CfgTagsJoinsRegistry::$entries` (akkumuliert), `DcaHelper` mit `static $dcTableCache`. `ResetInterface`/`kernel.reset` kommt in `src/` und `config/` nicht vor. + +- `src/Filter/Element/ArchiveFilterElement.php:34,399-404` · `src/Filter/Element/FieldValueChoiceFilterElement.php:31-32,263,300` · `src/Integration/CodefogTags/Registry/CfgTagsJoinsRegistry.php:14-18` · `src/Util/DcaHelper.php:64-77` + +### PS-08: Fehlendes Filter-Element wird auch bei intrinsischen Sicherheitsfiltern kommentarlos geskippt — Minor, sicherheitsrelevant (claude) + +Wirft `FilterFactory::createFromFilterModel()` (Element-Typ nicht registriert, Extension deinstalliert), loggt der Collector nur ein Warning und macht `continue` — auch für intrinsische Sicherheitsfilter wie `flare_published` → Liste zeigt ggf. Unveröffentlichtes (Sichtbarkeits-Leak). + +- `src/List/Collector/ListModelFilterCollector.php:56-71` · `src/Filter/Factory/FilterFactory.php:104-107` + +### PS-09: `PublishedFilterElement` ignoriert den Contao-Preview-Modus — Minor (claude) + +Immer `published`-/`start`-/`stop`-Bedingung mit `'now' => time()`, ohne `TokenChecker::isPreviewMode()`-Bypass (`TokenChecker` kommt in `src/` nicht vor) — unveröffentlichte Einträge sind in der offiziellen Frontend-Vorschau unsichtbar. + +- `src/Filter/Element/PublishedFilterElement.php:53-64` + +### PS-10: HTTP-Cache vs. zeitabhängige Filter: nur Tabellen-Tags — Minor (claude) + +Invalidierung ausschließlich über `contao.db.`-Tags; ein rein zeitgesteuerter `start`/`stop`-Wechsel invalidiert nichts. + +- `src/Controller/ContentElement/ListViewController.php:118` · `src/Filter/Element/PublishedFilterElement.php:62` + +### PS-11: MariaDB + `ONLY_FULL_GROUP_BY`: `SELECT main.* … GROUP BY main.id` — Minor (claude) + +MariaDB erkennt die funktionale Abhängigkeit vom PK nicht → Fehler 1055 bei aktivem `ONLY_FULL_GROUP_BY`. (Der Count-Pfad ist unbetroffen, da `SelectModifierListener` das GROUP BY entfernt.) + +- `src/Query/Factory/ListExecutionContextFactory.php:40-44` + +### PS-12: `AggregationLoader::fetchCount()` ohne int-Cast — Info (claude) + +`$count = $result->fetchOne() ?: 0;` direkt aus einer `int`-typisierten Methode returnt — liefert der Treiber (Emulation + stringify) einen String, gibt es unter `strict_types` einen TypeError. + +- `src/Engine/Loader/AggregationLoader.php:40-44` + +### PS-13: `FlareCollector::getSemVersion()` crasht bei null-Version — Info (claude) + +`data['version']` kommt aus `InstalledVersions::getVersion()` (kann null sein); `getSemVersion()` ruft `\explode('-', $this->data['version'])` ohne Guard — TypeError (nur mit aktivem Profiler relevant). + +- `src/DataCollector/FlareCollector.php:19,38-44` + +## Performance + +### PS-14: Query-/Filter-Pipeline läuft pro Request doppelt (Count + Daten) — Major (beide Audits) + +`InteractiveProjector::project()` erzeugt zuerst die AggregationView für den Count und danach den InteractiveLoader — beide Pfade laufen über `ListQueryDirector::createQueryBuilder()` und führen `FilterExecutor::invokeFilters()` komplett erneut aus, inkl. `FilterContextFactory::create()` mit OptionsResolver-`resolve()` pro Filter und Event-Dispatches. Filter-Elemente mit DB-Zugriff in `buildFilter()` zahlen doppelt; `ArchiveFilterElement` macht `findMultipleByIds`-Fetches zusätzlich ein drittes Mal in `buildForm()` (nur der `PtableInferrer` ist memoiert, `fetchParents()` nicht). Empfehlung: Filterquery-Fragmente request-scoped zwischen Count und Datenquery teilen. + +- `src/Engine/Projector/InteractiveProjector.php:50,61-68` · `src/Query/Executor/ListQueryDirector.php:48` · `src/Query/Executor/FilterExecutor.php:49-62` · `src/Filter/Element/ArchiveFilterElement.php:118,282-314,584-598` + +### PS-15: Calendar-Integration lädt die komplette Ergebnismenge unpaginiert — zweimal — Major/Hoch (beide Audits) + +`EventsInteractiveLoader` setzt `ContaoCalendar_doNotPaginate` (Listener entfernt LIMIT/OFFSET), holt alle Zeilen per `fetchAllAssociative()`, expandiert via `groupEntriesByDate()` und paginiert erst in PHP. `EventsAggregationLoader::fetchCount()` macht denselben Full-Fetch samt kompletter Expansion separat noch einmal. Zwei Full-Fetches + zwei Recurrence-Expansionen pro Request. Empfehlung: SQL-seitiges Zeitfenster. + +- `src/Integration/ContaoCalendar/Loader/EventsInteractiveLoader.php:31-48,50-86` · `src/Integration/ContaoCalendar/EventListener/DoNotPaginateModifierListener.php:15-19` · `src/Integration/ContaoCalendar/Loader/EventsAggregationLoader.php:30-58` + +### PS-16: Unbegrenzte Recurrence-Expansion (OOM-/CPU-Risiko) — Major/Hoch (beide Audits) + +`fillRecurringEvents()` läuft `while ($repeatDate <= $repeatEnd)` ohne Obergrenze; `fillInitialEvents()` legt per `DatePeriod` einen Eintrag pro Tag der gesamten Event-Dauer an. Ein minütlich wiederholtes Event mit fernem `repeatEnd` erzeugt Hunderttausende Array-Einträge pro Event — im Frontend-Request, auch im Count-Pfad. Redakteurs-Fehleingabe genügt für OOM. Empfehlung: harte Occurrence-Limits. + +- `src/Integration/ContaoCalendar/GroupsEntriesTrait.php:132-137,67-71` + +### PS-17: Partial-Templates triggern jeweils die volle Pipeline — Medium (beide Audits) + +Alle drei Partials setzen selbst `{% set flare_list = flare.createView %}`; `Engine::createView()` memoiert nichts. Formular/Liste/Paginator als drei Content-Elemente derselben Liste → 3× Spec-Bau, Formular-Bau, Count- und ggf. Entries-Query. + +- `contao/templates/content_element/flare_listview/form_only.html.twig:3`, `list_only.html.twig:3`, `paginator_only.html.twig:3` · `src/Engine/Engine.php:44-61` + +### PS-18: Count-Query läuft auch bei ungültig submittetem Formular; View meldet inkonsistenten Count — Minor (beide Audits) + +Siehe A-05 in [10-architektur.md](10-architektur.md): `$totalItems` wird vor der Validitätsprüfung berechnet und trotz `InteractiveEmptyLoader` unverändert an die View gereicht — `InteractiveView::getCount()` kann n > 0 bei leerer Liste melden (`src/Engine/Projector/InteractiveProjector.php:50,56-58,74-81`, `src/Engine/View/InteractiveView.php:49-52`). + +### PS-19: `ChoicesBuilder`: O(n²)-Wertauflösung via `array_search` — Minor (beide Audits) + +`buildChoiceValueCallback()` macht pro Choice ein lineares `array_search($choice, $this->choices, true)` — quadratisch beim Rendern großer Choice-Mengen; keine Reverse-Map. + +- `src/Form/ChoicesBuilder.php:251-266` + +### PS-20: `FieldValueChoiceFilterElement`: unbegrenzte DISTINCT-/Fremdtabellen-Scans — Minor/Mittel (beide Audits) + +`getLocalValues()` macht `SELECT DISTINCT CAST(… AS CHAR) … ORDER BY` ohne LIMIT über die ganze Tabelle; `getForeignValues()` lädt die komplette Fremdtabelle (`fetchAllKeyValue` mit `CONCAT`-Label, kein LIMIT). Ergebnis wird ungebremst zu Form-Choices; keine Begrenzung/Suche/Ajax-Pfad. + +- `src/Filter/Element/FieldValueChoiceFilterElement.php:298-325,261-295` + +### PS-21: DCA-`options_callback` läuft pro Request bis zu dreimal — Mittel (codex) + +`DcaSelectFieldFilterElement::getOptions()` (→ beliebige Contao-Callbacks) wird beim Formularbau und erneut beim Filterbau benötigt; da der Filterbau für Count und Daten doppelt läuft (PS-14), laufen Callbacks mit DB-Zugriff bis zu dreimal. Kein Request-Cache pro Tabelle/Feld. + +- `src/Filter/Element/DcaSelectFieldFilterElement.php:76,101,123,308` + +### PS-22: Suchfilter: unverankertes `LIKE '%…%'`, Term-Anzahl unbegrenzt — Info (beide Audits) + +Pro Term × Spalte ein nicht verankertes LIKE (kein Index nutzbar); Term-Anzahl aus User-Input unbegrenzt (nur Stopwords/Deduplizierung). Positiv: `makeTerms()` entfernt Wildcards (`%`, `_`) zuverlässig. + +- `src/Filter/Type/SearchKeywordsFilterType.php:37-47,55-64` + +### PS-23: Eager-Instanziierung aller Filter-Elemente über die Registry — Info (claude) + +Der Compiler-Pass injiziert echte Service-Referenzen per `addMethodCall('add', …)` — beim Instanziieren der `FilterElementRegistry` werden alle Elemente eager gebaut. Derzeit verschmerzbar; bei wachsendem Ökosystem auf ServiceLocator/lazy umstellen. + +- `src/DependencyInjection/Compiler/RegisterFilterElementsPass.php:39-48` + +### PS-24: `ListSpec::hash()` serialisiert die vollständige Config — Info (codex) + +`sha1(serialize([...]))` über Driver-Klasse, Typ, dc, source, komplette Config und alle Filter-Fingerprints — bei großen dynamischen Config-Arrays potenziell teuer; für typische Listen unkritisch. + +- `src/List/ListSpec.php:113-123` + +## Querverweise + +- Terminal42-Integration (toter Code): siehe A-01 in [10-architektur.md](10-architektur.md). +- `#[TaggedIterator]`-Deprecation: siehe A-13 in [10-architektur.md](10-architektur.md). diff --git a/.audit/260719012-combined/50-tests-ci.md b/.audit/260719012-combined/50-tests-ci.md new file mode 100644 index 00000000..a5a6010c --- /dev/null +++ b/.audit/260719012-combined/50-tests-ci.md @@ -0,0 +1,81 @@ +# Tests, CI & Tooling + +Kombinierte, am Stand `5940ad6` (2026-07-20) verifizierte Findings aus beiden Audits (claude 2607171801, codex 2607171755). Alle hier gelisteten Punkte wurden gegen den aktuellen Code geprüft und bestehen fort. Positive Beobachtungen: siehe [99-positive-punkte.md](99-positive-punkte.md). + +## T-01: Risikoreichste Subsysteme ohne jegliche Tests — Major (beide Audits) + +Die Testsuite besteht aus 22 Dateien (21 Testklassen + 1 Stub). Weiterhin vollständig ungetestet: + +- `src/Query/` — insbesondere `src/Query/FilterQueryBuilder.php` (SQL-Injection-Leitplanke, Identifier-Whitelist, Parameterbindung) und `src/Query/TableAliasRegistry.php` (rekursive JOIN-Auflösung), außerdem `src/Query/Executor/ListQueryDirector.php`, `src/Query/Executor/FilterExecutor.php` +- `src/Filter/Type/` — 0 von 11 konkreten Filter-Types getestet, obwohl namensgebendes Feature des Branches +- Filter-Elemente: nur 2 von 10 konkreten Elementen getestet (`ArchiveFilterElement`, `SimpleEquationFilterElement`); `BooleanFilterElement`, `DateRangeFilterElement`, `PublishedFilterElement`, `SearchKeywordsFilterElement` etc. ungetestet +- Engine-Pipeline (Contexts, Loader, Mods, Views, `EngineFactory`) — nur `tests/Engine/Projector/InteractiveProjectorTest.php` existiert +- `src/EventListener/QueryStructModifier/`, `src/Paginator/Paginator.php`, `src/Form/ChoicesBuilder.php` — 0 Tests +- `src/Util/`, `src/Reader/` (inkl. Marshal-Logik in `src/Reader/ReaderRequestAttribute.php`), `src/InferPtable/`, `src/Controller/`, `src/Sort/`, `src/DataContainer/`, `src/Twig/`, `src/Integration/` (alle), `src/DataCollector/`, `src/DependencyInjection/` +- List-Driver: `src/List/Driver/NewsListDriver.php`, `src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php` + +Alles überwiegend pure PHP-Logik und gut unit-testbar. Regressionstests für die validen Korrektheits-Findings (siehe [20-korrektheit.md](20-korrektheit.md)) sollten gleich mitgenommen werden. + +## T-02: Stub-Klassen nicht autoloadbar — Einzeldatei-Testläufe brechen — Minor/Mittel (beide Audits) + +`FilterModelStub` ist in `tests/Filter/Element/SimpleEquationFilterElementTest.php:82` definiert, wird aber in `tests/Filter/Element/ArchiveFilterElementTest.php:95` benutzt; `ListModelStub` ist in `tests/List/BaseListOptionsTest.php:76` definiert, wird in `tests/List/ListSpecBuilderTest.php:106` und `:165` benutzt. Dateiname ≠ Klassenname → PSR-4-autoload-dev kann sie nicht auflösen; isolierte Einzeldatei-Läufe und randomisierte Reihenfolge sind fragil. Vorlage für den Fix existiert bereits: `tests/List/StubFilterElement.php` (eigene Datei). + +## T-03: `phpunit.xml.dist` ohne `executionOrder="random"` / `beStrictAboutOutputDuringTests` — Minor (beide Audits) + +`phpunit.xml.dist:2-8` enthält nur `failOnRisky`/`failOnWarning`; keine Random-Order, kein `resolveDependencies`, kein `beStrictAboutOutputDuringTests`. Random-Order würde das Stub-Problem (T-02) sofort aufdecken. + +## T-04: `symfony/phpunit-bridge` in require-dev, aber nicht im Bootstrap — Minor (claude) + +`phpunit.xml.dist:4` bootstrapt plain `vendor/autoload.php`; `composer.json:39` deklariert `symfony/phpunit-bridge` — kein Deprecation-Tracking. + +## T-05: Coverage konfiguriert, aber nirgends erzeugt — Info (claude) + +`phpunit.xml.dist:19-26` definiert den Coverage-Filter, aber alle Workflows setzen `coverage: none`; kein Coveralls-Upload trotz `php-coveralls` in require-dev. + +## T-06: Keine DataProvider in der Suite — Info (claude) + +0 Treffer für `dataProvider`/`DataProvider` in `tests/`. Geschmackssache, bei Transformer-/Boolean-/Choice-Tests aber deutlich kompakter. + +## CI-01: PHPUnit läuft nur auf PHP 8.2 mit Highest-Deps — keine Runtime-Matrix — Major (beide Audits) + +`.github/workflows/phpunit.yaml:24` pinnt `php-version: '8.2'`; `composer update` (`:41`) installiert Highest-Deps; kein `--prefer-lowest`, kein Contao-4.13-Lauf, keine Matrix — obwohl `composer.json:7,10` PHP ^8.2 × Contao ^4.13||^5.0 verspricht. Die Compatibility-Matrix (`.github/workflows/compatibility.yaml:20-26`) prüft nur `composer update --dry-run` (`:50-52`), nie Verhalten. + +## CI-02: Compatibility-Matrix durch `continue-on-error: true` entwertet — Major (beide Audits) + +`.github/workflows/compatibility.yaml:16` setzt `continue-on-error: true` auf Job-Ebene — jede rote Matrix-Zelle wird grün durchgewunken. Ausgerechnet der einzige Workflow mit `pull_request`-Trigger (`:4`) ist damit dekorativ. + +## CI-03: Kein `pull_request`-Trigger auf PHPUnit/PHPStan/Mago — Fork-PRs ungeprüft — Minor (beide Audits) + +`.github/workflows/phpunit.yaml:3-11`, `.github/workflows/phpstan.yaml:3-11` und `.github/workflows/mago.yaml:3-11` triggern nur auf `push` + `workflow_dispatch`. Fork-PRs laufen ohne Tests und Statik. + +## CI-04: `composer audit || true` kann nie fehlschlagen — Minor/Mittel (beide Audits) + +`.github/workflows/security.yaml:56` enthält `composer audit || true` — Advisories werden nie zum Gate. (Semgrep failt dagegen korrekt via `--error`, `security.yaml:68`.) + +## CI-05: Mago lintet die Tests auf dem Branch nicht mehr — Niedrig (codex) + +Branch-Regression: `mago.toml:6` enthält nur noch `paths = ["src/"]`; auf `main` steht `paths = ["src/", "tests/"]`. Die neue Testsuite wird nicht gelintet/formatiert. + +## ST-01: PHPStan-Ignores zu breit — Minor/Niedrig (beide Audits) + +`phpstan.neon:25` und `:29` ignorieren `Access to an undefined property Contao\…Model::$…` bzw. undefined static methods repo-weit ohne `path`-Eingrenzung — echte Tippfehler in `src/` werden verschluckt. `phpstan.neon:19-20` ignoriert `class.notFound` für ganz `src/Integration/` (auch hausgemachte Klassen in ContaoCalendar/ContaoNews/ContaoComments); `phpstan.neon:13` schließt `src/Integration/Terminal42Languages` komplett aus. + +## ST-02: `phpVersion: 80200` — PHPStan sieht keine 8.4/8.5-Deprecations — Info (claude) + +`phpstan.neon:7`; teilkompensiert durch Magos Multi-Version-Lint (`mago.yaml:57-75`). + +## D-01: Tote Dev-Dependencies — Minor (claude) + +Per Grep über `tests/`, `src/`, `.github/` verifiziert (0 Treffer): `contao/test-case` (`composer.json:33`), `heimrichhannot/contao-test-utilities-bundle` (`:35`), `php-coveralls/php-coveralls` (`:37`, kein Coverage-Workflow) und `symfony/phpunit-bridge` (`:39`, nicht im Bootstrap) werden nirgends benutzt. + +## D-02: PHPUnit-Constraint `^8.0 || ^9.0` — `^8`-Standbein stale — Minor (claude) + +`composer.json:36`; `phpunit.xml.dist:3` nutzt das 9.5-Schema, AGENTS.md dokumentiert PHPUnit 9. + +## D-03: CSRF-Komponente in Tests nur transitiv deklariert — Info (claude) + +`tests/Form/FilterFormFactoryTest.php:29` importiert `Symfony\Component\Security\Csrf\CsrfTokenManager`; `symfony/security-csrf` fehlt in `composer.json` (kommt nur transitiv über `contao/core-bundle`). + +## M-01: Makefile-`.PHONY` unvollständig; Catch-all schluckt Tippfehler — Minor (claude) + +`Makefile:1` listet `phpstan`/`phpstan-pro` (`Makefile:17-21`) nicht in `.PHONY`. Catch-all `%: @:` (`Makefile:52-53`) beendet Tippfehler lautlos mit Exit 0. diff --git a/.audit/260719012-combined/60-contao-integration-doku.md b/.audit/260719012-combined/60-contao-integration-doku.md new file mode 100644 index 00000000..f2e7171f --- /dev/null +++ b/.audit/260719012-combined/60-contao-integration-doku.md @@ -0,0 +1,111 @@ +# Contao-Integration, Public API, Doku & Kompatibilität + +Kombinierte, am Stand `5940ad6` (2026-07-20) verifizierte Findings aus beiden Audits (claude 2607171801, codex 2607171755). Bereits behobene Punkte (u. a. `mergePalettes`-No-Op, `huh.flare.list_type`-Alt-Tags, Attribut-Fallback für feste Driver-Tabellen, Terminal42-Doku-Markierung) sind nicht mehr enthalten. Positive Beobachtungen: siehe [99-positive-punkte.md](99-positive-punkte.md). + +## C-01: Doku-Beispiele erzeugen Fatal Error: `DcaBuilder` statt `DcaBuilderInterface` — Major (beide Audits) + +Alle `buildDca()`-Beispiele typisieren den Parameter als konkrete Klasse; `DcaContract` verlangt das Interface (`src/Contract/DcaContract.php:18`) → Kontravarianz-Verletzung, Fatal Error beim Copy-Paste. + +- `docs/docs/dev/dca-builder.md:15,77` · `docs/docs/dev/contracts/dca-contract.md:10,30` · `docs/docs/dev/filter-elements/index.md:125,226` · `docs/docs/dev/list-types/index.md:200` · `docs/docs/migrating-from-v0.1.md:140` + +## C-02: Intrinsic-Handling ist Element-Verantwortung — Drittanbieterfalle — Major (beide Audits, teilentschärft) + +Die Form-Factory filtert intrinsische Filter nicht zentral (`src/Filter/Factory/FilterFormFactory.php:60-121`); `AbstractFilterElement::buildForm()` ist No-Op-Default (`src/Filter/Element/AbstractFilterElement.php:51`). Ein Dritt-Element ohne eigenen `$context->config['intrinsic']`-Check rendert Formfelder für intrinsische Filter im Frontend. Der Dev-Guide dokumentiert das Muster inzwischen inkl. Beispiel (`docs/docs/dev/filter-elements/index.md:145-146,256-262`) — der Interface-Docblock nennt das Pflichtmuster aber weiterhin nicht (`src/Filter/Element/FilterElementInterface.php:13-24`), und ein zentraler Guard fehlt. Zusammen mit dem stillen Skip fehlender intrinsischer Elemente (PS-08 in [40-performance-stabilitaet.md](40-performance-stabilitaet.md)) ein potentielles Sichtbarkeits-Leak. + +## C-03: Terminal42-/DcMultilingual-Integration halb verdrahtet — tote DB-Felder — Major (beide Audits, teilentschärft) + +Code-Seite siehe A-01 in [10-architektur.md](10-architektur.md). Zusätzlich auf DCA-Seite: `tl_content.flare_dcMultilingualDisplay` definiert (`contao/dca/tl_content.php:76-85`), in keiner Palette (`:92-99`); `tl_flare_list.dcMultilingual_display` definiert (`contao/dca/tl_flare_list.php:288-297`), in keiner Palette (`:317-323`); Label für `flare_generic_dc_multilingual` fehlt in `translations/flare_list.{de,en}.php`. Es entstehen SQL-Spalten, die kein Redakteur sieht. Doku/README markieren die Integration inzwischen korrekt als disabled — die Entscheidung „aktivieren oder ausbauen" steht aus. + +## C-04: Boolean-Element: Backend-Select zeigt rohe Übersetzungs-Keys, Feld-Labels fehlen — Major (claude) + +`preselect`-Options nutzen die Keys `flare.bool_preselect.{null,true,false}`, die nirgends definiert sind (weder `translations/` noch `contao/languages/`); Contao übersetzt Options-Labels nicht automatisch. Zusätzlich fehlen Labels für `boolMode`/`boolBinaryChoices` in beiden Sprachdateien. + +- `src/Filter/Element/BooleanFilterElement.php:117-130` · Felder `contao/dca/tl_flare_filter.php:616,630` · keine Label-Einträge in `contao/languages/{de,en}/tl_flare_filter.php` + +## C-05: DBAL-2-Versprechen nicht erfüllt; Compatibility-CI toleriert alle Fehler — Mittel (codex) + +`composer.json:12` erlaubt `doctrine/dbal ^2.13`, der Code nutzt `Doctrine\DBAL\ArrayParameterType` (erst ab DBAL 3.6): `src/Query/FilterQueryBuilder.php:7,123,160,202,206`, `src/Filter/Type/ArchiveFilterType.php:7,29`, `src/Filter/Type/IntegerIdChoiceFilterType.php:7`. Die Compatibility-Matrix läuft mit `continue-on-error: true` und prüft nur `composer update --dry-run`. (Fix: PS-03 in [40-performance-stabilitaet.md](40-performance-stabilitaet.md); CI: CI-01/CI-02 in [50-tests-ci.md](50-tests-ci.md).) + +## C-06: Verhaltensänderungen fehlen in der Migrationsdoku: stiller Alias-Skip & Intrinsic-Verlagerung — Minor (beide Audits) + +Aliase, die kein gültiger Symfony-Formname sind, werden still nicht gemountet (`src/Filter/Factory/FilterFormFactory.php:62-64`); das ist nur als Code-Docblock erklärt (`src/Util/Str.php:110-119`), nicht in `docs/docs/migrating-from-v0.1.md`. Gleiches gilt für die Intrinsic-Verantwortungsverlagerung (C-02) — beide Verhaltensänderungen gegenüber `main` fehlen auf der Migrationsseite. + +## C-07: Übersetzungs-Domain-Mismatch bei Fehlermeldungen — Minor (claude) + +Beide Controller fragen `ERR.flare.listview.malconfigured` mit Domain `contao_modules` an; definiert ist der Key in der Default-Sprachdatei (Domain `contao_default`) — funktioniert nur, weil Contao diese global lädt. Der Reader nutzt zudem denselben „list view"-Text. (Statuscode-Inkonsistenz 200 vs. 500: PS-05 in [40-performance-stabilitaet.md](40-performance-stabilitaet.md).) + +- `src/Controller/ContentElement/ListViewController.php:69-70` · `src/Controller/ContentElement/ReaderController.php:80-81` · `contao/languages/{de,en}/default.php:73` + +## C-08: DateRange: Optionen ohne Backend-Repräsentation, Palette ohne Legende — Minor (claude, teilentschärft) + +`from_enabled`/`to_enabled` sind im Schema definiert (`src/Filter/Element/DateRangeFilterElement.php:37-38`), aber weder `transformFilterModel()` (`:41-46`) noch ein DCA-Feld setzt sie — nur programmatisch nutzbar. Palette ohne `{filter_legend}` (`:93`). (Inzwischen immerhin dokumentiert: `docs/docs/reference/filter-elements.md:13`. Funktionsloser `intrinsic`-Modus: K-10 in [20-korrektheit.md](20-korrektheit.md).) + +## C-09: Übersetzungen: DE/EN-Lücken, Waisen, Tippfehler — Minor (claude) + +- EN fehlt der Eintrag für `cfg_tags_search` (DE: `translations/flare_filter.de.php:19`). Abgeschwächt: das Element ist via `isSupported(): false` im Backend ausgeblendet. +- Verwaist: `useTablePtable` in `contao/languages/{de,en}/tl_flare_filter.php:25` (keine DCA-/Code-Referenz). +- Ungenutzt: `filter.limited_scope.*`, `filter.scope.*`, `filter.info.alias` in `translations/flare.{de,en}.yaml`; auch `filter.info.intrinsic.yes|no` scheinen ungenutzt. +- Tippfehler: „This filter **ist** not intrinsic" — `translations/flare.en.yaml:31`. + +## C-10: Literal-Key-Trick in `messages.{de,en}.yaml` unkommentiert — Info (claude) + +Die Keys `Listen (FLARE)`/`Listings (FLARE)` spiegeln die MOD-Labels (`contao/languages/de/modules.php:7`) und brechen still bei Label-Änderung; `src/EventListener/BackendMenuBuildListener.php:30-33` string-matcht zusätzlich am `(FLARE)`-Suffix. Ein erklärender Kommentar fehlt. + +- `translations/messages.de.yaml:1` · `translations/messages.en.yaml:1` + +## C-11: Stale Excludes in `services.yaml` — Minor (claude) + +`../src/{…,Dto,…,Trait,…}` wird exkludiert — beide Verzeichnisse existieren nicht. + +- `config/services.yaml:14` + +## C-12: Tote Klasse `DateRangeFormType` inkl. verwaister Validator-Keys — Minor (claude) + +Nur Selbstreferenzen; die einzig dort genutzten Keys `flare.form.date_range.from_invalid|to_invalid` (`src/Form/Type/DateRangeFormType.php:95,102`) stehen noch in `translations/validators.{de,en}.yaml`. + +- `src/Form/Type/DateRangeFormType.php:14` + +## C-13: `CodefogTagsSearchElement` als Stub im Auslieferungszustand — Info (claude) + +`isSupported(): false`, zwei `TODO`-Bodies, als einziges Element nicht auf `…FilterElement`-Suffix umbenannt. Doku markiert es als disabled (`docs/docs/reference/filter-elements.md:32`). + +- `src/Integration/CodefogTags/FilterElement/CodefogTagsSearchElement.php:16-38` + +## C-14: Dokumentierte Builder-API `getDc()` existiert nicht — Minor (beide Audits) + +Der Text bewirbt `getDc()` auf dem Builder; `ListSpecBuilder` (`src/List/ListSpecBuilder.php:39-122`) und das Interface besitzen keine solche Methode. Die dc-Auflösung passiert erst in `ListSpecFactory::resolveDataContainer()`. + +- `docs/docs/dev/list-types/index.md:141-144` + +## C-15: `field()` liefert laut Doku `DcaFieldBuilder`, Interface liefert `DcaFieldBuilderInterface` — Minor (claude) + +- `docs/docs/dev/dca-builder.md:39` vs. `src/DataContainer/Builder/DcaBuilderInterface.php:17` + +## C-16: AGENTS.md/CLAUDE.md verwendet alte Namen — Minor (beide Audits) + +`ListBuilderFactory`/`ListBuilder` (tatsächlich `ListSpecBuilderFactory`/`ListSpecBuilder`), `#[AsListType]` (tatsächlich `AsListDriver`), `ListTypeRegistry` (tatsächlich `ListDriverRegistry`). (Vollständige Liste inkl. `FilterElementResolver`/EngineFactory: A-02 in [10-architektur.md](10-architektur.md).) + +- `AGENTS.md:21,39,60,71` + +## C-17: Kleinere Schönheitsfehler — Info (claude) + +- Fallback-Label `'CBX'` erreicht ungefiltert das Frontend: `src/Filter/Element/BooleanFilterElement.php:56` (= K-11) +- `Message::addError(...)` hartkodiert Englisch (`src/Filter/Element/BooleanFilterElement.php:136`), während `src/List/Driver/GenericDataContainerListDriver.php:111` sauber den Translator nutzt +- Docblocks verweisen auf nicht existentes `configureDca()` (tatsächlich `buildDca`): `src/EventListener/Contao/ElementDcaListener.php:24`, `src/Event/ElementDcaEvent.php:12` +- Palette enthält `guests` — Feld existiert in Contao 5 nicht mehr: `contao/dca/tl_content.php:89` + +## C-18: Offene Doku-Wünsche — Niedrig (codex) + +- Skalierungsgrenzen für `FieldValueChoice` (DISTINCT-Werte) und Calendar nicht dokumentiert (`docs/docs/reference/filter-elements.md:12,16`) +- Suchverhalten (OR-Semantik, Stoppwörter, Sonderzeichen) nicht spezifiziert (`docs/docs/reference/filter-elements.md:19`) +- Expliziter Hinweis fehlt, dass der Generic-Driver keine Published-/Access-Filter ergänzt (`docs/docs/reference/list-types.md:11-13`); teilentschärft durch die neue Backend-Info-Meldung (`src/List/Driver/GenericDataContainerListDriver.php:97-113`, siehe SEC-02 in [30-sicherheit.md](30-sicherheit.md)) + +## C-19: DX-Reibungspunkte — Info (claude) + +- `AbstractFilterElement` erzwingt `transformFilterModel()` als abstract — rein programmatische Elemente müssen eine leere Methode implementieren (`src/Filter/Element/AbstractFilterElement.php:47`) +- Elemente ohne `DcaContract` erhalten kommentarlos die nackte Prefix/Suffix-Palette, kein Hinweis-Log (`src/EventListener/Contao/ElementDcaListener.php:96-104`) + +## Querverweise + +- Stop-Word-Feature tot (`huh_flare.search_stop_words.{locale}` existiert nie): K-07 in [20-korrektheit.md](20-korrektheit.md) +- Backend-Ansicht ohne Null-Guard auf `$listModel`: PS-04 in [40-performance-stabilitaet.md](40-performance-stabilitaet.md) / A-15 in [10-architektur.md](10-architektur.md) diff --git a/.audit/260719012-combined/99-positive-punkte.md b/.audit/260719012-combined/99-positive-punkte.md new file mode 100644 index 00000000..74bc83b7 --- /dev/null +++ b/.audit/260719012-combined/99-positive-punkte.md @@ -0,0 +1,69 @@ +# Positive Punkte (nicht actionable) + +Positivbefunde aus beiden Audits (claude 2607171801, codex 2607171755), am Stand `5940ad6` (2026-07-20) nachgeprüft und weiterhin zutreffend. Bewusst aus den actionable Dateien herausgehalten — dieser Katalog dient dazu, dass diese Punkte in künftigen Reviews nicht erneut als Verdachtsfälle aufschlagen. + +## Architektur & Design + +- Kern-DTOs `ListSpec` und `Filter` sind `final readonly` mit `with*()`-Kopiersemantik (`src/List/ListSpec.php:26`, `src/Filter/Filter.php:21`); `type` und `dc` sind explizite Spec-Properties und fließen zusammen mit Driver-Klasse, Source, Config und Filter-Fingerprints in den Spec-Hash ein (`src/List/ListSpec.php:113-122`). *(beide)* +- `ListSpecFactory` ist der zentrale Konstruktionspfad für Typ-, Driver-, Config- und Data-Container-Auflösung; `OptionsResolver` an den Konstruktionsgrenzen macht Configfehler früh sichtbar. *(beide)* +- Transformer-Caches sind nach `(type, class)`-Paar getrennt memoiziert (`src/Filter/Resolver/FilterTransformerResolver.php:35-47`) — kein aliasübergreifendes Teilen von Konfiguration. *(ursprüngliches Audit-Finding, inzwischen gefixt)* +- Compiler-Passes exponieren Typ-Services als Aliase auf die Original-Definition (`src/DependencyInjection/Compiler/RegisterListDriversPass.php:47`, analog `RegisterFilterElementsPass.php:47`) — Container und Registry liefern dieselbe Instanz. *(claude)* +- Erweiterbarkeit intern bewiesen: Die ContaoCalendar-Integration ersetzt Projector/Loader/View ausschließlich über `supports()`/`priority()` (`src/Integration/ContaoCalendar/Projector/EventsInteractiveProjector.php:25-30`), ohne Kern-Services zu überschreiben. *(beide)* +- Collect-only `FilterFormBuilder` mit klaren Fehlerbarrieren (`addEventSubscriber(): never`, `getForm(): never` — `src/Form/FilterFormBuilder.php:65,74`). *(claude)* +- Named-Dispatch-Events als klare Alternative zu Service-Overrides; einheitliches, schlankes Muster (`src/EventListener/NamedDispatch/`). *(beide)* +- Lifecycle-Taxonomie `configure*` vs. `build*` konsequent über Filter-Elemente und List-Driver durchgezogen. *(beide)* +- Export-Achse bewusst unimplementiert und konsistent verdrahtet: `ExportProjector::supports()` → `false` (`src/Engine/Projector/ExportProjector.php:17-19`), keine broken References. *(claude)* +- DI-Tags auf einheitlichen `flare.*`-Namespace konsolidiert; Event-Klassen durchgängig im readonly-Property-Stil (Ausnahme by design: Render-Event-Familie mit `ModifiesTemplateTrait`). *(claude)* + +## Security & Query-Safety + +- Identifier-Validierung durchgängig: `FilterQueryBuilder::column()` erzwingt Regex `^[a-zA-Z0-9_]+$` + `quoteIdentifier()` (`src/Query/FilterQueryBuilder.php:51-57`); rohe SQL-Fragmente der FilterTypes interpolieren nur das validierte Ergebnis (z. B. `src/Filter/Type/PublishedFilterType.php:34,42`). +- Werte strikt parametrisiert: Parameternamen regex-validiert (`src/Query/FilterQueryBuilder.php:127`), Prefix-Rewriting ebenfalls (`:252`); Werte gelangen nie als String-Literal in SQL. **Keine SQL-Injection über anonyme Frontend-Requests gefunden.** *(beide)* +- ORDER BY abgesichert: `SortOrder` validiert Alias und Spalte via `Str::isValidSqlName()` (`src/Sort/SortOrder.php:134-138`). +- Serialisierte Spaltensuche gehärtet: `SqlHelper::findInSerializedArrayColumn()` nutzt `preg_quote` und quotet das Pattern über die Connection (`src/Util/SqlHelper.php:16,23`). +- Keine PHP-Object-Injection: alle nativen `unserialize()`-Aufrufe mit `['allowed_classes' => false]` (`src/Sort/SortOrder.php:65`, `src/Paginator/PaginatorConfig.php:204`). +- CSRF-Design korrekt: Filterformular bewusst GET ohne CSRF-Token für idempotente Queries (`src/Filter/Factory/FilterFormFactory.php:45`). +- Paginator-Input gehärtet: Seite via `query->getInt()`, Parameternamen sanitisiert (`src/Paginator/Factory/PaginatorFactory.php:38,127-138`). +- News-/Events-Driver fügen automatisch einen intrinsischen Published-Filter hinzu (`src/List/Driver/NewsListDriver.php:51-58`, `src/Integration/ContaoCalendar/ListDriver/EventsListDriver.php:64-69`). +- Tabellenname wird pro Filterausführung validiert (`src/Query/Executor/FilterExecutor.php:76-82`). +- Baseline zum Audit-Zeitpunkt: Semgrep 0 Findings, Composer Audit ohne Advisories. + +## Korrektheit + +- Die `abort()`-Muster (`FilterBuilder::abort()` / `FilterQueryBuilder::abort()` als `never`-werfende Methoden) sind korrekt; verdächtig aussehende `if (!$x = …) { $builder->abort(); }`-Konstrukte sind unproblematisch (`src/Query/FilterQueryBuilder.php:69-73`). +- Keine OR/AND-Präzedenzfalle: Conditions werden durchgängig über DBALs `CompositeExpression` kombiniert, die bei ≥2 Teilen jeden Teil einklammert (`src/Query/FilterQueryBuilder.php:245`, `src/EventListener/QueryStructModifier/ConditionsModifierListener.php:50-56`). +- `setParameter(':name', …)` mit führendem Doppelpunkt ist unschädlich (`ltrim($param, ':')`, `src/Query/FilterQueryBuilder.php:125`). +- Geprüfter Nicht-Bug: Callbacks interner Funktionen (`array_filter`/`array_map`) laufen coercive — der `fn (string $key)`-Callback in `PaginatorFactory` mit numerischen Query-Keys ist kein TypeError (empirisch bestätigt; `src/Paginator/Factory/PaginatorFactory.php:79-83`). +- Als geprüft-in-Ordnung bestätigt: OptionsResolver-Memoisierung, `FilterContext::SINGLE_VALUE = '0'` als Formkey, `collectFilterData`-Pfade, Build-Reihenfolge des `ListSpecBuilder` (Overrides gewinnen wie dokumentiert), `TransformerResolver`-Fastpath, topologische Join-Sortierung inkl. `requires` (`src/Query/TableAliasRegistry.php:176-207`), `FilterModel::findByPid` nie null-foreach. + +## Performance & Stabilität + +- COUNT-/Daten-Trennung korrekt: der Count-Pfad läuft ohne ORDER BY, LIMIT/OFFSET und GROUP BY (`SelectModifierListener` setzt `COUNT(DISTINCT main.id)` + `setGroupBy(null)`; `PageModifierListener`/`OrderModifierListener` steigen bei `isCounting` früh aus). +- `AbortFilteringException` sauber gelöst: `src/Query/Executor/ListQueryDirector.php:60-67` fängt sie, loggt debug und liefert eine leere Liste statt eines Fehlers. +- Wildcard-Entschärfung der Suche wirksam: `SearchKeywordsFilterType::makeTerms()` entfernt `%`/`_` zuverlässig aus User-Input (`:55-64`). +- Kein N+1 auf Model-Ebene: `HandlesModelsTrait::createModelsFromEntries()` hydratisiert aus dem geladenen Resultset; Reader-URLs werden pro ID gecacht (`src/Engine/View/LinksToReaderTrait.php:36-50`). +- Memoization der `configure*`-Familie funktioniert wie dokumentiert (`SchemaResolver` pro Key, `src/Filter/FilterBuilder.php:18` statisch). +- Pagination korrekt via LIMIT/OFFSET; Offset nie negativ (`src/Paginator/PaginatorConfig.php:26-28`); Reader-Lookup effektiv mit LIMIT 1 (`src/Engine/Context/ValidationContext.php:35`). +- Indizes auf `tl_flare_filter` decken die Zugriffe ab (`contao/dca/tl_flare_filter.php:21-26`). +- Exception-Hygiene: breite `catch (\Throwable)` in `FilterExecutor` und den Loadern wrappen konsequent in `FilterException`/`FlareException` mit Quellen-Metadaten; `FilterOptionsResolver` (`src/Filter/Resolver/FilterOptionsResolver.php:35-47`) liefert vorbildliche Fehlermeldungen. +- Unbekannter Listentyp degradiert sauber (Collector → null, Controller-Graceful-Path greift). +- `FlareCollector` läuft nur mit aktivem Profiler — kein Produktions-Overhead. + +## Tests & Tooling + +- Testqualität vorbildlich: nur 2 `createMock`-Aufrufe in der gesamten Suite; echte Kollaborateure (echte Form-Factory inkl. CSRF-Extension, echter `EventDispatcher`) und echte Ergebnis-Assertions statt Interaktionsprüfung. +- Präzise Edge-Cases und Schema-Roundtrip-Tests („Transform erfüllt das eigene Schema") mit realistischen Contao-Daten (`serialize()`-Blobs, Checkbox-`'1'`/`''`, String-IDs); schnelle Suite ohne Framework-Boot. +- `phpunit.xml.dist` setzt `failOnRisky`/`failOnWarning`, `error_reporting=-1`; Coverage-Filter konsistent mit PHPStan-Excludes. +- PHPStan Level 5 mit `bleedingEdge` + Symfony-Extension, ohne Baseline-Datei — keine versteckten Altlasten. +- Semgrep mit `--error` tatsächlich verpflichtend (`.github/workflows/security.yaml:68`); Mago lintet streng (`--minimum-fail-level note`) über PHP 8.2–8.5, Version gepinnt. +- `composer validate --strict` in den Workflows; Composer-Caching und Path-Filter konsistent; Makefile konsistent mit AGENTS.md. + +## Contao-Integration, Doku & DX + +- Übersetzungs-Rename sauber: `translations/flare_filter.{de,en}.php` und `flare_list.{de,en}.php` nutzen `::TYPE`-Klassenkonstanten direkt als Keys — verwaiste Typ-Keys strukturell ausgeschlossen. +- Migrationsdoku vorhanden und substanziell: `docs/docs/migrating-from-v0.1.md`, `docs/docs/removed-in-v0.2.md`; Named-Dispatch-Muster in `docs/docs/dev/events.md` dokumentiert. +- Erweiterbarkeits-DX gut: eigenes FilterElement mit `#[AsFilterElement]` + `AbstractFilterElement` in wenigen Zeilen; `registerAttributeForAutoconfiguration` wirkt auch für Fremd-Bundles (`src/DependencyInjection/HeimrichHannotFlareExtension.php:58-70`); reservierte Typnamen werden validiert (`src/DependencyInjection/Compiler/RegisterListDriversPass.php:57-59`). +- Bundle-Bootstrap korrekt und vollständig: `contao/config/config.php`, Backend-Modul, `ContaoManager\Plugin`, Compiler-Passes; bedingte Integration-Loads passen zu `config/integrations/*.yaml`. +- Template-↔-View-Datenvertrag konsistent (`flare_listview.html.twig`/`flare_reader.html.twig` gegen die View-Klassen); Twig-Globals `flare_str`/`flare_env` verdrahtet. +- Der v0.1-Snapshot unter `docs/versioned_docs/` dokumentiert absichtlich die Alt-API — kein Drift-Problem. +- Seit dem Audit verbessert: Intrinsic-Muster im Filter-Element-Guide dokumentiert (`docs/docs/dev/filter-elements/index.md:256-262`); Generic-Driver zeigt Backend-Info bei fehlendem Published-Filter (`src/List/Driver/GenericDataContainerListDriver.php:111`); Terminal42-Integration in Doku/README als disabled markiert. diff --git a/.github/workflows/phpunit.yaml b/.github/workflows/phpunit.yaml new file mode 100644 index 00000000..0a8573b1 --- /dev/null +++ b/.github/workflows/phpunit.yaml @@ -0,0 +1,51 @@ +name: PHPUnit + +on: + push: + branches-ignore: + - 'docs/**' + paths: + - '**.php' + - 'composer.json' + - 'phpunit.xml.dist' + workflow_dispatch: ~ + +jobs: + test: + name: Unit Tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + tools: composer + coverage: none + + - name: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Restore Composer cache + id: composer-cache-restore + uses: actions/cache/restore@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-8.2-${{ hashFiles('composer.json') }} + restore-keys: composer-8.2- + + - name: Install dependencies + run: composer update --no-progress --prefer-dist + + - name: Save Composer cache + if: always() && steps.composer-cache-restore.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-8.2-${{ hashFiles('composer.json') }} + + - name: Run PHPUnit + run: vendor/bin/phpunit diff --git a/AGENTS.md b/AGENTS.md index 230ae474..6038a324 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,9 +30,19 @@ Note: Contexts and Projectors/Views are separate axes — (Export Context/Projec The bundle follows standard Symfony Bundle architecture with deep Contao integration. +**Lifecycle taxonomy** — elements and list types own their lifecycle through two method families: +`configure*` methods are declarative, memoizable setup (`configureOptions` = OptionsResolver schema, +`configureTransformers` = source→canonical-config mappings); `build*` methods are per-invocation construction +(`buildDca`, `buildForm`, `buildFilter`, `buildList`, `buildTableRegistry`/`buildBaseQuery`). + **Notable subsystems** (beyond the flow above): -- `src/Specification/` — `ListSpecification` / `FilterDefinition`, the declarative input to the engine -- `src/Filter/`, `src/FilterElement/`, `src/FilterCollector/` — filter definition and execution +- `src/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` +- `src/Config/` — `ConfigBuilder` (fluent canonical-config accumulator; no cast helpers — transformers cast + declaratively off the typed model) and `TransformerResolver` (source class → transformer map) - `src/Form/` — filter form building (FilterFormFactory etc.) - `src/Reader/` — reader/detail-page URL generation (`ReaderUrlGenerator`) - `src/InferPtable/` — parent-table inference for DCAs @@ -46,19 +56,19 @@ The bundle follows standard Symfony Bundle architecture with deep Contao integra - `src/Controller/ContentElement/ListViewController.php` / `ReaderController.php` — frontend controllers **Extensibility via PHP 8 attributes** (compiler passes auto-register tagged services): -- `#[AsFilterElement(type: '...', palette: '...', formType: ...)]` — register a filter element -- `#[AsListType(type: '...', dataContainer: '...', palette: '...')]` — register a list type -- `#[AsFilterCallback(type, 'path.to.callback')]` — register a Contao DCA callback on a filter type -- `#[AsListCallback(type, 'path.to.callback')]` — register a Contao DCA callback on a list type -- `#[AsFilterInvoker]` — register a custom filter invocation handler - -(`AsFilterCallback` and `AsListCallback` both extend the `@internal` base attribute `AsFlareCallback`.) +- `#[AsFilterElement(type: '...', isTargeted: ...)]` — register a filter element +- `#[AsListType(type: '...', dataContainer: '...')]` — register a list type Attributes are in `src/DependencyInjection/Attribute/`, compiler passes in `src/DependencyInjection/Compiler/`. +Backend palettes/fields are declared in code via `DcaContract::buildDca(DcaBuilder, DcaContext)` (both +tl_flare_filter and tl_flare_list). -**Event system** — Events, some with aliased dispatch for targeted listening (`flare.form.{name}.build`, etc., implemented by the listeners in `src/EventListener/NamedDispatch/`). All events are in `src/Event/`. Prefer events over overriding services for customization. +**Event system** — Events, some with aliased dispatch for targeted listening (`flare.form.{name}.build`, +`flare.list.{type}.build`, `flare.filter_element.{type}.transformers`, `flare.filter_element.{type}.dca` / +`flare.list.{type}.dca`, etc., implemented by the listeners in `src/EventListener/NamedDispatch/`). All events +are in `src/Event/`. Prefer events over overriding services for customization. -**Registry pattern** — Registries in `src/Registry/` map type names to implementations: `FilterElementRegistry`, `ListTypeRegistry`, `FilterInvokerRegistry`, `ProjectorRegistry`, `FilterCollectorRegistry`, `FlareCallbackRegistry`, `EngineModRegistry`. +**Registry pattern** — Registries in `src/Registry/` map type names to implementations: `FilterElementRegistry`, `ListTypeRegistry`, `FilterTypeRegistry`, `ProjectorRegistry`, `EngineModRegistry`. **Query safety** — `FilterQueryBuilder` (`src/Query/FilterQueryBuilder.php`) enforces parameterized queries. `TableAliasRegistry` (`src/Query/TableAliasRegistry.php`) manages table aliases and JOINs safely. @@ -91,8 +101,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/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/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..660150a1 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,9 @@ 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 HeimrichHannot\FlareBundle\Engine\: resource: ../src/Engine @@ -21,14 +22,14 @@ 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: ~ + # 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/templates/content_element/flare_listview.html.twig b/contao/templates/content_element/flare_listview.html.twig index a0793c3c..dbc0741d 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 %} @@ -46,7 +30,7 @@
{{ 'list.default_template.description'|trans({}, 'flare') }} - {% if app.request.get('_preview') or app.debug %} + {% if app.debug %} {% for entry in flare_list.entries %}
#{{ entry.id }} {{ (entry.title ?? entry.email ?? entry.alias ?? null) ?: ('to reader <' ~ loop.index ~ '>') }} diff --git a/contao/templates/content_element/flare_reader.html.twig b/contao/templates/content_element/flare_reader.html.twig index 9be9e0dc..43a7610e 100644 --- a/contao/templates/content_element/flare_reader.html.twig +++ b/contao/templates/content_element/flare_reader.html.twig @@ -14,7 +14,7 @@
{{ 'reader.default_template.description'|trans({}, 'flare') }} - {% if app.request.get('_preview') or app.debug %} + {% if app.debug %}
{{ model.table }} {% for key, field in model.row -%} diff --git a/mago.toml b/mago.toml index c089d01c..4dedb4e4 100644 --- a/mago.toml +++ b/mago.toml @@ -3,7 +3,7 @@ php-version = "8.2" [source] -paths = ["src/", "tests/"] +paths = ["src/"] includes = ["vendor"] excludes = [] @@ -29,6 +29,7 @@ no-isset = { enabled = false } tagged-todo = { enabled = false } too-many-methods = { threshold = 20 } prefer-early-continue = { enabled = false } +assertion-style = { style = "self" } cyclomatic-complexity = { enabled = false } kan-defect = { enabled = false } diff --git a/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..d06693e9 --- /dev/null +++ b/src/Config/ConfigBuilder.php @@ -0,0 +1,42 @@ + $config + */ + public function __construct(private array $config = []) {} + + public function set(string $key, mixed $value): self + { + $this->config[$key] = $value; + + return $this; + } + + 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 @@ +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) @@ -95,13 +94,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) @@ -110,13 +109,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( @@ -161,17 +164,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 - */ 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, + 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; $this->attributes = $attributes; } -} \ No newline at end of file +} diff --git a/src/DependencyInjection/Attribute/AsFilterInvoker.php b/src/DependencyInjection/Attribute/AsFilterInvoker.php deleted file mode 100644 index 3ec83e6e..00000000 --- a/src/DependencyInjection/Attribute/AsFilterInvoker.php +++ /dev/null @@ -1,31 +0,0 @@ -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..9b3644f0 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,22 @@ public function process(ContainerBuilder $container): void foreach ($tags as $attributes) { $type = $this->getFilterElementType($definition, $attributes); - $attributes['type'] = $type; - - $serviceId = 'huh.flare.filter_element.' . $type; - $childDefinition = new ChildDefinition((string) $reference); - $childDefinition->setPublic(true); - - $config = $this->getFilterElementConfig($container, $reference, $attributes); + /** @see AsFilterElement::__construct */ + $attribute = new Definition(AsFilterElement::class, [$type, $attributes['isTargeted'] ?? null]); /** @see FilterElementRegistry::add() */ - $registry->addMethodCall('add', [$type, $config]); + $registry->addMethodCall('add', [$reference, $attribute, $type]); + + $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 +63,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/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 83557b9b..997d7286 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -14,7 +14,9 @@ public function getConfigTreeBuilder(): TreeBuilder $treeBuilder = new TreeBuilder('huh_flare'); $rootNode = $treeBuilder->getRootNode(); - // @phpstan-ignore class.notFound + // PHPStan 1.x cannot parse symfony/config 7.4 template defaults. + // This phpstan-ignore annotation is only required when using symfony/config >= 7: + // @ ### phpstan-ignore class.notFound $rootNode ->children() ->arrayNode('format_label_defaults') @@ -60,4 +62,4 @@ public function getConfigTreeBuilder(): TreeBuilder return $treeBuilder; } -} \ No newline at end of file +} diff --git a/src/DependencyInjection/Factory/TypeNameFactory.php b/src/DependencyInjection/Factory/TypeNameFactory.php index 8d696646..0dac04d2 100644 --- a/src/DependencyInjection/Factory/TypeNameFactory.php +++ b/src/DependencyInjection/Factory/TypeNameFactory.php @@ -7,13 +7,23 @@ use HeimrichHannot\FlareBundle\Util\Str; use function Symfony\Component\String\u; -class TypeNameFactory +final readonly class TypeNameFactory { - public static function createFilterElementType(string $className): string + private static function createType(string $className, array $suffixes): string { $shortName = \basename(\str_replace('\\', '/', $className)); - $trimmedName = Str::trimSubstrings($shortName, suffix: ['Controller', 'FilterElement', 'Element']); + $trimmedName = Str::trimSubstrings($shortName, suffix: $suffixes); return u($trimmedName)->snake()->toString(); } -} \ No newline at end of file + + public static function createFilterElementType(string $className): string + { + return self::createType($className, ['Controller', 'FilterElement', 'Element']); + } + + public static function createListDriverType(string $className): string + { + return self::createType($className, ['Controller', 'ListDriver', 'Driver']); + } +} diff --git a/src/DependencyInjection/HeimrichHannotFlareExtension.php b/src/DependencyInjection/HeimrichHannotFlareExtension.php index ebb5d8f6..ba08ea9c 100644 --- a/src/DependencyInjection/HeimrichHannotFlareExtension.php +++ b/src/DependencyInjection/HeimrichHannotFlareExtension.php @@ -4,13 +4,8 @@ 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\AsListDriver; use HeimrichHannot\FlareBundle\Util\Env; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ChildDefinition; @@ -56,40 +51,19 @@ 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, ]; 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 +80,4 @@ public function prepend(ContainerBuilder $container): void $loader = new YamlFileLoader($container, new FileLocator(\dirname(__DIR__) . '/../config')); $loader->load('config.yaml'); } -} \ No newline at end of file +} diff --git a/src/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 @@ -{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, 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/InteractiveContext.php b/src/Engine/Context/InteractiveContext.php index 2a3fdfc6..8ca7e8e1 100644 --- a/src/Engine/Context/InteractiveContext.php +++ b/src/Engine/Context/InteractiveContext.php @@ -4,7 +4,6 @@ namespace HeimrichHannot\FlareBundle\Engine\Context; -use Contao\ContentModel; use HeimrichHannot\FlareBundle\Paginator\PaginatorConfig; use HeimrichHannot\FlareBundle\Sort\SortOrderSequence; use Symfony\Component\Validator\Constraints as Assert; @@ -23,23 +22,16 @@ public static function getContextType(): string } public function __construct( - #[Assert\NotNull] public ?PaginatorConfig $paginatorConfig = null, - public ?SortOrderSequence $sortOrderSequence = null, - #[Assert\PositiveOrZero] public int $contentModelId = 0, - #[Assert\PositiveOrZero] public int $formActionPage = 0, - #[Assert\NotBlank] public string $formName = '', - #[Assert\PositiveOrZero] public int $jumpToReaderPageId = 0, - #[Assert\NotBlank] public string $autoItemField = 'id', - public ?string $pageParam = null, - ) {} - - public function getContentModel(): ?ContentModel - { - if ($this->contentModelId === 0) { - return null; - } - - return ContentModel::findByPk($this->contentModelId); + public PaginatorConfig $paginatorConfig, + #[Assert\NotBlank] public string $formName, + public ?SortOrderSequence $sortOrderSequence = null, + #[Assert\PositiveOrZero] public int $contentModelId = 0, + #[Assert\PositiveOrZero] public int $formActionPage = 0, + #[Assert\PositiveOrZero] public int $jumpToReaderPageId = 0, + #[Assert\NotBlank] public string $autoItemField = 'id', + public ?string $pageParam = null, + ) { + $this->initJumpToReaderPage(); } public function getFormName(): string @@ -77,20 +69,15 @@ public function with( ?string $formName = null, ?string $pageParam = null, ): static { - $clone = clone $this; - - if ($paginatorConfig !== null) { - $clone->paginatorConfig = $paginatorConfig; - } - - if ($formName !== null) { - $clone->formName = $formName; - } - - if ($pageParam !== null) { - $clone->pageParam = $pageParam; - } - - return $clone; + return new self( + paginatorConfig: $paginatorConfig ?? $this->paginatorConfig, + sortOrderSequence: $this->sortOrderSequence, + formName: $formName ?? $this->formName, + contentModelId: $this->contentModelId, + formActionPage: $this->formActionPage, + jumpToReaderPageId: $this->jumpToReaderPageId, + autoItemField: $this->autoItemField, + pageParam: $pageParam ?? $this->pageParam + ); } -} \ No newline at end of file +} diff --git a/src/Engine/Context/ReaderUrlConfigCreatorTrait.php b/src/Engine/Context/ReaderUrlConfigCreatorTrait.php index 490fdcc6..eaae2acd 100644 --- a/src/Engine/Context/ReaderUrlConfigCreatorTrait.php +++ b/src/Engine/Context/ReaderUrlConfigCreatorTrait.php @@ -9,16 +9,28 @@ trait ReaderUrlConfigCreatorTrait { - public function createReaderUrlConfig(): ?ReaderUrlConfig + private \Closure $jumpToReaderPage; + + final protected function initJumpToReaderPage(): void { - if (!$this->jumpToReaderPageId) { - return null; - } + $this->jumpToReaderPage = function (): ?PageModel { + $pageModel = PageModel::findByPk($this->jumpToReaderPageId); + $this->jumpToReaderPage = static fn (): ?PageModel => $pageModel; + return $pageModel; + }; + } - if (!$pageModel = PageModel::findByPk($this->jumpToReaderPageId)) { + protected function getJumpToReaderPage(): ?PageModel + { + return ($this->jumpToReaderPage)(); + } + + public function createReaderUrlConfig(): ?ReaderUrlConfig + { + if (!$pageModel = $this->getJumpToReaderPage()) { return null; } return new ReaderUrlConfig(readerPage: $pageModel, autoItemField: $this->autoItemField); } -} \ No newline at end of file +} diff --git a/src/Engine/Context/ValidationContext.php b/src/Engine/Context/ValidationContext.php index de51a4eb..3e47d86f 100644 --- a/src/Engine/Context/ValidationContext.php +++ b/src/Engine/Context/ValidationContext.php @@ -16,23 +16,29 @@ use ReaderUrlConfigCreatorTrait; private PaginatorConfig $paginatorConfig; + private \Closure $jumpToListViewPage; + private \Closure $jumpToReaderPage; 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->jumpToListViewPage = function (): ?PageModel { + $pageModel = PageModel::findByPk($this->jumpToListViewPageId); + $this->jumpToListViewPage = static fn (): ?PageModel => $pageModel; + return $pageModel; + }; + + $this->initJumpToReaderPage(); } public function createBackLink(): ?BackLink @@ -41,29 +47,13 @@ public function createBackLink(): ?BackLink return null; } - if (!$pageModel = PageModel::findByPk($this->jumpToListViewPageId)) { + if (!$pageModel = ($this->jumpToListViewPage)()) { 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 : []; - } - public function getFilterValues(): array { return $this->filterValues; @@ -87,11 +77,10 @@ public function setPaginatorQueryParameter(?string $queryParameter): void public function withFilterValues(array $values): self { return new self( - entryCache: $this->entryCache, jumpToReaderPageId: $this->jumpToReaderPageId, jumpToListViewPageId: $this->jumpToListViewPageId, autoItemField: $this->autoItemField, filterValues: $values, ); } -} \ No newline at end of file +} diff --git a/src/Engine/Engine.php b/src/Engine/Engine.php index 9641b6d1..b95a4ed1 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; } -} \ No newline at end of file +} 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..4dfa9611 100644 --- a/src/Engine/Loader/AggregationLoaderConfig.php +++ b/src/Engine/Loader/AggregationLoaderConfig.php @@ -5,13 +5,13 @@ namespace HeimrichHannot\FlareBundle\Engine\Loader; use HeimrichHannot\FlareBundle\Engine\Context\AggregationContext; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\List\ListSpec; readonly class AggregationLoaderConfig { 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..53eb1c51 100644 --- a/src/Engine/Loader/InteractiveLoaderConfig.php +++ b/src/Engine/Loader/InteractiveLoaderConfig.php @@ -5,12 +5,12 @@ namespace HeimrichHannot\FlareBundle\Engine\Loader; use HeimrichHannot\FlareBundle\Engine\Context\InteractiveContext; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\List\ListSpec; readonly class InteractiveLoaderConfig { public function __construct( - public ListSpecification $list, + public ListSpec $list, public InteractiveContext $context, public array $filterValues, ) {} diff --git a/src/Engine/Loader/ValidationLoader.php b/src/Engine/Loader/ValidationLoader.php index e405b335..3f8284a3 100644 --- a/src/Engine/Loader/ValidationLoader.php +++ b/src/Engine/Loader/ValidationLoader.php @@ -7,45 +7,65 @@ 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, + 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 +73,7 @@ public function fetchEntryById(int $id): ?array } catch (\Throwable $e) { - throw new FlareException($e->getMessage(), $e->getCode(), $e); + throw new FlareException($e->getMessage(), $e->getCode(), $e, method: __METHOD__); } } @@ -66,20 +86,33 @@ 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, + 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 +120,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 +145,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/SimpleEquationMod.php b/src/Engine/Mod/SimpleEquationMod.php index 176186f2..1a447e2d 100644 --- a/src/Engine/Mod/SimpleEquationMod.php +++ b/src/Engine/Mod/SimpleEquationMod.php @@ -6,11 +6,16 @@ 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 Symfony\Component\OptionsResolver\OptionsResolver; class SimpleEquationMod extends AbstractMod { + public function __construct( + private readonly FilterFactory $filterFactory, + ) {} + public static function getType(): string { return 'equation'; @@ -18,20 +23,17 @@ public static function getType(): string public function __invoke(Engine $engine, array $options): void { - $filter = SimpleEquationElement::define( - equationLeft: $options['operand1'], - equationOperator: $options['operator'], - equationRight: $options['operand2'], + $filter = $this->filterFactory->create( + element: SimpleEquationFilterElement::TYPE, + 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, $options['name'] ?: null)); } public function configureOptions(OptionsResolver $resolver): void @@ -56,4 +58,4 @@ public function configureOptions(OptionsResolver $resolver): void ?? throw new \InvalidArgumentException('Invalid equation operator provided') ); } -} \ No newline at end of file +} 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 662ff34f..a12536a2 100644 --- a/src/Engine/Projector/InteractiveProjector.php +++ b/src/Engine/Projector/InteractiveProjector.php @@ -4,26 +4,22 @@ 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\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\Filter\Factory\FilterFormFactory; +use HeimrichHannot\FlareBundle\Filter\FilterContext; +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; /** @@ -34,24 +30,21 @@ class InteractiveProjector extends AbstractProjector public function __construct( private readonly AggregationContextFactory $aggregationConfigFactory, private readonly FilterFormFactory $filterFormFactory, - private readonly LoaderFactory $loaderFactory, private readonly PaginatorFactory $paginatorFactory, - private readonly ReaderUrlGeneratorFactory $readerUrlGeneratorFactory, ) {} - public function supports(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); + $filterValues = $this->collectFilterData($list, $form); // pagination setup $totalItems = $this->createAggregationView($list, $context, $filterValues)->getCount(); @@ -75,7 +68,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 +83,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,109 +107,73 @@ 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; } /** - * @throws FlareException If the form does not contain the filter field. + * Collects each filter's form data, keyed by the filter's list-specification key. + * Flat-mounted single fields are normalized to the canonical values-bag shape + * `[FilterContext::SINGLE_VALUE => value]` that buildFilter() consumes. + * + * @return array> */ - private function hydrateForm(FormInterface $form, ListSpecification $list): void + protected function collectFilterData(ListSpec $list, FormInterface $form): array { - if ($form->isSubmitted()) { - return; - } - - $filterElementRegistry = $this->getFilterElementRegistry(); - $data = []; - foreach ($list->getFilters()->getIterator() as $filterDefinition) - { - if (!$filterElement = $filterElementRegistry->get($filterDefinition->getType())?->getService()) { - continue; - } - if (!$filterElement instanceof HydrateFormContract) { - continue; - } - - if ($filterDefinition->isIntrinsic()) { + foreach ($list->filters as $key => $filter) + { + if (!$filter->alias || !$form->has($filter->alias)) { continue; } - if (!$filterName = $filterDefinition->getAlias()) { - throw new FlareException(message: 'Non-intrinsic filter must provide a form field name.'); - } - - if (!$form->has($filterName)) { - continue; - } + $child = $form->get($filter->alias); - try + if ($child->getConfig()->getAttribute(FilterContext::ATTR_SINGLE_FIELD)) { - $field = $form->get($filterName); - } - catch (OutOfBoundsException $exception) - { - $filterSourceId = $filterDefinition->getDataSource()->getFilterIdentifier(); - - throw new FlareException( - message: 'Filter form does not contain field: ' . $filterName, - previous: $exception, - method: __METHOD__, - source: $filterSourceId ? \sprintf('tl_flare_filter.id=%s', $filterSourceId) : 'filter inlined' - ); - } - - $filterElement->hydrateForm($field, $list, $filterDefinition); + // 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(); - $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] = [FilterContext::SINGLE_VALUE => $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] = (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] = $values; + } } - return $values; + return $data; } /** * @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/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 @@ + $data + */ + public function __construct( + public readonly FilterContext $context, + public readonly FilterBuilderInterface $builder, + public readonly array $data = [], + public bool $shouldBuild = true, + ) {} +} diff --git a/src/Event/FilterElementBuiltEvent.php b/src/Event/FilterElementBuiltEvent.php new file mode 100644 index 00000000..1fed5bd3 --- /dev/null +++ b/src/Event/FilterElementBuiltEvent.php @@ -0,0 +1,21 @@ + $data + */ + public function __construct( + public readonly FilterContext $context, + public readonly FilterBuilderInterface $builder, + public readonly array $data = [], + ) {} +} diff --git a/src/Event/FilterElementFormBuiltEvent.php b/src/Event/FilterElementFormBuiltEvent.php new file mode 100644 index 00000000..63b91101 --- /dev/null +++ b/src/Event/FilterElementFormBuiltEvent.php @@ -0,0 +1,38 @@ +cancelled = true; + } + + public function isCancelled(): bool + { + return $this->cancelled; + } +} diff --git a/src/Event/FilterElementFormTypeOptionsEvent.php b/src/Event/FilterElementFormTypeOptionsEvent.php deleted file mode 100644 index 8ca501a1..00000000 --- a/src/Event/FilterElementFormTypeOptionsEvent.php +++ /dev/null @@ -1,20 +0,0 @@ -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 index 0c13d470..aef09e0c 100644 --- a/src/Event/FilterFormBuildEvent.php +++ b/src/Event/FilterFormBuildEvent.php @@ -4,15 +4,15 @@ namespace HeimrichHannot\FlareBundle\Event; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\List\ListSpec; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Contracts\EventDispatcher\Event; class FilterFormBuildEvent extends Event { public function __construct( - public readonly ListSpecification $listSpecification, - public readonly string $formName, - public FormBuilderInterface $formBuilder, + public readonly ListSpec $list, + public readonly string $formName, + public FormBuilderInterface $formBuilder, ) {} -} \ No newline at end of file +} diff --git a/src/Event/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/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..2d8b0094 --- /dev/null +++ b/src/EventListener/Contao/ElementDcaListener.php @@ -0,0 +1,168 @@ +configure($table); + } + + private function configure(string $table): void + { + if (!$id = Input::get('id')) { + return; + } + + if ($table === FilterModel::getTable()) + { + $filterModel = FilterModel::findByPk($id); + $listModel = $filterModel?->getRelated('pid'); + $type = (string) ($filterModel->type ?? ''); + $service = $this->filterElementRegistry->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..2ad106a3 100644 --- a/src/EventListener/NamedDispatch/FilterElementListener.php +++ b/src/EventListener/NamedDispatch/FilterElementListener.php @@ -4,8 +4,9 @@ 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 HeimrichHannot\FlareBundle\Event\FilterElementFormBuiltEvent; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -16,20 +17,32 @@ 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 + + #[AsEventListener(priority: -200)] + public function onFilterElementFormBuiltEvent(FilterElementFormBuiltEvent $event): void + { + if (!$type = $event->context->filter->type) { + return; + } + + $this->eventDispatcher->dispatch(event: $event, eventName: "flare.filter_element.{$type}.form_built"); + } +} diff --git a/src/EventListener/NamedDispatch/FilterFormListener.php b/src/EventListener/NamedDispatch/FilterFormListener.php index e43fde7e..85c12989 100644 --- a/src/EventListener/NamedDispatch/FilterFormListener.php +++ b/src/EventListener/NamedDispatch/FilterFormListener.php @@ -5,7 +5,6 @@ namespace HeimrichHannot\FlareBundle\EventListener\NamedDispatch; use HeimrichHannot\FlareBundle\Event\FilterFormBuildEvent; -use HeimrichHannot\FlareBundle\Event\FilterFormChildOptionsEvent; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -22,12 +21,4 @@ public function onFilterFormBuildEvent(FilterFormBuildEvent $event): void $this->eventDispatcher->dispatch(event: $event, eventName: $eventName); } - - #[AsEventListener(priority: -200)] - public function onFilterFormChildOptionsEvent(FilterFormChildOptionsEvent $event): void - { - $eventName = "flare.form.{$event->parentFormName}.child.{$event->formName}.options"; - - $this->eventDispatcher->dispatch(event: $event, eventName: $eventName); - } -} \ No newline at end of file +} diff --git a/src/EventListener/NamedDispatch/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/ListBuildListener.php similarity index 58% rename from src/EventListener/NamedDispatch/ListSpecificationListener.php rename to src/EventListener/NamedDispatch/ListBuildListener.php index 706cb8c1..1fbae64b 100644 --- a/src/EventListener/NamedDispatch/ListSpecificationListener.php +++ b/src/EventListener/NamedDispatch/ListBuildListener.php @@ -4,21 +4,25 @@ namespace HeimrichHannot\FlareBundle\EventListener\NamedDispatch; -use HeimrichHannot\FlareBundle\Event\ListSpecificationCreatedEvent; +use HeimrichHannot\FlareBundle\Event\ListBuildEvent; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; -readonly class ListSpecificationListener +readonly class ListBuildListener { public function __construct( private EventDispatcherInterface $eventDispatcher, ) {} #[AsEventListener(priority: -200)] - public function onListSpecificationCreated(ListSpecificationCreatedEvent $event): void + public function __invoke(ListBuildEvent $event): void { - $eventName = "flare.list_type.{$event->listSpecification->type}.list_specification_created"; + $type = $event->builder->getDriver(); - $this->eventDispatcher->dispatch(event: $event, eventName: $eventName); + if (!$type ||!\is_string($type)) { + return; + } + + $this->eventDispatcher->dispatch(event: $event, eventName: "flare.list.{$type}.build"); } -} \ No newline at end of file +} 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/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..337979d3 --- /dev/null +++ b/src/Filter/Element/AbstractFilterElement.php @@ -0,0 +1,86 @@ +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 buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): 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; + } + + public 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..18b0592b --- /dev/null +++ b/src/Filter/Element/ArchiveFilterElement.php @@ -0,0 +1,657 @@ +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 buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void + { + $config = $context->config; + + /** @var Model[] $selectedModels */ + $selectedModels = $config['intrinsic'] + ? $this->getWhitelistedParents($context->list, $config) + : $this->processRuntimeValue($values[FilterContext::SINGLE_VALUE] ?? null, $context->list, $config); + + $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->add(ArchiveFilterType::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(BelongsToRelationFilterType::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..51866899 --- /dev/null +++ b/src/Filter/Element/BelongsToRelationFilterElement.php @@ -0,0 +1,231 @@ +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 buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): 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(BelongsToRelationFilterType::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(BelongsToRelationFilterType::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( + FilterBuilderInterface $builder, + array $groupWhitelistParents, + string $fieldDynamicPtable, + string $fieldPid, + ?array $submittedData = null, + ): void { + $builder->add(BelongsToRelationFilterType::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..beb458b8 --- /dev/null +++ b/src/Filter/Element/BooleanFilterElement.php @@ -0,0 +1,167 @@ +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 buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void + { + $config = $context->config; + + if (!$targetField = $config['field']) { + $builder->abort(); + } + + $value = $config['intrinsic'] + ? $config['preselect'] + : $this->resolveRuntimeValue($values[FilterContext::SINGLE_VALUE] ?? null, $config); + + if ($value === null) { + return; + } + + $builder->add(BooleanFilterType::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..19867e53 --- /dev/null +++ b/src/Filter/Element/CalendarCurrentFilterElement.php @@ -0,0 +1,248 @@ +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 buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void + { + $config = $context->config; + + if (!$config['is_limited'] && $context->engineContext instanceof ValidationContext) { + return; + } + + $value = $this->processRuntimeValue($values) ?? []; + $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->add(CalendarCurrentFilterType::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]; + } + + /** + * @param array $value + * + * @return array{from: ?\DateTimeInterface, to: ?\DateTimeInterface}|null + */ + private function processRuntimeValue(array $value): ?array + { + if (!\array_key_exists('from', $value) && !\array_key_exists('to', $value)) + { + if (\count($value) !== 2) + { + return null; + } + + $value = \array_values($value); + + return [ + 'from' => $this->mixedToDateTime($value[0] ?? null), + 'to' => $this->mixedToDateTime($value[1] ?? null), + ]; + } + + return [ + 'from' => $this->mixedToDateTime($value['from'] ?? null), + 'to' => $this->mixedToDateTime($value['to'] ?? null), + ]; + } + + 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..cb4f95b5 --- /dev/null +++ b/src/Filter/Element/DateRangeFilterElement.php @@ -0,0 +1,112 @@ +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 buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void + { + if (!$field = $context->config['field']) { + throw new FilterException('Set fieldGeneric in filter model.'); + } + + $builder->add(DateRangeFilterType::class, [ + 'field' => $field, + 'from' => $values['from'] ?? null, + 'to' => $values['to'] ?? null, + ]); + } + + 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..5639fec4 --- /dev/null +++ b/src/Filter/Element/DcaSelectFieldFilterElement.php @@ -0,0 +1,373 @@ +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 buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void + { + $config = $context->config; + $options = $this->getOptions($context->list->dc, $config['field']) ?? []; + + $selected = $config['intrinsic'] + ? $config['preselect'] + : $this->normalizeSubmittedValue($values[FilterContext::SINGLE_VALUE] ?? null, $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(DcaSelectFilterType::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..2249a873 --- /dev/null +++ b/src/Filter/Element/FieldValueChoiceFilterElement.php @@ -0,0 +1,327 @@ +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 buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void + { + if ($context->engineContext instanceof ValidationContext) { + return; + } + + $config = $context->config; + + if (!$field = $config['field']) { + return; + } + + $value = $config['intrinsic'] + ? $config['preselect'] + : $this->normalizeRuntimeValue($values[FilterContext::SINGLE_VALUE] ?? null, $context); + + if (!$value) { + return; + } + + $builder->add(FieldValueChoiceFilterType::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..2c2dd894 --- /dev/null +++ b/src/Filter/Element/FilterElementInterface.php @@ -0,0 +1,35 @@ + $values Submitted form data of this filter (keyed by the local + * field names declared in buildForm(); single() fields use {@see FilterContext::SINGLE_VALUE}) + * or a programmatically set data bag; empty array when neither exists (e.g. non-interactive + * contexts). + */ + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void; +} diff --git a/src/Filter/Element/PublishedFilterElement.php b/src/Filter/Element/PublishedFilterElement.php new file mode 100644 index 00000000..9038e632 --- /dev/null +++ b/src/Filter/Element/PublishedFilterElement.php @@ -0,0 +1,70 @@ +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 buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void + { + $config = $context->config; + + $builder->add(PublishedFilterType::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..f78ab9dc --- /dev/null +++ b/src/Filter/Element/SearchKeywordsFilterElement.php @@ -0,0 +1,94 @@ +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 buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void + { + $config = $context->config; + + $value = $config['intrinsic'] + ? $config['prefill'] + : ($values[FilterContext::SINGLE_VALUE] ?? null); + + if (!$value || !\is_string($value)) { + return; + } + + if (!$columns = $config['columns']) { + return; + } + + $builder->add(SearchKeywordsFilterType::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..80bb4387 --- /dev/null +++ b/src/Filter/Element/SimpleEquationFilterElement.php @@ -0,0 +1,78 @@ +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 buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void + { + $config = $context->config; + + if (!($operand = $config['left']) || !($op = $config['operator'])) { + throw new FilterException('Invalid filter configuration.'); + } + + $builder->add(SimpleEquationFilterType::class, [ + 'operand_left' => $operand, + 'operator' => $op, + 'operand_right' => $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/FilterContextFactory.php b/src/Filter/Factory/FilterContextFactory.php new file mode 100644 index 00000000..daee5a40 --- /dev/null +++ b/src/Filter/Factory/FilterContextFactory.php @@ -0,0 +1,41 @@ +filterOptionsResolver->resolve($filter), + engineContext: $engineContext, + key: $key, + ); + } +} diff --git a/src/Filter/Factory/FilterFactory.php b/src/Filter/Factory/FilterFactory.php new file mode 100644 index 00000000..236da740 --- /dev/null +++ b/src/Filter/Factory/FilterFactory.php @@ -0,0 +1,111 @@ + $config + * @param array|null $data + * + * @throws FlareException In case no filter element is registered under the given type alias. + * + * @see Filter::__construct for the remaining parameters. + */ + public function create( + FilterElementInterface|string $element, + array $config = [], + ?array $data = null, + ?string $alias = null, + ?string $targetAlias = null, + bool $targetingForced = false, + ?string $source = null, + ): Filter { + $type = $this->resolveType($element, $source); + $element = $this->resolveElement($element, $source); + + return new Filter( + element: $element, + type: $type, + config: $config, + data: $data, + alias: $alias, + 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) ?? []; + + return new Filter( + element: $element, + type: $type, + config: $config, + alias: $filterModel->getFilterFormName() ?: "_.{$source}", + targetAlias: $filterModel->getFilterTargetAlias() ?: null, + source: $source, + ); + } + + /** + * @throws FlareException + */ + private function resolveType(FilterElementInterface|string $element, ?string $source = null): string + { + if (!$type = \is_object($element) ? \get_class($element) : $element) + { + throw new FlareException(\sprintf( + 'A filter element instance or registered type alias must be provided%s.', + $source ? " ({$source})" : "" + ), method: __METHOD__); + } + + return $type; + } + + /** + * @throws FlareException + */ + private function resolveElement(FilterElementInterface|string $element, ?string $source = null): FilterElementInterface + { + if ($element instanceof FilterElementInterface) { + return $element; + } + + return $this->filterElementRegistry->getService($element) + ?? throw new FlareException(\sprintf( + 'Filter element type "%s" not found%s', + $element, + $source ? " ({$source})" : "" + ), method: __METHOD__); + } +} diff --git a/src/Filter/Factory/FilterFormFactory.php b/src/Filter/Factory/FilterFormFactory.php new file mode 100644 index 00000000..6ad5db51 --- /dev/null +++ b/src/Filter/Factory/FilterFormFactory.php @@ -0,0 +1,162 @@ +getFormName(); + + $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); + $builder->setAttribute('flare.list', $list); + $builder->setAttribute('flare.engine_context', $context); + + foreach ($list->filters as $key => $filter) + { + if (!Str::isValidFormName($filter->alias)) { + continue; + } + + $filterContext = $this->filterContextFactory->create($list, $filter, $context, $key); + + // Collect-only builder: never mounted itself; its single-field spec, children, + // attributes, and deferred listeners are transferred onto the mounted builder below. + $wrapper = new FilterFormBuilder($filter->alias, null, new EventDispatcher(), $this->formFactory); + $wrapper->setAttribute(FilterContext::ATTR_SELF, $filterContext); + + $filter->element->buildForm($wrapper, $filterContext); + + /** @var FilterElementFormBuiltEvent $event */ + $event = $this->eventDispatcher->dispatch(new FilterElementFormBuiltEvent($wrapper, $filterContext)); + + $single = $wrapper->getSingle(); + + if ($event->isCancelled() || (!$single && $wrapper->count() === 0)) + // Filters without any form representation are never mounted. + { + continue; + } + + if ($single && $wrapper->count() === 0) + // Flat mount: the field lives at the root under the filter's alias. + { + $mount = $builder->create($filter->alias, $single['type'], $single['options']); + $mount->setAttribute(FilterContext::ATTR_SINGLE_FIELD, true); + } + /** @mago-expect lint:no-else-clause The mount decision is a genuine either-or. */ + else + // Nested mount: real compound; a single() field materializes under the + // canonical field name alongside any explicitly added children. + { + $mount = $builder->create($filter->alias, FormType::class, [ + 'inherit_data' => false, + 'label' => false, + 'required' => false, + ]); + + if ($single) { + $mount->add(FilterContext::SINGLE_VALUE, $single['type'], $single['options']); + } + + foreach ($wrapper->all() as $childBuilder) { + $mount->add($childBuilder); + } + } + + foreach ($wrapper->getAttributes() as $attrName => $attrValue) { + $mount->setAttribute($attrName, $attrValue); + } + + foreach ($wrapper->getDeferredListeners() as [$eventName, $listener, $priority]) { + $mount->addEventListener($eventName, $listener, $priority); + } + + $builder->add($mount); + } + + /* + * **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( + list: $list, + formName: $name, + formBuilder: $builder, + )); + + /** @var FormBuilder $builder */ + $builder = $formBuildEvent->formBuilder; + + return $builder->getForm(); + } + + private function resolveFormAction(FormContextInterface $config): ?string + { + if (!$jumpTo = $config->getFormActionPage()) { + return null; + } + + if (!$pageModel = PageModel::findByPk($jumpTo)) { + return null; + } + + return $pageModel->getAbsoluteUrl(); + } +} diff --git a/src/Filter/Filter.php b/src/Filter/Filter.php new file mode 100644 index 00000000..b1d698c7 --- /dev/null +++ b/src/Filter/Filter.php @@ -0,0 +1,110 @@ + $config Canonical config (element-defined schema); scalars, arrays, and enums only. + * @param array|null $data Runtime data bag, same shape buildFilter() receives + * (single-field elements read {@see FilterContext::SINGLE_VALUE}). Submitted form + * data takes precedence over this bag. + * @param string|null $alias Form name of the filter. An alias that is not a valid Symfony form + * name (e.g. the generated "_.{source}" fallback) never mounts form children. + * @param string|null $targetAlias Table alias the filter's conditions apply to. + * @param bool $targetingForced Whether the target alias applies even if the element is not marked as targeted. + * @param string|null $source Provenance for error messages, e.g. "tl_flare_filter.42". + * + * @internal Use {@see Factory\FilterFactory} to create instances. + */ + public function __construct( + public FilterElementInterface $element, + public string $type, + public array $config = [], + public ?array $data = null, + public ?string $alias = null, + public ?string $targetAlias = null, + public bool $targetingForced = false, + public ?string $source = null, + ) {} + + /** + * @param array|null $data + */ + public function withData(?array $data): self + { + return new self( + element: $this->element, + type: $this->type, + config: $this->config, + data: $data, + alias: $this->alias, + targetAlias: $this->targetAlias, + targetingForced: $this->targetingForced, + source: $this->source, + ); + } + + public function withAlias(?string $alias): self + { + return new self( + element: $this->element, + type: $this->type, + config: $this->config, + data: $this->data, + alias: $alias, + targetAlias: $this->targetAlias, + targetingForced: $this->targetingForced, + source: $this->source, + ); + } + + public function withTargetAlias(?string $targetAlias, bool $forced = true): self + { + return new self( + element: $this->element, + type: $this->type, + config: $this->config, + data: $this->data, + alias: $this->alias, + 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, + 'alias' => $this->alias, + 'targetAlias' => $this->targetAlias, + 'targetingForced' => $this->targetingForced, + ]; + } +} diff --git a/src/Filter/FilterBuilder.php b/src/Filter/FilterBuilder.php new file mode 100644 index 00000000..5d8863e7 --- /dev/null +++ b/src/Filter/FilterBuilder.php @@ -0,0 +1,71 @@ +, OptionsResolver> + */ + private static array $optionsResolvers = []; + + /** + * @var FilterCall[] + */ + private array $calls = []; + + public function __construct( + private readonly FilterTypeRegistry $filterTypeRegistry, + private readonly string $defaultTargetAlias, + ) {} + + /** + * @param class-string $type + * @param array $options + * + * @throws FilterException + */ + public function add(string $type, array $options = [], ?string $targetAlias = null): static + { + if (!$filterType = $this->filterTypeRegistry->get($type)) { + throw new FilterException( + \sprintf('No FLARE filter type service registered for "%s".', $type), + method: __METHOD__, + ); + } + + if (!isset(self::$optionsResolvers[$type])) + { + $resolver = new OptionsResolver(); + $filterType->configureOptions($resolver); + self::$optionsResolvers[$type] = $resolver; + } + + $this->calls[] = new FilterCall( + type: $filterType, + typeClass: $type, + targetAlias: $targetAlias ?: $this->defaultTargetAlias, + options: self::$optionsResolvers[$type]->resolve($options), + ); + + return $this; + } + + public function all(): array + { + return $this->calls; + } + + public function abort(): never + { + throw new AbortFilteringException(); + } +} diff --git a/src/Filter/FilterBuilderInterface.php b/src/Filter/FilterBuilderInterface.php new file mode 100644 index 00000000..a3cb384f --- /dev/null +++ b/src/Filter/FilterBuilderInterface.php @@ -0,0 +1,23 @@ + $type + * @param array $options + */ + public function add(string $type, array $options = [], ?string $targetAlias = null): static; + + /** + * @return FilterCall[] + */ + public function all(): array; + + public function abort(): never; +} \ No newline at end of file diff --git a/src/Filter/FilterCall.php b/src/Filter/FilterCall.php new file mode 100644 index 00000000..6c721cc9 --- /dev/null +++ b/src/Filter/FilterCall.php @@ -0,0 +1,17 @@ + $config Resolved canonical config of the filter. + * @param string|int|null $key Key of the filter within {@see ListSpec::$filters}. + */ + public function __construct( + public ListSpec $list, + public Filter $filter, + public array $config, + public ContextInterface $engineContext, + public string|int|null $key = null, + ) {} +} diff --git a/src/Filter/FilterFormBuilder.php b/src/Filter/FilterFormBuilder.php new file mode 100644 index 00000000..d0bad94f --- /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.' + . ' FilterFormFactory 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..c9069d8d --- /dev/null +++ b/src/Filter/FilterFormBuilderInterface.php @@ -0,0 +1,38 @@ + $options Form options of the field. + */ + public function single(string $type, array $options = []): static; + + /** + * @return array{type: class-string, options: array}|null + */ + public function getSingle(): ?array; +} diff --git a/src/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 @@ -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..f9427486 --- /dev/null +++ b/src/Filter/Resolver/FilterOptionsResolver.php @@ -0,0 +1,49 @@ + + * + * @throws FilterException If the config does not satisfy the element's schema. + */ + public function resolve(Filter $filter): array + { + $element = $filter->element; + + if (!$element instanceof OptionsContract) { + return $filter->config; + } + + try + { + return $this->schemaResolver->resolve($element::class, $element->configureOptions(...), $filter->config); + } + catch (\Throwable $e) + { + throw new FilterException( + \sprintf('[FLARE] Invalid filter config for element "%s": %s', $element::class, $e->getMessage()), + previous: $e, + method: $element::class . '::configureOptions', + source: $filter->source, + ); + } + } +} diff --git a/src/Filter/Resolver/FilterTransformerResolver.php b/src/Filter/Resolver/FilterTransformerResolver.php new file mode 100644 index 00000000..1f2ca5e1 --- /dev/null +++ b/src/Filter/Resolver/FilterTransformerResolver.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(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; + } + + $transformer($config = new ConfigBuilder(), $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/Type/AbstractFilterType.php b/src/Filter/Type/AbstractFilterType.php new file mode 100644 index 00000000..2cf9eb3f --- /dev/null +++ b/src/Filter/Type/AbstractFilterType.php @@ -0,0 +1,15 @@ +define('field')->default('pid')->allowedTypes('string'); + $resolver->define('parent_ids')->required()->allowedTypes('array'); + } + + public function buildQuery(FilterQueryBuilder $builder, array $options): void + { + $ids = \array_values(\array_unique(\array_filter(\array_map('\intval', $options['parent_ids'])))); + + if (!$ids) { + throw new FilterException('No valid parent archive ids extracted.', method: __METHOD__); + } + + $builder->where($builder->expr()->in($builder->column($options['field']), ':pids')) + ->setParameter('pids', $ids, ArrayParameterType::INTEGER); + } +} diff --git a/src/Filter/Type/BelongsToRelationFilterType.php b/src/Filter/Type/BelongsToRelationFilterType.php new file mode 100644 index 00000000..a433aed9 --- /dev/null +++ b/src/Filter/Type/BelongsToRelationFilterType.php @@ -0,0 +1,95 @@ +define('field_pid')->required()->allowedTypes('string'); + $resolver->define('field_dynamic_ptable')->default(null)->allowedTypes('null', 'string'); + $resolver->define('whitelist')->default([])->allowedTypes('array'); + $resolver->define('parent_groups')->default([])->allowedTypes('array'); + $resolver->define('submitted_data')->default(null)->allowedTypes('null', 'array'); + } + + public function buildQuery(FilterQueryBuilder $builder, array $options): void + { + if ($options['field_dynamic_ptable']) { + $this->buildDynamicQuery($builder, $options); + return; + } + + if (!$options['whitelist']) { + $builder::abort(); + } + + $builder->where($builder->expr()->in($builder->column($options['field_pid']), ':whitelist')) + ->setParameter('whitelist', $options['whitelist']); + } + + private function buildDynamicQuery(FilterQueryBuilder $builder, array $options): void + { + $ors = []; + $submittedData = $options['submitted_data']; + $fieldDynamicPtable = $options['field_dynamic_ptable']; + $fieldPid = $options['field_pid']; + + $colDynamicPtable = $builder->column($fieldDynamicPtable); + $colPid = $builder->column($fieldPid); + + foreach (\array_values($options['parent_groups']) as $i => $group) + { + $table = $group['table'] ?? null; + $parentIds = $group['ids'] ?? null; + + if (!$table || !\is_array($parentIds)) { + continue; + } + + if (\is_array($submittedData)) + { + $submittedWhitelist = $submittedData[$table] ?? null; + + if (!\is_array($submittedWhitelist)) { + continue; + } + + $parentIds = \array_intersect($parentIds, $submittedWhitelist); + } + + $parentIds = \array_values(\array_filter($parentIds)); + + if (!$parentIds) { + continue; + } + + $tableParam = \sprintf(':g%s_ptable', $i); + $idsParam = \sprintf(':g%s_whitelist', $i); + + $ors[] = $builder->expr()->and( + $builder->expr()->eq($colDynamicPtable, $tableParam), + $builder->expr()->in($colPid, $idsParam) + ); + + $builder->setParameter($tableParam, $table); + $builder->setParameter($idsParam, $parentIds); + } + + if (!$ors) { + $builder::abort(); + } + + if (\count($ors) === 1) { + $builder->where($ors[0]); + return; + } + + $builder->whereOr(...$ors); + } +} \ No newline at end of file diff --git a/src/Filter/Type/BooleanFilterType.php b/src/Filter/Type/BooleanFilterType.php new file mode 100644 index 00000000..d8c52206 --- /dev/null +++ b/src/Filter/Type/BooleanFilterType.php @@ -0,0 +1,24 @@ +define('field')->required()->allowedTypes('string'); + $resolver->define('value')->required()->allowedTypes('bool'); + } + + public function buildQuery(FilterQueryBuilder $builder, array $options): void + { + $builder->where($builder->expr()->eq($builder->column($options['field']), ':val')) + ->setParameter('val', $options['value'] ? '1' : '', ParameterType::STRING); + } +} \ No newline at end of file diff --git a/src/Filter/Type/CalendarCurrentFilterType.php b/src/Filter/Type/CalendarCurrentFilterType.php new file mode 100644 index 00000000..bf24abbc --- /dev/null +++ b/src/Filter/Type/CalendarCurrentFilterType.php @@ -0,0 +1,50 @@ +define('start')->required()->allowedTypes('int'); + $resolver->define('stop')->required()->allowedTypes('int'); + $resolver->define('has_extended_events')->default(false)->allowedTypes('bool'); + } + + public function buildQuery(FilterQueryBuilder $builder, array $options): void + { + $colStartTime = $builder->column('startTime'); + $colRepeatEnd = $builder->column('repeatEnd'); + $colRecurrences = $builder->column('recurrences'); + $colRecurring = $builder->column('recurring'); + + $or = [ + "{$colStartTime} >= :start AND {$colStartTime} <= :end", + $builder->expr()->and( + $builder->expr()->eq($colRecurring, '1'), + $builder->expr()->lte($colStartTime, ':end'), + $builder->expr()->or( + $builder->expr()->eq($colRecurrences, '0'), + $builder->expr()->gte($colRepeatEnd, ':start'), + ), + ), + ]; + + if ($options['has_extended_events']) + { + $colEndTime = $builder->column('endTime'); + + $or[] = "{$colEndTime} >= :start AND {$colEndTime} <= :end"; + $or[] = "{$colStartTime} <= :start AND {$colEndTime} >= :end"; + } + + $builder->whereOr(...$or); + $builder->setParameter('start', $options['start']); + $builder->setParameter('end', $options['stop']); + } +} \ No newline at end of file diff --git a/src/Filter/Type/DateRangeFilterType.php b/src/Filter/Type/DateRangeFilterType.php new file mode 100644 index 00000000..ad5a8414 --- /dev/null +++ b/src/Filter/Type/DateRangeFilterType.php @@ -0,0 +1,33 @@ +define('field')->required()->allowedTypes('string'); + $resolver->define('from')->default(null)->allowedTypes('null', \DateTimeInterface::class); + $resolver->define('to')->default(null)->allowedTypes('null', \DateTimeInterface::class); + } + + public function buildQuery(FilterQueryBuilder $builder, array $options): void + { + $field = $builder->column($options['field']); + + if ($options['from'] instanceof \DateTimeInterface) { + $builder->where($builder->expr()->gte($field, ':from')) + ->setParameter('from', $options['from']->getTimestamp()); + } + + if ($options['to'] instanceof \DateTimeInterface) { + $builder->where($builder->expr()->lte($field, ':to')) + ->setParameter('to', $options['to']->getTimestamp()); + } + } +} \ No newline at end of file diff --git a/src/Filter/Type/DcaSelectFilterType.php b/src/Filter/Type/DcaSelectFilterType.php new file mode 100644 index 00000000..4fa383b9 --- /dev/null +++ b/src/Filter/Type/DcaSelectFilterType.php @@ -0,0 +1,76 @@ +define('field')->required()->allowedTypes('string'); + $resolver->define('selected')->required()->allowedTypes('array'); + $resolver->define('valid_options')->required()->allowedTypes('array'); + $resolver->define('is_multiple_dca_field')->default(false)->allowedTypes('bool'); + } + + public function buildQuery(FilterQueryBuilder $builder, array $options): void + { + $selected = \array_values($options['selected']); + $validOptions = $options['valid_options']; + $field = $options['field']; + + if (!$selected) { + return; + } + + if (!$validOptions || !$field) { + $builder::abort(); + } + + if (\count($selected) === 1) + { + $value = \current($selected); + if (!\array_key_exists($value, $validOptions)) { + $builder::abort(); + } + + if ($options['is_multiple_dca_field']) { + $builder->whereInSerialized($value, $field); + return; + } + + $builder->where($builder->expr()->eq($builder->column($field), ':value')) + ->setParameter('value', $value); + return; + } + + if (\count(\array_unique($validOptions)) !== \count($validOptions)) { + throw new FilterException('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/Type/FieldValueChoiceFilterType.php b/src/Filter/Type/FieldValueChoiceFilterType.php new file mode 100644 index 00000000..1f02fd23 --- /dev/null +++ b/src/Filter/Type/FieldValueChoiceFilterType.php @@ -0,0 +1,38 @@ +define('field')->required()->allowedTypes('string'); + $resolver->define('values')->required()->allowedTypes('array'); + } + + public function buildQuery(FilterQueryBuilder $builder, array $options): void + { + $values = $options['values']; + + if (!$values) { + return; + } + + $field = $builder->column($options['field']); + + if (\count($values) < 2) + { + $builder->where("LOWER(TRIM({$field})) = :value") + ->setParameter('value', \reset($values)); + return; + } + + $builder->where("LOWER(TRIM({$field})) IN (:values)") + ->setParameter('values', $values); + } +} \ No newline at end of file diff --git a/src/Filter/Type/FilterTypeInterface.php b/src/Filter/Type/FilterTypeInterface.php new file mode 100644 index 00000000..8c1e06ff --- /dev/null +++ b/src/Filter/Type/FilterTypeInterface.php @@ -0,0 +1,27 @@ + $options + */ + public function buildQuery(FilterQueryBuilder $builder, array $options): void; +} diff --git a/src/Filter/Type/IntegerIdChoiceFilterType.php b/src/Filter/Type/IntegerIdChoiceFilterType.php new file mode 100644 index 00000000..49afee3c --- /dev/null +++ b/src/Filter/Type/IntegerIdChoiceFilterType.php @@ -0,0 +1,37 @@ +define('field')->default('id')->allowedTypes('string'); + $resolver->define('ids')->required()->allowedTypes('array'); + } + + public function buildQuery(FilterQueryBuilder $builder, array $options): void + { + $ids = \array_values(\array_unique(\array_filter(\array_map('\intval', $options['ids'])))); + + if (!$ids) { + return; + } + + if (\count($ids) === 1) { + $builder->where($builder->expr()->eq($builder->column($options['field']), ':id')) + ->setParameter('id', \reset($ids), ParameterType::INTEGER); + return; + } + + $builder->where($builder->expr()->in($builder->column($options['field']), ':ids')) + ->setParameter('ids', $ids, ArrayParameterType::INTEGER); + } +} \ No newline at end of file diff --git a/src/Filter/Type/PublishedFilterType.php b/src/Filter/Type/PublishedFilterType.php new file mode 100644 index 00000000..5d684dd9 --- /dev/null +++ b/src/Filter/Type/PublishedFilterType.php @@ -0,0 +1,48 @@ +define('published_field')->default(null)->allowedTypes('null', 'string'); + $resolver->define('start_field')->default(null)->allowedTypes('null', 'string'); + $resolver->define('stop_field')->default(null)->allowedTypes('null', 'string'); + $resolver->define('invert_published')->default(false)->allowedTypes('bool'); + $resolver->define('now')->required()->allowedTypes('int'); + } + + public function buildQuery(FilterQueryBuilder $builder, array $options): void + { + if ($options['published_field']) + { + $publishedField = $builder->column($options['published_field']); + $operator = $options['invert_published'] ? 'neq' : 'eq'; + + $builder->where($builder->expr()->{$operator}($publishedField, ':published')) + ->setParameter('published', '1'); + } + + if ($options['start_field']) + { + $startField = $builder->column($options['start_field']); + + $builder->where("{$startField} = '' OR {$startField} = '0' OR {$startField} <= :start") + ->setParameter('start', $options['now']); + } + + if ($options['stop_field']) + { + $stopField = $builder->column($options['stop_field']); + + $builder->where("{$stopField} = '' OR {$stopField} = '0' OR {$stopField} >= :stop") + ->setParameter('stop', $options['now']); + } + } +} \ No newline at end of file diff --git a/src/Filter/Type/SearchKeywordsFilterType.php b/src/Filter/Type/SearchKeywordsFilterType.php new file mode 100644 index 00000000..1db06f1d --- /dev/null +++ b/src/Filter/Type/SearchKeywordsFilterType.php @@ -0,0 +1,65 @@ +define('value')->required()->allowedTypes('string'); + $resolver->define('columns')->required()->allowedTypes('array'); + } + + public function buildQuery(FilterQueryBuilder $builder, array $options): void + { + $columns = \array_map($builder->column(...), $options['columns']); + $searchTermGroups = \array_values(\preg_split('/\s+OR\s+/i', $options['value'])); + $or = []; + + foreach ($searchTermGroups ?: [] as $i => $searchTermGroup) + { + if (!$searchTerms = $this->makeTerms($searchTermGroup)) { + 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/Type/SimpleEquationFilterType.php b/src/Filter/Type/SimpleEquationFilterType.php new file mode 100644 index 00000000..fefffdd7 --- /dev/null +++ b/src/Filter/Type/SimpleEquationFilterType.php @@ -0,0 +1,80 @@ +define('operand_left') + ->info('The left operand of the equation filter') + ->required() + ->allowedTypes('string') + ; + + $resolver->define('operator') + ->info('The operator of the equation filter.') + ->required() + ->allowedTypes(SqlEquationOperator::class, 'string') + ->allowedValues(static fn (SqlEquationOperator|string $value): bool => (bool) SqlEquationOperator::match($value)) + ->normalize(static fn (Options $resolver, SqlEquationOperator|string $value): ?SqlEquationOperator => SqlEquationOperator::match($value)) + ; + + $resolver->define('operand_right') + ->info('The right operand of the equation filter (optional for unary operators).') + ->allowedTypes('string', 'int', 'null') + ->default('') + ; + } + + /** + * @throws FilterException + */ + public function buildQuery(FilterQueryBuilder $builder, array $options): void + { + $operandLeft = $options['operand_left']; + $operator = SqlEquationOperator::match($options['operator']); + + if (!$operandLeft || !$operator instanceof SqlEquationOperator) { + throw new FilterException('Invalid filter configuration.', 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::IS_NULL => $builder->expr()->isNull($operandLeft), + SqlEquationOperator::IS_NOT_NULL => $builder->expr()->isNotNull($operandLeft), + default => null, + }; + + if (!$where) { + throw new FilterException( + 'Invalid filter configuration: Operator not supported.', + method: __METHOD__, + ); + } + + $builder->where($where); + + if (!$operator->isUnary()) { + $operandRight = $options['operand_right']; + $builder->setParameter(':eq_right', $operandRight); + } + } +} diff --git a/src/FilterCollector/FilterCollectorInterface.php b/src/FilterCollector/FilterCollectorInterface.php deleted file mode 100644 index c9ee2ed6..00000000 --- a/src/FilterCollector/FilterCollectorInterface.php +++ /dev/null @@ -1,17 +0,0 @@ -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 2dd16704..00000000 --- a/src/FilterElement/SimpleEquationElement.php +++ /dev/null @@ -1,114 +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::IS_NULL => $qb->expr()->isNull($operand), - SqlEquationOperator::IS_NOT_NULL => $qb->expr()->isNotNull($operand), - default => null, - }; - - if (!$where) { - throw new FilterException('Invalid filter configuration: Operator not supported.'); - } - - $qb->where($where); - - if (!$op->isUnary()) { - $qb->setParameter(':eq_right', $inv->filter->equationRight ?: ''); - } - } - - #[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; - } -} \ No newline at end of file diff --git a/src/Form/ChoicesBuilder.php b/src/Form/ChoicesBuilder.php index 2a0b5da8..9b24eb5b 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,7 +45,7 @@ * * Group support ({@see addGroup()}, {@see removeGroup()}) is reserved and not yet implemented. * - * @mago-expect lint:too-many-properties + * @mago-expect lint:too-many-methods */ class ChoicesBuilder { @@ -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/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..21764e7a 100644 --- a/src/HeimrichHannotFlareBundle.php +++ b/src/HeimrichHannotFlareBundle.php @@ -46,11 +46,8 @@ 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 $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..f96ce720 --- /dev/null +++ b/src/Integration/CodefogTags/FilterElement/CodefogTagsChoiceFilterElement.php @@ -0,0 +1,215 @@ +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 buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void + { + $config = $context->config; + + $preselect = $config['preselect'] ?: null; + + /** @var ?array $tagIds */ + $tagIds = $config['intrinsic'] + ? $preselect + : $this->processRuntimeValue($values[FilterContext::SINGLE_VALUE] ?? null); + + if (!$tagIds) { + return; + } + + $builder->add(IntegerIdChoiceFilterType::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/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 ffd7f95f..99b8b73b 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 Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\HttpFoundation\RequestStack; @@ -36,6 +37,7 @@ class ChangelanguageListener public function __construct( private readonly Connection $connection, + private readonly FilterFactory $filterFactory, private readonly ReaderRequestAttributeResolver $attributeResolver, private readonly RequestStack $requestStack, ) {} @@ -52,9 +54,9 @@ public function setMultilingualQueryBuilderFactory( #[AsEventListener] public function fetchAutoItem(FetchAutoItemEvent $event): void { - $list = $event->getListSpecification(); + $list = $event->getList(); - if ($list->type !== DcMultilingualListType::TYPE) { + if (!$list->driver instanceof DcMultilingualListType) { return; } @@ -110,7 +112,7 @@ private function isFallbackLanguage(AbstractFetchEvent $event): bool return $lang === $langFallback; } - + #[AsEventListener('flare.list.' . DcMultilingualListType::TYPE . '.fetch_count')] public function listViewFetchCountEvent(FetchCountEvent $event): void { @@ -122,31 +124,39 @@ public function listViewFetchCountEvent(FetchCountEvent $event): void $this->applyMlQueriesIfNecessary($event->getListQueryBuilder(), $filters, $lang); $contentContext = $event->getContentContext(); - + $dcMultilingualDisplay = $event->getContentContext()->getContentModel()->flare_dcMultilingualDisplay ?: $filters->getListModel()->dcMultilingual_display; - $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, // )); @@ -195,25 +205,23 @@ private function applyMlQueriesIfNecessary( $listQueryBuilder->setTableAliasMandatory('translation'); $listQueryBuilder->setGroupBy([]); } - + #[AsEventListener(priority: 220)] public function onListViewDetailsPageUrlGenerated(DetailsPageUrlGeneratedEvent $event): void { - $eventPage = $event->getPage(); - - if (!$langPage = $this->findPageForLanguage($eventPage)) { + if (!$langPage = $this->findPageForLanguage($event->page)) { return; } /** @noinspection PhpCastIsUnnecessaryInspection */ - if ((int) $langPage->id === (int) $eventPage->id) { + if ((int) $langPage->id === (int) $event->page->id) { return; } - $url = $langPage->getAbsoluteUrl('/' . $event->getAutoItem()); + $url = $langPage->getAbsoluteUrl('/' . $event->autoItem); - $event->setPage($langPage); - $event->setUrl($url); + $event->page = $langPage; + $event->url = $url; } private function findPageForLanguage(PageModel $page): ?PageModel @@ -259,8 +267,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; @@ -325,4 +333,4 @@ private function createQueryBuilder(string $table, string $language): QueryBuild DcMultilingualHelper::getTranslatableFields($table) )->buildQueryBuilderForFind($language); } -} \ No newline at end of file +} 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..8396b5f5 --- /dev/null +++ b/src/List/Collector/ListModelFilterCollector.php @@ -0,0 +1,80 @@ +|null + */ + public function collect(ListModel $listModel): ?array + { + if (!$listModel->id || !$table = $listModel::getTable()) { + return null; + } + + if (!$this->listDriverRegistry->getService((string) $listModel->type)) { + return null; + } + + 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] %e', + $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..7bb8f537 --- /dev/null +++ b/src/List/ListSpec.php @@ -0,0 +1,124 @@ + $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, ?string $key = null): self + { + if (null === ($key ??= $filter->alias)) + { + $index = 0; + + while (isset($this->filters["_generated_{$index}"])) { + $index++; + } + + $key = "_generated_{$index}"; + } + + return $this->withFilters([...$this->filters, $key => $filter]); + } + + public function withoutFilter(string $key): self + { + $filters = $this->filters; + unset($filters[$key]); + + return $this->withFilters($filters); + } + + /** + * @param array $filters + */ + public function withFilters(array $filters): self + { + return new self( + 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 38b59ecd..f229e05a 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 (string) $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) { @@ -116,4 +115,4 @@ public function __get($strKey) default => parent::__get($strKey), }; } -} \ No newline at end of file +} diff --git a/src/Model/ListModel.php b/src/Model/ListModel.php index 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 index d3eaa197..0dafecf1 100644 --- a/src/Query/Executor/FilterExecutor.php +++ b/src/Query/Executor/FilterExecutor.php @@ -4,19 +4,22 @@ namespace HeimrichHannot\FlareBundle\Query\Executor; -use HeimrichHannot\FlareBundle\Event\FilterElementInvokedEvent; -use HeimrichHannot\FlareBundle\Event\FilterElementInvokingEvent; +use HeimrichHannot\FlareBundle\Event\FilterElementBuildingEvent; +use HeimrichHannot\FlareBundle\Event\FilterElementBuiltEvent; use HeimrichHannot\FlareBundle\Exception\AbortFilteringException; use HeimrichHannot\FlareBundle\Exception\FilterException; use HeimrichHannot\FlareBundle\Exception\FlareException; -use HeimrichHannot\FlareBundle\Filter\FilterInvocation; -use HeimrichHannot\FlareBundle\Filter\Resolver\FilterInvokerResolver; +use HeimrichHannot\FlareBundle\Filter\Factory\FilterContextFactory; +use HeimrichHannot\FlareBundle\Filter\Filter; +use HeimrichHannot\FlareBundle\Filter\FilterBuilder; +use HeimrichHannot\FlareBundle\Filter\FilterCall; +use HeimrichHannot\FlareBundle\Filter\FilterContext; use HeimrichHannot\FlareBundle\Query\Factory\FilterQueryBuilderFactory; use HeimrichHannot\FlareBundle\Query\FilterQueryBuilder; use HeimrichHannot\FlareBundle\Query\ListQueryConfig; use HeimrichHannot\FlareBundle\Query\TableAliasRegistry; use HeimrichHannot\FlareBundle\Registry\FilterElementRegistry; -use HeimrichHannot\FlareBundle\Specification\FilterDefinition; +use HeimrichHannot\FlareBundle\Registry\FilterTypeRegistry; use HeimrichHannot\FlareBundle\Util\Str; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -24,9 +27,10 @@ { public function __construct( private EventDispatcherInterface $eventDispatcher, + private FilterContextFactory $filterContextFactory, private FilterElementRegistry $filterElementRegistry, - private FilterInvokerResolver $filterInvoker, private FilterQueryBuilderFactory $filterQueryBuilderFactory, + private FilterTypeRegistry $filterTypeRegistry, ) {} /** @@ -39,85 +43,66 @@ public function __construct( public function invokeFilters(ListQueryConfig $options): array { $list = $options->list; - $context = $options->context; $filterQueryBuilders = []; - /** - * @var int|string $key - * @var FilterDefinition $filter - */ - foreach ($list->getFilters()->all() as $key => $filter) + foreach ($list->filters as $key => $filter) { - $invocation = new FilterInvocation( - filter: $filter, - list: $list, - context: $context, - value: $options->filterValues[$key] ?? null, - ); - - if (!$filterQueryBuilder = $this->invokeFilter($invocation)) { + $context = $this->filterContextFactory->create($list, $filter, $options->context, $key); + + $data = (array) ($options->filterValues[$key] ?? $filter->data ?? []); + + if (!$builders = $this->invokeFilter($filter, $context, $data)) { continue; } - $filterQueryBuilders[] = $filterQueryBuilder; + \array_push($filterQueryBuilders, ...$builders); } return $filterQueryBuilders; } /** + * @param array $data + * + * @return FilterQueryBuilder[] + * * @throws AbortFilteringException * @throws FilterException * @throws FlareException */ - public function invokeFilter(FilterInvocation $invocation): ?FilterQueryBuilder + public function invokeFilter(Filter $filter, FilterContext $context, array $data = []): array { - if (!Str::isValidSqlName($table = $invocation->list->dc)) + if (!Str::isValidSqlName($table = $context->list->dc)) { throw new FlareException(\sprintf( - '[FLARE] ListSpecification data container cannot be used as SQL table identifier: "%s"', + '[FLARE] ListSpec data container cannot be used as SQL table identifier: "%s"', $table - ), method: __METHOD__); + ), method: __METHOD__, source: $filter->source ?: 'filter inlined'); } - $filter = $invocation->filter; - $context = $invocation->context; + $isTargeted = $this->filterElementRegistry->getAttribute($filter->type)?->isTargeted; - if (!$filterElementDescriptor = $this->filterElementRegistry->get($filter->getType())) { - return null; + $targetAlias = TableAliasRegistry::ALIAS_MAIN; + if ($isTargeted || $filter->targetingForced) { + $targetAlias = $filter->targetAlias ?: TableAliasRegistry::ALIAS_MAIN; } - if (!$invoker = $this->filterInvoker->get( - filterType: $filter->getType(), - contextType: $context::getContextType() - )) { - return null; - } + $builder = new FilterBuilder($this->filterTypeRegistry, $targetAlias); - $event = $this->eventDispatcher->dispatch(new FilterElementInvokingEvent( - invocation: $invocation, + $event = $this->eventDispatcher->dispatch(new FilterElementBuildingEvent( context: $context, - invoker: $invoker, - shouldInvoke: true, + builder: $builder, + data: $data, )); - if (!$event->shouldInvoke()) { - return null; + if (!$event->shouldBuild) { + return []; } - $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); + $filter->element->buildFilter($builder, $context, $data); } catch (AbortFilteringException $e) { @@ -125,59 +110,63 @@ public function invokeFilter(FilterInvocation $invocation): ?FilterQueryBuilder } catch (FilterException $e) { - throw $this->createCallbackException($e, $filter, $invoker); + throw $this->createFilterException($e, $filter, $filter->element::class . '::buildFilter'); } catch (\Throwable $e) { - throw new FilterException($e->getMessage(), code: $e->getCode(), previous: $e, method: __METHOD__); + throw new FilterException($e->getMessage(), code: $e->getCode(), previous: $e, + method: __METHOD__, source: $filter->source ?: 'filter inlined'); } - $this->eventDispatcher->dispatch(new FilterElementInvokedEvent($invocation, $filterQueryBuilder)); + $this->eventDispatcher->dispatch(new FilterElementBuiltEvent($context, $builder, $data)); - return $filterQueryBuilder; + return $this->buildQueryBuilders($builder->all(), $filter); } - private function createCallbackException( - FilterException $e, - FilterDefinition $filter, - mixed $callback - ): FilterException { - if (!$errorMethod = $e->getMethod()) + /** + * @param FilterCall[] $calls + * @return FilterQueryBuilder[] + */ + private function buildQueryBuilders(array $calls, Filter $filter): array + { + $filterQueryBuilders = []; + + foreach ($calls as $call) { - $serviceId = null; - $method = '___UNKNOWN___'; + $filterQueryBuilder = $this->filterQueryBuilderFactory->create($call->targetAlias); - if (\is_object($callback)) + try { - $serviceId = $callback::class; - $method = '::__invoke'; + $call->type->buildQuery($filterQueryBuilder, $call->options); } - - if (!$serviceId && \is_callable($callback)) + catch (AbortFilteringException $e) { - 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) {} + throw $e; } - - if (!$serviceId) + catch (FilterException $e) + { + throw $this->createFilterException($e, $filter, $call->typeClass . '::buildQuery'); + } + catch (\Throwable $e) { - $serviceId = \gettype($callback); - $method = '()'; + throw new FilterException($e->getMessage(), code: $e->getCode(), previous: $e, + method: $call->typeClass, source: $filter->source ?: 'filter inlined'); } - $errorMethod = $serviceId . $method; + $filterQueryBuilders[] = $filterQueryBuilder; } + return $filterQueryBuilders; + } + + private function createFilterException(FilterException $e, Filter $filter, string $fallbackMethod): FilterException + { + $errorMethod = $e->getMethod() ?: $fallbackMethod; + return new FilterException( \sprintf('[FLARE] Query denied: %s / Callback: %s', $e->getMessage(), $errorMethod), code: $e->getCode(), previous: $e, method: $errorMethod, - source: \sprintf('tl_flare_filter.id=%s', $filter->getDataSource()->getFilterIdentifier() ?: '0'), + source: $filter->source ?: 'filter inlined', ); } -} \ No newline at end of file +} diff --git a/src/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/FilterQueryBuilder.php b/src/Query/FilterQueryBuilder.php index 5ef813d8..16243c56 100644 --- a/src/Query/FilterQueryBuilder.php +++ b/src/Query/FilterQueryBuilder.php @@ -51,7 +51,10 @@ public function alias(): string public function column(string $column): string { if (!\preg_match('/^[a-zA-Z0-9_]+$/', $column)) { - throw new FilterException('Invalid column name: only alphanumeric characters and underscores are allowed.'); + throw new FilterException( + 'Invalid column name: only alphanumeric characters and underscores are allowed.', + method: __METHOD__, + ); } return $this->connection->quoteIdentifier($this->alias() . '.' . $column); @@ -282,4 +285,4 @@ function (array $matches) use ($prefix, &$parameters, &$types): string return new FilterQuery($alias, $sql, $parameters, $types); } -} \ No newline at end of file +} diff --git a/src/Query/ListQueryConfig.php b/src/Query/ListQueryConfig.php index fd8a7dea..d27c876f 100644 --- a/src/Query/ListQueryConfig.php +++ b/src/Query/ListQueryConfig.php @@ -5,12 +5,12 @@ namespace HeimrichHannot\FlareBundle\Query; use HeimrichHannot\FlareBundle\Engine\Context\ContextInterface; -use HeimrichHannot\FlareBundle\Specification\ListSpecification; +use HeimrichHannot\FlareBundle\List\ListSpec; readonly class ListQueryConfig { public function __construct( - public ListSpecification $list, + public ListSpec $list, public ContextInterface $context, public array $filterValues, public bool $isCounting = false, diff --git a/src/Reader/Factory/ReaderRequestAttributeFactory.php b/src/Reader/Factory/ReaderRequestAttributeFactory.php index d4cfb379..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 6a48d2fa..f2431fe6 100644 --- a/src/Reader/ReaderUrlGenerator.php +++ b/src/Reader/ReaderUrlGenerator.php @@ -33,6 +33,6 @@ public function generate(Model $model): ?string ) ); - return $event->getUrl(); + return $event->url; } -} \ No newline at end of file +} 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/FilterInvokerRegistry.php b/src/Registry/FilterInvokerRegistry.php deleted file mode 100644 index e231ce6c..00000000 --- a/src/Registry/FilterInvokerRegistry.php +++ /dev/null @@ -1,35 +0,0 @@ -invokers[$filterType][$context ?? 'default'][$priority][] = [ - 'serviceId' => $serviceId, - 'method' => $method - ]; - } - - public function find(string $filterType, string $context): ?array - { - $invokers = $this->invokers[$filterType][$context] ?? null; - - if ($invokers === null && $context !== 'default') { - $invokers = $this->invokers[$filterType]['default'] ?? null; - } - - if ($invokers === null) { - return null; - } - - \krsort($invokers); - - return \current($invokers)[0] ?? null; - } -} \ No newline at end of file diff --git a/src/Registry/FilterTypeRegistry.php b/src/Registry/FilterTypeRegistry.php new file mode 100644 index 00000000..0d288ce2 --- /dev/null +++ b/src/Registry/FilterTypeRegistry.php @@ -0,0 +1,59 @@ +, FilterTypeInterface> + */ + private array $types; + + public function __construct( + #[TaggedIterator(FilterTypeInterface::FLARE_FILTER_TYPE_TAG)] + private readonly iterable $filterTypes, + ) {} + + /** + * @param class-string $class + */ + public function get(string $class): ?FilterTypeInterface + { + return $this->resolve()[$class] ?? null; + } + + /** + * @return array, FilterTypeInterface> + */ + public function all(): array + { + return $this->resolve(); + } + + private function resolve(): array + { + if (!isset($this->types)) { + $this->types = []; + + foreach ($this->filterTypes as $filterType) { + if (!$filterType instanceof FilterTypeInterface) { + throw new \LogicException(\sprintf( + 'Service "%s" is tagged "%s" but does not implement %s.', + $filterType::class, + FilterTypeInterface::FLARE_FILTER_TYPE_TAG, + FilterTypeInterface::class, + )); + } + + $this->types[$filterType::class] = $filterType; + } + } + + return $this->types; + } +} \ No newline at end of file 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/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/MethodInjector.php b/src/Util/MethodInjector.php deleted file mode 100644 index df8dbb84..00000000 --- a/src/Util/MethodInjector.php +++ /dev/null @@ -1,89 +0,0 @@ -getParameters() as $parameter) - { - if ($skipped < \count($mandatoryParams)) - { - $skipped++; - continue; - } - - // @phpstan-ignore method.notFound - if ($parameter->getType() && !$parameter->getType()->isBuiltin()) - { - // @phpstan-ignore method.notFound - $typeName = $parameter->getType()->getName(); - - if (\array_key_exists($typeName, $optionalParams)) - { - $arguments[] = $optionalParams[$typeName]; - continue; - } - } - - if (\array_key_exists($parameter->getName(), $optionalParams)) - { - $arguments[] = $optionalParams[$parameter->getName()]; - continue; - } - - if ($parameter->isDefaultValueAvailable()) - { - $arguments[] = $parameter->getDefaultValue(); - continue; - } - - if (!$parameter->hasType() - || (($type = $parameter->getType()) instanceof \ReflectionNamedType - && ($type->allowsNull() || $type->getName() === 'mixed'))) - { - $arguments[] = null; - continue; - } - - throw new \RuntimeException(sprintf( - 'Unable to resolve parameter "%s" for method %s::%s', - $parameter->getName(), - get_class($service), - $method - )); - } - - return $reflectionMethod->invokeArgs($service, $arguments); - } -} \ No newline at end of file diff --git a/src/Util/Str.php b/src/Util/Str.php index 2db295b2..def15c7a 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; } @@ -329,4 +370,4 @@ public static function htmlJoinClasses(string|array|null ...$classes): string { return \implode(' ', self::htmlListClasses(...$classes)); } -} \ No newline at end of file +} 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/Engine/Projector/InteractiveProjectorTest.php b/tests/Engine/Projector/InteractiveProjectorTest.php new file mode 100644 index 00000000..841da951 --- /dev/null +++ b/tests/Engine/Projector/InteractiveProjectorTest.php @@ -0,0 +1,163 @@ +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 buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): 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 testFlatSubmittedValueIsKeyedCanonically(): void + { + $root = $this->createRootBuilder(); + $this->addFlatChild($root, 'suche'); + $form = $root->getForm(); + + $form->submit(['suche' => 'term']); + + $this->assertSame( + ['sucheKey' => [FilterContext::SINGLE_VALUE => 'term']], + $this->collect($this->listWithFilter('sucheKey', 'suche'), $form), + ); + } + + public function testFlatUnsubmittedDefaultIsCollected(): void + { + $root = $this->createRootBuilder(); + $this->addFlatChild($root, 'suche', ['data' => 'preset']); + $form = $root->getForm(); + + $this->assertSame( + ['sucheKey' => [FilterContext::SINGLE_VALUE => 'preset']], + $this->collect($this->listWithFilter('sucheKey', 'suche'), $form), + ); + } + + public function testFlatUnsubmittedWithoutDefaultStaysUnset(): void + { + $root = $this->createRootBuilder(); + $this->addFlatChild($root, 'suche'); + $form = $root->getForm(); + + $this->assertSame([], $this->collect($this->listWithFilter('sucheKey', 'suche'), $form)); + } + + public function testFlatSubmittedEmptyValueIsKeptSoItOverridesDataBags(): void + { + $root = $this->createRootBuilder(); + $this->addFlatChild($root, 'suche', ['data' => 'preset']); + $form = $root->getForm(); + + $form->submit(['suche' => '']); + + $this->assertSame( + ['sucheKey' => [FilterContext::SINGLE_VALUE => null]], + $this->collect($this->listWithFilter('sucheKey', 'suche'), $form), + ); + } + + public function testCompoundSubmittedDataIsCollected(): void + { + $root = $this->createRootBuilder(); + $root->add( + $root->create('range', FormType::class, ['inherit_data' => false]) + ->add('from', TextType::class) + ->add('to', TextType::class), + ); + $form = $root->getForm(); + + $form->submit(['range' => ['from' => 'a', 'to' => 'b']]); + + $this->assertSame( + ['rangeKey' => ['from' => 'a', 'to' => 'b']], + $this->collect($this->listWithFilter('rangeKey', 'range'), $form), + ); + } + + public function testCompoundUnsubmittedFieldDefaultsAreCollected(): void + { + $root = $this->createRootBuilder(); + $root->add( + $root->create('range', FormType::class, ['inherit_data' => false]) + ->add('from', TextType::class, ['data' => 'a']) + ->add('to', TextType::class), + ); + $form = $root->getForm(); + + $this->assertSame( + ['rangeKey' => ['from' => 'a']], + $this->collect($this->listWithFilter('rangeKey', 'range'), $form), + ); + } + + public function testFilterWithoutMountedChildIsSkipped(): void + { + $form = $this->createRootBuilder()->getForm(); + + $this->assertSame([], $this->collect($this->listWithFilter('key', 'missing'), $form)); + } +} diff --git a/tests/EventListener/NamedDispatch/ListBuildListenerTest.php b/tests/EventListener/NamedDispatch/ListBuildListenerTest.php new file mode 100644 index 00000000..02b06ba1 --- /dev/null +++ b/tests/EventListener/NamedDispatch/ListBuildListenerTest.php @@ -0,0 +1,68 @@ +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"; + }, + ); + } + + $builder = new ListSpecBuilder( + specFactory: new ListSpecFactory( + new ListDriverRegistry(), + new ListOptionsResolver(new SchemaResolver()), + new ListTransformerResolver($dispatcher), + ), + 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..cabe4fca --- /dev/null +++ b/tests/Filter/FilterBuilderTest.php @@ -0,0 +1,97 @@ +get(TestFilterType::class)); + self::assertSame([TestFilterType::class => $type], $registry->all()); + self::assertNull($registry->get(UnknownFilterType::class)); + } + + public function testBuilderResolvesOptionsAndRecordsTargetedCalls(): void + { + $builder = new FilterBuilder( + new FilterTypeRegistry([new TestFilterType()]), + 'main', + ); + + $builder + ->add(TestFilterType::class, ['value' => 'first']) + ->add(TestFilterType::class, ['value' => 'second', 'enabled' => true], 'translation'); + + $calls = $builder->all(); + + self::assertCount(2, $calls); + self::assertSame('main', $calls[0]->targetAlias); + self::assertSame('first', $calls[0]->options['value']); + self::assertFalse($calls[0]->options['enabled']); + self::assertSame('translation', $calls[1]->targetAlias); + self::assertSame('second', $calls[1]->options['value']); + self::assertTrue($calls[1]->options['enabled']); + } + + public function testBuilderRejectsUnknownFilterTypes(): void + { + $builder = new FilterBuilder(new FilterTypeRegistry([]), 'main'); + + $this->expectException(FilterException::class); + $builder->add(TestFilterType::class, ['value' => 'test']); + } + + public function testBuilderLetsOptionsResolverValidateRequiredOptions(): void + { + $builder = new FilterBuilder( + new FilterTypeRegistry([new TestFilterType()]), + 'main', + ); + + $this->expectException(MissingOptionsException::class); + $builder->add(TestFilterType::class); + } + + public function testBuilderAbortThrowsAbortFilteringException(): void + { + $builder = new FilterBuilder(new FilterTypeRegistry([]), 'main'); + + $this->expectException(AbortFilteringException::class); + $builder->abort(); + } +} + +final class TestFilterType extends AbstractFilterType +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->define('value')->required()->allowedTypes('string'); + $resolver->define('enabled')->default(false)->allowedTypes('bool'); + } + + public function buildQuery(FilterQueryBuilder $builder, array $options): void + { + } +} + +final class UnknownFilterType extends AbstractFilterType +{ + public function buildQuery(FilterQueryBuilder $builder, array $options): void + { + } +} diff --git a/tests/Filter/FilterFactoryTest.php b/tests/Filter/FilterFactoryTest.php new file mode 100644 index 00000000..d996efb9 --- /dev/null +++ b/tests/Filter/FilterFactoryTest.php @@ -0,0 +1,73 @@ +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/FilterOptionsResolverTest.php b/tests/Filter/FilterOptionsResolverTest.php new file mode 100644 index 00000000..2309de63 --- /dev/null +++ b/tests/Filter/FilterOptionsResolverTest.php @@ -0,0 +1,87 @@ +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 buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void + { + } +} + +final class PlainElement implements FilterElementInterface +{ + public function buildForm(FormBuilderInterface $builder, FilterContext $context): void + { + } + + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void + { + } +} diff --git a/tests/Filter/FilterTest.php b/tests/Filter/FilterTest.php new file mode 100644 index 00000000..22e61f0f --- /dev/null +++ b/tests/Filter/FilterTest.php @@ -0,0 +1,68 @@ + 1], + alias: 'foo', + source: 'tl_flare_filter.1', + ); + + $withData = $filter->withData(['value' => 42]); + + self::assertNull($filter->data); + self::assertSame(['value' => 42], $withData->data); + self::assertSame(self::element(), $withData->element); + self::assertSame('test', $withData->type); + self::assertSame('foo', $withData->alias); + self::assertSame(['a' => 1], $withData->config); + self::assertSame('tl_flare_filter.1', $withData->source); + + $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..e2ee82e3 --- /dev/null +++ b/tests/Filter/FilterTransformerResolverTest.php @@ -0,0 +1,114 @@ +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 buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void + { + } +} + +final class PlainTransformerlessElement implements FilterElementInterface +{ + public function buildForm(FormBuilderInterface $builder, FilterContext $context): void + { + } + + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void + { + } +} diff --git a/tests/Form/FilterFormBuilderTest.php b/tests/Form/FilterFormBuilderTest.php new file mode 100644 index 00000000..1fda7c45 --- /dev/null +++ b/tests/Form/FilterFormBuilderTest.php @@ -0,0 +1,115 @@ +assertNull($this->createBuilder()->getSingle()); + } + + public function testSingleRecordsTypeAndOptions(): void + { + $builder = $this->createBuilder(); + + $result = $builder->single(TextType::class, ['required' => false]); + + $this->assertSame($builder, $result); + $this->assertSame( + ['type' => TextType::class, 'options' => ['required' => false]], + $builder->getSingle(), + ); + $this->assertSame(0, $builder->count(), 'single() must not add a child'); + } + + public function testSingleOverwritesPreviousDeclaration(): void + { + $builder = $this->createBuilder(); + + $builder->single(TextType::class, ['required' => true]); + $builder->single(TextType::class, ['required' => false]); + + $this->assertSame( + ['type' => TextType::class, 'options' => ['required' => false]], + $builder->getSingle(), + ); + } + + public function testAddEventListenerDefersInsteadOfRegistering(): void + { + $builder = $this->createBuilder(); + $first = static function (): void {}; + $second = static function (): void {}; + + $result = $builder + ->addEventListener(FormEvents::POST_SUBMIT, $first) + ->addEventListener(FormEvents::PRE_SET_DATA, $second, 7); + + $this->assertSame($builder, $result); + $this->assertSame( + [ + [FormEvents::POST_SUBMIT, $first, 0], + [FormEvents::PRE_SET_DATA, $second, 7], + ], + $builder->getDeferredListeners(), + ); + $this->assertFalse( + $builder->getEventDispatcher()->hasListeners(FormEvents::POST_SUBMIT), + 'Deferred listeners must not reach the collector\'s own dispatcher', + ); + } + + public function testAddEventSubscriberThrows(): void + { + $subscriber = new class implements EventSubscriberInterface { + public static function getSubscribedEvents(): array + { + return []; + } + }; + + $this->expectException(\LogicException::class); + + $this->createBuilder()->addEventSubscriber($subscriber); + } + + public function testGetFormThrows(): void + { + $this->expectException(\LogicException::class); + + $this->createBuilder()->getForm(); + } + + public function testAddProducesRealMountableChildBuilders(): void + { + $builder = $this->createBuilder(); + + $builder->add('field', TextType::class, ['required' => false]); + + $this->assertSame(1, $builder->count()); + + $child = $builder->get('field'); + + $this->assertInstanceOf(FormBuilderInterface::class, $child); + $this->assertNotInstanceOf(FilterFormBuilder::class, $child); + $this->assertInstanceOf(FormInterface::class, $child->getForm()); + } +} diff --git a/tests/Form/FilterFormFactoryTest.php b/tests/Form/FilterFormFactoryTest.php new file mode 100644 index 00000000..9dbef1ff --- /dev/null +++ b/tests/Form/FilterFormFactoryTest.php @@ -0,0 +1,213 @@ +eventDispatcher = new EventDispatcher(); + } + + private function createFactory(): FilterFormFactory + { + // The CSRF extension only needs to define the "csrf_protection" option; the factory + // always disables it, so the token manager is never used. + $formFactory = Forms::createFormFactoryBuilder() + ->addExtension(new CsrfExtension(new CsrfTokenManager())) + ->getFormFactory(); + + return new FilterFormFactory( + eventDispatcher: $this->eventDispatcher, + filterContextFactory: new FilterContextFactory(new FilterOptionsResolver(new SchemaResolver())), + formFactory: $formFactory, + ); + } + + private function createForm(array $filters): FormInterface + { + $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 getFormActionPage(): int + { + return 0; + } + }; + + 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 buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void {} + }; + } + + public function testSingleFieldMountsFlatUnderTheAlias(): void + { + $element = $this->element(static function (FilterFormBuilderInterface $builder): void { + $builder->single(TextType::class, ['required' => false]); + $builder->setAttribute('custom.attr', 'kept'); + $builder->addEventListener(FormEvents::POST_SUBMIT, static function (): void {}); + }); + + $form = $this->createForm(['suche' => new Filter(element: $element, 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 testSingleWithCompanionFieldMountsNestedCompound(): void + { + $element = $this->element(static function (FilterFormBuilderInterface $builder): void { + $builder->single(TextType::class, ['required' => false]); + $builder->add('extra', TextType::class, ['required' => false]); + }); + + $form = $this->createForm(['suche' => new Filter(element: $element, type: 'test_element', alias: 'suche')]); + + $child = $form->get('suche'); + + $this->assertInstanceOf(FormType::class, $child->getConfig()->getType()->getInnerType()); + $this->assertNull($child->getConfig()->getAttribute(FilterContext::ATTR_SINGLE_FIELD)); + $this->assertTrue($child->has(FilterContext::SINGLE_VALUE)); + $this->assertTrue($child->has('extra')); + } + + public function testMultiFieldElementMountsNestedCompound(): void + { + $element = $this->element(static function (FilterFormBuilderInterface $builder): void { + $builder->add('from', TextType::class); + $builder->add('to', TextType::class); + $builder->addEventListener(FormEvents::POST_SUBMIT, static function (): void {}); + }); + + $form = $this->createForm(['range' => new Filter(element: $element, 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( + FilterElementFormBuiltEvent::class, + static fn (FilterElementFormBuiltEvent $event) => $event->cancel(), + ); + + $element = $this->element(static function (FilterFormBuilderInterface $builder): void { + $builder->single(TextType::class); + }); + + $form = $this->createForm(['suche' => new Filter(element: $element, type: 'test_element', alias: 'suche')]); + + $this->assertFalse($form->has('suche')); + } +} 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..e798b1bb --- /dev/null +++ b/tests/List/ListSpecBuilderTest.php @@ -0,0 +1,169 @@ +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 + { + $builder = new ListSpecBuilder( + specFactory: self::specFactory(), + 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(?EventDispatcher $dispatcher = null): ListSpecFactory + { + return new ListSpecFactory( + new ListDriverRegistry(), + new ListOptionsResolver(new SchemaResolver()), + new ListTransformerResolver($dispatcher ?? new EventDispatcher()), + ); + } + + private function createBuilder( + EventDispatcher $dispatcher, + ?ListDriverInterface $driver = null, + ?ListModel $model = null, + ): ListSpecBuilder { + return new ListSpecBuilder( + specFactory: self::specFactory($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..76889130 --- /dev/null +++ b/tests/List/ListSpecFactoryTest.php @@ -0,0 +1,88 @@ +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..610bbb2e --- /dev/null +++ b/tests/List/ListSpecTest.php @@ -0,0 +1,120 @@ +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..416aba7a --- /dev/null +++ b/tests/List/StubFilterElement.php @@ -0,0 +1,17 @@ +add($element, $attribute, 'choice'); + + self::assertTrue($registry->has('choice')); + self::assertSame($element, $registry->getService('choice')); + self::assertTrue($registry->getAttribute('choice')?->isTargeted); + self::assertFalse($registry->isInline('choice')); + self::assertSame(['choice'], $registry->keys()); + } + + public function testGetTypesMatchesRegisteredInstanceOnly(): void + { + $registry = new FilterElementRegistry(); + $registered = new RegistryElementStub(); + $inline = new RegistryElementStub(); + + $registry->add($registered, null, 'a'); + + self::assertSame(['a'], $registry->getTypes($registered)); + self::assertSame([], $registry->getTypes($inline)); + self::assertSame(['a'], $registry->getTypes(RegistryElementStub::class)); + } + + public function testInlineRegistrationUsesClassNameAsType(): void + { + $registry = new FilterElementRegistry(); + $element = new RegistryElementStub(); + + $registry->add($element); + + self::assertTrue($registry->isInline(RegistryElementStub::class)); + self::assertSame($element, $registry->getService(RegistryElementStub::class)); + self::assertSame([RegistryElementStub::class], $registry->getTypes($element)); + } +} + +final class RegistryElementStub implements FilterElementInterface +{ + public function buildForm(FilterFormBuilderInterface $builder, FilterContext $context): void {} + + public function buildFilter(FilterBuilderInterface $builder, FilterContext $context, array $values): void {} +} diff --git a/tests/Registry/ListDriverRegistryTest.php b/tests/Registry/ListDriverRegistryTest.php new file mode 100644 index 00000000..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/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', ];