In meinem Beitrag soll es um RSQL gehen, eine kleine Ausdruckssprache, die sich eignet, um dynamische Multiparameter Queries zu generieren. RSQL ist eine Erweiterung der „Feed Item Query Language“ (kurz: FIQL). FIQL ist ein IETF-Draft, der bisher nicht in den IETF-Standard übernommen wurde. RSQL/FIQL umfasst eine kontextfreie Grammatik, mit der sich Ausdrücke bilden lassen, die den Rechenregeln boolescher Algebra folgen.

Warum halte ich das Thema für relevant? Ein alltägliches Problem in der Anwendungsentwicklung ist, Suchfunktionen umzusetzen, die einerseits den Endanwendenden ein hohes Maß an Flexibilität lassen, welches nur durch fachliche Bedingungen eingeschränkt werden sollte, während die technische Komplexität für die Entwickelnden gering bleibt. Da wir Suchanfragen üblicherweise als Filter modellieren, die durch boolesche Ausdrücke repräsentiert werden, lohnt es sich, RSQL zu kennen.

Solche Filter zu implementieren ist nicht trivial, besonders, wenn sie zur Laufzeit variabel sein sollen. Und selbst wenn es trivial wäre, wäre die Umsetzung immer noch nicht umsonst: Ein Design muss her, Tests, Dokumentation etc, man kennt es; nur um am Ende ein bereits gelöstes Problem nochmal gelöst zu haben, während die üblichen Kinderkrankheiten frischer Prototypen als Bonus oben drauf kommen. Denjenigen, die in so einer Situation auch lieber pragmatisch auf Selbstverwirklichung auf Kundenkosten verzichten und stattdessen auf bewährte Technologien setzen, möchte ich im weiteren RSQL etwas detaillierter vorstellen. Bibliotheken, die FIQL-Ausdrücke generieren und parsen, gibt es z.b. für Python, Typescript und Java. Für das Beispiel verwende ich eine Java-Bibliothek.

Meiner Erfahrung nach ist RSQL noch nicht so bekannt, wie es sein sollte, daher habe ich mich entschlossen, diesen Artikel zu schreiben. (Bei dieser Gelegenheit meine freundlichsten Grüße an Thomas Behrendt, der mich auf RSQL aufmerksam gemacht hat)

Was meine ich mit dynamischen Multiparameter Queries?

 

Dynamische Multiparameter Queries sind Suchanfragen, die auf verschiedenen Spalten mit unterschiedlichen Operatoren filtern, während die Filter zur Laufzeit frei kombinierbar sind.

Nehmen wir einen Betrieb an, der sich für seinen Onlineshop Pflegemasken für Bestellungen, Rechnungen etc wünscht. Die Übersicht für Bestellungen könnte z.b. folgende Felder umfassen:

  • Kundennummer
  • Name
  • Produktnummer
  • Bestellstatus
  • Händlerpreis
  • Verkaufspreis
  • Bestelldatum
  • Lieferdatum
  • Rechnungsdatum
  • Verkäufer

Idealerweise möchten wir den Sachbearbeitenden erlauben, für jedes Feld Filter zu setzen. Z.b. nach Kundenummern, nach Bestellstatus, nach Bestelldatum. Aber auch nach Kombinationen all dieser:

  • Kundennummer == 1234567
  • Kundennummer startswith 123
  • Bestellstatus in (Geliefert, Bestellt)
  • Bestellstatus == Geliefert und Rechnungsdatum < now()
  • Lieferdatum < 31.12.2018 und Lieferdatum > 01.01.2017 oder Lieferdatum = now()

