Skip to main content

69 posts tagged with "news"

View All Tags

ClickHouse meets SeaORM: Arrow-powered data pipeline

Β· 5 min read
SeaQL Team
Chris Tsang
SeaORM 2.0 Banner

sea-clickhouse is a ClickHouse client that integrates with the SeaQL ecosystem. It is a soft fork of clickhouse-rs: 100% compatible with all upstream features, with SeaQL's dynamic type and Arrow support added on top. The fork is continually rebased on upstream.

In this blog post we cover:

  • Dynamic rows with try_get: fetch query results without defining any schema struct
  • Arrow RecordBatch streaming: stream query results as RecordBatches and insert them back into ClickHouse
  • SeaORM to ClickHouse: convert SeaORM entities to Arrow and insert into ClickHouse
  • Arrow Schema to ClickHouse DDL: derive CREATE TABLE DDL from an Arrow schema, no hand-written SQL

Quick Install​

[dependencies]
# a drop-in replacement of clickhouse
sea-clickhouse = { version = "0.14", features = ["arrow", "chrono", "rust_decimal"] }

Ergonomic dynamic column type with try_get​

The native clickhouse-rs client requires you to define a #[derive(Row)] struct whose field types match the query output exactly:

#[derive(Row, Deserialize)]
struct Reading {
#[serde(with = "special_deserializer")] // or use a custom deserializer?
temperature: f64, // f32? f64? depends on the SQL expression
}

let mut cursor = client.query("SELECT ...").fetch::<Reading>()?;

If the struct says f32 but the server returns Float64, you get a runtime deserialization error. For computed columns, ad-hoc queries, or SELECT * on evolving tables, maintaining these structs is fragile.

sea-clickhouse reads the column types from the response metadata and maps them to sea_query::Value automatically:

let mut cursor = client.query("SELECT 1::UInt32 + 1::Float32 AS value").fetch_rows()?;
let row = cursor.next().await?.expect("one row");

// UInt32 + Float32 -> Float64
assert_eq!(row.try_get::<f64, _>(0)?, 2.0); // by index
assert_eq!(row.try_get::<f32, _>(0)?, 2.0); // narrower type also works
assert_eq!(row.try_get::<Decimal, _>("value")?, 2.into()); // by column name

try_get coerces at runtime: access by index or column name, request the type you need, and it converts where possible.

Arrow RecordBatch Streaming​

next_arrow_batch(chunk_size) streams query results as Arrow RecordBatches, ready for DataFusion, Polars, Parquet export, or any Arrow consumer.

use clickhouse::Client;
use sea_orm_arrow::arrow::{array::RecordBatch, util::pretty};

let client = Client::default().with_url("http://localhost:18123");

let sql = r#"
SELECT
toUInt64(id) AS id,
toDateTime64(recorded_at, 6) AS recorded_at,
toInt32(sensor_id) AS sensor_id,
toFloat64(temperature) AS temperature,
toDecimal128(voltage, 4) AS voltage
FROM sensor_data
LIMIT 1000
"#;

let mut cursor = client.query(sql).fetch_rows()?;
let mut batches: Vec<RecordBatch> = Vec::new();
while let Some(batch) = cursor.next_arrow_batch(100).await? {
batches.push(batch);
}
pretty::print_batches(&batches).unwrap();
+----+-------------------------+-----------+----------------------+---------+
| id | recorded_at | sensor_id | temperature | voltage |
+----+-------------------------+-----------+----------------------+---------+
| 1 | 2026-01-01T13:35:36.736 | 106 | 36.345616831016436 | 3.2736 |
| 2 | 2026-01-01T10:07:38.458 | 108 | 10.122001773336567 | 3.3458 |
| 3 | 2026-01-01T01:15:18.518 | 108 | 35.21406789966149 | 3.1518 |
| 4 | 2026-01-01T05:36:57.017 | 107 | 22.92828141235666 | 3.2016 |
| 5 | 2026-01-01T13:17:36.056 | 106 | -2.082591477369223 | 3.0056 |
| ... |
+----+-------------------------+-----------+----------------------+---------+

Those batches can be inserted back into ClickHouse directly:

let mut insert = client.insert_arrow("sensor_data_processed", &batches[0].schema()).await?;
for batch in &batches {
insert.write_batch(batch).await?;
}
insert.end().await?;

No #[derive(Row)] macros. The Arrow schema carries the type information end-to-end.

SeaORM to ClickHouse​

SeaORM started as an OLTP toolkit, but with Arrow and now ClickHouse support, SeaQL bridges data engineering with data science: your OLTP entities become the source of truth for OLAP pipelines too.

use sea_orm::entity::prelude::*;

#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "measurement", arrow_schema)]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub recorded_at: ChronoDateTime,
pub sensor_id: i32,
pub temperature: f64,
#[sea_orm(column_type = "Decimal(Some((38, 4)))")]
pub voltage: Decimal,
}

Build models, convert to a RecordBatch, and insert:

use sea_orm::ArrowSchema;

let models: Vec<measurement::ActiveModel> = vec![..];

let schema = measurement::Entity::arrow_schema();
let batch = measurement::ActiveModel::to_arrow(&models, &schema)?;

let mut insert = client.insert_arrow("measurement", &schema).await?;
insert.write_batch(&batch).await?;
insert.end().await?;

arrow_schema on the entity derives the Arrow schema at compile time. to_arrow converts a slice of ActiveModels into a RecordBatch. From there, insert_arrow streams the batch into ClickHouse over HTTP. See the full working example.

Arrow Schema to ClickHouse DDL​

ClickHouseSchema::from_arrow derives a full CREATE TABLE statement from any Arrow schema, and exposes a SeaQuery-like fluent API to configure ClickHouse-specific attributes:

use clickhouse::schema::{ClickHouseSchema, Engine};

let mut schema = ClickHouseSchema::from_arrow(&batch.schema());
schema
.table_name("sensor_readings")
.engine(Engine::ReplacingMergeTree)
.primary_key(["recorded_at", "device"]);
schema.find_column_mut("device").set_low_cardinality(true);

let ddl = schema.to_string();
client.query(&ddl).execute().await?;

Generated DDL:

CREATE TABLE sensor_readings (
id UInt64,
recorded_at DateTime64(6),
device LowCardinality(String),
temperature Nullable(Float64),
voltage Decimal(38, 4)
) ENGINE = ReplacingMergeTree()
PRIMARY KEY (recorded_at, device)

The full workflow: Arrow -> derive DDL -> create table -> insert batches. Zero hand-written ClickHouse SQL. All examples shown here are available as runnable examples in the repository.

πŸ¦€ Rustacean Sticker Pack​

The Rustacean Sticker Pack is the perfect way to express your passion for Rust. Our stickers are made with a premium water-resistant vinyl with a unique matte finish.

Sticker Pack Contents:

  • Logo of SeaQL projects: SeaQL, SeaORM, SeaQuery, Seaography
  • Mascots: Ferris the Crab x 3, Terres the Hermit Crab
  • The Rustacean wordmark

Support SeaQL and get a Sticker Pack!

Rustacean Sticker Pack by SeaQL

Tutorial: let's make a resumable Pi Spigot with SQLite

Β· 6 min read
SeaQL Team
Chris Tsang
SeaORM 2.0 Banner

The Problem: Durability​

Long-running programs crash. When they do, you start over from zero. The fix is checkpointing: periodically save state to a database so the program can resume where it left off. In this tutorial we wire up checkpointing with SeaORM Sync and rusqlite. No async runtime, no handwritten SQL.

The Workload: Pi Spigot​

We need a computation that takes a long time and produces results incrementally. Here we use the Rabinowitz–Wagon pi spigot algorithm1 as demonstration.

The algorithm computes decimal digits of pi one at a time, using only integer arithmetic. Each iteration mutates the internal state, and may or may not produce a digit. So "I completed iteration N, therefore I have N digits" is not true.

This makes it a good test case for checkpointing. You can't just save the digit count and recompute from there: you need the full internal state.

The State Machine Pattern​

Any computation that can be modeled as a state machine can be made resumable. The recipe:

new()        β†’ initialize fresh state
step() β†’ advance one iteration, mutating &mut self
finalize() β†’ flush any buffered output
to_state() β†’ serialize self into a database row
from_state() β†’ deserialize a database row back into self

step() is the core loop body. Call it repeatedly to drive the computation forward. to_state() and from_state() handle persistence: save the internal state and resume from it later.

The checkpoint frequency is a param you control. Checkpoint every step and you lose almost no work on crash, but pay the IO cost every iteration. Checkpoint every 1000 steps and crashes cost you up to 1000 steps of rework, but the overhead is negligible.

Integrating with SeaORM + rusqlite​

Now let's wire this pattern into a real database. SeaORM 2.0 ships a sync API (crate sea-orm-sync) backed by rusqlite. The API surface is compatible with sea-orm, just without any async.

Step 1: Define the State Entity​

Map every mutable field of the computation to a column:

state.rs
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "state")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub digits: u32,
pub boxes: JsonVec,
pub i: u32,
pub nines: u32,
pub predigit: u8,
pub have_predigit: bool,
pub count: u32,
pub result: String,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, FromJsonQueryResult)]
pub struct JsonVec(pub Vec<u32>);

impl ActiveModelBehavior for ActiveModel {}

digits is the primary key: it identifies which computation this checkpoint belongs to (i.e. "compute 10000 digits of pi"). boxes holds the algorithm's working array as a JSON column via FromJsonQueryResult, since SQLite has no native array type. The rest are scalars: iteration counter, buffered-nine count, the held predigit, and the digits emitted so far.

Step 2: Serialize and Deserialize​

The computation struct itself has no dependency on SeaORM: it uses plain Vec<u32>, u32, String. Two glue functions convert between this struct and the entity model. This keeps the core algorithm pure and testable; the entire persistence layer can be gated behind a feature flag so that the library can work without sea-orm.

The conversion functions:

fn from_state(s: state::Model) -> Self {
Self {
digits: s.digits,
boxes: s.boxes.0,
nines: s.nines,
predigit: s.predigit,
have_predigit: s.have_predigit,
count: s.count,
result: s.result,
start_i: s.i,
}
}

fn to_state(&self, i: u32) -> state::Model {
state::Model {
digits: self.digits,
boxes: state::JsonVec(self.boxes.clone()),
i,
nines: self.nines,
predigit: self.predigit,
have_predigit: self.have_predigit,
count: self.count,
result: self.result.clone(),
}
}

Step 3: Checkpoint in a Transaction​

Inside the main loop, periodically save state. The delete-then-insert is wrapped in a transaction so the checkpoint is atomic: either the full state is written or nothing changes.

fn persist_state(&self, db: &DatabaseConnection, i: u32) -> Result<(), DbErr> {
let txn = db.begin()?;
state::Entity::delete_by_id(self.digits).exec(&txn)?;
self.to_state(i + 1).into_active_model().insert(&txn)?;
txn.commit()?;
Ok(())
}

Note the i + 1: we save the next iteration index, not the current one. The state has already been mutated by step(), so when the program resumes it should continue from the next iteration, not re-execute the one it just completed.

If the process dies at any point before commit(), SQLite rolls back the transaction automatically. The previous checkpoint remains intact. This is more resilient than writing to a plain JSON file, where a crash mid-write can leave you with a truncated or corrupted file and no valid checkpoint at all.

Step 4: Resume on Startup​

On startup, check for an existing checkpoint. get_schema_builder().sync() creates the table from the entity definition if it doesn't already exist, so there is no need to write CREATE TABLE SQL:

pub fn resume(db: &DatabaseConnection, digits: u32) -> Result<Self, DbErr> {
db.get_schema_builder()
.register(state::Entity)
.sync(db)?;

match state::Entity::find_by_id(digits).one(db)? {
Some(state) => Ok(Self::from_state(state)),
None => Ok(Self::new(digits)),
}
}

First run: creates the table, starts fresh. Subsequent runs: finds the checkpoint row, reconstructs the computation mid-flight. The calling code doesn't need to distinguish between the two cases.

Putting It Together​

The main loop looks like this:

// initialize states in self

for i in self.start_i..=self.digits {
self.step();

if self.count > 0 && self.count % checkpoint_interval == 0 {
self.persist_state(db, i)?;
}
}

self.finalize();

The full example including tests is in the SeaORM repository.

Conclusion​

SeaORM Sync with rusqlite is a lightweight combination: a single-file database, no server, no async runtime. You define your state as an entity, and SeaORM handles the SQL. No hand-written queries, no migration files. You stay focused on the core program logic.

The patterns here are reusable. State machine + serialization round-trip + transactional checkpoint: apply them to batch jobs, simulations, data pipelines, or any long-running process that shouldn't have to start over after a crash.

πŸ¦€ Rustacean Sticker Pack​

The Rustacean Sticker Pack is the perfect way to express your passion for Rust. Our stickers are made with a premium water-resistant vinyl with a unique matte finish.

Sticker Pack Contents:

  • Logo of SeaQL projects: SeaQL, SeaORM, SeaQuery, Seaography
  • Mascots: Ferris the Crab x 3, Terres the Hermit Crab
  • The Rustacean wordmark

Support SeaQL and get a Sticker Pack!

Rustacean Sticker Pack by SeaQL

Footnotes​

  1. The spigot algorithm is not the fastest way to compute pi. It can take hours to produce 1 million digits. ↩

SeaORM 2.0 with SQL Server Support

Β· 8 min read
SeaQL Team
Chris Tsang
SeaORM 2.0 Banner

SeaORM X is a proprietary extension of SeaORM with first-class Microsoft SQL Server support. It covers the same API surface, with the full test suite and examples ported to MSSQL. With SeaORM 2.0, SeaORM X has been rebuilt on top of it: every improvement in the open-source release ships to MSSQL users too.

If you are building enterprise software on SQL Server, you can request commercial access.

What's in SeaORM X​

1. Native MSSQL Driver via SQLz with Connection Pooling​

