strawberry graphql

„Strawberry JSON Fields Forever“: Filtern nach JSON-Feldern mit GraphQL und Strawberry

Keine Kommentare

Schon die Beatles besangen ein uraltes Problem in ihrem Song „Strawberry JSON Fields Forever“: Wie lässt sich mit der GraphQL Library Strawberry für Python nach Werten in JSON-Feldern einer PostgreSQL-Datenbank filtern?

Setup

Um das zu zeigen, braucht es dreierlei: eine PostgreSQL-Datenbank, die eine Tabelle mit JSON-Feldern enthält, eine Python-App, die via SQLAlchemy auf die Datenbank zugreifen kann und die zudem die GraphQL-Library Strawberry unterstützt.

Datenbank

Die PostgreSQL-Datenbank lässt sich minimal mit folgendem Init-Skript beschreiben.

CREATE TABLE "Members"
(
    beatle_id          serial CONSTRAINT beatle_id_pk PRIMARY KEY,
    name               VARCHAR,
    age                INTEGER,
    custom_information jsonb
);
 
INSERT INTO public."Members" VALUES (1, 'John Lennon', 82, '{ "favoriteNumber": 3, "height": 182 }');
INSERT INTO public."Members" VALUES (2, 'Paul McCartney', 80, '{ "height": 180 }');
INSERT INTO public."Members" VALUES (3, 'George Harrison', 79, '{ "height": 181 }');
INSERT INTO public."Members" VALUES (4, 'Ringo Starr', 82, '{ "height": 172 }');

Wir speichern Mitglieder der Beatles, die unterschiedliche Zusatzinformationen im Feld custom_information haben können (Vergleich: John Lennon möchte seine Lieblingszahl speichern, den anderen ist das gar nicht so wichtig).

Python-App

Als Python-Framework wählen wir FastAPI und verbinden uns mit dem ORM-Framework SQLAlchemy zur Datenbank.

Initialisiert wird die App inklusive Datenbankverbindung in wenigen Zeilen.

from fastapi import FastAPI
from fastapi_sqlalchemy import DBSessionMiddleware
 
 
app = FastAPI()
app.add_middleware(DBSessionMiddleware, db_url="postgresql://postgres:postgres@localhost:5432/postgres")

Das Modell, um auf die Mitglieder in der Datenbank zuzugreifen sieht so aus:

from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, Integer, String, JSON
 
 
Base = declarative_base()
 
 
class MemberModel(Base):
    __tablename__ = "Members"
 
    beatle_id = Column(Integer, primary_key=True, index=True)
    name = Column(String)
    age = Column(Integer)
    custom_information = Column(JSON)

Nun könnten wir schon per ORM aus der Datenbank lesen. Das Ziel ist allerdings, mit GraphQL via Strawberry Daten abzufragen und zu filtern.

Strawberry-Schema

Dafür installieren wir die Dependency strawberry-graphql und erzeugen uns einen Type, einen Filter und eine Query. Auf die einzelnen Teile wird im Teil „Die Lösung“ noch mehr eingegangen.

from typing import Optional
 
import strawberry
from fastapi_sqlalchemy import db
from sqlalchemy import text
from strawberry.scalars import JSON
 
from app.models import MemberModel
 
 
@strawberry.input
class Filters:
    custom_field: Optional[str] = None
    custom_value: Optional[int] = None
 
 
@strawberry.type
class Member:
    beatle_id: int
    name: str
    age: int
    custom_information: JSON
 
    @classmethod
    def marshal(cls, model: MemberModel) -> "Member":
        return Member(beatle_id=strawberry.ID(model.beatle_id),
                      name=model.name,
                      age=model.age,
                      custom_information=model.custom_information)
 
 
@strawberry.type
class Query:
 
    @strawberry.field(name="members")
    def resolve_members(self, custom_information_filter: Filters = None) -> list[Member]:
        if custom_information_filter:
            field = custom_information_filter.custom_field
            value = custom_information_filter.custom_value
            members = db.session \
                .query(MemberModel)\
                .filter(text(f"CAST(custom_information->'{field}' AS INTEGER) = {value}"))\
                .all()
        else:
            members = db.session.query(MemberModel).all()
        return [Member.marshal(m) for m in members]
 
 
schema = strawberry.Schema(Query)

Das Schema wird der FastAPI-App zugewiesen.

from fastapi import FastAPI
from fastapi_sqlalchemy import DBSessionMiddleware  # middleware helper
from strawberry.fastapi import GraphQLRouter
 