Viele Anfragen sind sicher Unsinn, aber auch an sinnvollen Anfragen gibt es unzählige Möglichkeiten. Einerseits möchten wir den Nutzenden ermöglichen, beliebige Anfragen zu stellen, ob sie nun Sinn machen oder nicht. Andererseits muss die technische Abbildung aller Filterkombinationen die Regeln der booleschen Algebra befolgen, was in der Umsetzung gar nicht so unkompliziert ist. ((„true && ((true || false) && false)“ ist bekanntlich was anderes als „true && (true || false && false)“), zumindest für mich. Bevor man sich selber was überlegt, bietet es sich an, einfach eine Bibliothek wie RSQL zu benutzen. Es löst das Problem und ist mit geringem Aufwand einbindbar.

Beispiel Verwendung von RSQL mit Java und Spring Web

 

Verwendete Bibliothek: https://github.com/jirutka/rsql-parser

Ein Filter im RSQL- Format kann als String als z.b. REST-Parameter übergeben werden: „Produktnummer==1234,Name=IN=(‚Max‘, ‚Kim‘, ‚Alex‘)“ entspricht z.b. der sql-Clause: „WHERE Produktnummer = 1234 OR Name in (‚Max‘, ‚Kim‘, ‚Alex‘)“. Das Mapping der FIQL-Darstellung nach einer in sql sähe z.b. so aus:

In einem REST-Controller deklarieren wir ein GET, dass als Request-Parameter einen String erwartet, mit dem eine RSQL-konforme Filteranweisung übergeben wird. Aus der Bibliothek erhalten wir einen RSQL-Parser, der den String in einen booleschen Ausdrucksbaum umwandelt:

import cz.jirutka.rsql.parser.RSQLParser;

@RestController
@RequestMapping("/api/v1/order")
public class PurchaseOrderController {

    private final PurchaseOrderService purchaseOrderService;

    public PurchaseOrderController(PurchaseOrderService purchaseOrderService) {
        this.purchaseOrderService = purchaseOrderService;
    }

    @GetMapping
    public ResponseEntity<PagedModel<PoOverview>> rsqlFeatured(@RequestParam String rsqlFilter, Pageable pageable) {
        var expressionTree = new RSQLParser().parse(rsqlFilter);
        var result = purchaseOrderService.findAll(expressionTree, pageable);

        return ResponseEntity.ok(new PagedModel<>(result));
    }
}

Der RSQL-Filterbaum ist visitable, d.h. im Service muss ein Visitor instanziiert werden, der den Baum auf einen Ausdruck abbildet, der von der verwendeten Datenbank, z.b. Postgresql, verstanden werden kann:

import cz.jirutka.rsql.parser.ast.*;

@Service
public class PurchaseOrderService {
    private final PurchaseOrderRepository purchaseOrderRepository;

    public PurchaseOrderService(PurchaseOrderRepository purchaseOrderRepository) {
        this.purchaseOrderRepository = purchaseOrderRepository;
    }

    @Transactional(readOnly = true)
    public Page<PoOverview> findAll(Node expressionTree, Pageable pageable) {
        var query = expressionTree.accept(new NodeToMyDatalayerQueryLanguageVisitor());
        return purchaseOrderRepository.findAll(query, pageable).map(PoOverview::of);
    }
}

Den Visitor (siehe Beispiel) kann man selbst implementieren und dort den RSQL-Filterbaum in die von der verwendeten Datenbank gewünschte Form bringen

import cz.jirutka.rsql.parser.ast.*;

final class NodeToMyDatalayerQueryLanguageVisitor extends NoArgRSQLVisitorAdapter<Specification<PurchaseOrder>> {
    @Override
    public Specification<PurchaseOrder> visit(AndNode andNode) {
        return andNode.getChildren()
                .stream()
                .map(node -> node.accept(this))
                .reduce(Specification.unrestricted(), Specification::and);
    }

    @Override
    public Specification<PurchaseOrder> visit(OrNode orNode) {
        return orNode.getChildren()
                .stream()
                .map(node -> node.accept(this))
                .reduce(Specification.not(Specification.unrestricted()), Specification::or);
    }

    @Override
    public Specification<PurchaseOrder> visit(ComparisonNode comparisonNode) {
        ComparisonOperator operator = comparisonNode.getOperator();
        String symbol = operator.getSymbol();
        String selector = comparisonNode.getSelector();
        List<String> arguments = comparisonNode.getArguments();

        return switch (symbol) {
            case "==" -> (root, _, cb) -> cb.equal(root.get(selector), arguments.getFirst());
            case "=IN=" -> (root, _, cb) -> cb.in(root.get(selector)).value(arguments);

            default -> throw new UnknownRsqlComparionOp(symbol);
        };
    }
}

Allerdings gibt es auch Bibliotheken, die diese Aufgabe erledigen, z.b. für Jpa-Specifications. Allerdings muss man an der Stelle ein bisschen auf Vulnerabilites, transitive Dependencies etc achten und überlegen, ob dieses Teilproblem nicht vielleicht doch eines ist, welches man lieber selbst löst.

Fazit

 

Mit RSQL lassen sich mit geringem Aufwand schlanke, robuste APIs schreiben, die Endanwendenden und UX-Designern flexiblen und niederschwelligen Zugriff auf den Datenbestand erlauben ohne die Komplexität im Backend in die Höhe zu treiben. Da die Lösung konsistent in gängige Autorisierung- und Datenzugriffsprozesse einbinden lässt (Der RSQL Filter lässt sich als Requestparameter in einem normalen REST-Request übermitteln und der RSQL-AST ist nicht von Datenbanktreibern out-of-the-box intepretierbar), entsteht kein zusätzliches Risiko.
Als Herausforderung bleibt, Konventionen zu erarbeiten, wie Selektoren und Argumente zu formen sind. Aber das betrachte ich mehr als Chance. Solche Diskussionen müssen ohnehin geführt werden und helfen, eine gemeinsame Sprache zu entwickeln, die jedes Produkt braucht.

Ein letzter Punkt: Beschränkt sich RSQL auf REST?

 

Nein, natürlich nicht. Solange die Zeichenkette mit der Filteranweisung nach den Regeln der RSQL-Grammatik erzeugt wird, spielen der technische oder der fachliche Kontext der Anwendung keine Rolle.

 

Alle Beiträge von Andreas Meyer

Schreibe einen Kommentar