SQLAlchemy

22. Okt. 2024

SQLAlchemy

Für ein kommendes Projekt möchte ich eine größere Menge Daten speichern, die miteinander in verschiedenen Beziehungen stehen oder voneinander abhängig sind. Daten an sich kann man in einer Text- oder Binärdatei ohne große Probleme speichern (z.B. mittels pickle). Doch wenn bestimmte Datensätze gesucht werden sollen empfiehlt es sich auf eine Datenbank zurückzugreifen.

Hierfür gibt es bereits Module in Python integriert sind (z.B. sqlite3). Doch mangelt es diesen Modulen an der Benutzerfreundlichkeit, da man jede Datenabfrage selbst als SQL-Abfrage formulieren und auswerten muss.

Das Modul SQLAlchemy bietet hingegen eine benutzerfreundliche Schnittstelle zwischen Datenstrukturen und einer Datenbank. Ziel ist es somit, anstelle von SQL-Abfragen, einfach ein Objekt bzw. eine Klasseninstanz zu manipulieren und diese dann der Datenbank zu übergeben.

Im nachfolgenden fasse ich meinen ersten Kontakt und das, was ich über SQLAlchemy gelernt habe zusammen.

Grundprinzipien

SQLAlchemy kann auf zwei Weisen verwendet werden. Entweder man nutzt SQLAlchemy ORM (Object-Relational Mapper), wobei der ORM Muster für die Datenzuordnung zwischen Datenbank und Klassen bereitstellt, oder man benutzt SQLAlchemy Core, was der klassischen Handhabung von Datenbanken näher kommt. Während mit dem ORM Einträge einer Tabelle als Instanzen einer Klasse dargestellt werden, muss man unter Core die Übersetzung der Datenbankstruktur zur Objekten unter Python selber vornehmen.

In beiden Fällen werden Beziehungen zwischen den Tabellen mit Primärschlüsseln (primary key) und Fremdschlüsseln (foreign key) gekennzeichnet und gespeichert. Aber will man auf das Bezugsobjekt zugreifen, muss man es unter ORM eben nicht extra aus der Datenbank auslesen, sondern kann direkt über das von SQLAlchemy zurückgegebene Objekt darauf zugreifen. Dies wird in dem nachfolgenden Beispiel deutlicher, wo eine überschaubare Datenbank zur Verwaltung von Einträgen eines Todo Programms erstellt und mit Beispieldaten gefüllt wird. Am Ende dieses Artikels erläutere ich auch kurz, wie eine vergleichbare Datenbankstruktur unter Core erstellt.

Setup

Zunächst muss SQLAlchemy installiert werden. Dies kann mittels PIP wie folgt getan werden:

pip install SQLAlchemy

Im Code muss dann zuerst eine Verbindung zu einer Datenbank erstellt werden. Der Datenbanktyp ist beliebig, aber für Beispielzwecke eignet sich eine lokale Datenbank:

from sqlalchemy import create_engine

engine = create_engine("sqlite:///database.db")

Oder man erstellt eine Datenbank im Zwischenspeicher, wodurch keine Datei erstellt wird - die Daten aber nicht dauerhaft gespeichert werden:

from sqlalchemy import create_engine

engine = create_engine("sqlite:///:memory:")

Diese Schritte sind sowohl für ORM als auch für Core dieselben.

ORM

Man kann sich merken, dass alle in Python definierte Klassen deklaratorisch sind und somit automatisch das Schema einer Tabelle definieren. Hierbei geben die Klassenparameter und ihre Datentypen jeweils den Spaltennamen und Datentypen (entsprechend in SQLAlchemy) an. Um die Klassen als deklaratorisch zu definieren, basieren sie auf der Klasse DeclarativeBase, und um die Datentypen anzugeben, werden Typenhinweise gemäß PEP 484 eingesetzt:

from sqlalchemy.orm import (
    DeclarativeBase,
    Mapped,
    mapped_column,
)
from typing import Optional
from datetime import datetime

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = "users_table"

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(nullable=False, unique=True)

    def __repr__(self) -> str:
        return f"<User(id={self.id}, name={self.name})>"

