somnia

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.
[]
= "0.8"
Why
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.
Quick start
Define a record
use ;
use ;
Build queries
use ;
// SELECT with typed columns + function-wrapped projections
let sql = table
.project
.filter
.order_desc
.limit
.to_surrealql;
// CREATE … with record links
let create = table
.create
.record
.set_lit
.set_expr
.set_raw
.returning
.to_surrealql;
// UPSERT — update the record if it exists, otherwise create it
let upserted = table
.upsert
.record
.set_lit
.returning
.to_surrealql;
// CREATE then SELECT back with typed projections
let batch = table
.create
.record
.set_lit
.set_expr
.set_raw
.returning
.then_select;
// UPDATE / DELETE with RETURN variants
let del = table
.delete
.filter
.returning
.to_surrealql;
// Graph traversal across RELATE edges (`Wrote`/`Knows` are `SurrealEdge` types)
use Path;
// SELECT ->wrote->post.title AS titles FROM author
let titles = table
.project_path
.to_surrealql;
// Recursive paths: every author within 3 "knows" hops
let network = table
.project_path
.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.
Schema as code
#[derive(SurrealRecord)] also implements SurrealSchema:
use SurrealSchema;
up; // DEFINE TABLE … ; DEFINE FIELD … ;
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():
For ad-hoc or richer indexes (full-text SEARCH, vector HNSW/MTREE), use the
DefineIndex builder directly.
Migrations
Lay out migrations Diesel-style — one timestamped folder per migration with
up.surql and down.surql:
use SomniaClient;
let client = connect.await?;
let migrator = client.migrator;
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?
Applied migrations are tracked in a _somnia_migrations table, so re-running only
applies what's pending.
Connecting and authentication
SomniaClient::connect signs in as a root user and selects a
namespace/database:
let client = connect.await?;
For other auth levels, connect_with takes a Credentials enum (root,
namespace, database, or a pre-issued token):
use Credentials;
let client = connect_with.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
.await?;
client.authenticate.await?; // attach a JWT to later requests
client.invalidate.await?; // drop the session's auth
connect_anonymous(endpoint, ns, db) connects without signing in — handy for the
embedded mem:// / rocksdb:// engines or deferred auth.
Live queries
live_select::<T>() starts a LIVE SELECT on T's table and streams typed
change notifications; dropping the stream issues KILL server-side:
use StreamExt;
use Action;
let mut stream = client..await?;
while let Some = stream.next.await
// `stream` dropped here → KILL sent.
More query power
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.
Parameters and LET
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 = table
.select
.filter
.limit
.to_surrealql_with_params;
// sql == "SELECT * FROM post WHERE title = $p0 LIMIT 10"
// params == { "p0": "hello" }
let rows: = client.query_with_params.await?;
Bind one value to a name and reuse it across a query with Param, and declare a
session variable with LetVar:
use ;
// `$q` appears twice in the SQL but is bound once
let q = new;
let = table
.select
.filter
.to_surrealql_with_params;
// "SELECT * FROM post WHERE title = $q OR body CONTAINS $q" with { "q": "rust" }
let now = new.to_surrealql;
// "LET $now = time::now()"
Transactions
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 Transaction;
let tx = new
.push
.push
.to_surrealql;
// BEGIN TRANSACTION;
// CREATE type::record('post', 'p1') SET title = 'Hi';
// UPDATE stats SET posts += 1;
// COMMIT TRANSACTION;
client..await?; // all-or-nothing
Subqueries and IN
A 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 ;
// posts referenced by recent comments
let recent = table
.project
.value // SELECT VALUE post → a bare list of record ids
.filter;
let sql = table
.select
.filter
.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 = table
.select
.from_subquery
.to_surrealql;
// SELECT * FROM (SELECT * FROM post WHERE published = true)
SELECT modifiers
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 = table.project.value.to_surrealql;
// SELECT VALUE title FROM post
let sql = table
.select
.omit
.with_index
.filter
.timeout
.to_surrealql;
// SELECT * OMIT body FROM post WITH INDEX idx_published WHERE published = true TIMEOUT 5s
let plan = table.select.explain.to_surrealql;
// SELECT * FROM post EXPLAIN
SurrealDB 3.1 doesn't accept
PARALLELas aSELECTclause, so somnia doesn't emit it.
Control flow — IF and FOR
IfExpr is an expression you can drop into a projection, a SET value, a
RETURN, or a WHERE. For builds an iterating block:
use ;
let tier = new
.else_if
.else_;
// 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 = new
.push
.to_surrealql;
// FOR $n IN [1, 2, 3] { CREATE counter SET v = $n; }
Schema DDL beyond tables, fields, and indexes
Builders for the remaining DEFINE statements — events, functions, analyzers,
and params — each with a matching ::remove(...) inverse:
use ;
new
.when
.then
.to_surrealql;
// DEFINE EVENT IF NOT EXISTS on_publish ON TABLE post WHEN … THEN { … }
new
.arg
.returns
.body
.to_surrealql;
// DEFINE FUNCTION IF NOT EXISTS fn::greet($name: string) -> string { RETURN 'hi ' + $name; }
new.tokenizers.filters.to_surrealql;
new.to_surrealql;
// DEFINE PARAM IF NOT EXISTS $rate VALUE 0.5
Fields gain ASSERT (validation), READONLY, and PERMISSIONS attributes,
which flow into the generated DEFINE FIELD:
// 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;
Full-text and vector search
Pair a FULLTEXT or HNSW index (see Schema as code) with the
search / nearest builders:
// full-text, ranked by BM25 relevance
let sql = table
.search
.score_as
.order_by_score
.limit
.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 = table
.nearest
.k
.distance_as
.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"
Closures and record references
Build anonymous functions with Closure, and mark record links as tracked
REFERENCEs in the derive:
use ;
let doubler = new.arg.returns;
// |$x: int| -> int $x * 2
Graph edges
Define an edge record once and derive its SurrealEdge impl (the edge name
comes from #[table(...)]) — no hand-written impl SurrealEdge:
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.
Crates
| 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). |
CLI
A standalone migration runner, modeled on diesel-cli. Install it with Cargo or
Homebrew (both provide the somnia binary):
&&
Then:
Connection settings are read from flags or environment variables (--help for
the full list).
Status
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.
License
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.