Skip to main content

No project description provided

Project description

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 typesString, Int, Float, Decimal, Boolean, Datetime, Duration, Bytes, Array[T], Set[T], Object, 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 modelsModel subclasses are real Pydantic v2 models (validation, mutation, model_dump(), etc. all work as expected) with automatic dirty tracking.
  • Immutable, chainable query builderSelect, 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

Edge tables are declared with Relation instead of Model:

from surorm import Relation
from surorm.statements import Relate

class Likes(Relation):
    __table__ = 'likes'
    in_: User
    out: Post

Relate('likes').from_(user.id).to(post.id).sql()
# 'RELATE user:alice->likes->post:1'

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 .

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

surorm-0.0.16.tar.gz (31.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

surorm-0.0.16-py3-none-any.whl (54.2 kB view details)

Uploaded Python 3

File details

Details for the file surorm-0.0.16.tar.gz.

File metadata

  • Download URL: surorm-0.0.16.tar.gz
  • Upload date:
  • Size: 31.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/2.1.4 CPython/3.13.5 Darwin/25.4.0

File hashes

Hashes for surorm-0.0.16.tar.gz
Algorithm Hash digest
SHA256 77bfdd173b3533985fb4507b40ff8a8f89f9bfb7bd33f1144ab7f17c5ef8c2a3
MD5 093dd4dddbaab3c8bed934c96ff3af25
BLAKE2b-256 ac25ccfa82d35a8480d024d210a636594b41b4789de058a003e7a89b760501ca

See more details on using hashes here.

File details

Details for the file surorm-0.0.16-py3-none-any.whl.

File metadata

  • Download URL: surorm-0.0.16-py3-none-any.whl
  • Upload date:
  • Size: 54.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/2.1.4 CPython/3.13.5 Darwin/25.4.0

File hashes

Hashes for surorm-0.0.16-py3-none-any.whl
Algorithm Hash digest
SHA256 19d28698ec4ea95d0ffb526f50501b3c122ed66552f409b40ed9c9a8da489f0e
MD5 fbd7d9790f8f50ed25e3f83ccc9bf8de
BLAKE2b-256 6b772d6a9d1b6a50b3be77f0e606868c93ec8d77a4e965d54c823e354d5115f1

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page