class Todo(Base):
    __tablename__ = "todos_table"

    id: Mapped[int] = mapped_column(primary_key=True)
    description: Mapped[str] = mapped_column(nullable=False)
    done_date: Mapped[Optional[datetime]] = mapped_column(nullable=True)

    def __repr__(self) -> str:
        return f"<Todo(id={self.id}, desc={self.description}, done_date={self.done_date})>"

Im vorstehenden Code werden also zwei Klassen definiert, die in Tabellen übersetzt werden. Der Tabellenname wird mit __tablename__ festgelegt und mit dem gemappten Typenhinweis (mit Mapped[_T]) bzw. der Funktion mapped_column() werden die Eigenschaften der Tabellenspalten beschrieben. Dass eine Spalte einen Primärschlüssel (mit primary_key=True) sein, nicht wiederholende Werte (mit unique=True) beinhalten, oder nicht leere Einträge (mit nullable=False) enthalten soll, wird hier recht anschaulich gezeigt. Man kann auch bei den Typenhinweisen angeben, dass Einträge nicht zwingend sind (mit Optional[_T]); z.B. wenn das Todo-Element noch nicht abgeschlossen ist, hat es noch kein Abschlussdatum, was durch den Wert Null gekennzeichnet wird.

Mag man auf die Typenhinweise nicht zurückgreifen, kann man die Datentypen auch in der Funktion mapped_column() als erstes Argument definieren.

Die Methode __repr__(self) erleichtern die Lesbarkeit einer Instanz der Klasse, wenn sie z.B. mit print() in der Befehlszeile ausgegeben wird.

Um nun eine Datenbank mit ihren Tabellen zu erzeugen, muss die Methode create_all() der Klasse Base aufgerufen werden, und man kann die ersten Daten einfügen:

from sqlalchemy.orm import sessionmaker
from datetime import datetime

def main() -> None:
    Base.metadata.create_all(engine)

    Session = sessionmaker(bind=engine)

    with Session() as session:
        user_max = User(name="Max")
        user_kathi = User(name="Kathi")
        session.add(user_max)
        session.add(user_kathi)

        todo_1 = Todo(description="Write code", done_date=datetime.now())
        todo_2 = Todo(description="Tidy your room")
        session.add(todo_1)
        session.add(todo_2)

        session.commit()

        print(session.query(User).all())
        print(session.query(Todo).all())

if __name__ == "__main__":
    main()

Führt man den vorstehenden Code nun aus, erhält man das folgende Ergebnis:

[<User(id=1, name=Max)>, <User(id=2, name=Kathi)>]
[<Todo(id=1, desc=Write code, done_date=2024-10-12 17:55:10.716922)>, <Todo(id=2, desc=Tidy your room, done_date=None)>]

Wie man sieht, können Daten recht einfach erzeugt und gespeichert werden. Hierbei muss immer eine Session erzeugt werden, die durch den Kontextmanager with auch geschlossen wird (sonst müsste man dies mit session.close() selber machen, was gerne vergessen wird). Mit session.add() werden die neuen Datensätze automatisch in die richtige Tabelle eingefügt, und mit session.commit() werden die Änderungen in die Datenbank geschrieben. Die Abfrage der Daten geschieht dann mit session.query(), wobei in diesem Beispiel einfach alle Daten angezeigt werden sollen (mit .all()). Möchte man einen Eintrag ändern, muss man nur den Parameter des entsprechenden Objekts ändern, die Änderung mit session.commit() anwenden und das Objekt mit session.refresh() aktualisieren. Mag man z.B. den Namen eines Benutzers ändern, tut man dies wie folgt:

...
    user_max.name = "Maximilian"
    session.commit()
    session.refresh(user_max)
...

Und wenn man den Benutzer erst aus der Datenbank auslesen muss, kann man dies mit einer Abfrage machen, die mit dem Zusatz .where() oder .filter() die zurückzugebenden Werte beschränkt:

