Core Concepts¶
This page explains the building blocks of runic.orm: how models map to
graph nodes and edges, how the session manages object lifecycle, and how the
identity map eliminates redundant queries.
All state — query generation, dirty tracking, identity tracking — lives in the
Session. The session is your unit of work.
Models are plain Python classes; they carry no database logic themselves.
Node and Edge¶
Every graph entity inherits from either Node
or Edge.
from runic.orm import Edge, Field, Node
class Person(Node, labels=["Person"]):
id: str = Field(primary_key=True, generated=True)
name: str
email: str = Field(index=True, unique=True)
class InvitationEdge(Edge, type="INVITED_TO"):
role: str
status: str
invited_at: str
Node maps to a graph vertex. Edge maps to a relationship type.
Both register themselves in the global metadata registry when the class body
executes — forward references in target= strings are resolved at that point.
labels controls which graph labels are applied. Multi-label nodes implement polymorphic hierarchies:
class Location(Node, labels=["Location"], primary_label="Location"):
id: str
title: str
class Country(Location, labels=["Location", "Country"], primary_label="Location"):
iso_code: str = Field(unique=True)
primary_label (optional) is the label used in MATCH predicates when
the node has more than one label. When it is omitted, the first entry in
labels is used. Set it on both the parent and the subclass so that
MATCH (n:Location) matches all subtypes:
# MATCH (n:Location {id: $id}) — both Country and City are matched
location: Location | None = session.get(Location, "FR")
See also
- examples/orm/01_simple_crud.py
Defines a
NodewithFielddescriptors and walks through all object states.- examples/orm/02_polymorphic_locations.py
Multi-label hierarchy (
Location → Country, City),primary_label, and polymorphic repository queries.
Field and Relation descriptors¶
Properties and relationships are declared with separate functions to keep scalar data and graph topology clearly separated:
Field()— scalar properties, index hints, and constraints.Relation()— graph relationships (edges).
Both return FieldDescriptor typed as
Any, so name: str = Field() is accepted by type checkers without
error. At runtime the descriptor intercepts __set__ to set _dirty
and __get__ to trigger lazy loading.
Field parameters
Parameter |
Type |
Description |
|---|---|---|
|
|
Python default value (evaluated lazily via |
|
|
Create a |
|
|
|
|
|
Unique constraint |
|
|
Validated on save; raises |
|
Custom encode/decode; omit for |
|
|
|
The database assigns the node ID on |
|
|
Store via |
Relation parameters
Parameter |
Type |
Description |
|---|---|---|
|
|
Edge-type string (required) |
|
|
|
|
|
Entity class (or forward-reference string) for the other end (required) |
|
|
Optional |
|
|
Delay relationship loading (default |
|
|
Auto-add related entities when the owning entity is added to a session |
|
|
Default value (defaults to |
Object states¶
Each entity lives in exactly one state at any time, mirroring SQLAlchemy’s unit-of-work pattern. The session is the source of truth for state transitions:
State |
When the object enters it |
|---|---|
Transient |
Created with |
Pending |
After |
Persistent |
Loaded from the graph, or after the first successful |
Deleted |
After |
Detached |
After |
A transient entity that is never added to a session is never persisted. If you
construct an entity with Entity(id="x", ...) and discard it, no query runs.
Dirty tracking¶
Two private flags drive which Cypher statement the mapper emits:
_new—Trueuntil the first successful flush. The mapper emitsCREATEwhen this is true._dirty—Truewhen any field is written on a persistent entity. The mapper emitsMERGE … SETwhen this is true.
The descriptor __set__ sets _dirty = True automatically. The session
clears both flags after a successful flush().
Only the fields that were actually set are included in the SET clause.
The ORM does not write fields that haven’t changed:
with Session(driver) as session:
person = session.get(Person, "alice")
assert person is not None
person.name = "Alice Smith"
# _dirty = True, only 'name' will be in SET
session.commit()
# emits: MERGE (n:Person {id: $id}) SET n.name = $name
Identity map¶
The session keeps one Python instance per (EntityClass, primary_key) pair.
Two reads for the same primary key within the same session return the same
object — no second Cypher query:
with Session(driver) as session:
a: Person | None = session.get(Person, "alice")
b: Person | None = session.get(Person, "alice")
assert a is b # True — single object, no second query
Repository reads also register entities in the identity map. If you call
repo.find_all() and then session.get(Person, "alice") in the same
session, the result is the same object that was returned by find_all().
The identity map is cleared when the session is closed. Objects become detached and no longer track dirty state.
Type converters¶
The ORM assigns converters automatically for well-known annotation types —
no converter= argument needed:
Annotation type |
Converter assigned automatically |
|---|---|
|
|
|
|
|
|
|
from datetime import datetime
from enum import StrEnum
from runic.orm import Field, GeoLocation, Node, Vector
class Status(StrEnum):
ACTIVE = "active"
ARCHIVED = "archived"
class Place(Node, labels=["Place"]):
id: str
status: Status # EnumConverter auto-assigned
created_at: datetime # DatetimeConverter auto-assigned
embedding: Vector = Field(index_type="VECTOR") # VectorConverter
location: GeoLocation # GeoLocationConverter
An explicit converter= always takes precedence over auto-assignment.
Interned strings (FalkorDB only)
Use interned=True to store a string property via FalkorDB’s intern()
function, which deduplicates the value across the database. Useful for
high-cardinality-but-low-variety fields like country names or status codes:
class Person(Node, labels=["Person"]):
id: str = Field()
country: str = Field(interned=True)
Custom converters
Implement TypeConverter (to_graph /
from_graph) for any type not covered above. Set cypher_fn on the
converter class to wrap the Cypher parameter with a backend function:
from runic.orm import TypeConverter
class MyConverter(TypeConverter):
cypher_fn = "myFunc" # wraps Cypher parameter: myFunc($value)
def to_graph(self, value): ...
def from_graph(self, value): ...
See also
- examples/orm/06_native_types.py
Vector,GeoLocation, interned strings,datetimeandEnumauto-converters in action.
Metadata registry¶
All Node and Edge subclasses are registered automatically in the
global metadata singleton when the class is
defined. The registry is used by IndexManager
to discover index hints and by the mapper for polymorphic label resolution.
Forward references in target= strings are resolved at import time.
from runic.orm import metadata
for node_meta in metadata.all_nodes():
print(node_meta.cls.__name__, node_meta.labels)