surorm
A typed Python ORM and query builder for SurrealDB, built on Pydantic v2.
Status: pre-release (
0.0.x). The API is not stable yet and breaking changes should be expected between versions — there is no compatibility shim between releases.
Features
- SurrealQL-native data types —
String,Int,Float,Decimal,Boolean,Datetime,Duration,Bytes,Array[T],Set[T],Object,Json,Option[T],Range,RecordID,RecordLink[T],UUID,File. Each type both declares a field's schema and knows how to serialize/deserialize itself to/from SurrealQL. - Pydantic-backed models —
Modelsubclasses are real Pydantic v2 models (validation, mutation,model_dump(), etc. all work as expected) with automatic dirty tracking. - Immutable, chainable query builder —
Select,Create,Update,Delete,Relate,Define*,Remove,Transactionmirror SurrealQL directly. Every builder method returns a new instance, so partially-built queries can be safely reused and extended. - Repository pattern — a small
Repository[Model]generic gives youget,list_,save,update, anddeletewithout hand-writing statements for common CRUD. - Graph relations — declare edge tables with
Relationand buildRELATE ... -> ... -> ...statements directly.
Installation
pip install surorm
Requires Python 3.12+ and a running SurrealDB instance.
Quickstart
Define a model
from surorm import Model
from surorm.data_model import String, Int, Option
class User(Model):
__table__ = 'user'
name: String
age: Int
bio: Option[String] = None
Model extends Pydantic's BaseModel, so instances are constructed and validated the normal way:
user = User(name='Alice', age=30)
user.age = 31
user.is_dirty() # True
user.changed_fields() # {'age': 31}
user.reset_snapshot() # call after persisting to clear the dirty state
id is declared automatically on every Model (id: RecordID | None = None) — None means the
record hasn't been created yet.
Connect and run queries
from surrealdb import AsyncWsSurrealConnection
from surorm import Session
async def main():
async with AsyncWsSurrealConnection('ws://localhost:8000/rpc') as connection:
await connection.signin({'username': 'root', 'password': 'root'})
await connection.use('my_namespace', 'my_database')
session = Session(connection)
result = await session.execute(
Select(User.name, User.age).from_(User).where(User.age > 18)
)
users = result.all() # -> list[User]
Session.execute() returns a Result, which exposes:
.all()— every row, deserialized into the model class if one was inferred from the statement.first()— the first row, orNone.dicts()— raw rows as plain dicts
Query builder
Statements are frozen dataclasses — every method call returns a new statement, leaving the original untouched:
from surorm.statements import Select, Create, Update, Delete
Select(User.name, User.age).from_(User).where(User.age > 18).sql()
# 'SELECT name, age FROM user WHERE age > 18'
Create(User).content({'name': 'Alice', 'age': 30}).sql()
# 'CREATE user CONTENT { name: "Alice", age: 30 }'
Update(User).set(age=31).where(User.id == 'user:alice').sql()
# 'UPDATE user SET age = 31 WHERE id = user:alice'
Delete(User).where(User.age < 18).sql()
# 'DELETE user WHERE age < 18'
.where() is additive — each call appends a condition (joined with AND), it never replaces the
existing clause:
Select(User.name).from_(User).where(User.age > 18).where(User.name == 'Alice').sql()
# 'SELECT name FROM user WHERE age > 18 AND name = "Alice"'
Conditions compose with &, |, and ~:
Select('*').from_(User).where((User.age > 18) & (User.name != 'Alice')).sql()
Other builders follow the same pattern: .limit(), .start(), .order_by(), .fetch(),
.merge(), .return_('after'). By default no RETURN clause is emitted — SurrealDB's own default
(the mutated record) applies unless you opt in explicitly.
Repository
For straightforward CRUD, wrap a model in a Repository:
from surorm import Repository
class UserRepository(Repository[User]):
pass
repo = UserRepository(session)
user = await repo.save(User(name='Alice', age=30)) # CREATE (id is None)
user = await repo.update(user, age=31) # UPDATE
users = await repo.list_(age=31) # SELECT * WHERE age = 31
await repo.delete(user) # DELETE
save() dispatches automatically: id is None issues a CREATE, otherwise it diffs
changed_fields() and issues an UPDATE with only the changed fields.
Relations and graph traversal
SurrealDB relations are edges: separate records that live on their own table and link two other
records together with RELATE. surorm supports them in three ways — declaring the edge table,
building/executing RELATE statements, and storing direct references with RecordLink.
Declaring an edge table
Subclass Relation instead of Model. in_ and out document the endpoint types; extra fields
become properties on the edge itself:
from surorm import Relation
from surorm.data_model import Int
class Likes(Relation):
__table__ = 'likes'
in_: User
out: Post
rating: Int
Optionally define the table's schema (FROM/TO constrain which tables the edge can connect):
from surorm.statements import DefineTable
DefineTable(Likes).type('relation', from_='user', to='post').sql()
# 'DEFINE TABLE likes SCHEMALESS TYPE RELATION FROM user TO post'
Creating a relation
Build a RELATE statement directly with Relate:
from surorm.statements import Relate
Relate('likes').from_(user.id).to(post.id).sql()
# 'RELATE user:alice->likes->post:1'
Relate('likes').from_(user.id).to(post.id).set(rating=5).return_('after').sql()
# 'RELATE user:alice->likes->post:1 SET rating = 5 RETURN AFTER'
.from_() and .to() each accept multiple records, which relates every source to every target:
Relate('likes').from_(alice.id, bob.id).to(post.id).sql()
# 'RELATE [user:alice,user:bob]->likes->post:1'
.set(**fields) and .content(dict) are mutually exclusive — whichever is called last wins.
Or use Repository.relate(), which takes model instances instead of raw IDs and executes the
statement immediately:
repo = UserRepository(session)
await repo.relate(user, Likes, post, rating=5)
# RELATE user:alice->likes->post:1 SET rating = 5 RETURN AFTER
Storing a direct reference with RecordLink
For a plain "points to one other record" field (as opposed to a many-to-many edge), use
RecordLink[T] on a regular Model:
from surorm.data_model import RecordLink, String
class Post(Model):
__table__ = 'post'
title: String
author: RecordLink[User]
author holds a RecordID at rest. Add .fetch() to a Select to have SurrealDB resolve it into
the full User record instead:
Select('*').from_(Post).fetch(Post.author).sql()
# 'SELECT * FROM post FETCH author'
Schema definitions
from surorm.statements import DefineTable, DefineField, DefineIndex
DefineTable(User).schemafull(True).sql()
# 'DEFINE TABLE user SCHEMAFULL TYPE NORMAL'
DefineField('name', String).on(User).sql()
# 'DEFINE FIELD name ON TABLE user TYPE string'
DefineIndex('user_name_idx').on('user').columns('name').unique().sql()
# 'DEFINE INDEX user_name_idx ON TABLE user COLUMNS name UNIQUE'
Computed fields
A field can be derived from its siblings instead of being assigned directly, via
Field(computed=..., computed_in=...). The computed lambda receives the model class and builds
an expression from its other fields (cls.field_name); computed_in picks where that expression
is evaluated:
from surorm import Field
from surorm.data_model import String, Option
class User(Model):
__table__ = 'user'
first_name: String
last_name: String
full_name: Option[String] = Field(
computed=lambda cls: cls.first_name + ' ' + cls.last_name, computed_in='orm'
)
-
computed_in='orm'— no database column. The expression is spliced into theSELECTlist at query time, soSELECT *auto-expands to include it:Select('*').from_(User).sql() # 'SELECT *, first_name + " " + last_name as full_name FROM user'
-
computed_in='surreal'— a real, server-maintained column. Restate the same expression in a migration withDefineField(...).value(...)(migrations are hand-authored in this repo, so nothing derives theVALUEclause automatically from the field definition):from surorm.data_model import Float from surorm.operators import Multiply from surorm.statements import DefineField class OrderItem(Model): __table__ = 'order_item' price: Float quantity: Float line_total: Option[Float] = Field( computed=lambda cls: Multiply(cls.price, cls.quantity), computed_in='surreal' ) DefineField('line_total', Float).on(OrderItem).value(Multiply(OrderItem.price, OrderItem.quantity)).sql() # 'DEFINE FIELD line_total ON TABLE order_item TYPE float VALUE price * quantity'
Class-level access renders context-sensitively — aliased in a top-level SELECT list, bare
everywhere else — so a computed field can be used in .where() / .order_by() without referencing
a not-yet-projected alias:
Select(User.name).from_(User).where(User.full_name == 'Alice Smith').sql()
# "SELECT name FROM user WHERE first_name + \" \" + last_name = \"Alice Smith\""
Computed fields are always declared as Option[T], are read-only on instances (assigning raises
AttributeError), and are excluded from dirty tracking and changed_fields(). They can reference
plain fields and other computed fields, with one rule: a surreal-computed field cannot reference
an orm-computed one, since the latter has no backing column to read from at the database level.
Embedded documents
Nested, inline documents (no table, no id, no dirty tracking) use EmbeddedModel:
from surorm.orm import EmbeddedModel
class Address(EmbeddedModel):
city: String
zip: String
class Customer(Model):
__table__ = 'customer'
name: String
address: Address
Development
poetry install
poetry run pytest
poetry run ruff check .
Metadata
Release files for surorm 0.0.21
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| surorm-0.0.21.tar.gz | 33.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| surorm-0.0.21-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 90.0 kB
Release files / surorm-0.0.21.tar.gz
| Download URL | surorm-0.0.21.tar.gz |
|---|---|
| Size | 33.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3cdab315c618d51a6e337b2f91e0a01f6b9cc2723cca3fa481e8b39c2189ab8e
|
|
BLAKE2b-256 checksum How to use checksums |
637c2f228625bb5fd9b9c4a91d7b1060f6e587104d33e9630d7d7d2b48b6d43e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
poetry/2.1.4 CPython/3.13.5 Darwin/25.4.0
|
Release files / surorm-0.0.21-py3-none-any.whl
| Download URL | surorm-0.0.21-py3-none-any.whl |
|---|---|
| Size | 56.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4a411c67493395dba97cd1704180b7ffcbe19e8d84d34bb37ccd7f0fc9de16c4
|
|
BLAKE2b-256 checksum How to use checksums |
0e03b271889f5ae85cf68ef99ca86a38b5a6ae7a09cb62802922e8a5a698e1ff
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
poetry/2.1.4 CPython/3.13.5 Darwin/25.4.0
|