Introduction
Welcome to this Book on DORM. Before we dive in, few assumptions:
- You have read the Introduction (README): https://github.com/romaninsh/dorm
- You have cloned this repository, installed postgres and ran
0-intro
example. - You have a bit of patience to get to the bottom of various components of DORM as they are explained.
- You might need some Rust experience, but I'll try to provide pointers and keep things simple.
The Purpose of separation of concerns and complexity abstraction
Terms concern separation
and complexity abstraction
may be new to you, so I'm going to
look at the enterprise software more generally and also explain why languages such as Java,
C# and TypeScript are considered suitable, while Rust so far had no traction.
Majority of code for the enterprise is designed to "codify" business logic. As company grows, business rules become more complex. When you open a bank account, you can choose from few account types, but under the hood banks have hundreds of legacy account types. A lot of hidden use-cases are needed for regulations or to support legacy systems.
In order to keep the code maintainable, old code must be pruned without affecting stability of remaining code. Refactoring will always face resistance from product leads, who prefer rolling out new features.
Companies prefer systems and frameworks which are more modular and easier to maintain. In many cases, developers who originally wrote the code would no longer be with the company and a lot of code is written by junior developers too.
Separation of Concerns
Within your average product team (say - 20 developers) engineers who share the code, engineers would find preference towards a certain sub-sections of your app. Some developers may prefer working on the "order processing" while others will dive into "onboarding" flow. Those are called areas of concern. The practice of separating those areas is called SoC - Separation of Concerns.
Rust generally relies on sub-crates to separate concerns, but there is one area, where those areas intersect - and that is the "persistence layer".
Persistence layer
As a business software is dealing with events - it needs to store it's state. Data needs to persist between execution as well as propogate between containers. Business software is state-less - when request comes in, the data is loaded, processed and stored back.
Request can come in from an API interface or event queue. Similarly data can be loaded/stored from Database, APIs or Caches.
It is quite common that in order to process a single request the data would need to be loaded and stored several times. Quite often external API would rely on intermediate APIs (middleware) to achieve the task.
Idempotence
Any request can fail. It can fail before any data is loaded or it can fail after the data is processed and stored. Idempotence is a design pattern that ensures that any requset can be retried by the caller. If the original request was successful, retry will also be successful, but it will not cause any duplicates. This is called Retry safety.
To give you an example, request to send payment of $10 should not send a total of $20 if it is "retried" due to communication issue inside a middleware.
Complexity Abstraction
I have just went through some of the essential design principles of enterprise software systems. If this is new to you, it may sound excessively complex.
Well, there is another trick we use in enterprise system and it is complexity abstraction. An application can be built in layers, where each layer hides complexity.
In fact - when you were going through the introduction
code, you have experienced
complexity abstraction first hand. You saw a simple and readable code, that was hiding
complexity through abstraction.
Rust is a complex language. Rust also does not provide any obvious way to implement complexity abstraction.
DORM has a ready-to-use way to do this in a consistent way - for instance SoftDelete extension
could provide retry safety
for your delete operation.
Safety and Performance
In my opinion, the most important aspect of enterprise software is safety. As humans we all make mistakes. Quite often in a pursuit of performance, we sacrifice safety.
A seasoned developer may write a chunk of SQL code that quickly and efficiently performs a complex aggregation. When junior developer comes in, they may introduce a serious safety bug just by trying to tweak or replicate this complex SQL code.
DORM offers a way to generate performant SQL code without sacrificing safety.
Impact of Change
I've seen many projects which were stuck with a "legacy" data structure because the codebase was riddled with hardcoded SQL queries. As business logic evolves, it will require changes in the data structure.
Persistence abstraction of DORM creates a layer for backward-compatibility. Operations like splitting a table into two tables, moving columns between tables can be handled without affecting change in business logic.
Testability
It is crucial that business logic is testable. However - quite often the logic only
works when it has access to the data. The integration tests
usually provides
test-data to a test-suite. Generally those are slow, they can't track code
coverage and they are hard to debug.
DORM provides a way to test business logic through unit tests
. By mocking your
data source, you can focus on the business logic. More importantly you can adopt
test-driven development
and create standards for your code with test coverage.
Use of faster unit-tests
also reduces your release cycle time - a metric that
companies are actively looking at.
Business Appps with DORM
DORM addresses many of the challenges of enterprise software development.
Ready to learn how? The answers are coming.
Data Sets
Most of Rust apps operate with data which is readily available in memory. Business apps are stateless, loading too much data is an anti-pattern.
Depending of the capabilities of the underlying data storage layer, you should look to create as many requests and retrieve as little data as possible.
DataSet
is is a representation of collection of records - stored remotely.
ReadableDataSet and WritableDataSet
DORM provides two traits: ReadableDataSet
and WritableDataSet
. DORM also provides
several implementations of these traits:
sql::Table
- implements bothReadableDataSet
andWritableDataSet
.sql::Query
- implementsReadableDataSet
only.
Design of DORM allow you to extend those into NoSQL (like MongoDB or GraphQL sources) as well as custom RestAPI sources. Those extensions do not need to be part of DORM, they can be implemented as separate crates.
Operating with Data sets
At the very basic level - you can iterate through a readable data set.
#![allow(unused)] fn main() { let set_of_clients = Client::table(); for client in set_of_clients.get().await? { println!("{}", client.name); } }
But quite often the advanced persistence layer could allow us to do much more. DORM approach is to provide ways how one Data Set can yield a different Data Set. Here are some examples:
- Get a set containing subset of records - by filtering.
- Converting
sql::table
intosql::query
for further manipulation. - Execute operation over set, such as calculate sum of a field from all records, or create comma-separated list of values.
- Modify or delete multiple records.
DORM prefers to off-load operation execution to the persistence layer, but because this may increase complexity, DORM also provides a way to abstract this complexity away.
Example - Lazy expression fields
In our introduction example, we came across an aggregate field: total
:
#![allow(unused)] fn main() { table .has_many("line_items", "order_id", || Box::new(LineItem::table())) .with_expression("total", |t| { let item = t.sub_line_items(); item.sum(item.total()).render_chunk() }) }
Lets examine more generally what is happening here. Use of has_many
creates
This is a very simple example of a lazy expression field. It is a field that is calculated by a closure. The closure is passed a reference to the table. The closure can then use the table to create a new field.
The above example is equivalent to this SQL:
SELECT id,
(SELECT SUM((SELECT price FROM product WHERE id = product_id) * quantity)
FROM order_line WHERE order_line.order_id = ord.id) AS total
FROM ord
In this example, we are using a sub-query to calculate the total. The sub-query is
created by calling sub_line_items()
on the table. This method returns a new table
that is a subset of the original table. The sub-query is then used to create a new
field called total
that is a sum of the price and quantity.
The implementation
of sql::Table
however provides ability to create new Data Sets from existing ones.
Table and Fields
In DORM, you define your business entities by creating a Table. A Table is associated with a physical DataSource and a table. Tables will also contain Fields, Conditions, Joins etc. Lets start with a simple example:
#![allow(unused)] fn main() { use dorm::prelude::*; let products = Table::new("product", postgres.clone()) .with_field("name") .with_field("description") .with_field("default_price"); }
Tables are created by calling Table::new()
and passing it a table name and a DataSource.
#![allow(unused)] fn main() { let products = Table::new("product", postgres.clone()) }
The following invocations to with_field()
are going to replace your "table" instance
with a new one. Our original code is equivalent to:
#![allow(unused)] fn main() { let products = products.with_field("name"); let products = products.with_field("description"); let products = products.with_field("default_price"); }
If you prefer to mutate your table, you can use add_field()
instead:
#![allow(unused)] fn main() { let mut products = Table::new("product", postgres.clone()); products.add_field("name"); products.add_field("description"); products.add_field("default_price"); }
You will see the above method chaining used quite often in DORM. In most cases both methods will have the identical arguments, but please check with the documentation.
DataSource
One of the puproses of DORM is to convert your business logic into a set of queries. Because query languages differ between databases (such as Postgres, MySQL, SQLite, etc), DORM provides a DataSource abstraction. A DataSource is a trait that defines how to execute queries and fetch data.
A DataSource for Postgress can be created like this:
#![allow(unused)] fn main() { let postgress_client = tokio_postgres::connect("host=localhost dbname=postgres", NoTls) .await .context("Failed to connect to Postgres")?; let postgres = Postgres::new(Arc::new(Box::new(postgress_client))); }
When you create a Table, you pass it a clone of your DataSource. You may use different DataSources for different tables and DORM will make to execute queries correctly even if you are traversing relationships between different DataSources.
What exactly is a Field?
In the example above, we used a pretty generic method to create a field. In reality, DORM has a very loose definition of what a field is. Lets look at SQL first:
SELECT id, CONCAT(first_name, ' ', last_name) AS full_name, father.name AS father_name
FROM person
JOIN father ON person.father_id = father.id
The above query will return table with 3 columns: id, full_name and father_name. From the DORM perspective, all three are valid fields.
- id: This is not only a field, but also a "id" field.
- full_name: This field is represented by a SQL expression.
- father_name: This field is imported from another table.
Although the DORM code is a bit complex at this point, I'll still include it here. Don't worry if you don't understand it all yet, we'll get to learning about expressions and joins in later chapters.
#![allow(unused)] fn main() { let person = Table::new("person", postgres.clone()) .with_id_field("id") .with_field("first_name") .with_field("last_name") .with_field("father_id"); .with_expression( "full_name", expr_arc!("CONCAT({}, ' ', {})", &person.get_field("first_name").unwrap(), &person.get_field("last_name").unwrap() ) ); let father = person.clone().with_alias("father"); let person_with_father = person .with_join("father", father, "father_id") .unwrap(); let query = person_with_father.get_select_query_for_field_names(&["id", "full_name", "father_name"]); writeln!(query.preview()); }
An important takeaway from this example is that we define table along with the fileds so that we could generate a query out of it. For now, lets simplify the example and talk about the query generation.
#![allow(unused)] fn main() { let person = Table::new("person", postgres.clone()) .with_field("id") .with_field("first_name") .with_field("last_name"); let query = person.get_select_query(); writeln!(query.preview()); }
The result of this query should be:
SELECT id, first_name, last_name FROM person
So what is Query?
Queries
In DORM, query is a dynamic representation of a SQL query. You already saw how to create a query in the previous chapter, but now we will learn how to create query from scratch.
Expressions
Expression is a building block of a query as well as a template engine for your query parameters. Lets start with a simple example:
#![allow(unused)] fn main() { let expression = Expression::new( "SELECT * FROM product WHERE name = {}", vec![json!("DeLorian Doughnut")]); writeln!(expression.preview()); }
The above expression will be rendered as:
SELECT * FROM product WHERE name = 'DeLorian Doughnut'
Expressions do not know anything about the underlying database and
they cannot execute themselves. Parameters you are passing, must be
of type serde_json::Value
.
To simplify the process DORM offers you a expr!
macro:
#![allow(unused)] fn main() { let expression = expr!("SELECT * FROM product WHERE name = {}", "DeLorian Doughnut"); }
The parameters to expr!
macro can be any owned scalar types, as long
as they can be converted to serde_json::Value
using serde_json::json!
.
macro.
While convenient, there is a significant limitation to Expressions - they cannot be nested. This is because Expression cannot render itself into a json::Value.
To overcome this limitation, DORM offers a ExpressionArc type.
Expression Arc
As the name implies, ExpressionAarc keeps its parameters inside an Arc
and therefore parameters can be dynamic objects. Anything that implements
SqlChunk
trait can be used as a parameter.
Naturally both Expression
and ExpressionArc
implement SqlChunk
, but
there are more types that implement SqlChunk
trait and we will look
at them later.
ExpressionArc can be created through a expr_arc!
macro:
#![allow(unused)] fn main() { let expression = expr_arc!("SELECT * FROM product WHERE name = {}", "DeLorian Doughnut"); writeln!(expression.preview()); // renders into: SELECT * FROM product WHERE name = 'DeLorian Doughnut' }
You can now pass expresisons recursively:
#![allow(unused)] fn main() { let condition = expr_arc!("name = {}", "DeLorian Doughnut"); let expression = expr_arc!("SELECT * FROM product WHERE {}", condition); writeln!(expression.preview()); // renders into: SELECT * FROM product WHERE name = 'DeLorian Doughnut' }
You might have noticed, that nested expressions are not escaped, but rest assured, parameters are never inserted into the SQL query. Both Expression and ExpressionArc can cloned and passed around freely.
Flattening Expressions
As you can see in the example above, SqlChunk
can have many sub-objects.
When we need to send off expression to the database, we need to flattern it.
SqlChunk
trait has a render_chunk()
method that will convert itself
into a static Expression
type:
#![allow(unused)] fn main() { let condition = expr_arc!("name = {}", "DeLorian Doughnut"); let expression = expr_arc!("SELECT * FROM product WHERE {}", condition); let flattened = expression.render_chunk(); dbg!(flattened.sql()); dbg!(flattened.params()); // renders into: SELECT * FROM product WHERE name = {} // params: [json!("DeLorian Doughnut")] }
In the example above, we used render_chunk()
method on ExpressionArc
to convert it into a static Expression
type. Then sql() and params()
methods can be called to get the final template and parameters. Template
has correctly combined nested condition, while leaving parameter value
separated.
How Query uses Expressions ?
A query object is designed as a template engine. It contains maps
of various columns, conditions, joins etc. Query implements SqlChunk
and query itself can be contained inside expression or another query.
Query implements wide range of "with_*" methods that can be used to manipulate the query. Lets create a query that will select all columns from "product" table, where name is "DeLorian Doughnut" and age is greater than 30:
#![allow(unused)] fn main() { let expr1 = expr!("name = {}", "John"); let expr2 = expr!("age > {}", 30); let query = Query::new() .with_table("users", None) .with_column_field("id") .with_column_field("name") .with_condition(expr1) .with_condition(expr2); writeln!(query.preview()); // renders into: SELECT id, name FROM users WHERE name = 'John' AND age > 30 }
Query does not know anything about the underlying database and therefore cannot execute itself. It can only be rendered into a template and parameters.
Query is immutable calling with_*
methods will take the ownership,
modify and return a new instance, making it perfect for chaining.
Methods like with_condition
can accept any argument
that implements SqlChunk
trait, lets create another query,
based on the one we had above:
#![allow(unused)] fn main() { // query is from example above let query2 = Query::new() .with_table("orders", None) .with_condition(expr_arc!("user_id in {}", query .clone() .without_columns() .with_column_field("id") )); writeln!(query2.preview()); // renders into: SELECT * FROM orders WHERE user_id in (SELECT id, name, age FROM users WHERE name = 'John' AND age > 30) }
Importantly - the two parameters which were set (and then cloned)
for the query
are kept separate from a final query rendering and
will be passed into DataSource separately. This ensures that
SQL injection is never possible.
Next, lets explore some other kinds of SqlChunk
implementation,
that are more intuitive to use over Expressions.
Fields and Operations
Well, Query stores columns as a Map<Arc<BoxColumn
trait,
but the simplest would be a Field
type:
To add a Field
to your Query
you can call with_column_field
method:
#![allow(unused)] fn main() { let query = Query::new() .with_table("product") .with_column_field("name") }
Another type that implements Column
trait is Expression
. Lets
modify our query by adding an expression by invoking with_column
method.
This method is more generic than "with_column_field" and will accept
two arguments - a name of the column and a static Column
object.
#![allow(unused)] fn main() { let person = person .with_column("name_caps".to_string(), expr!("UPPER(name)")); }
If you call preview()
on your query now, you should see this:
SELECT name, UPPER(name) AS name_caps FROM product
Query is not very long-lived. Once created, it can be executed, but that's it. More interesting for us - Table objects can convert themselves into a Query object.
This is our Table
object from earlier:
#![allow(unused)] fn main() { let product = Table::new("product", postgres.clone()) .with_field("id") .with_field("name"); }
You already know that Table
can produce a Query
:
#![allow(unused)] fn main() { let query = product.get_select_query(); writeln!(query.preview()); // renders into: SELECT id, name FROM product }
There should be a way to add a "expression" to our query by defining it in our table, right?
As it turns out, tables are quite lazy when it comes to expressions. They do not recognize them as fields, and instead will pop them into your "select_query" just at the last moment and only when you explicitly need that expresison field. This allows you to have wide range of expressions in your table and optimize select query automatically.
#![allow(unused)] fn main() { let product = product .with_expression( "name_caps", |t| expr_arc!("UPPER({})", t.get_field("name").unwrap()) ); }
You have probably noticed, that rather then hard-coding name in
the expression, we are using get_field
method. This method
will return a &Field object, which we can include in our
ExpressionArc. Field implements SqlChunk
, right?
Executing get_select_query()
will will not include name_caps
by default. You can explicitly ask which fields you'd like to see
by usign with_select_query_for_field_names
method:
#![allow(unused)] fn main() { let query = product.get_select_query_for_field_names(&["name", "name_caps"]); writeln!(query.preview()); // renders into: SELECT name, UPPER(name) AS name_caps FROM product }
Operations
Relying ot an arbitrary expression macro sometimes is not very
convenient. DORM offers an Operations
trait, that can be used
to generate some commonly used expressions:
#![allow(unused)] fn main() { let product = product .with_expression( "name_caps", |t| t.get_field("name").unwrap().upper() ); }
Operations can also be used for generating conditions, lets look into that next.
Conditions
By default, when you create a Table
object, it will represent
a set of all rows in the table. Sometimes you want to filter
the rows. Our product
SQL table contains a calories
column,
so lets define it and use it for filtering:
#![allow(unused)] fn main() { let product = Table::new("product", postgres.clone()) .with_field("id") .with_field("name") .with_field("calories"); let low_cal_products = product .with_condition( product .get_field("calories") .unwrap() .lt(100) ); let query = low_cal_products.get_select_query(); writeln!(query.preview()); // renders into: SELECT id, name, calories FROM product WHERE (calories < 100) }
Condition can be created from various other types, but the most convenient way is through the use of Operator.
- get_field returns a Option<Arc
> object - unwrap() as we know for sure field exists
- Arc
implements Operator trait - Operator::lt() returns a Condition object
- Condition implements SqlChunk, so it can be part of a query
DORM capabilities allow you to be very flexible with defining your entities and we are just scratching the surface here.
Before we continue, let me address one annoying bit here. So far
each chapter we have been re-creating our product
table.
In the real world, you would typically have a pre-defined table
structure, so that no matter how many times you need to operate
with a product
table, you can easily create it.
DORM has a recommended pattern for generating tables and we will explore it next.
Entity Model
So we need to conveniently create a product
table objects,
but we don't want to re-populate all the fields and conditions
every time.
DORM recommends you to have a object called Product
that
would vend Table
objects. We will place the code inside
a model
subfolder.
While we are building our Product
type, lets also create
a static instance of our DataSource:
inside your model/mod.rs:
#![allow(unused)] fn main() { use dorm::prelude::Postgres; pub mod products; pub use products::*; static POSTGRESS: OnceLock<Postgres> = OnceLock::new(); pub fn set_postgres(postgres: Postgres) -> Result<()> { POSTGRESS .set(postgres) .map_err(|_| anyhow::anyhow!("Failed to set Postgres instance")) } pub fn postgres() -> Postgres { POSTGRESS .get() .expect("Postgres has not been initialized") .clone() } }
Now you would need to call set_postgress() when your tokio_postgress client is ready and you can import and call postgress() from your models.
Lets create file models/products.rs
:
#![allow(unused)] fn main() { use dorm::prelude::*; use crate::postgres; pub struct Product {} impl Product { pub fn table() -> Table<Postgres> { Product::static_table().clone() } pub fn static_table() -> &'static Table<Postgres> { static TABLE: OnceLock<Table<Postgres>> = OnceLock::new(); TABLE.get_or_init(|| { Table::new("product", postgres()) .with_id_field("id") .with_field("name") }) } pub fn name() -> Arc<Field> { Product::static_table().get_field("name").unwrap() } } }
Now that you have created a Product type, we can reference it in your application like this:
#![allow(unused)] fn main() { use model; let products = model::Product::table(); writeln!(products.get_select_query().preview()); // renders into: SELECT id, name FROM product let low_cal_products = model::Product::table() .with_condition( model::Products::calories().lt(100) ); writeln!(low_cal_products.get_select_query().preview()); // renders into: SELECT id, name, calories FROM product WHERE (calories < 100) }
This is already much more portable, but we can do better.
Add this to your model/products.rs
and
#![allow(unused)] fn main() { pub struct LowCalProduct {} impl LowCalProduct { pub fn table() -> Table<Postgres> { Product::table().with_condition( Product::calories().lt(100) ) } } }
You can addopt a different approach here, those are just a few recommendations. Later we will explore a way to create a dynamic business entity pattern, but now we will focus on something more fun - joins.
Joins
Join can happen between two tables with one-to-some(one) relationship.
A good example for us is if we add a new table called inventory
that
joins product
table:
CREATE TABLE inventory (
product_id SERIAL PRIMARY KEY,
stock INT DEFAULT NULL
);
In this case, inventory does not particularly useful ol its own, so we can make it part of the Products type:
#![allow(unused)] fn main() { impl Product { pub fn table_with_inventory() -> Self { Product::table() .with_alias("p") .with_join( Table::new("inventory", postgres()) .with_alias("i") .with_id_field("product_id") .with_field("stock"), "id", ) } } } }
The beautiful syntax here is going to give you exactly what you expect:
#![allow(unused)] fn main() { let prod_inv = model::Product::table_with_inventory(); writeln!(prod_inv.get_select_query().preview()); // renders into: SELECT p.id, p.name, i.stock FROM product p LEFT JOIN inventory i ON (p.id = i.product_id) }
How is that possible? Well, DORM's "with_join" method will consume the table object that you pass and will move its fields into the original table. The joined table will be wrapped into a Join object and will instruct query builder to add join into your query.
There are ways to create different kind of joins too, but api for that is not yet stable.
As you will see later, DORM operates with joined tables just as good as with a single table.
Now is probably a great time to look into DORMs capabilities of operating with your entities.
So far we only used Table to create select queries, but we can in fact hydrate our entities with data from the database.
Fetching Data
So far we were creating tables and were generating queries from them, but wouldn't it be great if we could fetch data directly from the table object?
If you recall - our table is already associated with DataSource,
but when we execute get_select_query()
the resulting Query
is not associated with any DataSource.
There is a type called AssociatedQuery
that can be produced
by a table object and it has some beautiful methods to
actually fetch data.
Lets explore:
#![allow(unused)] fn main() { let low_cal_products = model::LowCalProduct::table(); let count = low_cal_products.count().get_one().await?; }
Table::count() is a method that returns a query for counting rows in a table. Because our LowCalProducts contains a condition, this will return a count of rows that match the condition.
You could use count().preview() still to confirm, that DORM won't attept to fetch all records and iterate over them, but instead will use a SUM() function to count rows:
SELECT COUNT(*) FROM "product" WHERE "calories" <= 100
Calling get_one
will instead allow us to fetch the value
directly into a variable. We do not know what type our query
would produce, so get_one() returns json::Value and you can
use "as_i64" to cast it into a numeric type:
#![allow(unused)] fn main() { let count = model::LowCalProduct::table() .count() .get_one() .await? .as_i64()? }
Previously I was using unwrap() and now I am using "?" to unwrap. This is because previously our code was certain that a field woudl exist, since we added it ourselves, but with .get_one() we are not sure about the response. Perhaps query execution would fail, so we need a proper error handling.
Lets explore another method:
#![allow(unused)] fn main() { let total_calories = model::LowCalProduct::new() .sum(model::Product::calories()) .get_one() .await? .as_i64()? }
Here we are passing a Column object to the sum() method.
Some of those methods will be useful for us later, but for now lets look at the way to fetch data from a table:
#![allow(unused)] fn main() { for product in model::LowCalProduct::table().get_all_data().await? { println!("{} ({} kcal)", product["name"]?, product["calories"]?); } }
Next we will look at how to use power of Deserialize trait of your custom type.
Struct Entities
So far we have defined our Product type without any fields. Lets add some fields to it:
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize, Clone)] pub struct Product { id: Option<i64>, name: String, calories: i64, } }
You can now use get
#![allow(unused)] fn main() { for product in model::LowCalProduct::table().get<Product>().await? { println!("{} ({} kcal)", product.name, product.calories); } }
There is now your type safety. You also can see that we can use any type that implements Serialize and Deserialize traits in get() method.
However, now that we have implemented a struct for our entity, we can start modifying our data. Check this code out:
#![allow(unused)] fn main() { model::LowCalProduct::table().map<Product>(|product| async move { product.calorues += 1; })().await?; }
This allows you to iterate over your data as you did before,
but map() will also store data back to the database. You
just need to remember to have id
field in your struct. Here
is what happens:
- DORM determines the type of your struct (Product)
- DORM will fetch row from a query that includes your condition
- DORM will deserialize the row into your struct
- DORM will call your closure
- DORM will serialize your struct back to a row
- DORM will replace row in the database with the new values
The map() method does not know if you have changed struct, so it will always try to execute "REPLACE" query and based on your unique id field, it should rewrite the row.
You can also use insert() method on your table to add a new row to the database:
#![allow(unused)] fn main() { model::Product::table().insert(Product { id: None, name: "New Product".to_string(), calories: 100, }) .await?; }
Deleting is also possible, but you need to be careful. delete_all() method will remove all rows.
#![allow(unused)] fn main() { model::LowCalProduct::table().delete_all().await?; }
If you want to delete a specific row, you can set a condition:
#![allow(unused)] fn main() { model::Product::table().with_id(1).delete_all(); }
Although a more convenient method delete() exists too:
#![allow(unused)] fn main() { model::Product::table().delete(1).await?; }
I should probably mention, that delete() method will not affect rows in a table, which do not match the condition:
#![allow(unused)] fn main() { model::LowCalProduct::table().delete(4).await?; }
Here a row with id=4 will only be deleted if calories is less than 100 for this row.
This will be useful for us later.
Next I want to tell you about associated entities.
Associated Entities
Do you remember how we used Query type only to discover that there is also AssociatedQuery type?
Well, in the last chapter we used our custom entity type Product and now it turns out you can have an associated entity type too.
AssociatedEntity
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize, Clone)] pub struct Product { name: String, calories: i64, } }
Now when we deal with associated entities, we load() and save() them:
#![allow(unused)] fn main() { let product = model::Product::table().load<Product>(4).await?; product.calories = 56; product.save().await?; }
AssociatedEntity derefs itself so that you can still access the fields. Additionally the following methods are added:
- reload() - reload entity from the database
- id() - return id of the entity
- delete() - delete the entity from the database
- save() - saves the entity to the database
- save_into() - saves entity into a different table
Here is example:
#![allow(unused)] fn main() { if let Some(product) = model::LowCalProduct::table().load_any::<Product>().await? { writeln!("Low-cal Product {} has {} calories", product.name, product.calories); product.calories += 100; // no londer LowCalProduct // product.save().await?; // will Err because of condition product.save_into(model::Product::table()).await?; } }
It should no longer be a surprise to you that you can do the exactly same stuff with a table which relies on Join:
#![allow(unused)] fn main() { struct ProductInventory { name: String, stock: i64, } if let Some(product) = model::Product::with_inventory().load::<ProductInventory>(4).await? { writeln!("Product {} has {} items in stock", product.name, product.stock); if product.stock > 0 { product.stock -= 1; product.save().await?; } else { product.delete().await?; } } }
DORM will automatically understand, which fields you have changed (stock) and will only update those fields. DORM will also delete the row from both "product" and "inventory" tables if stock is 0.
Your code remains intuitive, while DORM takes care of the rest, but lets make the code even better:
#![allow(unused)] fn main() { impl AssociatedEntity<ProductInventory> { pub async fn sell(self, qty: i64) -> Result<()> { if qty > self.stock { return Err(anyhow::anyhow!("Not enough items in stock")); } self.stock -= qty; if self.stock == 0 { self.delete().await?; } else { self.save().await?; } Ok(()) } } }
Now you can use your method like this:
#![allow(unused)] fn main() { let product = model::Product::with_inventory().load::<ProductInventory>(4).await?; product.sell(10).await?; }
Rust never looked so good!
But hey, that's not all. DORM also supports associations between two tables. Keep reading!
References
DORM allows you to connect tables together. Lets create two new tables in addition to "Product" that we have already:
#![allow(unused)] fn main() { struct Order { product: Product, client: Client, } struct Client { name: String, contact_details: String, } }
The definition of the tables would be just like in chapter 5-Entity Model, however we will use "has_one" and "has_many" methods:
#![allow(unused)] fn main() { impl Product { pub fn static_table() -> &'static Table<Postgres> { static TABLE: OnceLock<Table<Postgres>> = OnceLock::new(); TABLE.get_or_init(|| { Table::new("product", postgres()) .with_id_field("id") .with_field("name") .has_many("orders", "product_id", || Order::table()) }) } } impl Order { pub fn static_table() -> &'static Table<Postgres> { static TABLE: OnceLock<Table<Postgres>> = OnceLock::new(); TABLE.get_or_init(|| { Table::new("order", postgres()) .with_id_field("id") .with_field("name") .has_one("client", "client_id", || Client::table()) .has_one("product", "product_id", || Product::table()) }) } } impl Client { pub fn static_table() -> &'static Table<Postgres> { static TABLE: OnceLock<Table<Postgres>> = OnceLock::new(); TABLE.get_or_init(|| { Table::new("client", postgres()) .with_id_field("id") .with_field("name") .with_field("contact_details") .has_many("orders", "client_id", || Order::table()) }) } } }
Given one Table, DORM lets you traverse relationships between tables. Lets say we want to see how many orders does product with id=4 have:
#![allow(unused)] fn main() { let product = model::Product::table().with_id(4); let orders_count = product.get_ref("orders").unwrap().count().get_one().await?; }
Here is what happens under the hood:
- A query is generated for the Product where id=4
- product query is used as a condition for a order query
- order query is executed for counting rows
SELECT COUNT(*) FROM order WHERE (product_id = in (SELECT id FROM product WHERE (id = 4)));
This query may seem a bit redundant, but lets see how many LowCalProduct orders we have:
#![allow(unused)] fn main() { let product = model::LowCalProduct::table(); let low_cal_orders = product.get_ref("orders").unwrap().count().get_one().await?; }
Resulting query now looks like this:
SELECT COUNT(*) FROM order WHERE (product_id IN (SELECT id FROM product WHERE (calories < 100)));
In DORM relationship traversal is always converting one "set" into another "set".
In fact - if you want to calculate how many clients have placed orders for Low Calory Products, you can do it like this:
#![allow(unused)] fn main() { let low_cal_products = model::LowCalProduct::table(); let clients_on_diet = low_cal_products .get_ref("orders") .unwrap() .get_ref("client") .unwrap() .count() .get_one() .await?; }
But lets not stop here. Suppose you wanted to send all clients who are on a diet some email about a new product:
#![allow(unused)] fn main() { let low_cal_products = model::LowCalProduct::table(); let clients_on_diet = low_cal_products .get_ref("orders") .unwrap() .get_ref("client") .unwrap(); for client in clients_on_diet.get<Client>().await? { client.send_email("New low carb product is out!").await?; } }
Imagine all the other things you could do. Yet once again, DORM has more surprises for you.
We have learned about expressions before, right? Well, expressions can be built from subqueries.
Subquery Expressions
Once we have used "has_one" and "has_many" methods, we can now add some expressions as well.
I'll start by extending our "Client::table()" with "total_orders" field. Place that inside static_table() method:
#![allow(unused)] fn main() { Table::new("client", postgres()) .with_id_field("id") .with_field("name") .with_field("contact_details") .has_many("orders", "client_id", || Order::table()) .with_expression("total_orders", move |t| { t.get_ref_related("orders").unwrap().count() }) }
What happens here then?
- A query is generated for the Client
- If "total_orders" field is requested, a callback is called
- The callback is passed a Table object ("client"), which has a reference to the "orders" table
- get_ref_related() is similar to get_ref(), but is suitable for subquery expressions
- get_ref_related() returns a Table object
- count() is called on the Table object, producting a SqlChunk object
- The SqlChunk object is is aliased into "total_orders" field inside Query
- Query is executed
Lets also modify "Client" struct:
#![allow(unused)] fn main() { struct Client { name: String, contact_details: String, total_orders: Option<i64> } }
Now that we have know how many orders clients have placed, we can use it as a condition.
#![allow(unused)] fn main() { let vip_clients = clients.with_condition(clients.field("total_orders").gt(4)); }
Lets also calculate how many low_cal_orders clients have placed:
#![allow(unused)] fn main() { .with_expression("low_cal_orders", move |t| { t .get_ref_related("orders") .with_condition(t.get_filed("calories").unwrap().lt(100)) .unwrap() .count() }) .with_expression("high_cal_orders", move |t| { t .get_ref_related("orders") .with_condition(t .get_field("calories") .unwrap() .gt(100) .or(t.get_filed("calories").unwrap().eq(100)) ) .unwrap() .count() }) }
We just casually added 2 more expressions to our Client table. Those normally won't be queried unless needed. However, we can use them to calculate conditions:
#![allow(unused)] fn main() { let diet_clients = clients .with_condition(clients .field("low_cal_orders") .gt(clients.field("high_cal_orders")) ); }
Finally to clean things up a bit, we can move some of this logic
into our model/*.rs
files.
Overall, you are now familiar with the basics of DORM and can start building business model abstractions for your application.