A type-safe SurrealDB ORM for Rust — a typed query
builder, a #[derive(SurrealRecord)] macro, schema generation, and Diesel-style
migrations.
somnia — Latin for "dreams". SurrealDB is surreal (dreamlike); somnia is where your Rust types dream in SurrealQL.
[dependencies]
somnia = "0.8"Writing SurrealQL as hand-spliced strings is error-prone: typo'd table names,
unescaped values, record-link mistakes, and projection drift. somnia lets your
Rust types describe the schema once and gives you:
- Typed query building —
Post::table().select(...).filter(Post::title().eq("hello")) - Graph traversal — query across
RELATEedges with typed paths (Path::out::<Wrote>().to::<Post>()), including recursive@.{..}paths. #[derive(SurrealRecord)]— typed column accessors, table metadata, and schema DDL generated from the struct.- Schema as code —
up()/down()emitDEFINE TABLE/DEFINE FIELD/DEFINE INDEX/REMOVE TABLEfrom the Rust type. - Diesel-style migrations — a
Migratorthat appliesup.surql/ revertsdown.surqlfrom timestamped folders, with applied-state tracking. - The rest of SurrealQL, typed — atomic transactions,
$parambinding, subqueries,IF/FORcontrol flow, andDEFINE EVENT/FUNCTION/ANALYZER/PARAM— so you rarely drop toRaw(...).
somniainlines literals (with proper escaping) rather than relying on bind
parameters — to_surrealql() returns a ready-to-run statement string, which keeps
generated queries transparent and easy to log.
use somnia::{SurrealRecord,Thing};use serde::{Deserialize,Serialize};#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("post")]structPost{#[field(thing)]id:Thing<Post>,title:String,body:String,published_at:Option<String>,}use somnia::{col, field, ident,RecordLink,Returning};// SELECT with typed columns + function-wrapped projectionslet sql = Post::table().project(vec![
field("record::id(id)","id"),
col("title"),
field("type::string(published_at)","published_at"),]).filter(Post::published_at().ne(None)).order_desc(ident("published_at")).limit(20).to_surrealql();// CREATE … with record linkslet create = Post::table().create().record("post-1".to_string()).set_lit("title","Hello, world".to_string()).set_expr("author",RecordLink::new("author","bob".to_string())).set_raw("published_at","time::now()").returning(Returning::After).to_surrealql();// UPSERT — update the record if it exists, otherwise create itlet upserted = Post::table().upsert().record("post-1".to_string()).set_lit("title","Hello again".to_string()).returning(Returning::After).to_surrealql();// CREATE then SELECT back with typed projectionslet batch = Post::table().create().record("post-1".to_string()).set_lit("title","Hello, world".to_string()).set_expr("author",RecordLink::new("author","bob".to_string())).set_raw("published_at","time::now()").returning(Returning::After).then_select(Post::table().project(vec![
field("record::id(id)","id"),
col("title"),
field("type::string(published_at)","published_at"),]).limit(1),);// UPDATE / DELETE with RETURN variantslet del = Post::table().delete().filter(ident("id").eq_expr(RecordLink::new("post","post-1".to_string()))).returning(Returning::Before).to_surrealql();// Graph traversal across RELATE edges (`Wrote`/`Knows` are `SurrealEdge` types)use somnia::Path;// SELECT ->wrote->post.title AS titles FROM authorlet titles = Author::table().project_path(Path::out::<Wrote>().to::<Post>().field("title"),"titles").to_surrealql();// Recursive paths: every author within 3 "knows" hopslet network = Author::table().project_path(Path::out::<Knows>().to::<Author>().recurse_up_to(3),"network").to_surrealql();For SurrealQL that isn't modeled as typed nodes (lambdas, IF/THEN/ELSE,
string::* chains), use the Raw(...) / field("…raw…", "alias") escape hatch —
the builder still owns the statement structure, table names, and record links.
#[derive(SurrealRecord)] also implements SurrealSchema:
use somnia::SurrealSchema;#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("comment")]structComment{#[field(thing)]id:Thing<Comment>,#[field(record = "post")]post: serde_json::Value,body:String,#[field(ty = "datetime",default = "time::now()")]created_at:String,}Comment::up();// DEFINE TABLE … ; DEFINE FIELD … ;Comment::down();// REMOVE TABLE IF EXISTS comment;Field attributes: #[field(thing)] (record id), record = "table"
(record<table>), default = "…", value = "…", ty = "…" (full type
override), flexible, name = "…", skip. Table attributes:
#[table("name")], #[table("name", schemaless, permissions = "NONE")].
Field types are mapped from the Rust type — including typed arrays
(Vec<T> → array<…>), Option<…>, records, duration, and decimal.
Add indexes with a repeatable container attribute; they're emitted by up()
(after the fields) and exposed via SurrealSchema::define_indexes():
#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("member")]#[index(name = "member_email_unique", fields = "email", unique)]structMember{#[field(thing)]id:Thing<Member>,email:String,}For ad-hoc or richer indexes (full-text SEARCH, vector HNSW/MTREE), use the
DefineIndex builder directly.
Lay out migrations Diesel-style — one timestamped folder per migration with
up.surql and down.surql:
migrations/
2025-01-01-000000_create_posts/
up.surql
down.surql
2025-01-01-000100_seed_defaults/
up.surql
down.surqluse somnia::SomniaClient;let client = SomniaClient::connect("ws://localhost:8000","root","root","ns","db").await?;let migrator = client.migrator("migrations");
migrator.run().await?;// apply all pending up.surql in order
migrator.revert_last().await?;// run the latest down.surqlfor m in migrator.status().await? {println!("{} {}",if m.applied {"✓"} else {" "}, m.id);}Applied migrations are tracked in a _somnia_migrations table, so re-running only
applies what's pending.
SomniaClient::connect signs in as a root user and selects a
namespace/database:
let client = SomniaClient::connect("ws://localhost:8000","root","root","app","app").await?;For other auth levels, connect_with takes a Credentials enum (root,
namespace, database, or a pre-issued token):
use somnia::Credentials;let client = SomniaClient::connect_with("ws://localhost:8000","app","app",Credentials::database("app","app","svc","secret"),).await?;Record (scope) auth signs up / signs in against a DEFINE ACCESS … TYPE RECORD
method with arbitrary serializable params and returns the issued token; a JWT can
be re-attached with authenticate, and invalidate clears the session:
let token = client
.signup_record("app","app","account",&serde_json::json!({"email":"a@b.com","pass":"secret"})).await?;
client.authenticate(&token).await?;// attach a JWT to later requests
client.invalidate().await?;// drop the session's authconnect_anonymous(endpoint, ns, db) connects without signing in — handy for the
embedded mem:// / rocksdb:// engines or deferred auth.
live_select::<T>() starts a LIVE SELECT on T's table and streams typed
change notifications; dropping the stream issues KILL server-side:
use futures::StreamExt;use somnia::Action;letmut stream = client.live_select::<Post>().await?;whileletSome(note) = stream.next().await{let note = note?;// Notification<Post>match note.action{Action::Create => println!("created {:?}", note.data),Action::Update => println!("updated {:?}", note.data),Action::Delete => println!("deleted {:?}", note.data),}}// `stream` dropped here → KILL sent.somnia models much of SurrealDB's surface as typed builders, so you rarely fall
back to raw strings. Every builder renders to a plain SurrealQL string via
to_surrealql() — shown in the // comments below — so nothing is hidden. The
examples build on the Post and Comment structs from Quick start.
By default somnia inlines values as escaped literals. For statement reuse or
binary-safe values, switch to $param binding with to_surrealql_with_params(),
which returns the SQL plus a map of bound values; execute it with
client.query_with_params(...):
let(sql, params) = Post::table().select(Post::all()).filter(Post::title().eq("hello".to_string())).limit(10).to_surrealql_with_params();// sql == "SELECT * FROM post WHERE title = $p0 LIMIT 10"// params == { "p0": "hello" }let rows:Vec<Post> = client.query_with_params(&sql,¶ms).await?;Bind one value to a name and reuse it across a query with Param, and declare a
session variable with LetVar:
use somnia::{Param,LetVar,Raw};// `$q` appears twice in the SQL but is bound oncelet q = Param::new("q","rust".to_string());let(sql, params) = Post::table().select(Post::all()).filter(Post::title().eq_expr(q.clone()).or(Post::body().contains_expr(q))).to_surrealql_with_params();// "SELECT * FROM post WHERE title = $q OR body CONTAINS $q" with { "q": "rust" }let now = LetVar::new("now",Raw("time::now()".into())).to_surrealql();// "LET $now = time::now()"Transaction wraps statements in BEGIN … COMMIT so they apply atomically —
SurrealDB rolls the whole block back if any statement errors. .cancel() ends
with CANCEL TRANSACTION to roll back explicitly:
use somnia::Transaction;let tx = Transaction::new().push(Post::table().create().record("p1".to_string()).set_lit("title","Hi".to_string())).push("UPDATE stats SET posts += 1").to_surrealql();// BEGIN TRANSACTION;// CREATE type::record('post', 'p1') SET title = 'Hi';// UPDATE stats SET posts += 1;// COMMIT TRANSACTION;
client.query::<Post>(&tx).await?;// all-or-nothingA Select is itself an expression, so you can nest one inside a WHERE … IN,
use it as a scalar, or read FROM it. Columns and idents gain in_expr /
not_in_expr:
use somnia::{ident, col,Raw};// posts referenced by recent commentslet recent = Comment::table().project(vec![col("post")]).value()// SELECT VALUE post → a bare list of record ids.filter(Raw("created_at > time::now() - 1d".into()));let sql = Post::table().select(Post::all()).filter(ident("id").in_expr(recent)).to_surrealql();// SELECT * FROM post// WHERE id IN (SELECT VALUE post FROM comment WHERE created_at > time::now() - 1d)// …or read FROM a subquerylet sql = Post::table().select(Post::all()).from_subquery(Post::table().select(Post::all()).filter(ident("published").eq(true))).to_surrealql();// SELECT * FROM (SELECT * FROM post WHERE published = true)VALUE (bare values), OMIT (drop fields from *), SPLIT (fan a row out by an
array field), WITH INDEX / WITH NOINDEX (planner hints), TIMEOUT, and
EXPLAIN:
let bare = Post::table().project(vec![col("title")]).value().to_surrealql();// SELECT VALUE title FROM postlet sql = Post::table().select(Post::all()).omit("body").with_index(["idx_published"]).filter(ident("published").eq(true)).timeout("5s").to_surrealql();// SELECT * OMIT body FROM post WITH INDEX idx_published WHERE published = true TIMEOUT 5slet plan = Post::table().select(Post::all()).explain().to_surrealql();// SELECT * FROM post EXPLAINSurrealDB 3.1 doesn't accept
PARALLELas aSELECTclause, so somnia doesn't emit it.
IfExpr is an expression you can drop into a projection, a SET value, a
RETURN, or a WHERE. For builds an iterating block:
use somnia::{IfExpr,For,Raw};let tier = IfExpr::new(Raw("votes >= 100".into()),Raw("'hot'".into())).else_if(Raw("votes >= 10".into()),Raw("'warm'".into())).else_(Raw("'cold'".into()));// IF votes >= 100 THEN 'hot' ELSE IF votes >= 10 THEN 'warm' ELSE 'cold' END// e.g. as a projection: Post::table().project(vec![Projection::aliased(tier, "tier")])let seed = For::new("n",Raw("[1, 2, 3]".into())).push("CREATE counter SET v = $n").to_surrealql();// FOR $n IN [1, 2, 3] { CREATE counter SET v = $n; }Builders for the remaining DEFINE statements — events, functions, analyzers,
and params — each with a matching ::remove(...) inverse:
use somnia::{DefineEvent,DefineFunction,DefineAnalyzer,DefineParam};DefineEvent::new("on_publish","post").when("$event = 'UPDATE' AND $after.published = true").then("{ CREATE log SET post = $after.id, at = time::now() }").to_surrealql();// DEFINE EVENT IF NOT EXISTS on_publish ON TABLE post WHEN … THEN { … }DefineFunction::new("greet").arg("name","string").returns("string").body("RETURN 'hi ' + $name;").to_surrealql();// DEFINE FUNCTION IF NOT EXISTS fn::greet($name: string) -> string { RETURN 'hi ' + $name; }DefineAnalyzer::new("ascii").tokenizers(["class"]).filters(["lowercase","ascii"]).to_surrealql();DefineParam::new("rate","0.5").to_surrealql();// DEFINE PARAM IF NOT EXISTS $rate VALUE 0.5Fields gain ASSERT (validation), READONLY, and PERMISSIONS attributes,
which flow into the generated DEFINE FIELD:
#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("account")]structAccount{#[field(thing)]id:Thing<Account>,#[field(assert = "$value >= 0")]balance:i64,#[field(readonly)]created_by:String,#[field(permissions = "FOR select WHERE id = $auth.id")]secret:String,}// DEFINE FIELD … balance … TYPE int ASSERT $value >= 0;// DEFINE FIELD … created_by … TYPE string READONLY;// DEFINE FIELD … secret … TYPE string PERMISSIONS FOR select WHERE id = $auth.id;Pair a FULLTEXT or HNSW index (see Schema as code) with the
search / nearest builders:
// full-text, ranked by BM25 relevancelet sql = Post::table().search("body","rust database").score_as("score").order_by_score().limit(10).to_surrealql();// "SELECT *, search::score(0) AS score FROM post// WHERE body @0@ 'rust database' ORDER BY score DESC LIMIT 10"// vector K-nearest-neighbour, nearest firstlet sql = Doc::table().nearest("embedding",vec![0.1,0.2,0.3]).k(5).distance_as("dist").order_by_distance().to_surrealql();// "SELECT *, vector::distance::knn() AS dist FROM doc// WHERE embedding <|5,5|> [0.1, 0.2, 0.3] ORDER BY dist"Build anonymous functions with Closure, and mark record links as tracked
REFERENCEs in the derive:
use somnia::{Closure,Raw};let doubler = Closure::new(Raw("$x * 2".into())).arg("x","int").returns("int");// |$x: int| -> int $x * 2#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord)]#[table("comment")]structComment{#[field(thing)]id:Thing<Comment>,// DEFINE FIELD … TYPE record<user> REFERENCE ON DELETE CASCADE#[field(record = "user", reference = "cascade")]author:Thing<User>,body:String,}Define an edge record once and derive its SurrealEdge impl (the edge name
comes from #[table(...)]) — no hand-written impl SurrealEdge:
#[derive(Debug,Clone,Serialize,Deserialize,SurrealRecord,SurrealEdge)]#[table("wrote")]structWrote{#[field(thing)]id:Thing<Wrote>,}Then create edges with RELATE and query across them with typed paths — see the
Path::out::<Wrote>()… examples under Build queries above,
including recursive @.{..} traversal.
| Crate | Description |
|---|---|
somnia | Umbrella crate: client, migrator, re-exports. Start here. |
somnia-core | Query builder, expression tree, SurrealRecord/SurrealSchema traits. |
somnia-derive | #[derive(SurrealRecord)] / #[derive(SurrealEdge)] proc-macros. |
somnia-cli | Diesel-cli-style migration runner (the somnia binary). |
A standalone migration runner, modeled on diesel-cli. Install it with Cargo or
Homebrew (both provide the somnia binary):
cargo install somnia-cli # from crates.io
brew tap vbasky/somnia && brew install somnia # Homebrew (macOS / Linux)Then:
somnia migration generate create_posts # scaffold a timestamped up/down folder
somnia migration run # apply all pending migrations
somnia migration revert # revert the latest
somnia migration redo # revert + re-apply the latest
somnia migration list # show applied / pendingConnection settings are read from flags or environment variables (--help for
the full list).
0.8.x — early but tested against SurrealDB 3.x (query builder, derive, schema
generation, migrator, typed auth, live queries, and full-text/vector search
helpers all covered by integration tests that run on an in-memory engine). The
API may evolve before 1.0. See the
roadmap for what's covered today and what's planned on the way to
1.0.
MSRV: Rust 1.95 (set by the SurrealDB 3.x dependency tree). Bumping the minimum supported Rust version is treated as a minor-version change.
Licensed under the Apache License, Version 2.0.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be licensed as above, without any additional terms or conditions.
