Skip to main content

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 — Model subclasses 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, Transaction mirror 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 you get, list_, save, update, and delete without hand-writing statements for common CRUD.
  • Graph relations — declare edge tables with Relation and build RELATE ... -> ... -> ... 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, or None
  • .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

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 the SELECT list at query time, so SELECT * 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 with DefineField(...).value(...) (migrations are hand-authored in this repo, so nothing derives the VALUE clause 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)

Source distribution for surorm 0.0.25
File Size Uploaded
surorm-0.0.25.tar.gz 34.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for surorm 0.0.25
File Interpreter ABI Platform
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
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page