Rustango docs
← Anleitungen

ORM-Kochbuch

Muster für das Rustango-ORM jenseits der Grundlagen. Wenn du schon einmal mit einem ORM gearbeitet hast, werden dir die Formen hier vertraut vorkommen. Die meisten Beispiele setzen voraus, dass du bereits ein Post-Model aus Getting Started hast.

Typgeprüfte ORM-Abfragen: verkettete Filter, Sortierung, Limits und Aggregation — alles ohne rohes SQL

Quelle: rustango::sql (QuerySet, das Q!-Makro / der Qb-Builder) und die #[derive(Model)]-Query-API — immer kompiliert; wähle ein Backend-Feature (postgres / mysql / sqlite).

Lauffähige Version: die Muster hier laufen im getesteten orm_cookbook-Beispiel.

Neu bei einem Begriff hier? Das Glossar definiert model, queryset, pool und migration in einfacher Sprache.

Ein paar Rust-Begriffe tauchen durchgehend auf. &pool ist eine geteilte Referenz auf einen Datenbank-Verbindungspool — beachte, dass es zwei gibt und diese Seite beide verwendet. rustango::sql::Pool ist das Mehr-Backend-Enum, das fetch, count und die _pool-Writer nehmen. sqlx::PgPool ist der treiberspezifische Postgres-Pool, den die _on-Familie und die Transaktionsbeispiele nehmen. sql::Pool hat kein begin(); seine Transaktions-Einstiegspunkte sind transaction_pool und atomic. du übergibst sie an die Methoden, die tatsächlich SQL ausführen. .await führt einen asynchronen Aufruf aus und wartet auf das Ergebnis. Option<T> ist ein Wert, der vorhanden (Some) oder abwesend (None) sein kann — Rusts Null. Result ist Erfolg-oder-Fehler; das nachgestellte ? an einem Aufruf kehrt bei einem Fehler früh zurück. Auto<i64> ist ein automatisch hochzählender Primärschlüssel, der entweder Set (aus der DB geladen) oder Unset (noch nicht eingefügt) ist.

Neuere Ergänzungen

