Was ist neu in SQLAlchemy 2.0?

Hinweis für Leser

Die Übergangsdokumente für SQLAlchemy 2.0 sind in zwei Dokumente aufgeteilt – eines, das die wichtigsten API-Änderungen von der 1.x- zur 2.x-Serie beschreibt, und das andere, das neue Funktionen und Verhaltensweisen im Vergleich zu SQLAlchemy 1.4 beschreibt.

Leser, die ihre 1.4-Anwendung noch nicht an die Konventionen der SQLAlchemy 2.0 Engine und ORM angepasst haben, können im Dokument SQLAlchemy 2.0 – Leitfaden zur Hauptmigration eine Anleitung zur Sicherstellung der SQLAlchemy 2.0-Kompatibilität finden, die eine Voraussetzung für funktionierenden Code unter Version 2.0 ist.

Über dieses Dokument

Dieses Dokument beschreibt Änderungen zwischen SQLAlchemy Version 1.4 und SQLAlchemy Version 2.0, unabhängig von den größeren Änderungen zwischen der 1.x-Style und 2.0-Style Verwendung. Leser sollten mit dem Dokument SQLAlchemy 2.0 – Leitfaden zur Hauptmigration beginnen, um einen Gesamtüberblick über die wichtigsten Kompatibilitätsänderungen zwischen der 1.x- und der 2.x-Serie zu erhalten.

Neben dem Hauptmigrationspfad von 1.x zu 2.x ist die nächste große Paradigmenverschiebung in SQLAlchemy 2.0 die tiefe Integration mit den PEP 484 Typisierungspraktiken und aktuellen Fähigkeiten, insbesondere innerhalb des ORM. Neue typgesteuerte deklarative ORM-Stile, inspiriert von Python Dataclasses, sowie neue Integrationen mit Dataclasses selbst, ergänzen einen Gesamtansatz, der keine Stubs mehr erfordert und auch stark dazu beiträgt, eine typenbewusste Methodenkette von der SQL-Anweisung bis zur Ergebnismenge bereitzustellen.

Die Bedeutung der Python-Typisierung ist nicht nur wichtig, damit Typ-Checker wie mypy ohne Plugins laufen können; wichtiger ist, dass sie IDEs wie vscode und pycharm eine viel aktivere Rolle bei der Unterstützung der Komposition einer SQLAlchemy-Anwendung spielen können.

Neue Typunterstützung in Core und ORM – Stubs / Erweiterungen werden nicht mehr verwendet

Der Ansatz zur Typisierung für Core und ORM wurde vollständig überarbeitet im Vergleich zum Zwischenansatz, der in Version 1.4 über das Paket sqlalchemy2-stubs bereitgestellt wurde. Der neue Ansatz beginnt beim fundamentalsten Element in SQLAlchemy, nämlich der Column, oder genauer gesagt dem ColumnElement, das allen SQL-Ausdrücken mit einem Typ zugrunde liegt. Diese Typisierung auf Ausdrucksebene erstreckt sich dann auf den Bereich der Anweisungskonstruktion, Anweisungsausführung und Ergebnismengen und schließlich auf das ORM, wo neue deklarative Formen vollständig typisierte ORM-Modelle ermöglichen, die von der Anweisung bis zur Ergebnismenge integriert sind.

Tipp

Die Typunterstützung sollte für die 2.0-Serie als Software auf Beta-Niveau betrachtet werden. Details zur Typisierung können sich ändern, es sind jedoch keine wesentlichen rückwärtsinkompatiblen Änderungen geplant.

Typisierung von SQL-Ausdrücken / Anweisungen / Ergebnismengen

Dieser Abschnitt bietet Hintergrundinformationen und Beispiele für den neuen SQL-Ausdruck-Typisierungsansatz von SQLAlchemy, der sich von den grundlegenden ColumnElement-Konstrukten über SQL-Anweisungen und Ergebnismengen bis hin zum Bereich des ORM-Mappings erstreckt.

Begründung und Überblick

Tipp

Dieser Abschnitt ist eine architektonische Diskussion. Springen Sie zu Beispiele für die Typisierung von SQL-Ausdrücken, um zu sehen, wie die neue Typisierung aussieht.

In sqlalchemy2-stubs wurden SQL-Ausdrücke als Generics typisiert, die dann auf ein TypeEngine-Objekt wie Integer, DateTime oder String als deren generisches Argument verwiesen (z.B. Column[Integer]). Dies war bereits eine Abweichung von dem, was das ursprüngliche Dropbox sqlalchemy-stubs-Paket tat, bei dem Column und seine zugrunde liegenden Konstrukte direkt generisch auf Python-Typen wie int, datetime und str waren. Es wurde gehofft, dass, da Integer / DateTime / String selbst generisch für int / datetime / str sind, es Wege geben würde, beide Informationsebenen beizubehalten und den Python-Typ aus einem Spaltenausdruck über das TypeEngine als Zwischenkonstrukt zu extrahieren. Dies ist jedoch nicht der Fall, da PEP 484 keine ausreichend reichhaltige Funktionalität dafür bietet, da Funktionen wie Higher-Kinded TypeVars fehlen.

Nach einer eingehenden Bewertung der aktuellen Fähigkeiten von PEP 484 hat SQLAlchemy 2.0 die ursprüngliche Weisheit von sqlalchemy-stubs in diesem Bereich umgesetzt und verknüpft Spaltenausdrücke wieder direkt mit Python-Typen. Das bedeutet, dass bei SQL-Ausdrücken für verschiedene Subtypen, wie Column(VARCHAR) vs. Column(Unicode), die Besonderheiten dieser beiden String-Subtypen nicht mitgeführt werden, da der Typ nur str mitführt, aber in der Praxis ist dies normalerweise kein Problem und es ist im Allgemeinen weitaus nützlicher, dass der Python-Typ sofort vorhanden ist, da er die In-Python-Daten darstellt, die man für diese Spalte direkt speichern und empfangen wird.

Konkret bedeutet dies, dass ein Ausdruck wie Column('id', Integer) als Column[int] typisiert wird. Dies ermöglicht eine funktionierende Pipeline von SQLAlchemy-Konstrukt -> Python-Datentyp ohne die Notwendigkeit von Typisierungs-Plugins. Entscheidend ist, dass es die volle Interoperabilität mit dem ORM-Paradigma ermöglicht, select() und Row-Konstrukte zu verwenden, die auf ORM-gemappte Klassentypen verweisen (z.B. eine Row, die Instanzen benutzerdefinierter gemappter Instanzen enthält, wie die User und Address Beispiele aus unseren Tutorials). Obwohl die Python-Typisierung derzeit nur sehr begrenzte Unterstützung für die Anpassung von Tupel-Typen bietet (wobei PEP 646, die erste PEP, die versucht, Tupel-ähnliche Objekte zu behandeln, absichtlich in ihrer Funktionalität eingeschränkt war und für sich genommen noch nicht für beliebige Tupelmanipulationen geeignet ist), wurde ein ziemlich guter Ansatz entwickelt, der eine grundlegende select() -> Result -> Row-Typisierung ermöglicht, auch für ORM-Klassen, bei denen an der Stelle, an der ein Row-Objekt in einzelne Spalteneinträge entpackt werden soll, ein kleiner typenorientierter Accessor hinzugefügt wird, der es den einzelnen Python-Werten ermöglicht, den Python-Typ des SQL-Ausdrucks beizubehalten, von dem sie stammen (Übersetzung: es funktioniert).

Typisierung von SQL-Ausdrücken – Beispiele

Ein kurzer Überblick über die Typisierungsverhalten. Kommentare geben an, was man beim Überfahren des Codes mit der Maus in vscode sehen würde (oder ungefähr, was Typisierungstools anzeigen würden, wenn sie den reveal_type()-Helfer verwenden)

  • Einfache Python-Typen, die SQL-Ausdrücken zugewiesen sind

    # (variable) str_col: ColumnClause[str]
    str_col = column("a", String)
    
    # (variable) int_col: ColumnClause[int]
    int_col = column("a", Integer)
    
    # (variable) expr1: ColumnElement[str]
    expr1 = str_col + "x"
    
    # (variable) expr2: ColumnElement[int]
    expr2 = int_col + 10
    
    # (variable) expr3: ColumnElement[bool]
    expr3 = int_col == 15
  • Einzelne SQL-Ausdrücke, die select()-Konstrukten zugewiesen sind, sowie jede zeilenrückgebende Konstruktion, einschließlich zeilenrückgebender DML wie Insert mit Insert.returning(), werden in einen Tuple[]-Typ gepackt, der den Python-Typ für jedes Element beibehält.

    # (variable) stmt: Select[Tuple[str, int]]
    stmt = select(str_col, int_col)
    
    # (variable) stmt: ReturningInsert[Tuple[str, int]]
    ins_stmt = insert(table("t")).returning(str_col, int_col)
  • Der Tuple[]-Typ aus jeder zeilenrückgebenden Konstruktion, wenn er mit einer .execute()-Methode aufgerufen wird, wird an Result und Row weitergegeben. Um das Row-Objekt als Tupel zu entpacken, fasst der Row.tuple()- oder Row.t-Accessor das Row in den entsprechenden Tuple[] um (bleibt aber zur Laufzeit dasselbe Row-Objekt).

    with engine.connect() as conn:
        # (variable) stmt: Select[Tuple[str, int]]
        stmt = select(str_col, int_col)
    
        # (variable) result: Result[Tuple[str, int]]
        result = conn.execute(stmt)
    
        # (variable) row: Row[Tuple[str, int]] | None
        row = result.first()
    
        if row is not None:
            # for typed tuple unpacking or indexed access,
            # use row.tuple() or row.t  (this is the small typing-oriented accessor)
            strval, intval = row.t
    
            # (variable) strval: str
            strval
    
            # (variable) intval: int
            intval
  • Skalarwerte für Einzelspaltenanweisungen tun das Richtige mit Methoden wie Connection.scalar(), Result.scalars() usw.

    # (variable) data: Sequence[str]
    data = connection.execute(select(str_col)).scalars().all()
  • Die obige Unterstützung für zeilenrückgebende Konstrukte funktioniert am besten mit ORM-gemappten Klassen, da eine gemappte Klasse spezifische Typen für ihre Member auflisten kann. Das folgende Beispiel richtet eine Klasse mit neuen typenbewussten Syntaxen ein, die im folgenden Abschnitt beschrieben werden.

    from sqlalchemy.orm import DeclarativeBase
    from sqlalchemy.orm import Mapped
    from sqlalchemy.orm import mapped_column
    
    
    class Base(DeclarativeBase):
        pass
    
    
    class User(Base):
        __tablename__ = "user_account"
    
        id: Mapped[int] = mapped_column(primary_key=True)
        name: Mapped[str]
        addresses: Mapped[List["Address"]] = relationship()
    
    
    class Address(Base):
        __tablename__ = "address"
    
        id: Mapped[int] = mapped_column(primary_key=True)
        email_address: Mapped[str]
        user_id = mapped_column(ForeignKey("user_account.id"))

    Mit dem obigen Mapping sind die Attribute typisiert und drücken sich von der Anweisung bis zur Ergebnismenge aus

    with Session(engine) as session:
        # (variable) stmt: Select[Tuple[int, str]]
        stmt_1 = select(User.id, User.name)
    
        # (variable) result_1: Result[Tuple[int, str]]
        result_1 = session.execute(stmt_1)
    
        # (variable) intval: int
        # (variable) strval: str
        intval, strval = result_1.one().t

    Gemappte Klassen selbst sind ebenfalls Typen und verhalten sich genauso, z.B. ein SELECT gegen zwei gemappte Klassen

    with Session(engine) as session:
        # (variable) stmt: Select[Tuple[User, Address]]
        stmt_2 = select(User, Address).join_from(User, Address)
    
        # (variable) result_2: Result[Tuple[User, Address]]
        result_2 = session.execute(stmt_2)
    
        # (variable) user_obj: User
        # (variable) address_obj: Address
        user_obj, address_obj = result_2.one().t

    Beim Auswählen von gemappten Klassen funktionieren Konstrukte wie aliased ebenfalls und behalten die spaltenbezogenen Attribute der ursprünglichen gemappten Klasse sowie den erwarteten Rückgabetyp einer Anweisung bei.

    with Session(engine) as session:
        # this is in fact an Annotated type, but typing tools don't
        # generally display this
    
        # (variable) u1: Type[User]
        u1 = aliased(User)
    
        # (variable) stmt: Select[Tuple[User, User, str]]
        stmt = select(User, u1, User.name).filter(User.id == 5)
    
        # (variable) result: Result[Tuple[User, User, str]]
        result = session.execute(stmt)
  • Core Table hat noch keine gute Möglichkeit, die Typisierung von Column-Objekten beizubehalten, wenn auf sie über den Table.c-Accessor zugegriffen wird.

    Da Table als Instanz einer Klasse eingerichtet ist und der Table.c-Accessor typischerweise Column-Objekte dynamisch anhand des Namens abruft, gibt es dafür noch keinen etablierten Typisierungsansatz; eine alternative Syntax wäre erforderlich.

  • ORM-Klassen, Skalare usw. funktionieren hervorragend.

    Der typische Anwendungsfall, ORM-Klassen als Skalare oder Tupel auszuwählen, funktioniert bei beiden Abfragestilen (2.0 und 1.x) hervorragend und liefert den exakten Typ entweder für sich allein oder enthalten in einem entsprechenden Container wie Sequence[], List[] oder Iterator[].

    # (variable) users1: Sequence[User]
    users1 = session.scalars(select(User)).all()
    
    # (variable) user: User
    user = session.query(User).one()
    
    # (variable) user_iter: Iterator[User]
    user_iter = iter(session.scalars(select(User)))
  • Legacy Query erhält ebenfalls Tupel-Typisierung.

    Die Typunterstützung für Query geht weit über das hinaus, was sqlalchemy-stubs oder sqlalchemy2-stubs boten, wobei sowohl skalare Objekt- als auch tupeltypisierte Query-Objekte in den meisten Fällen das Ergebnistyp-Typing beibehalten.

    # (variable) q1: RowReturningQuery[Tuple[int, str]]
    q1 = session.query(User.id, User.name)
    
    # (variable) rows: List[Row[Tuple[int, str]]]
    rows = q1.all()
    
    # (variable) q2: Query[User]
    q2 = session.query(User)
    
    # (variable) users: List[User]
    users = q2.all()