SeaORM X ships a full ConnectionPool driver for SQL Server (mssql:// URI scheme) built on top of the SQLz/TDS stack. You get the same Database::connect(url) ergonomics as MySQL, PostgreSQL, and SQLite: no separate client setup required.

let db = Database::connect("mssql://user:pass@localhost:1433?trustCertificate=true").await?;

The underlying SQLz pool is a parallel implementation of SQLx's pool architecture applied to SQL Server. It supports the same lifecycle hooks as SQLx:

  • after_connect: initialize a freshly opened connection
  • before_acquire: validate an idle connection before handing it out
  • after_release: clean up before returning a connection to the pool

All standard PoolOptions settings are supported: max_connections, min_connections, acquire_timeout, max_lifetime, idle_timeout, test_before_acquire, etc.

2. Nested Transactions via Savepoints​

SQLz tracks transaction depth as a counter on the connection and maps it to MSSQL's savepoint syntax transparently:

Depthbegincommitrollback
0 β†’ 1BEGIN TRANCOMMIT TRANROLLBACK TRAN
n β†’ n+1SAVE TRAN _sqlz_savepoint_n(no-op: SQL Server releases savepoints implicitly)ROLLBACK TRAN _sqlz_savepoint_n

db.transaction(|tx| tx.transaction(|tx2| ...)) gives correct nested rollback semantics: rolling back an inner block unwinds only to the savepoint, leaving the outer transaction intact.

let txn = db.begin().await?;

{
let txn = txn.begin().await?;
let _ = bakery::ActiveModel { name: Set("Nested Bakery".to_owned()), .. }.save(&txn).await?;
assert_eq!(Bakery::find().all(&txn).await?.len(), 3);

{
let txn = txn.begin().await?;
let _ = bakery::ActiveModel { name: Set("Rock n Roll Bakery".to_owned()), .. }.save(&txn).await?;
assert_eq!(Bakery::find().all(&txn).await?.len(), 4);
// txn dropped here without commit: rolls back to savepoint
}
assert_eq!(Bakery::find().all(&txn).await?.len(), 3);

{
let txn = txn.begin().await?;
let _ = bakery::ActiveModel { name: Set("Rock n Roll Bakery".to_owned()), .. }.save(&txn).await?;
txn.commit().await?;
}
txn.commit().await?;
}

assert_eq!(Bakery::find().all(&txn).await?.len(), 4);
txn.commit().await?;
BEGIN TRAN
SAVE TRAN _sqlz_savepoint_1
SAVE TRAN _sqlz_savepoint_2
ROLLBACK TRAN _sqlz_savepoint_2 -- drop without commit
SAVE TRAN _sqlz_savepoint_2
-- (savepoint 2 released implicitly on commit)
-- (savepoint 1 released implicitly on commit)
COMMIT TRAN

When a Transaction is dropped without an explicit commit, SQLz spawns a local async task to execute ROLLBACK immediately and marks the connection as RollingBack. Any subsequent use of that connection awaits the rollback task before proceeding, making cleanup deterministic rather than deferred to an arbitrary future await point.

3. Automatic Schema Rewriting​

ConnectionTrait gains a get_schema() hook. When a connection is configured with a non-default schema (e.g. dbo, sales), SeaORM X automatically prefixes every outgoing statement with that schema. No manual [schema].[table] boilerplate in application code.

The rewriting is backed by the OverrideTableSchema trait, implemented for every sea-query statement type. Schema rewriting propagates recursively into subqueries, JOINs, and CTE branches.

// specify schema other than dbo
let db = Database::connect(
"mssql://user:pass@localhost:1433/my_db?currentSchema=my_schema").await?;
let related = cake::Entity::find()
.has_related(filling::Entity, filling::Column::Name.eq("Marmalade"))
.all(db)
.await?;
SELECT [cake].[id], [cake].[name] FROM [my_schema].[cake]
WHERE EXISTS(SELECT 1 FROM [my_schema].[filling]
INNER JOIN [my_schema].[cake_filling] ON [cake_filling].[filling_id] = [filling].[id]
WHERE [filling].[name] = 'Marmalade'
AND [cake].[id] = [cake_filling].[cake_id])

4. OUTPUT Clause for RETURNING​

SeaORM X maps SeaORM's RETURNING semantics to MSSQL's OUTPUT syntax:

  • INSERT β†’ OUTPUT INSERTED.*
  • DELETE β†’ OUTPUT DELETED.*

Constraint violations (UniqueConstraintViolation, ForeignKeyConstraintViolation) surfacing through the OUTPUT path are caught and returned as typed DbErr variants, not raw driver errors.

5. Automatic IDENTITY_INSERT Handling​

When inserting a model with an explicit primary-key value on an auto-increment column, SeaORM X automatically wraps the statement in SET IDENTITY_INSERT ON/OFF: no manual SQL required.

// From tests/empty_insert_tests.rs
let seaside_bakery = bakery::ActiveModel {
name: Set("SeaSide Bakery".to_owned()),
profit_margin: Set(10.4),
..Default::default() // id not set: normal insert
};
Bakery::insert(seaside_bakery).exec(db).await?;

let double_seaside_bakery = bakery::ActiveModel {
id: Set(1), // explicit PK: triggers IDENTITY_INSERT automatically
name: Set("SeaSide Bakery".to_owned()),
profit_margin: Set(10.4),
..Default::default()
};
let res = Bakery::insert_many([double_seaside_bakery])
.on_conflict_do_nothing()
.exec(db)
.await;
assert!(matches!(res, Ok(TryInsertResult::Conflicted)));
-- Normal insert: no IDENTITY_INSERT
INSERT INTO [bakery] ([name], [profit_margin]) OUTPUT [INSERTED].[id] VALUES ('SeaSide Bakery', 10.4)

-- Explicit PK insert: wrapped automatically
SET IDENTITY_INSERT [bakery] ON;
INSERT INTO [bakery] ([id], [name], [profit_margin]) OUTPUT [INSERTED].[id] VALUES (1, 'SeaSide Bakery', 10.4);
SET IDENTITY_INSERT [bakery] OFF
-- PK conflict β†’ returned as TryInsertResult::Conflicted, not a panic

6. Tuple IN Fallback for MSSQL​

MSSQL does not support tuple value syntax ((c1, c2) IN ((v1, v2), ...)). SeaORM X adds EntityTrait::column_tuple_in(), which expands to (c1 = v1 AND c2 = v2) OR ... when targeting MSSQL.

cake::Entity::find()
.filter(cake::Entity::column_tuple_in(
[cake::Column::Id, cake::Column::Name],
&[(1i32, "a").into_value_tuple(), (2i32, "b").into_value_tuple()],
DbBackend::MySql,
).unwrap())
.build(DbBackend::MySql)
.to_string();
-- MySQL: native tuple syntax:
SELECT `cake`.`id`, `cake`.`name` FROM `cake`
WHERE (`cake`.`id`, `cake`.`name`) IN ((1, 'a'), (2, 'b'))
cake::Entity::find()
.filter(cake::Entity::column_tuple_in(
[cake::Column::Id, cake::Column::Name],
&[(1i32, "a").into_value_tuple(), (2i32, "b").into_value_tuple()],
DbBackend::MsSql,
).unwrap())
.build(DbBackend::MsSql)
.to_string();
-- MSSQL: automatically expands to AND/OR:
SELECT [cake].[id], [cake].[name] FROM [cake]
WHERE ([cake].[id] = 1 AND [cake].[name] = 'a') OR ([cake].[id] = 2 AND [cake].[name] = 'b')

7. i64 / i32 Type Coercion​

MSSQL returns INT columns as i32 at the wire level. SeaORM X handles the coercion transparently: no schema changes or manual casting required.

8. Schema-First and Entity-First Workflows​

Schema-first: point sea-orm-cli generate entity at an existing MSSQL database and get ready-to-compile Rust entity files.

sea-orm-cli generate entity \
--database-url "mssql://sa:pass@localhost/AdventureWorksLT2016" \
--database-schema "SalesLT"
// Generated address.rs
#[sea_orm(schema_name = "SalesLT", table_name = "Address")]
pub struct Model {
#[sea_orm(column_name = "AddressID", primary_key)]
pub address_id: i32,
#[sea_orm(column_name = "AddressLine1")]
pub address_line1: String,
pub rowguid: Uuid,
// ...
}

Entity-first: define entities in Rust and let SeaORM sync the schema to the database. Tables, columns, unique keys, and foreign keys are created in topological order: no manual migration file needed.

// Requires the `schema-sync` feature flag
db.get_schema_builder()
.register(order::Entity)
.register(store::Entity)
.sync(db)
.await?;

9. execute_unprepared via Raw TDS Batch​

For DDL and migration scenarios, execute_unprepared uses the raw TDS execute_batch path rather than prepared statements. This preserves temp-table visibility across statement batches: a common requirement for MSSQL migration and stored-procedure patterns that break under prepared statement isolation.

10. Built on SeaORM 2.0​

Because SeaORM X tracks the open-source release, all SeaORM 2.0 improvements are available to MSSQL users. Highlights:

New entity format: a dense, readable layout with relations declared inline on the model struct, generated with --entity-format dense:

#[sea_orm::model]
#[sea_orm(table_name = "post")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub user_id: i32,
#[sea_orm(belongs_to, from = "user_id", to = "id")]
pub author: HasOne<super::user::Entity>,
#[sea_orm(has_many, via = "post_tag")]
pub tags: HasMany<super::tag::Entity>,
}

Smart Entity Loader: automatically chooses JOIN for 1-1 and the data-loader pattern for 1-N, eliminating the N+1 problem in nested queries.

Nested ActiveModel persistence: insert an entire object graph (user β†’ profile 1-1, posts 1-N, tags M-N) in a single .save(db).await? call.

Strongly-typed columns: Cake::COLUMN.name.contains("chocolate") with methods specific to the column type.

Injection-safe raw_sql! macro: use {param} interpolation and {..array} expansion in raw SQL without opening the door to SQL injection.

DerivePartialModel with #[sea_orm(nested)]: select only the fields you need across joins and map them into typed partial structs, preventing overfetching.

Limitations​

SQLz is a purpose-built SQL Server driver, not a full SQLx port. Known gaps:

  • No compile-time query checking. SQLx's sqlx::query! macro verifies SQL against a live database at compile time. SQLz has no equivalent; queries are checked at runtime only.
  • No custom type encoding/decoding. SQLx supports user-defined Encode/Decode implementations for arbitrary Rust types. SQLz ships a fixed set of supported types (primitives, uuid, Decimal, BigDecimal, chrono, time, serde_json) behind feature flags. Adding a new wire type requires changes inside SQLz itself.

Getting Access​

SeaORM X is a commercial offering for teams that need SQL Server support in Rust without leaving the SeaORM ecosystem. It has been tested against complex schemas including Sakila and AdventureWorks, and is used in production by companies today.

The practical upside for teams already on SeaORM: there is no second API to learn or maintain. Queries, relations, transactions all carry over unchanged. Developers can be productive on day one, and adding SQL Server to an existing multi-database project does not require parallel ORM maintenance or separate migration tooling.

For greenfield projects on legacy MSSQL schemas, the CLI codegen eliminates the mechanical work of writing entity files by hand.

SeaORM X is distributed under a commercial license. To evaluate it or discuss access for your team, request access here.

SeaORM now supports Arrow & Parquet

Β· 11 min read
SeaQL Team
Chris Tsang
SeaORM 2.0 Banner

SeaORM 2.0 adds native Apache Arrow and Parquet support. Derive an Arrow schema directly from your SeaORM entity: no redundant schema definitions, no drift.

Motivation​

Traditional ORMs are built for OLTP. But Rust backends increasingly need to:

  • Export data snapshots to object storage (S3, GCS)
  • Feed analytical pipelines (DataFusion, Polars, DuckDB)
  • Archive time-series rows efficiently in columnar format
  • Seed or replicate databases from Parquet files

Arrow is the lingua franca of in-memory columnar data. Parquet is its on-disk counterpart. Both are supported by the entire modern data stack.

The problem: you've already defined your schema as SeaORM entities. Redefining it as an Arrow schema is redundant and error-prone. SeaORM now comes with Arrow support out-of-the-box!

Getting Started​

Enable Arrow support with the with-arrow feature flag:

[dependencies]
sea-orm = { version = "2.0.0-rc", features = ["with-arrow"] }
parquet = { version = "57", features = ["arrow"] }

Suppose you have a sensor data pipeline. You want to archive today's rows to Parquet for downstream analytics.

Arrow Schema Derivation​

Add arrow_schema to the #[sea_orm(..)] attribute on your entity:

measurement.rs
use sea_orm::entity::prelude::*;

#[sea_orm::model] // <- new Entity
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "measurement", arrow_schema)] // <- enable Arrow
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub recorded_at: ChronoDateTimeUtc,
pub sensor_id: i32,
pub temperature: f64,
#[sea_orm(column_type = "Decimal(Some((10, 4)))")]
pub voltage: Decimal,
}

(for compact entity)

measurement.rs
#[derive(DeriveEntityModel, DeriveArrowSchema, ..)] // <- extra derive
#[sea_orm(table_name = "measurement")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
..
}

This derives the ArrowSchema trait on Entity and ActiveModel, exposing three methods:

use sea_orm::ArrowSchema;

// Get the Arrow Schema matching your entity
let schema = measurement::Entity::arrow_schema();

// Serialize a slice of ActiveModels into an Arrow RecordBatch
let batch = measurement::ActiveModel::to_arrow(&models, &schema)?;

// Deserialize an Arrow RecordBatch back into ActiveModels
let models = measurement::ActiveModel::from_arrow(&batch)?;

Exporting to Parquet​

Step 1: convert your ActiveModel slice into a RecordBatch:

use sea_orm::ArrowSchema;

let schema = measurement::Entity::arrow_schema();

let models: Vec<measurement::ActiveModel> = vec![..];
let batch = measurement::ActiveModel::to_arrow(&models, &schema)?;

Step 2: write to Parquet using the parquet crate:

let file = std::fs::File::create("measurements.parquet")?;
let mut writer = parquet::arrow::ArrowWriter::try_new(file, schema.into(), None)?;
writer.write(&batch)?; // write many more batches
writer.close()?;

The resulting file is readable by any Parquet-compatible tool: DuckDB, Polars, Spark, BigQuery, pandas.

Importing from Parquet​

Read a Parquet file back into ActiveModels and insert into any SeaORM-supported database:

use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;

let file = std::fs::File::open("measurements.parquet")?;
let reader = ParquetRecordBatchReaderBuilder::try_new(file)?.build()?;

let batches: Vec<_> = reader.collect::<Result<_, _>>()?;
let restored = measurement::ActiveModel::from_arrow(&batches[0])?;

measurement::Entity::insert_many(restored).exec(&db).await?;

from_arrow reconstructs full ActiveModel values: Arrow nulls become Set(None), absent columns become NotSet.

Full Example​

A complete working example: generate sensor readings, write to Parquet, verify the roundtrip, then insert into SQLite is available in the SeaORM repository: examples/parquet_example.

As a bonus, you can also use sea-orm-sync and avoid the async runtime entirely if your application is synchronous!

Type Mapping​

SeaORM maps Rust/SQL types to Arrow data types as follows:

Rust TypeSeaORM Column TypeArrow TypeNotes
i8TinyIntegerInt8
i16SmallIntegerInt16
i32IntegerInt32
i64BigIntegerInt64
u8TinyUnsignedUInt8
u16SmallUnsignedUInt16
u32UnsignedUInt32
u64BigUnsignedUInt64
f32FloatFloat32
f64DoubleFloat64
boolBooleanBoolean
StringCharUtf8
StringTextLargeUtf8unbounded strings use LargeUtf8
Vec<u8>Binary, VarBinaryBinary
DecimalDecimal(Some((p, s)))Decimal128(p, s)precision ≀ 38; use Decimal256 for larger
DecimalMoneyDecimal128(19, 4)default precision/scale
JsonJson, JsonBinaryUtf8serialized as JSON text
UuidUuidBinaryraw bytes
ActiveEnumEnumUtf8serialized as string
NaiveDateDateDate32days since epoch
NaiveTimeTimeTime64(Microsecond)
NaiveDateTimeDateTime, TimestampTimestamp(Microsecond, None)timezone-naive
DateTime<Utc>TimestampWithTimeZoneTimestamp(Microsecond, Some("UTC"))UTC-annotated

Key behaviors:

  • String length: String(StringLen::N(n)) with n ≀ 32767 maps to Utf8; Text and unbounded strings map to LargeUtf8.
  • Timestamp resolution: microseconds by default. Override per-field with arrow_timestamp_unit.
  • Timezone annotation: timezone-aware Rust types (DateTime<Utc>, DateTime<FixedOffset>) always produce a Timestamp with timezone. Naive types (NaiveDateTime) produce no annotation. Override with arrow_timezone.
  • Decimal: precision and scale are derived from column_type. If not specified, defaults are Decimal128(38, 10). Override per-field with arrow_precision and arrow_scale.

Timestamp Types​

Timezone and Resolution​

Arrow distinguishes timezone-aware and timezone-naive timestamps at the schema level. SeaORM maps them accordingly:

  • ChronoDateTime / NaiveDateTime / PrimitiveDateTime β†’ Timestamp(Microsecond, None): no timezone annotation
  • ChronoDateTimeUtc / DateTime<Utc>/ OffsetDateTime β†’ Timestamp(Microsecond, Some("UTC")): UTC annotated
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "test_chrono", arrow_schema)]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub created_date: ChronoDate, // -> Date32
pub created_time: ChronoTime, // -> Time64(Microsecond)
pub created_at: ChronoDateTime, // -> Timestamp(Microsecond, None)
pub updated_at: ChronoDateTimeUtc, // -> Timestamp(Microsecond, Some("UTC"))
pub nullable_ts: Option<ChronoDateTimeUtc>,
}

let models = vec![..];

let batch = ActiveModel::to_arrow(&models, &schema)?;
let restored = ActiveModel::from_arrow(&batch)?;

assert_eq!(restored, models);

The default resolution is microseconds. Both the time unit and timezone can be overridden per-field using arrow_timestamp_unit and arrow_timezone:

#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "event", arrow_schema)]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(column_type = "DateTime", arrow_timestamp_unit = "Nanosecond")]
pub nano_ts: ChronoDateTime, // -> Timestamp(Nanosecond, None)
#[sea_orm(column_type = "DateTime", arrow_timestamp_unit = "Second")]
pub second_ts: ChronoDateTime, // -> Timestamp(Second, None)
#[sea_orm(
column_type = "DateTime",
arrow_timestamp_unit = "Nanosecond",
arrow_timezone = "America/New_York"
)]
pub nano_with_tz: ChronoDateTime, // -> Timestamp(Nanosecond, Some("America/New_York"))
}

Valid values for arrow_timestamp_unit: "Second", "Millisecond", "Microsecond", "Nanosecond".

Decimal Types​

Each Decimal column is stored as Decimal128 in Arrow, preserving the exact precision and scale declared in column_type. Columns with different precision/scale are handled independently. Values are scaled to fit Arrow's internal i128 representation (value Γ— 10^scale).

#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "test_rust_decimal", arrow_schema)]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(column_type = "Decimal(Some((10, 2)))")]
pub price: Decimal, // -> Decimal128(10, 2)
#[sea_orm(
column_type = "Decimal(Some((20, 4)))",
arrow_precision = 20,
arrow_scale = 4
)]
pub amount: Decimal, // -> Decimal128(20, 4)
}

let price = Decimal::new(1234567, 2); // 12345.67
let amount = Decimal::new(98765432109, 4); // 9876543.2109

let models = vec![
decimal_entity::ActiveModel {
id: Set(1),
price: Set(price),
amount: Set(amount),
nullable_decimal: Set(Some(price)),
},
];

let batch = ActiveModel::to_arrow(&models, &schema)?;

// Arrow column carries the declared precision and scale
let price_arr = batch.column_by_name("price").unwrap()
.as_any().downcast_ref::<Decimal128Array>().unwrap();
assert_eq!(price_arr.value(0), 1234567); // 12345.67 stored as 1234567 (Γ— 10^-2)
assert_eq!(price_arr.precision(), 10);
assert_eq!(price_arr.scale(), 2);

// Full roundtrip
assert_eq!(ActiveModel::from_arrow(&batch)?, models);

BigDecimal is also supported with Decimal256 but not illustrated here.

SeaORM 2.0​

SeaORM 2.0 is shaping up to be our most significant release yet - with a few breaking changes, plenty of enhancements, and a clear focus on developer experience.

SeaORM 2.0 has reached its release candidate phase. We'd love for you to try it out and help shape the final release by sharing your feedback.

🌟 Sponsors​

Gold Sponsor​

QDX pioneers quantum dynamics-powered drug discovery, leveraging AI and supercomputing to accelerate molecular modeling. We're grateful to QDX for sponsoring the development of SeaORM, the SQL toolkit that powers their data intensive applications.

GitHub Sponsors​

If you feel generous, a small donation will be greatly appreciated, and goes a long way towards sustaining the organization.

A big shout out to our GitHub sponsors:

Godwin Effiong
Ryan Swart
OteroRafael
Yuta Hinokuma
wh7f
MS
Numeus
Caido Community
Marcus Buffett
MasakiMiyazaki
KallyDev
Manfred Lee
Afonso Barracha
Dean Sheather

πŸ¦€ Rustacean Sticker Pack​

The Rustacean Sticker Pack is the perfect way to express your passion for Rust. Our stickers are made with a premium water-resistant vinyl with a unique matte finish.

Sticker Pack Contents:

  • Logo of SeaQL projects: SeaQL, SeaORM, SeaQuery, Seaography
  • Mascots: Ferris the Crab x 3, Terres the Hermit Crab
  • The Rustacean wordmark

Support SeaQL and get a Sticker Pack!

Rustacean Sticker Pack by SeaQL

Notification in SeaORM Pro

Β· 5 min read
SeaQL Team
Chris Tsang

SeaORM Pro now supports sending notifications to Slack and Microsoft Teams via webhooks. Once configured, applications can send notifications based on triggers: for example, when a new order is created or an order item is updated.

The Design​

1. Application wide​

Notifications are not personalized; any user with select permission on the sea_orm_notification table can view them in the notification panel. Users who have visibility of the designated Slack channel or Teams channel will receive these messages.

Design: application level notification center.

2. Queue based​

When an event is triggered in application code (for example, when a table or row is updated), a notification request is enqueued.

Design: the event queue is intended to avoid adding latency to API requests.

3. Application defined​

Notifications can be sent to one (unicast) or many webhooks (multicast). The application has full control over when and what to send.

Design: application-defined logic tailored to business needs.

4. Background worker​

A background worker picks up pending notifications from the queue and delivers them sequentially.

Design: Notifications are delivered asynchronously by the background worker, avoiding bursts while preserving the order of events.

Configuring Webhooks​

Schema​

A webhook has the following fields:

#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "sea_orm_webhook")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: WebhookId, // uniquely identify the webhook
pub platform: WebhookPlatform, // can be Slack / MsTeams
pub url: String, // webhook URL
}

Config Webhook in Admin Panel​

Login to the admin dashboard. If you have read & update permission of the sea_orm_webhook table, you should able to see the "Webhook Settings" page appear on the left:

You can perform quick update of webhook URL here.

Double check the webhook URL is correct.

Config Webhook programatically​

You can also create new webhooks using database migration, such as m20260101_000001_webhook.rs:

#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
sea_orm_notify::create_tables(db).await?;

webhook::ActiveModel {
id: NotSet,
platform: Set(WebhookPlatform::MsTeams),
url: Set("https://teams.microsoft.com/12345678".to_owned()),
}
.insert(db)
.await?;

webhook::ActiveModel {
id: NotSet,
platform: Set(WebhookPlatform::Slack),
url: Set("https://hooks.slack.com/12345678".to_owned()),
}
.insert(db)
.await?;

// ...
}

async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// ...
}
}

Setup Slack Webhook​

The step-by-step walkthrough is available here.

Setup Microsoft Teams Webhook​

The step-by-step walkthrough is available here.

Sending Notifications​

Define after_save Trigger​

A common place to trigger notification is right after a database row being inserted or updated. For example in address.rs, we will trigger a notification after every successful insert or update:

#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn after_save<C>(model: Model, db: &C, insert: bool) -> Result<Model, DbErr>
where
C: ConnectionTrait,
{
let action = if insert { "Inserted" } else { "Updated" };
let message = format!("{action} Address Model ID: {}", model.address_id);
sea_orm_notify::notify_all(db, &message).await?;
tracing::info!("{message}");
Ok(model)
}
}

Of course these triggers can also be defined at individual API endpoints, where access to DatabaseConnection is provided.

Unicast / Multicast​

There are two methods to send notification, they are defined in sea-orm-notify/src/lib.rs:

// Send the notification to all webhook
sea_orm_notify::notify_all(db, message).await?;

// Send the notification to a specific webhook only
sea_orm_notify::notify(db, WebhookId(1), message).await?;

Formatting Messages​

You can format the notification messages with basic markdown.

Note that Slack has their own dialect. On MS Teams, a subset of HTML is supported. SeaORM Pro automatically compiles markdown to HTML when sending via Teams.

Notification Queue and Background Worker​

Notification will be stored in the sea_orm_notification table and with a column named sent to indicate the notification has been sent or not. A background task will send all pending notification in sequence from old to new, the source code is available at sea-orm-notify/src/lib.rs.

The background task should be spawned during the initialization of the Loco app in app.rs:

pub struct App;
#[async_trait]
impl Hooks for App {
// ...

async fn initializers(ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>> {
let initializers: Vec<Box<dyn Initializer>> =
vec![Box::new(initializers::graphql::GraphQLInitializer)];

sea_orm_notify::run_background_task(ctx.db.clone());

Ok(initializers)
}

// ...
}

Notification Status​

The βœ… checkmark indicates that the notification was sent successfully.

You can click the ⟳ (Resend) button to resend the notification immediately.

SeaORM Pro Plus​

SeaORM Pro Plus unlocks Role Based Access Control (RBAC), notifications, and the frontend source code. We prioritize answering questions and feature requests from sponsors. You can access the repository as a Pro tier Sponsor.

What's Next?​

There's really a lot we want to build, to make SeaORM Pro suit the needs of every project. Please consider being a sponsor and take part in shaping its future!

Here's what we have in mind:

  • Single Sign On: To be able to sign-in with Google Workspace or Microsoft Business email.
  • Audit Log: And so, we'd want to keep a record of users' action and being able to audit them.
  • Tasks: To be able to visualize and control scheduled tasks, and kick start once off tasks in ad-hoc way.

SeaORM 2.0 Migration Guide

Β· 8 min read
SeaQL Team
Chris Tsang
SeaORM 2.0 Banner

Over the past few months, we've rolled out a series of SeaORM 2.0 releases packed with new capabilities that reshape how you'd use SeaORM. We've stablized our API surface now. Other than dependency upgrades (sqlx 0.9), there won't be more major breaking changes.

If you're eager to upgrade, let's walk through the changes you're likely to encounter, full list of changes can be found here.

SeaORM 2.0 is designed as a seamless step forward: all existing features are preserved, no rewrites are required, and it remains functionally backward-compatible. Most migrations are straightforward and mechanical, with many changes amounting to simple find-and-replace operations.

SeaQuery Type System​

Full details can be found here.

ExprTrait is needed​

#890 For many methods on Expr, e.g. add, eq, ExprTrait is required.

Possible compile errors​

error[E0599]: no method named `like` found for enum `sea_query::Expr` in the current scope
|
| Expr::col((self.entity_name(), *self)).like(s)
|
| fn like<L>(self, like: L) -> Expr
| ---- the method is available for `sea_query::Expr` here
|
= help: items from traits can only be used if the trait is in scope
help: trait `ExprTrait` which provides `like` is implemented but not in scope; perhaps you want to import it
|
-> + use sea_orm::ExprTrait;
error[E0308]: mismatched types
--> src/sqlite/discovery.rs:27:57
|
| .and_where(Expr::col(Alias::new("type")).eq("table"))
| -- ^^^^^^^ expected `&Expr`, found `&str`
| |
| arguments to this method are incorrect
|
= note: expected reference `&sea_query::Expr`
found reference `&'static str`
error[E0308]: mismatched types
|
390 | Some(Expr::col(Name).eq(PgFunc::any(query.symbol)))
| -- ^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `&Expr`, found `FunctionCall`
| |
| arguments to this method are incorrect
|
note: method defined here
--> /rustc/6b00bc3880198600130e1cf62b8f8a93494488cc/library/core/src/cmp.rs:254:8

IntoCondition is no longer needed​

Can simply use .into

.on_condition(|_left, right| Expr::col((right, super::users::Column::IsSuperAdmin)).eq(true).into_condition())

Replace with:

.on_condition(|_left, right| Expr::col((right, super::users::Column::IsSuperAdmin)).eq(true).into())

ConditionExpression has been removed. Instead, just convert between Condition and Expr using From/Into:

Cond::all().add(Expr::new(..))

Possible compile errors​

error[E0603]: enum `ConditionExpression` is private
--> tests/mysql/query.rs:734:20
|
> | use sea_query::ConditionExpression;
| ^^^^^^^^^^^^^^^^^^^ private enum
> | Cond::all().add(ConditionExpression::Expr(Expr::new(
| ^^^^^^^^^^^^^^^^^^^ use of undeclared type `ConditionExpression`

Change of Iden trait​

#909 The method signature of Iden::unquoted is changed. If you're implementing Iden manually, you can modify it like below:

impl Iden for Glyph {
- fn unquoted(&self, s: &mut dyn fmt::Write) {
+ fn unquoted(&self) -> &str {
- write!(
- s,
- "{}",
match self {
Self::Table => "glyph",
Self::Id => "id",
Self::Tokens => "tokens",
}
- )
- .unwrap();
}
}

Possible compile errors​

error[E0050]: method `unquoted` has 2 parameters but the declaration in trait `types::Iden::unquoted` has 1
--> src/tests_cfg.rs:31:17
|
| fn unquoted(&self, s: &mut dyn std::fmt::Write) {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected 1 parameter, found 2
|
::: src/types.rs:63:17
|
| fn unquoted(&self) -> &str;
| ----- trait requires 1 parameter

Alias is no longer needed for &'static str​

You can use the string "name" in place of all Alias::new("name").

Expr::col(Alias::new("my_col"))

Replace with:

Expr::col("my_col")

SeaORM API​

DeriveValueType changes​

DeriveValueType now implements NotU8, IntoActiveValue, TryFromU64.

You can remove these manual implementation. As a side effects, you can use custom wrapper types as primary keys.

#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "my_value_type")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: MyInteger,
}

#[derive(Clone, Debug, PartialEq, Eq, DeriveValueType)]
pub struct MyInteger(pub i32);
// only for i8 | i16 | i32 | i64 | u8 | u16 | u32 | u64

execute -> execute_raw​

#2657 Overhauled ConnectionTrait API: execute, query_one, query_all, stream now takes in SeaQuery statement instead of raw SQL statement, and methods accepting Statement has been moved to *_raw.

// old
db.execute(Statement::from_sql_and_values(..)).await?;

// new
db.execute_raw(Statement::from_sql_and_values(..)).await?;

You no longer have to build SeaQuery statements manually:

// old
let query: SelectStatement = Entity::find().filter(..).into_query();
let backend = self.db.get_database_backend();
let stmt = backend.build(&query);
let rows = self.db.query_all(stmt).await?;

// new
let query: SelectStatement = Entity::find().filter(..).into_query();
let rows = self.db.query_all(&query).await?;

Possible compile errors​

  --> src/executor/paginator.rs:53:38
|
> | let rows = self.db.query_all(stmt).await?;
| --------- ^^^^ expected `&_`, found `Statement`
| |
| arguments to this method are incorrect
|
= note: expected reference `&_`
found struct `statement::Statement`

Overhauled Entity::insert_many​

#2628 We've made a number of changes to the insert many API:

  1. Removed APIs that can panic
  2. New helper struct InsertMany, last_insert_id is now Option<Value>
  3. On empty iterator, None or vec![] is returned on exec operations
  4. Allowing active models to have different column sets #2433

Previously, insert_many shares the same helper struct with insert_one, which led to an awkard API.

let res = Bakery::insert_many(std::iter::empty())
.on_empty_do_nothing() // <- you needed to add this
.exec(db)
.await;

assert!(matches!(res, Ok(TryInsertResult::Empty)));

last_insert_id is now Option<Value>:

struct InsertManyResult<A: ActiveModelTrait>
{
pub last_insert_id: Option<<PrimaryKey<A> as PrimaryKeyTrait>::ValueType>,
}

Which means the awkardness is removed:

let res = Entity::insert_many::<ActiveModel, _>([]).exec(db).await;

assert_eq!(res?.last_insert_id, None); // insert nothing return None

let res = Entity::insert_many([ActiveModel { id: Set(1) }, ActiveModel { id: Set(2) }])
.exec(db)
.await;

assert_eq!(res?.last_insert_id, Some(2)); // insert something return Some

Exec with returning now returns a Vec<Model>, so it feels intuitive:

assert!(
Entity::insert_many::<ActiveModel, _>([])
.exec_with_returning(db)
.await?
.is_empty() // no footgun, nice
);

assert_eq!(
Entity::insert_many([
ActiveModel {
id: NotSet,
value: Set("two".into()),
}
])
.exec_with_returning(db)
.await
.unwrap(),
[
Model {
id: 2,
value: "two".into(),
}
]
);

Possible compile errors​

You will get a warning that exec_with_returning_many is now deprecated. Simply change it to exec_with_returning.

Database Specific Changes​

Postgres​

#918 serial is now being replaced with GENERATED BY DEFAULT AS IDENTITY by default

To restore legacy behaviour, you can enable the option-postgres-use-serial feature flag.

let table = Table::create()
.table(Char::Table)
.col(ColumnDef::new(Char::Id).integer().not_null().auto_increment().primary_key())
.to_owned();

assert_eq!(
table.to_string(PostgresQueryBuilder),
[
r#"CREATE TABLE "character" ("#,
r#""id" integer GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY,"#,
r#")"#,
].join(" ")
);

Alternatively, you can also do the following:

// if you needed to support legacy system you can still do:
let table = Table::create()
.table(Char::Table)
.col(ColumnDef::new(Char::Id).custom("serial").not_null().primary_key())
.to_owned();

assert_eq!(
table.to_string(PostgresQueryBuilder),
[
r#"CREATE TABLE "character" ("#,
r#""id" serial NOT NULL PRIMARY KEY"#,
r#")"#,
].join(" ")
);

SQLite​

Both Integer and BigInteger (column type) is mapped to integer, and integer would be mapped to i64 by entity generator.

You can use --big-integer-type=i32 to use i32 for integer in sea-orm-cli.

sqlite-use-returning-for-3_35 is now enabled by default.

MariaDB​

#2710 Added mariadb-use-returning to use returning syntax for MariaDB

Deprecations​

Rust Version​

We've updated to Rust 2024 edition and MSRV to 1.85.

async-std is deprecated​

If you have been using async-std in migration crates, simply switch to tokio.

Framework Support​

Loco​

Loco has not officially migrated to 2.0 yet, but you can try out our fork meanwhile.

GraphQL​

Seaography has been updated to support SeaORM 2.0.

Why Upgrade?​

Here are a few reasons that upgrading to 2.0 is worth it:

  1. Exciting new features: new entity format with entity first workflow, nested queries and operations
  2. Removed panics from API surface, less footguns and better error handling
  3. More ergononmics and better compile-time constraints
  4. Admin Panel: SeaORM Pro with Role Based Access Control
  5. Performance improvements: less internal copying and 20% faster query building

Need Help?​

If you're using SeaORM in a professional environment, please consider asking your company to become a Startup / Enterprise tier sponsor. Company sponsorship comes with benefits such as direct communication with the SeaQL team, as well as professional technical guidance on SeaORM usage, data engineering and Rust development in general.

We've helped companies successfully deploy Rust web services in production on high-throughput workloads.

How we made SeaORM synchronous

Β· 11 min read
SeaQL Team
Chris Tsang
SeaORM 2.0 Banner

SeaORM now has an official Rusqlite backend.

SeaORM began as Rust's first async‑first ORM. Now we've come full circle with a synchronous crate: perfect for building lightweight CLI programs with SQLite.

In this post, we'll share how we ported a complex library like SeaORM, the tricks we learnt along the way, and the steps we're taking to keep it maintainable for the long run.

Gist​

We took an approach of translation: we wrote a script to convert async functions into synchronous ones. (It's more than a simple find‑and‑replace.)

The script would read the src directory and rewrite that into a new crate sea-orm-sync. This crate isn't a fork: it will be continually rebased on sea-orm, inheriting all new features and bug fixes.

sea-orm-sync supports the entire SeaORM's API surface, including recent features like Entity Loader, Nested ActiveModel and Entity-First workflow.

Async -> Sync​

At a high level, async Rust can be seen as a more complex form of sync Rust. So converting an async program is possible by stripping out the runtime and removing all async / await usage.

However, you can't always go from sync to async. Async Rust tightens lifetime rules and introduces Send / Sync requirements for futures and async closures, so existing sync code may fail those constraints.

Let's examine the required conversions, ordered by their level of complexity.

1. async / await​

Removing all async and .await keywords will almost make it compile.

2. #[main] / #[test]​

Simply remove tokio / async-std from Cargo and remove #[tokio::main]. Then replace #[tokio::test] with #[test].

3. async_trait​

Simply remove #[async_trait] usage and it's Cargo dependency.

4. Send + Sync​

Most Send and Sync in trait bounds can be removed.

5. Future​

impl Future<Output = T> can be converted to just T.

// async
fn exec<'a, C>(self, db: &'a C) -> impl Future<Output = Result<InsertResult<A>, DbErr>> + 'a;

// sync
fn exec<'a, C>(self, db: &'a C) -> Result<InsertResult<A>, DbErr>;

If you've used BoxFuture you can use the following shim:

#[cfg(not(feature = "sync"))]
type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;

#[cfg(feature = "sync")]
type BoxFuture<'a, T> = T;

6. Box::pin(async move { .. })​

Function signature: FnOnce() -> Pin<Box<dyn Future<Output = Result<T, E>> + Send + 'c>> can simply be FnOnce() -> Result<T, E>.

async
async fn transaction<F, T, E>(&self, _callback: F) -> Result<T, TransactionError<E>>
where
F: for<'c> FnOnce(
&'c DatabaseTransaction,
) -> Pin<Box<dyn Future<Output = Result<T, E>> + Send + 'c>>
+ Send,
T: Send,
E: std::fmt::Display + std::fmt::Debug + Send,
{}
sync
fn transaction<F, T, E>(&self, _callback: F) -> Result<T, TransactionError<E>>
where
F: for<'c> FnOnce(&'c DatabaseTransaction) -> Result<T, E>,
E: std::fmt::Display + std::fmt::Debug,
{}

Usage: Async futures can be simply converted to {}.

async
db
.transaction::<_, _, DbErr>(|txn| {
Box::pin(async move {
let bakeries = Bakery::find()
.filter(bakery::Column::Name.contains("Bakery"))
.all(txn)
.await?;

Ok(())
})
})
.await?
sync
db
.transaction::<_, _, DbErr>(|txn| {
let bakeries = Bakery::find()
.filter(bakery::Column::Name.contains("Bakery"))
.all(txn)?;

Ok(())
})?

7. Mutex​

The semantic difference in lock() between a synchronous mutex (std::sync::Mutex) and an asynchronous mutex (tokio::sync::Mutex or async_std::sync::Mutex) is crucial.

std::sync::Mutex::lock()​

fn lock(&self) -> LockResult<MutexGuard<T>>
  • Fallible: Returns a Result because the lock can be poisoned
  • Poisoning happens if a thread panics while holding the lock

tokio::sync::Mutex::lock().await​

async fn lock(&self) -> MutexGuard<'_, T>
  • Infallible: always succeeds and returns a guard
  • In async world mutexes don't get poisoned. A panic inside a task would abort the task, but would not affect other tasks

In practice, we did:

#[cfg(not(feature = "sync"))]
let conn = *self.conn.lock().await;

#[cfg(feature = "sync")]
let conn = *self.conn.lock().map_err(|_| DbErr::MutexPoisonError)?;

8. Stream​

This is the biggest discrepency between sync and async Rust. Simply put, Stream is the async version of Iterator:

IteratorStream
DefinitionSynchronous iteratorAsynchronous iterator
Trait methodfn next(&mut self) -> Option<Item>fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Item>>
ConsumptionCall .next() repeatedlyCall .next().await repeatedly (via StreamExt)
Blocking vs yieldingProduces items immediately, blocks until readyProduces items asynchronously, yields if not ready
Use casesIterating over collectionsReading database rows
Usagefor x in iter { ... }while let Some(x) = stream.next().await { ... }

Stream occurs in many places throughout the SeaORM API. The Stream trait is replaced by the Iterator trait.

Box<dyn Stream>​

#[cfg(not(feature = "sync"))]
type PinBoxStream<'a> = Pin<Box<dyn Stream<Item = Result<QueryResult, DbErr>> + 'a + Send>>;

#[cfg(feature = "sync")]
type PinBoxStream<'a> = Box<dyn Iterator<Item = Result<QueryResult, DbErr>> + 'a>;

impl Stream bounds​

async
async fn stream<'a: 'b, 'b, C>(self, db: &'a C)
-> Result<impl Stream<Item = Result<E::Model, DbErr>> + 'b + Send, DbErr>
where
C: ConnectionTrait + StreamTrait + Send,
{}
sync
fn stream<'a: 'b, 'b, C>(self, db: &'a C)
-> Result<impl Iterator<Item = Result<E::Model, DbErr>> + 'b, DbErr>
where
C: ConnectionTrait + StreamTrait,
{}

impl Stream for​

#[cfg(not(feature = "sync"))]
impl Stream for TransactionStream<'_> {
type Item = Result<QueryResult, DbErr>;

fn poll_next(
self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> Poll<Option<Self::Item>> {
Pin::new(self.stream).poll_next(cx)
}
}

#[cfg(feature = "sync")]
impl Iterator for TransactionStream<'_> {
type Item = Result<QueryResult, DbErr>;

fn next(&mut self) -> Option<Self::Item> {
self.stream.next()
}
}

TryStreamExt​

There is no equivalent to TryStreamExt in Rust's standard library, luckily it's very easy to make a shim:

while let Some(item) = stream.try_next().await? {
let item: fruit::ActiveModel = item.into();
}
pub trait TryIterator<T, E> {
fn try_next(&mut self) -> Result<Option<T>, E>;
}

impl<I, T, E> TryIterator<T, E> for I
where
I: Iterator<Item = Result<T, E>>,
{
fn try_next(&mut self) -> Result<Option<T>, E> {
self.next().transpose() // Option<Result<T>> becomes Result<Option<T>>
}
}

9. File / Network I/O​

This is very application specific. In SeaORM's case, the external I/O is handled by sqlx and rusqlite respectively. Their APIs differ significantly, that's why we have written sea-query-sqlx and sea-query-rusqlite to align their interfaces.

For HTTP requests, you can simply use the sync and async versions of reqwest::Client in different contexts.

For file I/O, the API difference between sync and async Rust is very small.

Conclusion: SQLite + SeaORM Sync = βš‘β€‹

You can now use sea-orm-sync in CLI programs, and only bringing in small number of additional dependencies compared to having to bring in the async ecosystem.

The compilation time speaks for itself. The async version of quickstart took 30 seconds to compile, while the sync version only took 15 seconds!

Right now only rusqlite is supported, but SeaORM's entire API surface is available: including nested transactions. It's a breeze to add SQLite query capabilities to CLI programs where async would be overkill.

// Same API as before
let db = &sea_orm::Database::connect("sqlite::memory:")?;

// Setup the database: create tables
db.get_schema_registry("sea_orm_quickstart::*").sync(db)?;

info!("Create user Bob with a profile:");
let bob = user::ActiveModel::builder()
.set_name("Bob")
.set_email("bob@sea-ql.org")
.set_profile(profile::ActiveModel::builder().set_picture("Tennis"))
.insert(db)?;