Jüngste Releases haben eine Reihe von Features hinzugefügt, die noch nicht in jeden Abschnitt weiter unten eingearbeitet sind. Kurze Hinweise:

  • Q!-Makro + Qb-Laufzeit-Builder (#269, #263) — kompilierzeitsichere Filter. User::objects().where_(Q!(User.email__icontains = "alice")) lässt sich bei einem falsch geschriebenen Feldnamen nicht bauen. Laufzeit-komponierbare Variante für Admin-Filter-Chips: let q = Qb::eq("active", true) & Qb::gt("age", 18i64);.
  • .distinct_on(&["author_id"]) (#264) — PG-nativ; portabler Fallback per Fensterfunktion auf MySQL / SQLite. Muster nach dem Schema "Neuestes pro Gruppe".
  • bulk_upsert_pool(rows, unique_fields, update_fields, &pool) (#267) — viele Zeilen in einem Statement einfügen oder bei Konflikt aktualisieren. Tri-dialektisches ON CONFLICT / ON DUPLICATE KEY UPDATE.
  • explain_pool() (#272) — tri-dialektisches EXPLAIN. PG EXPLAIN (FORMAT JSON, ANALYZE, BUFFERS) / MySQL EXPLAIN ANALYZE / SQLite EXPLAIN QUERY PLAN.
  • DB-Funktionsbibliothek (#266) — Cast, LPad, RPad, MD5, SHA1, SHA256, Position, Repeat, Reverse, Sign, Mod, Power, Sqrt. Emission pro Dialekt mit klaren Fehlern dort, wo SQLite die Funktion nicht hat.
  • Feldtypen — rust_decimal::Decimal (PG/MySQL-nativ, SQLite über einen Decode-Shim), chrono::NaiveTime, Vec<u8> (FieldType::Binary) werden jetzt von #[derive(Model)] akzeptiert (#524, v0.42).
  • ModelForm::prepare_save() / PreparedSave (#375, v0.42) — validieren, ohne sofort zu schreiben. Jetzt validieren, das vorbereitete Schreib-Set mutieren, committen, wenn bereit.
  • #[rustango(unique_when(columns = "...", condition = "..."))] (#265) — partielle Unique-Constraints. "Eindeutige E-Mail pro nicht-gelöschter Zeile" / "Eindeutiger Slug pro Mandant".
  • #[rustango(manager(ext = "FooManagerExt"))] (#271) — Erweiterungs-Trait für benutzerdefinierte Manager, emittiert neben dem Model. (Damit lassen sich auch mehrere "Persönlichkeiten" auf derselben physischen Tabelle abbilden, über per-Trait-Methoden. Siehe inheritance.rs:98-127.)
  • manage makemigrations --merge (#346, v0.42) — Merge-Knoten für divergente Branch-Ketten. Siehe docs/manage.md.

Das CHANGELOG führt den vollständigen Ticket-Index für jedes Release.

Inhaltsverzeichnis


Abfragen

Zeilen aus der Datenbank lesen. Post::objects() startet eine Abfrage; du verkettest Filter und Sortierung und rufst dann .fetch(&pool).await? auf, um sie auszuführen und ein Vec<Post> zurückzubekommen. .where_(...) fügt eine per-AND verknüpfte Bedingung hinzu.

use rustango::core::Column as _;
use rustango::core::{Op, SqlValue, WhereExpr};   // for filter_op / where_raw below
use rustango::sql::FetcherPool as _;

// Simplest — fetch all
let posts = Post::objects().fetch(&pool).await?;

// Single equality filter
let drafts = Post::objects()
    .where_(Post::status.eq("draft"))
    .fetch(&pool).await?;

// Chained filters (AND)
let recent_drafts = Post::objects()
    .where_(Post::status.eq("draft"))
    .where_(Post::author_id.eq(42))
    .where_(Post::deleted_at.is_null())
    .order_by(&[("created_at", true)])        // true = DESC
    .limit(20)
    .fetch(&pool).await?;

// String-keyed filter (validated at compile of the queryset)
let by_id = Post::objects()
    .filter_op("id", Op::Eq, SqlValue::I64(42))
    .fetch(&pool).await?;

// OR / nested
let qs = Post::objects().where_raw(WhereExpr::Or(vec![
    Post::status.eq("draft").into(),
    Post::status.eq("review").into(),
]));

// XOR. Matches rows where an odd number
// of operands evaluate to true (binary case = "exactly one is true").
// Issue #27.
let either_but_not_both = Post::objects()
    .where_(Post::status.eq("draft").xor(Post::author_id.eq(42)))
    .fetch(&pool).await?;
// Tri-dialect emission: native logical XOR exists on MySQL but not PG
// or SQLite, so the writer emits a portable rewrite uniformly —
// `(a AND NOT b) OR (NOT a AND b)` for the binary form, or a
// CASE-WHEN-1/0 tally `% 2 = 1` for N-ary chains.

Vergleichsfilter

Die alltäglichen Filtermethoden, eine pro SQL-Operator. Das sind die Feld-Lookups (__gt, __in, __icontains und so weiter) in typisierter Form.

Post::objects().where_(Post::view_count.gt(100)).fetch(&pool).await?;
Post::objects().where_(Post::view_count.gte(100)).fetch(&pool).await?;
Post::objects().where_(Post::view_count.lt(100)).fetch(&pool).await?;
Post::objects().where_(Post::view_count.lte(100)).fetch(&pool).await?;
Post::objects().where_(Post::status.ne("archived")).fetch(&pool).await?;
Post::objects().where_(Post::id.is_in([1, 2, 3])).fetch(&pool).await?;
Post::objects().where_(Post::status.not_in(["draft", "deleted"])).fetch(&pool).await?;
Post::objects().where_(Post::title.like("%draft%")).fetch(&pool).await?;          // case-sensitive contains
Post::objects().where_(Post::title.ilike("%draft%")).fetch(&pool).await?;         // case-insensitive contains
Post::objects().where_(Post::title.ilike("Hello%")).fetch(&pool).await?;          // case-insensitive starts-with
Post::objects().where_(Post::deleted_at.is_null()).fetch(&pool).await?;
Post::objects().where_(Post::published_at.between(start, end)).fetch(&pool).await?;

Ergebnisse sortieren

Sortiere Zeilen nach einer oder mehreren Spalten, nach einem Ausdruck oder mit expliziter Kontrolle darüber, wo NULLs landen. Über das grundlegende .order_by(&[("col", desc)]) hinaus bekommst du drei zusätzliche Dimensionen:

use rustango::core::funcs::lower;
use rustango::core::{F, NullsOrder};

// 1. Plain field + ASC/DESC (back-compat — implicit NULLS handling
//    differs between dialects; see the dialect note below).
Post::objects()
    .order_by(&[("published_at", true), ("id", false)])
    .fetch(&pool).await?;

// 2. Explicit NULLS FIRST/LAST control — portable across PG, MySQL,
//    and SQLite. MySQL has no native `NULLS …` keyword; the writer
//    emulates with an `<col> IS NULL` pre-sort term so the on-wire
//    ordering matches PG/SQLite.
Post::objects()
    .order_by_with_nulls(&[("score", true, NullsOrder::Last)])
    .fetch(&pool).await?;

// 3. Arbitrary Expr in the ORDER BY position — case-insensitive
//    title sort via `LOWER(title)`, computed sort keys via
//    `case() / when() / value()`, arithmetic via `F("a") + F("b")`.
Post::objects()
    .order_by_expr(lower(F("title")), false)
    .order_by_expr_with_nulls(F("score") + 1_i64, true, NullsOrder::Last)
    .fetch(&pool).await?;

NULLS-Behandlung pro Dialekt (kein explizites NullsOrder gesetzt):

DialektASC-StandardDESC-Standard
PostgreSQLNULLS LASTNULLS FIRST
SQLiteNULLS LASTNULLS FIRST
MySQLNULLs zuerst (Semantik des kleinsten Werts)NULLs zuletzt

Verwende .order_by_with_nulls(...) / .order_by_expr_with_nulls(...), um die Platzierung festzunageln; andernfalls gilt der native Standard der Datenbank. Auf MySQL emittiert der Writer <col> IS NULL <asc|desc> vor der eigentlichen Sortierung, um das nachzubilden; das emittierte SQL hat zwei ORDER-BY-Terme pro festgenagelter Spalte, aber die Semantik entspricht PG/SQLite.

Kettenkomposition. .order_by(...), .order_by_with_nulls(...) und .order_by_expr(...) sammeln sich in Registrierungsreihenfolge zu einer einheitlichen Liste. .replace_order_by(&[...]) löscht jeden vorherigen Order-by-Aufruf. .flip_order_by() invertiert jede Richtung UND tauscht NullsOrder::First ↔ NullsOrder::Last, sodass die Semantik "NULLs am selben Ende" eine Invertierung übersteht (für explizites First / Last; das Dialekt-Standardverhalten unter Default folgt weiterhin der Richtung).

Zufällige Sortierung

Gib Zeilen in zufälliger Reihenfolge zurück. Verwende .order_random(). Es emittiert ORDER BY RANDOM() auf PG und SQLite, ORDER BY RAND() auf MySQL. Praktisch für Banner-Rotation, Sampling oder A/B-Test-Bucket-Zuweisung, ohne Zeilen in die App zu ziehen, um sie zu mischen.

// Three random posts.
Post::objects()
    .order_random()
    .limit(3)
    .fetch(&pool).await?;

// Random tie-breaker after a primary sort: posts ordered by score
// descending, with ties shuffled.
Post::objects()
    .order_by(&[("score", true)])
    .order_random()
    .fetch(&pool).await?;

Die IR-Variante trägt keine Richtungs- oder NULLS-Klausel: zufällige Sortierung ist per Definition ungeordnet, und der Zufallsschlüssel wird pro Zeile berechnet (non-NULL).

Performance-Vorbehalt. ORDER BY RANDOM() erzwingt einen vollständigen Tabellenscan + In-Memory-Sortierung nach einem Zufallsschlüssel pro Zeile. Der Query-Planer kann keinen Index nutzen. Für Tabellen, die deutlich größer als der Speicher sind, bevorzuge das indexfreundliche Muster:

// Coin-flip offset; range-scans the PK index.
let max_id: i64 = Post::objects().max::<i64>("id", &pool).await?.unwrap_or(0);
let offset = rand::random::<u32>() as i64 % max_id.max(1);
Post::objects()
    .where_(Post::id.gte(offset))
    .order_by(&[("id", false)])
    .limit(1)
    .fetch(&pool).await?;

Der Kompromiss: die Nachbarschaft in den Ergebniszeilen spiegelt die PK-Nachbarschaft wider, ist also nicht "gleichverteilt zufällig" im strengen Sinn — dafür entgeht sie den Kosten des vollständigen Tabellenscans.

Paginierung

Hol dir jeweils eine Ergebnisseite auf einmal. .limit(size).offset(...) ist die einfache Seitennummern-Form; die Cursor-Form ("alles nach der letzten ID, die ich gesehen habe") skaliert bei großen Tabellen besser.

// Page-number — page 2 of 50-row pages = LIMIT 50 OFFSET 50.
let page = Post::objects().limit(50).offset(50).fetch(&pool).await?;

// Cursor (manual — no auto-next-token from QuerySet)
let next = Post::objects()
    .where_(Post::id.gt(last_id))
    .order_by(&[("id", false)])
    .limit(50)
    .fetch(&pool).await?;

Für Cursor-Paginierung auf HTTP-Seite verwende stattdessen ViewSet::cursor_pagination("id").

Zeilen in eine Map laden

Schlag viele Zeilen anhand einer Werteliste nach und bekomme sie als HashMap zurück, geschlüsselt nach dieser Spalte. Verwende .in_bulk(...) für "hol diese N Zeilen in einem Roundtrip, indiziert nach ID". Eine HashMap<K, V> ist Rusts Dictionary/Hash-Tabelle.

use std::collections::HashMap;
use rustango::sql::Auto;

// Default shape: keyed by the Auto<i64> PK.
let books: HashMap<i64, Book> = Book::objects()
    .in_bulk(Book::id, [1_i64, 2, 3], |b| match b.id {
        Auto::Set(v) => v,
        Auto::Unset  => unreachable!("fetched row has Auto::Set PK"),
    }, &pool)
    .await?;
assert_eq!(books[&1].title, "The Rust Programming Language");

// `field_name=` equivalent — key by any unique column.
let by_isbn: HashMap<String, Book> = Book::objects()
    .in_bulk(Book::isbn, ["isbn-1".to_string()], |b| b.isbn.clone(), &pool)
    .await?;

Komponiert mit vorherigen .where_()-Filtern — die IN-Liste wird per AND mit dem bestehenden WHERE verknüpft. Ein leeres ids schließt kurz mit einer leeren Map (es wird kein SQL abgesetzt). Der Closure behandelt das Auto<T> / ForeignKey<T, K>-Auspacken explizit und gibt den Aufrufern Kontrolle darüber, wie der Schlüssel materialisiert wird.

Mandantengebundenes Geschwister: in_bulk_on(column, ids, extract, &executor) nimmt jeden sqlx-Executor — kombiniere es mit tenant.conn() für Schema-Modus-Mandanten.

Zeilen zum Aktualisieren sperren

Sperre die Zeilen, die du auswählst, sodass keine andere Transaktion sie ändern kann, bis du committest — der Standardweg, um Arbeit zu beanspruchen oder verlorene Updates zu verhindern. Rufe .select_for_update() auf; es hängt SELECT … FOR UPDATE (oder eine Variante) an, und die Sperre dauert für die umgebende Transaktion.

// Canonical "claim next available row" pattern. Worker A grabs the
// lowest-priority pending job; concurrent worker B with SKIP LOCKED
// skips A's row and grabs the next instead — no blocking.
let mut tx = pool.begin().await?;
let claim: Vec<Job> = Job::objects()
    .where_(Job::status.eq("pending"))
    .order_by(&[("priority", false)])
    .limit(1)
    .select_for_update()
    .skip_locked()
    .fetch_on(&mut *tx).await?;
// ... mark claim[0] as in-progress, do work ...
tx.commit().await?;

Builder-Methoden — verkette sie, um sie zu aktivieren:

  • .select_for_update() — schlichtes FOR UPDATE.
  • .skip_locked() — hängt SKIP LOCKED an; Zeilen, die von einer anderen Transaktion gehalten werden, werden stillschweigend herausgefiltert, statt zu blockieren.
  • .nowait() — hängt NOWAIT an; liefert sofort einen Treiber-Fehler, wenn irgendeine passende Zeile gesperrt ist. Schließt sich gegenseitig mit skip_locked aus (der Writer wählt das permissivere SKIP LOCKED, wenn beide gesetzt sind).
  • .no_key() — emittiert stattdessen FOR NO KEY UPDATE (PG 9.3+). Schwächere Sperre, die Schreiber nicht blockiert, die nur Nicht-Schlüsselspalten anfassen.
  • .of(&["table_or_alias", …]) — beschränke die Sperre auf bestimmte Tabellen, wenn die Abfrage JOINt.

.skip_locked() / .nowait() / .no_key() / .of(…) ohne ein vorheriges .select_for_update() aufzurufen, aktiviert die Sperre implizit — der Aufruf bleibt so kurz wie möglich.

Tri-dialektisches Verhalten:

DialektVerhalten
PostgreSQLVolle Unterstützung — jedes Flag emittiert seine native Syntax.
MySQL 8.0.1+Unterstützt alles außer NO KEY — dieses Flag fällt auf schlichtes FOR UPDATE zurück (die strengere Sperre).
SQLiteKeine Syntax für Sperren auf Zeilenebene. Der Writer emittiert überhaupt keine Klausel; Transaktionen halten eine implizite Schreibsperre für die gesamte Datenbank. Verwende für SQLite eine andere Strategie (typischerweise eine Busy-Wait-Schleife auf der Transaktion selbst).

Muss innerhalb einer Transaktion laufen. FOR UPDATE außerhalb einer Transaktion ist auf PostgreSQL eine No-op (die implizite Ein-Statement-Transaktion gibt die Sperre sofort frei) und auf MySQL ein Fehler. Auf Postgres kombiniere es mit pool.begin() (einem sqlx::PgPool); für eine backend-agnostische Transaktion nimm rustango::sql::atomic(&pool, …) oder transaction_pool(&pool), die dir ein PoolTx zum matchen liefern.

Abfragen kombinieren (Vereinigung, Schnitt, Differenz)

Führe zwei oder mehr Abfragen über dasselbe Model mit SQL-Mengenoperatoren zusammen: .union(), .intersection() und .difference().

// Posts that are EITHER drafts OR currently in review.
let inbox: Vec<Post> = Post::objects()
    .where_(Post::status.eq("draft"))
    .union(Post::objects().where_(Post::status.eq("review")))
    .order_by(&[("created_at", true)])
    .limit(50)
    .fetch(&pool).await?;

Builder-Methoden:

MethodeSQLSemantik
.union(other)UNIONKombinieren + deduplizieren
.union_all(other)UNION ALLKombinieren, Duplikate behalten (günstiger, kein DISTINCT-Durchlauf)
.intersection(other)INTERSECTZeilen in BEIDEN Querysets
.difference(other)EXCEPTZeilen im ersten Queryset, aber NICHT in den anderen

Jede Methode nimmt QuerySet<T> — beide Zweige müssen auf dasselbe Model T zielen, sodass die Spaltenform per Konstruktion passt (zur Kompilierzeit durch Rusts Generics geprüft). Aufrufe sammeln sich an; das Mischen von Operatoren in einer Kette ist erlaubt (a.union(b).intersection(c) wertet gemäß SQL-Standard von links nach rechts aus).

Äußere Modifikatoren gelten für das zusammengeführte Ergebnis:

// Outer .order_by() / .limit() / .offset() / .select_for_update()
// set AFTER the union apply to the combined resultset, NOT per-branch.
let page: Vec<Post> = qs_a
    .union(qs_b)
    .union(qs_c)
    .order_by(&[("id", false)])    // sorts the merged rows
    .limit(20)                     // caps the merged count
    .offset(40)                    // skips into the merged result
    .fetch(&pool).await?;

// Per-branch ORDER BY / LIMIT stay INSIDE the branch's parens:
let mixed = qs_a
    .union(qs_b.order_by(&[("id", true)]).limit(5))   // branch picks its top 5
    .fetch(&pool).await?;

Tri-dialektisch: PostgreSQL + SQLite unterstützen alle vier Operatoren auf jeder Version, die Rustango unterstützt. MySQL 8.0+ unterstützt UNION/UNION ALL; INTERSECT/EXCEPT kamen in MySQL 8.0.31 dazu. Ältere MySQL-Versionen liefern den Syntaxfehler des Treibers zur Fetch-Zeit — es gibt kein clientseitiges Gate.

Fehlerpfad auf dem typisierten Builder: .union(other_qs) (und .intersection() / .difference()) kompiliert den Zweig sofort und panickt, wenn der Zweig sich nicht kompilieren lässt (falsch geschriebene Spalte etc.). Für fehlbare Komposition, bei der der Aufrufer ein Result will, kompiliere den Zweig zuerst und übergib ihn per .with_compound(SetOp::Union, branch) — ein generischer Einstiegspunkt deckt jeden Operator ab. Die Panic-Form ist Absicht: ein fehlerhafter Zweig ist ein Programmierfehler, keine Laufzeit-Datenbedingung.

Große Ergebnismengen streamen

Verarbeite eine riesige Tabelle, ohne sie komplett in den Speicher zu laden. Rufe .iterator(chunk_size) auf; es holt chunk_size Zeilen auf einmal (per LIMIT N OFFSET M) und puffert nie die gesamte Ergebnismenge. Greif danach bei Millionen-Zeilen-Exporten, ETL-Pipelines und Batch-Jobs.

// 1. Whole-chunk loop — process N rows at a time.
let mut iter = Post::objects()
    .where_(Post::published.eq(true))
    .order_by(&[("id", false)])
    .iterator(2_000)?;
while let Some(chunk) = iter.next_chunk(&pool).await? {
    for post in chunk { /* … */ }
}

// 2. Row-by-row loop — buffer one chunk internally, yield one row.
let mut iter = Post::objects().order_by(&[("id", false)]).iterator(2_000)?;
while let Some(post) = iter.next_row(&pool).await? {
    /* … */
}

Setze ein order_by. OFFSET gegen eine Abfrage ohne stabile Sortierung liefert über Chunks hinweg unvorhersehbare Zeilen — typischerweise .order_by(&[("pk", false)]), sodass jeder Chunk sauber weitermacht. Die Methode erzwingt keine Sortierung (manche Abfragen wollen legitimerweise keine Sortierung, z. B. ein einmaliges Leeren), aber unsortierte Iteration ist eine Fußfalle.

Kompromiss gegenüber serverseitigen Cursorn. Das ist ein einfacher LIMIT/OFFSET-Chunker. Auf einer btree-indizierten Sortierspalte scannt PostgreSQL die ersten N Zeilen, bevor es die (N+1)-te zurückgibt — tiefe Paginierung ist also O(n²) Gesamtarbeit. Für ein 10M-Zeilen-Leeren ist das relevant; für 100k Zeilen meistens nicht. Der Chunker gewinnt bei Portabilität (funktioniert auf allen Backends ohne Transaktions-Overhead) und Einfachheit (kein Verwalten eines Cursor-Lebenszyklus). Für wirklich streamende Reads auf PG steig direkt in pool.begin() + rohes sqlx::query(...).fetch(&mut *tx) Stream-API ein — das erweiterte Protokoll streamt vom Server ohne Offset-Reseek.

next_chunk und next_row auf demselben Iterator zu mischen ist sicher. Der interne VecDeque-Puffer leert sich in Zeilenreihenfolge vor jedem neuen DB-Fetch, sodass next_chunk nach einem partiellen next_row-Leeren zuerst die verbleibenden gepufferten Zeilen liefert und dann mit frischen Chunks fortfährt.

Sowohl .rows_seen() (kumulierter Zähler) als auch .is_exhausted() (Post-Drain-Flag) sind für Fortschrittsmeldung und Terminierungsprüfungen verfügbar.

Gefahr bei gleichzeitigem Schreiben. Jeder Chunk ist eine separate Abfrage, sodass zwischen Chunks eingefügte/gelöschte Zeilen übersprungen oder dupliziert werden können (das klassische "Windowing"-Problem der OFFSET-Paginierung). Für nur-lesbare / nur-anfügende Tabellen — den typischen Export-Anwendungsfall — ist das kein Thema. Für Tabellen, die gleichzeitig beschrieben werden, brauchst du eine Snapshot-Isolation-Transaktion, damit jeder Chunk dieselbe Sicht sieht. ChunkedIter nimmt &Pool, nicht &mut Transaction, sodass die Chunker-API nicht direkt innerhalb der Transaktion verwendet werden kann — rolle stattdessen den gechunkten SELECT gegen die Transaktion von Hand aus:

let mut tx = pool.begin().await?;
sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")
    .execute(&mut *tx).await?;

// Hand-loop LIMIT/OFFSET chunks against the tx with `.fetch_on(&mut *tx)`,
// so every chunk reads from the same snapshot.
let chunk_size = 2_000_i64;
let mut offset = 0_i64;
loop {
    let rows: Vec<Post> = Post::objects()
        .order_by(&[("id", false)])
        .limit(chunk_size)
        .offset(offset)
        .fetch_on(&mut *tx)
        .await?;
    if rows.is_empty() { break; }
    for post in &rows { /* … */ }
    if (rows.len() as i64) < chunk_size { break; }
    offset += rows.len() as i64;
}
tx.commit().await?;

select_for_update() propagiert nicht über Chunks hinweg. Zeilensperren, die von .select_for_update() gehalten werden, werden am Ende der impliziten Transaktion jedes Chunks freigegeben. Es gibt keine chunker-förmige Lösung: der .iterator()-Builder nimmt &Pool, die sperrenden Varianten brauchen ein &mut Transaction, und die beiden komponieren nicht. Für ein gesperrtes Leeren hast du zwei Wege, jeder mit einem Kompromiss:

  • Ganzes-Ergebnis .fetch_on(&mut *tx) — ein einziger Roundtrip, volles Vec<T> im Speicher. In Ordnung, wenn das Ergebnis passt.
  • Handgerolltes LIMIT/OFFSET innerhalb der Transaktion — dieselbe Form wie das Snapshot-Isolation-Snippet oben; Chunks bleiben gestreamt, aber du bist außerhalb der ChunkedIter-API.

Ein zukünftiges iterator_on(&mut *tx, chunk_size)-Gegenstück (Issue-Nachfolge) würde diese Lücke schließen. Nicht im Umfang von Issue #23.

chunk_size muss > 0 sein. Null oder negative Werte panicken. Wähle einen Wert, der zu deinem Zeilengrößen-Budget passt (2000 ist ein guter Ausgangspunkt für schmale Zeilen, niedriger für breite TEXT/JSONB-Spalten).

Bestimmte Spalten auswählen

Hol nur ein paar Spalten statt ganzer Post-Structs, über .values(...) und .values_list(...). Verwende diese, wenn du nur ein paar Spalten aus einer breiten Tabelle brauchst, oder wenn das Ergebnis dynamischen Code speist (Templates, CSV-Export, JSON). Du bekommst Maps, Tupel oder eine flache typisierte Liste zurück statt Model-Instanzen.

use rustango::core::SqlValue;
use std::collections::HashMap;

// 1. Column-keyed map per row.
let rows: Vec<HashMap<String, SqlValue>> = Post::objects()
    .where_(Post::published.eq(true))
    .order_by(&[("id", false)])
    .values_dict(&["id", "title"])
    .fetch(&pool).await?;

// 2. Ordered tuple per row.
//    Cell ordering matches the column-list argument.
let rows: Vec<Vec<SqlValue>> = Post::objects()
    .values_list(&["title", "id"])  // title first, id second
    .fetch(&pool).await?;

// 3. Single-column typed scalar.
//    Returns Vec<U> directly via sqlx's typed scalar path.
let ids: Vec<i64> = Post::objects()
    .where_(Post::published.eq(true))
    .values_list_flat("id")
    .fetch::<i64>(&pool).await?;

Drei Builder, eine IR. Alle drei setzen SelectQuery::projection auf die validierte Spaltenliste — das SQL ist über die drei terminalen Formen identisch; nur das Zeilen-Decode unterscheidet sich:

BuilderSQL-FormGibt zurück
.values_dict(&[cols])SELECT col1, col2 FROM …Vec<HashMap<String, SqlValue>>
.values_list(&[cols])SELECT col1, col2 FROM …Vec<Vec<SqlValue>> (geordnet nach cols)
.values_list_flat(col)SELECT col FROM …Vec<U> (typisiert, über fetch::<U>(...))

Funktioniert mit dem Rest der Query-Kette. .where_(), .filter(), .order_by(), .limit(), .offset() und die Mengenoperatoren (.union() / .intersection() / .difference()) — jede Methode, die VOR .values_* aufgerufen wird, wird übernommen. Die Values-Builder sind terminal (nichts verkettet danach), also setze zuerst die Query-Form und fetche dann.

Validierung zur .compile()- / .fetch()-Zeit:

  • Leere Spaltenliste (.values_dict(&[])) → [QueryError::EmptyValuesProjection].
  • Falsch geschriebener Spaltenname (.values_dict(&["nope"])) → [QueryError::UnknownField].

Tri-dialektisch: identische Projektions-Emission über PG / MySQL / SQLite (nur das Identifier-Quoting unterscheidet sich). Für .values_list_flat::<U>(...) muss U sqlx' Decode + Type auf jedem Backend implementieren, das die Binary anvisiert — gängige Auswahlen (i64, i32, String, bool, f64) funktionieren universell.

Warum das bestehende .values() nicht auf reine Projektion umstellen? QuerySet::values(cols) befördert bereits zum [AggregateBuilder] für den GROUP-BY-Auto-Inferenzpfad (Issue #75). Ein Umbenennen würde ~20 bestehende Aufrufstellen brechen. Die neuen .values_dict() / .values_list() / .values_list_flat()-Ketten-Methoden stehen daneben und lassen den Aggregatpfad unangetastet. Der vorbestehende QueryError::ValuesRequiresAggregate-Fehler feuert weiterhin für .values(cols).compile() ohne ein nachfolgendes .annotate(...) — seine Meldung verweist Aufrufer jetzt auf die neuen Reine-Projektion-Methoden.

Spalten einschließen oder ausschließen

Dieselbe Idee wie im vorherigen Abschnitt, aber als Einschluss-/Ausschluss-Paar: .only('id', 'name') behält nur die genannten Spalten, .defer('big_field') behält alles außer ihnen. Verwende diese bei breiten Tabellen, wo große TEXT / BLOB / JSONB-Spalten Listenansichten teuer im Lesen machen:

// .only(...) — fetch only the named columns.
let rows: Vec<HashMap<String, SqlValue>> = Post::objects()
    .where_(Post::published.eq(true))
    .only(&["id", "title"])
    .fetch(&pool).await?;

// .defer(...) — fetch everything except the named columns.
// Useful for "list view: skip body / metadata / large JSON".
let rows: Vec<HashMap<String, SqlValue>> = Post::objects()
    .defer(&["body", "raw_html"])
    .fetch(&pool).await?;

Semantik: .only(&[cols]) ist ein Synonym für .values_dict(cols) — gleiche IR, gleiche Rückgabeform, separater Einstiegspunkt der Lesbarkeit halber. .defer(&[cols]) berechnet das Komplement gegen das Model-Schema (jede Skalarspalte des Models AUSSER den aufgelisteten) und leitet auf denselben Pfad.

Vorbehalt — die Rückgabe ist kein Model. Es gibt keine teilweise hydrierten Model-Instanzen, die fehlende Felder beim Attributzugriff nachladen; die Rückgabeform ist Vec<HashMap<String, SqlValue>> (oder Vec<Vec<SqlValue>>, wenn du stattdessen .values_list(...) einwechselst). Typisiertes Teilzeilen-Decode ist für eine zukünftige Iteration vorgemerkt.

Tippfehlersicherheit: .defer(&["nope_col"]) liefert QueryError::UnknownField zur .compile()-Zeit — der Tippfehler verwandelt sich nicht stillschweigend in "alle Spalten projizieren". .only(&[]) liefert QueryError::EmptyValuesProjection; .defer(&[]) ist eine semantische No-op (projiziert jede Spalte).

Mit regulären Ausdrücken abgleichen

Gleiche eine Spalte gegen ein Regex-Muster ab, über die Lookups __regex / __iregex. .regex() ist case-sensitiv, .iregex() case-insensitiv, und .not_regex() / .not_iregex() sind die negierten Formen.

use rustango::core::Column as _;

// Names starting with "al" (case-sensitive).
User::objects()
    .where_(User::name.regex("^al.*"))
    .fetch(&pool).await?;

// Names starting with "al" — case-insensitive.
User::objects()
    .where_(User::name.iregex("^al.*"))
    .fetch(&pool).await?;

// Negated: exclude names starting with "admin" (case-sensitive).
User::objects()
    .where_(User::name.not_regex("^admin"))
    .fetch(&pool).await?;

// Lookup-suffix string form.
User::objects()
    .filter("name__iregex", "^bob")
    .fetch(&pool).await?;

Tri-dialektische Emission:

DialektCase-sensitivCase-insensitivHinweise
PostgreSQL<col> ~ ? / <col> !~ ?<col> ~* ? / <col> !~* ?Native POSIX-Operatoren
MySQL`col` REGEXP ? / `col` NOT REGEXP ?LOWER(col) REGEXP LOWER(?) (Negation umschließt mit NOT)LOWER()-Fallback für i*
SQLite"col" REGEXP ? / "col" NOT REGEXP ?LOWER("col") REGEXP LOWER(?) (Negation umschließt mit NOT)Braucht die regexp-User-Funktion auf der Verbindung geladen

SQLite benötigt eine registrierte regexp-User-Funktion — sie ist nicht eingebaut. sqlx-sqlite 0.8 registriert standardmäßig keine. Zwei Wege, sie zu aktivieren:

  1. Einfach — aktiviere sqlx-sqlites regexp-Cargo-Feature, dann opte die Verbindung ein:
    use sqlx::sqlite::SqliteConnectOptions;
    let opts = SqliteConnectOptions::new()
        .filename("app.db")
        .with_regexp();  // gated on sqlx-sqlite/regexp
    
  2. Manuell — registriere einen Rust-Closure über SqliteConnection::lock_handle() + rohes FFI (sqlite3_create_function_v2).

Ohne eine solche emittiert die Abfrage valides REGEXP-SQL, das SQLite bei der Ausführung mit no such function: regexp ablehnt (parser-sauber — tests/regex_sqlite_live.rs nagelt das fest).

Der Muster-Dialekt unterscheidet sich zwischen Backends. PostgreSQL verwendet POSIX-Extended-Regex; MySQL verwendet ICU-basiertes Regex mit eigenem Geschmack; SQLite delegiert an das, was die User-Funktion implementiert (typischerweise Rusts regex-Crate). Muster, die auf dialektspezifische Syntax setzen (z. B. PGs \m / \M Wortgrenzen), sind nicht rundlauffähig — halte dich an die portable Teilmenge (^, $, ., *, +, ?, [...], (), |), wenn dasselbe Model von mehreren Backends abgefragt wird.

Nicht-String-Werte werden bei .compile() abgelehnt — das Übergeben von SqlValue::I64(42) an __regex liefert QueryError::InvalidLookupValue { suffix: "regex", expected: "SqlValue::String(<regex pattern>)", … } statt stillschweigend zu casten.


Berechnete Werte & Datenbankfunktionen

Lass die Datenbank Dinge berechnen, statt Zeilen in die App zu ziehen, sie zu mutieren und zurückzuschreiben. F("col") verweist auf eine Spalte per Name, und die funcs::*-Builder umschließen skalare SQL-Funktionen wie LOWER oder COALESCE. Zusammen schalten sie drei Muster frei, die reines wertbasiertes .set() / .where_() nicht ausdrücken kann:

Atomare Inkremente (kein Read-Modify-Write-Race)

Der klassische Zähler-Bug — eine Zeile holen, ein Feld hochzählen, speichern — verliert Updates, wenn zwei Requests gleichzeitig laufen. F("col") + 1 fasst den Roundtrip zu einem einzigen UPDATE zusammen, sodass die Datenbank die Zeilensperre für dich hält:

use rustango::core::F;
use rustango::sql::UpdaterPool as _;

Post::objects()
    .eq("id", post_id)
    .update()
    .set_expr("view_count", F("view_count") + 1_i64)
    .execute_pool(&pool).await?;

Tri-dialektisch: emittiert views = ("views" + $1) auf PG, views = (`views` + ?) auf MySQL, identisch auf SQLite. Die Arithmetik wird geklammert, damit verschachtelte Operationen eindeutig bleiben: F("a") + F("b") * 2.

Unterstützte Operatoren: + - * / % plus & | ^ << >> (bitweise; XOR auf SQLite emittiert ein klares OpNotSupportedInDialect, da SQLite kein XOR-Symbol hat).

Zwei Spalten in einem Filter vergleichen

Filtere eine Spalte gegen eine andere, nicht gegen ein Literal — z. B. Reservation start_date < end_date, um eine Zeile auf Plausibilität zu prüfen, oder Inventory available > reserved, um Zeilen mit Kapazität zu finden:

use rustango::core::Column as _;

// `start_date < end_date` for every selected row.
let valid = Reservation::objects()
    .where_(Reservation::start_date.lt_expr(F("end_date")))
    .fetch(&pool).await?;

// Combine with literal predicates.
let oversold = Inventory::objects()
    .where_(Inventory::available.lt_expr(F("reserved")))
    .where_(Inventory::active.eq(true))
    .fetch(&pool).await?;

Die *_expr-Familie — eq_expr, ne_expr, lt_expr, lte_expr, gt_expr, gte_expr — spiegelt die literalen eq, ne, …-Methoden, nimmt aber auf der rechten Seite jedes impl Into<Expr>: nackte Spaltenreferenzen (F("col")), Arithmetik (F("price") * 2) oder Funktionsergebnisse (nächster Abschnitt).

Skalare Funktionen — Text, Mathematik, NULL-Behandlung

rustango::core::funcs liefert Builder für die meistgenutzten SQL-Funktionen. 72 skalare Builder. Die meistgenutzten, gruppiert:

GruppeBuilder
Textlower, upper, length, trim, ltrim, rtrim, concat, substr, replace
Mathematikabs, ceil, floor, round (1-arg) / round_to (2-arg-Präzision)
NULLcoalesce, greatest, least, nullif
use rustango::core::funcs::{lower, upper, concat, coalesce, trim, abs, round};
use rustango::core::F;
use rustango::sql::UpdaterPool as _;

// Normalize on write.
User::objects()
    .eq("id", id)
    .update()
    .set_expr("email", lower(trim(F("email"))))
    .execute_pool(&pool).await?;

// Build a derived column from two FKs + a literal.
User::objects()
    .update()
    .set_expr(
        "display_name",
        concat([F("first").into(), " ".into(), F("last").into()]),
    )
    .execute_pool(&pool).await?;

// First non-NULL fallback.
User::objects()
    .update()
    .set_expr(
        "label",
        coalesce([F("nickname").into(), F("username").into(), "anonymous".into()]),
    )
    .execute_pool(&pool).await?;

// Function on the WHERE rhs.
User::objects()
    .where_(User::email_norm.eq_expr(lower(F("email_norm"))))
    .fetch(&pool).await?;

// Functions compose freely — `abs(round(F("score") * 100))` is one Expr.
Player::objects()
    .update()
    .set_expr("score_int", abs(round(F("score") * 100_f64)))
    .execute_pool(&pool).await?;

Tri-dialektisches Verhalten

Die meisten Funktionen emittieren über PG / MySQL / SQLite identisches SQL. Die divergierenden Formen werden pro Dialekt transparent behandelt:

BuilderPGMySQLSQLite
concat([a, b])CONCAT(a, b)CONCAT(a, b)(a || b)
substr(s, 1, 3)SUBSTRING(s FROM 1 FOR 3)SUBSTRING(s, 1, 3)SUBSTR(s, 1, 3)
greatest([a, b])GREATEST(a, b)GREATEST(a, b)MAX(a, b) skalar
least([a, b])LEAST(a, b)LEAST(a, b)MIN(a, b) skalar

Gemischte Argumente an eine Funktion übergeben

Funktionen, die eine Liste von Argumenten nehmen (wie concat), akzeptieren jedes iterierbare von Expr. Rust-Arrays müssen einen Typ enthalten, sodass ein Mix aus F (Spalte) und &str (Literal) allein nicht typprüfen wird — rufe .into() einmal pro Element auf, um jedes in ein Expr zu heben:

concat([F("first").into(), " ".into(), F("last").into()])
//          ^^^^^^ each element lifted to Expr

Oder baue ein Vec<Expr> und übergib es direkt — gleiche Form, gleiches Ergebnis.

Vorbehalte

  • length Byte-vs-Zeichen: PG gibt Zeichen bei TEXT/VARCHAR zurück, MySQL gibt Bytes zurück (verwende den zukünftigen CharLength-Builder des Frameworks oder umschließe manuell mit CHAR_LENGTH, wenn du dialektübergreifende Zeichenzählungen brauchst).
  • round(x, n) auf PG: PGs 2-arg-Form erfordert numeric, nicht double. Übergib entweder eine Integer-Spalte oder caste den Float zuerst; MySQL und SQLite akzeptieren beide Typen.
  • greatest([single_arg]) / least([single_arg]) auf SQLite: nicht unterstützt — SQLites MAX(x) mit einem Argument ist die Aggregat-, nicht die skalare Form. Der Writer gibt OpNotSupportedInDialect zurück. PG und MySQL akzeptieren die Ein-Argument-Form als No-op, die x zurückgibt. Umschließe mit mindestens einem Literal, um portabel zu bleiben.
  • substr mit negativem Start: PG behandelt negativ als "starte ab Zeichenposition N" (klemmt effektiv auf 0); MySQL und SQLite behandeln negativ als "zähle vom Ende". Vermeide negative Starts in portablem Code.

Datums- & Zeitfunktionen

Die now()-, extract_*- und trunc_*-Builder arbeiten auf Daten und Zeitstempeln. Verwende sie für Kohortenabfragen, Zeitbucket-Aggregate und das Stempeln der aktuellen Zeit beim Schreiben — alles in der Datenbank, ohne Zeilen durch die App zu roundtrippen.

use rustango::core::funcs::{
    now, trunc_date, trunc_month,
    extract_year, extract_month, extract_weekday,
};
use rustango::core::F;

// 1. Stamp server-side current time on write.
Post::objects()
    .eq("id", id)
    .update()
    .set_expr("published_at", now())
    .execute_pool(&pool).await?;

// 2. Extract year / month / weekday into denormalized indexable
// columns so cohort + day-of-week queries are cheap.
Signup::objects()
    .update()
    .set_expr("bucket_year", extract_year(F("created_at")))
    .set_expr("bucket_month", extract_month(F("created_at")))
    .set_expr("weekday", extract_weekday(F("created_at")))
    .execute_pool(&pool).await?;

// 3. Filter on the stored bucket — typed integer comparison, uses
// the index, portable across all three dialects.
let friday_signups = Signup::objects()
    .where_(Signup::weekday.eq(5_i64))            // 5 = Friday (0=Sun)
    .fetch(&pool).await?;

// 4. For range filters where you'd be tempted to write
// `created_at >= trunc_year(now())` directly: don't. The function
// builders for `Trunc*` return text on MySQL/SQLite (see caveats
// below), so a column-vs-trunc comparison in WHERE only behaves
// well on PG. Compute the boundary in Rust instead and pass it as a
// typed literal — works the same on every backend and uses the
// index on `created_at`:
use chrono::{Datelike, TimeZone};
use rustango::sql::UpdaterPool as _;
let this_year = chrono::Utc::now().year();
let year_start = chrono::Utc.with_ymd_and_hms(this_year, 1, 1, 0, 0, 0).unwrap();

let recent = Order::objects()
    .where_(Order::created_at.gte(year_start))
    .fetch(&pool).await?;

// 5. `Trunc*` shines on the *write* side. `trunc_date` is the
// one trunc-family builder with identical SQL on every dialect
// (`DATE(x)`) — handy for grouping by day without the type-divergence
// caveat the year/month variants carry.
Order::objects()
    .update()
    .set_expr("day_bucket", trunc_date(F("created_at")))     // DATE column on every backend
    .set_expr("month_bucket", trunc_month(F("created_at")))  // see caveat
    .execute_pool(&pool).await?;
// `month_bucket` should be `TIMESTAMPTZ` on PG and `VARCHAR(10)` /
// `TEXT` on MySQL/SQLite — parse client-side when reading if you
// need a typed `chrono::NaiveDate`.

Emission pro Dialekt:

BuilderPGMySQLSQLite
now()NOW()NOW()CURRENT_TIMESTAMP
extract_year(x)CAST(EXTRACT(YEAR FROM x) AS INTEGER)YEAR(x)CAST(strftime('%Y', x) AS INTEGER)
extract_week(x) ⚠EXTRACT(WEEK FROM x) — ISO 8601, Bereich 1–53WEEK(x) — sonntagsbeginnend, Bereich 0–53strftime('%W', x) — montagsbeginnend, Bereich 00–53
extract_weekday(x)CAST(EXTRACT(DOW FROM x) AS INTEGER)(DAYOFWEEK(x) - 1)CAST(strftime('%w', x) AS INTEGER)
extract_quarter(x)EXTRACT(QUARTER FROM x)QUARTER(x)nicht unterstützt — Fehler
trunc_date(x)DATE(x)DATE(x)DATE(x)
trunc_year(x)DATE_TRUNC('year', x) → ZeitstempelDATE_FORMAT(x, '%Y-01-01') → Stringstrftime('%Y-01-01', x) → String
trunc_month(x)DATE_TRUNC('month', x) → ZeitstempelDATE_FORMAT(x, '%Y-%m-01') → Stringstrftime('%Y-%m-01', x) → String
trunc_day(x)DATE_TRUNC('day', x) → ZeitstempelDATE(x) → Datumdate(x) → Text

Vorbehalte speziell für Datum/Zeit:

  • trunc_year/month-Rückgabetyp divergiert: Zeitstempel auf PG, Text auf MySQL/SQLite. Caste auf App-Seite beim Lesen, wenn du ein typisiertes chrono::NaiveDate brauchst — oder speichere den Bucket als schlichten Integer (extract_year + extract_month) und rekonstruiere im Code.
  • extract_weekday ist auf 0 = Sonntag normalisiert über alle drei Dialekte. MySQLs natives DAYOFWEEK() gibt 1=Sonntag zurück, also subtrahiert der Writer 1.
  • ⚠ extract_week ist NICHT portabel. PG gibt ISO-8601-Wochennummern zurück (montagsbeginnend, Bereich 1–53); MySQLs Standard-WEEK(x) ist sonntagsbeginnend mit Bereich 0–53; SQLites strftime('%W') ist montagsbeginnend mit Bereich 00–53. Für 2024-01-01 (ein Montag) geben die drei Backends jeweils 1, 0 und 01 zurück. Single-Backend-Code kann es frei verwenden; dialektübergreifender Code sollte die Wochengrenze als typisiertes chrono::DateTime in Rust berechnen und stattdessen auf der Zeitstempel-Spalte filtern.
  • extract_quarter auf SQLite wirft einen Fehler mit OpNotSupportedInDialect — SQLite hat kein natives Quartals-Token. Gate das Feature entweder hinter cfg(not(sqlite)) oder berechne per ((extract_month - 1) / 3) + 1 im App-Code.
  • Zeitzonen-Behandlung: PG EXTRACT operiert in der Zeitzone der Spalte; MySQL YEAR() operiert in der Sitzungs-Zeitzone (SET time_zone = ...); SQLite hat keine echte TZ-Unterstützung — behandle alles als UTC. Verwende TIMESTAMPTZ auf PG, DATETIME auf MySQL mit gesetzter Sitzungs-TZ, ISO-8601-Strings auf SQLite.

CASE-WHEN-Ausdrücke

Baue ein SQL CASE WHEN … THEN … ELSE … END mit den case() / .when() / value()-Buildern. Verwende es für benutzerdefinierte Sortierungen, abgeleitete Spalten in annotate, berechnete Defaults in update und (gepaart mit Sum) bedingte Aggregate.

use rustango::core::case::{case, value};
use rustango::core::{Column as _, F};
use rustango::core::funcs::lower;
use rustango::sql::UpdaterPool as _;

// Custom ordering — published posts first, drafts last.
Post::objects()
    .update()
    .set_expr(
        "priority",
        case()
            .when(Post::status.eq("published"), 0_i64)
            .when(Post::status.eq("review"), 1_i64)
            .when(Post::status.eq("draft"), 2_i64)
            .default(99_i64),
    )
    .execute_pool(&pool).await?;

let ordered = Post::objects()
    .order_by(&[("priority", false), ("id", false)])
    .fetch(&pool).await?;

// Computed default on update — drafts get a lowercased title for
// the label, everything else uses the title verbatim.
Post::objects()
    .update()
    .set_expr(
        "label",
        case()
            .when(Post::status.eq("draft"), lower(F("title")))
            .default(F("title")),
    )
    .execute_pool(&pool).await?;

// AND / OR composition in the WHEN predicate.
let viral = Post::status.eq("published").and(Post::views.gt(1_000_i64));
Post::objects()
    .update()
    .set_expr(
        "label",
        case()
            .when(viral, value("viral"))
            .when(Post::status.eq("published"), value("live"))
            .default(value("pending")),
    )
    .execute_pool(&pool).await?;

Builder-Form:

  • case() — startet einen Builder.
  • .when(condition, then) — hängt einen Zweig an. condition ist alles Into<WhereExpr> (typischerweise Column::eq(), .and(), .or()); then ist alles Into<Expr> (Literal, F(), Funktionsaufruf, verschachteltes case()).
  • .default(expr) — setzt den optionalen ELSE-Zweig. Ihn wegzulassen erzeugt ein CASE, das NULL für nicht passende Zeilen zurückgibt (SQL-Standard).
  • .build() oder .into() — finalisiert zu einem Expr für set_expr / eq_expr / annotate.
  • value(literal) — Kurzform für Expr::Literal(...). Optional — nackte Literale werden per Into<Expr> gecoerct, aber value("…") liest sich explizit als "das ist ein String-Literal, keine Spaltenreferenz".

Tri-dialektische Emission:

CASE WHEN … THEN … [ELSE …] END ist SQL-92-Standard — über PG, MySQL und SQLite identisch emittiert. Kein Dialekt-Dispatch im Writer.

Vorbehalte:

  • Leere Zweige: case().build() ohne .when(...)-Aufrufe wird zur Emit-Zeit mit SqlError::EmptyCaseBranches abgelehnt. SQL erfordert mindestens eine WHEN-Klausel. Eine leere WHEN-Bedingung (z. B. WhereExpr::And(vec![])) wird aus demselben Grund mit SqlError::EmptyCaseWhenCondition abgelehnt.
  • Typvereinheitlichung über Zweige: jeder Dialekt wählt einen gemeinsamen Typ aus den THEN- und ELSE-Werten. Das Mischen von Typen (THEN 1_i64 + ELSE "string") kann einen Laufzeit-Cast-Fehler werfen oder überraschend coercen. Halte dich an einen Typ pro CASE.
  • Performance: jede Zeile wertet WHEN-Prädikate der Reihe nach aus, bis eines passt (First-Match-Wins, pro Zeile). Die Kosten wachsen mit der Anzahl der Zweige und den Kosten der Prädikate. Für viele feste String-Mappings kann ein Join gegen eine kleine Lookup-Tabelle günstiger und lesbarer sein.

Unterabfragen (EXISTS, IN, skalar)

Bette eine Abfrage in eine andere ein, mit den Buildern Exists, Subquery und OuterRef. Diese Builder decken die meisten "existiert eine verwandte Zeile?"- und "ist dieser Wert in dieser Menge?"-Muster ab:

BuilderFormVerwende es für
exists(qs)EXISTS (SELECT … FROM …)"Autoren, die mindestens ein Buch haben"
not_exists(qs)NOT EXISTS (SELECT …)"Autoren ohne Bücher" (Anti-Join)
in_subquery(col, qs)<col> IN (SELECT …)"Posts in irgendeiner öffentlichen Kategorie"
not_in_subquery(col, qs)<col> NOT IN (SELECT …)Umkehrung des obigen
subquery(qs)(SELECT …) als SkalarBerechneter Default in set_expr
outer_ref(col)"<outer_table>"."<col>"Verweise auf die äußere Zeile von innerhalb jedes der obigen
use rustango::core::subquery::{exists, not_exists, in_subquery, outer_ref};
use rustango::core::{Column as _, WhereExpr};

// "Authors with no books" — the canonical anti-join. Build the inner
// queryset first so its compile() catches typos; embed via not_exists.
let no_books = Book::objects()
    .where_(Book::author_id.eq_expr(outer_ref("id")))
    .compile()?;
let orphans = Author::objects()
    .where_raw(not_exists(no_books))
    .fetch(&pool).await?;

// "Authors who have a published book of more than 100 pages" — the
// inner predicate combines a correlation (outer_ref) with literal
// filters in the same WHERE.
let inner = Book::objects()
    .where_(Book::author_id.eq_expr(outer_ref("id")))
    .where_(Book::status.eq("published"))
    .where_(Book::pages.gt(100_i64))
    .compile()?;
let long_writers = Author::objects()
    .where_raw(exists(inner))
    .fetch(&pool).await?;

// Compose EXISTS with an OR.
let inner = Book::objects()
    .where_(Book::author_id.eq_expr(outer_ref("id")))
    .compile()?;
let featured = Author::objects()
    .where_raw(WhereExpr::Or(vec![
        Author::name.eq("Carol").into(),
        exists(inner),
    ]))
    .fetch(&pool).await?;

Verschachtelte Korrelation funktioniert. Ein OuterRef innerhalb einer doppelt verschachtelten Unterabfrage löst zum unmittelbar umschließenden Scope auf — der Writer pflegt beim Absteigen einen Scope-Stack, sodass EXISTS (Book WHERE id = outer.id AND EXISTS (Comment WHERE book_id = outer.id)) das innere outer.id zu Book.id auflöst, nicht zum äußersten Author.id. Verwende outer_ref(...) zweimal, wenn du wirklich zwei Scopes nach oben reichen musst.

Fehler:

  • OuterRefOutsideSubquery — outer_ref("col") auf oberster Ebene zu emittieren (nicht innerhalb eines Unterabfrage-Wrappers) ist ein Programmierfehler. Der Writer wirft das laut mit dem Spaltennamen, sodass die Aufrufstelle leicht zu finden ist.

Vorbehalte:

  • IN (SELECT …)-Projektionsverengung: PG erfordert strikt, dass der innere SELECT genau eine Spalte für die <col> IN (…)-Form projiziert. Rustango liefert noch keine .values("col")-artige Projektionsverengung (Issue #62), sodass das innere Queryset immer jede Model-Spalte projiziert — was in_subquery heute nur gegen Tabellen funktionieren lässt, deren Model eine einzige Spalte hat. Für den Mehr-Spalten-Fall greif zu exists(inner.where_(<outer col>.eq_expr(outer_ref(...)))) — es hat dieselbe Semantik und hängt nicht von der Projektionsform ab.
  • Skalares subquery(...) erfordert ein Ein-Spalte-eine-Zeile-Inneres: das emittierte SQL ist SET col = (SELECT …) — produziert das Innere mehr als eine Zeile, wirft die Datenbank zur Laufzeit einen Fehler. Beschränke per .limit(1) und entweder verenge die Projektion (sobald sie landet) oder gestalte das Innere um eine Eindeutigkeits-Invariante.
  • Kompilierzeit-Validierung der Unterabfrage lebt auf dem inneren Queryset: Spalten-Tippfehler tauchen beim inneren queryset.compile()?-Aufruf auf, nicht beim compile() der äußeren Abfrage. Baue das Innere zuerst und propagiere ?.

Wann man stattdessen auf rohes SQL zurückfällt

Die Tabelle oben ist eine Auswahl, nicht der ganze Satz. funcs hat außerdem cast, die Volltext-Familie (to_tsvector, plainto_tsquery, websearch_to_tsquery, ts_rank, ts_headline), JSON-Pfade (json_path, json_path_indexed, json_array_length) und Hashes (md5, sha1, sha256). Fensterfunktionen sind ein eigenes Modul, rustango::core::window, unten dokumentiert. Trigonometrische Funktionen sind die auffällige Lücke — dafür und für alles andere, was der Ausdrucksbaum nicht erreicht, siehe den Abschnitt Raw-SQL-Notausgang weiter unten.


Aggregationen

Zeilen zählen, summieren, mitteln und gruppieren. .count(), .sum(), .avg(), .min() und .max() geben eine einzelne Zahl zurück; .annotate(...) plus .values(...) baut GROUP-BY-Abfragen. Aggregatergebnisse kommen als Vec<HashMap<String, SqlValue>> zurück statt als typisierte Structs, da die Form dynamisch ist.

use rustango::sql::CounterPool as _;

// COUNT
let n = Post::objects()
    .where_(Post::status.eq("published"))
    .count(&pool).await?;

// SUM / AVG / MIN / MAX — string column name; each returns Option<U>
// (None when the filtered result set is empty).
let total_views = Post::objects().sum::<i64>("view_count", &pool).await?;
let avg_views = Post::objects().avg::<f64>("view_count", &pool).await?;
let max_views = Post::objects().max::<i64>("view_count", &pool).await?;

// Annotate + GROUP BY (issue #75 — auto-inferred grouping)
use rustango::core::aggregates::{count_all, sum};

// "Posts per author" — `.values()` lists the GROUP BY columns.
let by_author = Sale::objects()
    .values(&["author_id"])
    .annotate("n", count_all().into())
    .compile()?;
let rows = rustango::sql::fetch_aggregate_dict(&pool, &by_author).await?;
// rows: Vec<HashMap<String, SqlValue>> — { author_id: 1, n: 3 }, …

Wie GROUP BY inferiert wird

Du schreibst GROUP BY selten selbst — Rustango inferiert es aus der Form der Abfrage. Du rufst .group_by(...) nur auf, um diese Inferenz zu überschreiben. Die Tabelle zeigt, was jede Form erzeugt:

FormBuilderResultierendes GROUP BY
2 — values + Aggregat.values(&["author_id"]).annotate("n", count_all().into())GROUP BY "author_id"
3 — nacktes Aggregat.annotate("n", count_all().into())GROUP BY jede nicht-aggregierende Skalarspalte des Models
Nur Fenster.aggregate().annotate("rn", row_number()…)(kein GROUP BY — Fensterfunktionen sind pro Zeile)
Explizite Überschreibung.aggregate().group_by("month").annotate(...)GROUP BY "month" — explizit gewinnt

Der Klassifizierer AggregateExpr::is_aggregating() unterscheidet die zeilen-kollabierenden Varianten (Count / Sum / Avg / Max / Min / CountDistinct / StdDev* / Variance* — plus rekursive Filtered / Coalesced-Wrapper) von Window, das pro Zeile ist. Nur die aggregierenden Varianten lösen die Form-3-Inferenz aus.

use rustango::core::aggregates::{count_all, sum};

// Shape 2 — "monthly revenue per author".
Sale::objects()
    .where_(Sale::status.eq("paid"))
    .values(&["author_id", "month"])
    .annotate("total", sum("amount").into())
    .compile()?;
// → SELECT "author_id", "month", SUM("amount")::bigint AS "total"
//   FROM "sale" WHERE "status" = $1
//   GROUP BY "author_id", "month"

// Shape 3 — a bare .annotate() with no .values(): rustango adds every
// non-aggregate scalar column of the model to the GROUP BY.
Post::objects()
    .annotate("n", count_all().into())
    .compile()?;
// → SELECT <every Post column>, COUNT(*) AS "n"
//   FROM "post" GROUP BY <every Post column>

Reine Projektion. .values(cols) allein (ohne Aggregat-Annotation) gibt QueryError::ValuesRequiresAggregate zurück — dieser Pfad ist für GROUP BY reserviert, und eine Fenster-Annotation erfüllt ihn ebenfalls nicht, da ein Fenster nicht aggregiert. Reine Projektion als Dicts ist ausgeliefert: verwende .values_dict(...), .values_list(...) oder .values_list_flat(...), oben unter Bestimmte Spalten auswählen beschrieben. Die Fehlermeldung selbst nennt sie.

Bedingte & statistische Aggregate

Zähle oder summiere nur die Zeilen, die eine Bedingung erfüllen, liefere einen Fallback für leere Ergebnisse und berechne Standardabweichung / Varianz. Verkette .filter(...) und .default(...) an jeden Aggregat-Builder.

use rustango::core::aggregates::{avg, count, count_all, stddev, sum};
use rustango::core::Column as _;

let rows = Post::objects()
    .aggregate()
    // COUNT(*) FILTER (WHERE is_active AND status = 'published')
    .annotate(
        "active_published",
        count_all()
            .filter(Post::is_active.eq(true).and(Post::status.eq("published")))
            .into(),
    )
    // COALESCE(SUM(price) FILTER (WHERE status = 'published'), 0)
    //   — returns 0 instead of NULL when the queryset is empty.
    .annotate(
        "revenue_or_zero",
        sum("price")
            .filter(Post::status.eq("published"))
            .default(0_i64)
            .into(),
    )
    .annotate("avg_pages", avg("pages").into())
    .annotate("page_stddev", stddev("pages").into())
    .compile()?;
let result = rustango::sql::fetch_aggregate_dict(&pool, &rows).await?;

Builder in rustango::core::aggregates:

BuilderSQL
count(col)COUNT(col)
count_all()COUNT(*)
count_distinct(col)COUNT(DISTINCT col)
sum(col) / avg(col) / max(col) / min(col)das Übliche
stddev(col) / stddev_pop(col)STDDEV_SAMP / STDDEV_POP
variance(col) / variance_pop(col)VAR_SAMP / VAR_POP

Jedes gibt einen AggregateBuilder mit zwei verkettbaren Modifikatoren zurück:

  • .filter(predicate) — umschließe mit FILTER (WHERE predicate). Das Prädikat ist jedes WhereExpr (typisiertes .eq() / .and() / rohes WhereExpr::Or(...)), sodass es sich genauso komponiert wie ein normales WHERE.
  • .default(value) — umschließe mit COALESCE(..., value), sodass ein leeres Queryset den Default statt NULL zurückgibt.

Beide Ketten als Coalesced außerhalb Filtered aufzurufen: COALESCE(SUM(col) FILTER (WHERE p), 0). Die Kettenreihenfolge spielt keine Rolle — .filter(p).default(0) und .default(0).filter(p) erzeugen dieselbe IR.

Tri-dialektische Emission:

FeaturePGMySQLSQLite
Count / Sum / Avg / Max / Min / CountDistinct✓✓✓
StdDev / StdDevPop / Variance / VariancePop✓✓ (8.0+)✗ SqlError::AggregateNotSupported
.filter(...) — natives FILTER (WHERE …)✓✗ umgeschrieben✓ (3.30+)
.filter(...) — CASE WHEN-Fallback—✓ <agg>(CASE WHEN … THEN <arg> END)—
.default(...) — COALESCE✓✓✓

Der Writer wendet den Int/Float-Cast des Dialekts (::bigint, CAST(... AS SIGNED) etc.) um den gesamten FILTER-Ausdruck an — SUM(col)::bigint FILTER (...) ist ein PG-Parse-Fehler, sodass die emittierte Form (SUM(col) FILTER (...))::bigint ist. Gleiche Form für STDDEV_SAMP / VAR_SAMP (sie geben NUMERIC auf PG für bigint-Input zurück).

SQLite + StdDev/Variance: SQLite hat keine eingebauten statistischen Aggregate, sodass der Writer mit SqlError::AggregateNotSupported { aggregate, dialect: "sqlite" } ablehnt. Berechne die Varianzformel im App-Code, wenn portable Statistik benötigt wird.

Fensterfunktionen

Berechne laufende Summen, Rankings und Zeile-über-Zeile-Deltas, ohne Zeilen zu kollabieren — über Window(expression, partition_by=, order_by=, frame=). Acht Funktionen (row_number, rank, dense_rank, lag, lead, first_value, last_value, ntile) plus ROWS/RANGE-Frames. Jedes Backend, das Rustango unterstützt (PG ≥ 9.0, MySQL ≥ 8.0, SQLite ≥ 3.25), liefert native OVER (…)-Syntax, sodass die Emission uniform ist.

use rustango::core::aggregates::max;
use rustango::core::window::{lag, rank, row_number};

// "Rank users by score within each tenant" — the canonical
// integration target.
let q = User::objects()
    .aggregate()
    .group_by("id")
    .group_by("tenant_id")
    .group_by("name")
    .group_by("score")
    .annotate("_a", max("id").into())  // satisfies GROUP BY on the projection
    .annotate(
        "tenant_rank",
        rank().partition_by("tenant_id").order_by(&[("score", true)]).into(),
    )
    .order_by(&[("tenant_id", false), ("score", true)])
    .compile()?;
let rows = rustango::sql::fetch_aggregate_dict(&pool, &q).await?;

// Day-over-day delta via LAG with a default for the first row.
let q = Event::objects()
    .aggregate()
    .group_by("id")
    .group_by("day")
    .group_by("count")
    .annotate("_a", max("id").into())
    .annotate(
        "prev_count",
        lag("count", 1, Some(SqlValue::I64(0)))
            .partition_by("user_id")
            .order_by(&[("day", false)])
            .into(),
    )
    .compile()?;

// Stable row index per group for "show me row N" pagination.
let q = Post::objects()
    .aggregate()
    .group_by("id")
    .group_by("status")
    .group_by("created_at")
    .annotate("_a", max("id").into())
    .annotate(
        "rn",
        row_number()
            .partition_by("status")
            .order_by(&[("created_at", true)])
            .into(),
    )
    .compile()?;

Builder in rustango::core::window:

BuilderSQLArgumente
row_number()ROW_NUMBER()—
rank()RANK()—
dense_rank()DENSE_RANK()—
ntile(buckets)NTILE(buckets)Bucket-Anzahl
lag(col, offset, default)LAG(col, offset, default?)Spalte + Offset + optionaler Default
lead(col, offset, default)LEAD(col, offset, default?)Spalte + Offset + optionaler Default
first_value(col)FIRST_VALUE(col)Spalte
last_value(col)LAST_VALUE(col)Spalte

Jedes gibt einen WindowBuilder mit drei verkettbaren Modifikatoren zurück:

  • .partition_by("col") — hängt eine PARTITION BY-Spalte an. Rufe es mehrmals auf für Mehr-Spalten-Partitionierung.
  • .order_by(&[("col", desc)]) — hängt ORDER BY-Spalten an (desc = true → DESC).
  • .frame(WindowFrame { kind, start, end }) — setzt die optionale ROWS/RANGE-Frame-Klausel. FrameBoundary::UnboundedPreceding / Preceding(n) / CurrentRow / Following(n) / UnboundedFollowing.

Der Builder senkt per Into<AggregateExpr> ab, sodass Fensterfunktionen mit annotate() komponieren. Into<Expr> ist ebenfalls implementiert (der IR-Level-Slot für Fensterausdrücke), aber jedes Backend, das Rustango unterstützt, beschränkt Fensterfunktionen auf die SELECT-Liste und die ORDER BY-Klausel einer Abfrage — sie können nicht in WHERE / HAVING / GROUP BY / UPDATE SET / JOIN ON / RETURNING erscheinen. Der Writer gatet die Emission nicht darauf, sodass set_expr("col", row_number()) zu SQL kompiliert, das die Datenbank bei der Ausführung ablehnt. Baue Fensterausdrücke über annotate(); greif zu einer Unterabfrage, wenn du ein Fensterergebnis in einen WHERE-Filter oder ein UPDATE einspeisen musst.

LAST_VALUE-Default-Frame-Falle:

Ein nacktes last_value(col).order_by(&[("x", false)]) emittiert LAST_VALUE("col") OVER (ORDER BY "x") und sieht aus, als sollte es das letzte col der Partition zurückgeben. Tut es nicht — SQLs Default-Fensterframe ist RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, sodass LAST_VALUE den Wert der aktuellen Zeile zurückgibt, nicht der letzten Zeile der Partition. Um das intuitive "letzte Zeile der Partition"-Verhalten zu erhalten, übergib einen expliziten unbegrenzten Frame:

use rustango::core::{FrameBoundary, FrameKind, WindowFrame};

last_value("score")
    .partition_by("tenant_id")
    .order_by(&[("created_at", true)])
    .frame(WindowFrame {
        kind: FrameKind::Rows,
        start: FrameBoundary::UnboundedPreceding,
        end: Some(FrameBoundary::UnboundedFollowing),
    })

first_value hat diese Falle nicht — der Start des Default-Frames stimmt mit dem Partitionsstart überein, sodass die intuitive Antwort herausfällt.

Annotate und GROUP BY (Issue #75 ist ausgeliefert):

Ein reines Fenster-annotate() auf .aggregate() erzeugt inzwischen gar kein GROUP BY mehr — Fenster sind pro Zeile, es muss also nichts gruppiert werden. Zeilenspalten neben einem Fenster zu projizieren läuft weiterhin über den Aggregat-Builder; deshalb listen die Beispiele unten jede Zeilenspalte in .group_by(...) und ergänzen annotate("_a", max("id").into()) als No-op, um die Zeilenidentität stabil zu halten. Diese Form funktioniert weiterhin. Beachte: .values(cols).annotate(window) funktioniert nicht — ein Fenster aggregiert nicht und liefert ValuesRequiresAggregate.

Frame-Klauseln:

use rustango::core::{FrameBoundary, FrameKind, WindowFrame};

// Running total over the last 7 rows:
let frame = WindowFrame {
    kind: FrameKind::Rows,
    start: FrameBoundary::Preceding(6),
    end: Some(FrameBoundary::CurrentRow),
};

// Centered 11-row window:
let frame = WindowFrame {
    kind: FrameKind::Rows,
    start: FrameBoundary::Preceding(5),
    end: Some(FrameBoundary::Following(5)),
};

Tri-dialektische Emission:

<fn>(args) OVER (PARTITION BY … ORDER BY … [frame]) ist SQL-Standard — identisch über PG, MySQL 8+ und SQLite 3.25+. Die eine Eigenart: LAG / LEAD / NTILE erfordern Integer-Offsets/-Buckets auf PG (das Binden als bigint-$N-Parameter verursacht function lag(bigint, bigint, bigint) does not exist). Der Writer inlint Integer-Literale für diese Slots direkt ins SQL; Default-Wert-Argumente werden normal gebunden.

Vorbehalte:

  • FILTER + Window noch nicht unterstützt: das Kombinieren von .filter(...) mit einer Fensterfunktion wirft SqlError::NestedAggregateWrapper { wrapper: "Filtered(Window)" } — die zugrunde liegende Syntax variiert je nach Funktionsart (PG erlaubt agg_fn() FILTER (WHERE …) OVER (…) für Aggregat-Fensterfunktionen, aber nicht für Ranking-Funktionen), und dem Writer wurde der Dispatch noch nicht beigebracht. Für eine Nachfolge vorgemerkt, falls Nachfrage aufkommt.
  • PercentRank / CumeDist / NthValue sind nicht in v1 — SQL kennt mehr Fensterfunktionen, als hier abgedeckt sind. v1 liefert die 8 meistgenutzten Varianten; die fehlenden drei können inkrementell mit derselben Builder-Form hinzugefügt werden.

Auf Aggregaten filtern (HAVING)

Ein .filter(...)-Aufruf nach .annotate(...) landet entweder in WHERE oder HAVING, je nachdem, ob der Name einem Aggregat-Alias entspricht. So fügt das Filtern auf einer echten Spalte ein WHERE hinzu, während das Filtern auf einer Annotation wie post_count ein HAVING hinzufügt:

use rustango::core::aggregates::count_all;
use rustango::core::Op;

// "Authors with > 10 published posts" — the canonical pattern.
// status='published' is on the model       → routes to WHERE.
// post_count > 10 references the annotation → routes to HAVING.
let q = Post::objects()
    .aggregate()
    .group_by("author_id")
    .annotate("post_count", count_all().into())
    .filter("status",     Op::Eq, "published")
    .filter("post_count", Op::Gt, 10_i64)
    .compile()?;
let rows = rustango::sql::fetch_aggregate_dict(&pool, &q).await?;

Emittiert, auf PG:

SELECT "author_id", COUNT(*) AS "post_count"
FROM "post"
WHERE "status" = $1
GROUP BY "author_id"
HAVING COUNT(*) > $2

Der Aggregatausdruck wird in HAVING gehoben, nicht der SELECT-Alias. PG verbietet Aliase in HAVING strikt (nur der Ausdruck löst auf); MySQL + SQLite sind nachsichtiger. Der Writer emittiert die gehobene Form uniform über alle drei, sodass dieselbe Abfrage überall funktioniert.

Die Kettenreihenfolge ist in v1 wichtig. Rufe .annotate(alias, ...) VOR dem entsprechenden .filter(alias, ...) auf. Ist die Reihenfolge umgekehrt, schlägt filter() eine leere Annotationsregistrierung nach und leitet auf WHERE — und der resolve_pending-Validator liefert UnknownField bei compile(), weil der Alias keine echte Model-Spalte ist. Eine v0.50-Nachfolge könnte diese Auflösung auf die Query-Konstruktionszeit verschieben und die Reihenfolge damit egal machen.

Validator-Lücke (entspricht der bestehenden Aggregat-Haltung): alias-geroutete HAVING-Prädikate überspringen den Model-Schema-Spaltendurchlauf. Falsch geschriebene Aliase tauchen bei der Datenbank auf, nicht bei compile(). Gleiche Lücke wie Sum("typo_col") — vorbestehend und orthogonal.

Unterstützte Ops auf alias-geroutetem .filter() (Issue #87): die Binärvergleichs-Menge (Op::Eq / Ne / Lt / Lte / Gt / Gte) plus die SQL-92-Standard-Prädikate, die gegen einen Aggregat-LHS uniform über jedes Backend komponieren — Op::In / NotIn, Between, IsNull, Like / NotLike, ILike / NotILike. Jedes emittiert die vorhersehbare Form:

use rustango::core::{Op, SqlValue};

// HAVING COUNT(*) IN ($1, $2, $3)
Post::objects()
    .aggregate()
    .group_by("author_id")
    .annotate("post_count", count_all().into())
    .filter("post_count", Op::In, SqlValue::List(vec![5_i64.into(), 10_i64.into(), 20_i64.into()]))
    .compile()?;

// HAVING COUNT(*) BETWEEN $1 AND $2
.filter("post_count", Op::Between, SqlValue::List(vec![5_i64.into(), 10_i64.into()]))

// HAVING COUNT(*) IS NULL  /  IS NOT NULL  (bool: true = IS NULL)
.filter("post_count", Op::IsNull, SqlValue::Bool(false))

// HAVING MAX("name") LIKE $1  /  ILIKE $1 (PG) / LOWER(MAX("name")) LIKE LOWER(?) (MySQL/SQLite)
.filter("max_name", Op::ILike, "SMITH%")

Die verbleibenden Ops — die JSON-Op-Familie (JsonContains / JsonContainedBy / JsonHasKey / JsonHasAnyKey / JsonHasAllKeys) und die null-sichere Gleichheit (IsDistinctFrom / IsNotDistinctFrom) — brauchen weiterhin dialektspezifische Writer, die ein &str für den LHS nehmen, sodass sie bei compile() mit QueryError::HavingOpNotSupported { alias, op } ablehnen. Für die steig in die typisierte .having(<TypedExpr>)-Form mit einem vorgebauten Prädikat ein.

Param-Vektor-Aufblähung mit nicht-trivialen Aggregaten: wenn der Alias eine Filtered { Count, filter: pred }- oder Coalesced { Sum, default: 0 }-Annotation anvisiert, hebt der Writer den gesamten Aggregatausdruck in HAVING — inklusive seiner inneren Prädikate und Defaults. Ihre gebundenen Literale bekommen frische Parameter-Slots in HAVING, getrennt von der SELECT-Listen-Emission. Konkret:

SELECT … COUNT(*) FILTER (WHERE "status" = $1) AS "published_count" …
HAVING COUNT(*) FILTER (WHERE "status" = $2) > $3
              -- "published" bound twice (once at $1, once at $2)

Die SQL-Semantik ist unverändert (dieselben Zeilenzahlen kommen zurück), aber stmt.params.len() wächst pro .filter()-Aufruf, der einen nicht-trivialen Alias anvisiert. Für COUNT(*)-Aliase (keine inneren Literale) ist die Aufblähung null. Dokumentiere das, wenn deine Testsuite Parameterzählungen festnagelt.


Joins & Vorladen verwandter Zeilen

Zieh ein Foreign-Key-Ziel zusammen mit der Hauptzeile in einer einzigen Abfrage, sodass du nicht eine zusätzliche Abfrage pro Zeile abfeuerst (das N+1-Problem). .select_related("author") lädt die Relation vorab mit (Eager Loading). Ein ForeignKey<T>-Feld kommt dann bereits befüllt an, statt einen separaten Lookup zu brauchen.

let posts = Post::objects()
    .select_related("author")              // JOIN posts.author -> authors.id
    .fetch(&pool).await?;

for post in &posts {
    let author = post.author.value().unwrap();   // already loaded, no DB round-trip
    println!("{} by {}", post.title, author.name);
}

select_related löst FK-Felder zur Kompilierzeit des Querysets auf. Das ForeignKey<T>-Feld auf dem Elternteil geht von Unloaded(pk) zu Loaded { pk, value }.

Für Reverse-FKs (parent.children) verwende die makro-generierte _set-Methode:

let author_posts = author.post_set(&pool).await?;

Benutzerdefinierte Joins

Wenn der Join nicht von einem Foreign Key getrieben wird — ein benutzerdefiniertes Prädikat, ein Non-Equi-Join, INNER statt LEFT, ein Self-Join oder ein Join auf einer Nicht-PK-Spalte — verwende .join(Join { … }). Sein on-Feld nimmt jedes WhereExpr, sodass and() / or() / Not / Funktionsaufrufe / Spalte-gegen-Spalte / Literalfilter alle frei komponieren.

use rustango::core::joins::aliased;
use rustango::core::{Join, JoinKind, Op, WhereExpr};

// "Posts that have at least one APPROVED comment" — INNER JOIN with
// an extra predicate inside the ON. Posts with no approved comment
// drop out; LEFT JOIN would keep them.
Post::objects()
    .join(Join {
        target: Comment::SCHEMA,
        alias: "c",
        kind: JoinKind::Inner,
        on: WhereExpr::And(vec![
            // Column-on-column condition — both sides aliased.
            WhereExpr::ExprCompare {
                lhs: aliased("c", "post_id"),
                op: Op::Eq,
                rhs: aliased("post", "id"),
            },
            // Bare Filter — unqualified columns inside `on` resolve
            // to the joined alias ("c"), so this becomes
            // `"c"."is_approved" = $N`.
            Comment::is_approved.eq(true).into(),
        ]),
        project: vec![],
    })
    .fetch(&pool).await?;

Spaltenqualifizierungsregeln innerhalb on:

  • Nackte Filter / ColumnFilter-Spalten + F()-Spaltenreferenzen lösen zum gejointen Alias auf (<alias>, den du übergeben hast). Das ist die natürliche Lesart, weil der Großteil eines ON-Prädikats über die gejointe Tabelle geht.
  • aliased(alias, col) emittiert "<alias>"."<col>" explizit — verwende das für Querverweise zurück auf die äußere Tabelle (aliased("<outer_table>", "<col>")) oder auf einen zuvor gejointen Alias.
  • WhereExpr::ExprCompare { lhs, op, rhs } ist die richtige Form für Spalte-gegen-Spalte-Vergleiche über Tabellen, da beide Seiten jedes Expr nehmen.

⚠️ GEFÄHRLICHES MUSTER — typisierte Filter vom ÄUSSEREN Model innerhalb on. Post::status.eq("draft").into() erzeugt ein WhereExpr::Predicate(Filter { column: "status", ... }) und verwirft das Post-Model-Tag an der Into<WhereExpr>-Grenze. Die Auto-Qualifizierungsregel oben leitet diesen Filter dann fälschlich zum gejointen Alias, nicht zu Post. Du bekommst "<joined_alias>"."status" = $N — falsche Tabelle — und der Compiler kann es nicht fangen. Verwende joins::col_filter für Prädikate gegen jede Spalte, deren Tabelle nicht der Default-Alias des Joins ist:

use rustango::core::joins::{aliased, col_filter};
use rustango::core::Op;

// SAFE: explicit alias on the LHS.
col_filter("post", "status", Op::Eq, "draft")

Reserviere nackte typisierte Filter (Comment::is_approved.eq(true).into()) nur für Spalten auf dem GEJOINTEN Model — niemals für Spalten des äußeren Models.

JoinKind-Tri-Dialekt-Unterstützung:

ArtPGMySQLSQLite
Inner✓✓✓
Left (Standard)✓✓✓
Right✓✓✗ SqlError::JoinKindNotSupported
Full✓✗✗

Right ist leicht zu umgehen — tausche die Operanden und verwende Left. Full auf MySQL wird üblicherweise mit (LEFT JOIN) UNION (RIGHT JOIN) emuliert, wenn du es wirklich brauchst.

Andere Fehler zur Emit-Zeit:

  • Leeres on-Prädikat (WhereExpr::And(vec![]) oder keine ExprCompares) wird mit SqlError::EmptyJoinOnCondition abgelehnt. SQL erfordert mindestens ein boolesches Prädikat innerhalb ON; die Auto-true-Kurzform vom Top-Level-WHERE gilt hier nicht.

project ist derzeit tote Daten bei Ad-hoc-Joins.

Das Join.project-Feld weist den Writer an, <alias>"."<col>" AS "<alias>__<col>"-Spalten in der SELECT-Liste zu emittieren. Heute dekodiert nur select_related diese tatsächlich (über den Voll-Zeilen-Decoder des FK-Ziels); Ad-hoc-Joins emittieren die Spalten, aber der Vec<MainModel>-Decoder ignoriert sie, sodass das Befüllen von project bei einem Ad-hoc-Join nur Bytes an die Leitung anfügt. Lass es als vec![], bis Projektionsverengung + Tupel-Decoding landen.

Wann man zu Ad-hoc-Joins greift:

BedarfWerkzeug
Verwandte Zeilen zusammen mit der Hauptzeile ziehenselect_related
Hauptzeilen nach einem Prädikat der verwandten Tabelle filternexists(...) / not_exists(...)
Per INNER statt LEFT filtern oder mit zusätzlichen ON-Prädikaten.join(...)
Self-Join (z. B. employee.manager_id = manager.id).join(...)
Anti-Join (Zeilen in A mit KEINEM Treffer in B)not_exists(...)

select_related bleibt das richtige Werkzeug, wenn der Join "folge diesem FK und projiziere alle seine Spalten" ist. Ad-hoc-Joins sind der Notausstieg, wenn du brauchst: einen Nicht-FK-Join-Schlüssel, INNER statt LEFT, ein zusätzliches Prädikat innerhalb des ON oder einen Self-Join.


Nur einige Felder speichern

Schreib nur die Felder, die du geändert hast, statt jeder Spalte. Ein normales Speichern überschreibt jede Nicht-PK-Spalte; save_partial(&[...], &pool) überschreibt nur die, die du benennst.

let mut post = Post::objects().fetch(&pool).await?.pop().unwrap();
post.title = "new title".into();
post.save_partial(&["title"], &pool).await?;  // SET "title" = $1
                                                  // — leaves body, status, views untouched

Zwei Motivationen:

  • Performance. Breite Zeilen mit TEXT / JSON / bytea-Spalten zahlen dafür, jedes Feld bei jedem save() neu zu binden und neu zu schreiben, selbst wenn nur eines mutierte. save_partial hält die SET-Klausel auf genau das, was sich geändert hat.
  • Nebenläufigkeitssicherheit. Wenn zwei Schreiber nach einem gemeinsamen Read auseinanderlaufen, überschreibt der Verlierer stillschweigend die Edits des Gewinners auf Feldern, die er nicht angefasst hat. Nur das Feld zu benennen, das du tatsächlich geändert hast, bewahrt die Arbeit des anderen Schreibers überall sonst.
// Writer A — flips title.
a.title = "from-A".into();
a.save_partial(&["title"], &pool).await?;

// Writer B — started from the same read, flips status.
// B's local `title` is stale, but it's not in the list, so A's
// write survives.
b.status = "from-B".into();
b.save_partial(&["status"], &pool).await?;

Feldnamen sind Struct-Felder auf Rust-Seite, keine SQL-Spalten — ["author_id"] (nicht ["author"] für ein FK-typisiertes Feld). Unbekannte Feldnamen geben ExecError::Query(QueryError::UnknownField) zurück. Eine leere Liste ist eine No-op (gibt Ok(()) zurück und loggt ein tracing::warn!) — es gibt schlicht nichts zu schreiben. Auditierte Models (#[rustango(audit(...))]) verengen den Audit-Log-Snapshot auf dieselbe Spaltenmenge — das Log spiegelt genau das wider, was geschrieben wurde.

Auto-PK-Hinweis. save_partial ist nur UPDATE; es auf einem Auto::Unset-PK aufzurufen ist ein Benutzerfehler (verwende dafür insert_pool / save_pool). Anders als save_pool, das automatisch Unset → insert_pool dispatcht, nimmt diese Methode an, dass du bereits eingefügt hast.

Kompilierzeit-geprüfte Feldliste

Die string-geschlüsselte Form oben passt für dynamische Feldlisten (Admin-Formulare, API-Payloads). Wenn die Liste in deinem Code fest ist, fängt save_partial_typed((Post::title, ...), &pool) falsch geschriebene oder umbenannte Felder zur Kompilierzeit statt zur Laufzeit:

post.save_partial_typed((Post::title, Post::slug), &pool).await?;
//                       ──────────  ──────────
//                       title_col   slug_col   ← distinct ZSTs

Jedes Post::<field> ist sein eigener Zero-Sized Type — ein homogener Slice (&[Post::title, Post::slug]) typprüft in Rust nicht, sodass die API stattdessen ein Tupel nimmt. Ein-Feld-Aufrufe verwenden das Nachlaufkomma-Idiom: (Post::title,). Tupel werden von Stelligkeit 1 bis 12 unterstützt — darüber hinaus wechsle zu save_partial(&[&str], _).

Modellübergreifende Tupel sind ein Kompilierfehler — (Post::title, Author::name) scheitert an der TypedFieldList<Post>-Trait-Schranke, weil Author::names Column::Model = Author ist. Das ist der Kernvorteil gegenüber der string-geschlüsselten Form: Rename-Refactorings auf einem Spaltennamen tauchen an der typisierten Aufrufstelle auf, nicht zur Laufzeit.

Senkt intern zu save_partial ab — gleiche Audit-Verengung, gleiche Auto::Unset-Beschränkung, gleiche Leere-Liste-No-op-Semantik.


Massenoperationen

Nur Postgres. bulk_insert / bulk_insert_on und upsert / upsert_on werden unter #[cfg(feature = "postgres")] erzeugt und nehmen einen &PgPool oder einen Postgres-Executor — auf einem MySQL- oder SQLite-Build existieren sie nicht. Einen tri-dialektalen einfachen Bulk-Insert gibt es nicht; die Mehr-Backend-Batch-Writer sind bulk_upsert_pool und bulk_insert_or_ignore_pool, beide mit &Pool. Zusammen mit dem übrigen Namens-Split verfolgt in #1293.

Fallstrick — Massenoperationen überspringen Per-Zeile-Hooks. bulk_insert, Queryset .update().execute() und .delete() laufen als mengenbasiertes SQL: sie feuern keine Signale, schreiben nicht den Audit-Trail, routen nicht durch Soft-Delete und führen keine Per-Zeile-Validierung aus. Verwende sie für Geschwindigkeit; wechsle zu Per-Zeile-save() / delete(), wenn du diese Seiteneffekte brauchst.

Füge viele Zeilen in einem Statement ein, aktualisiere oder lösche sie, statt eine Anweisung pro Zeile abzusetzen. Der as _-Import bringt die Methoden eines Traits in Scope, ohne den Trait direkt zu benennen.

// Bulk INSERT — rows FIRST (a `&mut [Self]`), executor/pool second.
let mut rows = [p1, p2, p3];
Post::bulk_insert_on(&mut rows, &pool).await?;

// Bulk UPDATE — applies the same set to every matched row. `.set`
// takes a string column name.
Post::objects()
    .where_(Post::status.eq("draft"))
    .where_(Post::created_at.lt(thirty_days_ago))
    .update()
    .set("status", "archived")
    .execute_on(&pool).await?;

// Bulk DELETE
Post::objects()
    .where_(Post::deleted_at.is_not_null())
    .delete_on(&pool).await?;

Einfügen oder aktualisieren (Upsert)

Füge eine Zeile ein oder aktualisiere sie, wenn eine Zeile mit demselben Schlüssel bereits existiert — ein Upsert. Es emittiert das native ON CONFLICT … DO UPDATE der Datenbank.

Das Einzelinstanz-.upsert_on(executor) kollidiert auf dem Primärschlüssel: mit einem Auto::Unset-PK weist der Server einen neuen Schlüssel zu (äquivalent zu insert); mit einem Auto::Set-PK wird die Zeile eingefügt, wenn abwesend, oder alle Nicht-PK-Spalten werden überschrieben, wenn vorhanden.

// Upsert on the PK — INSERT, or UPDATE every non-PK column if the
// PK already exists.
post.upsert_on(&pool).await?;

Um auf einem beliebigen Unique-Schlüssel zu upserten, verwende den Bulk-Helper — er nimmt die Zeilen, die Konfliktziel-Spalten, die bei Konflikt zu aktualisierenden Spalten und den Pool ZULETZT:

// ON CONFLICT (external_id) DO UPDATE SET title = EXCLUDED.title
Post::bulk_upsert_pool(
    &[post],
    &["external_id"],          // conflict target (unique key)
    &["title"],                // columns to overwrite on conflict
    &pool,
).await?;

Transaktionen

Diese Beispiele sind Postgres. pool.begin() ist sqlx::PgPool::begin — rustango::sql::Pool hat überhaupt kein begin(). Und die Methoden, die du in einer Transaktion ausführen würdest (fetch_on, save_on, delete_on), sind selbst #[cfg(feature = "postgres")].

Die Mehr-Backend-Einstiegspunkte sind rustango::sql::transaction_pool(&pool) und rustango::sql::atomic(&pool, …). Beide liefern ein PoolTx — ein Enum, auf das du pro Backend matchst — statt einer Treiber-Transaktion, sodass eine tri-dialektale Transaktion pro Zweig geschrieben wird und nicht durch Tausch des Pool-Typs.

Fallstrick — mische keine &pool-Aufrufe innerhalb einer Transaktion. Jeder Aufruf zwischen pool.begin() und commit muss das Transaktions-Handle (&mut *tx) anvisieren. Ein verirrtes &pool / fetch() / save_on(&pool) checkt eine zweite Verbindung aus und kann den Pool unter Last deadlocken. Fädle die tx durch, oder verwende rustango::sql::atomic.

Führe mehrere Schreibvorgänge als eine Einheit aus, die entweder alle gelingen oder alle zurückrollen — eine Transaktion. Öffne eine mit pool.begin() und führe jedes Statement gegen die Verbindung der Transaktion über die _on-Methoden (fetch_on, save_on) aus, sodass die Arbeit auf der laufenden Transaktion landet statt auf einer frischen gepoolten Verbindung.

let mut tx = pool.begin().await?;

let mut a = Account::objects()
    .where_(Account::id.eq(1))
    .fetch_on(&mut *tx).await?
    .pop().unwrap();
let mut b = Account::objects()
    .where_(Account::id.eq(2))
    .fetch_on(&mut *tx).await?
    .pop().unwrap();

a.balance -= 100;
b.balance += 100;
a.save_on(&mut *tx).await?;
b.save_on(&mut *tx).await?;

tx.commit().await?;

Verwirf die tx, ohne commit() aufzurufen (z. B. bei einem frühen ?-Return), und die Transaktion rollt zurück. Für einen Nach-Commit-Hook ist der Scope rustango::sql::atomic(&pool, |tx| Box::pin(async move { … })), der bei Ok automatisch committet und bei Err zurückrollt — der Hook selbst ist rustango::sql::on_commit(|| { … }) und wird innerhalb dieser Closure aufgerufen. atomic leert die Queue, nachdem der Commit durch ist; ein on_commit außerhalb eines atomic-Scopes paniert, statt den Callback zu verwerfen.


Many-to-many

Verknüpfe viele Zeilen mit vielen anderen über eine Verknüpfungstabelle — eine Many-to-many-Relation. Deklariere die Relation auf dem Model, verwende dann den generierten Accessor, um die verknüpften IDs hinzuzufügen, zu entfernen, zu setzen oder aufzulisten.

#[rustango(
    table = "posts",
    m2m(name = "tags", to = "tags", through = "post_tags",
        src = "post_id", dst = "tag_id"),
)]
pub struct Post { ... }

Verwende den auto-generierten Accessor:

let tag_ids: Vec<i64> = post.tags_m2m().all(&pool).await?;
post.tags_m2m().add(42, &pool).await?;
post.tags_m2m().remove(42, &pool).await?;
post.tags_m2m().set(&[1, 2, 3], &pool).await?;        // replace all
post.tags_m2m().clear(&pool).await?;
let has = post.tags_m2m().contains(42, &pool).await?;

Die Verknüpfungstabelle (post_tags) wird von make_migrations automatisch erstellt mit zusammengesetztem PK + zwei FKs ON DELETE CASCADE. Derzeit hat die Verknüpfungstabelle nur die zwei FK-Spalten — für zusätzliche Spalten (added_by, order, created_at) definierst du ein separates Model und traversierst manuell, bis "custom through model" landet.


JSON / JSONB

Speichere und frage ein JSON-Dokument in einer Spalte ab. Deklariere das Feld als serde_json::Value (den generischen JSON-Typ), frage es dann mit json_contains oder einem Pfadfilter ab.

#[derive(Model)]
pub struct Event {
    #[rustango(primary_key)]
    pub id: Auto<i64>,
    #[rustango(default = r#"'{}'::jsonb"#)]
    pub data: serde_json::Value,
}

JSON-Inhalte abfragen:

use rustango::core::{Expr, Op, SqlValue, WhereExpr};
use rustango::core::funcs::json_path;
use rustango::core::F;

let with_email = Event::objects()
    .where_(Event::data.json_contains(serde_json::json!({"email_set": true})))
    .fetch(&pool).await?;

// Path extract — `json_path(F("data"), &["type"], true)` builds the
// `data ->> 'type'` text-extract LHS; compare it via `where_raw`.
let typed = Event::objects()
    .where_raw(WhereExpr::ExprCompare {
        lhs: json_path(F("data"), &["type"], true),
        op: Op::Eq,
        rhs: Expr::Literal(SqlValue::String("user.created".into())),
    })
    .fetch(&pool).await?;

Lies/schreibe Rust-Typen über serde_json::from_value / to_value.


Soft Delete

Markiere eine Zeile als gelöscht, indem du einen Zeitstempel setzt, statt sie zu entfernen — ein Soft Delete. Markiere die Zeitstempel-Spalte mit dem #[rustango(soft_delete)]-Attribut (eine Derive-Annotation, die dem Makro sagt, wie das Feld zu behandeln ist):

#[derive(Model)]
pub struct Post {
    #[rustango(primary_key)]
    pub id: Auto<i64>,
    pub title: String,
    #[rustango(soft_delete)]
    pub deleted_at: Option<DateTime<Utc>>,
}

Verwendung:

post.soft_delete_on(&pool).await?;     // sets deleted_at = NOW()
post.restore_on(&pool).await?;          // sets deleted_at = NULL

// Default queries DO include soft-deleted rows. Filter explicitly:
let live = Post::objects().where_(Post::deleted_at.is_null()).fetch(&pool).await?;

Der "Löschen"-Button des Admins routet automatisch zu soft_delete_on für jedes Model, das die Spalte hat. Standardabfragen enthalten weiterhin soft-gelöschte Zeilen, aber du musst den Filter nicht mehr selbst schreiben: .active() schließt sie aus, .only_trashed() liefert nur sie, .with_trashed() nimmt sie wieder auf. Den Ausschluss zum Default zu machen wird in #820 verfolgt.


Audit-Trail

Zeichne auf, wer welche Felder wann geändert hat, automatisch bei jedem Speichern und Löschen — ein Änderungsprotokoll pro Zeile. Annotiere das Model mit den zu verfolgenden Feldern:

#[derive(Model)]
#[rustango(audit(track = "title, body, status"))]
pub struct Post { ... }

Jedes Speichern/Löschen schreibt eine Zeile in rustango_audit_log mit einem before / after JSONB-Diff für die aufgelisteten Felder. Setze die Quelle pro Request:

use rustango::audit::{with_source, AuditSource};

with_source(
    AuditSource::User { id: user_id.to_string() },
    async {
        post.save_on(&pool).await
    },
).await?;

Das Per-Zeile-Verlaufspanel des Admins liest aus dieser Tabelle; der modellübergreifende Feed ist unter /__audit.

Bereinigung:

rustango::audit::cleanup_older_than(&pool, 90).await?;       // delete > 90 days
rustango::audit::cleanup_keep_last_n(&pool, 50).await?;      // keep most recent 50/row

// CLI
manage audit-cleanup --days 90
manage audit-cleanup --keep-last 50 --tenant acme

Raw-SQL-Notausstieg

Steig auf handgeschriebenes SQL um, wenn der Query-Builder nicht ausdrücken kann, was du brauchst. Die sqlx-Makros führen eine Abfrage aus und dekodieren das Ergebnis in ein Tupel, ein typisiertes Model oder nichts:

use rustango::sql::sqlx;

// Raw query → typed rows
let rows = sqlx::query_as::<_, (i64, String)>("SELECT id, title FROM posts WHERE views > $1 ORDER BY views DESC")
    .bind(1000)
    .fetch_all(&pool)
    .await?;

// Raw with model decoding
let posts: Vec<Post> = sqlx::query_as::<_, Post>("SELECT * FROM posts WHERE complicated_condition")
    .fetch_all(&pool)
    .await?;

// Raw without rows (DDL / DML)
sqlx::query("REINDEX TABLE posts").execute(&pool).await?;

Für programmatisches rohes SQL innerhalb der Rustango-Query-Schicht (tri-dialektisch; nimmt das SQL, ein Vec<SqlValue> von Binds, dann den Pool ZULETZT, und gibt Vec<T> zurück):

use rustango::sql::raw_query_pool;

let rows = raw_query_pool::<(i64,)>(
    "SELECT COUNT(*) FROM posts WHERE complicated",
    vec![],
    &pool,
).await?;
let count = rows.first().map(|r| r.0).unwrap_or(0);

Lazy-FK-Laden

Ein Foreign Key hält anfangs nur die verwandte ID (Unloaded), und du holst die volle verwandte Zeile erst, wenn du danach fragst (Lazy Loading). match auf den ForeignKey, um beide Zustände zu behandeln, oder rufe .get(&pool) auf, um ihn bei Bedarf zu laden. Für einen ganzen Batch verwende select_related (oben), um sie in einer Abfrage vorzuladen und den Per-Zeile-Fetch zu überspringen.

let mut post = Post::objects().find_or_fail(1, &pool).await?;

// FK starts Unloaded — just the PK. `Loaded` is a struct variant
// `{ pk, value }`; `value` is a `Box<Author>`.
match &post.author {
    ForeignKey::Unloaded(pk) => println!("author id = {pk}"),
    ForeignKey::Loaded { pk, value } => println!("author = {}", value.name),
}

// Force-load
let author = post.author.get(&pool).await?;          // fetches if Unloaded

Verwende select_related("author") auf dem Queryset, um einen Batch vorzuladen.


Vier Wege zu filtern

Es gibt vier Wege, einen Filter auszudrücken; wähle nach Kontext. Typisierte Spalten werden zur Kompilierzeit geprüft und sind am besten für App-Code; die field__lookup-String-Form ist die passende Syntax für Admin und generisches CRUD; filter_op ist dafür, wenn du bereits ein Op hältst; der HTTP-Query-String treibt die öffentliche API.

// 1. HTTP query string (set via ViewSet filter_fields)
//    GET /api/posts?author_id=42&status__ne=archived

// 2. String lookup (the same `field__lookup` grammar your
//    URL parser uses, but inside Rust). Suffix decides the operator
//    and value-shape; bare key is exact-eq. Field name is validated
//    at `.compile()`.
Post::objects()
    .filter("status", "published")                 // exact-eq
    .filter("title__icontains", "rust")            // ILIKE %rust%
    .filter("views__gt", 100_i64);

// 3. Explicit operator (legacy 3-arg shape — when you want to pass
//    an Op directly without parsing a suffix)
Post::objects().filter_op("author_id", Op::Eq, SqlValue::I64(42));

// 4. Typed columns (compile-time field check; preferred in app code)
Post::objects().where_(Post::author_id.eq(42));

Konvention: typisiert im App-Code, String-Form in Admin- / generischem CRUD-Code, filter_op nur, wenn du bereits ein Op berechnet hast (z. B. aus einem Request-Parser), HTTP-Query für die öffentliche API-Oberfläche.

Unterstützte Lookup-Suffixe

SuffixSQL-OperatorWertformHinweise
(keins) / __exact=Skalarnackter Schlüssel ist exact-eq
__ne<>Skalar
__gt / __gte / __lt / __lte> >= < <=Skalar
__containsLIKEStringumschließt den Wert als %v%
__icontainsILIKEStringumschließt den Wert als %v%; MySQL emuliert über LOWER()
__startswithLIKEStringumschließt als v%
__istartswithILIKEStringumschließt als v%
__endswithLIKEStringumschließt als %v
__iendswithILIKEStringumschließt als %v
__iexactILIKEStringkein Wildcard-Umschließen — exakter case-insensitiver Treffer
__inIN (…)SqlValue::Listlehnt Nicht-Listen-Werte ab
__isnullIS NULL / IS NOT NULLbooltrue → IS NULL, false → IS NOT NULL
__between / __rangeBETWEEN … AND …2-elementige SqlValue::Listan beiden Enden inklusiv
__regex / __iregexPG ~ / ~*, MySQL/SQLite REGEXPStringcase-insensitiv emuliert auf MySQL/SQLite über LOWER()-Umschließung; SQLite braucht eine regexp-User-Funktion

LIKE-Metazeichen werden escaped. __contains / __startswith / __endswith (und ihre i-Varianten) behandeln den Wert als wörtlichen Teilstring — ein % oder _ darin passt auf sich selbst, nicht als Platzhalter. Das Framework escaped sie und gibt ESCAPE '!' aus, auf allen drei Dialekten wirksam (#1257). Für ein rohes Muster mit eigenen % / _ nutze __like / __ilike, die den Wert wörtlich binden.

Fehler tauchen bei .compile() auf, nicht zur .filter()-Aufrufzeit — Wertform-Diskrepanzen (z. B. __in mit einem Skalar, __isnull mit einem Nicht-Bool, __between mit falscher Stelligkeit) und unbekannte Suffixe (status__nope) geben QueryError::UnknownLookup / QueryError::InvalidLookupValue von .compile() zurück, sodass die fluente Kette typsauber bleibt. Verkettete Traversierungen (author__name__icontains) werden in v0.39 nicht unterstützt — der Splitter nimmt das Suffix nach dem ersten __, sodass der ganze Schwanz name__icontains als unbekanntes Suffix behandelt wird.

Jeder Filteraufruf wird per AND mit allen vorhergehenden verknüpft; mische String-Form, filter_op und where_ frei auf demselben Queryset.


Mandantengebundene Abfragen

In einer Multi-Tenant-App führe jede Abfrage gegen die Verbindung des aktuellen Mandanten aus statt gegen den geteilten Pool. Hol dir eine Per-Request-Verbindung und übergib sie an fetch_on (das jeden Datenbank-Executor akzeptiert) statt an fetch (das immer &pool verwendet).

use rustango::extractors::Tenant;

async fn handler(mut t: Tenant) -> Result<...> {
    let conn = t.conn();        // &mut PgConnection for this tenant
    let posts = Post::objects().fetch_on(&mut *conn).await?;
    Ok(...)
}

fetch_on ist nur für Postgres (#[cfg(feature = "postgres")], gebunden an Database = sqlx::Postgres) und nimmt jeden Postgres-sqlx-Executor entgegen — &PgPool, &mut PgConnection oder eine Transaction. Es existiert genau für den Fall oben: Schema-Mode-Tenants teilen sich den Registry-Pool, verlassen sich aber auf ein SET search_path pro Checkout, sodass ein &PgPool stillschweigend das falsche Schema träfe.

fetch ist kein Zucker dafür. Es ist eine eigene FetcherPool-Methode, die &Pool nimmt, pro Dialekt dispatcht und auf allen drei Backends verfügbar ist — auf einem MySQL- oder SQLite-Build existiert von beiden also nur fetch.


Signale

Führe einen Callback aus, wenn etwas passiert — Signale. Es gibt zwei unabhängige Registrierungen: eine für Model-Schreibvorgänge, eine für HTTP-Requests.

Model-Lebenszyklus

Feure einen Hook vor oder nach dem Speichern oder Löschen eines Models: pre_save, post_save, pre_delete, post_delete. Registriere einen mit connect_post_save::<Post, _, _>(...).

use rustango::signals::{connect_post_save, PostSaveContext};

connect_post_save::<Post, _, _>(|post, ctx| async move {
    if ctx.created {
        tracing::info!("new post #{}", post.id.get().copied().unwrap_or(0));
    }
});

T: Clone + 'static ist erforderlich (der Dispatcher übergibt jedem Empfänger einen Arc<T>-Klon). Empfänger laufen sequenziell in Registrierungsreihenfolge. Trenne über die ReceiverId, die connect_* zurückgibt. Die vier Signalarten + ihre Kontextformen sind inline in rustango::signals dokumentiert.

Request-Lebenszyklus

Feure einen Hook um jeden HTTP-Request: request_started, request_finished, got_request_exception. Füge die RequestSignalsLayer-Middleware zu deinem Router hinzu, verbinde dann Callbacks. Nützlich für Tracing, Audit, Request-Zeit-Metriken und Fehlerberichterstattung.

use axum::Router;
use rustango::signals::request::{
    connect_request_started, connect_request_finished, RequestSignalsLayer,
};

connect_request_started(|ctx| Box::pin(async move {
    tracing::info!(method = %ctx.method, path = %ctx.path, "started");
}));
connect_request_finished(|ctx| Box::pin(async move {
    metrics::histogram!("http_request_ms").record(ctx.elapsed_ms);
}));

let app: Router = Router::new()
    .route("/", get(home))
    .layer(RequestSignalsLayer::new());  // outermost — sees request first / response last
SignalKontextfelder
request_startedmethod, path, query
request_finishedmethod, path, status, elapsed_ms
got_request_exceptionmethod, path, error

Empfänger laufen sequenziell in Registrierungsreihenfolge; umschließe einen Körper in tokio::spawn für parallelen Fanout oder Panic-Isolation. Die Request- und Model-Registrierungen sind unabhängig — das Verbinden / Trennen / Leeren der einen berührt die andere nicht.


Performance-Tipps

Eine schnelle Checkliste, um Abfragen schnell zu halten, während die Daten wachsen:

  • Verwende immer Indizes für WHERE- und ORDER BY-Spalten. Deklariere über #[rustango(index)], damit sie in den Migrationen sind.
  • select_related für FK-Anzeige in Listen — eliminiert N+1 in Admin-/Listenansichten.
  • page statt fetch().drain() — lade niemals ganze Tabellen.
  • Cursor-Paginierung für riesige Tabellen — überspringt COUNT(*) pro Seite.
  • bulk_insert_on für Batches — ein einziger Roundtrip statt N.
  • upsert_on für idempotente Importe — ON CONFLICT ist schneller als SELECT-dann-INSERT.
  • transaction für zusammenhängende Schreibvorgänge — reduziert Commit-Overhead und wahrt Konsistenz.
  • Cache heiße Reads mit cache::get_or_set — invalidiere im connect_post_save<T>(...)-Signalhandler.

Siehe auch

  • Models — ein Model deklarieren: Feldtypen, Primärschlüssel, jedes Attribut (der Begleiter zu diesem Query-Leitfaden).
  • Serializer — Model-Zeilen in JSON formen.
  • ViewSets — ein Model in eine JSON-CRUD-API verwandeln.
  • Der Admin — eine auto-generierte UI über denselben Models.
  • manage-CLI — makemigrations / migrate für Schemaänderungen.