Der Haken – alle Stubs müssen deinstalliert werden

Ein wichtiges Vorbehalt bei der Typunterstützung ist, dass alle SQLAlchemy-Stub-Pakete deinstalliert werden müssen, damit die Typisierung funktioniert. Beim Ausführen von mypy gegen ein Python-Virtualenv ist dies nur eine Frage der Deinstallation dieser Pakete. Ein SQLAlchemy-Stub-Paket ist jedoch derzeit auch Teil von typeshed, das selbst in einige Typisierungs-Tools wie Pylance gebündelt ist, daher kann es in einigen Fällen notwendig sein, die Dateien dieser Pakete zu lokalisieren und zu löschen, wenn sie tatsächlich die korrekte Funktion der neuen Typisierung beeinträchtigen.

Sobald SQLAlchemy 2.0 in der endgültigen Version veröffentlicht wird, wird Typeshed SQLAlchemy aus seiner eigenen Stub-Quelle entfernen.

ORM deklarative Modelle

SQLAlchemy 1.4 führte die erste SQLAlchemy-native ORM-Typunterstützung ein, die eine Kombination aus sqlalchemy2-stubs und dem Mypy Plugin verwendete. In SQLAlchemy 2.0 bleibt das Mypy-Plugin verfügbar und wurde aktualisiert, um mit dem Typisierungssystem von SQLAlchemy 2.0 zu funktionieren. Es sollte jedoch jetzt als veraltet betrachtet werden, da Anwendungen jetzt einen einfachen Weg zur Übernahme der neuen Typunterstützung haben, der keine Plugins oder Stubs verwendet.

Überblick

Der grundlegende Ansatz für das neue System ist, dass gemappte Spaltendeklarationen bei Verwendung eines vollständig deklarativen Modells (d.h. keine hybriden deklarativen oder imperativen Konfigurationen, die unverändert bleiben) zuerst zur Laufzeit abgeleitet werden, indem die Typannotation auf der linken Seite jeder Attributdeklaration inspiziert wird, falls vorhanden. Linke Typannotationen werden erwartet, innerhalb des Mapped-Generics enthalten zu sein, andernfalls wird das Attribut nicht als gemapptes Attribut betrachtet. Die Attributdeklaration kann dann auf die mapped_column()-Konstruktion auf der rechten Seite verweisen, die verwendet wird, um zusätzliche Core-Level-Schema-Informationen über die zu erzeugende und zu mappende Column bereitzustellen. Diese Deklaration auf der rechten Seite ist optional, wenn auf der linken Seite eine Mapped-Annotation vorhanden ist; wenn keine Annotation vorhanden ist, kann mapped_column() als exakter Ersatz für die Column-Direktive verwendet werden, wo sie für ein genaueres (aber nicht exaktes) Typisierungsverhalten des Attributs sorgt, obwohl keine Annotation vorhanden ist.

Der Ansatz ist vom Ansatz der Python Dataclasses inspiriert, der mit einer Annotation links beginnt und dann eine optionale dataclasses.field() Spezifikation rechts erlaubt; der Hauptunterschied zum Dataclasses-Ansatz ist, dass SQLAlchemy opt-in ist, wobei bestehende Mappings, die Column ohne Typannotationen verwenden, weiterhin wie immer funktionieren und die mapped_column()-Konstruktion als direkter Ersatz für Column ohne explizite Typannotationen verwendet werden kann. Nur für exakte Attribut-Ebene Python-Typen ist die Verwendung expliziter Annotationen mit Mapped erforderlich. Diese Annotationen können attributweise nach Bedarf für die Attribute verwendet werden, bei denen spezifische Typen hilfreich sind; nicht annotierte Attribute, die mapped_column() verwenden, werden auf Instanzebene als Any typisiert.

Migrieren einer vorhandenen Zuordnung

Der Übergang zum neuen ORM-Ansatz beginnt als umständlicher, wird aber prägnanter als zuvor möglich, wenn die verfügbaren neuen Funktionen voll genutzt werden. Die folgenden Schritte beschreiben einen typischen Übergang und illustrieren dann einige weitere Optionen.

Schritt eins – declarative_base() wird durch DeclarativeBase ersetzt.

Eine beobachtete Einschränkung bei der Python-Typisierung ist, dass es keine Möglichkeit zu geben scheint, eine Klasse dynamisch aus einer Funktion zu generieren, die dann von Typisierungstools als Basis für neue Klassen verstanden wird. Um dieses Problem ohne Plugins zu lösen, kann der übliche Aufruf von declarative_base() durch die Verwendung der Klasse DeclarativeBase ersetzt werden, die dasselbe Base-Objekt wie gewohnt erzeugt, nur dass Typisierungstools es verstehen.

from sqlalchemy.orm import DeclarativeBase


class Base(DeclarativeBase):
    pass
Schritt zwei – Ersetzen der deklarativen Verwendung von Column durch mapped_column()

Die mapped_column() ist eine ORM-typbewusste Konstruktion, die direkt für die Verwendung von Column eingesetzt werden kann. Gegeben sei eine Mapping im 1.x-Stil wie folgt

from sqlalchemy import Column
from sqlalchemy.orm import relationship
from sqlalchemy.orm import DeclarativeBase


class Base(DeclarativeBase):
    pass


class User(Base):
    __tablename__ = "user_account"

    id = Column(Integer, primary_key=True)
    name = Column(String(30), nullable=False)
    fullname = Column(String)
    addresses = relationship("Address", back_populates="user")


class Address(Base):
    __tablename__ = "address"

    id = Column(Integer, primary_key=True)
    email_address = Column(String, nullable=False)
    user_id = Column(ForeignKey("user_account.id"), nullable=False)
    user = relationship("User", back_populates="addresses")

Wir ersetzen Column durch mapped_column(); keine Argumente müssen geändert werden

from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import mapped_column
from sqlalchemy.orm import relationship


class Base(DeclarativeBase):
    pass


class User(Base):
    __tablename__ = "user_account"

    id = mapped_column(Integer, primary_key=True)
    name = mapped_column(String(30), nullable=False)
    fullname = mapped_column(String)
    addresses = relationship("Address", back_populates="user")


class Address(Base):
    __tablename__ = "address"

    id = mapped_column(Integer, primary_key=True)
    email_address = mapped_column(String, nullable=False)
    user_id = mapped_column(ForeignKey("user_account.id"), nullable=False)
    user = relationship("User", back_populates="addresses")

Die einzelnen Spalten oben sind noch nicht mit Python-Typen versehen, sondern sind stattdessen als Mapped[Any] typisiert; dies liegt daran, dass wir jede Spalte entweder als Optional oder nicht deklarieren können und es keine Möglichkeit gibt, eine "Vermutung" zu haben, die keine Typisierungsfehler verursacht, wenn wir sie explizit typisieren.

Allerdings hat unser obiges Mapping zu diesem Zeitpunkt passende Descriptor-Typen für alle Attribute eingerichtet und kann sowohl in Abfragen als auch für instanzbezogene Manipulationen verwendet werden, die alle mypy –strict mode ohne Plugins bestehen werden.

Schritt drei – wendet genaue Python-Typen nach Bedarf mit Mapped an.

Dies kann für alle Attribute geschehen, für die eine exakte Typisierung gewünscht ist; Attribute, die als Any belassen werden können, können übersprungen werden. Zur Veranschaulichung zeigen wir auch Mapped, das für eine relationship() verwendet wird, bei der wir einen exakten Typ anwenden. Das Mapping in diesem Zwischenschritt wird ausführlicher sein, jedoch kann dieser Schritt mit fortgeschrittener Kenntnis mit nachfolgenden Schritten kombiniert werden, um Mappings direkter zu aktualisieren.

from typing import List
from typing import Optional
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
from sqlalchemy.orm import relationship


class Base(DeclarativeBase):
    pass


class User(Base):
    __tablename__ = "user_account"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    name: Mapped[str] = mapped_column(String(30), nullable=False)
    fullname: Mapped[Optional[str]] = mapped_column(String)
    addresses: Mapped[List["Address"]] = relationship("Address", back_populates="user")


class Address(Base):
    __tablename__ = "address"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    email_address: Mapped[str] = mapped_column(String, nullable=False)
    user_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"), nullable=False)
    user: Mapped["User"] = relationship("User", back_populates="addresses")

Zu diesem Zeitpunkt ist unser ORM-Mapping vollständig typisiert und erzeugt exakt typisierte select()-, Query- und Result-Konstrukte. Nun können wir die Redundanz in der Mapping-Deklaration reduzieren.

Schritt vier – mapped_column()-Direktiven entfernen, wo sie nicht mehr benötigt werden

Alle nullable-Parameter können mit Optional[] impliziert werden; in Abwesenheit von Optional[] ist nullable standardmäßig False. Alle SQL-Typen ohne Argumente, wie z.B. Integer und String, können als reine Python-Annotation ausgedrückt werden. Eine mapped_column()-Direktive ohne Parameter kann vollständig entfernt werden. relationship() leitet seine Klasse nun von der linken Annotation ab und unterstützt auch Vorwärtsreferenzen (da relationship() bereits seit zehn Jahren String-basierte Vorwärtsreferenzen unterstützt ;))

from typing import List
from typing import Optional
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
from sqlalchemy.orm import relationship


class Base(DeclarativeBase):
    pass


class User(Base):
    __tablename__ = "user_account"

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(30))
    fullname: Mapped[Optional[str]]
    addresses: Mapped[List["Address"]] = relationship(back_populates="user")


class Address(Base):
    __tablename__ = "address"

    id: Mapped[int] = mapped_column(primary_key=True)
    email_address: Mapped[str]
    user_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"))
    user: Mapped["User"] = relationship(back_populates="addresses")
Schritt fünf – Mapped verwenden, um typisierte Attribute zu definieren

Dieser Schritt wird hier nicht übersetzt, da er sich mit Schritt drei überschneidet und durch die Beschreibung in Schritt drei abgedeckt ist.

