Expand description
Orlok
Orlok is a database toolkit that contains reusable generic implementations of the Repository pattern. It can help you to separate business logic from data-storage details and save you from writing some amount of boilerplate code.
At the moment, only PostgreSQL is supported thanks to sqlx.
The repository is here: https://github.com/meowmeowcode/orlok
Guide
Creating a repository
Suppose we have a struct representing a character of some story:
use uuid::Uuid;
#[derive(PartialEq, Clone, Debug)]
pub struct Character {
pub id: Uuid,
pub name: String,
pub location: String,
}
impl Character {
pub fn new(name: String, location: String) -> Self {
Self {
id: Uuid::new_v4(),
name,
location,
}
}
}
We’re going to store this struct in a database so let’s create a table for this purpose:
use sqlx::PgPool;
let pool = PgPool::connect("postgresql://orlok:orlok@localhost/orlok")
.await?;
sqlx::query(
"create table if not exists characters (
id uuid primary key,
name text not null,
location text
)"
)
.execute(&pool)
.await?;
To save the character struct to the database, we need
to somehow map its fields to a table row.
We can define a function for this.
This function must return a HashMap
with keys and values
that correspond to columns and values in the “characters” table:
use std::collections::HashMap;
use orlok::pg::Value;
fn dump_character(c: &Character) -> HashMap<String, Value> {
HashMap::from([
("id".to_string(), c.id.into()),
("name".to_string(), c.name.clone().into()),
("location".to_string(), c.location.clone().into()),
])
}
After saving the character struct to the database, we want to be able to load it back. To do this, we need a function that maps a database row to the struct:
use sqlx::Row;
use sqlx::postgres::PgRow;
fn load_character(row: &PgRow) -> Character {
Character {
id: row.get("id"),
name: row.get("name"),
location: row.get("location"),
}
}
Then, we can create a repository:
use orlok::Repo;
use orlok::pg::PgRepo;
let characters_repo = PgRepo::new("characters", dump_character, load_character);
The first argument for the new
function is the name of the table where we want to store our characters.
Saving entities
Repositories use a special wrapper around a connection to a database. Let’s create one:
use orlok::Db;
use orlok::pg::PgDb;
let db = PgDb::new(pool);
Now we can save new characters:
let orlok = Character::new("Orlok".to_string(), "Transylvania".to_string());
characters_repo.add(&db, &orlok).await?;
let thomas = Character::new("Thomas".to_string(), "Wisborg".to_string());
characters_repo.add(&db, &thomas).await?;
let ellen = Character::new("Ellen".to_string(), "Wisborg".to_string());
characters_repo.add(&db, &ellen).await?;
Loading one entity
Use the get
method if you want to load only one entity from the database:
use orlok::F;
let character = characters_repo.get(&db, &F::eq("name", "Orlok")).await?.unwrap();
assert_eq!(character, orlok);
The result of this method contains an Option
, which is None
if no record was found:
let character = characters_repo.get(&db, &F::eq("name", "Knock")).await?;
assert!(character.is_none());
Note that here we use the F
struct for filtering entities.
It has different methods for different conditions.
For example, find a character with
the letter “h” in their name, we can do something like this:
let character = characters_repo.get(&db, &F::contains("name", "h")).await?.unwrap();
assert_eq!(character, thomas);
Multiple filters can be combined this way:
let character = characters_repo.get(
&db,
&F::and(
vec![
F::starts_with("name", "E"),
F::ends_with("name", "n")
]
)
).await?.unwrap();
assert_eq!(character, ellen);
Loading several entities
To load several entities, use the get_many
method:
use orlok::Query;
let characters = characters_repo.get_many(
&db,
&Query::filter(F::contains("name", "l"))
).await?;
assert_eq!(characters, vec![orlok.clone(), ellen.clone()]);
In addition to the F
struct, we use the Query
struct here because it provides
options for the limit, offset, and order of entities that we want to retrieve:
use orlok::Order;
let characters = characters_repo.get_many(
&db,
&Query::new()
.order(vec![Order::Desc("name".to_string())])
.limit(2)
.offset(1)
).await?;
assert_eq!(characters, vec![orlok.clone(), ellen.clone()]);
Updating an entity
To update an entity, we need to modify it and pass its reference
to the update
method together with a filter that finds
an appropriate record in the database:
let mut orlok = characters_repo.get(&db, &F::eq("name", "Orlok")).await?.unwrap();
orlok.name = "Count Orlok".to_string();
characters_repo.update(&db, &F::eq("id", orlok.id), &orlok).await?;
Transactions
Use a closure to execute code in a transaction. Return Ok
from the closure to commit the transaction or an error to abort it:
db.transaction(|tx| {
Box::pin({
let characters_repo = characters_repo.clone();
async move {
let mut thomas = characters_repo.get_for_update(
&tx, &F::eq("name", "Thomas")
).await?.unwrap();
let mut orlok = characters_repo.get_for_update(
&tx, &F::eq("name", "Count Orlok")
).await?.unwrap();
thomas.location = "Transylvania".to_string();
orlok.location = "Wisborg".to_string();
characters_repo.update(&tx, &F::eq("id", thomas.id), &thomas).await?;
characters_repo.update(&tx, &F::eq("id", orlok.id), &orlok).await?;
Ok(())
}
})
}).await?;
Note that nested transactions are not supported at the moment.
Removing an entity
For this, we also need a filter:
characters_repo.delete(&db, &F::eq("name", "Count Orlok")).await?;
Repository customization
There are some methods that can be helpful if you need, for example, to use several tables to store one entity:
sqlx::query(
"create table if not exists items (
id uuid primary key,
name text not null,
character_id uuid references characters(id) on delete cascade
)"
)
.execute(&pool)
.await?;
#[derive(PartialEq, Clone, Debug)]
pub struct Character {
pub id: Uuid,
pub name: String,
pub items: Vec<String>,
}
impl Character {
pub fn new(name: String) -> Self {
Self {
id: Uuid::new_v4(),
name,
items: Vec::new(),
}
}
}
fn dump_character(u: &Character) -> HashMap<String, Value> {
HashMap::from([
("id".to_string(), u.id.into()),
("name".to_string(), u.name.clone().into()),
])
}
fn load_character(row: &PgRow) -> Character {
Character {
id: row.get("id"),
name: row.get("name"),
items: row.get("items"),
}
}
let characters_repo = PgRepo::new("characters", dump_character, load_character)
.query("
select characters.id, characters.name, array_agg(items.name) as items
from characters
left join items
on items.character_id = characters.id
group by characters.id, characters.name
")
.after_add(|character| {
character.items
.iter()
.map(|item| {
sqlx::query("insert into items (id, character_id, name) values ($1, $2, $3)")
.bind(Uuid::new_v4())
.bind(character.id)
.bind(item)
})
.collect()
})
.after_update(|character| {
let mut queries = vec![sqlx::query("delete from items where character_id = $1").bind(character.id)];
queries.extend(character.items.iter().map(|item| {
sqlx::query("insert into items (id, character_id, name) values ($1, $2, $3)")
.bind(Uuid::new_v4())
.bind(character.id)
.bind(item)
}));
queries
});
let mut orlok = Character::new("Orlok".to_string());
orlok.items = vec!["Coffin".to_string(), "Vampire's coat".to_string()];
characters_repo.add(&db, &orlok).await?;
let mut thomas = Character::new("Thomas".to_string());
thomas.items = vec!["Book about vampires".to_string()];
characters_repo.add(&db, &thomas).await?;
let c = characters_repo.get(&db, &F::eq("name", "Orlok")).await?.unwrap();
assert_eq!(c.items, vec!["Coffin".to_string(), "Vampire's coat".to_string()]);
let c = characters_repo.get(&db, &F::eq("name", "Thomas")).await?.unwrap();
assert_eq!(c.items, vec!["Book about vampires".to_string()]);
Fast prototyping
If you don’t have time to think about a database schema but want to try out some ideas, you can use an alternative repository implementation that stores records in memory as a collection of JSON objects.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use orlok::{Repo, Db};
use orlok::json::{JsonRepo, JsonDb};
#[derive(PartialEq, Clone, Debug, Serialize, Deserialize)]
pub struct Character {
pub id: Uuid,
pub name: String,
pub location: String,
}
impl Character {
pub fn new(name: String, location: String) -> Self {
Self {
id: Uuid::new_v4(),
name,
location,
}
}
}
let characters_repo = JsonRepo::new("characters");
let db = JsonDb::new();
let character = Character::new("Orlok".to_string(), "Transylvania".to_string());
characters_repo.add(&db, &character).await?;
Ok(())
Modules
- Main traits are here.
- In-memory repository implementation.
- Repository implementation for PostgreSQL.
- Structs that can be used for building queries.
Structs
- Struct for filtering entities with additional options.
Enums
- Enum for filtering entities.
- Types of ordering.
Traits
- Trait that must be implemented for a database-connection wrapper.
- Trait that must be implemented for a repository.