Abfrage-Builder: NOT-Operator, explizite Klammern und vollständige Feldabdeckung #194

Closed
opened 2026-04-17 19:58:36 +02:00 by shahondin1624 · 1 comment
Owner

Kontext

Der visuelle Abfrage-Builder (src/components/QueryBuilder.vue, lib/Service/QueryService.php, Issue #53) bietet aktuell verschachtelte UND/ODER-Gruppen, aber weder eine Negation (NOT) noch eine ausreichend vollständige Feldabdeckung. Damit lassen sich praktische Abfragen wie „alle aktiven Mitglieder, die keine Juleica besitzen“ oder „wer hat eine Nussallergie vermerkt?“ nicht ausdrücken.

Diese Issue erweitert den Builder in einem Schritt um drei zusammengehörige Fähigkeiten:

  1. NOT-Negation auf Bedingungs- und Gruppenebene
  2. Explizite Klammerung als eigenständige UX-Aktion
  3. Vollständige Feldliste inkl. bisher fehlender Member-Spalten, verschlüsselter Allergien und verknüpfter Entitäten (Telefone, E-Mails, Beiträge, Lagerteilnahme, Familie, Verletzungen)

Aktueller Zustand

  • Gruppen ({ "and": [...] } / { "or": [...] }) bis Tiefe depth < 3 hardcoded
  • Keine Möglichkeit, eine Bedingung oder eine Gruppe zu negieren
  • Felder-Dropdown (QueryService::getAvailableFields()) enthält 18 Felder und ignoriert u. a. notizen, zusatz_notizen, einwilligung_datum, juleica_nummer, juleica_ablaufdatum, allergien_encrypted sowie sämtliche verknüpfte Entitäten

Änderungen

1. NOT-Operator

  • Je Leaf-Bedingung: Toggle-Button „NICHT“ direkt an der Zeile, der die einzelne Bedingung negiert
  • Je Gruppe: Zusätzlicher „NICHT“-Schalter im Gruppenkopf, der den gesamten Bracket-Inhalt negiert (NOT (A AND B))
  • AST-Erweiterung: Ein Knoten darf ein optionales Feld not: true tragen, das beim Übersetzen in SQL einen NOT(...)-Wrapper erzeugt. Beispiel:
    { "and": [
        { "field": "status", "op": "=", "value": "aktiv" },
        { "not": true, "or": [
            { "field": "juleica_nummer", "op": "is_not_empty" },
            { "field": "juleica_ablaufdatum", "op": ">", "value": "2026-01-01" }
        ]}
    ]}
    
  • Rückwärtskompatibilität: bestehende gespeicherte Abfragen ohne not-Schlüssel funktionieren unverändert

2. Explizite Klammerung (UX)

Die bisherige verschachtelte-Blöcke-Darstellung bleibt, wird aber um echte visuelle Klammern ( ) um Gruppen ergänzt. Zusätzlich:

  • „In Klammer setzen“-Button: Markiert einen oder mehrere Nachbarbedingungen und wickelt sie in eine neue Untergruppe
  • „Klammer auflösen“-Button pro Gruppe: Hebt die Gruppe auf und hebt die Kinder in den Elternkontext (nur erlaubt, wenn die AND/ODER-Logik der Gruppe mit dem Eltern übereinstimmt — andernfalls disabled mit Tooltip-Hinweis)
  • Harte depth < 3-Grenze aufheben; stattdessen weiches Maximum entsprechend Backend-Validierung (aktuell 10) verwenden

Zielbild (ASCII-Mockup):

(  UND
    • Vorname = Max
    • (  ODER
         • Alter > 10
         • Stufe = Jungpfadfinder  )
    • NOT (  UND
              • Status = inaktiv  )
)
 [+ Bedingung] [+ Gruppe] [In Klammer setzen]

3. Feldliste erweitern

Alle Felder werden als Auswahl in getAvailableFields() ergänzt. Gruppierung im Dropdown nach Herkunft (z. B. optgroup oder Präfix) ist wünschenswert.

Fehlende Member-Spalten:

key Label Typ Hinweis
notizen Notizen string
zusatz_notizen Zusätzliche Notizen string
einwilligung_datum Einwilligungsdatum date
juleica_nummer Juleica-Nummer string
juleica_ablaufdatum Juleica-Ablaufdatum date

Verschlüsseltes Feld:

key Label Typ Hinweis
allergien Allergien string Verschlüsselt — Filterung durch serverseitige Entschlüsselung (siehe unten)

Verknüpfte Entitäten (über EXISTS-Subqueries bzw. LEFT JOIN):

key Label Typ
telefon Telefonnummer (irgendeines) string
email E-Mail (irgendeine) string
familie_id (bestehend) Familie number
familie_name Familienname string
beitrag_bezahlt_jahr Beitrag bezahlt in Jahr number (Jahr)
beitrag_offen_jahr Beitrag offen in Jahr number (Jahr)
beitrag_betrag Beitragsbetrag (aktuelles Jahr) number
lager_teilnahme Lagerteilnahme (hat je teilgenommen) boolean/exists
lager_name Lagername (war auf Lager X) string
verletzung_vorhanden Verletzung vermerkt boolean/exists

Für Beziehungsfelder:

  • Operatoren wie =, contains, starts_with suchen auf mindestens einer verknüpften Zeile (SQL EXISTS)
  • is_empty / is_not_empty prüfen auf Abwesenheit / Anwesenheit einer verknüpften Zeile

Verschlüsselte Abfrage auf Allergien

  • QueryService erkennt, ob die Abfrage allergien referenziert
  • Ist das der Fall, wird die SQL-Vorbedingung ohne Allergien-Filter ausgeführt; anschließend entschlüsselt QueryService die allergien_encrypted-Werte der Ergebniszeilen via EncryptionService und filtert in PHP
  • Pagination und total werden nach PHP-Filter berechnet (Gesamtzahl bezieht sich auf die gefilterte Menge)
  • Für is_empty/is_not_empty reicht ein reiner SQL-NULL/Empty-Check (keine Entschlüsselung nötig)
  • Audit-Logging: Jede Allergien-Abfrage wird im Audit-Log festgehalten (Feld, Operator, User) — aber niemals der Wert, da medizinisches Datum

Akzeptanzkriterien

Funktional — NOT

  • Einzelne Bedingung kann per Toggle „NICHT“ negiert werden; Ausgabe im UI zeigt ¬ oder „NICHT“-Prefix
  • Eine Gruppe kann per Toggle „NICHT“ negiert werden; Klammerinhalt wird mit SQL-NOT(...) gewrapped
  • Verschachtelte Negation funktioniert (NOT (A AND NOT B))
  • Bestehende gespeicherte Abfragen ohne not werden unverändert ausgeführt

Funktional — Klammern

  • Jede Gruppe wird mit sichtbaren Klammern ( ) gerendert
  • „In Klammer setzen“ wählt benachbarte Bedingungen aus und kapselt sie in eine neue Untergruppe
  • „Klammer auflösen“ hebt eine Gruppe auf (nur wenn logikkonsistent mit Elter)
  • depth < 3-Grenze entfernt; maximale Tiefe wird nur durch Backend-Validierung begrenzt
  • Die logische Semantik einer in-/ausgeklammerten Abfrage bleibt identisch

Funktional — Felder

  • Alle in der Tabelle oben gelisteten Member-Felder sind im Dropdown auswählbar
  • allergien ist als Feld auswählbar und liefert korrekte Ergebnisse für =, !=, contains, starts_with, is_empty, is_not_empty
  • Verknüpfte Entitätsfelder (Telefon, E-Mail, Beitrag, Lager, Verletzung, Familie) erzeugen korrekte EXISTS-/JOIN-SQL
  • Dropdown gruppiert Felder nach Herkunft (Mitglied / Adresse / Kontakt / Beiträge / Lager / Sonstiges)

Sicherheit

  • Allergien-Filter läuft ausschließlich serverseitig; Klartext verlässt das Backend nur über das Standard-Member-Response (das allergienEncrypted ohnehin liefert)
  • Audit-Log für Allergien-Abfragen (Feld + Operator, kein Wert)
  • Alle neuen Felder erscheinen in FIELD_MAP (Whitelist) — kein dynamisches SQL

Tests

  • Unit-Tests für QueryService: NOT-Knoten (Leaf und Gruppe), neue Felder inkl. EXISTS-Joins, Allergien-PHP-Filter, Rückwärtskompatibilität mit AST ohne not
  • UI-Tests: „NICHT“-Toggle, „In Klammer setzen“, „Klammer auflösen“, Rendering verschachtelter NOT-Gruppen
  • Regressionstest: bestehende gespeicherte Abfragen (saved_queries) laufen ohne Änderung

Dokumentation

  • docs/requirements.md / Feature-Doku aktualisiert
  • Kurzer Hinweis in der UI, dass Allergien-Abfragen serverseitig entschlüsseln (Performance-Hinweis ist nicht Teil dieses Tickets — kann separat kommen)

Technische Hinweise

  • AST-Schema: not-Flag ist additiv. Ein Knoten { not: true, and: [...] } entspricht NOT (A AND B). Auf Leaf-Ebene: { not: true, field, op, value } entspricht NOT (field op value).
  • QueryService::validateAst() muss not als optionales Flag akzeptieren
  • QueryService::buildConditions() wrapped das zurückgegebene Expression-Objekt in $qb->createFunction('NOT (' . $inner . ')') wenn $node['not'] === true
  • Neue EXISTS-Joins: extrahiere membersExistsClause($qb, $subTable, $column, $op, $value) als private Helper-Methode
  • Migration von saved_queries: nicht nötig (JSON-Spalte ist frei strukturiert)

Out-of-Scope

  • Änderungen an Suchen außerhalb des Abfrage-Builders (z. B. globale SearchBar)
  • Verschlüsselung der gespeicherten Abfragen selbst
  • Export / PDF-Generierung aus Query-Ergebnissen
  • Performance-Optimierung für Allergien-Abfragen bei sehr großen Datenmengen (>10k Mitglieder) — dies ist ein Vereins-Use-Case, nicht massentauglich

Abhängigkeiten / verwandte Issues

  • #53 Visual Query Builder (Basis)
  • #60 EncryptionService (bereits vorhanden, wird nur konsumiert)
## Kontext Der visuelle Abfrage-Builder (`src/components/QueryBuilder.vue`, `lib/Service/QueryService.php`, Issue #53) bietet aktuell verschachtelte UND/ODER-Gruppen, aber weder eine Negation (`NOT`) noch eine ausreichend vollständige Feldabdeckung. Damit lassen sich praktische Abfragen wie *„alle aktiven Mitglieder, die keine Juleica besitzen“* oder *„wer hat eine Nussallergie vermerkt?“* nicht ausdrücken. Diese Issue erweitert den Builder in einem Schritt um drei zusammengehörige Fähigkeiten: 1. **NOT-Negation** auf Bedingungs- und Gruppenebene 2. **Explizite Klammerung** als eigenständige UX-Aktion 3. **Vollständige Feldliste** inkl. bisher fehlender Member-Spalten, verschlüsselter `Allergien` und verknüpfter Entitäten (Telefone, E-Mails, Beiträge, Lagerteilnahme, Familie, Verletzungen) ## Aktueller Zustand - Gruppen (`{ "and": [...] }` / `{ "or": [...] }`) bis Tiefe `depth < 3` hardcoded - Keine Möglichkeit, eine Bedingung oder eine Gruppe zu negieren - Felder-Dropdown (`QueryService::getAvailableFields()`) enthält 18 Felder und ignoriert u. a. `notizen`, `zusatz_notizen`, `einwilligung_datum`, `juleica_nummer`, `juleica_ablaufdatum`, `allergien_encrypted` sowie sämtliche verknüpfte Entitäten ## Änderungen ### 1. NOT-Operator - **Je Leaf-Bedingung**: Toggle-Button „NICHT“ direkt an der Zeile, der die einzelne Bedingung negiert - **Je Gruppe**: Zusätzlicher „NICHT“-Schalter im Gruppenkopf, der den gesamten Bracket-Inhalt negiert (`NOT (A AND B)`) - AST-Erweiterung: Ein Knoten darf ein optionales Feld `not: true` tragen, das beim Übersetzen in SQL einen `NOT(...)`-Wrapper erzeugt. Beispiel: ```json { "and": [ { "field": "status", "op": "=", "value": "aktiv" }, { "not": true, "or": [ { "field": "juleica_nummer", "op": "is_not_empty" }, { "field": "juleica_ablaufdatum", "op": ">", "value": "2026-01-01" } ]} ]} ``` - Rückwärtskompatibilität: bestehende gespeicherte Abfragen ohne `not`-Schlüssel funktionieren unverändert ### 2. Explizite Klammerung (UX) Die bisherige verschachtelte-Blöcke-Darstellung bleibt, wird aber um echte visuelle Klammern `(` `)` um Gruppen ergänzt. Zusätzlich: - **„In Klammer setzen“**-Button: Markiert einen oder mehrere Nachbarbedingungen und wickelt sie in eine neue Untergruppe - **„Klammer auflösen“**-Button pro Gruppe: Hebt die Gruppe auf und hebt die Kinder in den Elternkontext (nur erlaubt, wenn die AND/ODER-Logik der Gruppe mit dem Eltern übereinstimmt — andernfalls disabled mit Tooltip-Hinweis) - Harte `depth < 3`-Grenze aufheben; stattdessen weiches Maximum entsprechend Backend-Validierung (aktuell 10) verwenden Zielbild (ASCII-Mockup): ``` ( UND • Vorname = Max • ( ODER • Alter > 10 • Stufe = Jungpfadfinder ) • NOT ( UND • Status = inaktiv ) ) [+ Bedingung] [+ Gruppe] [In Klammer setzen] ``` ### 3. Feldliste erweitern Alle Felder werden als Auswahl in `getAvailableFields()` ergänzt. Gruppierung im Dropdown nach Herkunft (z. B. `optgroup` oder Präfix) ist wünschenswert. **Fehlende Member-Spalten:** | key | Label | Typ | Hinweis | |---|---|---|---| | `notizen` | Notizen | string | | | `zusatz_notizen` | Zusätzliche Notizen | string | | | `einwilligung_datum` | Einwilligungsdatum | date | | | `juleica_nummer` | Juleica-Nummer | string | | | `juleica_ablaufdatum` | Juleica-Ablaufdatum | date | | **Verschlüsseltes Feld:** | key | Label | Typ | Hinweis | |---|---|---|---| | `allergien` | Allergien | string | **Verschlüsselt** — Filterung durch serverseitige Entschlüsselung (siehe unten) | **Verknüpfte Entitäten (über EXISTS-Subqueries bzw. LEFT JOIN):** | key | Label | Typ | |---|---|---| | `telefon` | Telefonnummer (irgendeines) | string | | `email` | E-Mail (irgendeine) | string | | `familie_id` (bestehend) | Familie | number | | `familie_name` | Familienname | string | | `beitrag_bezahlt_jahr` | Beitrag bezahlt in Jahr | number (Jahr) | | `beitrag_offen_jahr` | Beitrag offen in Jahr | number (Jahr) | | `beitrag_betrag` | Beitragsbetrag (aktuelles Jahr) | number | | `lager_teilnahme` | Lagerteilnahme (hat je teilgenommen) | boolean/exists | | `lager_name` | Lagername (war auf Lager X) | string | | `verletzung_vorhanden` | Verletzung vermerkt | boolean/exists | Für Beziehungsfelder: - Operatoren wie `=`, `contains`, `starts_with` suchen auf *mindestens einer* verknüpften Zeile (SQL `EXISTS`) - `is_empty` / `is_not_empty` prüfen auf Abwesenheit / Anwesenheit einer verknüpften Zeile ## Verschlüsselte Abfrage auf `Allergien` - `QueryService` erkennt, ob die Abfrage `allergien` referenziert - Ist das der Fall, wird die SQL-Vorbedingung ohne Allergien-Filter ausgeführt; anschließend entschlüsselt `QueryService` die `allergien_encrypted`-Werte der Ergebniszeilen via `EncryptionService` und filtert in PHP - Pagination und `total` werden nach PHP-Filter berechnet (Gesamtzahl bezieht sich auf die gefilterte Menge) - Für `is_empty`/`is_not_empty` reicht ein reiner SQL-NULL/Empty-Check (keine Entschlüsselung nötig) - Audit-Logging: Jede Allergien-Abfrage wird im Audit-Log festgehalten (Feld, Operator, User) — **aber niemals der Wert**, da medizinisches Datum ## Akzeptanzkriterien ### Funktional — NOT - [ ] Einzelne Bedingung kann per Toggle „NICHT“ negiert werden; Ausgabe im UI zeigt `¬` oder „NICHT“-Prefix - [ ] Eine Gruppe kann per Toggle „NICHT“ negiert werden; Klammerinhalt wird mit SQL-`NOT(...)` gewrapped - [ ] Verschachtelte Negation funktioniert (`NOT (A AND NOT B)`) - [ ] Bestehende gespeicherte Abfragen ohne `not` werden unverändert ausgeführt ### Funktional — Klammern - [ ] Jede Gruppe wird mit sichtbaren Klammern `(` `)` gerendert - [ ] „In Klammer setzen“ wählt benachbarte Bedingungen aus und kapselt sie in eine neue Untergruppe - [ ] „Klammer auflösen“ hebt eine Gruppe auf (nur wenn logikkonsistent mit Elter) - [ ] `depth < 3`-Grenze entfernt; maximale Tiefe wird nur durch Backend-Validierung begrenzt - [ ] Die logische Semantik einer in-/ausgeklammerten Abfrage bleibt identisch ### Funktional — Felder - [ ] Alle in der Tabelle oben gelisteten Member-Felder sind im Dropdown auswählbar - [ ] `allergien` ist als Feld auswählbar und liefert korrekte Ergebnisse für `=`, `!=`, `contains`, `starts_with`, `is_empty`, `is_not_empty` - [ ] Verknüpfte Entitätsfelder (Telefon, E-Mail, Beitrag, Lager, Verletzung, Familie) erzeugen korrekte `EXISTS`-/`JOIN`-SQL - [ ] Dropdown gruppiert Felder nach Herkunft (Mitglied / Adresse / Kontakt / Beiträge / Lager / Sonstiges) ### Sicherheit - [ ] Allergien-Filter läuft ausschließlich serverseitig; Klartext verlässt das Backend nur über das Standard-Member-Response (das `allergienEncrypted` ohnehin liefert) - [ ] Audit-Log für Allergien-Abfragen (Feld + Operator, **kein Wert**) - [ ] Alle neuen Felder erscheinen in `FIELD_MAP` (Whitelist) — kein dynamisches SQL ### Tests - [ ] Unit-Tests für `QueryService`: NOT-Knoten (Leaf und Gruppe), neue Felder inkl. EXISTS-Joins, Allergien-PHP-Filter, Rückwärtskompatibilität mit AST ohne `not` - [ ] UI-Tests: „NICHT“-Toggle, „In Klammer setzen“, „Klammer auflösen“, Rendering verschachtelter NOT-Gruppen - [ ] Regressionstest: bestehende gespeicherte Abfragen (`saved_queries`) laufen ohne Änderung ### Dokumentation - [ ] `docs/requirements.md` / Feature-Doku aktualisiert - [ ] Kurzer Hinweis in der UI, dass Allergien-Abfragen serverseitig entschlüsseln (Performance-Hinweis ist **nicht** Teil dieses Tickets — kann separat kommen) ## Technische Hinweise - AST-Schema: `not`-Flag ist **additiv**. Ein Knoten `{ not: true, and: [...] }` entspricht `NOT (A AND B)`. Auf Leaf-Ebene: `{ not: true, field, op, value }` entspricht `NOT (field op value)`. - `QueryService::validateAst()` muss `not` als optionales Flag akzeptieren - `QueryService::buildConditions()` wrapped das zurückgegebene Expression-Objekt in `$qb->createFunction('NOT (' . $inner . ')')` wenn `$node['not'] === true` - Neue EXISTS-Joins: extrahiere `membersExistsClause($qb, $subTable, $column, $op, $value)` als private Helper-Methode - Migration von `saved_queries`: **nicht nötig** (JSON-Spalte ist frei strukturiert) ## Out-of-Scope - Änderungen an Suchen außerhalb des Abfrage-Builders (z. B. globale `SearchBar`) - Verschlüsselung der gespeicherten Abfragen selbst - Export / PDF-Generierung aus Query-Ergebnissen - Performance-Optimierung für Allergien-Abfragen bei sehr großen Datenmengen (>10k Mitglieder) — dies ist ein Vereins-Use-Case, nicht massentauglich ## Abhängigkeiten / verwandte Issues - #53 Visual Query Builder (Basis) - #60 EncryptionService (bereits vorhanden, wird nur konsumiert)
shahondin1624 added the backendenhancementfrontendpriority:mediumsecurity labels 2026-04-17 19:58:36 +02:00
Author
Owner

Umgesetzt in PR #195 (squash-merged nach main als fa702e3) und released in v0.2.8. Alle Akzeptanzkriterien erfüllt; 25 neue Unit-Tests grün (1118/1118 gesamt).

Umgesetzt in PR #195 (squash-merged nach `main` als `fa702e3`) und released in [v0.2.8](https://git.shahondin1624.de/shahondin1624/Mitgliederverwaltung/releases/tag/v0.2.8). Alle Akzeptanzkriterien erfüllt; 25 neue Unit-Tests grün (1118/1118 gesamt).
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: shahondin1624/Mitgliederverwaltung#194