Filter sauber an Resolver anbinden
Wer GraphQL-Filter in Magento naiv über rohes SQL oder direkte Modell-Abfragen löst, baut technische Schulden auf. Der korrekte Weg führt über den SearchCriteriaBuilder — mit typsicheren Argumenten im Schema, sauberer Delegation im Resolver und testbarer Filterlogik im Service Contract.
Inhaltsverzeichnis
- 1. Warum SearchCriteria in GraphQL-Resolvern ein eigenständiges Thema ist
- 2. Das GraphQL-Schema: Filterargumente richtig modellieren
- 3. SearchCriteriaBuilder: Argumente sauber in Filtergruppen übersetzen
- 4. Der Resolver: Delegation statt Datenzugriffslogik
- 5. Vollständiges Beispiel: Produktliste mit Preisfilter
- 6. Falsch gegen richtig: Filterlogik im Resolver vs. Service Contract
- 7. Typische Fehlerbilder beim SearchCriteria-Einsatz
- 8. Resolver-Tests mit SearchCriteria-Mocking
- 9. Performance: Welche Filter teuer werden und warum
- 10. Zusammenfassung
- 11. FAQ
1. Warum SearchCriteria in GraphQL-Resolvern ein eigenständiges Thema ist
In Magento 2 ist der SearchCriteria-Mechanismus die standardisierte Schnittstelle für gefilterte Datenzugriffe über Service Contracts. Jedes Repository, das SearchCriteriaInterface akzeptiert, verspricht damit eine konsistente, erweiterbare Filterlogik — unabhängig davon, ob die Daten aus der Datenbank, einem Index oder einem externen System kommen. Wenn GraphQL-Resolver diese Schnittstelle nicht korrekt nutzen, entsteht ein System, in dem Filterlogik verstreut, doppelt implementiert und nicht testbar ist.
Das Problem zeigt sich besonders bei wachsenden Projekten: Ein Resolver filtert zuerst direkt über das Model-Collection-Objekt, weil das schnell geht. Später kommt ein weiterer Resolver für denselben Datentyp, der denselben Filter anders implementiert. Schließlich erzeugt ein dritter eine eigene SQL-Erweiterung. GraphQL-Filter sollten daher vom ersten Tag an über den SearchCriteriaBuilder laufen — der Resolver übersetzt GraphQL-Argumente in Filtergruppen und delegiert den Rest an den Service Contract.
Dieser Artikel zeigt den vollständigen Pfad: vom Schema-Design über die Resolver-Implementierung bis zum Test — mit realen Codebeispielen aus Magento-Modulen und einer Vergleichstabelle der häufigsten Fehlermuster.
2. Das GraphQL-Schema: Filterargumente richtig modellieren
Der erste Schritt beginnt in der schema.graphqls-Datei des Moduls. Filterargumente werden als Input Types modelliert — nicht als flache skalare Argumente. Der Grund: Magento's eigenes Schema nutzt dieses Muster durchgängig, und eigene Typen sollten sich nahtlos einfügen. Ein ProductAttributeFilterInput-ähnlicher Typ ermöglicht es, denselben Eingabetyp in mehreren Queries wiederzuverwenden und im Resolver konsistent auszuwerten.
Ein wichtiger Aspekt bei der Schema-Modellierung ist die Trennung zwischen Filtereingaben und dem eigentlichen Ergebnistyp. Filtereingaben sind input-Typen, die nur für Argumente genutzt werden. Der Ergebnistyp ist ein normaler Objekttyp. Diese Trennung verhindert, dass Filterfelder versehentlich in Subscriptions oder anderen Kontexten als Ausgabe verwendet werden. Außerdem ermöglicht sie es, Breaking Changes im Filter-Modell gezielt zu deprecaten, ohne den Ergebnistyp anzufassen.
# Schema definition for a filterable product list query
# File: Vendor/Module/etc/schema.graphqls
type Query {
customProducts(
filter: CustomProductFilterInput
pageSize: Int = 20
currentPage: Int = 1
sort: CustomProductSortInput
): CustomProductsOutput @resolver(class: "Vendor\\Module\\Model\\Resolver\\CustomProducts")
}
input CustomProductFilterInput {
sku: FilterEqualTypeInput
price: FilterRangeTypeInput
category_id: FilterEqualTypeInput
status: FilterEqualTypeInput
}
input CustomProductSortInput {
price: SortEnum
name: SortEnum
created_at: SortEnum
}
type CustomProductsOutput {
items: [CustomProductItem]
total_count: Int
page_info: SearchResultPageInfo
}
type CustomProductItem {
id: Int
sku: String
name: String
price: Float
}
3. SearchCriteriaBuilder: Argumente sauber in Filtergruppen übersetzen
Der SearchCriteriaBuilder ist die Brücke zwischen den GraphQL-Argumenten und dem Repository-Aufruf. Seine Aufgabe im Resolver ist klar definiert: Er nimmt die rohen Argumente aus dem $args-Array entgegen, erstellt daraus typisierte Filterobjekte und übergibt das fertige SearchCriteriaInterface-Objekt an den Service Contract. Wer diese Logik direkt im Resolver implementiert, vermischt Präsentationsschicht mit Datenzugriffslogik — ein klassisches Anti-Pattern.
Eine saubere Lösung trennt die Übersetzung in einen eigenen FilterBuilder, der eine klare Eingabe-Ausgabe-Schnittstelle hat: GraphQL-Argumente rein, SearchCriteria raus. Dieser FilterBuilder ist unabhängig vom Resolver testbar, kann in mehreren Resolvern verwendet werden und macht den eigentlichen Resolver deutlich schlanker. Die Filtergruppen-Logik von Magento — mehrere Filter in einer Gruppe wirken als OR, mehrere Gruppen wirken als AND — muss dabei explizit berücksichtigt werden.
4. Der Resolver: Delegation statt Datenzugriffslogik
Ein sauber implementierter GraphQL-Resolver in Magento hat genau eine Aufgabe: Er nimmt die validierten Argumente aus dem Request entgegen, delegiert die Datenbeschaffung an den zuständigen Service Contract und transformiert das Ergebnis in die vom Schema erwartete Datenstruktur. Alles andere — Filterlogik, Caching, Autorisierung, Fehlerbehandlung — gehört in spezialisierte Klassen, nicht in den Resolver selbst.
Dieses Prinzip ist besonders wichtig, weil Magento's Resolver-Infrastruktur keine automatische Fehlerweiterleitung kennt. Exceptions, die im Resolver nicht abgefangen werden, landen als generische GraphQL-Fehler beim Client — ohne Details, ohne Kontext. Ein Resolver, der sauber delegiert, kann Exceptions aus dem Service Contract gezielt abfangen und in strukturierte GraphQL-Fehler übersetzen, die für das Frontend auswertbar sind.
# Example query using the custom filter schema
query FilteredProducts {
customProducts(
filter: {
price: { from: "10.00", to: "150.00" }
category_id: { eq: "5" }
status: { eq: "1" }
}
pageSize: 12
currentPage: 1
sort: { price: ASC }
) {
total_count
page_info {
current_page
page_size
total_pages
}
items {
id
sku
name
price
}
}
}
5. Vollständiges Beispiel: Produktliste mit Preisfilter
Das folgende Beispiel zeigt, wie ein Resolver die GraphQL-Filterargumente über einen dedizierten SearchCriteriaBuilder-Aufruf in eine korrekte Datenbankabfrage übersetzen kann. Die Schlüsseleigenschaft: Der Resolver enthält keine Filterlogik. Er liest Argumente, erstellt Kriterien über den Builder, ruft den Service auf und mappt das Ergebnis. Der SearchCriteriaBuilder wird per Dependency Injection bereitgestellt, der eigentliche FilterBuilder ist ein separates Objekt.
Wichtig ist die Handhabung von Paginierung: Magento's SearchResultsInterface enthält bereits alle nötigen Informationen für das page_info-Feld im Schema. Der total_count-Wert stammt direkt aus dem Repository-Ergebnis und gibt die Gesamtanzahl der ungefilterten Datensätze zurück — das Frontend kann daraus die Seitenanzahl berechnen, ohne eine separate Count-Query abzusetzen.
# Introspection check: verify custom filter input types are registered
query IntrospectFilterInput {
__type(name: "CustomProductFilterInput") {
name
kind
inputFields {
name
type {
name
kind
ofType {
name
kind
}
}
}
}
}
# Expected result: inputFields should contain
# sku (FilterEqualTypeInput), price (FilterRangeTypeInput),
# category_id (FilterEqualTypeInput), status (FilterEqualTypeInput)
6. Falsch gegen richtig: Filterlogik im Resolver vs. Service Contract
Die häufigste falsche Variante: Der Resolver instanziert direkt eine Model-Collection, ruft addFieldToFilter() auf und iteriert über das Ergebnis im Resolver selbst. Das funktioniert, ist aber nicht testbar, nicht erweiterbar und ignoriert Magento's Caching- und Indexierungsinfrastruktur vollständig. Der SearchCriteria-Weg ist die Voraussetzung dafür, dass das Ergebnis korrekt gecacht und bei Indexänderungen automatisch invalidiert werden kann.
| Aspekt | Falsch: Collection direkt | Richtig: SearchCriteria | Vorteil |
|---|---|---|---|
| Testbarkeit | Kaum mockbar, DB nötig | Repository mockbar | Unit-Tests ohne Datenbankanbindung |
| Caching | Kein automatisches Caching | Cache-Infrastruktur greift | Ergebnisse werden automatisch invalidiert |
| Erweiterbarkeit | Plugin auf Collection schwer | Plugin auf Repository möglich | Filter nachträglich ergänzbar |
| Typsicherheit | Strings als Feldnamen | Typed FilterGroup-Objekte | Fehler zur Compile-Zeit erkennbar |
| Paginierung | Manuell implementieren | Im SearchCriteria enthalten | pageSize und currentPage standardisiert |
Ein weiteres Argument für den SearchCriteria-Weg: OpenSearch-Integration. Wenn Magento auf OpenSearch als Suchbackend wechselt, leiten Repositories, die auf SearchCriteriaInterface basieren, Anfragen automatisch an den richtigen Adapter weiter. Eine Filterlogik, die direkt auf Collections operiert, kennt diesen Wechsel nicht und liefert weiterhin Datenbankabfragen — ein ernstes Performance-Problem bei großen Produktkatalogen.
7. Typische Fehlerbilder beim SearchCriteria-Einsatz
Das häufigste Fehlerbild: OR-Verknüpfungen werden falsch implementiert. Mehrere Aufrufe von addFilter() auf dem SearchCriteriaBuilder erzeugen standardmäßig AND-Verknüpfungen. Um OR zu erzielen, müssen alle OR-verknüpften Filter in einer einzigen FilterGroup zusammengefasst werden, bevor diese Gruppe dem Builder übergeben wird. Wer das nicht weiß, implementiert OR-Logik und erhält AND-Ergebnisse — ein schwer zu debuggendes Verhalten, weil die Query keine Fehlermeldung liefert, sondern einfach weniger Ergebnisse.
Ein zweites Fehlerbild betrifft nicht deklarierte Filter: Wenn ein GraphQL-Argument im Schema definiert ist, aber im Resolver nicht verarbeitet wird, ignoriert Magento den Filter stillschweigend. Das Frontend übergibt einen Filter, erhält ungefilterte Ergebnisse und interpretiert das als Bug im Backend. Deshalb sollte jedes im Schema definierte Filterfeld explizit im FilterBuilder behandelt werden — mit einem expliziten if (isset($args['filter']['feldname']))-Guard.
8. Resolver-Tests mit SearchCriteria-Mocking
Ein großer Vorteil der SearchCriteria-Architektur ist die Testbarkeit. Weil der Resolver nur das Repository aufruft — und nicht die Datenbankschicht direkt — kann das Repository in Unit-Tests vollständig gemockt werden. Das Mock-Objekt überprüft, ob der Resolver die korrekten Filter übergibt: ob der Preisfilter den richtigen from- und to-Wert trägt, ob der Statusfilter nur aktive Produkte selektiert und ob die Paginierungsparameter korrekt an das SearchCriteria-Objekt übergeben werden.
Für Integrationstests empfiehlt sich ein anderer Ansatz: Hier wird der echte Resolver mit einer Testdatenbank aufgerufen, und das Ergebnis wird gegen eine bekannte Produktmenge geprüft. Magento's eigene Integrationstestinfrastruktur stellt dafür Fixtures bereit, die Testprodukte mit definierten Attributen anlegen. Der GraphQL-Endpunkt kann direkt über GraphQlQueryTest angesprochen werden — das ist der sicherste Weg, um sicherzustellen, dass Schema, Resolver und Filterlogik zusammen korrekt funktionieren.
# Integration test query: verify filter returns exactly matching products
# Run against test database with known fixture data
query TestPriceFilter {
customProducts(
filter: {
price: { from: "50.00", to: "100.00" }
status: { eq: "1" }
}
pageSize: 100
currentPage: 1
) {
total_count
items {
sku
price
}
}
}
# Expected: all items have price between 50 and 100
# Expected: total_count matches fixture data count in that range
# Assertion: no item.price outside [50, 100] range
9. Performance: Welche Filter teuer werden und warum
Nicht alle Filter sind gleich teuer. EAV-Attributfilter — also Filter auf Produktattribute wie Farbe, Material oder Hersteller — erzeugen JOIN-Abfragen über die EAV-Tabellen, die bei großen Produktkatalogen erhebliche Laufzeiten produzieren können. SearchCriteria-Filter auf EAV-Attribute gehen über den eav_attribute_value-Join, der ohne Index auf dem Attribut-Spalte und dem Entity-ID-Feld linear skaliert. Das ist ein bekanntes Magento-Performance-Problem, das durch OpenSearch-Indexierung umgangen werden kann.
Ein weiteres Performance-Problem: verschachtelte Filtergruppen mit IN-Bedingungen über große ID-Listen. Ein Filter wie category_id IN (1, 2, 3, ..., 500) erzeugt intern eine catalog_category_product-Join-Abfrage, die bei tiefer Kategoriehierarchie alle Unterkategorien einschließt. Für solche Fälle ist OpenSearch die korrekte Lösung — der GraphQL-Filter wird dann nicht über die Datenbank, sondern über den Suchindex ausgewertet, der diese Daten bereits denormalisiert vorhält.
10. Zusammenfassung
SearchCriteria-Filter sauber an Magento GraphQL-Resolver anzubinden bedeutet: Filterargumente im Schema als Input Types modellieren, im Resolver über den SearchCriteriaBuilder in typisierte Filtergruppen übersetzen und die eigentliche Datenbeschaffung vollständig an den Service Contract delegieren. Der Resolver selbst enthält keine Filterlogik — er ist der Übersetzer zwischen GraphQL-Welt und Magento-Service-Contract-Welt.
Die Trennung zahlt sich sofort aus: Tests werden einfacher, weil das Repository gemockt werden kann. Erweiterungen werden sicherer, weil Plugins auf Repositories greifen. Performance-Optimierungen durch OpenSearch funktionieren transparent, weil die Filterlogik nicht an eine bestimmte Persistenzschicht gebunden ist. Das ist kein akademisches Architekturprinzip, sondern eine praktische Voraussetzung für wartbaren GraphQL-Code in Magento.
SearchCriteria und Magento GraphQL — Das Wichtigste auf einen Blick
Schema-Design
Filterargumente als Input Types modellieren — nicht als flache skalare Argumente. Wiederverwendbar und schema-konform.
Resolver-Prinzip
Resolver übersetzt GraphQL-Argumente in SearchCriteria und delegiert an den Service Contract. Keine Filterlogik im Resolver.
OR vs. AND
Mehrere addFilter()-Aufrufe = AND. OR-Verknüpfungen brauchen eine gemeinsame FilterGroup. Dieser Unterschied erzeugt die häufigsten Bugs.
Performance
EAV-Filter und große IN-Listen über DB sind teuer. OpenSearch-Integration löst das — aber nur wenn SearchCriteria korrekt genutzt wird.