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(...).
somnia inlines 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")]
struct Post {
#[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 projections
let 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 links
let 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 it
let 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 projections
let 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 variants
let 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 author
let titles = Author::table()
.project_path(Path::out::<Wrote>().to::<Post>().field("title"), "titles")
.to_surrealql();
// Recursive paths: every author within 3 "knows" hops
let 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")]
struct Comment {
#[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)]
struct Member {
#[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.surql
for 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": "[email protected]", "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;
let mut stream = client.live_select::<Post>().await?;
while let Some(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 once
let 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 comments
let 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 subquery
let 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 post
let 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 5s
let 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")]
struct Account {
#[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 relevance
let 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 first
let 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")]
struct Comment {
#[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")]
struct Wrote {
#[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.