Erstens ermöglicht das deklarative Mapping, dass die Zuordnung von Python-Typ zu SQL-Typ, wie z.B. str zu String, mit registry.type_annotation_map angepasst werden kann. Die Verwendung von PEP 593 Annotated ermöglicht es uns, Varianten eines bestimmten Python-Typs zu erstellen, so dass derselbe Typ, wie z.B. str, verwendet werden kann, wobei jede Variante eine Variante von String bereitstellt, wie unten gezeigt, wo die Verwendung eines Annotated str namens str50 String(50) anzeigt.

from typing_extensions import Annotated
from sqlalchemy.orm import DeclarativeBase

str50 = Annotated[str, 50]


# declarative base with a type-level override, using a type that is
# expected to be used in multiple places
class Base(DeclarativeBase):
    type_annotation_map = {
        str50: String(50),
    }

Zweitens extrahiert Declarative vollständige mapped_column()-Definitionen aus dem linken Typ, wenn Annotated[] verwendet wird, indem ein mapped_column()-Konstrukt als beliebiges Argument an das Annotated[]-Konstrukt übergeben wird (danke an @adriangb01 für die Illustration dieser Idee). Diese Funktion kann in zukünftigen Versionen auf relationship(), composite() und andere Konstrukte erweitert werden, ist aber derzeit auf mapped_column() beschränkt. Das nachstehende Beispiel fügt zusätzliche Annotated-Typen zusätzlich zu unserem str50-Beispiel hinzu, um diese Funktion zu veranschaulichen.

from typing_extensions import Annotated
from typing import List
from typing import Optional
from sqlalchemy import ForeignKey
from sqlalchemy import String
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
from sqlalchemy.orm import relationship

# declarative base from previous example
str50 = Annotated[str, 50]


class Base(DeclarativeBase):
    type_annotation_map = {
        str50: String(50),
    }


# set up mapped_column() overrides, using whole column styles that are
# expected to be used in multiple places
intpk = Annotated[int, mapped_column(primary_key=True)]
user_fk = Annotated[int, mapped_column(ForeignKey("user_account.id"))]


class User(Base):
    __tablename__ = "user_account"

    id: Mapped[intpk]
    name: Mapped[str50]
    fullname: Mapped[Optional[str]]
    addresses: Mapped[List["Address"]] = relationship(back_populates="user")


class Address(Base):
    __tablename__ = "address"

    id: Mapped[intpk]
    email_address: Mapped[str50]
    user_id: Mapped[user_fk]
    user: Mapped["User"] = relationship(back_populates="addresses")

Oben beziehen sich Spalten, die mit Mapped[str50], Mapped[intpk] oder Mapped[user_fk] gemappt sind, sowohl aus der registry.type_annotation_map als auch direkt aus dem Annotated-Konstrukt, um bereits etablierte Typisierungs- und Spaltenkonfigurationen wiederzuverwenden.

Optionaler Schritt – zu Dataclasses gemappte Klassen umwandeln

Wir können gemappte Klassen in Dataclasses umwandeln, wobei ein wesentlicher Vorteil darin besteht, dass wir eine streng typisierte __init__()-Methode mit expliziten positions-, nur-Schlüsselwort- und Standardargumenten erstellen können, ganz zu schweigen davon, dass wir Methoden wie __str__() und __repr__() kostenlos erhalten. Der nächste Abschnitt Native Unterstützung für als ORM-Modelle gemappte Dataclasses veranschaulicht weitere Transformationen des obigen Modells.

Typisierung wird ab Schritt 3 unterstützt

Mit den obigen Beispielen werden alle Beispiele ab „Schritt 3“ so gestaltet, dass die Attribute des Modells typisiert sind und bis zu select(), Query und Row-Objekten durchgehen.

# (variable) stmt: Select[Tuple[int, str]]
stmt = select(User.id, User.name)

with Session(e) as sess:
    for row in sess.execute(stmt):
        # (variable) row: Row[Tuple[int, str]]
        print(row)

    # (variable) users: Sequence[User]
    users = sess.scalars(select(User)).all()

    # (variable) users_legacy: List[User]
    users_legacy = sess.query(User).all()

Siehe auch

Deklarative Tabelle mit mapped_column() – Aktualisierte deklarative Dokumentation für die deklarative Generierung und das Mapping von Table-Spalten.

Verwendung von Legacy-Mypy-typisierten Modellen

SQLAlchemy-Anwendungen, die das Mypy-Plugin mit expliziten Annotationen verwenden, die Mapped in ihren Annotationen nicht verwenden, sind im neuen System fehleranfällig, da solche Annotationen bei Verwendung von Konstrukten wie relationship() als Fehler markiert werden.

Der Abschnitt Migration zu 2.0 Schritt Sechs – Hinzufügen von __allow_unmapped__ zu explizit typisierten ORM-Modellen zeigt, wie diese Fehler vorübergehend für ein Legacy-ORM-Modell, das explizite Annotationen verwendet, deaktiviert werden können.

Native Unterstützung für als ORM-Modelle gemappte Dataclasses

Die oben unter ORM-Deklarative Modelle eingeführten neuen ORM-Deklarativfunktionen führten den neuen mapped_column()-Konstrukt ein und veranschaulichten typzentriertes Mapping mit optionaler Verwendung von PEP 593 Annotated. Wir können das Mapping einen Schritt weiter treiben, indem wir es mit Python- Dataclasses integrieren. Diese neue Funktion wird durch PEP 681 ermöglicht, die es Typenprüfern erlaubt, Klassen zu erkennen, die dataclass-kompatibel sind oder vollständige dataclasses sind, aber über alternative APIs deklariert wurden.

Unter Verwendung der Dataclass-Funktion erhalten gemappte Klassen eine __init__()-Methode, die positionsbezogene Argumente sowie konfigurierbare Standardwerte für optionale Schlüsselwortargumente unterstützt. Wie bereits erwähnt, generieren Dataclasses auch viele nützliche Methoden wie __str__(), __eq__(). Dataclass-Serialisierungsmethoden wie dataclasses.asdict() und dataclasses.astuple() funktionieren ebenfalls, können aber derzeit keine selbstbezüglichen Strukturen verarbeiten, was sie für Mappings mit bidirektionalen Beziehungen weniger praktikabel macht.

SQLAlchemy's aktueller Integrationsansatz wandelt die benutzerdefinierte Klasse in eine echte Dataclass um, um Laufzeitfunktionalität bereitzustellen; die Funktion nutzt die vorhandene Dataclass-Funktion, die in SQLAlchemy 1.4 unter Python Dataclasses, attrs unterstützt mit deklarativen, imperativen Mappings eingeführt wurde, um ein äquivalentes Laufzeit-Mapping mit einem vollständig integrierten Konfigurationsstil zu erzeugen, der auch korrekter typisiert ist als mit dem vorherigen Ansatz möglich.

Um Dataclasses gemäß PEP 681 zu unterstützen, akzeptieren ORM-Konstrukte wie mapped_column() und relationship() zusätzliche PEP 681-Argumente init, default und default_factory, die an den Dataclass-Erstellungsprozess weitergegeben werden. Diese Argumente müssen derzeit in einer expliziten Direktive auf der rechten Seite vorhanden sein, so wie sie mit dataclasses.field() verwendet würden; sie können derzeit nicht lokal in einem Annotated-Konstrukt auf der linken Seite sein. Um die bequeme Verwendung von Annotated zu unterstützen und gleichzeitig die Dataclass-Konfiguration zu unterstützen, kann mapped_column() eine minimale Menge von Argumenten auf der rechten Seite mit einem bestehenden mapped_column()-Konstrukt auf der linken Seite innerhalb eines Annotated-Konstrukts zusammenführen, sodass die meiste Prägnanz erhalten bleibt, wie unten zu sehen sein wird.

Um Dataclasses mithilfe von Klassenvererbung zu aktivieren, verwenden wir die Mixin-Klasse MappedAsDataclass, entweder direkt auf jeder Klasse oder auf der Base-Klasse, wie unten gezeigt, wo wir das Beispiel-Mapping aus „Schritt 5“ von ORM-Deklarative Modelle weiter modifizieren.

from typing_extensions import Annotated
from typing import List
from typing import Optional
from sqlalchemy import ForeignKey
from sqlalchemy import String
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import MappedAsDataclass
from sqlalchemy.orm import mapped_column
from sqlalchemy.orm import relationship


class Base(MappedAsDataclass, DeclarativeBase):
    """subclasses will be converted to dataclasses"""


intpk = Annotated[int, mapped_column(primary_key=True)]
str30 = Annotated[str, mapped_column(String(30))]
user_fk = Annotated[int, mapped_column(ForeignKey("user_account.id"))]


class User(Base):
    __tablename__ = "user_account"

    id: Mapped[intpk] = mapped_column(init=False)
    name: Mapped[str30]
    fullname: Mapped[Optional[str]] = mapped_column(default=None)
    addresses: Mapped[List["Address"]] = relationship(
        back_populates="user", default_factory=list
    )


class Address(Base):
    __tablename__ = "address"

    id: Mapped[intpk] = mapped_column(init=False)
    email_address: Mapped[str]
    user_id: Mapped[user_fk] = mapped_column(init=False)
    user: Mapped["User"] = relationship(back_populates="addresses", default=None)

Das obige Mapping hat den Dekorator @dataclasses.dataclass direkt auf jeder gemappten Klasse angewendet, während gleichzeitig das deklarative Mapping eingerichtet wurde, wobei intern jede dataclasses.field()-Direktive wie angegeben eingerichtet wurde. User / Address-Strukturen können mithilfe von positionsbezogenen Argumenten wie konfiguriert erstellt werden.

>>> u1 = User("username", fullname="full name", addresses=[Address("email@address")])
>>> u1
User(id=None, name='username', fullname='full name', addresses=[Address(id=None, email_address='email@address', user_id=None, user=...)])

Optimierte ORM-Masseninsertion jetzt für alle Backends außer MySQL implementiert

Die dramatische Leistungssteigerung, die in der 1.4-Serie eingeführt und unter ORM Batch-Einfügungen mit psycopg2 führen jetzt meistens zu Batch-Anweisungen mit RETURNING beschrieben wurde, wurde nun auf alle enthaltenen Backends verallgemeinert, die RETURNING unterstützen, was alle Backends außer MySQL sind: SQLite, MariaDB, PostgreSQL (alle Treiber) und Oracle; SQL Server hat Unterstützung, ist aber in Version 2.0.9 vorübergehend deaktiviert [1]. Während die ursprüngliche Funktion für den psycopg2-Treiber am kritischsten war, der ansonsten erhebliche Leistungsprobleme bei der Verwendung von cursor.executemany() hatte, ist die Änderung auch für andere PostgreSQL-Treiber wie asyncpg entscheidend, da bei Verwendung von RETURNING einzelne INSERT-Anweisungen immer noch unakzeptabel langsam sind, sowie bei Verwendung von SQL Server, das ebenfalls eine sehr langsame executemany-Geschwindigkeit für INSERT-Anweisungen aufweist, unabhängig davon, ob RETURNING verwendet wird oder nicht.

Die Leistung der neuen Funktion bietet eine fast durchgängig um eine Größenordnung höhere Leistung für praktisch jeden Treiber bei der INSERTION von ORM-Objekten, die keinen vorab zugewiesenen Primärschlüsselwert haben, wie in der nachstehenden Tabelle angegeben, in den meisten Fällen spezifisch für die Verwendung von RETURNING, das normalerweise nicht mit executemany() unterstützt wird.

Der „fast execution helper“-Ansatz von psycopg2 besteht darin, eine INSERT..RETURNING-Anweisung mit einem einzelnen Satz von Parametern in eine einzelne Anweisung zu verwandeln, die viele Parameter-Sätze einfügt und mehrere „VALUES…“-Klauseln verwendet, sodass sie viele Parameter-Sätze auf einmal aufnehmen kann. Die Parameter-Sätze werden dann typischerweise in Gruppen von 1000 oder ähnlich gebündelt, sodass keine einzelne INSERT-Anweisung übermäßig groß wird, und die INSERT-Anweisung wird dann für jede Gruppe von Parametern aufgerufen, anstatt für jeden einzelnen Parameter-Satz. Primärschlüsselwerte und Server-Standardwerte werden von RETURNING zurückgegeben, was weiterhin funktioniert, da jede Anweisungsausführung mithilfe von cursor.execute() anstelle von cursor.executemany() aufgerufen wird.

