- "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
| É 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.
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-accessem 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
Schemanã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
UPDATEpode levar até uma hora. Atualizações linha a linha levariam ainda mais tempo.