...
    user_max = session.query(User).where(User.id == 1).first()
    # or
    user_max = session.query(User).where(User.name == "Max").first()
...

Der Eintrag des Benutzers hätte nun von <User(id=1, name=Max)> zu <User(id=1, name=Maximilian)> geändert.

Das Löschen eines Eintrags geschieht mit session.delete(), wobei der zu löschende Eintrag als Objekt übergeben wird und die Löschung mit session.commit() angewendet wird.

Diese Daten stehen aber noch in keiner Beziehung zueinander. Dies ändert man, indem die Fremdschlüssel (mit dem Argument ForeignKey()) und Beziehungen (mit der Funktion relationship()) zu den Klassenparametern hinzugefügt werden. Der letztendliche Code sieht dann wie folgt aus:

from sqlalchemy import create_engine, ForeignKey
from sqlalchemy.orm import (
    sessionmaker,
    DeclarativeBase,
    Mapped,
    mapped_column,
    relationship,
)
from typing import List, Optional
from datetime import datetime

engine = create_engine("sqlite:///:memory:")

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = "users_table"

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(nullable=False, unique=True)
    todos: Mapped[List["Todo"]] = relationship(back_populates="user")

    def __repr__(self) -> str:
        return f"<User(id={self.id}, name={self.name}, todos={len(self.open_todos)}/{len(self.todos)})>"

    @property
    def open_todos(self) -> List["Todo"]:
        return [t for t in self.todos if t.done_date is None]

class Todo(Base):
    __tablename__ = "todos_table"

    id: Mapped[int] = mapped_column(primary_key=True)
    description: Mapped[str] = mapped_column(nullable=False)
    done_date: Mapped[Optional[datetime]] = mapped_column(nullable=True)
    user_id: Mapped[int] = mapped_column(ForeignKey("users_table.id"), nullable=False)
    user: Mapped["User"] = relationship(back_populates="todos")

    def __repr__(self) -> str:
        return f"<Todo(id={self.id}, desc={self.description}, user={self.user.name}, done_date={self.done_date})>"

def main() -> None:
    Base.metadata.create_all(engine)

    Session = sessionmaker(bind=engine)

    with Session() as session:
        user_max = User(name="Max")
        user_kathi = User(name="Kathi")
        session.add(user_max)
        session.add(user_kathi)

        todo_1 = Todo(description="Write code", user=user_max, done_date=datetime.now())
        todo_2 = Todo(description="Tidy your room", user=user_max)
        session.add(todo_1)
        session.add(todo_2)

        session.commit()

        print(session.query(User).all())
        print(session.query(Todo).all())

if __name__ == "__main__":
    main()

In diesem Fall ist die Klasse User um den Parameter todos erweitert worden, die eine Liste von Instanzen der Klasse Todo enthalten soll (mit Mapped[List["Todo"]]). Da diese Liste keine Werte an sich enthält, sondern nur eine vereinfachte Zugriffsmöglichkeit auf Todo-Elemente bereitstellen soll, wird hier auch keine Spalte definiert, sondern eine Beziehung (mit relationship(back_populates="user")). Dies wirkt vielleicht etwas kryptisch auf den ersten Blick. Allerdings bedeutet das Argument der Funktion nur, dass der Parameter (bzw. die Spalte) user der Klasse Todo zurück auf die Klasse User verweisen soll.

Dementsprechend ist in der Klasse Todo auch der Parameter user hinzugefügt worden, der vom Typ User ist (mit Mapped["User"]) und die der Klasse User entsprechende Beziehung angibt (nämlich relationship(back_populates="todos")).

Um den Bezug zwischen den Klassen User und Todo auch zu speichern, wird der Fremdschlüssel user_id als weiterer Parameter in der Klasse Todo aufgenommen. Da hier tatsächlich Daten gespeichert werden, wird wieder die Funktion mapped_column() verwendet, und die Tatsache, dass es sich hierbei um einen Fremdschlüssel handelt mit ForeignKey("users_table.id") angegeben. Der Fremdschlüssel ist hier also in der Tabelle users_table die Spalte id (bzw. der Parameter id der Klasse User). Da jedes Todo-Element auch einem Benutzer zugewiesen sein muss, ist nullable=False gesetzt.