Dies ermöglicht das Einfügen vieler Zeilen in einer einzigen Anweisung und gleichzeitig die Rückgabe von neu generierten Primärschlüsselwerten sowie SQL- und Server-Standardwerten. SQLAlchemy musste historisch immer eine Anweisung pro Parameter-Satz aufrufen, da es sich auf Python DBAPI-Funktionen wie cursor.lastrowid stützte, die keine Mehrfachzeilen unterstützen.

Da die meisten Datenbanken inzwischen RETURNING anbieten (mit der auffälligen Ausnahme von MySQL, da MariaDB es unterstützt), verallgemeinert die neue Änderung den psycopg2 „fast execution helper“-Ansatz auf alle Dialekte, die RETURNING unterstützen, was nun SQLite und MariaDB einschließt, und für die kein anderer Ansatz für „executemany plus RETURNING“ möglich ist, was SQLite, MariaDB und alle PG-Treiber einschließt. Die cx_Oracle- und oracledb-Treiber für Oracle unterstützen RETURNING nativ mit executemany, und dies wurde ebenfalls implementiert, um vergleichbare Leistungsverbesserungen zu erzielen. Da SQLite und MariaDB nun RETURNING-Unterstützung anbieten, gehört die ORM-Nutzung von cursor.lastrowid fast der Vergangenheit an, nur MySQL setzt weiterhin darauf.

Für INSERT-Anweisungen, die kein RETURNING verwenden, wird für die meisten Backends das traditionelle executemany()-Verhalten verwendet, mit der aktuellen Ausnahme von psycopg2, das insgesamt eine sehr langsame executemany()-Leistung aufweist und durch den „insertmanyvalues“-Ansatz weiter verbessert wird.

Benchmarks

SQLAlchemy enthält eine Performance-Suite im Verzeichnis examples/, wo wir die bulk_insert-Suite verwenden können, um INSERTs vieler Zeilen sowohl mit Core als auch mit ORM auf unterschiedliche Weise zu benchmarken.

Für die unten aufgeführten Tests fügen wir 100.000 Objekte ein, und in allen Fällen haben wir tatsächlich 100.000 reale Python-ORM-Objekte im Speicher, entweder vorab erstellt oder on-the-fly generiert. Alle Datenbanken außer SQLite werden über eine Netzwerkverbindung, nicht über localhost, ausgeführt; dies führt dazu, dass die „langsameren“ Ergebnisse extrem langsam sind.

Von dieser Funktion verbesserte Operationen umfassen

  • Unit-of-Work-Flushes für Objekte, die mit Session.add() und Session.add_all() zur Sitzung hinzugefügt wurden.

  • Die neue Funktion ORM Bulk Insert Statement, die die experimentelle Version dieser Funktion, die erstmals in SQLAlchemy 1.4 eingeführt wurde, verbessert.

  • die Session-„Bulk“-Operationen, die unter Bulk-Operationen beschrieben werden und durch die oben erwähnte ORM Bulk Insert-Funktion abgelöst werden.

Um ein Gefühl für den Umfang der Operation zu bekommen, folgen hier Leistungsmessungen, die die test_flush_no_pk-Performance-Suite verwenden, die historisch gesehen die schlechteste INSERT-Performance-Aufgabe von SQLAlchemy darstellt, bei der Objekte ohne Primärschlüsselwerte eingefügt werden müssen und dann die neu generierten Primärschlüsselwerte abgerufen werden müssen, damit die Objekte für nachfolgende Flush-Operationen verwendet werden können, wie z.B. die Einrichtung in Beziehungen, das Flushen von Joined-Inheritance-Modellen usw.

@Profiler.profile
def test_flush_no_pk(n):
    """INSERT statements via the ORM (batched with RETURNING if available),
    fetching generated row id"""
    session = Session(bind=engine)
    for chunk in range(0, n, 1000):
        session.add_all(
            [
                Customer(
                    name="customer name %d" % i,
                    description="customer description %d" % i,
                )
                for i in range(chunk, chunk + 1000)
            ]
        )
        session.flush()
    session.commit()

Dieser Test kann von jedem SQLAlchemy-Quellbaum aus wie folgt ausgeführt werden:

python -m examples.performance.bulk_inserts --test test_flush_no_pk

Die nachstehende Tabelle fasst die Leistungsmessungen der neuesten 1.4-Serie von SQLAlchemy im Vergleich zu 2.0 zusammen, wobei beide denselben Test ausführen.

Treiber

SQLA 1.4 Zeit (Sekunden)

SQLA 2.0 Zeit (Sekunden)

sqlite+pysqlite2 (Speicher)

6.204843

3.554856

postgresql+asyncpg (Netzwerk)

88.292285

4.561492

postgresql+psycopg (Netzwerk)

N/A (psycopg3)

4.861368

mssql+pyodbc (Netzwerk)

158.396667

4.825139

oracle+cx_Oracle (Netzwerk)

92.603953

4.809520

mariadb+mysqldb (Netzwerk)

71.705197

4.075377

Hinweis

Zwei zusätzliche Treiber zeigen keine Leistungsänderung; die psycopg2-Treiber, für die fast executemany bereits in SQLAlchemy 1.4 implementiert war, und MySQL, das weiterhin keine RETURNING-Unterstützung bietet.

Treiber

SQLA 1.4 Zeit (Sekunden)

SQLA 2.0 Zeit (Sekunden)

postgresql+psycopg2 (Netzwerk)

4.704876

4.699883

mysql+mysqldb (Netzwerk)

77.281997

76.132995

Zusammenfassung der Änderungen

Die folgenden Aufzählungspunkte listen die einzelnen Änderungen auf, die in 2.0 vorgenommen wurden, um alle Treiber in diesen Zustand zu versetzen:

  • RETURNING für SQLite implementiert – #6195

  • RETURNING für MariaDB implementiert – #7011

  • Fix Multi-Row RETURNING für Oracle – #6245

  • macht insert() executemany() RETURNING für so viele Dialekte wie möglich unterstützt, normalerweise mit VALUES() – #6047

  • Gibt eine Warnung aus, wenn RETURNING mit executemany für ein nicht unterstützendes Backend verwendet wird (derzeit hat kein RETURNING-Backend diese Einschränkung) – #7907

  • Der ORM-Parameter Mapper.eager_defaults hat nun den neuen Standardwert "auto", der „eager defaults“ automatisch für INSERT-Anweisungen aktiviert, wenn das verwendete Backend RETURNING mit „insertmanyvalues“ unterstützt. Siehe Abrufen von serverseitig generierten Standardwerten für Dokumentation.

Siehe auch

„Insert Many Values“-Verhalten für INSERT-Anweisungen – Dokumentation und Hintergrundinformationen zur neuen Funktion sowie zur Konfiguration.

ORM-aktivierte Insert-, Upsert-, Update- und Delete-Anweisungen mit ORM RETURNING

SQLAlchemy 1.4 portierte die Funktionen des Legacy-Query-Objekts auf die 2.0-Style-Ausführung, was bedeutete, dass der Select-Konstrukt an Session.execute() übergeben werden konnte, um ORM-Ergebnisse zu liefern. Unterstützung wurde auch für Update und Delete hinzugefügt, die an Session.execute() übergeben werden konnten, insofern sie Implementierungen von Query.update() und Query.delete() bereitstellen konnten.

Das Hauptfehlende Element war die Unterstützung für den Insert-Konstrukt. Die Dokumentation von 1.4 befasste sich damit mit einigen Rezepten für „Inserts“ und „Upserts“ unter Verwendung von Select.from_statement(), um RETURNING in einen ORM-Kontext zu integrieren. 2.0 schließt nun die Lücke vollständig, indem es direkte Unterstützung für Insert als verbesserte Version der Methode Session.bulk_insert_mappings() integriert, zusammen mit vollständiger ORM RETURNING-Unterstützung für alle DML-Konstrukte.

Bulk Insert mit RETURNING

Insert kann an Session.execute() übergeben werden, mit oder ohne Insert.returning(). Wenn es mit einer separaten Parameterliste übergeben wird, wird derselbe Prozess aufgerufen, der zuvor durch Session.bulk_insert_mappings() implementiert wurde, mit zusätzlichen Verbesserungen. Dies optimiert das Batching von Zeilen unter Nutzung der neuen Fast Insertmany-Funktion, während es auch Unterstützung für heterogene Parametersätze und Mehr-Tabellen-Mappings wie Joined Table Inheritance hinzufügt.

>>> users = session.scalars(
...     insert(User).returning(User),
...     [
...         {"name": "spongebob", "fullname": "Spongebob Squarepants"},
...         {"name": "sandy", "fullname": "Sandy Cheeks"},
...         {"name": "patrick", "fullname": "Patrick Star"},
...         {"name": "squidward", "fullname": "Squidward Tentacles"},
...         {"name": "ehkrabs", "fullname": "Eugene H. Krabs"},
...     ],
... )
>>> print(users.all())
[User(name='spongebob', fullname='Spongebob Squarepants'),
 User(name='sandy', fullname='Sandy Cheeks'),
 User(name='patrick', fullname='Patrick Star'),
 User(name='squidward', fullname='Squidward Tentacles'),
 User(name='ehkrabs', fullname='Eugene H. Krabs')]

RETURNING wird für all diese Anwendungsfälle unterstützt, wobei die ORM einen vollständigen Ergebnissatz aus mehreren Anweisungsaufrufen konstruiert.

Bulk UPDATE

Ähnlich wie bei Insert wird das Übergeben des Update-Konstrukts zusammen mit einer Parameterliste, die Primärschlüsselwerte enthält, an Session.execute() denselben Prozess aufrufen, der zuvor durch die Methode Session.bulk_update_mappings() unterstützt wurde. Diese Funktion unterstützt jedoch kein RETURNING, da sie eine SQL UPDATE-Anweisung verwendet, die über DBAPI executemany aufgerufen wird.

>>> from sqlalchemy import update
>>> session.execute(
...     update(User),
...     [
...         {"id": 1, "fullname": "Spongebob Squarepants"},
...         {"id": 3, "fullname": "Patrick Star"},
...     ],
... )

INSERT / upsert … VALUES … RETURNING

Bei Verwendung von Insert mit Insert.values() kann die Parametersammlung SQL-Ausdrücke enthalten. Zusätzlich werden auch Upsert-Varianten für SQLite, PostgreSQL und MariaDB unterstützt. Diese Anweisungen können nun Insert.returning()-Klauseln mit Spaltenausdrücken oder vollständigen ORM-Entitäten enthalten.

>>> from sqlalchemy.dialects.sqlite import insert as sqlite_upsert
>>> stmt = sqlite_upsert(User).values(
...     [
...         {"name": "spongebob", "fullname": "Spongebob Squarepants"},
...         {"name": "sandy", "fullname": "Sandy Cheeks"},
...         {"name": "patrick", "fullname": "Patrick Star"},
...         {"name": "squidward", "fullname": "Squidward Tentacles"},
...         {"name": "ehkrabs", "fullname": "Eugene H. Krabs"},
...     ]
... )
>>> stmt = stmt.on_conflict_do_update(
...     index_elements=[User.name], set_=dict(fullname=stmt.excluded.fullname)
... )
>>> result = session.scalars(stmt.returning(User))
>>> print(result.all())
[User(name='spongebob', fullname='Spongebob Squarepants'),
User(name='sandy', fullname='Sandy Cheeks'),
User(name='patrick', fullname='Patrick Star'),
User(name='squidward', fullname='Squidward Tentacles'),
User(name='ehkrabs', fullname='Eugene H. Krabs')]

ORM UPDATE / DELETE mit WHERE … RETURNING

SQLAlchemy 1.4 hatte auch eine bescheidene Unterstützung für die RETURNING-Funktion, die mit den Konstrukten update() und delete() verwendet wurde, wenn sie mit Session.execute() verwendet wurden. Diese Unterstützung wurde nun auf vollständig native Unterstützung aufgerüstet, einschließlich der Tatsache, dass die fetch Synchronisationsstrategie auch dann erfolgen kann, wenn explizit RETURNING verwendet wird oder nicht.

