Expand description
A Laravel-inspired ORM for Rust
Introduction
Ensemble is a Laravel-inspired object-relational mapper (ORM) that makes it enjoyable to interact with your database. When using Ensemble, each database table has a corresponding “Model” that is used to interact with that table. In addition to retrieving records from the database table, Ensemble models allow you to insert, update, and delete records from the table as well.
Model Conventions
Ensemble Models will generally live inside the models
module. Let’s examine a basic model and discuss some of Ensemble’s key conventions:
use ensemble::Model;
#[derive(Debug, Model)]
struct Flight {
pub id: u64,
pub name: String,
}
Table Names
After glancing at the example above, you may have noticed that we did not tell Ensemble which database table corresponds to our Flight
model. By convention, the “snake case”, plural name of the class will be used as the table name unless another name is explicitly specified. So, in this case, Ensemble will assume the Flight
model stores records in the flights
table, while an AirTrafficController
model would store records in an air_traffic_controllers
table.
If your model’s corresponding database table does not fit this convention, you may manually specify the model’s table name using the #[ensemble(table)]
attribute:
#[derive(Debug, Model)]
#[ensemble(table = "my_flights")]
struct Flight {
pub id: u64,
pub name: String,
}
Column Names
By default, Ensemble assumes that your table columns will match the names of the fields on your model. If you would like to specify a different column name, you can use the #[model(column)]
attribute:
#[derive(Debug, Model)]
struct Flight {
pub id: u64,
#[model(column = "name")]
pub flight_name: String,
}
Primary Keys
Ensemble requires every model to have a primary key. If your model includes an id
field, Ensemble will assume that this is your primary key. If necessary, you may mark any other field as the primary field using the #[model(primary)]
attribute:
#[derive(Debug, Model)]
struct Flight {
#[model(primary)]
pub flight_id: u64,
pub name: String,
}
In addition, if the primary key is of type u64
, Ensemble will use the database’s incrementing feature and automatically give it a value on new instances of the model. If you wish to use a non-incrementing primary key, you must use a different type or opt-out with the #[model(incrementing = false)]
attribute:
#[derive(Debug, Model)]
struct Flight {
#[model(incrementing = false)]
pub id: u64,
pub name: String,
}
“Composite” Primary Keys
Like we’ve said, Ensemble requires each model to have at least one uniquely identifying “ID” that can serve as its primary key. “Composite” primary keys are not supported by Ensemble models. However, you are free to add additional multi-column, unique indexes to your database tables in addition to the table’s uniquely identifying primary key.
UUID Keys
Instead of using auto-incrementing integers as your Ensemble model’s primary keys, you may choose to use UUIDs instead. UUIDs are universally unique alpha-numeric identifiers that are 36 characters long.
If you would like a model to use a UUID key instead of an auto-incrementing integer key, you may use the ensemble::types::Uuid
type on the model’s primary key, and mark it with the #[model(uuid)]
attribute:
use ensemble::types::Uuid;
#[derive(Debug, Model)]
struct Flight {
#[model(uuid)]
pub id: Uuid,
pub name: String,
}
Timestamps
If your model includes created_at
and updated_at
fields, Ensemble will automatically set these column’s values when models are created or updated, like so:
use ensemble::types::DateTime;
#[derive(Debug, Model)]
struct Flight {
pub id: u64,
pub name: String,
pub created_at: DateTime,
pub updated_at: DateTime,
}
If you need to customize the names of the fields used to store the timestamps, you may use the #[model(created_at)]
and #[model(updated_at)]
attributes:
#[derive(Debug, Model)]
struct Flight {
pub id: u64,
pub name: String,
#[model(created_at)]
pub creation_date: DateTime,
#[model(updated_at)]
pub updated_date: DateTime,
}
Default Values
Ensemble models automatically implement the Default
trait, which means you can use the default
method to create a new instance of the model with all its fields set to their default values. You can customize the default value of a field by using the #[model(default)]
attribute:
#[derive(Debug, Model)]
struct Flight {
pub id: u64,
pub name: String,
#[model(default = true)]
pub is_commercial: bool,
}
Retrieving Models
Once you have created a model and its associated database table, you are ready to start retrieving data from your database. You can think of each Ensemble model as a powerful query builder allowing you to fluently query the database table associated with the model. The model’s all
method will retrieve all of the records from the model’s associated database table:
for flight in Flight::all().await? {
println!("{}", flight.name);
}
Building Queries
The Ensemble all
method will return all of the results in the model’s table. However, since each Ensemble model serves as a query builder, you may add additional constraints to queries and then invoke the get method to retrieve the results:
let flights: Vec<Flight> = Flight::query()
.r#where("active", '=', 1)
.order_by("name", "asc")
.limit(10)
.get().await?;
Refreshing Models
If you already have an instance of an Ensemble model that was retrieved from the database, you can “refresh” the model using the fresh
method. The fresh method will re-retrieve the model from the database. The existing model instance will not be affected:
let flight: Flight = Flight::query()
.r#where("number", '=', "FR 900")
.first().await?
.unwrap();
let fresh_flight = flight.fresh().await?;
Retrieving Single Models / Aggregates
In addition to retrieving all of the records matching a given query, you may also retrieve single records using the find
or first
methods. Instead of returning a collection of models, these methods return a single model instance:
// Retrieve a model by its primary key...
let flight = Flight::find(1).await?;
// Retrieve the first model matching the query constraints...
let flight: Flight = Flight::query()
.r#where("active", "=", 1)
.first().await?
.unwrap();
Inserting & Updating Models
Inserts
Of course, when using Ensemble, we don’t only need to retrieve models from the database. We also need to insert new records. Thankfully, Ensemble makes it simple. To insert a new record into the database, you should instantiate a new model instance and set attributes on the model. Then, call the save
method on the model instance:
use axum::{Json, http::StatusCode, response::IntoResponse};
#[derive(serde::Deserialize)]
struct FlightRequest {
name: String,
}
async fn store_flight(Json(request): Json<FlightRequest>) -> impl IntoResponse {
let mut flight = Flight {
name: request.name,
..Flight::default()
};
flight.save().await.unwrap();
(StatusCode::CREATED, Json(flight))
}
In this example, we assign the name field from the incoming HTTP request to the name attribute of the model instance. When we call the save
method, a record will be inserted into the database. The model’s created_at
and updated_at
timestamps will automatically be set when the save method is called, so there is no need to set them manually.
Alternatively, you may use the create
method to “save” a new model using a single statement. The inserted model instance will be returned to you by the create
method:
let flight = Flight::create(Flight {
name: "London to Paris".to_string(),
..Flight::default()
}).await?;
Updates
The save
method may also be used to update models that already exist in the database. To update a model, you should retrieve it and set any attributes you wish to update. Then, you should call the model’s save
method. Again, the updated_at
timestamp will automatically be updated, so there is no need to manually set its value:
let mut flight = Flight::find(1).await?;
flight.name = "Paris to London".to_string();
flight.save().await?;
Mass Updates
Updates can also be performed against models that match a given query. In this example, all flights that are active and have a destination of San Diego will be marked as delayed:
Flight::query()
.r#where("active", '=', 1)
.r#where("destination", '=', "San Diego")
.update(vec![("delayed", 1)])
.await?;
The update method expects an array of tuples containing representing column and value pairs for the columns that should be updated. The update method returns the number of affected rows.
Deleting Models
To delete a model, you may call the delete method on the model instance:
let flight = Flight::find(1).await?;
flight.delete().await?;
You may call the truncate method to delete all of the model’s associated database records. The truncate operation will also reset any auto-incrementing IDs on the model’s associated table:
Flight::query().truncate().await?;
Deleting Models Using Queries
Of course, you may build an Ensemble query to delete all models matching your query’s criteria. In this example, we will delete all flights that are marked as inactive:
Flight::query()
.r#where("active", '=', 0)
.delete().await?;
Serializing Models
To convert a model to JSON, you should use the json
method. This will return a serde_json::Value
, which can be used to serialize the model to a JSON string. This is particularly useful when you need to send the model data as a response in a web API:
let user = User::find(1).await?;
return Ok(user.json());
Hiding Attributes From JSON
Sometimes you may wish to limit the attributes, such as passwords, that are included in your model’s array or JSON representation. To do so, add the #[model(hide)]
attribute to hidden fields. Attributes marked as hidden will not be included in the serialized representation of your model:
use ensemble::Model;
#[derive(Debug, Model)]
struct User {
pub id: u64,
pub name: String,
#[model(hide)]
pub secret: String,
}
Note that Ensemble will mark password
fields as hidden by default. You can explicitly include them by negating the `#[model(hide)] attribute, like so:
use ensemble::{Model, types::Hashed};
#[derive(Debug, Model)]
struct User {
pub id: u64,
pub name: String,
#[model(hide = false)]
pub password: Hashed<String>,
}
Note that fields marked as hidden will not be present on any serde
serialized formats, not just JSON.
Modules
- Relationships between models.
Macros
- Accepts a list of structs that implement the
Migration
trait, and runs them.
Enums
Traits
Functions
- Sets up the database pool.