Nun kann man sowohl in der Klasse User als auch in der Klasse Todo auf die Parameter der jeweils anderen Klasse zugreifen, ohne sich Gedanken darüber machen zu müssen, wie die entsprechenden Daten aus der Datenbank ausgelesen werden können. Dies ist im vorstehenden Beispiel gut zu sehen: z.B. in der Property open_todos der Klasse User, wo auf den Parameter done_date von Todo zugegriffen wird; oder in der Methode __repr__(self) der Klasse Todo, wo auf den Parameter name von User zugegriffen wird.

Die Ausgabe in der Befehlszeile ist demnach deutlich informativer, aber der dafür benötigte Code bleibt weiterhin gut verständlich:

[<User(id=1, name=Max, todos=1/2)>, <User(id=2, name=Kathi, todos=0/0)>]
[<Todo(id=1, desc=Write code, user=Max, done_date=2024-10-12 18:03:16.312209)>, <Todo(id=2, desc=Tidy your room, user=Max, done_date=None)>]

Core

Anstatt Klassen zu definieren, die als Modelle für Tabellen einer Datenbank dienen, kann man die Tabellen in SQLAlchemy auch auf eine klassische Art, bzw. direkt erstellen.

Anstelle der Klasse Base braucht man ein Objekt, dass die Metadaten und das Schema verwaltet:

from sqlalchemy import MetaData()

metadata = MetaData()

Nun kann man die Tabellen als Variablen definieren, und das Metadaten-Objekt beim Erstellen übergeben, sodass das Metadaten-Objekt die Tabellen mit seiner Methode create_all() erstellen kann:

from sqlalchemy import (
    create_engine,
    ForeignKey,
    MetaData,
    Table,
    Column,
    String,
    Integer,
    DateTime,
)

users_table = Table(
    "users_table",
    metadata,
    Column("id", Integer, primary_key=True),
    Column("name", String, nullable=False, unique=True),
)

todos_table = Table(
    "todos_table",
    metadata,
    Column("id", Integer, primary_key=True),
    Column("description", String, nullable=False),
    Column("done_date", DateTime, nullable=True),
    Column("user_id", Integer, ForeignKey("users_table.id"), nullable=False),
)

def main() -> None:
    connection = engine.connect()
    metadata.create_all(engine)

if __name__ == "__main__":
    main()

Wie man sieht, muss hier eine Verbindung zur Datenbank-Engine erstellt werden, sodass das Metadaten-Objekt die Anweisungen zum Erstellen der Tabellen an die Datenbank übergeben kann. Jetzt sind zwar alle Tabellen erstellt, doch gibt es keine Funktionen, mit denen Daten erzeugt, gespeichert und ausgelesen werden können. Diese muss man nun selber definieren:

from datetime import datetime

def insert_user(name: str) -> None:
    query = users_table.insert().values(name=name)
    connection.execute(query)

def select_user(name: str) -> dict | None:
    query = users_table.select().where(users_table.c.name == name)
    result = connection.execute(query)
    d = result.fetchone()
    if d is None:
        return None
    return dict(zip((c.name for c in users_table.c), d))

def list_users() -> list:
    query = users_table.select()
    result = connection.execute(query)
    return [dict(zip((c.name for c in users_table.c), d)) for d in result.fetchall()]

def insert_todo(description: str, user_id: int, done_date: datetime = None) -> None:
    query = todos_table.insert().values(
        description=description, user_id=user_id, done_date=done_date
    )
    connection.execute(query)

def select_todo(id: int) -> dict | None:
    query = todos_table.select().where(todos_table.c.id == id)
    result = connection.execute(query)
    d = result.fetchone()
    if d is None:
        return None
    return dict(zip((c.name for c in todos_table.c), d))

def list_todos() -> list:
    query = todos_table.select()
    result = connection.execute(query)
    return [dict(zip((c.name for c in todos_table.c), d)) for d in result.fetchall()]