>>> from sqlalchemy import update
>>> stmt = (
...     update(User)
...     .where(User.name == "squidward")
...     .values(name="spongebob")
...     .returning(User)
... )
>>> result = session.scalars(stmt, execution_options={"synchronize_session": "fetch"})
>>> print(result.all())

Verbessertes synchronize_session Verhalten für ORM UPDATE / DELETE

Die Standardstrategie für synchronize_session ist nun ein neuer Wert "auto". Diese Strategie versucht, die "evaluate" Strategie zu verwenden und fällt dann automatisch auf die "fetch" Strategie zurück. Für alle Backends außer MySQL / MariaDB verwendet "fetch" RETURNING, um UPDATE/DELETE primäre Schlüssel-Identifikatoren im selben Statement abzurufen, ist daher im Allgemeinen effizienter als frühere Versionen (in 1.4 war RETURNING nur für PostgreSQL, SQL Server verfügbar).

Zusammenfassung der Änderungen

Aufgelistete Tickets für neue ORM DML mit RETURNING-Funktionen

  • konvertiere insert() auf ORM-Ebene, um values() in einem ORM-Kontext zu interpretieren - #7864

  • evaluiere die Machbarkeit von dml.returning(Entity), um ORM-Ausdrücke zu liefern, wende automatisch select().from_statement equiv an - #7865

  • bei ORM insert versuche, die Bulk-Methoden mitzunehmen, bzgl. Vererbung - #8360

Neue "Write Only" Beziehungsstrategie ersetzt "dynamic"

Die lazy="dynamic" Laderstrategie wird zu Legacy, da sie fest kodiert ist, um eine Legacy Query zu verwenden. Diese Laderstrategie ist weder mit asyncio kompatibel noch hat sie viele Verhaltensweisen, die ihren Inhalt implizit iterieren, was den ursprünglichen Zweck der "dynamischen" Beziehung als für sehr große Sammlungen, die nicht implizit vollständig in den Speicher geladen werden sollten, zunichte macht.

Die "dynamic" Strategie wird nun von einer neuen Strategie lazy="write_only" abgelöst. Die Konfiguration von "write only" kann mit dem Parameter relationship.lazy von relationship() erreicht werden, oder wenn Typ-annotierte Mappings verwendet werden, indem die Annotation WriteOnlyMapped als Mapping-Stil angegeben wird.

from sqlalchemy.orm import WriteOnlyMapped


class Base(DeclarativeBase):
    pass


class Account(Base):
    __tablename__ = "account"
    id: Mapped[int] = mapped_column(primary_key=True)
    identifier: Mapped[str]
    account_transactions: WriteOnlyMapped["AccountTransaction"] = relationship(
        cascade="all, delete-orphan",
        passive_deletes=True,
        order_by="AccountTransaction.timestamp",
    )


class AccountTransaction(Base):
    __tablename__ = "account_transaction"
    id: Mapped[int] = mapped_column(primary_key=True)
    account_id: Mapped[int] = mapped_column(
        ForeignKey("account.id", ondelete="cascade")
    )
    description: Mapped[str]
    amount: Mapped[Decimal]
    timestamp: Mapped[datetime] = mapped_column(default=func.now())

Die Schreibgeschützte gemappte Sammlung ähnelt lazy="dynamic", da die Sammlung im Voraus zugewiesen werden kann und auch Methoden wie WriteOnlyCollection.add() und WriteOnlyCollection.remove() zum Modifizieren der Sammlung auf einzelner Elementbasis hat.

new_account = Account(
    identifier="account_01",
    account_transactions=[
        AccountTransaction(description="initial deposit", amount=Decimal("500.00")),
        AccountTransaction(description="transfer", amount=Decimal("1000.00")),
        AccountTransaction(description="withdrawal", amount=Decimal("-29.50")),
    ],
)

new_account.account_transactions.add(
    AccountTransaction(description="transfer", amount=Decimal("2000.00"))
)

Der größere Unterschied liegt auf der Seite des Datenbankladens, wo die Sammlung keine Fähigkeit hat, Objekte direkt aus der Datenbank zu laden; stattdessen werden SQL-Konstruktionsmethoden wie WriteOnlyCollection.select() verwendet, um SQL-Konstrukte wie Select zu erzeugen, die dann unter Verwendung von 2.0 Stil ausgeführt werden, um die gewünschten Objekte auf explizite Weise zu laden.

account_transactions = session.scalars(
    existing_account.account_transactions.select()
    .where(AccountTransaction.amount < 0)
    .limit(10)
).all()

Die WriteOnlyCollection integriert sich auch in die neuen ORM Bulk DML Funktionen, einschließlich Unterstützung für Bulk INSERT und UPDATE/DELETE mit WHERE-Kriterien, alles einschließlich RETURNING-Unterstützung. Siehe die vollständige Dokumentation unter Write Only Relationships.

Neue PEP-484 / Typ-annotierte Mapping-Unterstützung für dynamische Beziehungen

Obwohl "dynamische" Beziehungen in 2.0 veraltet sind, da diese Muster voraussichtlich eine lange Lebensdauer haben werden, wurde nun die Unterstützung für Typ-annotierte Mappings für "dynamische" Beziehungen hinzugefügt, genauso wie sie für den neuen Ansatz lazy="write_only" verfügbar ist, unter Verwendung der Annotation DynamicMapped.

from sqlalchemy.orm import DynamicMapped


class Base(DeclarativeBase):
    pass


class Account(Base):
    __tablename__ = "account"
    id: Mapped[int] = mapped_column(primary_key=True)
    identifier: Mapped[str]
    account_transactions: DynamicMapped["AccountTransaction"] = relationship(
        cascade="all, delete-orphan",
        passive_deletes=True,
        order_by="AccountTransaction.timestamp",
    )


class AccountTransaction(Base):
    __tablename__ = "account_transaction"
    id: Mapped[int] = mapped_column(primary_key=True)
    account_id: Mapped[int] = mapped_column(
        ForeignKey("account.id", ondelete="cascade")
    )
    description: Mapped[str]
    amount: Mapped[Decimal]
    timestamp: Mapped[datetime] = mapped_column(default=func.now())

Das obige Mapping liefert eine Account.account_transactions Sammlung, die als Rückgabe des AppenderQuery Sammlungstyps typisiert ist, einschließlich seines Elementtyps, z. B. AppenderQuery[AccountTransaction]. Dies ermöglicht dann Iteration und Abfragen, um Objekte zu liefern, die als AccountTransaction typisiert sind.

#7123

Installation ist nun vollständig PEP-517-fähig

Die Quellcode-Distribution enthält nun eine pyproject.toml Datei, um die vollständige PEP 517 Unterstützung zu ermöglichen. Insbesondere ermöglicht dies einen lokalen Quellcode-Build mit pip, um die optionale Cython Abhängigkeit automatisch zu installieren.

#7311

C-Erweiterungen nun nach Cython portiert

Die SQLAlchemy C-Erweiterungen wurden durch komplett neue Erweiterungen in Cython ersetzt. Obwohl Cython im Jahr 2010 evaluiert wurde, als die C-Erweiterungen zum ersten Mal erstellt wurden, hat sich die Natur und der Fokus der heute verwendeten C-Erweiterungen seitdem ziemlich verändert. Gleichzeitig hat sich Cython anscheinend erheblich weiterentwickelt, ebenso wie die Python-Build-/Distributions-Toolchain, die es uns ermöglicht hat, es erneut zu berücksichtigen.

Der Umstieg auf Cython bietet dramatische neue Vorteile ohne offensichtliche Nachteile

  • Die Cython-Erweiterungen, die spezifische C-Erweiterungen ersetzen, haben sich in Benchmarks als **schneller** erwiesen, oft leicht, aber manchmal signifikant, als praktisch der gesamte C-Code, den SQLAlchemy zuvor enthielt. Obwohl dies erstaunlich erscheint, scheint es ein Produkt von nicht offensichtlichen Optimierungen innerhalb der Cython-Implementierung zu sein, die in einer direkten Python-zu-C-Portierung einer Funktion nicht vorhanden wären, was insbesondere für viele benutzerdefinierte Sammlungsarten galt, die zu den C-Erweiterungen hinzugefügt wurden.

  • Cython-Erweiterungen sind viel einfacher zu schreiben, zu warten und zu debuggen als reiner C-Code, und in den meisten Fällen sind sie zeilenweise äquivalent zum Python-Code. Es wird erwartet, dass in den kommenden Releases viele weitere Elemente von SQLAlchemy nach Cython portiert werden, was viele neue Türen für Leistungsverbesserungen öffnen sollte, die bisher unerreichbar waren.

  • Cython ist sehr ausgereift und weit verbreitet, einschließlich der Basis für einige der prominenten Datenbanktreiber, die von SQLAlchemy unterstützt werden, darunter asyncpg, psycopg3 und asyncmy.

Wie die früheren C-Erweiterungen werden die Cython-Erweiterungen in den Wheel-Distributionen von SQLAlchemy vorab erstellt, die für pip von PyPi automatisch verfügbar sind. Die manuellen Build-Anweisungen bleiben mit Ausnahme der Cython-Anforderung unverändert.

#7256

Umfangreiche architektonische, leistungsbezogene und API-Verbesserungen für die Datenbankreflexion

Das interne System, mit dem Table-Objekte und ihre Komponenten reflektiert werden, wurde komplett neuarchitektiert, um eine hochleistungsfähige Massenreflexion von Tausenden von Tabellen auf einmal für teilnehmende Dialekte zu ermöglichen. Derzeit nehmen die **PostgreSQL** und **Oracle** Dialekte an der neuen Architektur teil, wobei der PostgreSQL-Dialekt eine große Serie von Table-Objekten fast dreimal schneller reflektieren kann und der Oracle-Dialekt eine große Serie von Table-Objekten zehnmal schneller reflektieren kann.

Die Re-Architektur wirkt sich am direktesten auf Dialekte aus, die SELECT-Abfragen gegen Systemkatalogtabellen verwenden, um Tabellen zu reflektieren, und der verbleibende enthaltene Dialekt, der von diesem Ansatz profitieren kann, ist der SQL Server-Dialekt. Die MySQL/MariaDB- und SQLite-Dialekte verwenden hingegen nicht-relationale Systeme zur Reflexion von Datenbanktabellen und waren nicht von einem bestehenden Leistungsproblem betroffen.

Die neue API ist rückwärtskompatibel mit dem vorherigen System und sollte keine Änderungen an Drittanbieter-Dialekten erfordern, um die Kompatibilität zu erhalten; Drittanbieter-Dialekte können sich auch für das neue System entscheiden, indem sie Batch-Abfragen für die Schemareflexion implementieren.

Zusammen mit dieser Änderung wurden die API und das Verhalten des Inspector-Objekts verbessert und mit konsistenteren Dialekt-übergreifenden Verhaltensweisen sowie neuen Methoden und neuen Leistungsmerkmalen angereichert.

Leistungsübersicht

Die Quellcode-Distribution enthält ein Skript test/perf/many_table_reflection.py, das sowohl bestehende Reflexionsfunktionen als auch neue testet. Eine begrenzte Auswahl seiner Tests kann auf älteren Versionen von SQLAlchemy ausgeführt werden, wobei wir hier darauf zurückgreifen, um Leistungsunterschiede beim Aufruf von metadata.reflect() zur Reflexion von 250 Table-Objekten auf einmal über eine lokale Netzwerkverbindung zu veranschaulichen.

Dialect

Operation

SQLA 1.4 Zeit (Sekunden)

SQLA 2.0 Zeit (Sekunden)

postgresql+psycopg2

metadata.reflect(), 250 Tabellen

8.2

3.3

oracle+cx_oracle

metadata.reflect(), 250 Tabellen

60.4

6.8

Verhaltensänderungen für Inspector()

