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 .
Releasing to PyPI
The version lives in pyproject.toml ([project].version). Releases use Poetry.
One-time setup (create an API token at https://pypi.org/manage/account/token/):
poetry config pypi-token.pypi <your-token>
Each release:
# 1. Make sure tests and lint pass on a clean master
poetry run pytest
poetry run ruff check .
# 2. Bump the version (patch: 0.0.25 -> 0.0.26; use minor/major for bigger releases)
poetry version patch
# 3. Commit and tag the bump
git commit -am "Bump version to $(poetry version -s)"
git tag "v$(poetry version -s)"
git push && git push --tags
# 4. Build and upload (publish only uploads dist/ files for the current version)
poetry build
poetry publish
poetry publish --build combines step 4 into one command. To check the result first, upload to
TestPyPI instead:
poetry config repositories.testpypi https://test.pypi.org/legacy/
poetry config pypi-token.testpypi <your-testpypi-token>
poetry publish --build -r testpypi
Metadata
Release files for surorm 0.0.25
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.25.tar.gz | 34.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| surorm-0.0.25-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 92.4 kB
Release files / surorm-0.0.25.tar.gz
| Download URL | surorm-0.0.25.tar.gz |
|---|---|
| Size | 34.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
25e145009d1322825c998871bf77f2ea40fd8d97d56f29d346d1919d0d8f31a4
|
|
BLAKE2b-256 checksum How to use checksums |
12bad0d23831d4a5a006d4ba1f4caf8e7138ab7da25a639dd714a9a760dd1d28
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
poetry/2.5.1 CPython/3.14.8 Darwin/25.4.0
|
Release files / surorm-0.0.25-py3-none-any.whl
| Download URL | surorm-0.0.25-py3-none-any.whl |
|---|---|
| Size | 57.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
7a3cf5fa75fbbc9ba2b7def4e285daa5afb453f3f82c9023abfc058c3be419a8
|
|
BLAKE2b-256 checksum How to use checksums |
0031c027ef2468b0222cc8d1008d2e8ca8a9a440f00dc8e9be89f29070264439
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
poetry/2.5.1 CPython/3.14.8 Darwin/25.4.0
|