info!("Query user with profile in a single query:");
let bob = user::Entity::load()
.filter_by_id(bob.id)
.with(profile::Entity)
.one(db)?
.expect("Not found");
assert_eq!(bob.name, "Bob");
assert_eq!(bob.profile.as_ref().unwrap().picture, "Tennis");

SeaORM 2.0 RC​

SeaORM 2.0 is shaping up to be our most significant release yet - with a few breaking changes, plenty of enhancements, and a clear focus on developer experience.

SeaORM 2.0 has reached its release candidate phase. We'd love for you to try it out and help shape the final release by sharing your feedback.

🌟 Sponsors​

Gold Sponsor​

QDX pioneers quantum dynamics-powered drug discovery, leveraging AI and supercomputing to accelerate molecular modeling. We're grateful to QDX for sponsoring the development of SeaORM, the SQL toolkit that powers their data intensive applications.

GitHub Sponsors​

If you feel generous, a small donation will be greatly appreciated, and goes a long way towards sustaining the organization.

A big shout out to our GitHub sponsors:

Godwin Effiong
Ryan Swart
OteroRafael
Yuta Hinokuma
wh7f
MS
Numeus
Caido Community
Marcus Buffett
MasakiMiyazaki
KallyDev
Manfred Lee
Afonso Barracha
Dean Sheather

πŸ¦€ Rustacean Sticker Pack​

The Rustacean Sticker Pack is the perfect way to express your passion for Rust. Our stickers are made with a premium water-resistant vinyl with a unique matte finish.

Sticker Pack Contents:

  • Logo of SeaQL projects: SeaQL, SeaORM, SeaQuery, Seaography
  • Mascots: Ferris the Crab x 3, Terres the Hermit Crab
  • The Rustacean wordmark

Support SeaQL and get a Sticker Pack!

Rustacean Sticker Pack by SeaQL

A walk-through of SeaORM 2.0

Β· 13 min read
SeaQL Team
Chris Tsang
SeaORM 2.0 Banner

Over the past few months, we've rolled out a series of SeaORM 2.0 releases packed with new capabilities that reshape how you'd use SeaORM. There may be a lot of details, that's why in this post we'll take a guided walk‑through of the best features with a small example blogging platform.

The full example can be found here.

New Entity Format​

SeaORM 1.0's entity format is explicit, but on the more verbose end, making it difficult to write by hand. SeaORM 2.0 introduced a new Entity Format that's denser and can be generated by sea-orm-cli generate entity --entity-format dense.

The following User Entity has two related Entities.

user 1-1 profile
user 1-N post
user.rs
use sea_orm::entity::prelude::*;

#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "user")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
#[sea_orm(unique)]
pub email: String,
#[sea_orm(has_one)]
pub profile: HasOne<super::profile::Entity>,
#[sea_orm(has_many)]
pub posts: HasMany<super::post::Entity>,
}

impl ActiveModelBehavior for ActiveModel {}

Entity First Workflow​

SeaORM used to adopt a schema‑first approach: meaning you design database tables and write migration scripts first, then generate entities from that schema.

Entity‑first flips the flow: you hand-write the entity files, and let SeaORM generates the tables and foreign keys for you.

Simply call the sync function after initializing the database connection, and SeaORM will create the missing tables, columns, and foreign keys for you.

// The order of entity definitions does not matter.
// SeaORM resolves foreign key dependencies automatically
// and creates the tables in the correct order with their keys.
db.get_schema_registry("sea_orm_quickstart::*")
.sync(db)
.await?;

ActiveModel Builder and Entity Loader​

Let's get started with the blogging platform example!

1. Create User with Profile​

We can use ActiveModel builder to create a nested object (user + profile) in a single operation.

Entity:

profile.rs
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "profile")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub picture: String,
#[sea_orm(unique)]
pub user_id: i32, // β¬… belongs to
#[sea_orm(belongs_to, from = "user_id", to = "id")]
pub user: HasOne<super::user::Entity>,
}

Operation:

info!("Create user Bob with a profile:");
let bob = user::ActiveModel::builder()
.set_name("Bob")
.set_email("bob@sea-ql.org")
.set_profile(
// here is a nested object: profile's user_id will be
// automatically set to bob's id after it has been created
profile::ActiveModel::builder().set_picture("Tennis.jpg")
)
.insert(db)
.await?;

Execution:

INSERT INTO "user" ("name", "email") VALUES ('Bob', 'bob@sea-ql.org') RETURNING "id", "name", "email"
INSERT INTO "profile" ("picture", "user_id") VALUES ('Tennis', 1) RETURNING "id", "picture", "user_id"

2. Query User with Profile​

Now, we'd like to query the user we've just created along with the profile in a single SQL query:

info!("Query user with profile in a single query:");
let bob = user::Entity::load()
.filter_by_email("bob@sea-ql.org") // β¬… email is a unique key
.with(profile::Entity)
.one(db)
.await?
.expect("Not found");
assert_eq!(bob.name, "Bob");
assert_eq!(bob.profile.as_ref().unwrap().picture, "Tennis.jpg");

Execution:

SELECT "user"."id" AS "A_id", "user"."name" AS "A_name", "user"."email" AS "A_email", "profile"."id" AS "B_id", "profile"."picture" AS "B_picture", "profile"."user_id" AS "B_user_id"
FROM "user" LEFT JOIN "profile" ON "user"."id" = "profile"."user_id" WHERE "user"."id" = 1 LIMIT 1

3. Update Bob's profile​

We can update and save a nested object in place:

let mut bob = bob.into_active_model(); // convert Model to ActiveModel
bob.profile.as_mut().unwrap().picture = Set("Hiking.jpg");
bob.save(db)?;

(or in a more fluent way)

bob.profile.take().unwrap().into_active_model()
.set_picture("Hiking.jpg").save(db).await?;

Execution:

UPDATE "profile" SET "picture" = 'Hiking.jpg' WHERE "profile"."id" = 1 RETURNING "id", "picture", "user_id"

4. Add some blog posts​

Again, let's use the new ActiveModel builder API to create some blog posts:

Entity:

post.rs
use sea_orm::entity::prelude::*;

#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "post")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub user_id: i32,
pub title: String,
#[sea_orm(belongs_to, from = "user_id", to = "id")]
pub author: HasOne<super::user::Entity>,
#[sea_orm(has_many)]
pub comments: HasMany<super::comment::Entity>,
}

impl ActiveModelBehavior for ActiveModel {}

Operation:

info!("Bob wrote some posts:");
bob.posts
// user_id will be set to bob.id
.push(post::ActiveModel::builder()
.set_title("Lorem ipsum dolor sit amet, consectetur adipiscing elit"))
.push(post::ActiveModel::builder()
.set_title("Ut enim ad minim veniam, quis nostrud exercitation"));
bob.save(db).await?;

Execution:

INSERT INTO "post" ("user_id", "title") VALUES (1, 'Lorem ipsum dolor sit amet ..') RETURNING "id", "user_id", "title"
INSERT INTO "post" ("user_id", "title") VALUES (1, 'Ut enim ad minim veniam ..') RETURNING "id", "user_id", "title"

5. Add comments to blog post​

Not only can we insert new posts via the Bob ActiveModel, we can also add new comments to the posts. SeaORM walks the document tree and figures out what's changed, and perform the operation in one transaction.

info!("Alice commented on Bob's post:");
bob.posts[0].comments.push(
comment::ActiveModel::builder()
.set_comment("nice post!")
.set_user(alice),
);
bob.save(db).await?;

Execution:

INSERT INTO "comment" ("comment", "user_id", "post_id") VALUES ('nice post!', 2, 1) RETURNING "id", "comment", "user_id", "post_id"

6. Using Entity Loader​

We can query user + profile + post together:

info!("Find Bob's profile and his posts:");
let bob = user::Entity::load()
.filter(user::COLUMN.name.eq("Bob"))
.with(profile::Entity)
.with(post::Entity)
.one(db)
.await?
.unwrap();

bob == user::ModelEx {
id: 1,
name: "Bob".into(),
email: "bob@sea-ql.org".into(),
profile: HasOne::Loaded(profile::ModelEx {
picture: "Hiking.jpg",
..
}),
posts: HasMany::Loaded(vec![post::ModelEx {
title: "..",
..
}, ..]),
}

Execution:

-- 1-1 uses join
SELECT "user"."id", "profile"."id", ..
FROM "user" LEFT JOIN "profile" ON "user"."id" = "profile"."user_id"
WHERE "user"."name" = 'Bob' LIMIT 1
-- 1-N uses loader
SELECT "post"."id", "post"."user_id", "post"."title"
FROM "post" WHERE "post"."user_id" IN (1) ORDER BY "post"."id" ASC

7. Cascade delete​

Let's say we want to delete a blog post. There are still comments belonging to the posts, so they will prevent the post from being deleted (unless ON DELETE CASCADE is set).

info!("Delete the post along with all comments");
post.delete(db).await?;

Execution:

DELETE FROM "comment" WHERE "comment"."id" = 1
DELETE FROM "post" WHERE "post"."id" = 1

8. M-N relation: post <-> tag​

In addition to 1-1 and 1-N relations as shown above, SeaORM also models M-N relation as a first class construct. Let's say post M-N tag:

Entities:

post.rs
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "post")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub title: String,
#[sea_orm(has_many, via = "post_tag")] // β¬… specify junction table
pub tags: HasMany<super::tag::Entity>,
..
}
post_tag.rs
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "post_tag")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)] // β¬… composite key
pub post_id: i32,
#[sea_orm(primary_key, auto_increment = false)] // β¬… composite key
pub tag_id: i32,
#[sea_orm(belongs_to, from = "post_id", to = "id")] // β¬… belongs to both
pub post: Option<super::post::Entity>,
#[sea_orm(belongs_to, from = "tag_id", to = "id")] // β¬… belongs to both
pub tag: Option<super::tag::Entity>,
}
tag.rs
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "tag")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub tag: String,
}

Now we can easily attach tags to a post:

info!("Insert one tag for later use");
let sunny = tag::ActiveModel::builder().set_tag("sunny").save(db).await?;

info!("Insert a new post with 2 tags");
let post = post::ActiveModel::builder()
.set_title("A sunny day")
.set_user(bob)
.add_tag(sunny) // β¬… an existing tag
.add_tag(tag::ActiveModel::builder().set_tag("outdoor")) // β¬… a new tag
.save(db) // new tag will be created and associcated to the new post
.await?;

Execution:

INSERT INTO "post" ("user_id", "title") VALUES (2, 'A sunny day') RETURNING "id", "user_id", "title"
INSERT INTO "tag" ("tag") VALUES ('outdoor') RETURNING "id", "tag"
INSERT INTO "post_tag" ("post_id", "tag_id") VALUES (3, 1), (3, 2) ON CONFLICT ("post_id", "tag_id") DO NOTHING RETURNING "post_id", "tag_id"

9. Users follow other Users​

Now it starts to look like a social media network!

Entity:

user.rs
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "user")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
// ⬇ new keyword `self_ref`, `from` and `to` are relations
#[sea_orm(self_ref, via = "user_follower", from = "User", to = "Follower")]
pub followers: HasMany<Entity>,
// reverse of above, no need to repeat yourself! ⬇
#[sea_orm(self_ref, via = "user_follower", reverse)]
pub following: HasMany<Entity>,
..
}

The junction table has same structure as post_tag.

user_follower.rs
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "user_follower")]
pub struct Model {
#[sea_orm(primary_key)]
pub user_id: i32,
#[sea_orm(primary_key)]
pub follower_id: i32,
#[sea_orm(belongs_to, from = "user_id", to = "id")] // β¬… same as before
pub user: Option<super::user::Entity>,
// ⬇ because `User` is already taken, has to specify enum name
#[sea_orm(belongs_to, relation_enum = "Follower", from = "follower_id", to = "id")]
pub follower: Option<super::user::Entity>,
}

Now we can start connecting them:

info!("Add follower to Alice");
alice.add_follower(bob).save(db).await?;

info!("Sam starts following Alice");
sam.add_following(alice).save(db).await?;

Executes:

INSERT INTO "user_follower" ("user_id", "follower_id") VALUES (1, 2) ON CONFLICT ("user_id", "follower_id") DO NOTHING RETURNING "user_id", "follower_id"
INSERT INTO "user_follower" ("user_id", "follower_id") VALUES (1, 3) ON CONFLICT ("user_id", "follower_id") DO NOTHING RETURNING "user_id", "follower_id"

You can query user with followers with:

let users = user::Entity::load()
.filter_by_id(alice.id)
.with(user_follower::Entity)
.all(db)
.await?;

assert_eq!(users[0].name, "Alice");
assert_eq!(users[0].followers.len(), 2);
assert_eq!(users[0].followers[0].name, "Bob");
assert_eq!(users[0].followers[1].name, "Sam");

That's it! With these examples in hand, I hope you'll find SeaORM 2.0 both fun and productive to use.

New COLUMN constant​

Previously, column names in queries had to be written in CamelCase. This is because the Column type was defined as an enum, as it's faster to compile than generating one struct per column.

Our new design keeps compilation fast while offering stronger type guarantees. As a bonus, it eliminates the need for CamelCase and even saves a keystroke!

// old: an enum variant
user::Entity::find().filter(user::Column::Name.contains("Bob"))

// new: a constant value
user::Entity::find().filter(user::COLUMN.name.contains("Bob"))

// compile error: the trait `From<{integer}>` is not implemented for `String`
user::Entity::find().filter(user::COLUMN.name.like(2))

🧭 Instant GraphQL API​

With Seaography, the Entities you wrote can instantly be exposed as a GraphQL schema, with full CRUD, filtering and pagination. No extra macros, no Entity re-generation is needed!

With SeaORM and Seaography, you can prototype quickly and stay in the flow. The Entity:

#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "user")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
#[sea_orm(unique)]
pub email: String,
#[sea_orm(has_one)]
pub profile: HasOne<super::profile::Entity>,
#[sea_orm(has_many)]
pub posts: HasMany<super::post::Entity>,
}

Instantly turned into a GraphQL type:

type User {
id: Int!
name: String!
email: String!
profile: Profile
post(
filters: PostFilterInput
orderBy: PostOrderInput
pagination: PaginationInput
): PostConnection!
}

πŸ–₯️ SeaORM Pro: Admin Panel​

SeaORM Pro is an admin panel solution allowing you to quickly and easily launch an admin panel for your application - frontend development skills not required, but certainly nice to have!

SeaORM Pro has been updated to support the latest features in SeaORM 2.0.