Für SQLAlchemy-eigene Dialekte für SQLite, PostgreSQL, MySQL/MariaDB, Oracle und SQL Server verhalten sich Inspector.has_table(), Inspector.has_sequence(), Inspector.has_index(), Inspector.get_table_names() und Inspector.get_sequence_names() nun alle konsistent in Bezug auf Caching: Sie cachen alle ihre Ergebnisse nach dem ersten Aufruf für ein bestimmtes Inspector-Objekt vollständig. Programme, die Tabellen/Sequenzen erstellen oder löschen, während sie dasselbe Inspector-Objekt aufrufen, erhalten nach Änderung des Datenbankzustands keinen aktualisierten Status. Ein Aufruf von Inspector.clear_cache() oder ein neuer Inspector sollte verwendet werden, wenn DDL-Änderungen ausgeführt werden sollen. Zuvor implementierten die Methoden Inspector.has_table() und Inspector.has_sequence() kein Caching und der Inspector unterstützte kein Caching für diese Methoden, während die Methoden Inspector.get_table_names() und Inspector.get_sequence_names() dies taten, was zu inkonsistenten Ergebnissen zwischen den beiden Methodentypen führte.

Das Verhalten für Drittanbieter-Dialekte hängt davon ab, ob sie den "reflection cache" Dekorator für die Dialekt-Level-Implementierung dieser Methoden implementieren.

Neue Methoden und Verbesserungen für Inspector()

  • eine Methode Inspector.has_schema() hinzugefügt, die zurückgibt, ob ein Schema in der Ziel-Datenbank vorhanden ist

  • eine Methode Inspector.has_index() hinzugefügt, die zurückgibt, ob eine Tabelle einen bestimmten Index hat.

  • Inspektionsmethoden wie Inspector.get_columns(), die einzeln an einer Tabelle arbeiten, sollten nun alle konsistent NoSuchTableError auslösen, wenn eine Tabelle oder Ansicht nicht gefunden wird; diese Änderung ist spezifisch für einzelne Dialekte und trifft daher möglicherweise nicht für bestehende Drittanbieter-Dialekte zu.

  • Die Behandlung von "Views" und "Materialized Views" wurde getrennt, da diese beiden Konstrukte in realen Anwendungsfällen unterschiedliche DDL für CREATE und DROP verwenden; dies beinhaltet, dass es nun separate Methoden Inspector.get_view_names() und Inspector.get_materialized_view_names() gibt.

#4379

Dialekt-Unterstützung für psycopg 3 (auch bekannt als "psycopg")

Dialekt-Unterstützung für die psycopg 3 DBAPI wurde hinzugefügt, die trotz der Nummer "3" jetzt unter dem Paketnamen psycopg firmiert und das vorherige Paket psycopg2 ablöst, das vorerst der "Standard"-Treiber von SQLAlchemy für die postgresql Dialekte bleibt. psycopg ist ein komplett überarbeiteter und modernisierter Datenbankadapter für PostgreSQL, der Konzepte wie vorbereitete Anweisungen sowie Python asyncio unterstützt.

psycopg ist der erste von SQLAlchemy unterstützte DBAPI, der sowohl eine PEP-249-synchrone API als auch einen asyncio-Treiber bereitstellt. Dieselbe psycopg-Datenbank-URL kann mit den Engine-Erstellungsfunktionen create_engine() und create_async_engine() verwendet werden, und die entsprechende synchrone oder asynchrone Version des Dialekts wird automatisch ausgewählt.

Siehe auch

psycopg

Dialekt-Unterstützung für oracledb

Dialekt-Unterstützung für den oracledb DBAPI hinzugefügt, der die umbenannte, neue Hauptversion des beliebten cx_Oracle-Treibers ist.

Siehe auch

python-oracledb

Neue bedingte DDL für Constraints und Indizes

Eine neue Methode Constraint.ddl_if() und Index.ddl_if() ermöglicht es, Konstrukte wie CheckConstraint, UniqueConstraint und Index bedingt für eine gegebene Table zu rendern, basierend auf denselben Kriterien, die von der Methode DDLElement.execute_if() akzeptiert werden. Im folgenden Beispiel werden die CHECK-Constraint und der Index nur gegen ein PostgreSQL-Backend erzeugt.

meta = MetaData()


my_table = Table(
    "my_table",
    meta,
    Column("id", Integer, primary_key=True),
    Column("num", Integer),
    Column("data", String),
    Index("my_pg_index", "data").ddl_if(dialect="postgresql"),
    CheckConstraint("num > 5").ddl_if(dialect="postgresql"),
)

e1 = create_engine("sqlite://", echo=True)
meta.create_all(e1)  # will not generate CHECK and INDEX


e2 = create_engine("postgresql://scott:tiger@localhost/test", echo=True)
meta.create_all(e2)  # will generate CHECK and INDEX

#7631

DATE, TIME, DATETIME Datentypen unterstützen nun die literale Darstellung auf allen Backends

Die literale Darstellung wurde nun für Datums- und Zeitdatentypen für die Backend-spezifische Kompilierung implementiert, einschließlich PostgreSQL und Oracle.

>>> import datetime

>>> from sqlalchemy import DATETIME
>>> from sqlalchemy import literal
>>> from sqlalchemy.dialects import oracle
>>> from sqlalchemy.dialects import postgresql

>>> date_literal = literal(datetime.datetime.now(), DATETIME)

>>> print(
...     date_literal.compile(
...         dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}
...     )
... )
'2022-12-17 11:02:13.575789'
>>> print( ... date_literal.compile( ... dialect=oracle.dialect(), compile_kwargs={"literal_binds": True} ... ) ... )
TO_TIMESTAMP('2022-12-17 11:02:13.575789', 'YYYY-MM-DD HH24:MI:SS.FF')