Dies ist deutlich aufwendiger als die Herangehensweise mit ORM. Allerdings hat man mehr Kontrolle über die Abläufe.

Anzumerken ist in dem vorstehenden Code, dass mit (c.name for c in _TABLE_NAME_.c) eine Liste der Spaltennamen erstellt wird, die dann mit dict(zip(a, b)) an ein Dictionary als Schlüsselworte übergeben wird. Das ist ein kleiner syntaktischer Hack, mit dem die Ergebnisse einer Abfrage überschaubar zurückgegeben werden können.

Mit dem vorstehenden Code wird auch der Fremdschlüssel users_table.id als die Tabellenspalte user_id in todos_table gespeichert. Nur muss man hier, um auf den entsprechenden Benutzer zugreifen zu können, auf Grundlage des in todos_table gespeicherten user_id eine erneute Abfrage starten, um den Eintrag des Benutzers aus users_table auszulesen.

Zudem greift jede Abfrage immer auf die Verbindung (also connection) zurück, was bedeutet, dass diese jeder Abfrage-Funktion zur Verfügung stehen muss. Der Einfachheit halber ist die Verbindung im folgenden Code global definiert, doch diese Herangehensweise ist aus Sicherheitsgründen nicht empfehlenswert:

from sqlalchemy import (
    create_engine,
    ForeignKey,
    MetaData,
    Table,
    Column,
    String,
    Integer,
    DateTime,
)

from datetime import datetime

engine = create_engine("sqlite:///:memory:")
connection = engine.connect()

metadata = MetaData()

users_table = Table(
    "users_table",
    metadata,
    Column("id", Integer, primary_key=True),
    Column("name", String, nullable=False, unique=True),
)

def insert_user(name: str) -> None:
    query = users_table.insert().values(name=name)
    connection.execute(query)

def select_user(name: str) -> dict | None:
    query = users_table.select().where(users_table.c.name == name)
    result = connection.execute(query)
    d = result.fetchone()
    if d is None:
        return None
    return dict(zip((c.name for c in users_table.c), d))

def list_users() -> list:
    query = users_table.select()
    result = connection.execute(query)
    return [dict(zip((c.name for c in users_table.c), d)) for d in result.fetchall()]

todos_table = Table(
    "todos_table",
    metadata,
    Column("id", Integer, primary_key=True),
    Column("description", String, nullable=False),
    Column("done_date", DateTime, nullable=True),
    Column("user_id", Integer, ForeignKey("users_table.id"), nullable=False),
)

def insert_todo(description: str, user_id: int, done_date: datetime = None) -> None:
    query = todos_table.insert().values(
        description=description, user_id=user_id, done_date=done_date
    )
    connection.execute(query)

def select_todo(id: int) -> dict | None:
    query = todos_table.select().where(todos_table.c.id == id)
    result = connection.execute(query)
    d = result.fetchone()
    if d is None:
        return None
    return dict(zip((c.name for c in todos_table.c), d))

def list_todos() -> list:
    query = todos_table.select()
    result = connection.execute(query)
    return [dict(zip((c.name for c in todos_table.c), d)) for d in result.fetchall()]

def main() -> None:
    metadata.create_all(engine)

    insert_user("Max")
    insert_user("Kathi")

    max = select_user("Max")

    insert_todo("Write code", max["id"], datetime.now())
    insert_todo("Tidy your room", max["id"])

    print(list_users())
    print(list_todos())

    connection.close()

if __name__ == "__main__":
    main()

Die Ausgabe in der Befehlszeile zeigt die gewünschten Informationen an, ist aber im Vergleich zu ORM unvollständiger und (meiner Ansicht nach) mit aufwändigerem Code erzeugt worden:

[{'id': 1, 'name': 'Max'}, {'id': 2, 'name': 'Kathi'}]
[{'id': 1, 'description': 'Write code', 'done_date': datetime.datetime(2024, 10, 12, 10, 4, 23, 249914), 'user_id': 1}, {'id': 2, 'description': 'Tidy your room', 'done_date': None, 'user_id': 1}]