Features:

  • Full CRUD
  • Built on React + GraphQL
  • Built-in GraphQL resolver
  • Customize the UI with TOML config
  • Role Based Access Control (new in 2.0)

🌟 Sponsors​

Gold Sponsor​

QDX pioneers quantum dynamics-powered drug discovery, leveraging AI and supercomputing to accelerate molecular modeling. We're grateful to QDX for sponsoring the development of SeaORM, the SQL toolkit that powers their data intensive applications.

GitHub Sponsors​

If you feel generous, a small donation will be greatly appreciated, and goes a long way towards sustaining the organization.

A big shout out to our GitHub sponsors:


πŸ¦€ Rustacean Sticker Pack​

The Rustacean Sticker Pack is the perfect way to express your passion for Rust. Our stickers are made with a premium water-resistant vinyl with a unique matte finish.

Sticker Pack Contents:

  • Logo of SeaQL projects: SeaQL, SeaORM, SeaQuery, Seaography
  • Mascots: Ferris the Crab x 3, Terres the Hermit Crab
  • The Rustacean wordmark

Support SeaQL and get a Sticker Pack!

Rustacean Sticker Pack by SeaQL

SeaORM 2.0: Nested ActiveModel

Β· 16 min read
SeaQL Team
Chris Tsang
SeaORM 2.0 Banner

In our last post, we introduced a Smart Entity Loader that made querying multi-path relations into nested models simple and efficient. That solved the read side of the problem.

With nested ActiveModel, you can now do the reverse: persist a nested object back into the database in one operation. SeaORM walks the tree, detects changes, constructs the insert and update statements, and executes them in the correct order to respect foreign key dependencies.

Gist​

The following operation saves a new set of user + profile + post + tag + post_tag into the database atomically:

let user = user::ActiveModel::builder()
.set_name("Bob")
.set_email("bob@sea-ql.org")
.set_profile(profile::ActiveModel::builder().set_picture("image.jpg"))
.add_post(
post::ActiveModel::builder()
.set_title("Nice weather")
.add_tag(tag::ActiveModel::builder().set_tag("sunny")),
)
.save(db)
.await?;

Unfolding​

This builder pattern constructs the following object tree:

user::ActiveModelEx {
name: Set("Bob".into()),
email: Set("bob@sea-ql.org".into()),
profile: HasOneModel::Set(profile::ActiveModelEx {
picture: Set("image.jpg".into()),
..Default::default()
}),
posts: HasManyModel::Append(post::ActiveModelEx {
title: Set("Nice weather".into()),
tags: HasManyModel::Append(tag::ActiveModel {
tag: Set("sunny".into()),
..Default::default()
}),
..Default::default()
}),
..Default::default()
}
.save(db)
.await?

.. which is equivalent to doing the following manually:

let txn = db.begin().await?;

// insert a user
let user = user::ActiveModelEx {
name: Set("Bob".into()),
email: Set("bob@sea-ql.org".into()),
..Default::default()
}.insert(&txn).await?;

// profile belongs_to user (1-1)
let profile = profile::ActiveModelEx {
user_id: Set(user.id),
picture: Set("image.jpg".into()),
..Default::default()
}.insert(&txn).await?;

// post belongs_to user (1-N)
let post = post::ActiveModelEx {
user_id: Set(user.id),
title: Set("Nice weather".into()),
..Default::default()
}.insert(&txn).await?;

// insert a tag
let tag = tag::ActiveModel {
tag: Set("sunny".into()),
..Default::default()
}
.insert(&txn)
.await?;

// associate tag to post
post_tag::ActiveModel {
post_id: Set(post.id),
tag_id: Set(tag.id),
}
.insert(&txn)
.await?;

txn.commit().await?;

Relational Dependency​

The core of the problem lies in figuring the foreign key relations between Entities and executing the queries in the correct order. SeaORM supports the following:

Has One / Belongs To​

User 1-1 Profile

user.rs
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "user")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(has_one)]
pub profile: HasOne<super::profile::Entity>,
..
}

There is a unique key on user_id, making this relation one-to-one.

profile.rs
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "profile")]
pub struct Model {
#[sea_orm(unique)]
pub user_id: i32,
#[sea_orm(belongs_to, from = "user_id", to = "id")]
pub user: HasOne<super::user::Entity>,
..
}

Since profile belongs to user, the user has to be inserted first to obtain it's id.

In SeaORM, it doesn't matter which way the model is nested, it will be executed in the correct order.

// also okay:
profile::ActiveModel::builder()
.set_user(
user::ActiveModel::builder()
.set_name("Alice")
.set_email("alice@rust-lang.org"),
)
.set_picture("image.jpg")
.save(db)
.await?;

Has Many​

User 1-N Post

#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "post")]
pub struct Model {
pub user_id: i32,
#[sea_orm(belongs_to, from = "user_id", to = "id")]
pub author: HasOne<super::user::Entity>,
..
}

This is very similar to 1-1, with the nested model being a vector instead of option.

There are two possible actions: Replace and Append. The default action is append, which is non-destructive.

Let's say Bob has written a new blog post, there is no reason having to query all posts bob has written; we can simply do the following:

// query user, but no posts
let bob = user::Entity::load().filter_by_email("bob@sea-ql.org").one(db).await?.unwrap();

let mut bob.into_active_model();
bob.posts.push(post::ActiveModel::builder().set_title("Another weekend"));
bob.save(db).await?; // INSERT INTO post ..

However, sometimes we do want an empty vector to mean 'delete all', or we want to specify the exact set of children. Then we can use Replace.

bob.posts.replace_all([]); // delete all
bob.posts.replace_all([post_1]); // retain only this post

This will result in the following actions, where posts other than post 1 will be deleted:

SELECT FROM post WHERE user_id = bob.id
DELETE FROM post WHERE id = 2

Many to Many​

Post M-N Tag

Many-to-many relations are essential when modeling complex schemas. A unique feature of SeaORM is that it models many-to-many relations as a first-class construct, so you don't need to think about the junction table.

post.rs
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "post")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub title: String,
#[sea_orm(has_many, via = "post_tag")] // β¬… specify junction table
pub tags: HasMany<super::tag::Entity>,
..
}
post_tag.rs
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "post_tag")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)] // β¬… composite key
pub post_id: i32,
#[sea_orm(primary_key, auto_increment = false)] // β¬… composite key
pub tag_id: i32,
#[sea_orm(belongs_to, from = "post_id", to = "id")] // β¬… belongs to both
pub post: Option<super::post::Entity>,
#[sea_orm(belongs_to, from = "tag_id", to = "id")] // β¬… belongs to both
pub tag: Option<super::tag::Entity>,
}
tag.rs
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "tag")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub tag: String,
}

M-N relations is not just 1-N + 1-1, it actually breaks away from the notion. Let's look at the following example, insert a new post with 2 tags:

// Insert one tag for later use
let sunny = tag::ActiveModel::builder().set_tag("sunny").save(db).await?;

// Insert a new post with 2 tags
let post = post::ActiveModel::builder()
.set_title("A sunny day")
.set_user(bob)
.add_tag(sunny) // an existing tag
.add_tag(tag::ActiveModel::builder().set_tag("outdoor")) // a new tag
.save(db) // new tag will be created and associcated to the new post
.await?;

Which results in the following actions:

INSERT INTO post (user_id, title) VALUES (bob.id, 'A sunny day') RETURNING id
INSERT INTO tag (tag) VALUES ('outdoor') RETURNING id
INSERT INTO post_tag (post_id, tag_id) VALUES (post.id, sunny.id) (post.id, outdoor.id)

Their relation is no longer "belongs to", they are just associated with each other. Removing tags from post does not delete the tags, but only the associations in the junction table.

post.tags.replace_all([outdoor]); // let's say we remove the tag sunny

Results in:

DELETE FROM post_tag WHERE (post_id, tag_id) IN ((post.id, sunny.id))

One more example to make this easier to grasp: in a Film M-N Actor relation, deleting a film does not delete its actors, since they can still appear in other films.

Note​

SeaORM also supports using a surrogate primary key on the junction table, though a composite primary key is recommended.

film_actor.rs
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "film_actor")]
pub struct Model {
#[sea_orm(primary_key)] // β¬… normal primary key
pub id: i32,
#[sea_orm(unique_key = "film_actor")] // β¬… unique key
pub film_id: i32,
#[sea_orm(unique_key = "film_actor")] // β¬… unique key
pub actor_id: i32,
#[sea_orm(belongs_to, from = "film_id", to = "id")]
pub film: HasOne<super::film::Entity>,
#[sea_orm(belongs_to, from = "actor_id", to = "id")]
pub actor: HasOne<super::actor::Entity>,
}

Change Detection​

Let's go back to the basics, in SeaORM every Model is backed by an ActiveModel:

post.rs
pub struct ModelEx {
#[sea_orm(primary_key)]
pub id: i32,
pub user_id: i32,
pub title: String,
pub author: HasOne<super::user::Entity>,
pub tags: HasMany<super::tag::Entity>,
}

// generated by macro:
pub struct ActiveModelEx {
#[sea_orm(primary_key)]
pub id: ActiveValue<i32>,
pub user_id: ActiveValue<i32>,
pub title: ActiveValue<String>,
pub author: HasOneModel<super::user::Entity>,
pub tags: HasManyModel<super::tag::Entity>,
}

Each ActiveValue is a tri-state.

pub enum ActiveValue<V>
{
Set(V),
Unchanged(V),
NotSet,
}

This allows SeaORM to keep track of what's changed. When you first query a fresh Model from database, the default state is Unchanged. When you perform some change in code, the state will be changed to Set. So when you run save, only the changed columns are updated.

let post = post::Entity::find_by_id(22).one(db).await?.unwrap(); // Model
let mut post = post.into_active_model(); // ActiveModel
post.title = Set("The weather changed!");
post.save(db).await?; // UPDATE post SET title = '..' WHERE id = 22

This has two advantages: it avoids over-updating, reducing the amount of data sent over the wire. More importantly, multiple API endpoints can safely update different column sets without risk of race conditions and without relying on transactions or locking mechanisms.

This concept is extended to nested ActiveModels, allowing SeaORM to walk the nested object tree and determine which sub-tree has been changed and requires updating.

For example:

let mut bob: user::ActiveModel = ..;

// update post title
bob.posts[0].title = Set("Lorem ipsum dolor sit amet".into());
// update post comment
bob.posts[0].comments[0].comment = Set("nice post! I learnt a lot".into());
// add a new comment too
bob.posts[1].comments.push(
comment::ActiveModel::builder().set_comment("interesting!")
);
bob.save(db).await?;

Results in:

BEGIN TRANSACTION

UPDATE post SET title = '..' WHERE id = post.id
UPDATE comment SET comment = '..' WHERE id = comment.id
INSERT INTO comment (post_id, comment) VALUES (post.id. '..')

COMMIT

It's a lot to take in, but once you build a clear mental model of SeaORM's concepts and mechanisms, you'll find yourself far more productive!

You can find all the techniques described in this blog post in a single-file example application.

Cascade Delete​

If the relations are defined with ON DELETE CASCADE, this problem does not exist. However, SeaORM can also perform cascade deletes on the client side. It applies the same rules described above, but in reverse.

For example, Post belongs to User. All posts must be deleted before the user; otherwise, the SQL operation will fail.

let user_4 = user::Entity::find_by_id(4).one(db).await?.unwrap();

user_4.cascade_delete(db).await?; // equivalent to below
user_4.into_ex().delete(db).await?;
-- query the profile belonging to user
SELECT FROM profile INNER JOIN user ON user.id = profile.user_id WHERE user.id = 4 LIMIT 1
-- delete the profile, if exist
DELETE FROM profile WHERE profile.id = 2
-- query the posts belonging to user
SELECT FROM post INNER JOIN user ON user.id = post.user_id WHERE user.id = 4
-- query the comments belonging to post
SELECT FROM comment INNER JOIN post ON post.id = comment.post_id WHERE post.id = 7
-- delete the comments, if exist
DELETE FROM comment WHERE comment.id = 5
-- query the post's tags
SELECT FROM post_tag INNER JOIN post ON post_tag.post_id = post.id WHERE post.id = 7
-- delete the post-tag associations
DELETE FROM post_tag WHERE (post_id, tag_id) IN ((7, 2), (7, 3), (7, 4))
-- post has no dependents, safe to delete now
DELETE FROM post WHERE post.id = 7
-- user has no dependents, safe to delete now
DELETE FROM user WHERE user.id = 4

Weak Belongs To​

There is one more special case of the Belongs To relation not yet mentioned: weak 1-N associations, where an entity may have an owner, but is not strictly required.

To give an example, Post 1-N Attachment. However users can upload attachments before drafting posts, thus some attachments may have no associated posts.

attachment.rs
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "attachment")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub post_id: Option<i32>, // this is nullable
pub file: String,
#[sea_orm(belongs_to, from = "post_id", to = "id")]
pub post: HasOne<super::post::Entity>,
}
// this post has attachment_2
let post_1 = post::Entity::find_by_id(1).one(db).await?.unwrap();
post_1.cascade_delete(db).await?;

// post_id of attachment will be set to null, instead of deleting the attachment
let attachment_2 = attachment::Entity::find_by_id(2).one(db).await?.unwrap();
assert!(attachment_2.post_id.is_none());

Idempotence​

The general rule of thumb is idempotence: saving an ActiveModel a second time should be a no-op. Unless you use replace or delete, no delete will be executed.

let post = post.save(db).await?;
let post = post.save(db).await?; // no-op, as all fields are unchanged

The ActiveModel you provides is a snapshot of the desired final state of the data, and SeaORM should ensure that it ends up that way. This can be complicated, so please report any bugs.

Tips​

Keeping track of whether to use insert or update can be hard, unless the intended action is "create new from scratch". Use save as the default, and let SeaORM decide when to execute insert or update.

(If you already find these concepts familiar, it's no surprise that it's called ActiveModel.)

Backwards Compatibility​

All the 2.0 features introduced in this post are fully backwards compatible with 1.0, since only new types and methods have been added: ActiveModelEx, HasOneModel, HasManyModel and a few methods. ActiveModel continues to behave exactly as before.

However, due to the macros needing the relations' attributes to generate the implementations, these features are only available to #[sea_orm::model], but not #[sea_orm::compact_model].

🧭 Instant GraphQL API​

With Seaography, the Entities you wrote can instantly be exposed as a GraphQL schema, with full CRUD, filtering and pagination. No extra macros, no Entity re-generation is needed!

With SeaORM and Seaography, you can prototype quickly and stay in the flow. The Entity:

#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "user")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
#[sea_orm(unique)]
pub email: String,
#[sea_orm(has_one)]
pub profile: HasOne<super::profile::Entity>,
#[sea_orm(has_many)]
pub posts: HasMany<super::post::Entity>,
}

Instantly turned into a GraphQL type:

type User {
id: Int!
name: String!
email: String!
profile: Profile
post(
filters: PostFilterInput
orderBy: PostOrderInput
pagination: PaginationInput
): PostConnection!
}

πŸ–₯️ SeaORM Pro: Admin Panel​

SeaORM Pro is an admin panel solution allowing you to quickly and easily launch an admin panel for your application - frontend development skills not required, but certainly nice to have!

SeaORM Pro has been updated to support the latest features in SeaORM 2.0.

Features:

  • Full CRUD
  • Built on React + GraphQL
  • Built-in GraphQL resolver
  • Customize the UI with TOML config
  • Role Based Access Control (new in 2.0)

🌟 Sponsors​

Gold Sponsor​

QDX pioneers quantum dynamics-powered drug discovery, leveraging AI and supercomputing to accelerate molecular modeling. We're grateful to QDX for sponsoring the development of SeaORM, the SQL toolkit that powers their data intensive applications.

GitHub Sponsors​

If you feel generous, a small donation will be greatly appreciated, and goes a long way towards sustaining the organization.

A big shout out to our GitHub sponsors:

Godwin Effiong
Ryan Swart
OteroRafael
Yuta Hinokuma
wh7f
MS
Numeus
Data Intuitive
Caido Community
Marcus Buffett
MasakiMiyazaki
KallyDev
Manfred Lee
Afonso Barracha
Dean Sheather

πŸ¦€ Rustacean Sticker Pack​

The Rustacean Sticker Pack is the perfect way to express your passion for Rust. Our stickers are made with a premium water-resistant vinyl with a unique matte finish.

Sticker Pack Contents:

  • Logo of SeaQL projects: SeaQL, SeaORM, SeaQuery, Seaography
  • Mascots: Ferris the Crab x 3, Terres the Hermit Crab
  • The Rustacean wordmark

Support SeaQL and get a Sticker Pack!

Rustacean Sticker Pack by SeaQL

What's new in SeaORM Pro 2.0

Β· 13 min read
SeaQL Team
Chris Tsang

SeaORM Pro is an admin panel solution allowing you to quickly and easily launch an admin panel for your application - frontend development skills not required (but certainly nice to have).

An admin panel is essential for operating backend applications. But it often is an after-thought, or no dedicated resources is put into developing them.

SeaORM Pro is designed to bridge this gap, providing a solution that is both quick to implement and reliable for long-term use.

Table View​

There are two kinds of table view in SeaORM Pro, raw table and composite table:

Raw Table​

Each raw table corresponds to a table in the database, by default it will display all columns for all tables. You can configure the displayed columns and other settings via TOML.

pro_admin/raw_tables/product.toml
[table]
title = "Products"
table_size = "middle"
page_size = 30
order_by = { field = "product_id", order = "desc" }
columns = [
{ title = "ID", field = "product_id", width = 80 },
{ title = "Thumbnail", field = "thumb_nail_photo", input_type = "image", width = 120 },
{ title = "Product Category", field = "name", relation = "product_category", ellipsis = false, width = 180 },
]
hidden_columns = [ "size", "weight" ]
all_columns = false

Composite Table​

This is where SeaORM Pro shine. You can construct table views with data joining from multiple related tables. The underlying GraphQL query can be deeply nested.

Data from parent-child relations (e.g. Order -> OrderItem) are represented as collapsible nested tables. You can configure the settings via TOML.

pro_admin/composite_tables/sales_order.toml
[parent]
name = "sales_order_header"

[parent.table]
columns = [
{ title = "ID", field = "sales_order_id", width = 80 },
{ field = "order_date" },
{ field = "purchase_order_number" },
{ field = "account_number" },
{ field = "ship_method" },
{ field = "sub_total" },
{ field = "tax_amt" },
{ field = "freight" },
]
all_columns = false


[[children]]
relation = "customer"

[children.table]
columns = [
{ title = "ID", field = "customer_id", width = 80 },
{ field = "title", width = 100 },
{ field = "first_name", width = 120 },
{ field = "middle_name", width = 120 },
{ field = "last_name", width = 120 },
]
hidden_columns = [ "name_style", "suffix", "email_address", "phone", "rowguid", "created_date" ]


[[children]]
relation = "address1"

[children.table]
title = "Shipping Address"
columns = [
{ title = "ID", field = "address_id", width = 80 },
]
hidden_columns = [ "rowguid", "created_date" ]


[[children]]
relation = "address2"

[children.table]
title = "Billing Address"
columns = [
{ title = "ID", field = "address_id", width = 80 },
]
hidden_columns = [ "rowguid", "created_date" ]


[[children]]
relation = "sales_order_detail"

[children.table]
columns = [
{ title = "Thumbnail", field = "thumb_nail_photo", relation = "product", input_type = "image", width = 120 },
{ field = "name", relation = "product", width = 300 },
{ field = "product_number", relation = "product" },
{ field = "color", relation = "product" },
{ field = "size", relation = "product" },
{ field = "weight", relation = "product" },
{ field = "order_qty" },
{ field = "unit_price" },
{ field = "unit_price_discount" },
]
hidden_columns = [ "sales_order_id", "sales_order_detail_id", "product_id", "rowguid", "created_date" ]

Editor​

Data is not editable by default. You can configure create, update and delete forms via TOML. You can also configure which columns are editable. Non-editable columns will be shown as read-only.

Pop-up Editor​

By default, the pop-up editor will be used to create and update database table.

Create​

Enable create on this database table, this is disabled by default. Columns can be hidden from the create form but it's still visible on the view table.

pro_admin/raw_tables/product.toml
[create]
# Enable create for this table
enable = true
# Columns that are hidden on the create form
hidden_columns = [ "created_date" ]

Update​

Enable update on this database table, this is disabled by default. Columns can be hidden from the update form but it's still visible on the view table. Fields can also be readonly on the update form.

pro_admin/raw_tables/product.toml
[update]
# Enable update for this table
enable = true
# Columns that are hidden on the update form
hidden_columns = [ "created_date" ]
# Columns that are readonly on the update form
readonly_columns = [ "product_id" ]

Delete​

Enable delete on this database table, this is disabled by default.

pro_admin/raw_tables/product.toml
[delete]
# Enable delete for this table
enable = true

Model Editor​

Enable the use of model editor on this database table, this is disabled by default. The configuration of each fields are specified here in sequence, displaying across table view, create and update editor.

[editor]
# Enable model editor for this table
enable = true
# Title field to be shown
title_field = "address_line1"
# Display following columns in sequence
fields = [
{ title = "ID", field = "address_id", span = 8 },
{ field = "rowguid", span = 8 },
{ field = "created_date", span = 8 },
{ field = "address_line1", span = 12, input_type = "textarea", rows = 4 },
{ field = "address_line2", span = 12, input_type = "textarea", rows = 4 },
{ field = "city", span = 6 },
{ field = "state_province", span = 6 },
{ field = "country_region", span = 6 },
{ field = "postal_code", span = 6 },
]

Role-Based Access Control​

SeaORM Pro has been updated to support the latest features in SeaORM 2.0. Role-Based Access Control (RBAC) is fully integrated into SeaORM Pro Plus. It offers a GUI editor to edit RBAC permissions and assign user roles. Without the corresponding select permission, users will not be able to see relevant tables in the GUI. Similarly, edit buttons will be hidden if user does not have update permission.

Opt-in RBAC​

Upon upgrading to SeaORM 2.0, you can opt-in RBAC by enabling the rbac feature flag in the Rust backend:

Cargo.toml
seaography = { version = "2.0", features = ["rbac"] }

And enabling RBAC in the SeaORM Pro admin panel:

pro_admin/config.toml
[site.rbac]
# Is RBAC enabled?
enable = true

Role Permissions​

View Role Hierarchy Diagram​

Role has a self-referencing relation, and they form a DAG (Directed Acyclic Graph). Most commonly they form a hierarchy tree that somewhat resembles an organization chart.

A simple tree example:

admin <- manager <- public
<- ...etc.

Update Role Permission​

Each role has their own set of permissions. On runtime, the engine will walk the role hierarchy and take the union of all permissions of the sub-graph.

The actions we can perform on resources. There are 4 basic permissions, select, insert, update and delete. You can define more for your application.

Role Hierarchy​

Each role has their own set of permissions. On runtime, the engine will walk the role hierarchy and take the union of all permissions of the sub-graph.

User Role​

User has a 1-1 relationship with role, meaning each user can only be assigned at most 1 role.

User Override​

The schema is a mirror of above: User <-> Permission <-> Resource, with an extra grant field, false means deny.

What's Next?​

There's really a lot we want to build, to make SeaORM Pro suit the needs of every project. Please consider being a sponsor and take part in shaping its future!

Here's what we have in mind:

  • Single Sign On: To be able to sign-in with Google Workspace or Microsoft Business email.
  • Audit Log: And so, we'd want to keep a record of users' action and being able to audit them.
  • Advanced Dashboard: We want to make it super easy to design graphs and charts for the Admin Dashboard.
  • Tasks: To be able to visualize and control scheduled tasks, and kick start once off tasks in ad-hoc way.
  • Data Export: Export data to various formats, including CSV, Excel, and DataFrame!

🌟 Sponsors​

Gold Sponsor​

QDX pioneers quantum dynamics-powered drug discovery, leveraging AI and supercomputing to accelerate molecular modeling. We're grateful to QDX for sponsoring the development of SeaORM, the SQL toolkit that powers their data intensive applications.

GitHub Sponsors​

If you feel generous, a small donation will be greatly appreciated, and goes a long way towards sustaining the organization.

A big shout out to our GitHub sponsors:

Godwin Effiong
Ryan Swart
OteroRafael
Yuta Hinokuma
wh7f
MS
Numeus
Data Intuitive
Caido Community
Marcus Buffett
MasakiMiyazaki
KallyDev
Manfred Lee
Afonso Barracha
Dean Sheather

πŸ¦€ Rustacean Sticker Pack​

The Rustacean Sticker Pack is the perfect way to express your passion for Rust. Our stickers are made with a premium water-resistant vinyl with a unique matte finish.

Sticker Pack Contents:

  • Logo of SeaQL projects: SeaQL, SeaORM, SeaQuery, Seaography
  • Mascots: Ferris the Crab x 3, Terres the Hermit Crab
  • The Rustacean wordmark

Support SeaQL and get a Sticker Pack!

Rustacean Sticker Pack by SeaQL

SeaORM 2.0: Strongly-Typed Column

Β· 10 min read
SeaQL Team
Chris Tsang
SeaORM 2.0 Banner

In our last post, we introduced a new Entity format - designed to be more concise, more readable, and easy to write by hand.

We've also added a new COLUMN constant to make it more ergonomic, along with other enhancements.

Bye-bye CamelCase​

Previously, column names in queries had to be written in CamelCase. This was because the Column type was defined as an enum, it's simpler for the type system and faster to compile than generating a struct per column, but at the cost of losing column‑specific type information.

Our new design keeps compilation fast while restoring stronger type guarantees. As a bonus, it eliminates the need for CamelCase and even saves a keystroke.

// old
user::Entity::find().filter(user::Column::Name.contains("Bob"))

// new
user::Entity::find().filter(user::COLUMN.name.contains("Bob"))

// compile error: the trait `From<{integer}>` is not implemented for `String`
user::Entity::find().filter(user::COLUMN.name.like(2))

Under the hood, each Column value is wrapped in a byte-sized struct TypeAwareColumn. This wrapper is generic over Entity, so whether a table has 1 column or 100, the compile‑time cost stays roughly the same.

COLUMN Constant​

pub struct NumericColumn<E: EntityTrait>(pub E::Column);

impl<E: EntityTrait> NumericColumn<E> {
pub fn eq<V>(v: V) -> Expr { .. }
}
user.rs
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "user")]
pub struct Model {
..
}

// expands into following:

pub struct TypedColumn {
pub id: NumericColumn<Entity>,
pub name: StringColumn<Entity>,
pub date_of_birth: DateLikeColumn<Entity>,
}

pub const COLUMN: TypedColumn = TypedColumn {
id: NumericColumn(Column::Id),
name: StringColumn(Column::Name),
date_of_birth: DateLikeColumn(Column::DateOfBirth),
};

impl Entity {
pub const COLUMN: TypedColumn = COLUMN;
}

Type-Aware Helper Methods​

Each column type wrapper exposes a set of methods that's relevant for the column's type. For example StringColumn::contains and ArrayColumn::contains are distinct methods that do the right thing!

Entity::COLUMN.name.contains("Bob") // WHERE "name" LIKE '%Bob%'

// tags is Vec<String>
Entity::COLUMN.tags.contains(vec!["awesome"]) // WHERE "tags" @> ARRAY ['awesome']

Right now there are a set of types: BoolColumn, NumericColumn, StringColumn, BytesColumn, JsonColumn, DateLikeColumn, TimeLikeColumn, DateTimeLikeColumn, UuidColumn, IpNetworkColumn, and more relevant methods can be added, feel free to make sugguestions.

Column as typed value​

One advantage of this design is that Columns are values: you can pass them into functions, combine with reflection, and build safe dynamic queries:

// returns an Expression fragment that can be used to build queries
fn filter_by_column(col: post::Column) -> Expr {
col.eq("attribute")
}

// get an integer from a model depends on runtime condition
fn get_value_from(model: &post::Model, col: post::Column) {
let value: i32 = model.get(col).unwrap();
// do something on value
}

Opt-in Only​

These new structs are generated only when using the new #[sea_orm::model] or #[sea_orm::compact_model] macros. This keeps the change fully backwards‑compatible, and you incur no cost if you don't use them.

More Entity Enhancements​

A big thanks to early-adopters who provided feedback to improve SeaORM 2.0.

The nested types for HasOne and HasMany have been changed from transparent type aliases to wrapper types. This makes it possible to distinguish between a relation that hasn’t been loaded and one that has loaded but yielded no models.

pub enum HasOne<E: EntityTrait> {
#[default]
Unloaded,
NotFound,
Loaded(Box<<E as EntityTrait>::ModelEx>),
}

pub enum HasMany<E: EntityTrait> {
#[default]
Unloaded,
Loaded(Vec<<E as EntityTrait>::ModelEx>),
}

We've added a range of methods to the wrapper type to make it feel as transparent as possible. The goal is to reduce friction while still preserving the benefits of a strong type system.

// len() / is_empty() methods
assert_eq!(users[0].posts.len(), 2);
assert!(!users[0].posts.is_empty());

// impl PartialEq
assert_eq!(users[0].posts, [post_1, post_2]);

// this creates HasOne::Loaded(Box<profile::ModelEx>)
profile: HasOne::loaded(profile::Model {
id: 1,
picture: "jpeg".into(),
..
})

Entity Loader Paginator​

Entity Loader now supports pagination. It has the same API as a regular Select:

let paginator = user::Entity::load()
.with(profile::Entity)
.order_by_asc(user::COLUMN.id)
.paginate(db, 10);

let users: Vec<user::ModelEx> = paginator.fetch().await?;

Added delete_by_key​

In addition to find_by_key, now the delete_by_key convenience method is also added:

user::Entity::delete_by_email("bob@spam.com").exec(db).await?

The delete_by_* methods now return DeleteOne instead of DeleteMany. It doesn't change normal exec usage, but would change return type of exec_with_returning to Option<Model>:

fn delete_by_id<T>(values: T) -> DeleteMany<Self>         // old

fn delete_by_id<T>(values: T) -> ValidatedDeleteOne<Self> // new

Self-Referencing Relations​

Let's say we have a staff table, where each staff has a manager that they report to:

staff.rs
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "staff")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
pub reports_to_id: Option<i32>,
#[sea_orm(
self_ref,
relation_enum = "ReportsTo",
relation_reverse = "Manages",
from = "reports_to_id",
to = "id"
)]
pub reports_to: HasOne<Entity>,
#[sea_orm(self_ref, relation_enum = "Manages", relation_reverse = "ReportsTo")]
pub manages: HasMany<Entity>,
}

Entity Loader​

let staff = staff::Entity::load()
.with(staff::Relation::ReportsTo)
.with(staff::Relation::Manages)
.all(db)
.await?;

assert_eq!(staff[0].name, "Alan");
assert_eq!(staff[0].reports_to, None);
assert_eq!(staff[0].manages[0].name, "Ben");
assert_eq!(staff[0].manages[1].name, "Alice");

assert_eq!(staff[1].name, "Ben");
assert_eq!(staff[1].reports_to.as_ref().unwrap().name, "Alan");
assert!(staff[1].manages.is_empty());

assert_eq!(staff[2].name, "Alice");
assert_eq!(staff[2].reports_to.as_ref().unwrap().name, "Alan");
assert!(staff[2].manages.is_empty());

assert_eq!(staff[3].name, "Elle");
assert_eq!(staff[3].reports_to, None);
assert!(staff[3].manages.is_empty());

Model Loader​

let staff = staff::Entity::find().all(db).await?;

let reports_to = staff
.load_self(staff::Entity, staff::Relation::ReportsTo, db)
.await?;

assert_eq!(staff[0].name, "Alan");
assert_eq!(reports_to[0], None);

assert_eq!(staff[1].name, "Ben");
assert_eq!(reports_to[1].as_ref().unwrap().name, "Alan");

assert_eq!(staff[2].name, "Alice");
assert_eq!(reports_to[2].as_ref().unwrap().name, "Alan");

assert_eq!(staff[3].name, "Elle");
assert_eq!(reports_to[3], None);

It can work in reverse too.

let manages = staff
.load_self_many(staff::Entity, staff::Relation::Manages, db)
.await?;

assert_eq!(staff[0].name, "Alan");
assert_eq!(manages[0].len(), 2);
assert_eq!(manages[0][0].name, "Ben");
assert_eq!(manages[0][1].name, "Alice");

assert_eq!(staff[1].name, "Ben");
assert_eq!(manages[1].len(), 0);

assert_eq!(staff[2].name, "Alice");
assert_eq!(manages[2].len(), 0);

assert_eq!(staff[3].name, "Elle");
assert_eq!(manages[3].len(), 0);

Unix Timestamp Column Type​

Sometimes it may be desirable (or no choice but) to store a timestamp as i64 in database, but mapping it to a DateTimeUtc in application code.

We've created a new set of UnixTimestamp wrapper types that does this transparently:

#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "access_log")]
pub struct Model {
.. // with `chrono` crate
pub ts: ChronoUnixTimestamp,
pub ms: ChronoUnixTimestampMillis,
.. // with `time` crate
pub ts: TimeUnixTimestamp,
pub ms: TimeUnixTimestampMillis,
}
let now = ChronoUtc::now();
let log = access_log::ActiveModel {
ts: Set(now.into()),
..Default::default()
}
.insert(db)
.await?;

assert_eq!(log.ts.timestamp(), now.timestamp());

Entity-First Workflow​

SchemaBuilder can now be used in migrations.

#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();

db.get_schema_builder()
.register(user::Entity)
.apply(db) // or sync(db)
.await
}
}

🧭 Instant GraphQL API​

With Seaography, the Entities you wrote can instantly be exposed as a GraphQL schema, with full CRUD, filtering and pagination. No extra macros, no Entity re-generation is needed!

With SeaORM and Seaography, you can prototype quickly and stay in the flow. The Entity:

#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "user")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
#[sea_orm(unique)]
pub email: String,
#[sea_orm(has_one)]
pub profile: HasOne<super::profile::Entity>,
#[sea_orm(has_many)]
pub posts: HasMany<super::post::Entity>,
}

Instantly turned into a GraphQL type:

type User {
id: Int!
name: String!
email: String!
profile: Profile
post(
filters: PostFilterInput
orderBy: PostOrderInput
pagination: PaginationInput
): PostConnection!
}

πŸ–₯️ SeaORM Pro: Admin Panel​

SeaORM Pro is an admin panel solution allowing you to quickly and easily launch an admin panel for your application - frontend development skills not required, but certainly nice to have!

SeaORM Pro has been updated to support the latest features in SeaORM 2.0.

Features:

  • Full CRUD
  • Built on React + GraphQL
  • Built-in GraphQL resolver
  • Customize the UI with TOML config
  • Role Based Access Control (new in 2.0)

🌟 Sponsors​

Gold Sponsor​

QDX pioneers quantum dynamics-powered drug discovery, leveraging AI and supercomputing to accelerate molecular modeling. We're grateful to QDX for sponsoring the development of SeaORM, the SQL toolkit that powers their data intensive applications.

GitHub Sponsors​

If you feel generous, a small donation will be greatly appreciated, and goes a long way towards sustaining the organization.

A big shout out to our GitHub sponsors:


πŸ¦€ Rustacean Sticker Pack​

The Rustacean Sticker Pack is the perfect way to express your passion for Rust. Our stickers are made with a premium water-resistant vinyl with a unique matte finish.

Sticker Pack Contents:

  • Logo of SeaQL projects: SeaQL, SeaORM, SeaQuery, Seaography
  • Mascots: Ferris the Crab x 3, Terres the Hermit Crab
  • The Rustacean wordmark

Support SeaQL and get a Sticker Pack!

Rustacean Sticker Pack by SeaQL

SeaORM 2.0: Entity First Workflow

Β· 8 min read
SeaQL Team
Chris Tsang
SeaORM 2.0 Banner

In our last post, we introduced a new Entity format - designed to be more concise, more readable, and easy to write by hand.

With this format, you can embrace an Entity‑first workflow: stay focused on your domain model without getting bogged down in database tables or migration scripts.

And the best part? Pair it with Seaography and you'll have a working GraphQL API instantly - meaning you can skip writing most of the backend logic code until much later in your project's lifecycle.

This combination keeps you in the flow, and lets you focus on what really matters: building apps.

What's Entity first?​

SeaORM used to adopt a schema‑first approach: meaning you design database tables and write migration scripts first, then generate entities from that schema.

Entity‑first flips the flow: you hand-write the entity files, and let SeaORM generates the tables and foreign keys for you.

All you have to do is to add the following to your main.rs right after creating the database connection:

let db = &Database::connect(db_url).await?;
// synchronizes database schema with entity definitions
db.get_schema_registry("my_crate::entity::*").sync(db).await?;

This requires two feature flags schema-sync and entity-registry, and we're going to explain what they do.

Unfolding​

Entity Registry​

The above function get_schema_registry unfolds into the following:

db.get_schema_builder()
.register(comment::Entity)
.register(post::Entity)
.register(profile::Entity)
.register(user::Entity)
.sync(db)
.await?;

You might be wondering: how can SeaORM recognize my entities when, at compile time, the SeaORM crate itself has no knowledge of them?

Rest assured, there's no source‑file scanning or other hacks involved - this is powered by the brilliant inventory crate. The inventory crate works by registering items (called plugins) into linker-collected sections.

At compile-time, each Entity module registers itself to the global inventory along with their module paths and some metadata. On runtime, SeaORM then filters the Entities you requested and construct a SchemaBuilder.

The EntityRegistry is completely optional and just adds extra convenience, it's perfectly fine for you to register Entities manually like above.

Resolving Entity Relations​

If you remember from the previous post, you'll notice that comment has a foreign key referencing post. Since SQLite doesn't allow adding foreign keys after the fact, the post table must be created before the comment table.

This is where SeaORM shines: it automatically builds a dependency graph from your entities and determines the correct topological order to create the tables, so you don't have to keep track of them in your head.

Schema Sync in Action​

The second feature, schema-sync, compares the in‑memory entity definitions with the live database schema, detects missing tables, columns, and keys, and creates them idempotently - no matter how many times you run sync, the schema converges to the same state.

Let's walk through the different scenarios:

Adding Table​

Let's say you added a new Entity under mod.rs

entity/mod.rs
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.14

pub mod prelude;

pub mod post;
pub mod upvote; // β¬… new entity module
..

The next time you cargo run, you'll see the following:

CREATE TABLE "upvote" ( "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, .. )

This will create the table along with any foreign keys.

Adding Columns​

entity/profile.rs
use sea_orm::entity::prelude::*;

#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "profile")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub picture: String,
pub date_of_birth: Option<DateTimeUtc>, // β¬… new column
..
}

impl ActiveModelBehavior for ActiveModel {}

The next time you cargo run, you'll see the following:

ALTER TABLE "profile" ADD COLUMN "date_of_birth" timestamp with time zone

How about adding a non-nullable column? You can set a default_value or default_expr:

#[sea_orm(default_value = 0)]
pub post_count: i32,

// this doesn't work in SQLite
#[sea_orm(default_expr = "Expr::current_timestamp()")]
pub updated_at: DateTimeUtc,

Rename Column​

If you only want to rename the field name in code, you can simply remap the column name:

pub struct Model {
..
#[sea_orm(column_name = "date_of_birth")]
pub dob: Option<DateTimeUtc>, // β¬… renamed for brevity
}

This doesn't involve any schema change.

If you want to actually rename the column, then you have to add a special attribute. Note that you can't simply change the field name, as this will be recognized as adding a new column.

pub struct Model {
..
#[sea_orm(renamed_from = "date_of_birth")] // β¬… special annotation
pub dob: Option<DateTimeUtc>,
}

The next time you cargo run, you'll see the following:

ALTER TABLE "profile" RENAME COLUMN "date_of_birth" TO "dob"

Nice, isn't it?

Add Foreign Key​

Let's create a new table with a foreign key:

entity/upvote.rs
use sea_orm::entity::prelude::*;

#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "upvote")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub post_id: i32,
#[sea_orm(belongs_to, from = "post_id", to = "id")]
pub post: HasOne<super::post::Entity>,
..
}

impl ActiveModelBehavior for ActiveModel {}

The next time you cargo run, you'll see the following:

CREATE TABLE "upvote" (
"post_id" integer NOT NULL PRIMARY KEY,
..
FOREIGN KEY ("post_id") REFERENCES "post" ("id")
)

If however, the post relation is added after the table has been created, then the foreign key couldn't be created for SQLite. Relational queries would still work, but functions completely client-side.

Add Unique Key​

Now, let's say we've forgotten to add a unique constraint on user name:

entity/user.rs
use sea_orm::entity::prelude::*;

#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "user")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)] // β¬… add unique key
pub name: String,
#[sea_orm(unique)]
pub email: String,
..
}

The next time you cargo run, you'll see the following:

CREATE UNIQUE INDEX "idx-user-name" ON "user" ("name")

As mentioned in the previous blog post, you'll also get a shorthand method generated on the Entity:

user::Entity::find_by_name("Bob")..

Remove Unique Key​

Well, you've changed your mind and want to remove the unique constraint on user name:

pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
// no annotation
pub name: String,
#[sea_orm(unique)]
pub email: String,
..
}

The next time you cargo run, you'll see the following:

DROP INDEX "idx-user-name"

Footnotes​

Note that in general schema sync would not attempt to do any destructive actions, so meaning no DROP on tables, columns and foreign keys. Dropping index is an exception here.

Every time the application starts, a full schema discovery is performed. This may not be desirable in production, so sync is gated behind a feature flag schema-sync that can be turned off based on build profile.

🧭 Instant GraphQL API​

With Seaography, the Entities you wrote can instantly be exposed as a GraphQL schema, with full CRUD, filtering and pagination. No extra macros, no Entity re-generation is needed!

With SeaORM and Seaography, you can prototype quickly and stay in the flow. And because Seaography is highly customizable, you can gradually shift resolver logic into your own implementation as the application evolves, and layer access control on top before the project goes to production.

The Entity:

#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "user")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
#[sea_orm(unique)]
pub email: String,
#[sea_orm(has_one)]
pub profile: HasOne<super::profile::Entity>,
#[sea_orm(has_many)]
pub posts: HasMany<super::post::Entity>,
}

Instantly turned into a GraphQL type:

type User {
id: Int!
name: String!
email: String!
profile: Profile
post(
filters: PostFilterInput
orderBy: PostOrderInput
pagination: PaginationInput
): PostConnection!
}

πŸ–₯️ SeaORM Pro: Admin Panel​

SeaORM Pro is an admin panel solution allowing you to quickly and easily launch an admin panel for your application - frontend development skills not required, but certainly nice to have!

SeaORM Pro has been updated to support the latest features in SeaORM 2.0.

Features:

  • Full CRUD
  • Built on React + GraphQL
  • Built-in GraphQL resolver
  • Customize the UI with TOML config
  • Role Based Access Control (new in 2.0)

πŸ¦€ Rustacean Sticker Pack​

The Rustacean Sticker Pack is the perfect way to express your passion for Rust. Our stickers are made with a premium water-resistant vinyl with a unique matte finish.

Sticker Pack Contents:

  • Logo of SeaQL projects: SeaQL, SeaORM, SeaQuery, Seaography
  • Mascots: Ferris the Crab x 3, Terres the Hermit Crab
  • The Rustacean wordmark

Support SeaQL and get a Sticker Pack!

Rustacean Sticker Pack by SeaQL