Zuvor funktionierte eine solche literale Darstellung nur beim Stringifizieren von Statements ohne angegebenen Dialekt; bei dem Versuch, mit einem Dialekt-spezifischen Typ zu rendern, wurde eine NotImplementedError ausgelöst, bis SQLAlchemy 1.4.45, wo dies zu einer CompileError wurde (Teil von #8800).

Die Standarddarstellung ist eine modifizierte ISO-8601-Darstellung (d. h. ISO-8601 mit dem T durch ein Leerzeichen ersetzt), wenn literal_binds mit den SQL-Kompilierern der Dialekte PostgreSQL, MySQL, MariaDB, MSSQL, Oracle verwendet wird. Für Oracle ist das ISO-Format in einen entsprechenden TO_DATE()-Funktionsaufruf eingeschlossen. Die Darstellung für SQLite ist unverändert, da dieser Dialekt immer eine Stringdarstellung für Datumswerte enthielt.

#5052

Kontextmanager-Unterstützung für Result, AsyncResult

Das Result-Objekt unterstützt nun die Verwendung als Kontextmanager, der sicherstellt, dass das Objekt und sein zugrunde liegender Cursor am Ende des Blocks geschlossen werden. Dies ist besonders nützlich bei serverseitigen Cursorn, bei denen es wichtig ist, dass das offene Cursor-Objekt am Ende einer Operation geschlossen wird, auch wenn benutzerdefinierte Ausnahmen aufgetreten sind.

with engine.connect() as conn:
    with conn.execution_options(yield_per=100).execute(
        text("select * from table")
    ) as result:
        for row in result:
            print(f"{row}")

Bei der Verwendung von asyncio wurden AsyncResult und AsyncConnection geändert, um eine optionale asynchrone Kontextmanager-Nutzung zu ermöglichen, wie z. B.:

async with async_engine.connect() as conn:
    async with conn.execution_options(yield_per=100).execute(
        text("select * from table")
    ) as result:
        for row in result:
            print(f"{row}")

#8710

Verhaltensänderungen

Dieser Abschnitt behandelt Verhaltensänderungen, die in SQLAlchemy 2.0 vorgenommen wurden und nicht anderweitig Teil des Hauptmigrationspfads von 1.4 auf 2.0 sind; Änderungen hier werden voraussichtlich keine wesentlichen Auswirkungen auf die Abwärtskompatibilität haben.

Neue Transaktionsverbindungsmodi für Session

Das Verhalten des "Einbindens einer externen Transaktion in eine Session" wurde überarbeitet und verbessert, was eine explizite Kontrolle darüber ermöglicht, wie die Session eine eingehende Connection aufnimmt, die bereits eine Transaktion und möglicherweise einen Savepoint enthält. Der neue Parameter Session.join_transaction_mode umfasst eine Reihe von Optionswerten, die die vorhandene Transaktion auf verschiedene Weise aufnehmen können, insbesondere indem sie einer Session ermöglichen, in einem vollständig transaktionalen Stil ausschließlich unter Verwendung von Savepoints zu arbeiten, während die extern initiierte Transaktion unter allen Umständen nicht committed und aktiv bleibt, was es Test-Suiten ermöglicht, alle innerhalb von Tests stattfindenden Änderungen zurückzurollen.

Die Hauptverbesserung, die dies ermöglicht, ist, dass das unter Einbinden einer Session in eine externe Transaktion (z. B. für Test-Suiten) dokumentierte Rezept, das sich ebenfalls von SQLAlchemy 1.3 zu 1.4 geändert hat, nun vereinfacht ist und keine explizite Verwendung eines Event-Handlers oder jegliche Erwähnung eines expliziten Savepoints mehr erfordert; durch die Verwendung von join_transaction_mode="create_savepoint" wird die Session niemals den Zustand einer eingehenden Transaktion beeinflussen und stattdessen einen Savepoint (d. h. eine "verschachtelte Transaktion") als ihre Wurzeltransaktion erstellen.

Das Folgende illustriert einen Teil des Beispiels unter Einbinden einer Session in eine externe Transaktion (z. B. für Test-Suiten); siehe diesen Abschnitt für ein vollständiges Beispiel.

class SomeTest(TestCase):
    def setUp(self):
        # connect to the database
        self.connection = engine.connect()

        # begin a non-ORM transaction
        self.trans = self.connection.begin()

        # bind an individual Session to the connection, selecting
        # "create_savepoint" join_transaction_mode
        self.session = Session(
            bind=self.connection, join_transaction_mode="create_savepoint"
        )

    def tearDown(self):
        self.session.close()

        # rollback non-ORM transaction
        self.trans.rollback()

        # return connection to the Engine
        self.connection.close()

Der Standardmodus für Session.join_transaction_mode ist "conditional_savepoint", der das Verhalten "create_savepoint" verwendet, wenn die gegebene Connection selbst bereits auf einem Savepoint liegt. Befindet sich die gegebene Connection in einer Transaktion, aber nicht auf einem Savepoint, wird die Session "rollback"-Aufrufe weitergeben, aber keine "commit"-Aufrufe, beginnt aber keinen neuen Savepoint von sich aus. Dieses Verhalten wird standardmäßig wegen seiner maximalen Kompatibilität mit älteren SQLAlchemy-Versionen gewählt und da es keinen neuen SAVEPOINT startet, es sei denn, der gegebene Treiber verwendet bereits SAVEPOINT, da die Unterstützung für SAVEPOINT nicht nur mit spezifischen Backends und Treibern, sondern auch konfigurativ variiert.

Das Folgende illustriert einen Fall, der in SQLAlchemy 1.3 funktionierte, in SQLAlchemy 1.4 aufhörte zu funktionieren und nun in SQLAlchemy 2.0 wiederhergestellt ist.

engine = create_engine("...")

# setup outer connection with a transaction and a SAVEPOINT
conn = engine.connect()
trans = conn.begin()
nested = conn.begin_nested()

# bind a Session to that connection and operate upon it, including
# a commit
session = Session(conn)
session.connection()
session.commit()
session.close()

# assert both SAVEPOINT and transaction remain active
assert nested.is_active
nested.rollback()
trans.rollback()

Wo oben eine Session an eine Connection gebunden ist, auf der ein Savepoint gestartet wurde; der Zustand dieser beiden Einheiten bleibt unverändert, nachdem die Session mit der Transaktion gearbeitet hat. In SQLAlchemy 1.3 funktionierte der obige Fall, weil die Session eine "Subtransaktion" auf der Connection startete, was es der äußeren Savepoint/Transaktion ermöglichte, in einfachen Fällen wie dem obigen unberührt zu bleiben. Da Subtransaktionen in 1.4 als veraltet galten und in 2.0 entfernt wurden, war dieses Verhalten nicht mehr verfügbar. Das neue Standardverhalten verbessert das Verhalten von "Subtransaktionen", indem es stattdessen einen echten, zweiten SAVEPOINT verwendet, so dass selbst Aufrufe von Session.rollback() verhindern, dass die Session aus dem extern initiierten SAVEPOINT oder der Transaktion "ausbricht".

Neuer Code, der eine gestartete Transaktion einer Connection Connection in eine Session einbindet, sollte jedoch explizit einen Session.join_transaction_mode auswählen, damit das gewünschte Verhalten explizit definiert ist.

#9015

str(engine.url) wird das Passwort standardmäßig verschleiern

Um ein Leck von Datenbankpasswörtern zu vermeiden, wird die Verwendung von str() auf einer URL standardmäßig die Passwortverschleierungsfunktion aktivieren. Zuvor war diese Verschleierung für __repr__() Aufrufe aktiv, aber nicht für __str__(). Diese Änderung wirkt sich auf Anwendungen und Test-Suiten aus, die versuchen, create_engine() aufzurufen, mit der stringifizierten URL einer anderen Engine, wie z.B.:

>>> e1 = create_engine("postgresql+psycopg2://scott:tiger@localhost/test")
>>> e2 = create_engine(str(e1.url))

Die obige Engine e2 wird nicht das korrekte Passwort haben; sie wird den verschleierten String "***" haben.

Der bevorzugte Ansatz für das obige Muster ist, das URL-Objekt direkt zu übergeben, es muss nicht stringifiziert werden.

>>> e1 = create_engine("postgresql+psycopg2://scott:tiger@localhost/test")
>>> e2 = create_engine(e1.url)

Andernfalls, für eine stringifizierte URL mit Klartext-Passwort, verwenden Sie die Methode URL.render_as_string() und übergeben Sie den Parameter URL.render_as_string.hide_password als False.

>>> e1 = create_engine("postgresql+psycopg2://scott:tiger@localhost/test")
>>> url_string = e1.url.render_as_string(hide_password=False)
>>> e2 = create_engine(url_string)

#8567

Strengere Regeln für den Austausch von Spalten in Tabellenobjekten mit gleichen Namen, Schlüsseln

Strengere Regeln gelten für das Anhängen von Column-Objekten an Table-Objekten, wobei einige frühere Deprecation-Warnungen zu Ausnahmen gemacht werden und einige frühere Szenarien verhindert werden, die dazu führen würden, dass doppelte Spalten in Tabellen erscheinen, wenn Table.extend_existing auf True gesetzt war, sowohl für die programmatische Table-Konstruktion als auch während der Reflektionsoperationen.

  • Unter keinen Umständen darf ein Table-Objekt zwei oder mehr Column-Objekte mit demselben Namen enthalten, unabhängig von ihrem .key. Ein Grenzfall, in dem dies immer noch möglich war, wurde identifiziert und behoben.

  • Das Hinzufügen einer Column zu einer Table, die denselben Namen oder Schlüssel wie eine vorhandene Column hat, löst immer eine DuplicateColumnError aus (eine neue Unterklasse von ArgumentError in 2.0.0b4), es sei denn, zusätzliche Parameter sind vorhanden; Table.append_column.replace_existing für Table.append_column() und Table.extend_existing für die Konstruktion einer gleichnamigen Table wie einer vorhandenen, mit oder ohne Reflexion. Zuvor gab es eine Deprecation-Warnung für dieses Szenario.

  • Es wird nun eine Warnung ausgegeben, wenn eine Table erstellt wird, die Table.extend_existing enthält, bei der eine eingehende Column ohne separaten Column.key eine vorhandene Column mit einem Schlüssel vollständig ersetzen würde, was darauf hindeutet, dass die Operation nicht dem vom Benutzer beabsichtigten entspricht. Dies kann insbesondere bei einem sekundären Reflektionsschritt auftreten, wie z. B. metadata.reflect(extend_existing=True). Die Warnung schlägt vor, den Parameter Table.autoload_replace auf False zu setzen, um dies zu verhindern. Zuvor, in 1.4 und früher, wurde die eingehende Spalte **zusätzlich** zur vorhandenen Spalte hinzugefügt. Dies war ein Fehler und eine Verhaltensänderung in 2.0 (ab 2.0.0b4), da der vorherige Schlüssel bei diesem Vorkommen **nicht mehr vorhanden** in der Spaltensammlung ist.

#8925

ORM Declarative wendet Spaltenreihenfolgen unterschiedlich an; Steuern Sie das Verhalten mit sort_order

Declarative hat das System geändert, nach dem zugeordnete Spalten, die aus Mixin- oder abstrakten Basisklassen stammen, zusammen mit den Spalten sortiert werden, die sich in der deklarierten Klasse selbst befinden, um Spalten aus der deklarierten Klasse zuerst und dann Mixin-Spalten zu platzieren. Die folgende Zuordnung

class Foo:
    col1 = mapped_column(Integer)
    col3 = mapped_column(Integer)


class Bar:
    col2 = mapped_column(Integer)
    col4 = mapped_column(Integer)


class Model(Base, Foo, Bar):
    id = mapped_column(Integer, primary_key=True)
    __tablename__ = "model"

Erzeugt eine CREATE TABLE wie folgt in 1.4

CREATE TABLE model (
  col1 INTEGER,
  col3 INTEGER,
  col2 INTEGER,
  col4 INTEGER,
  id INTEGER NOT NULL,
  PRIMARY KEY (id)
)

Während es in 2.0 erzeugt

CREATE TABLE model (
  id INTEGER NOT NULL,
  col1 INTEGER,
  col3 INTEGER,
  col2 INTEGER,
  col4 INTEGER,
  PRIMARY KEY (id)
)

Für den spezifischen Fall oben kann dies als Verbesserung angesehen werden, da die Primärschlüsselspalten des Model nun dort sind, wo man es typischerweise bevorzugen würde. Dies ist jedoch kein Trost für die Anwendung, die Modelle andersherum definiert hat, da

class Foo:
    id = mapped_column(Integer, primary_key=True)
    col1 = mapped_column(Integer)
    col3 = mapped_column(Integer)


class Model(Foo, Base):
    col2 = mapped_column(Integer)
    col4 = mapped_column(Integer)
    __tablename__ = "model"

Dies erzeugt nun die CREATE TABLE-Ausgabe als

CREATE TABLE model (
  col2 INTEGER,
  col4 INTEGER,
  id INTEGER NOT NULL,
  col1 INTEGER,
  col3 INTEGER,
  PRIMARY KEY (id)
)

Um dieses Problem zu lösen, führt SQLAlchemy 2.0.4 einen neuen Parameter in mapped_column() namens mapped_column.sort_order ein, der ein ganzzahliger Wert ist, der standardmäßig auf 0 gesetzt ist und auf einen positiven oder negativen Wert gesetzt werden kann, damit Spalten vor oder nach anderen Spalten platziert werden, wie im folgenden Beispiel

class Foo:
    id = mapped_column(Integer, primary_key=True, sort_order=-10)
    col1 = mapped_column(Integer, sort_order=-1)
    col3 = mapped_column(Integer)


class Model(Foo, Base):
    col2 = mapped_column(Integer)
    col4 = mapped_column(Integer)
    __tablename__ = "model"

Das obige Modell platziert „id“ vor allen anderen und „col1“ nach „id“

CREATE TABLE model (
  id INTEGER NOT NULL,
  col1 INTEGER,
  col2 INTEGER,
  col4 INTEGER,
  col3 INTEGER,
  PRIMARY KEY (id)
)

Zukünftige SQLAlchemy-Versionen können einen expliziten Ordnungsheintrag für das mapped_column-Konstrukt bereitstellen, da diese Ordnung spezifisch für ORM ist.

Das Sequence-Konstrukt kehrt zu keinem expliziten Standard-"Start"-Wert zurück; betrifft MS SQL Server

Vor SQLAlchemy 1.4 gab das Sequence-Konstrukt nur einfache CREATE SEQUENCE DDL aus, wenn keine zusätzlichen Argumente angegeben wurden

>>> # SQLAlchemy 1.3 (and 2.0)
>>> from sqlalchemy import Sequence
>>> from sqlalchemy.schema import CreateSequence
>>> print(CreateSequence(Sequence("my_seq")))
CREATE SEQUENCE my_seq

Da jedoch die Sequence-Unterstützung für MS SQL Server hinzugefügt wurde, wo der Standardstartwert ungünstigerweise auf -2**63 gesetzt ist, entschied sich Version 1.4, die DDL standardmäßig auf einen Startwert von 1 festzulegen, wenn Sequence.start nicht anderweitig angegeben wurde

>>> # SQLAlchemy 1.4 (only)
>>> from sqlalchemy import Sequence
>>> from sqlalchemy.schema import CreateSequence
>>> print(CreateSequence(Sequence("my_seq")))
CREATE SEQUENCE my_seq START WITH 1

Diese Änderung hat weitere Komplexitäten eingeführt, einschließlich der Tatsache, dass, wenn der Parameter Sequence.min_value enthalten ist, dieser Standardwert von 1 tatsächlich auf das gesetzt werden sollte, was Sequence.min_value angibt, andernfalls könnte ein min_value unter dem start_value als widersprüchlich angesehen werden. Da die Untersuchung dieses Problems zu einem ziemlichen Kaninchenbau anderer verschiedener Grenzfälle wurde, haben wir uns entschieden, diese Änderung stattdessen rückgängig zu machen und das ursprüngliche Verhalten von Sequence wiederherzustellen, das darin besteht, keine Meinung zu haben und nur CREATE SEQUENCE auszugeben, was es der Datenbank selbst erlaubt, ihre Entscheidungen darüber zu treffen, wie die verschiedenen Parameter von SEQUENCE miteinander interagieren.

Um sicherzustellen, dass der Startwert auf allen Backends 1 ist, **kann der Startwert von 1 explizit angegeben werden**, wie unten gezeigt

>>> # All SQLAlchemy versions
>>> from sqlalchemy import Sequence
>>> from sqlalchemy.schema import CreateSequence
>>> print(CreateSequence(Sequence("my_seq", start=1)))
CREATE SEQUENCE my_seq START WITH 1

Über all das hinaus sollte für die Autogenerierung von ganzzahligen Primärschlüsseln auf modernen Backends, einschließlich PostgreSQL, Oracle, SQL Server, das Identity-Konstrukt bevorzugt werden, das in 1.4 und 2.0 ebenfalls auf die gleiche Weise funktioniert, ohne Verhaltensänderungen.

#7211

„with_variant()“ klont das ursprüngliche TypeEngine, anstatt den Typ zu ändern

Die Methode TypeEngine.with_variant(), die verwendet wird, um alternative datenbankspezifische Verhaltensweisen auf einen bestimmten Typ anzuwenden, gibt nun eine Kopie des ursprünglichen TypeEngine-Objekts zurück, wobei die Variantinformationen intern gespeichert werden, anstatt sie in die Variant-Klasse zu wickeln.

Während der vorherige Variant-Ansatz alle In-Python-Verhaltensweisen des ursprünglichen Typs mithilfe dynamischer Attribut-Getter beibehalten konnte, besteht die Verbesserung darin, dass bei Aufruf einer Variante der zurückgegebene Typ eine Instanz des ursprünglichen Typs bleibt, was reibungsloser mit Typenprüfern wie mypy und pylance funktioniert. Angenommen ein Programm wie das unten

import typing

from sqlalchemy import String
from sqlalchemy.dialects.mysql import VARCHAR

type_ = String(255).with_variant(VARCHAR(255, charset="utf8mb4"), "mysql", "mariadb")

if typing.TYPE_CHECKING:
    reveal_type(type_)

Ein Typenprüfer wie pyright wird den Typ nun melden als

info: Type of "type_" is "String"

Darüber hinaus können, wie oben gezeigt, mehrere Dialektnamen für einen einzelnen Typ übergeben werden, insbesondere ist dies für das Paar "mysql" und "mariadb"-Dialekte hilfreich, die ab SQLAlchemy 1.4 separat betrachtet werden.

#6980

Der Python-Divisionsoperator führt für alle Backends eine echte Division durch; Ganzzahlige Division hinzugefügt

Die Core-Ausdruckssprache unterstützt nun sowohl „echte Division“ (d. h. den Python-Operator /) als auch „Ganzzahlige Division“ (d. h. den Python-Operator //), einschließlich Backend-spezifischer Verhaltensweisen, um verschiedene Datenbanken in dieser Hinsicht zu normalisieren.

Bei einer „echten Divisionsoperation“ gegen zwei Ganzzahlwerte

expr = literal(5, Integer) / literal(10, Integer)

Der SQL-Divisionsoperator auf PostgreSQL zum Beispiel fungiert normalerweise als „Ganzzahlige Division“, wenn er gegen Ganzzahlen verwendet wird, was bedeutet, dass das obige Ergebnis die Ganzzahl „0“ zurückgeben würde. Für diese und ähnliche Backends rendert SQLAlchemy nun die SQL mit einer Form, die äquivalent ist zu

%(param_1)s / CAST(%(param_2)s AS NUMERIC)

Mit param_1=5, param_2=10, so dass der Rückgabewert vom Typ NUMERIC, typischerweise als Python-Wert decimal.Decimal("0.5") ist.

Bei einer „Ganzzahlige Divisionsoperation“ gegen zwei Ganzzahlwerte

expr = literal(5, Integer) // literal(10, Integer)

Der SQL-Divisionsoperator auf MySQL und Oracle zum Beispiel fungiert normalerweise als „echte Division“, wenn er gegen Ganzzahlen verwendet wird, was bedeutet, dass das obige Ergebnis den Gleitkommawert „0,5“ zurückgeben würde. Für diese und ähnliche Backends rendert SQLAlchemy nun die SQL mit einer Form, die äquivalent ist zu

FLOOR(%(param_1)s / %(param_2)s)

Mit param_1=5, param_2=10, so dass der Rückgabewert vom Typ INTEGER ist, als Python-Wert 0.

Die abwärtsinkompatible Änderung hier wäre, wenn eine Anwendung, die PostgreSQL, SQL Server oder SQLite verwendet und sich auf den Python „truediv“-Operator verlassen hat, um in allen Fällen einen Ganzzahlwert zurückzugeben. Anwendungen, die sich auf dieses Verhalten verlassen, sollten stattdessen den Python „Ganzzahlige Division“-Operator // für diese Operationen verwenden, oder für zukunftsfähige Kompatibilität bei Verwendung einer früheren SQLAlchemy-Version die floor-Funktion

expr = func.floor(literal(5, Integer) / literal(10, Integer))

Die obige Form wäre auf jeder SQLAlchemy-Version vor 2.0 erforderlich, um eine Backend-agnostische Ganzzahlige Division zu ermöglichen.

#4926

Session löst proaktiv aus, wenn illegale gleichzeitige oder reentrant Zugriffe erkannt werden

Die Session kann nun mehr Fehler im Zusammenhang mit illegalen gleichzeitigen Zustandsänderungen in Multithreading- oder anderen gleichzeitigen Szenarien sowie für Ereignishooks, die unerwartete Zustandsänderungen durchführen, abfangen.

Ein Fehler, der bekanntlich auftritt, wenn eine Session gleichzeitig in mehreren Threads verwendet wird, ist AttributeError: 'NoneType' object has no attribute 'twophase', was völlig kryptisch ist. Dieser Fehler tritt auf, wenn ein Thread Session.commit() aufruft, was intern die Methode SessionTransaction.close() aufruft, um den Transaktionskontext zu beenden, während ein anderer Thread gerade eine Abfrage ausführt, z. B. von Session.execute(). Innerhalb von Session.execute() beginnt die interne Methode, die eine Datenbankverbindung für die aktuelle Transaktion erwirbt, zunächst damit, zu bestätigen, dass die Sitzung „aktiv“ ist. Nachdem diese Bestätigung erfolgreich war, stört der gleichzeitige Aufruf von Session.close() diesen Zustand, was zu dem oben genannten undefinierten Zustand führt.

Die Änderung wendet Schutzmaßnahmen auf alle zustandsverändernden Methoden rund um das Objekt SessionTransaction an, sodass in dem oben genannten Fall die Methode Session.commit() stattdessen fehlschlägt, da sie versucht, den Zustand in einen zu ändern, der für die Dauer der bereits laufenden Methode, die die aktuelle Verbindung abrufen möchte, um eine Datenbankabfrage auszuführen, nicht zulässig ist.

Unter Verwendung des Testskripts, das in #7433 dargestellt ist, sieht der vorherige Fehlerfall wie folgt aus

Traceback (most recent call last):
File "/home/classic/dev/sqlalchemy/test3.py", line 30, in worker
    sess.execute(select(A)).all()
File "/home/classic/tmp/sqlalchemy/lib/sqlalchemy/orm/session.py", line 1691, in execute
    conn = self._connection_for_bind(bind)
File "/home/classic/tmp/sqlalchemy/lib/sqlalchemy/orm/session.py", line 1532, in _connection_for_bind
    return self._transaction._connection_for_bind(
File "/home/classic/tmp/sqlalchemy/lib/sqlalchemy/orm/session.py", line 754, in _connection_for_bind
    if self.session.twophase and self._parent is None:
AttributeError: 'NoneType' object has no attribute 'twophase'

Wo die Methode _connection_for_bind() nicht fortfahren kann, da gleichzeitiger Zugriff sie in einen ungültigen Zustand gebracht hat. Mit dem neuen Ansatz löst der Verursacher der Zustandsänderung stattdessen den Fehler aus

File "/home/classic/dev/sqlalchemy/lib/sqlalchemy/orm/session.py", line 1785, in close
   self._close_impl(invalidate=False)
File "/home/classic/dev/sqlalchemy/lib/sqlalchemy/orm/session.py", line 1827, in _close_impl
   transaction.close(invalidate)
File "<string>", line 2, in close
File "/home/classic/dev/sqlalchemy/lib/sqlalchemy/orm/session.py", line 506, in _go
   raise sa_exc.InvalidRequestError(
sqlalchemy.exc.InvalidRequestError: Method 'close()' can't be called here;
method '_connection_for_bind()' is already in progress and this would cause
an unexpected state change to symbol('CLOSED')

Die Zustandsübergangsprüfungen verwenden absichtlich keine expliziten Sperren, um gleichzeitige Thread-Aktivitäten zu erkennen, sondern verlassen sich stattdessen auf einfache Attribut-Set-/Wert-Testoperationen, die fehlschlagen, wenn unerwartete gleichzeitige Änderungen auftreten. Die Begründung ist, dass der Ansatz illegale Zustandsänderungen erkennen kann, die vollständig innerhalb eines einzigen Threads auftreten, wie z. B. ein Ereignis-Handler, der bei Sitzungstransaktionsereignissen aufgerufen wird und eine nicht erwartete zustandsverändernde Methode aufruft, oder unter asyncio, wenn eine bestimmte Session zwischen mehreren asyncio-Tasks geteilt wird, sowie bei Verwendung von Patching-artigen Gleichzeitigkeitsansätzen wie gevent.

#7433

Der SQLite-Dialekt verwendet QueuePool für dateibasierte Datenbanken

Der SQLite-Dialekt verwendet nun standardmäßig QueuePool, wenn eine dateibasierte Datenbank verwendet wird. Dies wird zusammen mit der Einstellung des Parameters check_same_thread auf False vorgenommen. Es wurde beobachtet, dass der vorherige Ansatz, der standardmäßig auf NullPool setzte, der keine Datenbankverbindungen nach deren Freigabe hält, tatsächlich einen messbar negativen Leistungseffekt hatte. Wie immer ist die Poolklasse über den Parameter create_engine.poolclass anpassbar.

Geändert in Version 2.0.38: - Eine äquivalente Änderung wird auch für den aiosqlite-Dialekt vorgenommen, wobei AsyncAdaptedQueuePool anstelle von NullPool verwendet wird. Der aiosqlite-Dialekt war nicht in der ursprünglichen Fehleränderung enthalten.

#7490

Neuer Oracle FLOAT-Typ mit binärer Präzision; Dezimalpräzision wird nicht direkt akzeptiert

Ein neuer Datentyp FLOAT wurde dem Oracle-Dialekt hinzugefügt, um die Ergänzung von Double und datenbankspezifischen DOUBLE, DOUBLE_PRECISION und REAL Datentypen zu ergänzen. Oracles FLOAT akzeptiert einen sogenannten „binären Präzisions“-Parameter, der laut Oracle-Dokumentation ungefähr einem Standardwert für „Präzision“ geteilt durch 0,3103 entspricht

from sqlalchemy.dialects import oracle

Table("some_table", metadata, Column("value", oracle.FLOAT(126)))

Ein binärer Präzisionswert von 126 ist gleichbedeutend mit der Verwendung des Datentyps DOUBLE_PRECISION, und ein Wert von 63 entspricht der Verwendung des Datentyps REAL. Andere Präzisionswerte sind spezifisch für den FLOAT-Typ selbst.

Der SQLAlchemy Float-Datentyp akzeptiert ebenfalls einen „Präzisions“-Parameter, dies ist jedoch die Dezimalpräzision, die von Oracle nicht akzeptiert wird. Anstatt zu versuchen, die Konvertierung zu erraten, wird der Oracle-Dialekt nun eine informative Fehlermeldung ausgeben, wenn Float mit einem Präzisionswert gegen das Oracle-Backend verwendet wird. Um einen Float-Datentyp mit explizitem Präzisionswert für unterstützte Backends anzugeben und gleichzeitig andere Backends zu unterstützen, verwenden Sie die Methode TypeEngine.with_variant() wie folgt

from sqlalchemy.types import Float
from sqlalchemy.dialects import oracle

Table(
    "some_table",
    metadata,
    Column("value", Float(5).with_variant(oracle.FLOAT(16), "oracle")),
)

Neue RANGE / MULTIRANGE-Unterstützung und Änderungen für PostgreSQL-Backends

RANGE / MULTIRANGE-Unterstützung wurde vollständig für die Dialekte psycopg2, psycopg3 und asyncpg implementiert. Die neue Unterstützung verwendet ein neues SQLAlchemy-spezifisches Range-Objekt, das agnostisch gegenüber den verschiedenen Backends ist und keine backend-spezifischen Importe oder Erweiterungsschritte erfordert. Für die Multirange-Unterstützung werden Listen von Range-Objekten verwendet.

Code, der die vorherigen psycopg2-spezifischen Typen verwendete, sollte angepasst werden, um Range zu verwenden, das eine kompatible Schnittstelle bietet.

Das Range-Objekt bietet auch Vergleichsunterstützung, die der von PostgreSQL entspricht. Bisher implementiert sind die Methoden Range.contains() und Range.contained_by(), die auf die gleiche Weise funktionieren wie die PostgreSQL-Operatoren @> und <@. Zusätzliche Operatorunterstützung kann in zukünftigen Versionen hinzugefügt werden.

Siehe die Dokumentation unter Range und Multirange Typen für Hintergrundinformationen zur Verwendung der neuen Funktion.

#7156 #8706

Der match()-Operator auf PostgreSQL verwendet plainto_tsquery() anstelle von to_tsquery()

Die Funktion Operators.match() rendert nun col @@ plainto_tsquery(expr) auf dem PostgreSQL-Backend anstelle von col @@ to_tsquery(). plainto_tsquery() akzeptiert reinen Text, während to_tsquery() spezialisierte Abfragesymbole akzeptiert und daher weniger mit anderen Backends kompatibel ist.

Alle PostgreSQL-Suchfunktionen und Operatoren sind über die Verwendung von func zur Generierung von PostgreSQL-spezifischen Funktionen und Operators.bool_op() (eine boolesche Version von Operators.op()) zur Generierung beliebiger Operatoren verfügbar, auf die gleiche Weise, wie sie in früheren Versionen verfügbar waren. Siehe die Beispiele unter Volltextsuche.

Bestehende SQLAlchemy-Projekte, die PG-spezifische Direktiven innerhalb von Operators.match() verwenden, sollten stattdessen direkt func.to_tsquery() verwenden. Um SQL in genau derselben Form wie in 1.4 zu rendern, siehe den Versionshinweis unter Einfaches Plain-Text-Matching mit match().

#7086