Lesson 9 of 30
SeaORM: Entities, Relations, and ActiveModel
SeaORM 1.1 is an async object-relational mapper (ORM) built on SQLx. An ORM maps tables to Rust types and builds SQL from method calls. This lesson builds a Storefront service with products and orders on PostgreSQL, using entities, ActiveModel, batched relation loading, and transactions. The schema follows Relationships, indexes, and N+1.
Project setup
Start PostgreSQL and create the tables. Wait a few seconds after docker run until PostgreSQL accepts connections before the psql line.
docker run --name pg -e POSTGRES_PASSWORD=pw -e POSTGRES_DB=storefront -p 5432:5432 -d postgres:16
docker exec -i pg psql -U postgres -d storefront < migrations/0001_init.sql
export DATABASE_URL=postgres://postgres:pw@127.0.0.1:5432/storefrontCargo.toml
# Cargo.toml
[package]
name = "storefront-seaorm"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8"
sea-orm = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-rustls", "macros"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }migrations/0001_init.sql
-- migrations/0001_init.sql
CREATE TABLE products (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
price_cents INTEGER NOT NULL CHECK (price_cents > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE order_lines (
order_id BIGINT NOT NULL REFERENCES orders (id) ON DELETE CASCADE,
line_no INTEGER NOT NULL,
product_id BIGINT NOT NULL REFERENCES products (id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
price_at_purchase_cents INTEGER NOT NULL CHECK (price_at_purchase_cents > 0),
PRIMARY KEY (order_id, line_no)
);
CREATE INDEX orders_customer_idx ON orders (customer_id);
CREATE INDEX order_lines_product_idx ON order_lines (product_id);Example 1: Entities
An entity file has a Model struct with one field per column. DeriveEntityModel generates the Entity, Column, PrimaryKey, and ActiveModel types from it. The Relation enum lists foreign keys, and each Related impl lets one entity navigate to another.
src/entities/mod.rs
// src/entities/mod.rs
pub mod order;
pub mod order_line;
pub mod product;src/entities/product.rs
// src/entities/product.rs
use sea_orm::entity::prelude::*;
use serde::Serialize;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)]
#[sea_orm(table_name = "products")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
#[sea_orm(unique)]
pub sku: String,
pub name: String,
pub price_cents: i32,
#[serde(skip)]
pub created_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::order_line::Entity")]
OrderLines,
}
impl Related<super::order_line::Entity> for Entity {
fn to() -> RelationDef {
Relation::OrderLines.def()
}
}
impl ActiveModelBehavior for ActiveModel {}src/entities/order.rs
// src/entities/order.rs
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "orders")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub customer_id: i64,
pub status: String,
pub created_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::order_line::Entity")]
OrderLines,
}
impl Related<super::order_line::Entity> for Entity {
fn to() -> RelationDef {
Relation::OrderLines.def()
}
}
impl ActiveModelBehavior for ActiveModel {}src/entities/order_line.rs
// src/entities/order_line.rs
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "order_lines")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub order_id: i64,
#[sea_orm(primary_key, auto_increment = false)]
pub line_no: i32,
pub product_id: i64,
pub quantity: i32,
pub price_at_purchase_cents: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::order::Entity",
from = "Column::OrderId",
to = "super::order::Column::Id"
)]
Order,
#[sea_orm(
belongs_to = "super::product::Entity",
from = "Column::ProductId",
to = "super::product::Column::Id"
)]
Product,
}
impl Related<super::order::Entity> for Entity {
fn to() -> RelationDef {
Relation::Order.def()
}
}
impl Related<super::product::Entity> for Entity {
fn to() -> RelationDef {
Relation::Product.def()
}
}
impl ActiveModelBehavior for ActiveModel {}#[sea_orm(primary_key)]marks the key. An integer key is treated as auto-increment, soActiveModelleaves itNotSeton insert and the database assigns it.order_linehas a composite key. Each key field carriesauto_increment = false.#[serde(skip)]keepscreated_atout of the JSON, so responses are stable.sea-orm-cli generate entitycan produce these files from an existing database. The generated files are a starting point to review and commit.
Example 2: Connect and read products
Database::connect returns a DatabaseConnection, which wraps an SQLx pool and is cheap to clone. Queries start from Entity::find() and filter with the generated Column enum, so a misspelled column is a compile error.
src/error.rs
// src/error.rs
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use sea_orm::{DbErr, SqlErr};
use serde_json::json;
pub enum ApiError {
NotFound,
Conflict,
BadRequest(&'static str),
Internal,
}
impl From<DbErr> for ApiError {
fn from(err: DbErr) -> Self {
match err.sql_err() {
Some(SqlErr::UniqueConstraintViolation(_)) => ApiError::Conflict,
Some(SqlErr::ForeignKeyConstraintViolation(_)) => {
ApiError::BadRequest("unknown reference")
}
_ => {
eprintln!("database error: {err}");
ApiError::Internal
}
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match self {
ApiError::NotFound => (StatusCode::NOT_FOUND, "not found"),
ApiError::Conflict => (StatusCode::CONFLICT, "conflict"),
ApiError::BadRequest(message) => (StatusCode::BAD_REQUEST, message),
ApiError::Internal => (StatusCode::INTERNAL_SERVER_ERROR, "internal error"),
};
(status, Json(json!({ "error": message }))).into_response()
}
}src/products.rs
// src/products.rs
use axum::{
extract::{Path, Query, State},
Json,
};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
use serde::Deserialize;
use crate::{entities::product, error::ApiError, AppState};
#[derive(Deserialize)]
pub struct ProductFilter {
max_price: Option<i32>,
name: Option<String>,
}
pub async fn get_one(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<product::Model>, ApiError> {
let found = product::Entity::find_by_id(id).one(&state.db).await?;
found.map(Json).ok_or(ApiError::NotFound)
}
pub async fn list(
State(state): State<AppState>,
Query(filter): Query<ProductFilter>,
) -> Result<Json<Vec<product::Model>>, ApiError> {
let mut query = product::Entity::find();
if let Some(max) = filter.max_price {
query = query.filter(product::Column::PriceCents.lte(max));
}
if let Some(name) = filter.name {
query = query.filter(product::Column::Name.contains(name));
}
let rows = query
.order_by_asc(product::Column::Id)
.limit(50)
.all(&state.db)
.await?;
Ok(Json(rows))
}src/main.rs
// src/main.rs
mod entities;
mod error;
mod products;
use std::time::Duration;
use axum::{
routing::{get, post},
Router,
};
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
#[derive(Clone)]
pub struct AppState {
pub db: DatabaseConnection,
}
#[tokio::main]
async fn main() {
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let mut options = ConnectOptions::new(url);
options
.max_connections(10)
.min_connections(1)
.acquire_timeout(Duration::from_secs(3))
.sqlx_logging(false);
let db = Database::connect(options).await.expect("database connection");
let app = Router::new()
.route("/products", get(products::list))
.route("/products/{id}", get(products::get_one))
.with_state(AppState { db });
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}onereturnsOption<Model>andallreturnsVec<Model>.- Repeated
filtercalls combine withAND.containsbuilds aLIKEwith a bound value. DbErr::sql_err()classifies unique and foreign key violations, so handlers map them to 409 and 400 without reading driver error codes.
Example 3: Insert and update with ActiveModel
A Model is a row that was read. An ActiveModel wraps each field in ActiveValue: Set(v) writes the field, NotSet leaves it out of the statement, and Unchanged(v) holds a value that was read and is not written. insert runs INSERT ... RETURNING on PostgreSQL and returns the stored Model.
src/products.rs gains create and update. Its imports change too. main.rs registers the new methods.
src/products.rs
// src/products.rs
use axum::{
extract::{Path, Query, State},
http::StatusCode,
Json,
};
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
};
use serde::Deserialize;
use crate::{entities::product, error::ApiError, AppState};
#[derive(Deserialize)]
pub struct ProductFilter {
max_price: Option<i32>,
name: Option<String>,
}
#[derive(Deserialize)]
pub struct NewProduct {
sku: String,
name: String,
price_cents: i32,
}
#[derive(Deserialize)]
pub struct UpdateProduct {
name: Option<String>,
price_cents: Option<i32>,
}
pub async fn get_one(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<product::Model>, ApiError> {
let found = product::Entity::find_by_id(id).one(&state.db).await?;
found.map(Json).ok_or(ApiError::NotFound)
}
pub async fn list(
State(state): State<AppState>,
Query(filter): Query<ProductFilter>,
) -> Result<Json<Vec<product::Model>>, ApiError> {
let mut query = product::Entity::find();
if let Some(max) = filter.max_price {
query = query.filter(product::Column::PriceCents.lte(max));
}
if let Some(name) = filter.name {
query = query.filter(product::Column::Name.contains(name));
}
let rows = query
.order_by_asc(product::Column::Id)
.limit(50)
.all(&state.db)
.await?;
Ok(Json(rows))
}
pub async fn create(
State(state): State<AppState>,
Json(body): Json<NewProduct>,
) -> Result<(StatusCode, Json<product::Model>), ApiError> {
if body.price_cents <= 0 {
return Err(ApiError::BadRequest("price must be positive"));
}
let created = product::ActiveModel {
sku: Set(body.sku),
name: Set(body.name),
price_cents: Set(body.price_cents),
..Default::default()
}
.insert(&state.db)
.await?;
Ok((StatusCode::CREATED, Json(created)))
}
pub async fn update(
State(state): State<AppState>,
Path(id): Path<i64>,
Json(body): Json<UpdateProduct>,
) -> Result<Json<product::Model>, ApiError> {
if body.name.is_none() && body.price_cents.is_none() {
return Err(ApiError::BadRequest("nothing to update"));
}
let existing = product::Entity::find_by_id(id)
.one(&state.db)
.await?
.ok_or(ApiError::NotFound)?;
let mut active: product::ActiveModel = existing.into();
if let Some(name) = body.name {
active.name = Set(name);
}
if let Some(price) = body.price_cents {
if price <= 0 {
return Err(ApiError::BadRequest("price must be positive"));
}
active.price_cents = Set(price);
}
let updated = active.update(&state.db).await?;
Ok(Json(updated))
}src/main.rs
// src/main.rs
mod entities;
mod error;
mod products;
use std::time::Duration;
use axum::{
routing::{get, post},
Router,
};
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
#[derive(Clone)]
pub struct AppState {
pub db: DatabaseConnection,
}
#[tokio::main]
async fn main() {
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let mut options = ConnectOptions::new(url);
options
.max_connections(10)
.min_connections(1)
.acquire_timeout(Duration::from_secs(3))
.sqlx_logging(false);
let db = Database::connect(options).await.expect("database connection");
let app = Router::new()
.route("/products", get(products::list).post(products::create))
.route("/products/{id}", get(products::get_one).patch(products::update))
.with_state(AppState { db });
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}update converts the Model into an ActiveModel and sets only the changed fields. Only fields marked Set appear in the UPDATE. The read followed by a write is a race between concurrent requests. The version-column technique in SQL transactions closes it.
Example 4: Relations without N+1
An order has many lines. SeaORM has three ways to load them.
| Method | Queries | Result |
|---|---|---|
model.find_related(Other) |
One per call | Related rows of one model |
Entity::find().find_with_related(Other) |
One join | Vec<(Model, Vec<Related>)>; a limit applies to joined rows |
models.load_many(Other, db) |
One extra IN query for all models |
Vec<Vec<Related>> in the order of the input |
find_related inside a loop is the N+1 pattern from Relationships, indexes, and N+1. The handler below pages the parent rows first and then runs one load_many, so the query count stays at two for any page size.
src/orders.rs
// src/orders.rs
use axum::{
extract::{Path, State},
Json,
};
use sea_orm::{ColumnTrait, EntityTrait, LoaderTrait, QueryFilter, QueryOrder, QuerySelect};
use serde::Serialize;
use crate::{
entities::{order, order_line},
error::ApiError,
AppState,
};
#[derive(Serialize)]
pub struct LineDto {
line_no: i32,
product_id: i64,
quantity: i32,
price_at_purchase_cents: i32,
}
#[derive(Serialize)]
pub struct OrderDto {
id: i64,
customer_id: i64,
status: String,
lines: Vec<LineDto>,
}
impl OrderDto {
pub fn new(order: order::Model, mut lines: Vec<order_line::Model>) -> Self {
lines.sort_by_key(|line| line.line_no);
OrderDto {
id: order.id,
customer_id: order.customer_id,
status: order.status,
lines: lines
.into_iter()
.map(|line| LineDto {
line_no: line.line_no,
product_id: line.product_id,
quantity: line.quantity,
price_at_purchase_cents: line.price_at_purchase_cents,
})
.collect(),
}
}
}
pub async fn list_for_customer(
State(state): State<AppState>,
Path(customer_id): Path<i64>,
) -> Result<Json<Vec<OrderDto>>, ApiError> {
let orders = order::Entity::find()
.filter(order::Column::CustomerId.eq(customer_id))
.order_by_desc(order::Column::Id)
.limit(20)
.all(&state.db)
.await?;
let lines = orders.load_many(order_line::Entity, &state.db).await?;
let dtos = orders
.into_iter()
.zip(lines)
.map(|(order, lines)| OrderDto::new(order, lines))
.collect();
Ok(Json(dtos))
}src/main.rs
// src/main.rs
mod entities;
mod error;
mod orders;
mod products;
use std::time::Duration;
use axum::{
routing::{get, post},
Router,
};
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
#[derive(Clone)]
pub struct AppState {
pub db: DatabaseConnection,
}
#[tokio::main]
async fn main() {
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let mut options = ConnectOptions::new(url);
options
.max_connections(10)
.min_connections(1)
.acquire_timeout(Duration::from_secs(3))
.sqlx_logging(false);
let db = Database::connect(options).await.expect("database connection");
let app = Router::new()
.route("/products", get(products::list).post(products::create))
.route("/products/{id}", get(products::get_one).patch(products::update))
.route("/customers/{customer_id}/orders", get(orders::list_for_customer))
.with_state(AppState { db });
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}With sqlx_logging(true) in ConnectOptions, the SQL of each request is logged, so the statements per request can be counted. The builder hides the SQL text, so this check is the way to see the query count.
Example 5: Transactions and raw SQL
db.begin() returns a DatabaseTransaction. It implements the same connection trait as DatabaseConnection, so every query accepts &txn where it accepts &db. Returning early drops the transaction, which rolls it back. create reads each product price inside the transaction and stores it as price_at_purchase_cents.
totals runs a grouped aggregate as raw SQL. FromQueryResult maps the columns to a struct. Window functions, complex aggregates, and FOR UPDATE SKIP LOCKED claims are usually clearer as SQL.
src/orders.rs
// src/orders.rs
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use sea_orm::{
ActiveModelTrait, ColumnTrait, DbBackend, EntityTrait, FromQueryResult, LoaderTrait,
QueryFilter, QueryOrder, QuerySelect, Set, Statement, TransactionTrait,
};
use serde::{Deserialize, Serialize};
use crate::{
entities::{order, order_line, product},
error::ApiError,
AppState,
};
#[derive(Serialize)]
pub struct LineDto {
line_no: i32,
product_id: i64,
quantity: i32,
price_at_purchase_cents: i32,
}
#[derive(Serialize)]
pub struct OrderDto {
id: i64,
customer_id: i64,
status: String,
lines: Vec<LineDto>,
}
impl OrderDto {
pub fn new(order: order::Model, mut lines: Vec<order_line::Model>) -> Self {
lines.sort_by_key(|line| line.line_no);
OrderDto {
id: order.id,
customer_id: order.customer_id,
status: order.status,
lines: lines
.into_iter()
.map(|line| LineDto {
line_no: line.line_no,
product_id: line.product_id,
quantity: line.quantity,
price_at_purchase_cents: line.price_at_purchase_cents,
})
.collect(),
}
}
}
#[derive(Deserialize)]
pub struct NewLine {
product_id: i64,
quantity: i32,
}
#[derive(Deserialize)]
pub struct NewOrder {
customer_id: i64,
lines: Vec<NewLine>,
}
#[derive(Serialize, FromQueryResult)]
pub struct OrderTotal {
order_id: i64,
total_cents: i64,
}
pub async fn list_for_customer(
State(state): State<AppState>,
Path(customer_id): Path<i64>,
) -> Result<Json<Vec<OrderDto>>, ApiError> {
let orders = order::Entity::find()
.filter(order::Column::CustomerId.eq(customer_id))
.order_by_desc(order::Column::Id)
.limit(20)
.all(&state.db)
.await?;
let lines = orders.load_many(order_line::Entity, &state.db).await?;
let dtos = orders
.into_iter()
.zip(lines)
.map(|(order, lines)| OrderDto::new(order, lines))
.collect();
Ok(Json(dtos))
}
pub async fn create(
State(state): State<AppState>,
Json(body): Json<NewOrder>,
) -> Result<(StatusCode, Json<OrderDto>), ApiError> {
if body.lines.is_empty() {
return Err(ApiError::BadRequest("order needs at least one line"));
}
let txn = state.db.begin().await?;
let new_order = order::ActiveModel {
customer_id: Set(body.customer_id),
..Default::default()
}
.insert(&txn)
.await?;
let mut saved_lines = Vec::new();
for (index, line) in body.lines.into_iter().enumerate() {
if line.quantity <= 0 {
return Err(ApiError::BadRequest("quantity must be positive"));
}
let product = product::Entity::find_by_id(line.product_id)
.one(&txn)
.await?
.ok_or(ApiError::BadRequest("unknown product"))?;
let saved = order_line::ActiveModel {
order_id: Set(new_order.id),
line_no: Set(index as i32 + 1),
product_id: Set(product.id),
quantity: Set(line.quantity),
price_at_purchase_cents: Set(product.price_cents),
}
.insert(&txn)
.await?;
saved_lines.push(saved);
}
txn.commit().await?;
Ok((StatusCode::CREATED, Json(OrderDto::new(new_order, saved_lines))))
}
pub async fn totals(
State(state): State<AppState>,
Path(customer_id): Path<i64>,
) -> Result<Json<Vec<OrderTotal>>, ApiError> {
let statement = Statement::from_sql_and_values(
DbBackend::Postgres,
"SELECT l.order_id,
SUM(l.quantity::bigint * l.price_at_purchase_cents)::bigint AS total_cents
FROM order_lines l
JOIN orders o ON o.id = l.order_id
WHERE o.customer_id = $1
GROUP BY l.order_id
ORDER BY l.order_id",
[customer_id.into()],
);
let rows = OrderTotal::find_by_statement(statement).all(&state.db).await?;
Ok(Json(rows))
}SQLx or SeaORM
| SQLx | SeaORM | |
|---|---|---|
| Query style | SQL strings | Rust method calls and generated Column types |
| Row types | Own structs with FromRow |
Generated Model and ActiveModel |
| Partial writes | Explicit SQL | ActiveModel with Set |
| Relations | Explicit joins and batched queries | Related, load_many, find_with_related |
| Visible SQL | Always | Generated; needs logging to inspect |
SeaORM runs on SQLx, so pool sizing, timeouts, and transaction behavior follow the same rules. Both can share a database in one service, at the cost of two query APIs.
Complete example
File tree:
storefront-seaorm/
Cargo.toml
migrations/0001_init.sql
src/
main.rs
error.rs
products.rs
orders.rs
entities/
mod.rs
product.rs
order.rs
order_line.rsCargo.toml
# Cargo.toml
[package]
name = "storefront-seaorm"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8"
sea-orm = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-rustls", "macros"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }migrations/0001_init.sql
-- migrations/0001_init.sql
CREATE TABLE products (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
price_cents INTEGER NOT NULL CHECK (price_cents > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE order_lines (
order_id BIGINT NOT NULL REFERENCES orders (id) ON DELETE CASCADE,
line_no INTEGER NOT NULL,
product_id BIGINT NOT NULL REFERENCES products (id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
price_at_purchase_cents INTEGER NOT NULL CHECK (price_at_purchase_cents > 0),
PRIMARY KEY (order_id, line_no)
);
CREATE INDEX orders_customer_idx ON orders (customer_id);
CREATE INDEX order_lines_product_idx ON order_lines (product_id);src/main.rs
// src/main.rs
mod entities;
mod error;
mod orders;
mod products;
use std::time::Duration;
use axum::{
routing::{get, post},
Router,
};
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
#[derive(Clone)]
pub struct AppState {
pub db: DatabaseConnection,
}
#[tokio::main]
async fn main() {
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let mut options = ConnectOptions::new(url);
options
.max_connections(10)
.min_connections(1)
.acquire_timeout(Duration::from_secs(3))
.sqlx_logging(false);
let db = Database::connect(options).await.expect("database connection");
let app = Router::new()
.route("/products", get(products::list).post(products::create))
.route("/products/{id}", get(products::get_one).patch(products::update))
.route("/customers/{customer_id}/orders", get(orders::list_for_customer))
.route("/customers/{customer_id}/order-totals", get(orders::totals))
.route("/orders", post(orders::create))
.with_state(AppState { db });
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}src/error.rs
// src/error.rs
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use sea_orm::{DbErr, SqlErr};
use serde_json::json;
pub enum ApiError {
NotFound,
Conflict,
BadRequest(&'static str),
Internal,
}
impl From<DbErr> for ApiError {
fn from(err: DbErr) -> Self {
match err.sql_err() {
Some(SqlErr::UniqueConstraintViolation(_)) => ApiError::Conflict,
Some(SqlErr::ForeignKeyConstraintViolation(_)) => {
ApiError::BadRequest("unknown reference")
}
_ => {
eprintln!("database error: {err}");
ApiError::Internal
}
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match self {
ApiError::NotFound => (StatusCode::NOT_FOUND, "not found"),
ApiError::Conflict => (StatusCode::CONFLICT, "conflict"),
ApiError::BadRequest(message) => (StatusCode::BAD_REQUEST, message),
ApiError::Internal => (StatusCode::INTERNAL_SERVER_ERROR, "internal error"),
};
(status, Json(json!({ "error": message }))).into_response()
}
}src/entities/mod.rs
// src/entities/mod.rs
pub mod order;
pub mod order_line;
pub mod product;src/entities/product.rs
// src/entities/product.rs
use sea_orm::entity::prelude::*;
use serde::Serialize;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)]
#[sea_orm(table_name = "products")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
#[sea_orm(unique)]
pub sku: String,
pub name: String,
pub price_cents: i32,
#[serde(skip)]
pub created_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::order_line::Entity")]
OrderLines,
}
impl Related<super::order_line::Entity> for Entity {
fn to() -> RelationDef {
Relation::OrderLines.def()
}
}
impl ActiveModelBehavior for ActiveModel {}src/entities/order.rs
// src/entities/order.rs
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "orders")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub customer_id: i64,
pub status: String,
pub created_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::order_line::Entity")]
OrderLines,
}
impl Related<super::order_line::Entity> for Entity {
fn to() -> RelationDef {
Relation::OrderLines.def()
}
}
impl ActiveModelBehavior for ActiveModel {}src/entities/order_line.rs
// src/entities/order_line.rs
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "order_lines")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub order_id: i64,
#[sea_orm(primary_key, auto_increment = false)]
pub line_no: i32,
pub product_id: i64,
pub quantity: i32,
pub price_at_purchase_cents: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::order::Entity",
from = "Column::OrderId",
to = "super::order::Column::Id"
)]
Order,
#[sea_orm(
belongs_to = "super::product::Entity",
from = "Column::ProductId",
to = "super::product::Column::Id"
)]
Product,
}
impl Related<super::order::Entity> for Entity {
fn to() -> RelationDef {
Relation::Order.def()
}
}
impl Related<super::product::Entity> for Entity {
fn to() -> RelationDef {
Relation::Product.def()
}
}
impl ActiveModelBehavior for ActiveModel {}src/products.rs
// src/products.rs
use axum::{
extract::{Path, Query, State},
http::StatusCode,
Json,
};
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
};
use serde::Deserialize;
use crate::{entities::product, error::ApiError, AppState};
#[derive(Deserialize)]
pub struct ProductFilter {
max_price: Option<i32>,
name: Option<String>,
}
#[derive(Deserialize)]
pub struct NewProduct {
sku: String,
name: String,
price_cents: i32,
}
#[derive(Deserialize)]
pub struct UpdateProduct {
name: Option<String>,
price_cents: Option<i32>,
}
pub async fn get_one(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<product::Model>, ApiError> {
let found = product::Entity::find_by_id(id).one(&state.db).await?;
found.map(Json).ok_or(ApiError::NotFound)
}
pub async fn list(
State(state): State<AppState>,
Query(filter): Query<ProductFilter>,
) -> Result<Json<Vec<product::Model>>, ApiError> {
let mut query = product::Entity::find();
if let Some(max) = filter.max_price {
query = query.filter(product::Column::PriceCents.lte(max));
}
if let Some(name) = filter.name {
query = query.filter(product::Column::Name.contains(name));
}
let rows = query
.order_by_asc(product::Column::Id)
.limit(50)
.all(&state.db)
.await?;
Ok(Json(rows))
}
pub async fn create(
State(state): State<AppState>,
Json(body): Json<NewProduct>,
) -> Result<(StatusCode, Json<product::Model>), ApiError> {
if body.price_cents <= 0 {
return Err(ApiError::BadRequest("price must be positive"));
}
let created = product::ActiveModel {
sku: Set(body.sku),
name: Set(body.name),
price_cents: Set(body.price_cents),
..Default::default()
}
.insert(&state.db)
.await?;
Ok((StatusCode::CREATED, Json(created)))
}
pub async fn update(
State(state): State<AppState>,
Path(id): Path<i64>,
Json(body): Json<UpdateProduct>,
) -> Result<Json<product::Model>, ApiError> {
if body.name.is_none() && body.price_cents.is_none() {
return Err(ApiError::BadRequest("nothing to update"));
}
let existing = product::Entity::find_by_id(id)
.one(&state.db)
.await?
.ok_or(ApiError::NotFound)?;
let mut active: product::ActiveModel = existing.into();
if let Some(name) = body.name {
active.name = Set(name);
}
if let Some(price) = body.price_cents {
if price <= 0 {
return Err(ApiError::BadRequest("price must be positive"));
}
active.price_cents = Set(price);
}
let updated = active.update(&state.db).await?;
Ok(Json(updated))
}src/orders.rs
// src/orders.rs
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use sea_orm::{
ActiveModelTrait, ColumnTrait, DbBackend, EntityTrait, FromQueryResult, LoaderTrait,
QueryFilter, QueryOrder, QuerySelect, Set, Statement, TransactionTrait,
};
use serde::{Deserialize, Serialize};
use crate::{
entities::{order, order_line, product},
error::ApiError,
AppState,
};
#[derive(Serialize)]
pub struct LineDto {
line_no: i32,
product_id: i64,
quantity: i32,
price_at_purchase_cents: i32,
}
#[derive(Serialize)]
pub struct OrderDto {
id: i64,
customer_id: i64,
status: String,
lines: Vec<LineDto>,
}
impl OrderDto {
pub fn new(order: order::Model, mut lines: Vec<order_line::Model>) -> Self {
lines.sort_by_key(|line| line.line_no);
OrderDto {
id: order.id,
customer_id: order.customer_id,
status: order.status,
lines: lines
.into_iter()
.map(|line| LineDto {
line_no: line.line_no,
product_id: line.product_id,
quantity: line.quantity,
price_at_purchase_cents: line.price_at_purchase_cents,
})
.collect(),
}
}
}
#[derive(Deserialize)]
pub struct NewLine {
product_id: i64,
quantity: i32,
}
#[derive(Deserialize)]
pub struct NewOrder {
customer_id: i64,
lines: Vec<NewLine>,
}
#[derive(Serialize, FromQueryResult)]
pub struct OrderTotal {
order_id: i64,
total_cents: i64,
}
pub async fn list_for_customer(
State(state): State<AppState>,
Path(customer_id): Path<i64>,
) -> Result<Json<Vec<OrderDto>>, ApiError> {
let orders = order::Entity::find()
.filter(order::Column::CustomerId.eq(customer_id))
.order_by_desc(order::Column::Id)
.limit(20)
.all(&state.db)
.await?;
let lines = orders.load_many(order_line::Entity, &state.db).await?;
let dtos = orders
.into_iter()
.zip(lines)
.map(|(order, lines)| OrderDto::new(order, lines))
.collect();
Ok(Json(dtos))
}
pub async fn create(
State(state): State<AppState>,
Json(body): Json<NewOrder>,
) -> Result<(StatusCode, Json<OrderDto>), ApiError> {
if body.lines.is_empty() {
return Err(ApiError::BadRequest("order needs at least one line"));
}
let txn = state.db.begin().await?;
let new_order = order::ActiveModel {
customer_id: Set(body.customer_id),
..Default::default()
}
.insert(&txn)
.await?;
let mut saved_lines = Vec::new();
for (index, line) in body.lines.into_iter().enumerate() {
if line.quantity <= 0 {
return Err(ApiError::BadRequest("quantity must be positive"));
}
let product = product::Entity::find_by_id(line.product_id)
.one(&txn)
.await?
.ok_or(ApiError::BadRequest("unknown product"))?;
let saved = order_line::ActiveModel {
order_id: Set(new_order.id),
line_no: Set(index as i32 + 1),
product_id: Set(product.id),
quantity: Set(line.quantity),
price_at_purchase_cents: Set(product.price_cents),
}
.insert(&txn)
.await?;
saved_lines.push(saved);
}
txn.commit().await?;
Ok((StatusCode::CREATED, Json(OrderDto::new(new_order, saved_lines))))
}
pub async fn totals(
State(state): State<AppState>,
Path(customer_id): Path<i64>,
) -> Result<Json<Vec<OrderTotal>>, ApiError> {
let statement = Statement::from_sql_and_values(
DbBackend::Postgres,
"SELECT l.order_id,
SUM(l.quantity::bigint * l.price_at_purchase_cents)::bigint AS total_cents
FROM order_lines l
JOIN orders o ON o.id = l.order_id
WHERE o.customer_id = $1
GROUP BY l.order_id
ORDER BY l.order_id",
[customer_id.into()],
);
let rows = OrderTotal::find_by_statement(statement).all(&state.db).await?;
Ok(Json(rows))
}Run (PostgreSQL started and migrated as in Project setup):
cargo runRequests, with a helper that prints the status code after each body:
req() { curl -s -w ' [%{http_code}]\n' "$@"; }
J='content-type: application/json'
req -X POST localhost:3000/products -H "$J" -d '{"sku":"PEN-1","name":"Pen","price_cents":250}'
req -X POST localhost:3000/products -H "$J" -d '{"sku":"PEN-1","name":"Pen","price_cents":250}'
req -X POST localhost:3000/orders -H "$J" -d '{"customer_id":7,"lines":[{"product_id":1,"quantity":2}]}'
req -X PATCH localhost:3000/products/1 -H "$J" -d '{"price_cents":300}'
req localhost:3000/customers/7/orders
req localhost:3000/customers/7/order-totals
req -X POST localhost:3000/orders -H "$J" -d '{"customer_id":7,"lines":[{"product_id":99,"quantity":1}]}'
req localhost:3000/products/42Responses on a fresh database:
{"id":1,"sku":"PEN-1","name":"Pen","price_cents":250} [201]
{"error":"conflict"} [409]
{"id":1,"customer_id":7,"status":"pending","lines":[{"line_no":1,"product_id":1,"quantity":2,"price_at_purchase_cents":250}]} [201]
{"id":1,"sku":"PEN-1","name":"Pen","price_cents":300} [200]
[{"id":1,"customer_id":7,"status":"pending","lines":[{"line_no":1,"product_id":1,"quantity":2,"price_at_purchase_cents":250}]}] [200]
[{"order_id":1,"total_cents":500}] [200]
{"error":"unknown product"} [400]
{"error":"not found"} [404]The order keeps the price of 250 after the product price changes to 300, because the line stores the price at purchase. The failed order rolls back, so no order row remains for it.
Summary
- An entity has
Model,Column,Relation, andActiveModel, all derived from one annotated struct. find,filter, andColumnmethods build type-checked queries.onereturns anOption,allreturns aVec.ActiveModelwithSethandles inserts and partial updates.insertreturns the storedModel.find_relatedin a loop is N+1.load_manyruns one extra query for all parents.DatabaseTransactionaccepts the same calls asDatabaseConnectionand rolls back when dropped withoutcommit.DbErr::sql_err()classifies unique and foreign key violations.- Raw statements with
FromQueryResultcover queries the builder does not express well.
