12 pontos por GN⁺ 2024-12-01 | 2 comentários | Compartilhar no WhatsApp
  • "E se fosse possível persistir dados com segurança no Rust, escrever consultas complexas com facilidade e não precisar escrever uma única linha de SQL?"
    • Rust-query é uma biblioteca desenvolvida para tornar isso realidade

Rust e banco de dados

  • As bibliotecas de banco de dados existentes no Rust carecem de garantias em tempo de compilação ou são trabalhosas de usar e menos intuitivas que o ideal
  • Bancos de dados têm um papel importante na construção de software resistente a falhas e no suporte a transações atômicas
  • SQL é o protocolo padrão para interagir com bancos de dados, mas é mais adequado para ser gerado por computadores e ineficiente para ser escrito manualmente por humanos

Apresentando Rust-query

  • rust-query é uma biblioteca de consultas a banco de dados profundamente integrada ao sistema de tipos do Rust
  • Foi projetada para permitir operações de banco de dados no Rust de forma nativa

Principais recursos e decisões de design

  • Aliases de tabela explícitos: fornece objetos dummy que representam a tabela após joins (let user = User::join(rows);)
  • Segurança com null: valores opcionais na consulta são tratados com o tipo Option do Rust
  • Funções de agregação intuitivas: suporte a agregações intuitivas por linha sem GROUP BY
  • Navegação de chaves estrangeiras type-safe: permite joins implícitos com facilidade com base em chaves estrangeiras (track.album().artist().name())
  • Busca única type-safe: consulta linhas com uma restrição única específica (retorna Option<Rating>)
  • Schema multiversão: permite verificar declarativamente todas as diferenças entre versões do schema
  • Migrações type-safe: permite processar linhas usando código Rust arbitrário
  • Tratamento type-safe de conflitos de unicidade: retorna um tipo de erro específico quando há conflito com restrição única
  • Referências a linhas vinculadas ao tempo de vida da transação: referências a linhas só são válidas enquanto a linha existir
  • IDs de linha encapsulados por tipo: números de linha não são expostos fora da API

Consultas e inserção de dados

Definição do schema

#[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::*;  
  • O schema é definido usando a sintaxe enum do Rust
  • Restrições de chave estrangeira são criadas ao especificar o nome de outra tabela como tipo de coluna
  • Restrições de unicidade são adicionadas com o atributo #[unique]
  • A macro #[schema] analisa a definição e gera o módulo v0

Inserção de dados

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");  
}  
  • Operações de inserção retornam referências para as linhas recém-inseridas
  • Ao inserir em tabelas com restrições de unicidade, é necessário usar try_insert
  • try_insert retorna um tipo de erro específico em caso de conflito

Consulta de dados

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 representa o conjunto atual de linhas na consulta
  • Use aggregate para realizar operações de agregação
  • Os resultados podem ser coletados em um vetor de tuplas ou structs

Evolução de schema e migrações

  • Ao criar uma nova versão do schema, usa-se o atributo #[version]

Adicionando uma nova versão do schema

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

Migração de dados

  • As migrações são verificadas por tipo em relação ao schema anterior e ao novo
  • Os dados das linhas podem ser processados com código Rust arbitrário (usando map_dummy)
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")),  
        })  
    }),  
});  

Encerrando

  • rust-query propõe uma nova abordagem para interagir com bancos de dados relacionais no Rust:
    • verificação em tempo de compilação
    • consultas combináveis com Rust
    • suporte à evolução do schema por meio de verificação de tipos
  • Atualmente usa SQLite como único backend e é adequado para o desenvolvimento de aplicações experimentais
  • Feedback é bem-vindo via issues no GitHub

2 comentários

 
halfenif 2024-12-02

| É adequado que seja gerado pelo computador, e é ineficiente para uma pessoa escrever isso diretamente.
Do ponto de vista de quem participa desses projetos “de próxima geração”, algo que só existe na Coreia e em que são colocados mais de 100 desenvolvedores.

Muito interessante.

Na verdade, a maioria dos desenvolvedores envolvidos são especialistas em SQL, por assim dizer.

 
GN⁺ 2024-12-01
Comentários do Hacker News
  • A preocupação com esquemas definidos pela aplicação é que eles são validados pelo sistema errado. O banco de dados é a autoridade sobre o esquema, e todas as outras camadas da aplicação fazem suposições com base nele. O SQLx do Rust gera structs com base nos tipos do banco de dados e valida em tempo de compilação, mas não garante os mesmos tipos do banco de produção. Se você projetar a consulta em um Postgres v15 local e rodar em produção com Postgres v12, podem ocorrer erros em tempo de execução. Esquemas definidos pela aplicação passam uma falsa sensação de segurança e impõem trabalho extra aos engenheiros.

  • SQL não é perfeito, mas tem algumas vantagens. A maioria das pessoas conhece SQL básico, e a documentação de bancos de dados como PostgreSQL é escrita em SQL. Ferramentas externas também usam SQL, e alterar consultas não exige uma etapa cara de compilação. O SQLx evita problemas de sistemas de tipos que aumentam o tempo de compilação ao verificar os tipos dos parâmetros e deixar que o próprio banco de dados valide a consulta. Em bancos de dados novos, uma linguagem de consulta melhor pode vencer, mas em bancos SQL existentes, SQLx é a melhor escolha.

  • Há quem discorde da opinião de que SQL deve ser escrito por computadores. SQL é uma linguagem de alto nível, mais alto nível do que Python ou Rust. SQL foi projetada para ser fácil de ler e usar, e durante a compilação é transformada em vários procedimentos. SQL está no gargalo do desenvolvimento web, onde ocorrem as transformações de estado. Por ser uma linguagem de alto nível, SQL é difícil de otimizar. SQL é dívida técnica, mas usá-la é 10 vezes mais eficiente do que desenvolver uma API mais apropriada.

  • Há opiniões dizendo que é ótimo ver a exploração de typesafe-db-access em Rust. Bibliotecas existentes não oferecem garantias em tempo de compilação e são prolixas ou estranhas como SQL. O diesel oferece garantias em tempo de compilação. No debate ORM vs não ORM, há preferência por query builders com segurança de tipos, e o diesel entra nessa categoria. O Rust-query parece tender mais para o lado de um ORM completo.

  • Há opiniões de que a abordagem de conectar esquema e tipos de dados é interessante. No exemplo, o fato de não haver um enum Schema não é intuitivo. Ficaria mais claro se fosse definido dentro da macro.

  • É confuso que a API da biblioteca não exponha os números reais das linhas. Em um servidor web, é preciso passar IDs de linha nos dados para que o frontend possa referenciar e modificar os dados em outras requisições.

  • Há concordância parcial com a opinião de que SQL deve ser escrito por computadores, mas SQL não é a linguagem mais conveniente para geradores de código escreverem. Uma otimização simples de plano pode mudar completamente o layout da consulta. A proposta SQL pipe do Google melhorou um pouco isso, mas ainda tem os problemas de uma nova linguagem de consulta.

  • Há opiniões de que vinham usando SeaQuery, mas a documentação não é suficiente para gerar consultas avançadas. Consultas fortemente tipadas podem desacelerar o processo de desenvolvimento, então estão considerando voltar para prepared statements tradicionais e binding de valores.

  • Migrações por manipulação em nível de linha individual podem ser muito lentas para executar. Por exemplo, em uma tabela com 1 bilhão de linhas, uma instrução comum de UPDATE pode levar até uma hora. Atualizações linha a linha levariam ainda mais tempo.