from app.schema import schema
 
 
app = FastAPI()
app.add_middleware(DBSessionMiddleware, db_url="postgresql://postgres:postgres@localhost:5432/postgres")
app.include_router(GraphQLRouter(schema), prefix="/graphql")

Die Lösung

Wo wird nun das Ursprungsproblem behoben? Dafür sind abermals drei Punkte notwendig.

Filter

Wir definieren eine Filtermöglichkeit über einen @strawberry.input. Das ermöglicht es dem Nutzer, frei anzugeben, nach welchem konkreten Feld und Wert im JSON-Feld gesucht werden soll.

@strawberry.input
class Filters:
    custom_field: Optional[str] = None
    custom_value: Optional[int] = None

Marshaller

Die marshal-Methode am @strawberry.type sorgt dafür, dass das DB-Model in einen GraphQL-Type umgewandelt werden kann. Die Methode wird im Resolver aufgerufen.

@strawberry.type
class Member:
    id: int
    name: str
    age: int
    custom_information: JSON
 
    @classmethod
    def marshal(cls, model: MemberModel) -> "Member":
        return Member(id=strawberry.ID(model.beatle_id),
                      name=model.name,
                      age=model.age,
                      custom_information=model.custom_information)

Resolver

Herzlich willkommen in der Essenz der Lösung: dem Resolver. Ihm wird der Filter übergeben. Somit wissen wir, nach welchem Wert und Feld wir im JSON-Feld filtern sollen. Mit diesen Informationen bauen wir unsere Query zusammen.

@strawberry.type
class Query:
 
    @strawberry.field(name="members")
    def resolve_members(self, custom_information_filter: Filters = None) -> list[Member]:
        if custom_information_filter:
            field = custom_information_filter.custom_field
            value = custom_information_filter.custom_value
            members = db.session \
                .query(MemberModel)\
                .filter(text(f"CAST(custom_information->'{field}' AS INTEGER) = {value}"))\
                .all()
        else:
            members = db.session.query(MemberModel).all()
        return [Member.marshal(m) for m in members]

Nachdem wir die Query ausgeführt haben, müssen wir die erhaltenen Modelle der Members noch in GraphQL-Typen umwandeln. Hier kommt der Marshaller ins Spiel.

Hinweis

Der Resolver kann beliebig komplex werden. In diesem Beispiel wird nur auf Gleichheit eines Felds auf oberster Ebene abgefragt. Sollte das JSON-Feld tief verschachtelt sein oder sollten die Werte auf Ranges verglichen werden, ist das hier der richtige Ort, um das zu lösen. Auch ein ORDER BY lässt sich hier umsetzen. Entsprechend müsste man die Filter erweitern oder anpassen. Here is the place to go nuts.

Abfrage

Nun lässt sich mit GraphiQL explizit nach Beatles-Mitgliedern filtern, deren Lieblingszahl 3 ist.

query MyQuery {
  members(customInformationFilter: {customField: "favoriteNumber", customValue: 3}) {
    age
    customInformation
    id
    name
  }
}

und man erhält nur John Lennon mit all seinen Informationen.

{
  "data": {
    "members": [
      {
        "age": 82,
        "customInformation": {
          "height": 182,
          "favoriteNumber": 3
        },
        "id": 1,
        "name": "John Lennon"
      }
    ]
  }
}

Codebeispiel

Das komplette Codebeispiel findet ihr auf meinem GitHub-Account (mymindwentblvnk).

Kennt ihr noch andere Möglichkeiten, um JSON-Felder in PostgreSQL oder anderen Datenbanken mit GraphQL abzufragen?

Als lösungsortientierter Entwickler hat Michael sehr gerne Python im Einsatz, da sich der Weg von Idee zur Lösung verführerisch kurz gestaltet. Nach Ausflügen in Wasserfall- und V-Modell am Anfang seiner Karriere genießt er die Arbeit mit agilen Frameworks wie Scrum und Kanban. Gerade die offene und regelmäßige Kommunikation im Team und mit Stakeholdern hält er dabei für den essentiellen Bestandteil erfolgreicher Teamarbeit.

Über 1.000 Abonnenten sind up to date!

Die neuesten Tipps, Tricks, Tools und Technologien.
Jede Woche direkt in deine Inbox.

Kostenfrei anmelden und immer auf dem neuesten Stand bleiben!
(Keine Sorge, du kannst dich jederzeit abmelden.)

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht.