12 Punkte von GN⁺ 2024-12-01 | 2 Kommentare | Auf WhatsApp teilen
  • „Was wäre, wenn man in Rust Daten sicher persistent speichern, komplexe Abfragen einfach schreiben und dabei keine einzige Zeile SQL verfassen könnte?“
    • Rust-query ist eine Bibliothek, die genau das ermöglichen soll

Rust und Datenbanken

  • Bestehende Datenbankbibliotheken für Rust bieten entweder zu wenige Garantien zur Compile-Zeit oder sind umständlich in der Nutzung und nicht so intuitiv wie SQL
  • Datenbanken spielen eine wichtige Rolle beim Aufbau konfliktvermeidender Software und bei der Unterstützung atomarer Transaktionen
  • SQL ist das Standardprotokoll für die Interaktion mit Datenbanken, eignet sich aber eher zur Generierung durch Computer und ist ineffizient, wenn Menschen es direkt schreiben

Einführung in Rust-query

  • rust-query ist eine Datenbank-Abfragebibliothek, die tief in das Rust-Typsystem integriert ist
  • Sie ist so entworfen, dass sich Datenbankoperationen in Rust wie native Sprachkonstrukte anfühlen

Hauptfunktionen und Designentscheidungen

  • Explizite Tabellen-Aliase: Stellt nach einem Tabellen-Join ein Dummy-Objekt bereit, das die jeweilige Tabelle repräsentiert (let user = User::join(rows);)
  • Null-Sicherheit: Optionale Werte in Abfragen werden mit Rusts Option-Typ verarbeitet
  • Intuitive Aggregatfunktionen: Unterstützt intuitive Aggregationen auf Zeilenebene ohne GROUP BY
  • Typsichere Navigation über Fremdschlüssel: Implizite Joins auf Basis von Fremdschlüsseln lassen sich einfach ausführen (track.album().artist().name())
  • Typsichere eindeutige Abfragen: Abfrage von Zeilen mit einer bestimmten Unique-Constraint (Option<Rating> wird zurückgegeben)
  • Schemas mit mehreren Versionen: Unterschiede zwischen allen Schema-Versionen lassen sich deklarativ nachvollziehen
  • Typsichere Migrationen: Zeilen können mit beliebigem Rust-Code verarbeitet werden
  • Typsichere Behandlung von Unique-Konflikten: Bei Konflikten mit einer Unique-Constraint wird ein spezifischer Fehlertyp zurückgegeben
  • An die Transaktionslebensdauer gebundene Zeilenreferenzen: Zeilenreferenzen sind nur gültig, solange die Zeile existiert
  • Gekapselte typisierte Zeilen-IDs: Zeilennummern werden außerhalb der API nicht offengelegt

Abfragen und Dateneinfügen

Schemadefinition

#[schema]  
enum Schema {  
    User {  
        name: String,  
    },  
    Story {  
        author: User,  
        title: String,  
        content: String,  
    },  
    #[unique(user, story)]  
    Rating {  
        user: User,  
        story: Story,  
        stars: i64,  
    },  
}  
use v0::*;  
  • Das Schema wird mit Rusts enum-Syntax definiert
  • Fremdschlüssel-Constraints werden erstellt, indem der Name einer anderen Tabelle als Spaltentyp angegeben wird
  • Mit dem Attribut #[unique] werden Unique-Constraints hinzugefügt
  • Das Makro #[schema] analysiert die Definition und erzeugt das Modul v0

Dateneinfügen

fn insert_data(txn: &mut TransactionMut<Schema>) {  
    let alice = txn.insert(User { name: "alice" });  
    let bob = txn.insert(User { name: "bob" });  
  
    let dream = txn.insert(Story {  
        author: alice,  
        title: "My crazy dream",  
        content: "A dinosaur and a bird...",  
    });  
  
    let rating = txn.try_insert(Rating {  
        user: bob,  
        story: dream,  
        stars: 5,  
    }).expect("no rating for this user and story exists yet");  
}  
  • Einfügeoperationen geben Referenzen auf die neu eingefügten Zeilen zurück
  • Beim Einfügen in Tabellen mit Unique-Constraints muss try_insert verwendet werden
  • try_insert gibt bei einem Konflikt einen spezifischen Fehlertyp zurück

Daten abfragen

fn query_data(txn: &Transaction<Schema>) {  
    let results = txn.query(|rows| {  
        let story = Story::join(rows);  
        let avg_rating = aggregate(|rows| {  
            let rating = Rating::join(rows);  
            rows.filter_on(rating.story(), &story);  
            rows.avg(rating.stars().as_float())  
        });  
        rows.into_vec((story.title(), avg_rating))  
    });  
  
    for (title, avg_rating) in results {  
        println!("story '{title}' has avg rating {avg_rating:?}");  
    }  
}  
  • rows repräsentiert die aktuelle Zeilenmenge in der Abfrage
  • Mit aggregate werden Aggregatoperationen ausgeführt
  • Ergebnisse können als Vektor von Tupeln oder Strukturen gesammelt werden

Schemaentwicklung und Migrationen

  • Beim Erstellen einer neuen Schema-Version wird das Attribut #[version] verwendet

Neue Schema-Version hinzufügen

#[schema]  
#[version(0..=1)]  
enum Schema {  
    User {  
        name: String,  
        #[version(1..)]  
        email: String,  
    },  
    // ... restliches Schema ...  
}  
use v1::*;  

Datenmigration

  • Migrationen werden sowohl für das alte als auch für das neue Schema typgeprüft
  • Zeilendaten können mit beliebigem Rust-Code verarbeitet werden (map_dummy wird verwendet)
let m = m.migrate(v1::update::Schema {  
    user: Box::new(|old_user| {  
        Alter::new(v1::update::UserMigration {  
            email: old_user  
                .name()  
                .map_dummy(|name| format!("{name}@example.com")),  
        })  
    }),  
});  

Fazit

  • rust-query schlägt einen neuen Ansatz für die Interaktion mit relationalen Datenbanken in Rust vor:
    • Compile-Zeit-Prüfungen
    • Mit Rust kombinierbare Abfragen
    • Unterstützung für Schemaentwicklung durch Typprüfung
  • Derzeit wird SQLite als einziges Backend unterstützt, wodurch sich das Projekt gut für die Entwicklung experimenteller Anwendungen eignet
  • Feedback ist über GitHub-Issues willkommen

2 Kommentare

 
halfenif 2024-12-02

| Es eignet sich dafür, von Computern erzeugt zu werden, und ist für Menschen ineffizient, es direkt zu schreiben.
Aus der Perspektive von jemandem, der in Korea ein nur dort existierendes "Next Generation"-Projekt mit dem Einsatz von mehr als 100 Entwicklern erlebt.

Sehr interessant.

Tatsächlich sind die meisten der eingesetzten Entwickler SQL-Experten, oder?

 
GN⁺ 2024-12-01
Hacker-News-Kommentare
  • Das Problem mit anwendungsdefinierten Schemata ist, dass sie von dem falschen System validiert werden. Die Datenbank ist die maßgebliche Instanz für das Schema, und alle anderen Anwendungsebenen treffen darauf basierende Annahmen. Rusts SQLx erzeugt Strukturen auf Basis von Datenbanktypen und validiert zur Compile-Zeit, garantiert aber nicht dieselben Typen wie in der Produktionsdatenbank. Wenn man Abfragen auf einem lokalen Postgres v15 entwirft und in der Produktion Postgres v12 läuft, kann es zu Laufzeitfehlern kommen. Anwendungsdefinierte Schemata vermitteln ein falsches Sicherheitsgefühl und verursachen zusätzliche Arbeit für Engineers.

  • SQL ist nicht perfekt, hat aber einige Vorteile. Die meisten Menschen kennen grundlegendes SQL, und die Dokumentation von Datenbanken wie PostgreSQL ist in SQL geschrieben. Externe Tools verwenden ebenfalls SQL, und bei Änderungen an Abfragen ist kein teurer Compile-Schritt nötig. SQLx vermeidet Probleme von Typsystemen, die die Compile-Zeit erhöhen, indem es Parameter typprüft und die Datenbank selbst die Abfrage validieren lässt. Bei neuen Datenbanken könnte sich eine bessere Abfragesprache durchsetzen, aber bei bestehenden SQL-Datenbanken ist SQLx die bessere Wahl.

  • Der Ansicht, dass SQL von Computern geschrieben werden sollte, wird widersprochen. SQL ist eine Hochsprache, sogar auf höherem Niveau als Python oder Rust. SQL wurde so entworfen, dass es leicht lesbar und einfach zu verwenden ist, und wird beim Kompilieren in mehrere Verfahrensschritte umgewandelt. SQL ist der Engpass in der Webentwicklung und der Ort, an dem Zustandsänderungen stattfinden. Gerade weil SQL eine Hochsprache ist, ist es schwer zu optimieren. SQL ist technische Schuld, aber es ist zehnmal effizienter, SQL zu verwenden, als eine passendere API zu entwickeln.

  • Es wird begrüßt, dass Rusts typesafe-db-access weiter erforscht wird. Bestehende Bibliotheken bieten keine Garantien zur Compile-Zeit und sind so weitschweifig oder sperrig wie SQL. diesel bietet Garantien zur Compile-Zeit. In der ORM-gegen-nicht-ORM-Debatte werden typsichere Query-Builder bevorzugt, und diesel fällt in diese Kategorie. Rust-query scheint eher in Richtung vollständiges ORM zu tendieren.

  • Der Ansatz, Schema und Datentypen miteinander zu verknüpfen, wird als interessant angesehen. Dass im Beispiel kein Schema-Enum vorhanden ist, wirkt nicht intuitiv. Wenn es innerhalb eines Makros definiert wäre, wäre es klarer.

  • Es ist verwirrend, dass in der Bibliotheks-API keine tatsächlichen Zeilen-IDs offengelegt werden. In einem Webserver sollte man Zeilen-IDs mit den Daten weitergeben können, damit das Frontend die Daten in anderen Requests referenzieren und ändern kann.

  • Der Meinung, dass SQL von Computern geschrieben werden sollte, wird teilweise zugestimmt, aber SQL ist nicht die bequemste Sprache für Code-Generatoren. Schon eine einfache Planoptimierung kann das Layout einer Abfrage vollständig verändern. Googles Vorschlag für SQL pipe ist zwar etwas verbessert, hat aber weiterhin die Probleme einer neuen Abfragesprache.

  • SeaQuery wurde zwar verwendet, aber für die Erzeugung komplexer Abfragen reicht die Dokumentation nicht aus. Stark typisierte Abfragen können den Entwicklungsprozess verlangsamen, daher wird erwogen, wieder zu herkömmlichen vorbereiteten Statements und Value Binding zurückzukehren.

  • Migrationen durch Manipulation auf Ebene einzelner Zeilen können extrem langsam sein. Auf einer Tabelle mit einer Milliarde Zeilen kann ein gewöhnliches UPDATE zum Beispiel bis zu einer Stunde dauern. Aktualisierungen Zeile für Zeile würden noch deutlich länger dauern.