uni_pydantic

uni-pydantic: Pydantic-based OGM for Uni Graph Database.

This package provides a type-safe Object-Graph Mapping layer on top of the Uni graph database, using Pydantic v2 for model definitions.

Example:

from uni_db import Uni from uni_pydantic import UniNode, UniSession, Field, Relationship, Vector

class Person(UniNode): ... name: str ... age: int | None = None ... email: str = Field(unique=True) ... embedding: Vector[1536] ... friends: list["Person"] = Relationship("FRIEND_OF", direction="both")

db = Uni("./my_graph") session = UniSession(db) session.register(Person) session.sync_schema()

alice = Person(name="Alice", age=30, email="alice@example.com") session.add(alice) session.commit()

Query with type safety

adults = session.query(Person).filter(Person.age >= 18).all()

  1# SPDX-License-Identifier: Apache-2.0
  2# Copyright 2024-2026 Dragonscale Team
  3
  4"""
  5uni-pydantic: Pydantic-based OGM for Uni Graph Database.
  6
  7This package provides a type-safe Object-Graph Mapping layer on top of
  8the Uni graph database, using Pydantic v2 for model definitions.
  9
 10Example:
 11    >>> from uni_db import Uni
 12    >>> from uni_pydantic import UniNode, UniSession, Field, Relationship, Vector
 13    >>>
 14    >>> class Person(UniNode):
 15    ...     name: str
 16    ...     age: int | None = None
 17    ...     email: str = Field(unique=True)
 18    ...     embedding: Vector[1536]
 19    ...     friends: list["Person"] = Relationship("FRIEND_OF", direction="both")
 20    >>>
 21    >>> db = Uni("./my_graph")
 22    >>> session = UniSession(db)
 23    >>> session.register(Person)
 24    >>> session.sync_schema()
 25    >>>
 26    >>> alice = Person(name="Alice", age=30, email="alice@example.com")
 27    >>> session.add(alice)
 28    >>> session.commit()
 29    >>>
 30    >>> # Query with type safety
 31    >>> adults = session.query(Person).filter(Person.age >= 18).all()
 32"""
 33
 34from pathlib import Path
 35
 36# Base classes
 37# Async support
 38from .async_query import AsyncQueryBuilder
 39from .async_session import AsyncUniSession, AsyncUniTransaction
 40from .base import SearchScores, UniEdge, UniNode
 41
 42# Database wrappers
 43from .database import AsyncUniDatabase, UniDatabase
 44
 45# Exceptions
 46from .exceptions import (
 47    BulkLoadError,
 48    CypherInjectionError,
 49    LazyLoadError,
 50    NotPersisted,
 51    NotRegisteredError,
 52    NotTrackedError,
 53    QueryError,
 54    RelationshipError,
 55    SchemaError,
 56    SessionError,
 57    TransactionError,
 58    TypeMappingError,
 59    UniPydanticError,
 60    ValidationError,
 61)
 62
 63# Field configuration
 64from .fields import (
 65    Direction,
 66    Field,
 67    FieldConfig,
 68    IndexType,
 69    Relationship,
 70    RelationshipConfig,
 71    RelationshipDescriptor,
 72    VectorMetric,
 73    get_field_config,
 74)
 75
 76# Lifecycle hooks
 77from .hooks import (
 78    after_create,
 79    after_delete,
 80    after_load,
 81    after_update,
 82    before_create,
 83    before_delete,
 84    before_load,
 85    before_update,
 86)
 87
 88# Query builder
 89from .query import (
 90    FilterExpr,
 91    FilterOp,
 92    HybridSearchConfig,
 93    ModelProxy,
 94    OrderByClause,
 95    PropertyProxy,
 96    QueryBuilder,
 97    SparseSearchConfig,
 98    TraversalStep,
 99    VectorSearchConfig,
100)
101
102# Schema generation
103from .schema import (
104    DatabaseSchema,
105    EdgeTypeSchema,
106    LabelSchema,
107    PropertySchema,
108    SchemaGenerator,
109    generate_schema,
110)
111
112# Session management
113from .session import UniSession, UniTransaction
114
115# Type utilities
116from .types import (
117    DATETIME_TYPES,
118    Btic,
119    SparseVector,
120    Vector,
121    db_to_python_value,
122    get_sparse_vector_dimensions,
123    get_vector_dimensions,
124    is_list_type,
125    is_optional,
126    python_to_db_value,
127    python_type_to_uni,
128    uni_to_python_type,
129    unwrap_annotated,
130)
131
132
133def _package_version() -> str:
134    """Read the version from installed package metadata.
135
136    Derived rather than hand-written: a literal here is a second source of truth
137    beside ``pyproject.toml`` and drifts silently, because only ``pyproject.toml``
138    is checked at release time. It had drifted to ``2.5.0`` against a ``3.3.0``
139    package, which would have shipped a wrong ``__version__`` to every consumer.
140
141    Falls back to reading ``pyproject.toml`` for a source checkout that has not
142    been installed, and finally to ``"0.0.0"`` so an import never fails over a
143    version string.
144    """
145    from importlib.metadata import PackageNotFoundError, version
146
147    try:
148        return version("uni-pydantic")
149    except PackageNotFoundError:
150        pass
151
152    try:
153        import tomllib
154
155        pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
156        with pyproject.open("rb") as fh:
157            return str(tomllib.load(fh)["project"]["version"])
158    except Exception:  # noqa: BLE001 - a version string must never break import
159        return "0.0.0"
160
161
162__version__ = _package_version()
163
164
165__all__ = [
166    # Version
167    "__version__",
168    # Base classes
169    "UniNode",
170    "UniEdge",
171    "SearchScores",
172    # Session
173    "UniSession",
174    "UniTransaction",
175    # Async Session
176    "AsyncUniSession",
177    "AsyncUniTransaction",
178    # Fields
179    "Field",
180    "FieldConfig",
181    "Relationship",
182    "RelationshipConfig",
183    "RelationshipDescriptor",
184    "get_field_config",
185    "IndexType",
186    "Direction",
187    "VectorMetric",
188    # Types
189    "Btic",
190    "Vector",
191    "SparseVector",
192    "python_type_to_uni",
193    "uni_to_python_type",
194    "get_vector_dimensions",
195    "get_sparse_vector_dimensions",
196    "is_optional",
197    "is_list_type",
198    "unwrap_annotated",
199    "python_to_db_value",
200    "db_to_python_value",
201    "DATETIME_TYPES",
202    # Query
203    "QueryBuilder",
204    "AsyncQueryBuilder",
205    "FilterExpr",
206    "FilterOp",
207    "PropertyProxy",
208    "ModelProxy",
209    "OrderByClause",
210    "TraversalStep",
211    "VectorSearchConfig",
212    "SparseSearchConfig",
213    "HybridSearchConfig",
214    # Schema
215    "SchemaGenerator",
216    "DatabaseSchema",
217    "LabelSchema",
218    "EdgeTypeSchema",
219    "PropertySchema",
220    "generate_schema",
221    # Database
222    "UniDatabase",
223    "AsyncUniDatabase",
224    # Hooks
225    "before_create",
226    "after_create",
227    "before_update",
228    "after_update",
229    "before_delete",
230    "after_delete",
231    "before_load",
232    "after_load",
233    # Exceptions
234    "UniPydanticError",
235    "SchemaError",
236    "TypeMappingError",
237    "ValidationError",
238    "SessionError",
239    "NotRegisteredError",
240    "NotPersisted",
241    "NotTrackedError",
242    "TransactionError",
243    "QueryError",
244    "RelationshipError",
245    "LazyLoadError",
246    "BulkLoadError",
247    "CypherInjectionError",
248]
__version__ = '3.4.0'
class UniNode(pydantic.main.BaseModel):
214class UniNode(BaseModel, metaclass=UniModelMeta):
215    """
216    Base class for graph node models.
217
218    Subclass this to define your node types. Each UniNode subclass
219    represents a vertex label in the graph database.
220
221    Attributes:
222        __label__: The vertex label name. Defaults to the class name.
223        __relationships__: Dictionary of relationship configurations.
224
225    Private Attributes:
226        _vid: The vertex ID assigned by the database.
227        _uid: The unique identifier (content-addressed hash).
228        _session: Reference to the owning session.
229        _dirty: Set of modified field names.
230
231    Example:
232        >>> class Person(UniNode):
233        ...     __label__ = "Person"
234        ...
235        ...     name: str
236        ...     age: int | None = None
237        ...     email: str = Field(unique=True)
238        ...
239        ...     friends: list["Person"] = Relationship("FRIEND_OF", direction="both")
240    """
241
242    model_config = ConfigDict(
243        # Allow extra fields for future extensibility
244        extra="forbid",
245        # Validate on assignment for dirty tracking
246        validate_assignment=True,
247        # Allow arbitrary types (for Vector, etc.)
248        arbitrary_types_allowed=True,
249        # Use enum values
250        use_enum_values=True,
251    )
252
253    # Class-level configuration
254    __label__: ClassVar[str] = ""
255    __relationships__: ClassVar[dict[str, RelationshipConfig]] = {}
256
257    # Private attributes for session tracking
258    _vid: int | None = PrivateAttr(default=None)
259    _uid: str | None = PrivateAttr(default=None)
260    _session: UniSession | None = PrivateAttr(default=None)
261    _dirty: set[str] = PrivateAttr(default_factory=set)
262    _is_new: bool = PrivateAttr(default=True)
263    _scores: SearchScores | None = PrivateAttr(default=None)
264
265    def __init_subclass__(cls, **kwargs: Any) -> None:
266        super().__init_subclass__(**kwargs)
267        # Set default label to class name if not specified
268        if not cls.__label__:
269            cls.__label__ = cls.__name__
270
271    def model_post_init(self, __context: Any) -> None:
272        """Clear dirty tracking after construction."""
273        super().model_post_init(__context)
274        self._dirty = set()
275
276    @property
277    def vid(self) -> int | None:
278        """The vertex ID assigned by the database."""
279        return self._vid
280
281    @property
282    def uid(self) -> str | None:
283        """The unique identifier (content-addressed hash)."""
284        return self._uid
285
286    @property
287    def is_persisted(self) -> bool:
288        """Whether this node has been saved to the database."""
289        return self._vid is not None
290
291    @property
292    def is_dirty(self) -> bool:
293        """Whether this node has unsaved changes."""
294        return bool(self._dirty)
295
296    @property
297    def search_scores(self) -> SearchScores | None:
298        """Relevance scores, if this instance came from a search builder.
299
300        ``None`` for instances from ordinary queries. See :class:`SearchScores`.
301        """
302        return self._scores
303
304    def __setattr__(self, name: str, value: Any) -> None:
305        # Track dirty fields (but not private attributes)
306        if not name.startswith("_") and hasattr(self, "_dirty"):
307            self._dirty.add(name)
308        super().__setattr__(name, value)
309
310    def _mark_clean(self) -> None:
311        """Mark all fields as clean (called after commit)."""
312        self._dirty.clear()
313        self._is_new = False
314
315    def _attach_session(
316        self, session: UniSession, vid: int, uid: str | None = None
317    ) -> None:
318        """Attach this node to a session with its database IDs."""
319        self._session = session
320        self._vid = vid
321        self._uid = uid
322        self._is_new = False
323
324    @classmethod
325    def get_property_fields(cls) -> dict[str, FieldInfo]:
326        """Get all property fields (excluding relationships)."""
327        return {
328            name: info
329            for name, info in cls.model_fields.items()
330            if name not in cls.__relationships__
331        }
332
333    @classmethod
334    def get_relationship_fields(cls) -> dict[str, RelationshipConfig]:
335        """Get all relationship field configurations."""
336        return cls.__relationships__
337
338    def to_properties(self) -> dict[str, Any]:
339        """Convert to a property dictionary for database storage.
340
341        Uses python_to_db_value() for type conversion. Includes None
342        explicitly so null-outs work.
343        """
344        return _model_to_properties(self, self.get_property_fields())
345
346    @classmethod
347    def from_properties(
348        cls,
349        props: dict[str, Any],
350        *,
351        vid: int | None = None,
352        uid: str | None = None,
353        session: UniSession | None = None,
354    ) -> UniNode:
355        """Create an instance from a property dictionary.
356
357        Accepts _id (string->int vid) and _label from uni-db node dicts.
358        Does not mutate the input dict.
359        """
360        data = dict(props)
361
362        raw_id = data.pop("_id", None)
363        if raw_id is not None and vid is None:
364            vid = int(raw_id) if not isinstance(raw_id, int) else raw_id
365        data.pop("_label", None)
366
367        converted = _convert_db_values(data, cls)
368
369        instance = cls.model_validate(converted)
370        if vid is not None:
371            instance._vid = vid
372        if uid is not None:
373            instance._uid = uid
374        if session is not None:
375            instance._session = session
376        instance._is_new = vid is None
377        instance._dirty = set()
378        return instance
379
380    def __repr__(self) -> str:
381        vid_str = f"vid={self._vid}" if self._vid else "unsaved"
382        return f"{self.__class__.__name__}({vid_str}, {super().__repr__()})"

Base class for graph node models.

Subclass this to define your node types. Each UniNode subclass represents a vertex label in the graph database.

Attributes: __label__: The vertex label name. Defaults to the class name. __relationships__: Dictionary of relationship configurations.

Private Attributes: _vid: The vertex ID assigned by the database. _uid: The unique identifier (content-addressed hash). _session: Reference to the owning session. _dirty: Set of modified field names.

Example:

class Person(UniNode): ... __label__ = "Person" ... ... name: str ... age: int | None = None ... email: str = Field(unique=True) ... ... friends: list["Person"] = Relationship("FRIEND_OF", direction="both")

vid: int | None
276    @property
277    def vid(self) -> int | None:
278        """The vertex ID assigned by the database."""
279        return self._vid

The vertex ID assigned by the database.

uid: str | None
281    @property
282    def uid(self) -> str | None:
283        """The unique identifier (content-addressed hash)."""
284        return self._uid

The unique identifier (content-addressed hash).

is_persisted: bool
286    @property
287    def is_persisted(self) -> bool:
288        """Whether this node has been saved to the database."""
289        return self._vid is not None

Whether this node has been saved to the database.

is_dirty: bool
291    @property
292    def is_dirty(self) -> bool:
293        """Whether this node has unsaved changes."""
294        return bool(self._dirty)

Whether this node has unsaved changes.

search_scores: SearchScores | None
296    @property
297    def search_scores(self) -> SearchScores | None:
298        """Relevance scores, if this instance came from a search builder.
299
300        ``None`` for instances from ordinary queries. See :class:`SearchScores`.
301        """
302        return self._scores

Relevance scores, if this instance came from a search builder.

None for instances from ordinary queries. See SearchScores.

@classmethod
def get_property_fields(cls) -> dict[str, pydantic.fields.FieldInfo]:
324    @classmethod
325    def get_property_fields(cls) -> dict[str, FieldInfo]:
326        """Get all property fields (excluding relationships)."""
327        return {
328            name: info
329            for name, info in cls.model_fields.items()
330            if name not in cls.__relationships__
331        }

Get all property fields (excluding relationships).

@classmethod
def get_relationship_fields(cls) -> dict[str, RelationshipConfig]:
333    @classmethod
334    def get_relationship_fields(cls) -> dict[str, RelationshipConfig]:
335        """Get all relationship field configurations."""
336        return cls.__relationships__

Get all relationship field configurations.

def to_properties(self) -> dict[str, typing.Any]:
338    def to_properties(self) -> dict[str, Any]:
339        """Convert to a property dictionary for database storage.
340
341        Uses python_to_db_value() for type conversion. Includes None
342        explicitly so null-outs work.
343        """
344        return _model_to_properties(self, self.get_property_fields())

Convert to a property dictionary for database storage.

Uses python_to_db_value() for type conversion. Includes None explicitly so null-outs work.

@classmethod
def from_properties( cls, props: dict[str, typing.Any], *, vid: int | None = None, uid: str | None = None, session: UniSession | None = None) -> UniNode:
346    @classmethod
347    def from_properties(
348        cls,
349        props: dict[str, Any],
350        *,
351        vid: int | None = None,
352        uid: str | None = None,
353        session: UniSession | None = None,
354    ) -> UniNode:
355        """Create an instance from a property dictionary.
356
357        Accepts _id (string->int vid) and _label from uni-db node dicts.
358        Does not mutate the input dict.
359        """
360        data = dict(props)
361
362        raw_id = data.pop("_id", None)
363        if raw_id is not None and vid is None:
364            vid = int(raw_id) if not isinstance(raw_id, int) else raw_id
365        data.pop("_label", None)
366
367        converted = _convert_db_values(data, cls)
368
369        instance = cls.model_validate(converted)
370        if vid is not None:
371            instance._vid = vid
372        if uid is not None:
373            instance._uid = uid
374        if session is not None:
375            instance._session = session
376        instance._is_new = vid is None
377        instance._dirty = set()
378        return instance

Create an instance from a property dictionary.

Accepts _id (string->int vid) and _label from uni-db node dicts. Does not mutate the input dict.

class UniEdge(pydantic.main.BaseModel):
385class UniEdge(BaseModel, metaclass=UniModelMeta):
386    """
387    Base class for graph edge models with properties.
388
389    Subclass this to define edge types with typed properties.
390    Edges represent relationships between nodes.
391
392    Attributes:
393        __edge_type__: The edge type name.
394        __from__: The source node type(s).
395        __to__: The target node type(s).
396
397    Private Attributes:
398        _eid: The edge ID assigned by the database.
399        _src_vid: The source vertex ID.
400        _dst_vid: The destination vertex ID.
401        _session: Reference to the owning session.
402
403    Example:
404        >>> class FriendshipEdge(UniEdge):
405        ...     __edge_type__ = "FRIEND_OF"
406        ...     __from__ = Person
407        ...     __to__ = Person
408        ...
409        ...     since: date
410        ...     strength: float = 1.0
411    """
412
413    model_config = ConfigDict(
414        extra="forbid",
415        validate_assignment=True,
416        arbitrary_types_allowed=True,
417        use_enum_values=True,
418    )
419
420    # Class-level configuration
421    __edge_type__: ClassVar[str] = ""
422    __from__: ClassVar[type[UniNode] | tuple[type[UniNode], ...] | None] = None
423    __to__: ClassVar[type[UniNode] | tuple[type[UniNode], ...] | None] = None
424    __relationships__: ClassVar[dict[str, RelationshipConfig]] = {}
425
426    # Private attributes
427    _eid: int | None = PrivateAttr(default=None)
428    _src_vid: int | None = PrivateAttr(default=None)
429    _dst_vid: int | None = PrivateAttr(default=None)
430    _session: UniSession | None = PrivateAttr(default=None)
431    _is_new: bool = PrivateAttr(default=True)
432
433    def __init_subclass__(cls, **kwargs: Any) -> None:
434        super().__init_subclass__(**kwargs)
435        # Set default edge type to class name if not specified
436        if not cls.__edge_type__:
437            cls.__edge_type__ = cls.__name__
438
439    @property
440    def eid(self) -> int | None:
441        """The edge ID assigned by the database."""
442        return self._eid
443
444    @property
445    def src_vid(self) -> int | None:
446        """The source vertex ID."""
447        return self._src_vid
448
449    @property
450    def dst_vid(self) -> int | None:
451        """The destination vertex ID."""
452        return self._dst_vid
453
454    @property
455    def is_persisted(self) -> bool:
456        """Whether this edge has been saved to the database."""
457        return self._eid is not None
458
459    def _attach(
460        self,
461        session: UniSession,
462        eid: int,
463        src_vid: int,
464        dst_vid: int,
465    ) -> None:
466        """Attach this edge to a session with its database IDs."""
467        self._session = session
468        self._eid = eid
469        self._src_vid = src_vid
470        self._dst_vid = dst_vid
471        self._is_new = False
472
473    @classmethod
474    def get_from_labels(cls) -> list[str]:
475        """Get the source label names."""
476        if cls.__from__ is None:
477            return []
478        if isinstance(cls.__from__, tuple):
479            return [n.__label__ for n in cls.__from__]
480        return [cls.__from__.__label__]
481
482    @classmethod
483    def get_to_labels(cls) -> list[str]:
484        """Get the target label names."""
485        if cls.__to__ is None:
486            return []
487        if isinstance(cls.__to__, tuple):
488            return [n.__label__ for n in cls.__to__]
489        return [cls.__to__.__label__]
490
491    @classmethod
492    def get_property_fields(cls) -> dict[str, FieldInfo]:
493        """Get all property fields."""
494        return dict(cls.model_fields)
495
496    def to_properties(self) -> dict[str, Any]:
497        """Convert to a property dictionary for database storage."""
498        return _model_to_properties(self, self.get_property_fields())
499
500    @classmethod
501    def from_properties(
502        cls,
503        props: dict[str, Any],
504        *,
505        eid: int | None = None,
506        src_vid: int | None = None,
507        dst_vid: int | None = None,
508        session: UniSession | None = None,
509    ) -> UniEdge:
510        """Create an instance from a property dictionary.
511
512        Accepts _id, _type, _src, _dst from uni-db edge dicts.
513        Does not mutate the input dict.
514        """
515        data = dict(props)
516
517        raw_id = data.pop("_id", None)
518        if raw_id is not None and eid is None:
519            eid = int(raw_id) if not isinstance(raw_id, int) else raw_id
520        data.pop("_type", None)
521        raw_src = data.pop("_src", None)
522        if raw_src is not None and src_vid is None:
523            src_vid = int(raw_src) if not isinstance(raw_src, int) else raw_src
524        raw_dst = data.pop("_dst", None)
525        if raw_dst is not None and dst_vid is None:
526            dst_vid = int(raw_dst) if not isinstance(raw_dst, int) else raw_dst
527
528        converted = _convert_db_values(data, cls)
529
530        instance = cls.model_validate(converted)
531        if eid is not None:
532            instance._eid = eid
533        if src_vid is not None:
534            instance._src_vid = src_vid
535        if dst_vid is not None:
536            instance._dst_vid = dst_vid
537        if session is not None:
538            instance._session = session
539        instance._is_new = eid is None
540        return instance
541
542    @classmethod
543    def from_edge_result(
544        cls,
545        data: dict[str, Any],
546        *,
547        session: UniSession | None = None,
548    ) -> UniEdge:
549        """Create an instance from a uni-db edge result dict.
550
551        Convenience method that handles _id, _type, _src, _dst keys.
552        """
553        return cls.from_properties(data, session=session)
554
555    def __repr__(self) -> str:
556        eid_str = f"eid={self._eid}" if self._eid else "unsaved"
557        return f"{self.__class__.__name__}({eid_str}, {super().__repr__()})"

Base class for graph edge models with properties.

Subclass this to define edge types with typed properties. Edges represent relationships between nodes.

Attributes: __edge_type__: The edge type name. __from__: The source node type(s). __to__: The target node type(s).

Private Attributes: _eid: The edge ID assigned by the database. _src_vid: The source vertex ID. _dst_vid: The destination vertex ID. _session: Reference to the owning session.

Example:

class FriendshipEdge(UniEdge): ... __edge_type__ = "FRIEND_OF" ... __from__ = Person ... __to__ = Person ... ... since: date ... strength: float = 1.0

eid: int | None
439    @property
440    def eid(self) -> int | None:
441        """The edge ID assigned by the database."""
442        return self._eid

The edge ID assigned by the database.

src_vid: int | None
444    @property
445    def src_vid(self) -> int | None:
446        """The source vertex ID."""
447        return self._src_vid

The source vertex ID.

dst_vid: int | None
449    @property
450    def dst_vid(self) -> int | None:
451        """The destination vertex ID."""
452        return self._dst_vid

The destination vertex ID.

is_persisted: bool
454    @property
455    def is_persisted(self) -> bool:
456        """Whether this edge has been saved to the database."""
457        return self._eid is not None

Whether this edge has been saved to the database.

@classmethod
def get_from_labels(cls) -> list[str]:
473    @classmethod
474    def get_from_labels(cls) -> list[str]:
475        """Get the source label names."""
476        if cls.__from__ is None:
477            return []
478        if isinstance(cls.__from__, tuple):
479            return [n.__label__ for n in cls.__from__]
480        return [cls.__from__.__label__]

Get the source label names.

@classmethod
def get_to_labels(cls) -> list[str]:
482    @classmethod
483    def get_to_labels(cls) -> list[str]:
484        """Get the target label names."""
485        if cls.__to__ is None:
486            return []
487        if isinstance(cls.__to__, tuple):
488            return [n.__label__ for n in cls.__to__]
489        return [cls.__to__.__label__]

Get the target label names.

@classmethod
def get_property_fields(cls) -> dict[str, pydantic.fields.FieldInfo]:
491    @classmethod
492    def get_property_fields(cls) -> dict[str, FieldInfo]:
493        """Get all property fields."""
494        return dict(cls.model_fields)

Get all property fields.

def to_properties(self) -> dict[str, typing.Any]:
496    def to_properties(self) -> dict[str, Any]:
497        """Convert to a property dictionary for database storage."""
498        return _model_to_properties(self, self.get_property_fields())

Convert to a property dictionary for database storage.

@classmethod
def from_properties( cls, props: dict[str, typing.Any], *, eid: int | None = None, src_vid: int | None = None, dst_vid: int | None = None, session: UniSession | None = None) -> UniEdge:
500    @classmethod
501    def from_properties(
502        cls,
503        props: dict[str, Any],
504        *,
505        eid: int | None = None,
506        src_vid: int | None = None,
507        dst_vid: int | None = None,
508        session: UniSession | None = None,
509    ) -> UniEdge:
510        """Create an instance from a property dictionary.
511
512        Accepts _id, _type, _src, _dst from uni-db edge dicts.
513        Does not mutate the input dict.
514        """
515        data = dict(props)
516
517        raw_id = data.pop("_id", None)
518        if raw_id is not None and eid is None:
519            eid = int(raw_id) if not isinstance(raw_id, int) else raw_id
520        data.pop("_type", None)
521        raw_src = data.pop("_src", None)
522        if raw_src is not None and src_vid is None:
523            src_vid = int(raw_src) if not isinstance(raw_src, int) else raw_src
524        raw_dst = data.pop("_dst", None)
525        if raw_dst is not None and dst_vid is None:
526            dst_vid = int(raw_dst) if not isinstance(raw_dst, int) else raw_dst
527
528        converted = _convert_db_values(data, cls)
529
530        instance = cls.model_validate(converted)
531        if eid is not None:
532            instance._eid = eid
533        if src_vid is not None:
534            instance._src_vid = src_vid
535        if dst_vid is not None:
536            instance._dst_vid = dst_vid
537        if session is not None:
538            instance._session = session
539        instance._is_new = eid is None
540        return instance

Create an instance from a property dictionary.

Accepts _id, _type, _src, _dst from uni-db edge dicts. Does not mutate the input dict.

@classmethod
def from_edge_result( cls, data: dict[str, typing.Any], *, session: UniSession | None = None) -> UniEdge:
542    @classmethod
543    def from_edge_result(
544        cls,
545        data: dict[str, Any],
546        *,
547        session: UniSession | None = None,
548    ) -> UniEdge:
549        """Create an instance from a uni-db edge result dict.
550
551        Convenience method that handles _id, _type, _src, _dst keys.
552        """
553        return cls.from_properties(data, session=session)

Create an instance from a uni-db edge result dict.

Convenience method that handles _id, _type, _src, _dst keys.

@dataclass(frozen=True)
class SearchScores:
191@dataclass(frozen=True)
192class SearchScores:
193    """Relevance scores attached to a hydrated search result.
194
195    Populated on instances returned by the search builders
196    (``vector_search`` / ``sparse_search`` / ``hybrid_search``) and reachable
197    via :attr:`UniNode.search_scores`. Kept in a sidecar (rather than as a model
198    field) so it never collides with a user field named ``score`` and survives
199    hydration under ``extra="forbid"``.
200
201    ``score`` is the primary score used for ordering (the fused score for
202    hybrid; the per-source score for single-source search). The per-arm fields
203    are populated only when that retrieval source contributed, ``None`` otherwise.
204    """
205
206    score: float
207    vector: float | None = None
208    fts: float | None = None
209    sparse: float | None = None
210    rerank: float | None = None
211    distance: float | None = None

Relevance scores attached to a hydrated search result.

Populated on instances returned by the search builders (vector_search / sparse_search / hybrid_search) and reachable via UniNode.search_scores. Kept in a sidecar (rather than as a model field) so it never collides with a user field named score and survives hydration under extra="forbid".

score is the primary score used for ordering (the fused score for hybrid; the per-source score for single-source search). The per-arm fields are populated only when that retrieval source contributed, None otherwise.

SearchScores( score: float, vector: float | None = None, fts: float | None = None, sparse: float | None = None, rerank: float | None = None, distance: float | None = None)
score: float
vector: float | None = None
fts: float | None = None
sparse: float | None = None
rerank: float | None = None
distance: float | None = None
class UniSession:
163class UniSession:
164    """
165    Session for interacting with the graph database using Pydantic models.
166
167    The session manages model registration, schema synchronization,
168    and provides CRUD operations and query building.
169
170    Example:
171        >>> from uni_db import Uni
172        >>> from uni_pydantic import UniSession
173        >>>
174        >>> db = Uni("./my_graph")
175        >>> session = UniSession(db)
176        >>> session.register(Person, Company)
177        >>> session.sync_schema()
178        >>>
179        >>> alice = Person(name="Alice", age=30)
180        >>> session.add(alice)
181        >>> session.commit()
182    """
183
184    def __init__(self, db: uni_db.Uni) -> None:
185        self._db = db
186        self._db_session = db.session()
187        self._schema_gen = SchemaGenerator()
188        self._identity_map: WeakValueDictionary[tuple[str, int], UniNode] = (
189            WeakValueDictionary()
190        )
191        self._pending_new: list[UniNode] = []
192        self._pending_delete: list[UniNode] = []
193
194    def __enter__(self) -> UniSession:
195        return self
196
197    def __exit__(
198        self,
199        exc_type: type[BaseException] | None,
200        exc_val: BaseException | None,
201        exc_tb: TracebackType | None,
202    ) -> None:
203        self.close()
204
205    def close(self) -> None:
206        """Close the session and clear all pending state."""
207        self._pending_new.clear()
208        self._pending_delete.clear()
209
210    @property
211    def db(self) -> uni_db.Uni:
212        """Access the underlying uni_db.Uni for low-level operations."""
213        return self._db
214
215    def locy(
216        self, program: str, params: dict[str, Any] | None = None
217    ) -> uni_db.LocyResult:
218        """
219        Evaluate a Locy program and return derived facts, stats, and warnings.
220
221        Delegates to the underlying ``uni_db.Session.locy()``.
222        """
223        return self._db_session.locy(program, params)
224
225    def register(self, *models: type[UniNode] | type[UniEdge]) -> None:
226        """
227        Register model classes with the session.
228
229        Registered models can be used for schema generation and queries.
230
231        Args:
232            *models: UniNode or UniEdge subclasses to register.
233        """
234        self._schema_gen.register(*models)
235
236    def sync_schema(self) -> None:
237        """
238        Synchronize database schema with registered models.
239
240        Creates labels, edge types, properties, and indexes as needed.
241        This is additive-only; it won't remove existing schema elements.
242        """
243        self._schema_gen.apply_to_database(self._db)
244
245    def query(self, model: type[NodeT]) -> QueryBuilder[NodeT]:
246        """
247        Create a query builder for the given model.
248
249        Args:
250            model: The UniNode subclass to query.
251
252        Returns:
253            A QueryBuilder for constructing queries.
254        """
255        return QueryBuilder(self, model)
256
257    def add(self, entity: UniNode) -> None:
258        """
259        Add a new entity to be persisted.
260
261        The entity will be inserted on the next commit().
262        """
263        if entity.is_persisted:
264            raise SessionError(f"Entity {entity!r} is already persisted")
265        entity._session = self
266        self._pending_new.append(entity)
267
268    def add_all(self, entities: Sequence[UniNode]) -> None:
269        """Add multiple entities to be persisted."""
270        for entity in entities:
271            self.add(entity)
272
273    def delete(self, entity: UniNode) -> None:
274        """Mark an entity for deletion."""
275        if not entity.is_persisted:
276            raise NotPersisted(entity)
277        self._pending_delete.append(entity)
278
279    def get(
280        self,
281        model: type[NodeT],
282        vid: int | None = None,
283        uid: str | None = None,
284        **kwargs: Any,
285    ) -> NodeT | None:
286        """
287        Get an entity by ID or unique properties.
288
289        Args:
290            model: The model type to retrieve.
291            vid: Vertex ID to look up.
292            uid: Unique ID to look up.
293            **kwargs: Property equality filters.
294
295        Returns:
296            The model instance or None if not found.
297        """
298        # Check identity map first
299        if vid is not None:
300            cached = self._identity_map.get((model.__label__, vid))
301            if cached is not None:
302                return cached  # type: ignore[return-value]
303
304        # Build query
305        label = model.__label__
306        params: dict[str, Any] = {}
307
308        if vid is not None:
309            cypher = f"MATCH (n:{label}) WHERE id(n) = $vid RETURN {_NODE_RETURN}"
310            params["vid"] = vid
311        elif uid is not None:
312            cypher = f"MATCH (n:{label}) WHERE n._uid = $uid RETURN {_NODE_RETURN}"
313            params["uid"] = uid
314        elif kwargs:
315            # Validate property names
316            for k in kwargs:
317                _validate_property(k, model)
318            conditions = [f"n.{k} = ${k}" for k in kwargs]
319            cypher = f"MATCH (n:{label}) WHERE {' AND '.join(conditions)} RETURN {_NODE_RETURN} LIMIT 1"
320            params.update(kwargs)
321        else:
322            raise ValueError("Must provide vid, uid, or property filters")
323
324        results = self._db_session.query(cypher, params)
325        if not results:
326            return None
327
328        node_data = _row_to_node_dict(results[0].to_dict())
329        if node_data is None:
330            return None
331        return self._result_to_model(node_data, model)
332
333    def refresh(self, entity: UniNode) -> None:
334        """Refresh an entity's properties from the database."""
335        if not entity.is_persisted:
336            raise NotPersisted(entity)
337
338        label = entity.__class__.__label__
339        cypher = f"MATCH (n:{label}) WHERE id(n) = $vid RETURN {_NODE_RETURN}"
340        results = self._db_session.query(cypher, {"vid": entity._vid})
341
342        if not results:
343            raise SessionError(f"Entity with vid={entity._vid} no longer exists")
344
345        # Update properties
346        props = _row_to_node_dict(results[0].to_dict())
347        if props is None:
348            raise SessionError(f"Entity with vid={entity._vid} no longer exists")
349        try:
350            hints = get_type_hints(type(entity))
351        except Exception:
352            hints = {}
353
354        for field_name in entity.get_property_fields():
355            if field_name in props:
356                value = props[field_name]
357                if field_name in hints:
358                    value = db_to_python_value(value, hints[field_name])
359                setattr(entity, field_name, value)
360
361        entity._mark_clean()
362
363    def commit(self) -> None:
364        """
365        Commit all pending changes to the database.
366
367        This persists new entities, updates dirty entities,
368        and deletes marked entities.
369        """
370        # Insert new entities
371        for entity in self._pending_new:
372            self._create_node(entity)
373
374        # Update dirty entities in identity map
375        for (label, vid), entity in list(self._identity_map.items()):
376            if entity.is_dirty and entity.is_persisted:
377                self._update_node(entity)
378
379        # Delete marked entities
380        for entity in self._pending_delete:
381            self._delete_node(entity)
382
383        # Flush to storage
384        self._db.flush()
385
386        # Clear pending lists
387        self._pending_new.clear()
388        self._pending_delete.clear()
389
390    def rollback(self) -> None:
391        """Discard all pending changes."""
392        # Clear pending new — detach entities
393        for entity in self._pending_new:
394            entity._session = None
395        self._pending_new.clear()
396
397        # Clear pending deletes
398        self._pending_delete.clear()
399
400        # Invalidate dirty identity map entries
401        for entity in list(self._identity_map.values()):
402            if entity.is_dirty:
403                self.refresh(entity)
404
405    @contextmanager
406    def transaction(self) -> Iterator[UniTransaction]:
407        """Create a transaction context."""
408        tx = UniTransaction(self)
409        with tx:
410            yield tx
411
412    def begin(self) -> UniTransaction:
413        """Begin a new transaction."""
414        tx = UniTransaction(self)
415        tx._tx = self._db_session.tx()
416        return tx
417
418    def cypher(
419        self,
420        query: str,
421        params: dict[str, Any] | None = None,
422        result_type: type[NodeT] | None = None,
423    ) -> list[NodeT] | list[dict[str, Any]]:
424        """
425        Execute a raw Cypher query.
426
427        Args:
428            query: Cypher query string.
429            params: Query parameters.
430            result_type: Optional model type for result mapping.
431
432        Returns:
433            List of results (model instances if result_type provided).
434        """
435        results = self._db_session.query(query, params)
436
437        if result_type is None:
438            return [r.to_dict() for r in results]
439
440        # Map results to model instances
441        mapped = []
442        for raw_row in results:
443            row = raw_row.to_dict()
444            # Try to find node data in the row
445            for key, value in row.items():
446                if isinstance(value, dict):
447                    # Check for _id/_label keys (uni-db node dict)
448                    if "_id" in value and "_label" in value:
449                        instance = self._result_to_model(value, result_type)
450                        if instance is not None:
451                            mapped.append(instance)
452                            break
453                    # Also check if _label matches registered model
454                    elif "_label" in value:
455                        label = value["_label"]
456                        if label in self._schema_gen._node_models:
457                            model = self._schema_gen._node_models[label]
458                            instance = self._result_to_model(value, model)
459                            if instance is not None:
460                                mapped.append(instance)
461                                break
462            else:
463                # Try the first column
464                first_value = next(iter(row.values()), None)
465                if isinstance(first_value, dict):
466                    instance = self._result_to_model(first_value, result_type)
467                    if instance is not None:
468                        mapped.append(instance)
469
470        return mapped
471
472    @staticmethod
473    def _validate_edge_endpoints(
474        source: UniNode, target: UniNode
475    ) -> tuple[int, int, str, str]:
476        """Validate that both endpoints are persisted and return (src_vid, dst_vid, src_label, dst_label)."""
477        if not source.is_persisted:
478            raise NotPersisted(source)
479        if not target.is_persisted:
480            raise NotPersisted(target)
481        return (
482            source._vid,
483            target._vid,
484            source.__class__.__label__,
485            target.__class__.__label__,
486        )
487
488    @staticmethod
489    def _normalize_edge_properties(
490        properties: dict[str, Any] | UniEdge | None,
491    ) -> dict[str, Any]:
492        """Normalize edge properties from dict, UniEdge, or None."""
493        if isinstance(properties, UniEdge):
494            return properties.to_properties()
495        if properties:
496            return properties
497        return {}
498
499    def create_edge(
500        self,
501        source: UniNode,
502        edge_type: str,
503        target: UniNode,
504        properties: dict[str, Any] | UniEdge | None = None,
505    ) -> None:
506        """Create an edge between two nodes."""
507        src_vid, dst_vid, src_label, dst_label = self._validate_edge_endpoints(
508            source, target
509        )
510        props = self._normalize_edge_properties(properties)
511
512        # Build CREATE edge query with labels (required by Cypher implementation)
513        props_str = ", ".join(f"{k}: ${k}" for k in props)
514        if props_str:
515            cypher = f"MATCH (a:{src_label}), (b:{dst_label}) WHERE a._vid = $src AND b._vid = $dst CREATE (a)-[r:{edge_type} {{{props_str}}}]->(b)"
516        else:
517            cypher = f"MATCH (a:{src_label}), (b:{dst_label}) WHERE a._vid = $src AND b._vid = $dst CREATE (a)-[r:{edge_type}]->(b)"
518
519        params = {"src": src_vid, "dst": dst_vid, **props}
520        with self._db_session.tx() as tx:
521            tx.execute(cypher, params)
522            tx.commit()
523
524    def delete_edge(
525        self,
526        source: UniNode,
527        edge_type: str,
528        target: UniNode,
529    ) -> int:
530        """Delete edges between two nodes. Returns the number of deleted edges."""
531        src_vid, dst_vid, src_label, dst_label = self._validate_edge_endpoints(
532            source, target
533        )
534        cypher = (
535            f"MATCH (a:{src_label})-[r:{edge_type}]->(b:{dst_label}) "
536            f"WHERE a._vid = $src AND b._vid = $dst "
537            f"DELETE r RETURN count(r) as count"
538        )
539        with self._db_session.tx() as tx:
540            results = tx.query(cypher, {"src": src_vid, "dst": dst_vid})
541            tx.commit()
542        return cast(int, results[0]["count"]) if results else 0
543
544    def update_edge(
545        self,
546        source: UniNode,
547        edge_type: str,
548        target: UniNode,
549        properties: dict[str, Any],
550    ) -> int:
551        """Update properties on edges between two nodes. Returns the number of updated edges."""
552        src_vid, dst_vid, src_label, dst_label = self._validate_edge_endpoints(
553            source, target
554        )
555        set_parts = [f"r.{k} = ${k}" for k in properties]
556        params: dict[str, Any] = {"src": src_vid, "dst": dst_vid, **properties}
557        cypher = (
558            f"MATCH (a:{src_label})-[r:{edge_type}]->(b:{dst_label}) "
559            f"WHERE a._vid = $src AND b._vid = $dst "
560            f"SET {', '.join(set_parts)} "
561            f"RETURN count(r) as count"
562        )
563        with self._db_session.tx() as tx:
564            results = tx.query(cypher, params)
565            tx.commit()
566        return cast(int, results[0]["count"]) if results else 0
567
568    def get_edge(
569        self,
570        source: UniNode,
571        edge_type: str,
572        target: UniNode,
573        edge_model: type[EdgeT] | None = None,
574    ) -> list[dict[str, Any]] | list[EdgeT]:
575        """Get edges between two nodes. Returns dicts or edge model instances."""
576        src_vid, dst_vid, src_label, dst_label = self._validate_edge_endpoints(
577            source, target
578        )
579        cypher = (
580            f"MATCH (a:{src_label})-[r:{edge_type}]->(b:{dst_label}) "
581            f"WHERE a._vid = $src AND b._vid = $dst "
582            f"RETURN properties(r) AS _props, id(r) AS _eid"
583        )
584        results = self._db_session.query(cypher, {"src": src_vid, "dst": dst_vid})
585        rows = [r.to_dict() for r in results]
586
587        if edge_model is None:
588            edge_dicts: list[dict[str, Any]] = []
589            for row in rows:
590                props = row.get("_props", {})
591                if isinstance(props, dict):
592                    edge_dict = dict(props)
593                    edge_dict["_eid"] = row.get("_eid")
594                    edge_dicts.append(edge_dict)
595            return edge_dicts
596
597        edges = []
598        for row in rows:
599            r_data = row.get("_props", {})
600            if isinstance(r_data, dict):
601                edge = edge_model.from_properties(
602                    r_data,
603                    src_vid=src_vid,
604                    dst_vid=dst_vid,
605                    session=self,
606                )
607                edges.append(edge)
608        return edges
609
610    def bulk_add(self, entities: Sequence[UniNode]) -> list[int]:
611        """
612        Bulk-add entities using bulk_writer for performance.
613
614        Groups entities by label and uses db.bulk_writer().
615        Returns VIDs and attaches sessions.
616
617        Args:
618            entities: Sequence of UniNode instances to bulk-insert.
619
620        Returns:
621            List of assigned vertex IDs.
622
623        Raises:
624            BulkLoadError: If bulk insertion fails.
625        """
626        if not entities:
627            return []
628
629        # Group by label
630        by_label: dict[str, list[UniNode]] = {}
631        for entity in entities:
632            label = entity.__class__.__label__
633            if label not in by_label:
634                by_label[label] = []
635            by_label[label].append(entity)
636
637        all_vids: list[int] = []
638        try:
639            for label, group in by_label.items():
640                # Run before_create hooks
641                for entity in group:
642                    run_hooks(entity, _BEFORE_CREATE)
643
644                # Convert to property dicts
645                prop_dicts = [e.to_properties() for e in group]
646
647                # Bulk insert via transaction
648                tx = self._db_session.tx()
649                with tx.bulk_writer().build() as bw:
650                    vids = bw.insert_vertices(label, prop_dicts)
651                    bw.commit()
652                tx.commit()
653
654                # Attach sessions and record VIDs
655                for entity, vid in zip(group, vids):
656                    entity._attach_session(self, vid)
657                    self._identity_map[(label, vid)] = entity
658                    run_hooks(entity, _AFTER_CREATE)
659                    entity._mark_clean()
660
661                all_vids.extend(vids)
662        except Exception as e:
663            raise BulkLoadError(f"Bulk insert failed: {e}") from e
664
665        return all_vids
666
667    def explain(self, cypher: str) -> uni_db.ExplainOutput:
668        """Get the query execution plan without running it."""
669        return self._db_session.explain(cypher)
670
671    def profile(self, cypher: str) -> tuple[uni_db.QueryResult, uni_db.ProfileOutput]:
672        """Run the query with profiling and return results + stats."""
673        return self._db_session.profile(cypher)
674
675    def save_schema(self, path: str) -> None:
676        """Save the database schema to a file."""
677        self._db.save_schema(path)
678
679    def load_schema(self, path: str) -> None:
680        """Load a database schema from a file."""
681        self._db.load_schema(path)
682
683    # -------------------------------------------------------------------------
684    # Internal Methods
685    # -------------------------------------------------------------------------
686
687    def _create_node(self, entity: UniNode) -> None:
688        """Create a node in the database."""
689        # Run before_create hooks
690        run_hooks(entity, _BEFORE_CREATE)
691
692        label = entity.__class__.__label__
693        props = entity.to_properties()
694
695        # Build CREATE query
696        props_str = ", ".join(f"{k}: ${k}" for k in props)
697        cypher = f"CREATE (n:{label} {{{props_str}}}) RETURN id(n) as vid"
698
699        with self._db_session.tx() as tx:
700            results = tx.query(cypher, props)
701            tx.commit()
702        if results:
703            vid = results[0]["vid"]
704            entity._attach_session(self, vid)
705
706            # Add to identity map
707            self._identity_map[(label, vid)] = entity
708
709        # Run after_create hooks
710        run_hooks(entity, _AFTER_CREATE)
711        entity._mark_clean()
712
713    def _create_node_in_tx(self, entity: UniNode, tx: uni_db.Transaction) -> None:
714        """Create a node within a transaction."""
715        run_hooks(entity, _BEFORE_CREATE)
716
717        label = entity.__class__.__label__
718        props = entity.to_properties()
719
720        props_str = ", ".join(f"{k}: ${k}" for k in props)
721        cypher = f"CREATE (n:{label} {{{props_str}}}) RETURN id(n) as vid"
722
723        results = tx.query(cypher, props)
724        if results:
725            vid = results[0]["vid"]
726            entity._attach_session(self, vid)
727            self._identity_map[(label, vid)] = entity
728
729        run_hooks(entity, _AFTER_CREATE)
730
731    def _create_edge_in_tx(
732        self,
733        source: UniNode,
734        edge_type: str,
735        target: UniNode,
736        properties: UniEdge | None,
737        tx: uni_db.Transaction,
738    ) -> None:
739        """Create an edge within a transaction."""
740        props = properties.to_properties() if properties else {}
741        src_label = source.__class__.__label__
742        dst_label = target.__class__.__label__
743
744        props_str = ", ".join(f"{k}: ${k}" for k in props)
745        if props_str:
746            cypher = f"MATCH (a:{src_label}), (b:{dst_label}) WHERE a._vid = $src AND b._vid = $dst CREATE (a)-[:{edge_type} {{{props_str}}}]->(b)"
747        else:
748            cypher = f"MATCH (a:{src_label}), (b:{dst_label}) WHERE a._vid = $src AND b._vid = $dst CREATE (a)-[:{edge_type}]->(b)"
749
750        params = {"src": source._vid, "dst": target._vid, **props}
751        tx.query(cypher, params)
752
753    def _update_node(self, entity: UniNode) -> None:
754        """Update a node in the database."""
755        run_hooks(entity, _BEFORE_UPDATE)
756
757        label = entity.__class__.__label__
758
759        # Convert dirty prop values via python_to_db_value
760        try:
761            hints = get_type_hints(type(entity))
762        except Exception:
763            hints = {}
764
765        dirty_props = {}
766        for name in entity._dirty:
767            value = getattr(entity, name)
768            if name in hints:
769                value = python_to_db_value(value, hints[name])
770            dirty_props[name] = value
771
772        if not dirty_props:
773            return
774
775        set_clause = ", ".join(f"n.{k} = ${k}" for k in dirty_props)
776        cypher = f"MATCH (n:{label}) WHERE id(n) = $vid SET {set_clause}"
777        params = {"vid": entity._vid, **dirty_props}
778
779        with self._db_session.tx() as tx:
780            tx.execute(cypher, params)
781            tx.commit()
782
783        run_hooks(entity, _AFTER_UPDATE)
784        entity._mark_clean()
785
786    def _delete_node(self, entity: UniNode) -> None:
787        """Delete a node from the database."""
788        run_hooks(entity, _BEFORE_DELETE)
789
790        label = entity.__class__.__label__
791        vid = entity._vid
792
793        # DETACH DELETE to also remove connected edges
794        cypher = f"MATCH (n:{label}) WHERE id(n) = $vid DETACH DELETE n"
795        with self._db_session.tx() as tx:
796            tx.execute(cypher, {"vid": vid})
797            tx.commit()
798
799        # Remove from identity map
800        if vid is not None and (label, vid) in self._identity_map:
801            del self._identity_map[(label, vid)]
802
803        # Clear entity IDs
804        entity._vid = None
805        entity._uid = None
806        entity._session = None
807
808        run_hooks(entity, _AFTER_DELETE)
809
810    def _result_to_model(
811        self,
812        data: dict[str, Any],
813        model: type[NodeT],
814    ) -> NodeT | None:
815        """Convert a query result row to a model instance.
816
817        Does not mutate the input dict.
818        """
819        if not data:
820            return None
821
822        # Work on a copy
823        data = dict(data)
824
825        # Run before_load hooks
826        data = run_class_hooks(model, _BEFORE_LOAD, data) or data
827
828        # Extract _id → vid (uni-db returns _id as string or int)
829        vid = data.pop("_id", None)
830        if vid is None:
831            vid = data.pop("_vid", None)
832        if vid is None:
833            vid = data.pop("vid", None)
834        if vid is not None and not isinstance(vid, int):
835            vid = int(vid)
836
837        # Remove _label (informational)
838        data.pop("_label", None)
839
840        try:
841            instance = cast(
842                NodeT,
843                model.from_properties(
844                    data,
845                    vid=vid,
846                    session=self,
847                ),
848            )
849        except ValidationError as exc:
850            _warn_unhydratable(model, vid, exc)
851            return None
852
853        # Add to identity map if we have a vid
854        if vid is not None:
855            existing = self._identity_map.get((model.__label__, vid))
856            if existing is not None:
857                return cast(NodeT, existing)
858            self._identity_map[(model.__label__, vid)] = instance
859
860        # Run after_load hooks
861        run_hooks(instance, _AFTER_LOAD)
862
863        return instance
864
865    def _load_relationship(
866        self,
867        entity: UniNode,
868        descriptor: RelationshipDescriptor[Any],
869    ) -> list[UniNode] | UniNode | None:
870        """Load a relationship for an entity."""
871        if not entity.is_persisted:
872            raise NotPersisted(entity)
873
874        config = descriptor.config
875        label = entity.__class__.__label__
876        pattern = _edge_pattern(config.edge_type, config.direction)
877
878        cypher = (
879            f"MATCH (a:{label}){pattern}(b) WHERE id(a) = $vid "
880            f"RETURN properties(b) AS _props, id(b) AS _vid, labels(b) AS _labels"
881        )
882        results = self._db_session.query(cypher, {"vid": entity._vid})
883
884        nodes = []
885        for raw_row in results:
886            row = raw_row.to_dict()
887            node_data = _row_to_node_dict(row)
888            if node_data is None:
889                continue
890            # Try to find the model for this node
891            node_label = node_data.get("_label")
892            if node_label and node_label in self._schema_gen._node_models:
893                model = self._schema_gen._node_models[node_label]
894                instance = self._result_to_model(node_data, model)
895                if instance is not None:
896                    nodes.append(instance)
897
898        if not descriptor.is_list:
899            return nodes[0] if nodes else None
900        return nodes
901
902    def _eager_load_relationships(
903        self,
904        entities: list[NodeT],
905        relationships: list[str],
906    ) -> None:
907        """Eager load relationships for a list of entities."""
908        if not entities:
909            return
910
911        model = type(entities[0])
912        rel_configs = model.get_relationship_fields()
913
914        for rel_name in relationships:
915            if rel_name not in rel_configs:
916                continue
917
918            config = rel_configs[rel_name]
919            label = model.__label__
920            vids = [e._vid for e in entities if e._vid is not None]
921
922            if not vids:
923                continue
924
925            pattern = _edge_pattern(config.edge_type, config.direction)
926            cypher = (
927                f"MATCH (a:{label}){pattern}(b) WHERE id(a) IN $vids "
928                f"RETURN id(a) as src_vid, properties(b) AS _props, id(b) AS _vid, labels(b) AS _labels"
929            )
930            results = self._db_session.query(cypher, {"vids": vids})
931
932            # Hydrate, exactly as the lazy path does.
933            #
934            # These rows used to be cached as raw result dicts.
935            # `RelationshipDescriptor.__get__` returns the cache verbatim, so
936            # eager loading handed back `list[dict]` where lazy loading hands
937            # back `list[Model]` -- `user.posts[0].title` raised where the same
938            # access worked without `.eager_load()`.
939            descriptor = getattr(model, rel_name, None)
940            is_list = getattr(descriptor, "is_list", True)
941
942            by_source: dict[int, list[Any]] = {}
943            for raw_row in results:
944                row = raw_row.to_dict()
945                src_vid = row["src_vid"]
946                node_data = _row_to_node_dict(row)
947                if node_data is None:
948                    continue
949                node_label = node_data.get("_label")
950                if not node_label or node_label not in self._schema_gen._node_models:
951                    continue
952                target_model = self._schema_gen._node_models[node_label]
953                instance = self._result_to_model(node_data, target_model)
954                if instance is None:
955                    continue
956                by_source.setdefault(src_vid, []).append(instance)
957
958            # Every entity gets a cache entry, including the ones with nothing
959            # attached. Leaving those unset sends the descriptor down the lazy
960            # path on first access -- which on an async session raises, telling
961            # the caller to use the `eager_load()` they already used.
962            cache_attr = f"_rel_cache_{rel_name}"
963            for entity in entities:
964                related = by_source.get(entity._vid, [])
965                if is_list:
966                    setattr(entity, cache_attr, related)
967                else:
968                    setattr(entity, cache_attr, related[0] if related else None)

Session for interacting with the graph database using Pydantic models.

The session manages model registration, schema synchronization, and provides CRUD operations and query building.

Example:

from uni_db import Uni from uni_pydantic import UniSession

db = Uni("./my_graph") session = UniSession(db) session.register(Person, Company) session.sync_schema()

alice = Person(name="Alice", age=30) session.add(alice) session.commit()

UniSession(db: Uni)
184    def __init__(self, db: uni_db.Uni) -> None:
185        self._db = db
186        self._db_session = db.session()
187        self._schema_gen = SchemaGenerator()
188        self._identity_map: WeakValueDictionary[tuple[str, int], UniNode] = (
189            WeakValueDictionary()
190        )
191        self._pending_new: list[UniNode] = []
192        self._pending_delete: list[UniNode] = []
def close(self) -> None:
205    def close(self) -> None:
206        """Close the session and clear all pending state."""
207        self._pending_new.clear()
208        self._pending_delete.clear()

Close the session and clear all pending state.

db: Uni
210    @property
211    def db(self) -> uni_db.Uni:
212        """Access the underlying uni_db.Uni for low-level operations."""
213        return self._db

Access the underlying uni_db.Uni for low-level operations.

def locy( self, program: str, params: dict[str, typing.Any] | None = None) -> LocyResult:
215    def locy(
216        self, program: str, params: dict[str, Any] | None = None
217    ) -> uni_db.LocyResult:
218        """
219        Evaluate a Locy program and return derived facts, stats, and warnings.
220
221        Delegates to the underlying ``uni_db.Session.locy()``.
222        """
223        return self._db_session.locy(program, params)

Evaluate a Locy program and return derived facts, stats, and warnings.

Delegates to the underlying uni_db.Session.locy().

def register( self, *models: type[UniNode] | type[UniEdge]) -> None:
225    def register(self, *models: type[UniNode] | type[UniEdge]) -> None:
226        """
227        Register model classes with the session.
228
229        Registered models can be used for schema generation and queries.
230
231        Args:
232            *models: UniNode or UniEdge subclasses to register.
233        """
234        self._schema_gen.register(*models)

Register model classes with the session.

Registered models can be used for schema generation and queries.

Args: *models: UniNode or UniEdge subclasses to register.

def sync_schema(self) -> None:
236    def sync_schema(self) -> None:
237        """
238        Synchronize database schema with registered models.
239
240        Creates labels, edge types, properties, and indexes as needed.
241        This is additive-only; it won't remove existing schema elements.
242        """
243        self._schema_gen.apply_to_database(self._db)

Synchronize database schema with registered models.

Creates labels, edge types, properties, and indexes as needed. This is additive-only; it won't remove existing schema elements.

def query(self, model: type[~NodeT]) -> QueryBuilder[~NodeT]:
245    def query(self, model: type[NodeT]) -> QueryBuilder[NodeT]:
246        """
247        Create a query builder for the given model.
248
249        Args:
250            model: The UniNode subclass to query.
251
252        Returns:
253            A QueryBuilder for constructing queries.
254        """
255        return QueryBuilder(self, model)

Create a query builder for the given model.

Args: model: The UniNode subclass to query.

Returns: A QueryBuilder for constructing queries.

def add(self, entity: UniNode) -> None:
257    def add(self, entity: UniNode) -> None:
258        """
259        Add a new entity to be persisted.
260
261        The entity will be inserted on the next commit().
262        """
263        if entity.is_persisted:
264            raise SessionError(f"Entity {entity!r} is already persisted")
265        entity._session = self
266        self._pending_new.append(entity)

Add a new entity to be persisted.

The entity will be inserted on the next commit().

def add_all(self, entities: Sequence[UniNode]) -> None:
268    def add_all(self, entities: Sequence[UniNode]) -> None:
269        """Add multiple entities to be persisted."""
270        for entity in entities:
271            self.add(entity)

Add multiple entities to be persisted.

def delete(self, entity: UniNode) -> None:
273    def delete(self, entity: UniNode) -> None:
274        """Mark an entity for deletion."""
275        if not entity.is_persisted:
276            raise NotPersisted(entity)
277        self._pending_delete.append(entity)

Mark an entity for deletion.

def get( self, model: type[~NodeT], vid: int | None = None, uid: str | None = None, **kwargs: Any) -> Optional[~NodeT]:
279    def get(
280        self,
281        model: type[NodeT],
282        vid: int | None = None,
283        uid: str | None = None,
284        **kwargs: Any,
285    ) -> NodeT | None:
286        """
287        Get an entity by ID or unique properties.
288
289        Args:
290            model: The model type to retrieve.
291            vid: Vertex ID to look up.
292            uid: Unique ID to look up.
293            **kwargs: Property equality filters.
294
295        Returns:
296            The model instance or None if not found.
297        """
298        # Check identity map first
299        if vid is not None:
300            cached = self._identity_map.get((model.__label__, vid))
301            if cached is not None:
302                return cached  # type: ignore[return-value]
303
304        # Build query
305        label = model.__label__
306        params: dict[str, Any] = {}
307
308        if vid is not None:
309            cypher = f"MATCH (n:{label}) WHERE id(n) = $vid RETURN {_NODE_RETURN}"
310            params["vid"] = vid
311        elif uid is not None:
312            cypher = f"MATCH (n:{label}) WHERE n._uid = $uid RETURN {_NODE_RETURN}"
313            params["uid"] = uid
314        elif kwargs:
315            # Validate property names
316            for k in kwargs:
317                _validate_property(k, model)
318            conditions = [f"n.{k} = ${k}" for k in kwargs]
319            cypher = f"MATCH (n:{label}) WHERE {' AND '.join(conditions)} RETURN {_NODE_RETURN} LIMIT 1"
320            params.update(kwargs)
321        else:
322            raise ValueError("Must provide vid, uid, or property filters")
323
324        results = self._db_session.query(cypher, params)
325        if not results:
326            return None
327
328        node_data = _row_to_node_dict(results[0].to_dict())
329        if node_data is None:
330            return None
331        return self._result_to_model(node_data, model)

Get an entity by ID or unique properties.

Args: model: The model type to retrieve. vid: Vertex ID to look up. uid: Unique ID to look up. **kwargs: Property equality filters.

Returns: The model instance or None if not found.

def refresh(self, entity: UniNode) -> None:
333    def refresh(self, entity: UniNode) -> None:
334        """Refresh an entity's properties from the database."""
335        if not entity.is_persisted:
336            raise NotPersisted(entity)
337
338        label = entity.__class__.__label__
339        cypher = f"MATCH (n:{label}) WHERE id(n) = $vid RETURN {_NODE_RETURN}"
340        results = self._db_session.query(cypher, {"vid": entity._vid})
341
342        if not results:
343            raise SessionError(f"Entity with vid={entity._vid} no longer exists")
344
345        # Update properties
346        props = _row_to_node_dict(results[0].to_dict())
347        if props is None:
348            raise SessionError(f"Entity with vid={entity._vid} no longer exists")
349        try:
350            hints = get_type_hints(type(entity))
351        except Exception:
352            hints = {}
353
354        for field_name in entity.get_property_fields():
355            if field_name in props:
356                value = props[field_name]
357                if field_name in hints:
358                    value = db_to_python_value(value, hints[field_name])
359                setattr(entity, field_name, value)
360
361        entity._mark_clean()

Refresh an entity's properties from the database.

def commit(self) -> None:
363    def commit(self) -> None:
364        """
365        Commit all pending changes to the database.
366
367        This persists new entities, updates dirty entities,
368        and deletes marked entities.
369        """
370        # Insert new entities
371        for entity in self._pending_new:
372            self._create_node(entity)
373
374        # Update dirty entities in identity map
375        for (label, vid), entity in list(self._identity_map.items()):
376            if entity.is_dirty and entity.is_persisted:
377                self._update_node(entity)
378
379        # Delete marked entities
380        for entity in self._pending_delete:
381            self._delete_node(entity)
382
383        # Flush to storage
384        self._db.flush()
385
386        # Clear pending lists
387        self._pending_new.clear()
388        self._pending_delete.clear()

Commit all pending changes to the database.

This persists new entities, updates dirty entities, and deletes marked entities.

def rollback(self) -> None:
390    def rollback(self) -> None:
391        """Discard all pending changes."""
392        # Clear pending new — detach entities
393        for entity in self._pending_new:
394            entity._session = None
395        self._pending_new.clear()
396
397        # Clear pending deletes
398        self._pending_delete.clear()
399
400        # Invalidate dirty identity map entries
401        for entity in list(self._identity_map.values()):
402            if entity.is_dirty:
403                self.refresh(entity)

Discard all pending changes.

@contextmanager
def transaction(self) -> Iterator[UniTransaction]:
405    @contextmanager
406    def transaction(self) -> Iterator[UniTransaction]:
407        """Create a transaction context."""
408        tx = UniTransaction(self)
409        with tx:
410            yield tx

Create a transaction context.

def begin(self) -> UniTransaction:
412    def begin(self) -> UniTransaction:
413        """Begin a new transaction."""
414        tx = UniTransaction(self)
415        tx._tx = self._db_session.tx()
416        return tx

Begin a new transaction.

def cypher( self, query: str, params: dict[str, typing.Any] | None = None, result_type: type[~NodeT] | None = None) -> list[~NodeT] | list[dict[str, typing.Any]]:
418    def cypher(
419        self,
420        query: str,
421        params: dict[str, Any] | None = None,
422        result_type: type[NodeT] | None = None,
423    ) -> list[NodeT] | list[dict[str, Any]]:
424        """
425        Execute a raw Cypher query.
426
427        Args:
428            query: Cypher query string.
429            params: Query parameters.
430            result_type: Optional model type for result mapping.
431
432        Returns:
433            List of results (model instances if result_type provided).
434        """
435        results = self._db_session.query(query, params)
436
437        if result_type is None:
438            return [r.to_dict() for r in results]
439
440        # Map results to model instances
441        mapped = []
442        for raw_row in results:
443            row = raw_row.to_dict()
444            # Try to find node data in the row
445            for key, value in row.items():
446                if isinstance(value, dict):
447                    # Check for _id/_label keys (uni-db node dict)
448                    if "_id" in value and "_label" in value:
449                        instance = self._result_to_model(value, result_type)
450                        if instance is not None:
451                            mapped.append(instance)
452                            break
453                    # Also check if _label matches registered model
454                    elif "_label" in value:
455                        label = value["_label"]
456                        if label in self._schema_gen._node_models:
457                            model = self._schema_gen._node_models[label]
458                            instance = self._result_to_model(value, model)
459                            if instance is not None:
460                                mapped.append(instance)
461                                break
462            else:
463                # Try the first column
464                first_value = next(iter(row.values()), None)
465                if isinstance(first_value, dict):
466                    instance = self._result_to_model(first_value, result_type)
467                    if instance is not None:
468                        mapped.append(instance)
469
470        return mapped

Execute a raw Cypher query.

Args: query: Cypher query string. params: Query parameters. result_type: Optional model type for result mapping.

Returns: List of results (model instances if result_type provided).

def create_edge( self, source: UniNode, edge_type: str, target: UniNode, properties: dict[str, typing.Any] | UniEdge | None = None) -> None:
499    def create_edge(
500        self,
501        source: UniNode,
502        edge_type: str,
503        target: UniNode,
504        properties: dict[str, Any] | UniEdge | None = None,
505    ) -> None:
506        """Create an edge between two nodes."""
507        src_vid, dst_vid, src_label, dst_label = self._validate_edge_endpoints(
508            source, target
509        )
510        props = self._normalize_edge_properties(properties)
511
512        # Build CREATE edge query with labels (required by Cypher implementation)
513        props_str = ", ".join(f"{k}: ${k}" for k in props)
514        if props_str:
515            cypher = f"MATCH (a:{src_label}), (b:{dst_label}) WHERE a._vid = $src AND b._vid = $dst CREATE (a)-[r:{edge_type} {{{props_str}}}]->(b)"
516        else:
517            cypher = f"MATCH (a:{src_label}), (b:{dst_label}) WHERE a._vid = $src AND b._vid = $dst CREATE (a)-[r:{edge_type}]->(b)"
518
519        params = {"src": src_vid, "dst": dst_vid, **props}
520        with self._db_session.tx() as tx:
521            tx.execute(cypher, params)
522            tx.commit()

Create an edge between two nodes.

def delete_edge( self, source: UniNode, edge_type: str, target: UniNode) -> int:
524    def delete_edge(
525        self,
526        source: UniNode,
527        edge_type: str,
528        target: UniNode,
529    ) -> int:
530        """Delete edges between two nodes. Returns the number of deleted edges."""
531        src_vid, dst_vid, src_label, dst_label = self._validate_edge_endpoints(
532            source, target
533        )
534        cypher = (
535            f"MATCH (a:{src_label})-[r:{edge_type}]->(b:{dst_label}) "
536            f"WHERE a._vid = $src AND b._vid = $dst "
537            f"DELETE r RETURN count(r) as count"
538        )
539        with self._db_session.tx() as tx:
540            results = tx.query(cypher, {"src": src_vid, "dst": dst_vid})
541            tx.commit()
542        return cast(int, results[0]["count"]) if results else 0

Delete edges between two nodes. Returns the number of deleted edges.

def update_edge( self, source: UniNode, edge_type: str, target: UniNode, properties: dict[str, typing.Any]) -> int:
544    def update_edge(
545        self,
546        source: UniNode,
547        edge_type: str,
548        target: UniNode,
549        properties: dict[str, Any],
550    ) -> int:
551        """Update properties on edges between two nodes. Returns the number of updated edges."""
552        src_vid, dst_vid, src_label, dst_label = self._validate_edge_endpoints(
553            source, target
554        )
555        set_parts = [f"r.{k} = ${k}" for k in properties]
556        params: dict[str, Any] = {"src": src_vid, "dst": dst_vid, **properties}
557        cypher = (
558            f"MATCH (a:{src_label})-[r:{edge_type}]->(b:{dst_label}) "
559            f"WHERE a._vid = $src AND b._vid = $dst "
560            f"SET {', '.join(set_parts)} "
561            f"RETURN count(r) as count"
562        )
563        with self._db_session.tx() as tx:
564            results = tx.query(cypher, params)
565            tx.commit()
566        return cast(int, results[0]["count"]) if results else 0

Update properties on edges between two nodes. Returns the number of updated edges.

def get_edge( self, source: UniNode, edge_type: str, target: UniNode, edge_model: type[~EdgeT] | None = None) -> list[dict[str, typing.Any]] | list[~EdgeT]:
568    def get_edge(
569        self,
570        source: UniNode,
571        edge_type: str,
572        target: UniNode,
573        edge_model: type[EdgeT] | None = None,
574    ) -> list[dict[str, Any]] | list[EdgeT]:
575        """Get edges between two nodes. Returns dicts or edge model instances."""
576        src_vid, dst_vid, src_label, dst_label = self._validate_edge_endpoints(
577            source, target
578        )
579        cypher = (
580            f"MATCH (a:{src_label})-[r:{edge_type}]->(b:{dst_label}) "
581            f"WHERE a._vid = $src AND b._vid = $dst "
582            f"RETURN properties(r) AS _props, id(r) AS _eid"
583        )
584        results = self._db_session.query(cypher, {"src": src_vid, "dst": dst_vid})
585        rows = [r.to_dict() for r in results]
586
587        if edge_model is None:
588            edge_dicts: list[dict[str, Any]] = []
589            for row in rows:
590                props = row.get("_props", {})
591                if isinstance(props, dict):
592                    edge_dict = dict(props)
593                    edge_dict["_eid"] = row.get("_eid")
594                    edge_dicts.append(edge_dict)
595            return edge_dicts
596
597        edges = []
598        for row in rows:
599            r_data = row.get("_props", {})
600            if isinstance(r_data, dict):
601                edge = edge_model.from_properties(
602                    r_data,
603                    src_vid=src_vid,
604                    dst_vid=dst_vid,
605                    session=self,
606                )
607                edges.append(edge)
608        return edges

Get edges between two nodes. Returns dicts or edge model instances.

def bulk_add(self, entities: Sequence[UniNode]) -> list[int]:
610    def bulk_add(self, entities: Sequence[UniNode]) -> list[int]:
611        """
612        Bulk-add entities using bulk_writer for performance.
613
614        Groups entities by label and uses db.bulk_writer().
615        Returns VIDs and attaches sessions.
616
617        Args:
618            entities: Sequence of UniNode instances to bulk-insert.
619
620        Returns:
621            List of assigned vertex IDs.
622
623        Raises:
624            BulkLoadError: If bulk insertion fails.
625        """
626        if not entities:
627            return []
628
629        # Group by label
630        by_label: dict[str, list[UniNode]] = {}
631        for entity in entities:
632            label = entity.__class__.__label__
633            if label not in by_label:
634                by_label[label] = []
635            by_label[label].append(entity)
636
637        all_vids: list[int] = []
638        try:
639            for label, group in by_label.items():
640                # Run before_create hooks
641                for entity in group:
642                    run_hooks(entity, _BEFORE_CREATE)
643
644                # Convert to property dicts
645                prop_dicts = [e.to_properties() for e in group]
646
647                # Bulk insert via transaction
648                tx = self._db_session.tx()
649                with tx.bulk_writer().build() as bw:
650                    vids = bw.insert_vertices(label, prop_dicts)
651                    bw.commit()
652                tx.commit()
653
654                # Attach sessions and record VIDs
655                for entity, vid in zip(group, vids):
656                    entity._attach_session(self, vid)
657                    self._identity_map[(label, vid)] = entity
658                    run_hooks(entity, _AFTER_CREATE)
659                    entity._mark_clean()
660
661                all_vids.extend(vids)
662        except Exception as e:
663            raise BulkLoadError(f"Bulk insert failed: {e}") from e
664
665        return all_vids

Bulk-add entities using bulk_writer for performance.

Groups entities by label and uses db.bulk_writer(). Returns VIDs and attaches sessions.

Args: entities: Sequence of UniNode instances to bulk-insert.

Returns: List of assigned vertex IDs.

Raises: BulkLoadError: If bulk insertion fails.

def explain(self, cypher: str) -> ExplainOutput:
667    def explain(self, cypher: str) -> uni_db.ExplainOutput:
668        """Get the query execution plan without running it."""
669        return self._db_session.explain(cypher)

Get the query execution plan without running it.

def profile(self, cypher: str) -> tuple[QueryResult, ProfileOutput]:
671    def profile(self, cypher: str) -> tuple[uni_db.QueryResult, uni_db.ProfileOutput]:
672        """Run the query with profiling and return results + stats."""
673        return self._db_session.profile(cypher)

Run the query with profiling and return results + stats.

def save_schema(self, path: str) -> None:
675    def save_schema(self, path: str) -> None:
676        """Save the database schema to a file."""
677        self._db.save_schema(path)

Save the database schema to a file.

def load_schema(self, path: str) -> None:
679    def load_schema(self, path: str) -> None:
680        """Load a database schema from a file."""
681        self._db.load_schema(path)

Load a database schema from a file.

class UniTransaction:
 64class UniTransaction:
 65    """
 66    Transaction context for atomic operations.
 67
 68    Provides commit/rollback semantics for a group of operations.
 69
 70    Example:
 71        >>> with session.transaction() as tx:
 72        ...     alice = Person(name="Alice")
 73        ...     tx.add(alice)
 74        ...     # Auto-commits on success, rolls back on exception
 75    """
 76
 77    def __init__(self, session: UniSession) -> None:
 78        self._session = session
 79        self._tx: uni_db.Transaction | None = None
 80        self._pending_nodes: list[UniNode] = []
 81        self._pending_edges: list[tuple[UniNode, str, UniNode, UniEdge | None]] = []
 82        self._committed = False
 83        self._rolled_back = False
 84
 85    def __enter__(self) -> UniTransaction:
 86        self._tx = self._session._db_session.tx()
 87        return self
 88
 89    def __exit__(
 90        self,
 91        exc_type: type[BaseException] | None,
 92        exc_val: BaseException | None,
 93        exc_tb: TracebackType | None,
 94    ) -> None:
 95        if exc_type is not None:
 96            self.rollback()
 97            return
 98        if not self._committed and not self._rolled_back:
 99            self.commit()
100
101    def add(self, entity: UniNode) -> None:
102        """Add a node to be created in this transaction."""
103        self._pending_nodes.append(entity)
104
105    def create_edge(
106        self,
107        source: UniNode,
108        edge_type: str,
109        target: UniNode,
110        properties: UniEdge | None = None,
111        **kwargs: Any,
112    ) -> None:
113        """Create an edge between two nodes in this transaction."""
114        if not source.is_persisted:
115            raise NotPersisted(source)
116        if not target.is_persisted:
117            raise NotPersisted(target)
118        self._pending_edges.append((source, edge_type, target, properties))
119
120    def commit(self) -> None:
121        """Commit the transaction."""
122        if self._committed:
123            raise TransactionError("Transaction already committed")
124        if self._rolled_back:
125            raise TransactionError("Transaction already rolled back")
126
127        if self._tx is None:
128            raise TransactionError("Transaction not started")
129
130        try:
131            # Create pending nodes
132            for node in self._pending_nodes:
133                self._session._create_node_in_tx(node, self._tx)
134
135            # Create pending edges
136            for source, edge_type, target, props in self._pending_edges:
137                self._session._create_edge_in_tx(
138                    source, edge_type, target, props, self._tx
139                )
140
141            self._tx.commit()
142            self._committed = True
143
144            # Mark nodes as clean
145            for node in self._pending_nodes:
146                node._mark_clean()
147
148        except Exception as e:
149            self.rollback()
150            raise TransactionError(f"Commit failed: {e}") from e
151
152    def rollback(self) -> None:
153        """Rollback the transaction."""
154        if self._rolled_back:
155            return
156        if self._tx is not None:
157            self._tx.rollback()
158        self._rolled_back = True
159        self._pending_nodes.clear()
160        self._pending_edges.clear()

Transaction context for atomic operations.

Provides commit/rollback semantics for a group of operations.

Example:

with session.transaction() as tx: ... alice = Person(name="Alice") ... tx.add(alice) ... # Auto-commits on success, rolls back on exception

UniTransaction(session: UniSession)
77    def __init__(self, session: UniSession) -> None:
78        self._session = session
79        self._tx: uni_db.Transaction | None = None
80        self._pending_nodes: list[UniNode] = []
81        self._pending_edges: list[tuple[UniNode, str, UniNode, UniEdge | None]] = []
82        self._committed = False
83        self._rolled_back = False
def add(self, entity: UniNode) -> None:
101    def add(self, entity: UniNode) -> None:
102        """Add a node to be created in this transaction."""
103        self._pending_nodes.append(entity)

Add a node to be created in this transaction.

def create_edge( self, source: UniNode, edge_type: str, target: UniNode, properties: UniEdge | None = None, **kwargs: Any) -> None:
105    def create_edge(
106        self,
107        source: UniNode,
108        edge_type: str,
109        target: UniNode,
110        properties: UniEdge | None = None,
111        **kwargs: Any,
112    ) -> None:
113        """Create an edge between two nodes in this transaction."""
114        if not source.is_persisted:
115            raise NotPersisted(source)
116        if not target.is_persisted:
117            raise NotPersisted(target)
118        self._pending_edges.append((source, edge_type, target, properties))

Create an edge between two nodes in this transaction.

def commit(self) -> None:
120    def commit(self) -> None:
121        """Commit the transaction."""
122        if self._committed:
123            raise TransactionError("Transaction already committed")
124        if self._rolled_back:
125            raise TransactionError("Transaction already rolled back")
126
127        if self._tx is None:
128            raise TransactionError("Transaction not started")
129
130        try:
131            # Create pending nodes
132            for node in self._pending_nodes:
133                self._session._create_node_in_tx(node, self._tx)
134
135            # Create pending edges
136            for source, edge_type, target, props in self._pending_edges:
137                self._session._create_edge_in_tx(
138                    source, edge_type, target, props, self._tx
139                )
140
141            self._tx.commit()
142            self._committed = True
143
144            # Mark nodes as clean
145            for node in self._pending_nodes:
146                node._mark_clean()
147
148        except Exception as e:
149            self.rollback()
150            raise TransactionError(f"Commit failed: {e}") from e

Commit the transaction.

def rollback(self) -> None:
152    def rollback(self) -> None:
153        """Rollback the transaction."""
154        if self._rolled_back:
155            return
156        if self._tx is not None:
157            self._tx.rollback()
158        self._rolled_back = True
159        self._pending_nodes.clear()
160        self._pending_edges.clear()

Rollback the transaction.

class AsyncUniSession:
141class AsyncUniSession:
142    """
143    Async session for interacting with the graph database.
144
145    Mirrors UniSession with async methods. Uses AsyncUni.
146
147    Example:
148        >>> from uni_db import AsyncUni
149        >>> from uni_pydantic import AsyncUniSession
150        >>>
151        >>> db = await AsyncUni.open("./my_graph")
152        >>> async with AsyncUniSession(db) as session:
153        ...     session.register(Person)
154        ...     await session.sync_schema()
155        ...     alice = Person(name="Alice", age=30)
156        ...     session.add(alice)
157        ...     await session.commit()
158    """
159
160    def __init__(self, db: uni_db.AsyncUni) -> None:
161        self._db = db
162        self._db_session = db.session()
163        self._schema_gen = SchemaGenerator()
164        self._identity_map: WeakValueDictionary[tuple[str, int], UniNode] = (
165            WeakValueDictionary()
166        )
167        self._pending_new: list[UniNode] = []
168        self._pending_delete: list[UniNode] = []
169
170    async def __aenter__(self) -> AsyncUniSession:
171        return self
172
173    async def __aexit__(
174        self,
175        exc_type: type[BaseException] | None,
176        exc_val: BaseException | None,
177        exc_tb: TracebackType | None,
178    ) -> None:
179        self.close()
180
181    def close(self) -> None:
182        """Close the session and clear pending state."""
183        self._pending_new.clear()
184        self._pending_delete.clear()
185
186    @property
187    def db(self) -> uni_db.AsyncUni:
188        """Access the underlying uni_db.AsyncUni for low-level operations."""
189        return self._db
190
191    async def locy(self, program: str, params: dict[str, Any] | None = None) -> Any:
192        """
193        Evaluate a Locy program and return derived facts, stats, and warnings.
194
195        Delegates to the underlying ``uni_db.AsyncSession.locy()``.
196        """
197        return await self._db_session.locy(program, params)
198
199    def register(self, *models: type[UniNode] | type[UniEdge]) -> None:
200        """Register model classes with the session (sync)."""
201        self._schema_gen.register(*models)
202
203    async def sync_schema(self) -> None:
204        """Synchronize database schema with registered models."""
205        await self._schema_gen.async_apply_to_database(self._db)
206
207    def query(self, model: type[NodeT]) -> AsyncQueryBuilder[NodeT]:
208        """Create an async query builder for the given model."""
209        return AsyncQueryBuilder(self, model)
210
211    def add(self, entity: UniNode) -> None:
212        """Add a new entity to be persisted (sync — just collects)."""
213        if entity.is_persisted:
214            raise SessionError(f"Entity {entity!r} is already persisted")
215        entity._session = self
216        self._pending_new.append(entity)
217
218    def add_all(self, entities: Sequence[UniNode]) -> None:
219        """Add multiple entities (sync — just collects)."""
220        for entity in entities:
221            self.add(entity)
222
223    def delete(self, entity: UniNode) -> None:
224        """Mark an entity for deletion (sync — just collects)."""
225        if not entity.is_persisted:
226            raise NotPersisted(entity)
227        self._pending_delete.append(entity)
228
229    async def get(
230        self,
231        model: type[NodeT],
232        vid: int | None = None,
233        uid: str | None = None,
234        **kwargs: Any,
235    ) -> NodeT | None:
236        """Get an entity by ID or unique properties."""
237        if vid is not None:
238            cached = self._identity_map.get((model.__label__, vid))
239            if cached is not None:
240                return cached  # type: ignore[return-value]
241
242        label = model.__label__
243        params: dict[str, Any] = {}
244
245        if vid is not None:
246            cypher = f"MATCH (n:{label}) WHERE id(n) = $vid RETURN {_NODE_RETURN}"
247            params["vid"] = vid
248        elif uid is not None:
249            cypher = f"MATCH (n:{label}) WHERE n._uid = $uid RETURN {_NODE_RETURN}"
250            params["uid"] = uid
251        elif kwargs:
252            for k in kwargs:
253                _validate_property(k, model)
254            conditions = [f"n.{k} = ${k}" for k in kwargs]
255            cypher = f"MATCH (n:{label}) WHERE {' AND '.join(conditions)} RETURN {_NODE_RETURN} LIMIT 1"
256            params.update(kwargs)
257        else:
258            raise ValueError("Must provide vid, uid, or property filters")
259
260        results = await self._db_session.query(cypher, params)
261        if not results:
262            return None
263
264        node_data = _row_to_node_dict(results[0].to_dict())
265        if node_data is None:
266            return None
267        return self._result_to_model(node_data, model)
268
269    async def refresh(self, entity: UniNode) -> None:
270        """Refresh an entity's properties from the database."""
271        if not entity.is_persisted:
272            raise NotPersisted(entity)
273
274        label = entity.__class__.__label__
275        cypher = f"MATCH (n:{label}) WHERE id(n) = $vid RETURN {_NODE_RETURN}"
276        results = await self._db_session.query(cypher, {"vid": entity._vid})
277
278        if not results:
279            raise SessionError(f"Entity with vid={entity._vid} no longer exists")
280
281        props = _row_to_node_dict(results[0].to_dict())
282        if props is None:
283            raise SessionError(f"Entity with vid={entity._vid} no longer exists")
284        try:
285            hints = get_type_hints(type(entity))
286        except Exception:
287            hints = {}
288
289        for field_name in entity.get_property_fields():
290            if field_name in props:
291                value = props[field_name]
292                if field_name in hints:
293                    value = db_to_python_value(value, hints[field_name])
294                setattr(entity, field_name, value)
295
296        entity._mark_clean()
297
298    async def commit(self) -> None:
299        """Commit all pending changes."""
300        for entity in self._pending_new:
301            await self._create_node(entity)
302
303        for (label, vid), entity in list(self._identity_map.items()):
304            if entity.is_dirty and entity.is_persisted:
305                await self._update_node(entity)
306
307        for entity in self._pending_delete:
308            await self._delete_node(entity)
309
310        await self._db.flush()
311        self._pending_new.clear()
312        self._pending_delete.clear()
313
314    async def rollback(self) -> None:
315        """Discard all pending changes."""
316        for entity in self._pending_new:
317            entity._session = None
318        self._pending_new.clear()
319        self._pending_delete.clear()
320        for entity in list(self._identity_map.values()):
321            if entity.is_dirty:
322                await self.refresh(entity)
323
324    async def transaction(self) -> AsyncUniTransaction:
325        """Create an async transaction. Use as `async with session.transaction() as tx:`."""
326        return AsyncUniTransaction(self)
327
328    async def cypher(
329        self,
330        query: str,
331        params: dict[str, Any] | None = None,
332        result_type: type[NodeT] | None = None,
333    ) -> list[NodeT] | list[dict[str, Any]]:
334        """Execute a raw Cypher query."""
335        results = await self._db_session.query(query, params)
336
337        if result_type is None:
338            return [r.to_dict() for r in results]
339
340        mapped = []
341        for raw_row in results:
342            row = raw_row.to_dict()
343            for key, value in row.items():
344                if isinstance(value, dict):
345                    if "_id" in value and "_label" in value:
346                        instance = self._result_to_model(value, result_type)
347                        if instance is not None:
348                            mapped.append(instance)
349                            break
350                    elif "_label" in value:
351                        label = value["_label"]
352                        if label in self._schema_gen._node_models:
353                            model = self._schema_gen._node_models[label]
354                            instance = self._result_to_model(value, model)
355                            if instance is not None:
356                                mapped.append(instance)
357                                break
358            else:
359                first_value = next(iter(row.values()), None)
360                if isinstance(first_value, dict):
361                    instance = self._result_to_model(first_value, result_type)
362                    if instance is not None:
363                        mapped.append(instance)
364
365        return mapped
366
367    async def create_edge(
368        self,
369        source: UniNode,
370        edge_type: str,
371        target: UniNode,
372        properties: dict[str, Any] | UniEdge | None = None,
373    ) -> None:
374        """Create an edge between two nodes."""
375        src_vid, dst_vid, src_label, dst_label = UniSession._validate_edge_endpoints(
376            source, target
377        )
378        props = UniSession._normalize_edge_properties(properties)
379
380        props_str = ", ".join(f"{k}: ${k}" for k in props)
381        if props_str:
382            cypher = f"MATCH (a:{src_label}), (b:{dst_label}) WHERE a._vid = $src AND b._vid = $dst CREATE (a)-[r:{edge_type} {{{props_str}}}]->(b)"
383        else:
384            cypher = f"MATCH (a:{src_label}), (b:{dst_label}) WHERE a._vid = $src AND b._vid = $dst CREATE (a)-[r:{edge_type}]->(b)"
385
386        async with await self._db_session.tx() as tx:
387            await tx.execute(cypher, {"src": src_vid, "dst": dst_vid, **props})
388            await tx.commit()
389
390    async def delete_edge(
391        self, source: UniNode, edge_type: str, target: UniNode
392    ) -> int:
393        """Delete edges between two nodes. Returns the number of deleted edges."""
394        src_vid, dst_vid, src_label, dst_label = UniSession._validate_edge_endpoints(
395            source, target
396        )
397        cypher = (
398            f"MATCH (a:{src_label})-[r:{edge_type}]->(b:{dst_label}) "
399            f"WHERE a._vid = $src AND b._vid = $dst "
400            f"DELETE r RETURN count(r) as count"
401        )
402        async with await self._db_session.tx() as tx:
403            results = await tx.query(cypher, {"src": src_vid, "dst": dst_vid})
404            await tx.commit()
405        return cast(int, results[0]["count"]) if results else 0
406
407    async def bulk_add(self, entities: Sequence[UniNode]) -> list[int]:
408        """Bulk-add entities using bulk_writer."""
409        if not entities:
410            return []
411
412        by_label: dict[str, list[UniNode]] = {}
413        for entity in entities:
414            label = entity.__class__.__label__
415            if label not in by_label:
416                by_label[label] = []
417            by_label[label].append(entity)
418
419        all_vids: list[int] = []
420        try:
421            for label, group in by_label.items():
422                for entity in group:
423                    run_hooks(entity, _BEFORE_CREATE)
424                prop_dicts = [e.to_properties() for e in group]
425                tx = await self._db_session.tx()
426                async with await tx.bulk_writer().build() as bw:
427                    vids = await bw.insert_vertices(label, prop_dicts)
428                    await bw.commit()
429                await tx.commit()
430                for entity, vid in zip(group, vids):
431                    entity._attach_session(self, vid)
432                    self._identity_map[(label, vid)] = entity
433                    run_hooks(entity, _AFTER_CREATE)
434                    entity._mark_clean()
435                all_vids.extend(vids)
436        except Exception as e:
437            raise BulkLoadError(f"Bulk insert failed: {e}") from e
438
439        return all_vids
440
441    async def explain(self, cypher: str) -> Any:
442        """Get the query execution plan."""
443        return await self._db_session.explain(cypher)
444
445    async def profile(self, cypher: str) -> Any:
446        """Run the query with profiling and return results + stats."""
447        return await self._db_session.profile(cypher)
448
449    async def save_schema(self, path: str) -> None:
450        """Save the database schema to a file."""
451        await self._db.save_schema(path)
452
453    async def load_schema(self, path: str) -> None:
454        """Load a database schema from a file."""
455        await self._db.load_schema(path)
456
457    # ---- Internal methods ----
458
459    async def _create_node(self, entity: UniNode) -> None:
460        run_hooks(entity, _BEFORE_CREATE)
461        label = entity.__class__.__label__
462        props = entity.to_properties()
463        props_str = ", ".join(f"{k}: ${k}" for k in props)
464        cypher = f"CREATE (n:{label} {{{props_str}}}) RETURN id(n) as vid"
465        async with await self._db_session.tx() as tx:
466            results = await tx.query(cypher, props)
467            await tx.commit()
468        if results:
469            vid = results[0]["vid"]
470            entity._attach_session(self, vid)
471            self._identity_map[(label, vid)] = entity
472        run_hooks(entity, _AFTER_CREATE)
473        entity._mark_clean()
474
475    async def _create_node_in_tx(
476        self, entity: UniNode, tx: uni_db.AsyncTransaction
477    ) -> None:
478        run_hooks(entity, _BEFORE_CREATE)
479        label = entity.__class__.__label__
480        props = entity.to_properties()
481        props_str = ", ".join(f"{k}: ${k}" for k in props)
482        cypher = f"CREATE (n:{label} {{{props_str}}}) RETURN id(n) as vid"
483        results = await tx.query(cypher, props)
484        if results:
485            vid = results[0]["vid"]
486            entity._attach_session(self, vid)
487            self._identity_map[(label, vid)] = entity
488        run_hooks(entity, _AFTER_CREATE)
489
490    async def _create_edge_in_tx(
491        self,
492        source: UniNode,
493        edge_type: str,
494        target: UniNode,
495        properties: UniEdge | None,
496        tx: uni_db.AsyncTransaction,
497    ) -> None:
498        props = properties.to_properties() if properties else {}
499        src_label = source.__class__.__label__
500        dst_label = target.__class__.__label__
501        props_str = ", ".join(f"{k}: ${k}" for k in props)
502        if props_str:
503            cypher = f"MATCH (a:{src_label}), (b:{dst_label}) WHERE a._vid = $src AND b._vid = $dst CREATE (a)-[:{edge_type} {{{props_str}}}]->(b)"
504        else:
505            cypher = f"MATCH (a:{src_label}), (b:{dst_label}) WHERE a._vid = $src AND b._vid = $dst CREATE (a)-[:{edge_type}]->(b)"
506        params = {"src": source._vid, "dst": target._vid, **props}
507        await tx.query(cypher, params)
508
509    async def _update_node(self, entity: UniNode) -> None:
510        run_hooks(entity, _BEFORE_UPDATE)
511        label = entity.__class__.__label__
512        try:
513            hints = get_type_hints(type(entity))
514        except Exception:
515            hints = {}
516        dirty_props = {}
517        for name in entity._dirty:
518            value = getattr(entity, name)
519            if name in hints:
520                value = python_to_db_value(value, hints[name])
521            dirty_props[name] = value
522        if not dirty_props:
523            return
524        set_clause = ", ".join(f"n.{k} = ${k}" for k in dirty_props)
525        cypher = f"MATCH (n:{label}) WHERE id(n) = $vid SET {set_clause}"
526        params = {"vid": entity._vid, **dirty_props}
527        async with await self._db_session.tx() as tx:
528            await tx.execute(cypher, params)
529            await tx.commit()
530        run_hooks(entity, _AFTER_UPDATE)
531        entity._mark_clean()
532
533    async def _delete_node(self, entity: UniNode) -> None:
534        run_hooks(entity, _BEFORE_DELETE)
535        label = entity.__class__.__label__
536        vid = entity._vid
537        cypher = f"MATCH (n:{label}) WHERE id(n) = $vid DETACH DELETE n"
538        async with await self._db_session.tx() as tx:
539            await tx.execute(cypher, {"vid": vid})
540            await tx.commit()
541        if vid is not None and (label, vid) in self._identity_map:
542            del self._identity_map[(label, vid)]
543        entity._vid = None
544        entity._uid = None
545        entity._session = None
546        run_hooks(entity, _AFTER_DELETE)
547
548    def _result_to_model(
549        self,
550        data: dict[str, Any],
551        model: type[NodeT],
552    ) -> NodeT | None:
553        """Convert a query result row to a model instance (sync — pure dict processing)."""
554        if not data:
555            return None
556
557        data = dict(data)
558        data = run_class_hooks(model, _BEFORE_LOAD, data) or data
559
560        vid = data.pop("_id", None)
561        if vid is None:
562            vid = data.pop("_vid", None)
563        if vid is None:
564            vid = data.pop("vid", None)
565        if vid is not None and not isinstance(vid, int):
566            vid = int(vid)
567        data.pop("_label", None)
568
569        try:
570            instance = cast(
571                NodeT,
572                model.from_properties(data, vid=vid, session=self),
573            )
574        except ValidationError as exc:
575            _warn_unhydratable(model, vid, exc)
576            return None
577
578        if vid is not None:
579            existing = self._identity_map.get((model.__label__, vid))
580            if existing is not None:
581                return cast(NodeT, existing)
582            self._identity_map[(model.__label__, vid)] = instance
583
584        run_hooks(instance, _AFTER_LOAD)
585        return instance
586
587    def _load_relationship(
588        self,
589        entity: UniNode,
590        descriptor: RelationshipDescriptor[Any],
591    ) -> list[UniNode] | UniNode | None:
592        """Sync relationship loading — raises error for async session.
593        Use _async_load_relationship instead."""
594        raise SessionError(
595            "Cannot synchronously load relationships in an async session. "
596            "Use eager_load() or access relationships via async queries."
597        )
598
599    async def _async_eager_load_relationships(
600        self,
601        entities: list[NodeT],
602        relationships: list[str],
603    ) -> None:
604        """Eager load relationships for a list of entities (async)."""
605        if not entities:
606            return
607
608        model = type(entities[0])
609        rel_configs = model.get_relationship_fields()
610
611        for rel_name in relationships:
612            if rel_name not in rel_configs:
613                continue
614
615            config = rel_configs[rel_name]
616            label = model.__label__
617            vids = [e._vid for e in entities if e._vid is not None]
618
619            if not vids:
620                continue
621
622            pattern = _edge_pattern(config.edge_type, config.direction)
623            cypher = (
624                f"MATCH (a:{label}){pattern}(b) WHERE id(a) IN $vids "
625                f"RETURN id(a) as src_vid, properties(b) AS _props, id(b) AS _vid, labels(b) AS _labels"
626            )
627            results = await self._db_session.query(cypher, {"vids": vids})
628
629            # Hydrate, mirroring the sync session. This matters more here:
630            # `_load_relationship` raises on an async session and tells the
631            # caller to use `eager_load()`, so this is the *only* relationship
632            # path async has -- and it was the broken one.
633            descriptor = getattr(model, rel_name, None)
634            is_list = getattr(descriptor, "is_list", True)
635
636            by_source: dict[int, list[Any]] = {}
637            for raw_row in results:
638                row = raw_row.to_dict()
639                src_vid = row["src_vid"]
640                node_data = _row_to_node_dict(row)
641                if node_data is None:
642                    continue
643                node_label = node_data.get("_label")
644                if not node_label or node_label not in self._schema_gen._node_models:
645                    continue
646                target_model = self._schema_gen._node_models[node_label]
647                instance = self._result_to_model(node_data, target_model)
648                if instance is None:
649                    continue
650                by_source.setdefault(src_vid, []).append(instance)
651
652            cache_attr = f"_rel_cache_{rel_name}"
653            for entity in entities:
654                related = by_source.get(entity._vid, [])
655                if is_list:
656                    setattr(entity, cache_attr, related)
657                else:
658                    setattr(entity, cache_attr, related[0] if related else None)

Async session for interacting with the graph database.

Mirrors UniSession with async methods. Uses AsyncUni.

Example:

from uni_db import AsyncUni from uni_pydantic import AsyncUniSession

db = await AsyncUni.open("./my_graph") async with AsyncUniSession(db) as session: ... session.register(Person) ... await session.sync_schema() ... alice = Person(name="Alice", age=30) ... session.add(alice) ... await session.commit()

AsyncUniSession(db: AsyncUni)
160    def __init__(self, db: uni_db.AsyncUni) -> None:
161        self._db = db
162        self._db_session = db.session()
163        self._schema_gen = SchemaGenerator()
164        self._identity_map: WeakValueDictionary[tuple[str, int], UniNode] = (
165            WeakValueDictionary()
166        )
167        self._pending_new: list[UniNode] = []
168        self._pending_delete: list[UniNode] = []
def close(self) -> None:
181    def close(self) -> None:
182        """Close the session and clear pending state."""
183        self._pending_new.clear()
184        self._pending_delete.clear()

Close the session and clear pending state.

db: AsyncUni
186    @property
187    def db(self) -> uni_db.AsyncUni:
188        """Access the underlying uni_db.AsyncUni for low-level operations."""
189        return self._db

Access the underlying uni_db.AsyncUni for low-level operations.

async def locy(self, program: str, params: dict[str, typing.Any] | None = None) -> Any:
191    async def locy(self, program: str, params: dict[str, Any] | None = None) -> Any:
192        """
193        Evaluate a Locy program and return derived facts, stats, and warnings.
194
195        Delegates to the underlying ``uni_db.AsyncSession.locy()``.
196        """
197        return await self._db_session.locy(program, params)

Evaluate a Locy program and return derived facts, stats, and warnings.

Delegates to the underlying uni_db.AsyncSession.locy().

def register( self, *models: type[UniNode] | type[UniEdge]) -> None:
199    def register(self, *models: type[UniNode] | type[UniEdge]) -> None:
200        """Register model classes with the session (sync)."""
201        self._schema_gen.register(*models)

Register model classes with the session (sync).

async def sync_schema(self) -> None:
203    async def sync_schema(self) -> None:
204        """Synchronize database schema with registered models."""
205        await self._schema_gen.async_apply_to_database(self._db)

Synchronize database schema with registered models.

def query( self, model: type[~NodeT]) -> AsyncQueryBuilder[~NodeT]:
207    def query(self, model: type[NodeT]) -> AsyncQueryBuilder[NodeT]:
208        """Create an async query builder for the given model."""
209        return AsyncQueryBuilder(self, model)

Create an async query builder for the given model.

def add(self, entity: UniNode) -> None:
211    def add(self, entity: UniNode) -> None:
212        """Add a new entity to be persisted (sync — just collects)."""
213        if entity.is_persisted:
214            raise SessionError(f"Entity {entity!r} is already persisted")
215        entity._session = self
216        self._pending_new.append(entity)

Add a new entity to be persisted (sync — just collects).

def add_all(self, entities: Sequence[UniNode]) -> None:
218    def add_all(self, entities: Sequence[UniNode]) -> None:
219        """Add multiple entities (sync — just collects)."""
220        for entity in entities:
221            self.add(entity)

Add multiple entities (sync — just collects).

def delete(self, entity: UniNode) -> None:
223    def delete(self, entity: UniNode) -> None:
224        """Mark an entity for deletion (sync — just collects)."""
225        if not entity.is_persisted:
226            raise NotPersisted(entity)
227        self._pending_delete.append(entity)

Mark an entity for deletion (sync — just collects).

async def get( self, model: type[~NodeT], vid: int | None = None, uid: str | None = None, **kwargs: Any) -> Optional[~NodeT]:
229    async def get(
230        self,
231        model: type[NodeT],
232        vid: int | None = None,
233        uid: str | None = None,
234        **kwargs: Any,
235    ) -> NodeT | None:
236        """Get an entity by ID or unique properties."""
237        if vid is not None:
238            cached = self._identity_map.get((model.__label__, vid))
239            if cached is not None:
240                return cached  # type: ignore[return-value]
241
242        label = model.__label__
243        params: dict[str, Any] = {}
244
245        if vid is not None:
246            cypher = f"MATCH (n:{label}) WHERE id(n) = $vid RETURN {_NODE_RETURN}"
247            params["vid"] = vid
248        elif uid is not None:
249            cypher = f"MATCH (n:{label}) WHERE n._uid = $uid RETURN {_NODE_RETURN}"
250            params["uid"] = uid
251        elif kwargs:
252            for k in kwargs:
253                _validate_property(k, model)
254            conditions = [f"n.{k} = ${k}" for k in kwargs]
255            cypher = f"MATCH (n:{label}) WHERE {' AND '.join(conditions)} RETURN {_NODE_RETURN} LIMIT 1"
256            params.update(kwargs)
257        else:
258            raise ValueError("Must provide vid, uid, or property filters")
259
260        results = await self._db_session.query(cypher, params)
261        if not results:
262            return None
263
264        node_data = _row_to_node_dict(results[0].to_dict())
265        if node_data is None:
266            return None
267        return self._result_to_model(node_data, model)

Get an entity by ID or unique properties.

async def refresh(self, entity: UniNode) -> None:
269    async def refresh(self, entity: UniNode) -> None:
270        """Refresh an entity's properties from the database."""
271        if not entity.is_persisted:
272            raise NotPersisted(entity)
273
274        label = entity.__class__.__label__
275        cypher = f"MATCH (n:{label}) WHERE id(n) = $vid RETURN {_NODE_RETURN}"
276        results = await self._db_session.query(cypher, {"vid": entity._vid})
277
278        if not results:
279            raise SessionError(f"Entity with vid={entity._vid} no longer exists")
280
281        props = _row_to_node_dict(results[0].to_dict())
282        if props is None:
283            raise SessionError(f"Entity with vid={entity._vid} no longer exists")
284        try:
285            hints = get_type_hints(type(entity))
286        except Exception:
287            hints = {}
288
289        for field_name in entity.get_property_fields():
290            if field_name in props:
291                value = props[field_name]
292                if field_name in hints:
293                    value = db_to_python_value(value, hints[field_name])
294                setattr(entity, field_name, value)
295
296        entity._mark_clean()

Refresh an entity's properties from the database.

async def commit(self) -> None:
298    async def commit(self) -> None:
299        """Commit all pending changes."""
300        for entity in self._pending_new:
301            await self._create_node(entity)
302
303        for (label, vid), entity in list(self._identity_map.items()):
304            if entity.is_dirty and entity.is_persisted:
305                await self._update_node(entity)
306
307        for entity in self._pending_delete:
308            await self._delete_node(entity)
309
310        await self._db.flush()
311        self._pending_new.clear()
312        self._pending_delete.clear()

Commit all pending changes.

async def rollback(self) -> None:
314    async def rollback(self) -> None:
315        """Discard all pending changes."""
316        for entity in self._pending_new:
317            entity._session = None
318        self._pending_new.clear()
319        self._pending_delete.clear()
320        for entity in list(self._identity_map.values()):
321            if entity.is_dirty:
322                await self.refresh(entity)

Discard all pending changes.

async def transaction(self) -> AsyncUniTransaction:
324    async def transaction(self) -> AsyncUniTransaction:
325        """Create an async transaction. Use as `async with session.transaction() as tx:`."""
326        return AsyncUniTransaction(self)

Create an async transaction. Use as async with session.transaction() as tx:.

async def cypher( self, query: str, params: dict[str, typing.Any] | None = None, result_type: type[~NodeT] | None = None) -> list[~NodeT] | list[dict[str, typing.Any]]:
328    async def cypher(
329        self,
330        query: str,
331        params: dict[str, Any] | None = None,
332        result_type: type[NodeT] | None = None,
333    ) -> list[NodeT] | list[dict[str, Any]]:
334        """Execute a raw Cypher query."""
335        results = await self._db_session.query(query, params)
336
337        if result_type is None:
338            return [r.to_dict() for r in results]
339
340        mapped = []
341        for raw_row in results:
342            row = raw_row.to_dict()
343            for key, value in row.items():
344                if isinstance(value, dict):
345                    if "_id" in value and "_label" in value:
346                        instance = self._result_to_model(value, result_type)
347                        if instance is not None:
348                            mapped.append(instance)
349                            break
350                    elif "_label" in value:
351                        label = value["_label"]
352                        if label in self._schema_gen._node_models:
353                            model = self._schema_gen._node_models[label]
354                            instance = self._result_to_model(value, model)
355                            if instance is not None:
356                                mapped.append(instance)
357                                break
358            else:
359                first_value = next(iter(row.values()), None)
360                if isinstance(first_value, dict):
361                    instance = self._result_to_model(first_value, result_type)
362                    if instance is not None:
363                        mapped.append(instance)
364
365        return mapped

Execute a raw Cypher query.

async def create_edge( self, source: UniNode, edge_type: str, target: UniNode, properties: dict[str, typing.Any] | UniEdge | None = None) -> None:
367    async def create_edge(
368        self,
369        source: UniNode,
370        edge_type: str,
371        target: UniNode,
372        properties: dict[str, Any] | UniEdge | None = None,
373    ) -> None:
374        """Create an edge between two nodes."""
375        src_vid, dst_vid, src_label, dst_label = UniSession._validate_edge_endpoints(
376            source, target
377        )
378        props = UniSession._normalize_edge_properties(properties)
379
380        props_str = ", ".join(f"{k}: ${k}" for k in props)
381        if props_str:
382            cypher = f"MATCH (a:{src_label}), (b:{dst_label}) WHERE a._vid = $src AND b._vid = $dst CREATE (a)-[r:{edge_type} {{{props_str}}}]->(b)"
383        else:
384            cypher = f"MATCH (a:{src_label}), (b:{dst_label}) WHERE a._vid = $src AND b._vid = $dst CREATE (a)-[r:{edge_type}]->(b)"
385
386        async with await self._db_session.tx() as tx:
387            await tx.execute(cypher, {"src": src_vid, "dst": dst_vid, **props})
388            await tx.commit()

Create an edge between two nodes.

async def delete_edge( self, source: UniNode, edge_type: str, target: UniNode) -> int:
390    async def delete_edge(
391        self, source: UniNode, edge_type: str, target: UniNode
392    ) -> int:
393        """Delete edges between two nodes. Returns the number of deleted edges."""
394        src_vid, dst_vid, src_label, dst_label = UniSession._validate_edge_endpoints(
395            source, target
396        )
397        cypher = (
398            f"MATCH (a:{src_label})-[r:{edge_type}]->(b:{dst_label}) "
399            f"WHERE a._vid = $src AND b._vid = $dst "
400            f"DELETE r RETURN count(r) as count"
401        )
402        async with await self._db_session.tx() as tx:
403            results = await tx.query(cypher, {"src": src_vid, "dst": dst_vid})
404            await tx.commit()
405        return cast(int, results[0]["count"]) if results else 0

Delete edges between two nodes. Returns the number of deleted edges.

async def bulk_add(self, entities: Sequence[UniNode]) -> list[int]:
407    async def bulk_add(self, entities: Sequence[UniNode]) -> list[int]:
408        """Bulk-add entities using bulk_writer."""
409        if not entities:
410            return []
411
412        by_label: dict[str, list[UniNode]] = {}
413        for entity in entities:
414            label = entity.__class__.__label__
415            if label not in by_label:
416                by_label[label] = []
417            by_label[label].append(entity)
418
419        all_vids: list[int] = []
420        try:
421            for label, group in by_label.items():
422                for entity in group:
423                    run_hooks(entity, _BEFORE_CREATE)
424                prop_dicts = [e.to_properties() for e in group]
425                tx = await self._db_session.tx()
426                async with await tx.bulk_writer().build() as bw:
427                    vids = await bw.insert_vertices(label, prop_dicts)
428                    await bw.commit()
429                await tx.commit()
430                for entity, vid in zip(group, vids):
431                    entity._attach_session(self, vid)
432                    self._identity_map[(label, vid)] = entity
433                    run_hooks(entity, _AFTER_CREATE)
434                    entity._mark_clean()
435                all_vids.extend(vids)
436        except Exception as e:
437            raise BulkLoadError(f"Bulk insert failed: {e}") from e
438
439        return all_vids

Bulk-add entities using bulk_writer.

async def explain(self, cypher: str) -> Any:
441    async def explain(self, cypher: str) -> Any:
442        """Get the query execution plan."""
443        return await self._db_session.explain(cypher)

Get the query execution plan.

async def profile(self, cypher: str) -> Any:
445    async def profile(self, cypher: str) -> Any:
446        """Run the query with profiling and return results + stats."""
447        return await self._db_session.profile(cypher)

Run the query with profiling and return results + stats.

async def save_schema(self, path: str) -> None:
449    async def save_schema(self, path: str) -> None:
450        """Save the database schema to a file."""
451        await self._db.save_schema(path)

Save the database schema to a file.

async def load_schema(self, path: str) -> None:
453    async def load_schema(self, path: str) -> None:
454        """Load a database schema from a file."""
455        await self._db.load_schema(path)

Load a database schema from a file.

class AsyncUniTransaction:
 61class AsyncUniTransaction:
 62    """Async transaction context for atomic operations."""
 63
 64    def __init__(self, session: AsyncUniSession) -> None:
 65        self._session = session
 66        self._tx: uni_db.AsyncTransaction | None = None
 67        self._pending_nodes: list[UniNode] = []
 68        self._pending_edges: list[tuple[UniNode, str, UniNode, UniEdge | None]] = []
 69        self._committed = False
 70        self._rolled_back = False
 71
 72    async def __aenter__(self) -> AsyncUniTransaction:
 73        self._tx = await self._session._db_session.tx()
 74        return self
 75
 76    async def __aexit__(
 77        self,
 78        exc_type: type[BaseException] | None,
 79        exc_val: BaseException | None,
 80        exc_tb: TracebackType | None,
 81    ) -> None:
 82        if exc_type is not None:
 83            await self.rollback()
 84            return
 85        if not self._committed and not self._rolled_back:
 86            await self.commit()
 87
 88    def add(self, entity: UniNode) -> None:
 89        """Add a node to be created in this transaction (sync — just collects)."""
 90        self._pending_nodes.append(entity)
 91
 92    def create_edge(
 93        self,
 94        source: UniNode,
 95        edge_type: str,
 96        target: UniNode,
 97        properties: UniEdge | None = None,
 98    ) -> None:
 99        """Create an edge between two nodes in this transaction (sync — just collects)."""
100        if not source.is_persisted:
101            raise NotPersisted(source)
102        if not target.is_persisted:
103            raise NotPersisted(target)
104        self._pending_edges.append((source, edge_type, target, properties))
105
106    async def commit(self) -> None:
107        """Commit the transaction."""
108        if self._committed:
109            raise TransactionError("Transaction already committed")
110        if self._rolled_back:
111            raise TransactionError("Transaction already rolled back")
112        if self._tx is None:
113            raise TransactionError("Transaction not started")
114
115        try:
116            for node in self._pending_nodes:
117                await self._session._create_node_in_tx(node, self._tx)
118            for source, edge_type, target, props in self._pending_edges:
119                await self._session._create_edge_in_tx(
120                    source, edge_type, target, props, self._tx
121                )
122            await self._tx.commit()
123            self._committed = True
124            for node in self._pending_nodes:
125                node._mark_clean()
126        except Exception as e:
127            await self.rollback()
128            raise TransactionError(f"Commit failed: {e}") from e
129
130    async def rollback(self) -> None:
131        """Rollback the transaction."""
132        if self._rolled_back:
133            return
134        if self._tx is not None:
135            await self._tx.rollback()
136        self._rolled_back = True
137        self._pending_nodes.clear()
138        self._pending_edges.clear()

Async transaction context for atomic operations.

AsyncUniTransaction(session: AsyncUniSession)
64    def __init__(self, session: AsyncUniSession) -> None:
65        self._session = session
66        self._tx: uni_db.AsyncTransaction | None = None
67        self._pending_nodes: list[UniNode] = []
68        self._pending_edges: list[tuple[UniNode, str, UniNode, UniEdge | None]] = []
69        self._committed = False
70        self._rolled_back = False
def add(self, entity: UniNode) -> None:
88    def add(self, entity: UniNode) -> None:
89        """Add a node to be created in this transaction (sync — just collects)."""
90        self._pending_nodes.append(entity)

Add a node to be created in this transaction (sync — just collects).

def create_edge( self, source: UniNode, edge_type: str, target: UniNode, properties: UniEdge | None = None) -> None:
 92    def create_edge(
 93        self,
 94        source: UniNode,
 95        edge_type: str,
 96        target: UniNode,
 97        properties: UniEdge | None = None,
 98    ) -> None:
 99        """Create an edge between two nodes in this transaction (sync — just collects)."""
100        if not source.is_persisted:
101            raise NotPersisted(source)
102        if not target.is_persisted:
103            raise NotPersisted(target)
104        self._pending_edges.append((source, edge_type, target, properties))

Create an edge between two nodes in this transaction (sync — just collects).

async def commit(self) -> None:
106    async def commit(self) -> None:
107        """Commit the transaction."""
108        if self._committed:
109            raise TransactionError("Transaction already committed")
110        if self._rolled_back:
111            raise TransactionError("Transaction already rolled back")
112        if self._tx is None:
113            raise TransactionError("Transaction not started")
114
115        try:
116            for node in self._pending_nodes:
117                await self._session._create_node_in_tx(node, self._tx)
118            for source, edge_type, target, props in self._pending_edges:
119                await self._session._create_edge_in_tx(
120                    source, edge_type, target, props, self._tx
121                )
122            await self._tx.commit()
123            self._committed = True
124            for node in self._pending_nodes:
125                node._mark_clean()
126        except Exception as e:
127            await self.rollback()
128            raise TransactionError(f"Commit failed: {e}") from e

Commit the transaction.

async def rollback(self) -> None:
130    async def rollback(self) -> None:
131        """Rollback the transaction."""
132        if self._rolled_back:
133            return
134        if self._tx is not None:
135            await self._tx.rollback()
136        self._rolled_back = True
137        self._pending_nodes.clear()
138        self._pending_edges.clear()

Rollback the transaction.

def Field( default: Any = Ellipsis, *, default_factory: Callable[[], typing.Any] | None = None, alias: str | None = None, title: str | None = None, description: str | None = None, examples: list[typing.Any] | None = None, exclude: bool = False, json_schema_extra: dict[str, typing.Any] | None = None, index: Optional[Literal['btree', 'hash', 'fulltext', 'vector', 'sparse']] = None, unique: bool = False, tokenizer: str | None = None, metric: Optional[Literal['l2', 'cosine', 'dot']] = None, generated: str | None = None) -> Any:
 69def Field(
 70    default: Any = ...,
 71    *,
 72    default_factory: Callable[[], Any] | None = None,
 73    alias: str | None = None,
 74    title: str | None = None,
 75    description: str | None = None,
 76    examples: list[Any] | None = None,
 77    exclude: bool = False,
 78    json_schema_extra: dict[str, Any] | None = None,
 79    # Uni-specific options
 80    index: IndexType | None = None,
 81    unique: bool = False,
 82    tokenizer: str | None = None,
 83    metric: VectorMetric | None = None,
 84    generated: str | None = None,
 85) -> Any:
 86    """
 87    Create a field with uni-pydantic configuration.
 88
 89    This extends Pydantic's Field with graph database options.
 90
 91    Args:
 92        default: Default value for the field.
 93        default_factory: Factory function for default value.
 94        alias: Field alias for serialization.
 95        title: Human-readable title.
 96        description: Field description.
 97        examples: Example values.
 98        exclude: Exclude from serialization.
 99        json_schema_extra: Extra JSON schema properties.
100        index: Index type ("btree", "hash", "fulltext", "vector").
101        unique: Whether to create a unique constraint.
102        tokenizer: Tokenizer for fulltext index (default: "standard").
103        metric: Distance metric for vector index ("l2", "cosine", "dot").
104        generated: Expression for generated/computed property.
105
106    Returns:
107        A Pydantic FieldInfo with uni-pydantic metadata attached.
108
109    Examples:
110        >>> class Person(UniNode):
111        ...     name: str = Field(index="btree")
112        ...     email: str = Field(unique=True)
113        ...     bio: str = Field(index="fulltext", tokenizer="standard")
114        ...     embedding: Vector[768] = Field(metric="cosine")
115    """
116    # Default tokenizer for fulltext indexes
117    if index == "fulltext" and tokenizer is None:
118        tokenizer = "standard"
119
120    # Store uni config in json_schema_extra
121    uni_config = FieldConfig(
122        index=index,
123        unique=unique,
124        tokenizer=tokenizer,
125        metric=metric,
126        generated=generated,
127        default=default,
128        default_factory=default_factory,
129        alias=alias,
130        title=title,
131        description=description,
132        examples=examples,
133        exclude=exclude,
134        json_schema_extra=json_schema_extra,
135    )
136
137    # Merge uni config into json_schema_extra
138    extra = json_schema_extra or {}
139    extra["uni_config"] = uni_config
140
141    # Create Pydantic FieldInfo
142    from pydantic.fields import FieldInfo as PydanticFieldInfo
143
144    if default_factory is not None:
145        return PydanticFieldInfo(
146            default_factory=default_factory,
147            alias=alias,
148            title=title,
149            description=description,
150            examples=examples,
151            exclude=exclude,
152            json_schema_extra=extra,
153        )
154    elif default is not ...:
155        return PydanticFieldInfo(
156            default=default,
157            alias=alias,
158            title=title,
159            description=description,
160            examples=examples,
161            exclude=exclude,
162            json_schema_extra=extra,
163        )
164    else:
165        return PydanticFieldInfo(
166            alias=alias,
167            title=title,
168            description=description,
169            examples=examples,
170            exclude=exclude,
171            json_schema_extra=extra,
172        )

Create a field with uni-pydantic configuration.

This extends Pydantic's Field with graph database options.

Args: default: Default value for the field. default_factory: Factory function for default value. alias: Field alias for serialization. title: Human-readable title. description: Field description. examples: Example values. exclude: Exclude from serialization. json_schema_extra: Extra JSON schema properties. index: Index type ("btree", "hash", "fulltext", "vector"). unique: Whether to create a unique constraint. tokenizer: Tokenizer for fulltext index (default: "standard"). metric: Distance metric for vector index ("l2", "cosine", "dot"). generated: Expression for generated/computed property.

Returns: A Pydantic FieldInfo with uni-pydantic metadata attached.

Examples:

class Person(UniNode): ... name: str = Field(index="btree") ... email: str = Field(unique=True) ... bio: str = Field(index="fulltext", tokenizer="standard") ... embedding: Vector[768] = Field(metric="cosine")

@dataclass
class FieldConfig:
41@dataclass
42class FieldConfig:
43    """Configuration for a uni-pydantic field."""
44
45    # Index configuration
46    index: IndexType | None = None
47    unique: bool = False
48
49    # Fulltext index options
50    tokenizer: str | None = None
51
52    # Vector index options
53    metric: VectorMetric | None = None
54
55    # Generated/computed property
56    generated: str | None = None
57
58    # Pydantic field options (passed through)
59    default: Any = dataclass_field(default_factory=lambda: ...)
60    default_factory: Callable[[], Any] | None = None
61    alias: str | None = None
62    title: str | None = None
63    description: str | None = None
64    examples: list[Any] | None = None
65    exclude: bool = False
66    json_schema_extra: dict[str, Any] | None = None

Configuration for a uni-pydantic field.

FieldConfig( index: Optional[Literal['btree', 'hash', 'fulltext', 'vector', 'sparse']] = None, unique: bool = False, tokenizer: str | None = None, metric: Optional[Literal['l2', 'cosine', 'dot']] = None, generated: str | None = None, default: Any = <factory>, default_factory: Callable[[], typing.Any] | None = None, alias: str | None = None, title: str | None = None, description: str | None = None, examples: list[typing.Any] | None = None, exclude: bool = False, json_schema_extra: dict[str, typing.Any] | None = None)
index: Optional[Literal['btree', 'hash', 'fulltext', 'vector', 'sparse']] = None
unique: bool = False
tokenizer: str | None = None
metric: Optional[Literal['l2', 'cosine', 'dot']] = None
generated: str | None = None
default: Any
default_factory: Callable[[], typing.Any] | None = None
alias: str | None = None
title: str | None = None
description: str | None = None
examples: list[typing.Any] | None = None
exclude: bool = False
json_schema_extra: dict[str, typing.Any] | None = None
def Relationship( edge_type: str, *, direction: Literal['outgoing', 'incoming', 'both'] = 'outgoing', edge_model: type[UniEdge] | None = None, eager: bool = False, cascade_delete: bool = False) -> Any:
272def Relationship(
273    edge_type: str,
274    *,
275    direction: Direction = "outgoing",
276    edge_model: type[UniEdge] | None = None,
277    eager: bool = False,
278    cascade_delete: bool = False,
279) -> Any:
280    """
281    Declare a relationship to another node type.
282
283    Relationships are lazy-loaded by default. Use eager=True or
284    query.eager_load() to load them with the parent query.
285
286    Args:
287        edge_type: The edge type name (e.g., "FRIEND_OF", "WORKS_AT").
288        direction: Relationship direction:
289            - "outgoing": Follow edges from this node (default)
290            - "incoming": Follow edges to this node
291            - "both": Follow edges in both directions
292        edge_model: Optional UniEdge subclass for typed edge properties.
293        eager: Whether to eager-load this relationship by default.
294        cascade_delete: Whether to delete related edges when this node is deleted.
295
296    Returns:
297        A RelationshipDescriptor that will be processed during model creation.
298
299    Examples:
300        >>> class Person(UniNode):
301        ...     # Outgoing relationship (default)
302        ...     follows: list["Person"] = Relationship("FOLLOWS")
303        ...
304        ...     # Incoming relationship
305        ...     followers: list["Person"] = Relationship("FOLLOWS", direction="incoming")
306        ...
307        ...     # Single optional relationship
308        ...     manager: "Person | None" = Relationship("REPORTS_TO")
309        ...
310        ...     # Relationship with edge properties
311        ...     friendships: list[tuple["Person", FriendshipEdge]] = Relationship(
312        ...         "FRIEND_OF",
313        ...         edge_model=FriendshipEdge
314        ...     )
315    """
316    config = RelationshipConfig(
317        edge_type=edge_type,
318        direction=direction,
319        edge_model=edge_model,
320        eager=eager,
321        cascade_delete=cascade_delete,
322    )
323    # Return a marker that will be processed by the metaclass
324    return _RelationshipMarker(config)

Declare a relationship to another node type.

Relationships are lazy-loaded by default. Use eager=True or query.eager_load() to load them with the parent query.

Args: edge_type: The edge type name (e.g., "FRIEND_OF", "WORKS_AT"). direction: Relationship direction: - "outgoing": Follow edges from this node (default) - "incoming": Follow edges to this node - "both": Follow edges in both directions edge_model: Optional UniEdge subclass for typed edge properties. eager: Whether to eager-load this relationship by default. cascade_delete: Whether to delete related edges when this node is deleted.

Returns: A RelationshipDescriptor that will be processed during model creation.

Examples:

class Person(UniNode): ... # Outgoing relationship (default) ... follows: list["Person"] = Relationship("FOLLOWS") ... ... # Incoming relationship ... followers: list["Person"] = Relationship("FOLLOWS", direction="incoming") ... ... # Single optional relationship ... manager: "Person | None" = Relationship("REPORTS_TO") ... ... # Relationship with edge properties ... friendships: list[tuple["Person", FriendshipEdge]] = Relationship( ... "FRIEND_OF", ... edge_model=FriendshipEdge ... )

@dataclass
class RelationshipConfig:
185@dataclass
186class RelationshipConfig:
187    """Configuration for a relationship field."""
188
189    edge_type: str
190    direction: Direction = "outgoing"
191    edge_model: type[UniEdge] | None = None
192    eager: bool = False
193    cascade_delete: bool = False

Configuration for a relationship field.

RelationshipConfig( edge_type: str, direction: Literal['outgoing', 'incoming', 'both'] = 'outgoing', edge_model: type[UniEdge] | None = None, eager: bool = False, cascade_delete: bool = False)
edge_type: str
direction: Literal['outgoing', 'incoming', 'both'] = 'outgoing'
edge_model: type[UniEdge] | None = None
eager: bool = False
cascade_delete: bool = False
class RelationshipDescriptor(typing.Generic[~NodeT]):
196class RelationshipDescriptor(Generic[NodeT]):
197    """
198    Descriptor for relationship fields that enables lazy loading.
199
200    When accessed on an instance, it returns the related nodes.
201    When accessed on the class, it returns the descriptor for query building.
202    """
203
204    def __init__(
205        self,
206        config: RelationshipConfig,
207        field_name: str,
208        target_type: type[NodeT] | str | None = None,
209        is_list: bool = True,
210    ) -> None:
211        self.config = config
212        self.field_name = field_name
213        self.target_type = target_type
214        self.is_list = is_list
215        self._cache_attr = f"_rel_cache_{field_name}"
216
217    def __set_name__(self, owner: type, name: str) -> None:
218        self.field_name = name
219        self._cache_attr = f"_rel_cache_{name}"
220
221    @overload
222    def __get__(
223        self, obj: None, objtype: type[NodeT]
224    ) -> RelationshipDescriptor[NodeT]: ...
225
226    @overload
227    def __get__(
228        self, obj: NodeT, objtype: type[NodeT] | None = None
229    ) -> list[NodeT] | NodeT | None: ...
230
231    def __get__(
232        self, obj: NodeT | None, objtype: type[NodeT] | None = None
233    ) -> RelationshipDescriptor[NodeT] | list[NodeT] | NodeT | None:
234        if obj is None:
235            # Class-level access returns the descriptor
236            return self
237
238        # Instance-level access - check cache first
239        if hasattr(obj, self._cache_attr):
240            cached = getattr(obj, self._cache_attr)
241            return cast("list[NodeT] | NodeT | None", cached)
242
243        # Check if we have a session for lazy loading
244        session = getattr(obj, "_session", None)
245        if session is None:
246            from .exceptions import LazyLoadError
247
248            raise LazyLoadError(
249                self.field_name,
250                "No session attached. Use session.get() or enable eager loading.",
251            )
252
253        # Lazy load the relationship
254        result = session._load_relationship(obj, self)
255
256        # Cache the result
257        setattr(obj, self._cache_attr, result)
258        return cast("list[NodeT] | NodeT | None", result)
259
260    def __set__(self, obj: NodeT, value: list[NodeT] | NodeT | None) -> None:
261        # Allow setting the cached value (e.g., during eager loading)
262        setattr(obj, self._cache_attr, value)
263
264    def __repr__(self) -> str:
265        return f"Relationship({self.config.edge_type!r}, direction={self.config.direction!r})"

Descriptor for relationship fields that enables lazy loading.

When accessed on an instance, it returns the related nodes. When accessed on the class, it returns the descriptor for query building.

RelationshipDescriptor( config: RelationshipConfig, field_name: str, target_type: type[~NodeT] | str | None = None, is_list: bool = True)
204    def __init__(
205        self,
206        config: RelationshipConfig,
207        field_name: str,
208        target_type: type[NodeT] | str | None = None,
209        is_list: bool = True,
210    ) -> None:
211        self.config = config
212        self.field_name = field_name
213        self.target_type = target_type
214        self.is_list = is_list
215        self._cache_attr = f"_rel_cache_{field_name}"
config
field_name
target_type
is_list
def get_field_config( field_info: pydantic.fields.FieldInfo) -> FieldConfig | None:
175def get_field_config(field_info: FieldInfo) -> FieldConfig | None:
176    """Extract uni-pydantic config from a Pydantic FieldInfo."""
177    extra = field_info.json_schema_extra
178    if isinstance(extra, dict):
179        config = extra.get("uni_config")
180        if isinstance(config, FieldConfig):
181            return config
182    return None

Extract uni-pydantic config from a Pydantic FieldInfo.

IndexType = typing.Literal['btree', 'hash', 'fulltext', 'vector', 'sparse']
Direction = typing.Literal['outgoing', 'incoming', 'both']
VectorMetric = typing.Literal['l2', 'cosine', 'dot']
class Btic:
281class Btic:
282    """A BTIC temporal interval value for Uni graph database.
283
284    Construct from an ISO 8601-inspired string literal::
285
286        Btic("1985")
287        Btic("1985-03/2024-06")
288        Btic("~1985")           # approximate certainty
289        Btic("2020-03/")        # ongoing (unbounded hi)
290
291    Use as a Pydantic model field type::
292
293        class Event(UniNode):
294            when: Btic
295    """
296
297    def __init__(self, value: str | object) -> None:
298        if _PyBtic is None:
299            raise ImportError("uni_db is required for Btic type")
300        if isinstance(value, str):
301            self._inner = _PyBtic(value)
302        elif _PyBtic is not None and isinstance(value, _PyBtic):
303            self._inner = value
304        elif isinstance(value, Btic):
305            self._inner = value._inner
306        else:
307            raise TypeError(f"Expected str or Btic, got {type(value)}")
308
309    @property
310    def lo(self) -> int:
311        """Lower bound in milliseconds since epoch."""
312        return self._inner.lo
313
314    @property
315    def hi(self) -> int:
316        """Upper bound in milliseconds since epoch."""
317        return self._inner.hi
318
319    @property
320    def meta(self) -> int:
321        """Raw 64-bit metadata word."""
322        return self._inner.meta
323
324    @property
325    def lo_granularity(self) -> str:
326        """Lower bound granularity name."""
327        return self._inner.lo_granularity
328
329    @property
330    def hi_granularity(self) -> str:
331        """Upper bound granularity name."""
332        return self._inner.hi_granularity
333
334    @property
335    def lo_certainty(self) -> str:
336        """Lower bound certainty name."""
337        return self._inner.lo_certainty
338
339    @property
340    def hi_certainty(self) -> str:
341        """Upper bound certainty name."""
342        return self._inner.hi_certainty
343
344    @property
345    def duration_ms(self) -> int | None:
346        """Duration in milliseconds, or None if unbounded."""
347        return self._inner.duration_ms
348
349    @property
350    def is_instant(self) -> bool:
351        """True if the interval is exactly 1 millisecond wide."""
352        return self._inner.is_instant
353
354    @property
355    def is_unbounded(self) -> bool:
356        """True if either bound is infinite."""
357        return self._inner.is_unbounded
358
359    @property
360    def is_finite(self) -> bool:
361        """True if both bounds are finite."""
362        return self._inner.is_finite
363
364    def __repr__(self) -> str:
365        return f'Btic("{self._inner}")'
366
367    def __str__(self) -> str:
368        return str(self._inner)
369
370    def __eq__(self, other: object) -> bool:
371        if isinstance(other, Btic):
372            return self._inner == other._inner
373        return False
374
375    def __hash__(self) -> int:
376        return hash(self._inner)
377
378    @classmethod
379    def __get_pydantic_core_schema__(
380        cls, source_type: Any, handler: GetCoreSchemaHandler
381    ) -> CoreSchema:
382        """Make Btic compatible with Pydantic v2."""
383
384        def validate_btic(v: Any) -> Btic:
385            if isinstance(v, Btic):
386                return v
387            if isinstance(v, str):
388                return Btic(v)
389            if _PyBtic is not None and isinstance(v, _PyBtic):
390                return Btic(v)
391            raise TypeError(f"Expected str or Btic, got {type(v)}")
392
393        return core_schema.no_info_plain_validator_function(
394            validate_btic,
395            serialization=core_schema.plain_serializer_function_ser_schema(
396                lambda v: str(v._inner) if isinstance(v, Btic) else str(v),
397                info_arg=False,
398            ),
399        )

A BTIC temporal interval value for Uni graph database.

Construct from an ISO 8601-inspired string literal::

Btic("1985")
Btic("1985-03/2024-06")
Btic("~1985")           # approximate certainty
Btic("2020-03/")        # ongoing (unbounded hi)

Use as a Pydantic model field type::

class Event(UniNode):
    when: Btic
Btic(value: str | object)
297    def __init__(self, value: str | object) -> None:
298        if _PyBtic is None:
299            raise ImportError("uni_db is required for Btic type")
300        if isinstance(value, str):
301            self._inner = _PyBtic(value)
302        elif _PyBtic is not None and isinstance(value, _PyBtic):
303            self._inner = value
304        elif isinstance(value, Btic):
305            self._inner = value._inner
306        else:
307            raise TypeError(f"Expected str or Btic, got {type(value)}")
lo: int
309    @property
310    def lo(self) -> int:
311        """Lower bound in milliseconds since epoch."""
312        return self._inner.lo

Lower bound in milliseconds since epoch.

hi: int
314    @property
315    def hi(self) -> int:
316        """Upper bound in milliseconds since epoch."""
317        return self._inner.hi

Upper bound in milliseconds since epoch.

meta: int
319    @property
320    def meta(self) -> int:
321        """Raw 64-bit metadata word."""
322        return self._inner.meta

Raw 64-bit metadata word.

lo_granularity: str
324    @property
325    def lo_granularity(self) -> str:
326        """Lower bound granularity name."""
327        return self._inner.lo_granularity

Lower bound granularity name.

hi_granularity: str
329    @property
330    def hi_granularity(self) -> str:
331        """Upper bound granularity name."""
332        return self._inner.hi_granularity

Upper bound granularity name.

lo_certainty: str
334    @property
335    def lo_certainty(self) -> str:
336        """Lower bound certainty name."""
337        return self._inner.lo_certainty

Lower bound certainty name.

hi_certainty: str
339    @property
340    def hi_certainty(self) -> str:
341        """Upper bound certainty name."""
342        return self._inner.hi_certainty

Upper bound certainty name.

duration_ms: int | None
344    @property
345    def duration_ms(self) -> int | None:
346        """Duration in milliseconds, or None if unbounded."""
347        return self._inner.duration_ms

Duration in milliseconds, or None if unbounded.

is_instant: bool
349    @property
350    def is_instant(self) -> bool:
351        """True if the interval is exactly 1 millisecond wide."""
352        return self._inner.is_instant

True if the interval is exactly 1 millisecond wide.

is_unbounded: bool
354    @property
355    def is_unbounded(self) -> bool:
356        """True if either bound is infinite."""
357        return self._inner.is_unbounded

True if either bound is infinite.

is_finite: bool
359    @property
360    def is_finite(self) -> bool:
361        """True if both bounds are finite."""
362        return self._inner.is_finite

True if both bounds are finite.

class Vector(typing.Generic[~N]):
 67class Vector(Generic[N], metaclass=VectorMeta):
 68    """
 69    A vector type with fixed dimensions for embeddings.
 70
 71    Usage:
 72        embedding: Vector[1536]  # 1536-dimensional vector
 73
 74    At runtime, vectors are stored as list[float].
 75    """
 76
 77    __dimensions__: int = 0
 78    __origin__: type | None = None
 79
 80    def __init__(self, values: list[float]) -> None:
 81        expected = self.__class__.__dimensions__
 82        if expected > 0 and len(values) != expected:
 83            raise ValueError(f"Vector expects {expected} dimensions, got {len(values)}")
 84        self._values = values
 85
 86    @property
 87    def values(self) -> list[float]:
 88        return self._values
 89
 90    def __repr__(self) -> str:
 91        dims = self.__class__.__dimensions__
 92        return (
 93            f"Vector[{dims}]({self._values[:3]}...)"
 94            if len(self._values) > 3
 95            else f"Vector[{dims}]({self._values})"
 96        )
 97
 98    def __eq__(self, other: object) -> bool:
 99        if isinstance(other, Vector):
100            return self._values == other._values
101        if isinstance(other, list):
102            return self._values == other
103        return False
104
105    def __len__(self) -> int:
106        return len(self._values)
107
108    def __iter__(self):  # type: ignore[no-untyped-def]
109        return iter(self._values)
110
111    @classmethod
112    def __get_pydantic_core_schema__(
113        cls, source_type: Any, handler: GetCoreSchemaHandler
114    ) -> CoreSchema:
115        """Make Vector compatible with Pydantic v2."""
116        dimensions = getattr(source_type, "__dimensions__", 0)
117        vec_cls = source_type if dimensions > 0 else cls
118
119        def validate_vector(v: Any) -> Vector:  # type: ignore[type-arg]
120            if isinstance(v, Vector):
121                if dimensions > 0 and len(v) != dimensions:
122                    raise ValueError(
123                        f"Vector expects {dimensions} dimensions, got {len(v)}"
124                    )
125                return v
126            if isinstance(v, list):
127                if dimensions > 0 and len(v) != dimensions:
128                    raise ValueError(
129                        f"Vector expects {dimensions} dimensions, got {len(v)}"
130                    )
131                return vec_cls([float(x) for x in v])
132            raise TypeError(f"Expected list or Vector, got {type(v)}")
133
134        return core_schema.no_info_plain_validator_function(
135            validate_vector,
136            serialization=core_schema.plain_serializer_function_ser_schema(
137                lambda v: v.values if isinstance(v, Vector) else list(v),
138                info_arg=False,
139            ),
140        )

A vector type with fixed dimensions for embeddings.

Usage: embedding: Vector[1536] # 1536-dimensional vector

At runtime, vectors are stored as list[float].

Vector(values: list[float])
80    def __init__(self, values: list[float]) -> None:
81        expected = self.__class__.__dimensions__
82        if expected > 0 and len(values) != expected:
83            raise ValueError(f"Vector expects {expected} dimensions, got {len(values)}")
84        self._values = values
values: list[float]
86    @property
87    def values(self) -> list[float]:
88        return self._values
class SparseVector(typing.Generic[~N]):
182class SparseVector(Generic[N], metaclass=SparseVectorMeta):
183    """
184    A learned-sparse (SPLADE / BGE-M3) vector over a fixed-size vocabulary.
185
186    Usage:
187        terms: SparseVector[30522]  # SPLADE head over a 30522-term BERT vocab
188
189    At runtime, holds parallel ``indices`` (term ids) and ``values`` (weights).
190    Accepts a ``dict[int, float]`` of term id -> weight, a ``uni_db.SparseVector``,
191    or an existing instance; ingestion serializes to the typed Rust binding when
192    available, otherwise to an ``{"indices": [...], "values": [...]}`` mapping.
193    """
194
195    __sparse_dimensions__: int = 0
196    __origin__: type | None = None
197
198    def __init__(self, indices: list[int], values: list[float]) -> None:
199        if len(indices) != len(values):
200            raise ValueError(
201                f"SparseVector indices/values length mismatch: "
202                f"{len(indices)} vs {len(values)}"
203            )
204        self._indices = [int(i) for i in indices]
205        self._values = [float(v) for v in values]
206
207    @property
208    def indices(self) -> list[int]:
209        return self._indices
210
211    @property
212    def values(self) -> list[float]:
213        return self._values
214
215    @classmethod
216    def from_dict(cls, mapping: dict[int, float]) -> SparseVector[Any]:
217        """Build from a ``{term_id: weight}`` mapping (sorted by term id)."""
218        items = sorted(mapping.items())
219        return cls([k for k, _ in items], [v for _, v in items])
220
221    def __repr__(self) -> str:
222        dims = self.__class__.__sparse_dimensions__
223        return f"SparseVector[{dims}](indices={self._indices}, values={self._values})"
224
225    def __eq__(self, other: object) -> bool:
226        if isinstance(other, SparseVector):
227            return self._indices == other._indices and self._values == other._values
228        return False
229
230    def __len__(self) -> int:
231        return len(self._indices)
232
233    @classmethod
234    def __get_pydantic_core_schema__(
235        cls, source_type: Any, handler: GetCoreSchemaHandler
236    ) -> CoreSchema:
237        """Make SparseVector compatible with Pydantic v2."""
238        dimensions = getattr(source_type, "__sparse_dimensions__", 0)
239        sv_cls = source_type if dimensions > 0 else cls
240
241        def validate_sparse(v: Any) -> SparseVector:  # type: ignore[type-arg]
242            if isinstance(v, SparseVector):
243                return v
244            if _PySparseVector is not None and isinstance(v, _PySparseVector):
245                return sv_cls(list(v.indices), list(v.values))
246            if isinstance(v, dict):
247                return sv_cls.from_dict(v)
248            if isinstance(v, (tuple, list)) and len(v) == 2:
249                return sv_cls(list(v[0]), list(v[1]))
250            raise TypeError(
251                f"Expected SparseVector, dict, or (indices, values), got {type(v)}"
252            )
253
254        def serialize_sparse(v: SparseVector) -> Any:  # type: ignore[type-arg]
255            if _PySparseVector is not None:
256                return _PySparseVector(v.indices, v.values)
257            return {"indices": v.indices, "values": v.values}
258
259        return core_schema.no_info_plain_validator_function(
260            validate_sparse,
261            serialization=core_schema.plain_serializer_function_ser_schema(
262                serialize_sparse,
263                info_arg=False,
264            ),
265        )

A learned-sparse (SPLADE / BGE-M3) vector over a fixed-size vocabulary.

Usage: terms: SparseVector[30522] # SPLADE head over a 30522-term BERT vocab

At runtime, holds parallel indices (term ids) and values (weights). Accepts a dict[int, float] of term id -> weight, a uni_db.SparseVector, or an existing instance; ingestion serializes to the typed Rust binding when available, otherwise to an {"indices": [...], "values": [...]} mapping.

SparseVector(indices: list[int], values: list[float])
198    def __init__(self, indices: list[int], values: list[float]) -> None:
199        if len(indices) != len(values):
200            raise ValueError(
201                f"SparseVector indices/values length mismatch: "
202                f"{len(indices)} vs {len(values)}"
203            )
204        self._indices = [int(i) for i in indices]
205        self._values = [float(v) for v in values]
indices: list[int]
207    @property
208    def indices(self) -> list[int]:
209        return self._indices
values: list[float]
211    @property
212    def values(self) -> list[float]:
213        return self._values
@classmethod
def from_dict(cls, mapping: dict[int, float]) -> 'SparseVector[Any]':
215    @classmethod
216    def from_dict(cls, mapping: dict[int, float]) -> SparseVector[Any]:
217        """Build from a ``{term_id: weight}`` mapping (sorted by term id)."""
218        items = sorted(mapping.items())
219        return cls([k for k, _ in items], [v for _, v in items])

Build from a {term_id: weight} mapping (sorted by term id).

def python_type_to_uni(type_hint: Any, *, nullable: bool = False) -> tuple[str, bool]:
558def python_type_to_uni(type_hint: Any, *, nullable: bool = False) -> tuple[str, bool]:
559    """
560    Convert a Python type hint to a Uni DataType string.
561
562    Args:
563        type_hint: The Python type hint to convert.
564        nullable: Whether the field is explicitly nullable.
565
566    Returns:
567        Tuple of (uni_data_type, is_nullable)
568
569    Raises:
570        TypeMappingError: If the type cannot be mapped.
571    """
572    # Unwrap Annotated if present
573    type_hint, _ = unwrap_annotated(type_hint)
574
575    # Check for optional (T | None)
576    is_opt, inner_type = is_optional(type_hint)
577    if is_opt:
578        uni_type, _ = python_type_to_uni(inner_type)
579        return uni_type, True
580
581    # Check for SparseVector types (before dense Vector: distinct marker attr).
582    sparse_dims = get_sparse_vector_dimensions(type_hint)
583    if sparse_dims is not None:
584        return f"sparse_vector:{sparse_dims}", nullable
585
586    # Check for Vector types
587    dims = get_vector_dimensions(type_hint)
588    if dims is not None:
589        return f"vector:{dims}", nullable
590
591    # Check for list types
592    is_lst, elem_type = is_list_type(type_hint)
593    if is_lst:
594        if elem_type in (str, int, float, bool):
595            # Simple list types
596            elem_uni = TYPE_MAP.get(elem_type, "string")
597            return f"list:{elem_uni}", nullable
598        # list[Vector[N]] (multi-vector / ColBERT) -> list:vector:N
599        elem_dims = get_vector_dimensions(elem_type)
600        if elem_dims is not None:
601            return f"list:vector:{elem_dims}", nullable
602        # Complex list types stored as JSON
603        return "json", nullable
604
605    # Direct type mapping
606    if type_hint in TYPE_MAP:
607        return TYPE_MAP[type_hint], nullable
608
609    # Handle generic dict types -> typed MAP<STRING, V> when the key is `str` and the
610    # value type maps to a concrete Uni type; otherwise schemaless JSON. Keys must be
611    # `str` because the storage value model (Value::Map) is string-keyed.
612    origin = get_origin(type_hint)
613    if origin is dict:
614        args = get_args(type_hint)
615        if len(args) == 2 and args[0] is str:
616            try:
617                val_uni, _ = python_type_to_uni(args[1])
618            except TypeMappingError:
619                val_uni = "json"
620            # Recurse handles nested values: dict[str, list[int]] -> map:string:list:int64,
621            # dict[str, Vector[N]] -> map:string:vector:N, dict[str, dict[str,int]] -> nested.
622            if val_uni != "json":
623                return f"map:string:{val_uni}", nullable
624        return "json", nullable
625
626    # Handle forward references (strings)
627    if isinstance(type_hint, str):
628        # This is a forward reference, can't resolve here
629        raise TypeMappingError(
630            type_hint,
631            f"Cannot resolve forward reference {type_hint!r}. "
632            "Ensure the referenced class is defined before schema sync.",
633        )
634
635    raise TypeMappingError(type_hint)

Convert a Python type hint to a Uni DataType string.

Args: type_hint: The Python type hint to convert. nullable: Whether the field is explicitly nullable.

Returns: Tuple of (uni_data_type, is_nullable)

Raises: TypeMappingError: If the type cannot be mapped.

def uni_to_python_type(uni_type: str) -> type:
638def uni_to_python_type(uni_type: str) -> type:
639    """
640    Convert a Uni DataType string to a Python type.
641
642    Args:
643        uni_type: The Uni data type string.
644
645    Returns:
646        The corresponding Python type.
647    """
648    # Reverse mapping — manually constructed to avoid bytes overwriting str for "string"
649    _REVERSE_MAP: dict[str, type] = {
650        "string": str,
651        "int64": int,
652        "float64": float,
653        "bool": bool,
654        "datetime": datetime,
655        "date": date,
656        "time": time,
657        "duration": timedelta,
658        "json": dict,
659        "btic": Btic,
660        "bytes": bytes,
661    }
662
663    # Handle vector types
664    if uni_type.startswith("vector:"):
665        return list  # Vectors are stored as list[float]
666
667    # Handle list types
668    if uni_type.startswith("list:"):
669        return list
670
671    return _REVERSE_MAP.get(uni_type.lower(), str)

Convert a Uni DataType string to a Python type.

Args: uni_type: The Uni data type string.

Returns: The corresponding Python type.

def get_vector_dimensions(type_hint: Any) -> int | None:
143def get_vector_dimensions(type_hint: Any) -> int | None:
144    """Extract vector dimensions from a Vector[N] type hint."""
145    if hasattr(type_hint, "__dimensions__"):
146        dims: int = type_hint.__dimensions__
147        return dims
148    origin = get_origin(type_hint)
149    if origin is Vector:
150        args = get_args(type_hint)
151        if args and isinstance(args[0], int):
152            return args[0]
153    return None

Extract vector dimensions from a Vector[N] type hint.

def get_sparse_vector_dimensions(type_hint: Any) -> int | None:
268def get_sparse_vector_dimensions(type_hint: Any) -> int | None:
269    """Extract the vocabulary size from a SparseVector[N] type hint."""
270    if hasattr(type_hint, "__sparse_dimensions__"):
271        dims: int = type_hint.__sparse_dimensions__
272        return dims
273    origin = get_origin(type_hint)
274    if origin is SparseVector:
275        args = get_args(type_hint)
276        if args and isinstance(args[0], int):
277            return args[0]
278    return None

Extract the vocabulary size from a SparseVector[N] type hint.

def is_optional(type_hint: Any) -> tuple[bool, typing.Any]:
402def is_optional(type_hint: Any) -> tuple[bool, Any]:
403    """
404    Check if a type hint is Optional (T | None).
405
406    Returns:
407        Tuple of (is_optional, inner_type)
408    """
409    origin = get_origin(type_hint)
410
411    # Handle Union types (including T | None which is Union[T, None])
412    if origin is Union:
413        args = get_args(type_hint)
414        non_none_args = [arg for arg in args if arg is not type(None)]
415        if len(non_none_args) == 1 and type(None) in args:
416            return True, non_none_args[0]
417
418    # Python 3.10+ uses types.UnionType for X | Y syntax
419    if isinstance(type_hint, types.UnionType):
420        args = get_args(type_hint)
421        non_none_args = [arg for arg in args if arg is not type(None)]
422        if len(non_none_args) == 1 and type(None) in args:
423            return True, non_none_args[0]
424
425    return False, type_hint

Check if a type hint is Optional (T | None).

Returns: Tuple of (is_optional, inner_type)

def is_list_type(type_hint: Any) -> tuple[bool, typing.Any | None]:
428def is_list_type(type_hint: Any) -> tuple[bool, Any | None]:
429    """
430    Check if a type hint is a list type.
431
432    Returns:
433        Tuple of (is_list, element_type)
434    """
435    origin = get_origin(type_hint)
436    if origin is list:
437        args = get_args(type_hint)
438        return True, args[0] if args else Any
439    return False, None

Check if a type hint is a list type.

Returns: Tuple of (is_list, element_type)

def unwrap_annotated(type_hint: Any) -> tuple[typing.Any, tuple[typing.Any, ...]]:
442def unwrap_annotated(type_hint: Any) -> tuple[Any, tuple[Any, ...]]:
443    """
444    Unwrap an Annotated type.
445
446    Returns:
447        Tuple of (base_type, metadata_tuple)
448    """
449    origin = get_origin(type_hint)
450    if origin is Annotated:
451        args = get_args(type_hint)
452        return args[0], args[1:]
453    return type_hint, ()

Unwrap an Annotated type.

Returns: Tuple of (base_type, metadata_tuple)

def python_to_db_value(value: Any, type_hint: Any) -> Any:
460def python_to_db_value(value: Any, type_hint: Any) -> Any:
461    """Convert a Python value to a database-compatible value.
462
463    Passes datetime/date/time/timedelta through to the Rust layer which
464    converts them to proper Value::Temporal types. Converts Vector to
465    list[float] and passes through everything else.
466    """
467    if value is None:
468        return None
469
470    # list[Vector[N]] → list[list[float]] (multi-vector / ColBERT)
471    if isinstance(value, list) and value and isinstance(value[0], Vector):
472        return [v.values if isinstance(v, Vector) else v for v in value]
473
474    # Vector → list[float]
475    if isinstance(value, Vector):
476        return value.values
477
478    # Btic → unwrap to the Rust PyBtic for py_object_to_value
479    if isinstance(value, Btic):
480        return value._inner
481
482    # datetime/date/time/timedelta pass through — the Rust py_object_to_value
483    # handles conversion to Value::Temporal with proper type information.
484    return value

Convert a Python value to a database-compatible value.

Passes datetime/date/time/timedelta through to the Rust layer which converts them to proper Value::Temporal types. Converts Vector to list[float] and passes through everything else.

def db_to_python_value(value: Any, type_hint: Any) -> Any:
487def db_to_python_value(value: Any, type_hint: Any) -> Any:
488    """Convert a database value back to a Python value.
489
490    The Rust layer now returns proper Python datetime/date/time objects
491    via Value::Temporal, so in most cases values pass through directly.
492    """
493    if value is None:
494        return None
495
496    # Unwrap Optional
497    _, inner = is_optional(type_hint)
498    if inner is not type_hint:
499        type_hint = inner
500
501    # Unwrap Annotated
502    type_hint, _ = unwrap_annotated(type_hint)
503
504    # If value is already the right Python type, pass through
505    if type_hint is datetime and isinstance(value, datetime):
506        return value
507    if type_hint is date and isinstance(value, date):
508        return value
509    if type_hint is time and isinstance(value, time):
510        return value
511    if type_hint is timedelta and isinstance(value, timedelta):
512        return value
513
514    # Btic — wrap Rust PyBtic in the pydantic Btic wrapper
515    if type_hint is Btic and _PyBtic is not None and isinstance(value, _PyBtic):
516        return Btic(value)
517
518    # Handle struct dict from Arrow deserialization (e.g. datetime struct)
519    if type_hint is datetime and isinstance(value, dict):
520        nanos = value.get("nanos_since_epoch")
521        if nanos is not None:
522            return datetime.fromtimestamp(nanos / 1_000_000_000)
523        return None
524
525    # list[Vector[N]] fields: list[list[float]] → list[Vector[N]] (multi-vector)
526    is_lst, elem_type = is_list_type(type_hint)
527    if is_lst:
528        elem_dims = get_vector_dimensions(elem_type)
529        if elem_dims is not None and isinstance(value, list):
530            vec_cls = Vector[elem_dims]
531            return [vec_cls(v) if isinstance(v, list) else v for v in value]
532
533    # Vector fields: list[float] → Vector
534    dims = get_vector_dimensions(type_hint)
535    if dims is not None and isinstance(value, list):
536        vec_cls = Vector[dims]
537        return vec_cls(value)
538
539    return value

Convert a database value back to a Python value.

The Rust layer now returns proper Python datetime/date/time objects via Value::Temporal, so in most cases values pass through directly.

DATETIME_TYPES = {<class 'datetime.date'>, <class 'datetime.datetime'>, <class 'datetime.timedelta'>, <class 'Btic'>, <class 'datetime.time'>}
class QueryBuilder(uni_pydantic.query._QueryBuilderBase[~NodeT]):
1003class QueryBuilder(_QueryBuilderBase[NodeT]):
1004    """
1005    Immutable, type-safe query builder for graph queries.
1006
1007    Each method returns a **new** QueryBuilder instance. The original is
1008    never mutated. Provides a fluent API for building Cypher queries
1009    with type checking and IDE autocomplete support.
1010
1011    Example:
1012        >>> adults = (
1013        ...     session.query(Person)
1014        ...     .filter(Person.age >= 18)
1015        ...     .order_by(Person.name)
1016        ...     .limit(10)
1017        ...     .all()
1018        ... )
1019    """
1020
1021    def __init__(self, session: UniSession, model: type[NodeT]) -> None:
1022        self._init_state(session, model)
1023
1024    def _execute_query(
1025        self, cypher: str, params: dict[str, Any]
1026    ) -> list[dict[str, Any]]:
1027        """Execute a query, using query_with if timeout/max_memory is set."""
1028        if self._timeout is not None or self._max_memory is not None:
1029            builder = self._session._db_session.query_with(cypher)
1030            if params:
1031                builder = builder.params(params)
1032            if self._timeout is not None:
1033                builder = builder.timeout(self._timeout)
1034            if self._max_memory is not None:
1035                builder = builder.max_memory(self._max_memory)
1036            result = builder.fetch_all()
1037        else:
1038            result = self._session._db_session.query(cypher, params)
1039        return [row.to_dict() for row in result]
1040
1041    def all(self) -> list[NodeT]:
1042        """Execute the query and return all results."""
1043        cypher, params = self._build_cypher()
1044        results = self._execute_query(cypher, params)
1045        if self._is_search():
1046            instances = self._rows_to_scored_instances(results)
1047        else:
1048            instances = self._rows_to_instances(results)
1049        if self._eager_load and instances:
1050            self._session._eager_load_relationships(instances, self._eager_load)
1051        return instances
1052
1053    def first(self) -> NodeT | None:
1054        """Execute the query and return the first result."""
1055        clone = self._clone()
1056        clone._limit = 1
1057        results = clone.all()
1058        return results[0] if results else None
1059
1060    def one(self) -> NodeT:
1061        """Execute the query and return exactly one result.
1062
1063        Raises QueryError if no results or more than one result.
1064        """
1065        clone = self._clone()
1066        clone._limit = 2
1067        results = clone.all()
1068        if not results:
1069            raise QueryError("Query returned no results")
1070        if len(results) > 1:
1071            raise QueryError("Query returned more than one result")
1072        return results[0]
1073
1074    def count(self) -> int:
1075        """Execute the query and return the count of results."""
1076        cypher, params = self._build_count_cypher()
1077        results = self._execute_query(cypher, params)
1078        return cast(int, results[0]["count"]) if results else 0
1079
1080    def exists(self) -> bool:
1081        """Check if any matching records exist."""
1082        cypher, params = self._build_exists_cypher()
1083        results = self._execute_query(cypher, params)
1084        return len(results) > 0
1085
1086    def delete(self) -> int:
1087        """Delete all matching records (DETACH DELETE)."""
1088        cypher, params = self._build_delete_cypher()
1089        with self._session._db_session.tx() as tx:
1090            results = tx.query(cypher, params)
1091            tx.commit()
1092        return results[0].to_dict()["count"] if results else 0
1093
1094    def update(self, **kwargs: Any) -> int:
1095        """Update all matching records."""
1096        cypher, params = self._build_update_cypher(**kwargs)
1097        with self._session._db_session.tx() as tx:
1098            results = tx.query(cypher, params)
1099            tx.commit()
1100        return results[0].to_dict()["count"] if results else 0

Immutable, type-safe query builder for graph queries.

Each method returns a new QueryBuilder instance. The original is never mutated. Provides a fluent API for building Cypher queries with type checking and IDE autocomplete support.

Example:

adults = ( ... session.query(Person) ... .filter(Person.age >= 18) ... .order_by(Person.name) ... .limit(10) ... .all() ... )

QueryBuilder(session: UniSession, model: type[~NodeT])
1021    def __init__(self, session: UniSession, model: type[NodeT]) -> None:
1022        self._init_state(session, model)
def all(self) -> list[~NodeT]:
1041    def all(self) -> list[NodeT]:
1042        """Execute the query and return all results."""
1043        cypher, params = self._build_cypher()
1044        results = self._execute_query(cypher, params)
1045        if self._is_search():
1046            instances = self._rows_to_scored_instances(results)
1047        else:
1048            instances = self._rows_to_instances(results)
1049        if self._eager_load and instances:
1050            self._session._eager_load_relationships(instances, self._eager_load)
1051        return instances

Execute the query and return all results.

def first(self) -> Optional[~NodeT]:
1053    def first(self) -> NodeT | None:
1054        """Execute the query and return the first result."""
1055        clone = self._clone()
1056        clone._limit = 1
1057        results = clone.all()
1058        return results[0] if results else None

Execute the query and return the first result.

def one(self) -> ~NodeT:
1060    def one(self) -> NodeT:
1061        """Execute the query and return exactly one result.
1062
1063        Raises QueryError if no results or more than one result.
1064        """
1065        clone = self._clone()
1066        clone._limit = 2
1067        results = clone.all()
1068        if not results:
1069            raise QueryError("Query returned no results")
1070        if len(results) > 1:
1071            raise QueryError("Query returned more than one result")
1072        return results[0]

Execute the query and return exactly one result.

Raises QueryError if no results or more than one result.

def count(self) -> int:
1074    def count(self) -> int:
1075        """Execute the query and return the count of results."""
1076        cypher, params = self._build_count_cypher()
1077        results = self._execute_query(cypher, params)
1078        return cast(int, results[0]["count"]) if results else 0

Execute the query and return the count of results.

def exists(self) -> bool:
1080    def exists(self) -> bool:
1081        """Check if any matching records exist."""
1082        cypher, params = self._build_exists_cypher()
1083        results = self._execute_query(cypher, params)
1084        return len(results) > 0

Check if any matching records exist.

def delete(self) -> int:
1086    def delete(self) -> int:
1087        """Delete all matching records (DETACH DELETE)."""
1088        cypher, params = self._build_delete_cypher()
1089        with self._session._db_session.tx() as tx:
1090            results = tx.query(cypher, params)
1091            tx.commit()
1092        return results[0].to_dict()["count"] if results else 0

Delete all matching records (DETACH DELETE).

def update(self, **kwargs: Any) -> int:
1094    def update(self, **kwargs: Any) -> int:
1095        """Update all matching records."""
1096        cypher, params = self._build_update_cypher(**kwargs)
1097        with self._session._db_session.tx() as tx:
1098            results = tx.query(cypher, params)
1099            tx.commit()
1100        return results[0].to_dict()["count"] if results else 0

Update all matching records.

class AsyncQueryBuilder(uni_pydantic.query._QueryBuilderBase[~NodeT]):
 26class AsyncQueryBuilder(_QueryBuilderBase[NodeT]):
 27    """
 28    Immutable, async query builder for graph queries.
 29
 30    Inherits all Cypher-building and immutable builder methods from
 31    ``_QueryBuilderBase``. Only the execution methods are async.
 32    """
 33
 34    def __init__(self, session: AsyncUniSession, model: type[NodeT]) -> None:
 35        self._init_state(session, model)
 36
 37    async def _execute_query(
 38        self, cypher: str, params: dict[str, Any]
 39    ) -> list[dict[str, Any]]:
 40        """Execute a query, using query_with if timeout/max_memory is set."""
 41        if self._timeout is not None or self._max_memory is not None:
 42            builder = self._session._db_session.query_with(cypher)
 43            if params:
 44                builder = builder.params(params)
 45            if self._timeout is not None:
 46                builder = builder.timeout(self._timeout)
 47            if self._max_memory is not None:
 48                builder = builder.max_memory(self._max_memory)
 49            result = await builder.fetch_all()
 50        else:
 51            result = await self._session._db_session.query(cypher, params)
 52        return [row.to_dict() for row in result]
 53
 54    async def all(self) -> list[NodeT]:
 55        """Execute the query and return all results."""
 56        cypher, params = self._build_cypher()
 57        results = await self._execute_query(cypher, params)
 58        if self._is_search():
 59            instances = self._rows_to_scored_instances(results)
 60        else:
 61            instances = self._rows_to_instances(results)
 62        if self._eager_load and instances:
 63            await self._session._async_eager_load_relationships(
 64                instances, self._eager_load
 65            )
 66        return instances
 67
 68    async def first(self) -> NodeT | None:
 69        """Execute the query and return the first result."""
 70        clone = self._clone()
 71        clone._limit = 1
 72        results = await clone.all()
 73        return results[0] if results else None
 74
 75    async def one(self) -> NodeT:
 76        """Execute the query and return exactly one result.
 77
 78        Raises QueryError if no results or more than one result.
 79        """
 80        clone = self._clone()
 81        clone._limit = 2
 82        results = await clone.all()
 83        if not results:
 84            raise QueryError("Query returned no results")
 85        if len(results) > 1:
 86            raise QueryError("Query returned more than one result")
 87        return results[0]
 88
 89    async def count(self) -> int:
 90        """Execute the query and return the count of results."""
 91        cypher, params = self._build_count_cypher()
 92        results = await self._execute_query(cypher, params)
 93        return cast(int, results[0]["count"]) if results else 0
 94
 95    async def exists(self) -> bool:
 96        """Check if any matching records exist."""
 97        cypher, params = self._build_exists_cypher()
 98        results = await self._execute_query(cypher, params)
 99        return len(results) > 0
100
101    async def delete(self) -> int:
102        """Delete all matching records (DETACH DELETE)."""
103        cypher, params = self._build_delete_cypher()
104        async with await self._session._db_session.tx() as tx:
105            results = await tx.query(cypher, params)
106            await tx.commit()
107        return results[0].to_dict()["count"] if results else 0
108
109    async def update(self, **kwargs: Any) -> int:
110        """Update all matching records."""
111        cypher, params = self._build_update_cypher(**kwargs)
112        async with await self._session._db_session.tx() as tx:
113            results = await tx.query(cypher, params)
114            await tx.commit()
115        return results[0].to_dict()["count"] if results else 0

Immutable, async query builder for graph queries.

Inherits all Cypher-building and immutable builder methods from _QueryBuilderBase. Only the execution methods are async.

AsyncQueryBuilder( session: AsyncUniSession, model: type[~NodeT])
34    def __init__(self, session: AsyncUniSession, model: type[NodeT]) -> None:
35        self._init_state(session, model)
async def all(self) -> list[~NodeT]:
54    async def all(self) -> list[NodeT]:
55        """Execute the query and return all results."""
56        cypher, params = self._build_cypher()
57        results = await self._execute_query(cypher, params)
58        if self._is_search():
59            instances = self._rows_to_scored_instances(results)
60        else:
61            instances = self._rows_to_instances(results)
62        if self._eager_load and instances:
63            await self._session._async_eager_load_relationships(
64                instances, self._eager_load
65            )
66        return instances

Execute the query and return all results.

async def first(self) -> Optional[~NodeT]:
68    async def first(self) -> NodeT | None:
69        """Execute the query and return the first result."""
70        clone = self._clone()
71        clone._limit = 1
72        results = await clone.all()
73        return results[0] if results else None

Execute the query and return the first result.

async def one(self) -> ~NodeT:
75    async def one(self) -> NodeT:
76        """Execute the query and return exactly one result.
77
78        Raises QueryError if no results or more than one result.
79        """
80        clone = self._clone()
81        clone._limit = 2
82        results = await clone.all()
83        if not results:
84            raise QueryError("Query returned no results")
85        if len(results) > 1:
86            raise QueryError("Query returned more than one result")
87        return results[0]

Execute the query and return exactly one result.

Raises QueryError if no results or more than one result.

async def count(self) -> int:
89    async def count(self) -> int:
90        """Execute the query and return the count of results."""
91        cypher, params = self._build_count_cypher()
92        results = await self._execute_query(cypher, params)
93        return cast(int, results[0]["count"]) if results else 0

Execute the query and return the count of results.

async def exists(self) -> bool:
95    async def exists(self) -> bool:
96        """Check if any matching records exist."""
97        cypher, params = self._build_exists_cypher()
98        results = await self._execute_query(cypher, params)
99        return len(results) > 0

Check if any matching records exist.

async def delete(self) -> int:
101    async def delete(self) -> int:
102        """Delete all matching records (DETACH DELETE)."""
103        cypher, params = self._build_delete_cypher()
104        async with await self._session._db_session.tx() as tx:
105            results = await tx.query(cypher, params)
106            await tx.commit()
107        return results[0].to_dict()["count"] if results else 0

Delete all matching records (DETACH DELETE).

async def update(self, **kwargs: Any) -> int:
109    async def update(self, **kwargs: Any) -> int:
110        """Update all matching records."""
111        cypher, params = self._build_update_cypher(**kwargs)
112        async with await self._session._db_session.tx() as tx:
113            results = await tx.query(cypher, params)
114            await tx.commit()
115        return results[0].to_dict()["count"] if results else 0

Update all matching records.

@dataclass
class FilterExpr:
150@dataclass
151class FilterExpr:
152    """A filter expression for a query."""
153
154    property_name: str
155    op: FilterOp
156    value: Any = None
157
158    def to_cypher(self, node_var: str, param_name: str) -> tuple[str, dict[str, Any]]:
159        """Convert to Cypher WHERE clause fragment."""
160        prop = f"{node_var}.{self.property_name}"
161
162        if self.op == FilterOp.IS_NULL:
163            return f"{prop} IS NULL", {}
164        elif self.op == FilterOp.IS_NOT_NULL:
165            return f"{prop} IS NOT NULL", {}
166        elif self.op == FilterOp.IN:
167            return f"{prop} IN ${param_name}", {param_name: self.value}
168        elif self.op == FilterOp.NOT_IN:
169            return f"NOT {prop} IN ${param_name}", {param_name: self.value}
170        elif self.op == FilterOp.LIKE:
171            return f"{prop} =~ ${param_name}", {param_name: self.value}
172        elif self.op == FilterOp.STARTS_WITH:
173            return f"{prop} STARTS WITH ${param_name}", {param_name: self.value}
174        elif self.op == FilterOp.ENDS_WITH:
175            return f"{prop} ENDS WITH ${param_name}", {param_name: self.value}
176        elif self.op == FilterOp.CONTAINS:
177            return f"{prop} CONTAINS ${param_name}", {param_name: self.value}
178        else:
179            return f"{prop} {self.op.value} ${param_name}", {param_name: self.value}

A filter expression for a query.

FilterExpr( property_name: str, op: FilterOp, value: Any = None)
property_name: str
op: FilterOp
value: Any = None
def to_cypher( self, node_var: str, param_name: str) -> tuple[str, dict[str, typing.Any]]:
158    def to_cypher(self, node_var: str, param_name: str) -> tuple[str, dict[str, Any]]:
159        """Convert to Cypher WHERE clause fragment."""
160        prop = f"{node_var}.{self.property_name}"
161
162        if self.op == FilterOp.IS_NULL:
163            return f"{prop} IS NULL", {}
164        elif self.op == FilterOp.IS_NOT_NULL:
165            return f"{prop} IS NOT NULL", {}
166        elif self.op == FilterOp.IN:
167            return f"{prop} IN ${param_name}", {param_name: self.value}
168        elif self.op == FilterOp.NOT_IN:
169            return f"NOT {prop} IN ${param_name}", {param_name: self.value}
170        elif self.op == FilterOp.LIKE:
171            return f"{prop} =~ ${param_name}", {param_name: self.value}
172        elif self.op == FilterOp.STARTS_WITH:
173            return f"{prop} STARTS WITH ${param_name}", {param_name: self.value}
174        elif self.op == FilterOp.ENDS_WITH:
175            return f"{prop} ENDS WITH ${param_name}", {param_name: self.value}
176        elif self.op == FilterOp.CONTAINS:
177            return f"{prop} CONTAINS ${param_name}", {param_name: self.value}
178        else:
179            return f"{prop} {self.op.value} ${param_name}", {param_name: self.value}

Convert to Cypher WHERE clause fragment.

class FilterOp(enum.Enum):
131class FilterOp(Enum):
132    """Filter operation types."""
133
134    EQ = "="
135    NE = "<>"
136    LT = "<"
137    LE = "<="
138    GT = ">"
139    GE = ">="
140    IN = "IN"
141    NOT_IN = "NOT IN"
142    LIKE = "=~"
143    IS_NULL = "IS NULL"
144    IS_NOT_NULL = "IS NOT NULL"
145    STARTS_WITH = "STARTS WITH"
146    ENDS_WITH = "ENDS WITH"
147    CONTAINS = "CONTAINS"

Filter operation types.

EQ = <FilterOp.EQ: '='>
NE = <FilterOp.NE: '<>'>
LT = <FilterOp.LT: '<'>
LE = <FilterOp.LE: '<='>
GT = <FilterOp.GT: '>'>
GE = <FilterOp.GE: '>='>
IN = <FilterOp.IN: 'IN'>
NOT_IN = <FilterOp.NOT_IN: 'NOT IN'>
LIKE = <FilterOp.LIKE: '=~'>
IS_NULL = <FilterOp.IS_NULL: 'IS NULL'>
IS_NOT_NULL = <FilterOp.IS_NOT_NULL: 'IS NOT NULL'>
STARTS_WITH = <FilterOp.STARTS_WITH: 'STARTS WITH'>
ENDS_WITH = <FilterOp.ENDS_WITH: 'ENDS WITH'>
CONTAINS = <FilterOp.CONTAINS: 'CONTAINS'>
class PropertyProxy(typing.Generic[~T]):
182class PropertyProxy(Generic[T]):
183    """
184    Proxy for model properties that enables filter expressions.
185
186    Used in query builder to create type-safe filter conditions.
187
188    Example:
189        >>> query.filter(Person.age >= 18)
190        >>> query.filter(Person.name.starts_with("A"))
191    """
192
193    def __init__(self, property_name: str, model: type[UniNode]) -> None:
194        self._property_name = property_name
195        self._model = model
196
197    def __eq__(self, other: Any) -> FilterExpr:  # type: ignore[override]
198        return FilterExpr(self._property_name, FilterOp.EQ, other)
199
200    def __ne__(self, other: Any) -> FilterExpr:  # type: ignore[override]
201        return FilterExpr(self._property_name, FilterOp.NE, other)
202
203    def __lt__(self, other: Any) -> FilterExpr:
204        return FilterExpr(self._property_name, FilterOp.LT, other)
205
206    def __le__(self, other: Any) -> FilterExpr:
207        return FilterExpr(self._property_name, FilterOp.LE, other)
208
209    def __gt__(self, other: Any) -> FilterExpr:
210        return FilterExpr(self._property_name, FilterOp.GT, other)
211
212    def __ge__(self, other: Any) -> FilterExpr:
213        return FilterExpr(self._property_name, FilterOp.GE, other)
214
215    def in_(self, values: Sequence[T]) -> FilterExpr:
216        """Check if value is in a list."""
217        return FilterExpr(self._property_name, FilterOp.IN, list(values))
218
219    def not_in(self, values: Sequence[T]) -> FilterExpr:
220        """Check if value is not in a list."""
221        return FilterExpr(self._property_name, FilterOp.NOT_IN, list(values))
222
223    def like(self, pattern: str) -> FilterExpr:
224        """Match a regex pattern."""
225        return FilterExpr(self._property_name, FilterOp.LIKE, pattern)
226
227    def is_null(self) -> FilterExpr:
228        """Check if value is null."""
229        return FilterExpr(self._property_name, FilterOp.IS_NULL)
230
231    def is_not_null(self) -> FilterExpr:
232        """Check if value is not null."""
233        return FilterExpr(self._property_name, FilterOp.IS_NOT_NULL)
234
235    def starts_with(self, prefix: str) -> FilterExpr:
236        """Check if string starts with prefix."""
237        return FilterExpr(self._property_name, FilterOp.STARTS_WITH, prefix)
238
239    def ends_with(self, suffix: str) -> FilterExpr:
240        """Check if string ends with suffix."""
241        return FilterExpr(self._property_name, FilterOp.ENDS_WITH, suffix)
242
243    def contains(self, substring: str) -> FilterExpr:
244        """Check if string contains substring."""
245        return FilterExpr(self._property_name, FilterOp.CONTAINS, substring)

Proxy for model properties that enables filter expressions.

Used in query builder to create type-safe filter conditions.

Example:

query.filter(Person.age >= 18) query.filter(Person.name.starts_with("A"))

PropertyProxy(property_name: str, model: type[UniNode])
193    def __init__(self, property_name: str, model: type[UniNode]) -> None:
194        self._property_name = property_name
195        self._model = model
def in_(self, values: Sequence[~T]) -> FilterExpr:
215    def in_(self, values: Sequence[T]) -> FilterExpr:
216        """Check if value is in a list."""
217        return FilterExpr(self._property_name, FilterOp.IN, list(values))

Check if value is in a list.

def not_in(self, values: Sequence[~T]) -> FilterExpr:
219    def not_in(self, values: Sequence[T]) -> FilterExpr:
220        """Check if value is not in a list."""
221        return FilterExpr(self._property_name, FilterOp.NOT_IN, list(values))

Check if value is not in a list.

def like(self, pattern: str) -> FilterExpr:
223    def like(self, pattern: str) -> FilterExpr:
224        """Match a regex pattern."""
225        return FilterExpr(self._property_name, FilterOp.LIKE, pattern)

Match a regex pattern.

def is_null(self) -> FilterExpr:
227    def is_null(self) -> FilterExpr:
228        """Check if value is null."""
229        return FilterExpr(self._property_name, FilterOp.IS_NULL)

Check if value is null.

def is_not_null(self) -> FilterExpr:
231    def is_not_null(self) -> FilterExpr:
232        """Check if value is not null."""
233        return FilterExpr(self._property_name, FilterOp.IS_NOT_NULL)

Check if value is not null.

def starts_with(self, prefix: str) -> FilterExpr:
235    def starts_with(self, prefix: str) -> FilterExpr:
236        """Check if string starts with prefix."""
237        return FilterExpr(self._property_name, FilterOp.STARTS_WITH, prefix)

Check if string starts with prefix.

def ends_with(self, suffix: str) -> FilterExpr:
239    def ends_with(self, suffix: str) -> FilterExpr:
240        """Check if string ends with suffix."""
241        return FilterExpr(self._property_name, FilterOp.ENDS_WITH, suffix)

Check if string ends with suffix.

def contains(self, substring: str) -> FilterExpr:
243    def contains(self, substring: str) -> FilterExpr:
244        """Check if string contains substring."""
245        return FilterExpr(self._property_name, FilterOp.CONTAINS, substring)

Check if string contains substring.

class ModelProxy(typing.Generic[~NodeT]):
248class ModelProxy(Generic[NodeT]):
249    """
250    Proxy for model classes that provides property proxies.
251
252    Enables type-safe property access in query filters.
253
254    Example:
255        >>> Person.name  # Returns PropertyProxy for 'name'
256        >>> query.filter(Person.age >= 18)
257    """
258
259    def __init__(self, model: type[NodeT]) -> None:
260        self._model = model
261
262    def __getattr__(self, name: str) -> PropertyProxy[Any]:
263        if name.startswith("_"):
264            raise AttributeError(name)
265        return PropertyProxy(name, self._model)

Proxy for model classes that provides property proxies.

Enables type-safe property access in query filters.

Example:

Person.name # Returns PropertyProxy for 'name' query.filter(Person.age >= 18)

ModelProxy(model: type[~NodeT])
259    def __init__(self, model: type[NodeT]) -> None:
260        self._model = model
@dataclass
class OrderByClause:
268@dataclass
269class OrderByClause:
270    """An ORDER BY clause."""
271
272    property_name: str
273    descending: bool = False

An ORDER BY clause.

OrderByClause(property_name: str, descending: bool = False)
property_name: str
descending: bool = False
@dataclass
class TraversalStep:
276@dataclass
277class TraversalStep:
278    """A relationship traversal step."""
279
280    edge_type: str
281    direction: Literal["outgoing", "incoming", "both"]
282    target_label: str | None = None

A relationship traversal step.

TraversalStep( edge_type: str, direction: Literal['outgoing', 'incoming', 'both'], target_label: str | None = None)
edge_type: str
direction: Literal['outgoing', 'incoming', 'both']
target_label: str | None = None
@dataclass
class VectorSearchConfig:
285@dataclass
286class VectorSearchConfig:
287    """Configuration for vector similarity search."""
288
289    property_name: str
290    query_vector: list[float]
291    k: int
292    threshold: float | None = None
293    pre_filter: str | None = None

Configuration for vector similarity search.

VectorSearchConfig( property_name: str, query_vector: list[float], k: int, threshold: float | None = None, pre_filter: str | None = None)
property_name: str
query_vector: list[float]
k: int
threshold: float | None = None
pre_filter: str | None = None
@dataclass
class SparseSearchConfig:
296@dataclass
297class SparseSearchConfig:
298    """Configuration for learned-sparse (SPLADE) similarity search."""
299
300    property_name: str
301    query_indices: list[int]
302    query_values: list[float]
303    k: int
304    threshold: float | None = None
305    pre_filter: str | None = None

Configuration for learned-sparse (SPLADE) similarity search.

SparseSearchConfig( property_name: str, query_indices: list[int], query_values: list[float], k: int, threshold: float | None = None, pre_filter: str | None = None)
property_name: str
query_indices: list[int]
query_values: list[float]
k: int
threshold: float | None = None
pre_filter: str | None = None
@dataclass
class HybridSearchConfig:
308@dataclass
309class HybridSearchConfig:
310    """Configuration for three-way fused hybrid search (``uni.search``).
311
312    Each ``*_property`` is ``None`` when that retrieval arm is off. ``query_text``
313    is the shared string that drives both FTS matching and dense auto-embed;
314    ``query_vector`` holds a precomputed dense vector (``None`` ⇒ auto-embed from
315    ``query_text``). ``sparse_query`` is the coerced ``(indices, values)`` pair
316    (``None`` ⇒ sparse arm off).
317    """
318
319    query_text: str
320    k: int
321    vector_property: str | None = None
322    fts_property: str | None = None
323    sparse_property: str | None = None
324    query_vector: list[float] | None = None
325    sparse_query: tuple[list[int], list[float]] | None = None
326    method: str = "rrf"
327    alpha: float | None = None
328    weights: list[float] | None = None
329    rrf_k: int | None = None
330    over_fetch: float | None = None
331    filter: str | None = None

Configuration for three-way fused hybrid search (uni.search).

Each *_property is None when that retrieval arm is off. query_text is the shared string that drives both FTS matching and dense auto-embed; query_vector holds a precomputed dense vector (None ⇒ auto-embed from query_text). sparse_query is the coerced (indices, values) pair (None ⇒ sparse arm off).

HybridSearchConfig( query_text: str, k: int, vector_property: str | None = None, fts_property: str | None = None, sparse_property: str | None = None, query_vector: list[float] | None = None, sparse_query: tuple[list[int], list[float]] | None = None, method: str = 'rrf', alpha: float | None = None, weights: list[float] | None = None, rrf_k: int | None = None, over_fetch: float | None = None, filter: str | None = None)
query_text: str
k: int
vector_property: str | None = None
fts_property: str | None = None
sparse_property: str | None = None
query_vector: list[float] | None = None
sparse_query: tuple[list[int], list[float]] | None = None
method: str = 'rrf'
alpha: float | None = None
weights: list[float] | None = None
rrf_k: int | None = None
over_fetch: float | None = None
filter: str | None = None
class SchemaGenerator:
 66class SchemaGenerator:
 67    """Generates Uni database schema from registered models."""
 68
 69    def __init__(self) -> None:
 70        self._node_models: dict[str, type[UniNode]] = {}
 71        self._edge_models: dict[str, type[UniEdge]] = {}
 72        self._schema: DatabaseSchema | None = None
 73
 74    def register_node(self, model: type[UniNode]) -> None:
 75        """Register a node model for schema generation."""
 76        label = model.__label__
 77        if not label:
 78            raise SchemaError(f"Model {model.__name__} has no __label__", model)
 79        self._node_models[label] = model
 80        self._schema = None  # Invalidate cached schema
 81
 82    def register_edge(self, model: type[UniEdge]) -> None:
 83        """Register an edge model for schema generation."""
 84        edge_type = model.__edge_type__
 85        if not edge_type:
 86            raise SchemaError(f"Model {model.__name__} has no __edge_type__", model)
 87        self._edge_models[edge_type] = model
 88        self._schema = None
 89
 90    def register(self, *models: type[UniNode] | type[UniEdge]) -> None:
 91        """Register multiple models."""
 92        for model in models:
 93            if issubclass(model, UniEdge):
 94                self.register_edge(model)
 95            elif issubclass(model, UniNode):
 96                self.register_node(model)
 97            else:
 98                raise SchemaError(
 99                    f"Model {model.__name__} must be a subclass of UniNode or UniEdge"
100                )
101
102    def _generate_property_schema(
103        self,
104        model: type[UniNode] | type[UniEdge],
105        field_name: str,
106    ) -> PropertySchema:
107        """Generate schema for a single property field."""
108        field_info = model.model_fields[field_name]
109
110        # Get type hints with forward refs resolved
111        try:
112            hints = get_type_hints(model)
113            type_hint = hints.get(field_name, field_info.annotation)
114        except Exception:
115            type_hint = field_info.annotation
116
117        # Check for nullability
118        is_nullable, inner_type = is_optional(type_hint)
119
120        # Get Uni data type
121        data_type, nullable = python_type_to_uni(type_hint, nullable=is_nullable)
122
123        # Check for vector dimensions
124        vec_dims = get_vector_dimensions(inner_type if is_nullable else type_hint)
125        if vec_dims:
126            data_type = f"vector:{vec_dims}"
127
128        # Check for sparse-vector dimensions (vocabulary size)
129        sparse_dims = get_sparse_vector_dimensions(
130            inner_type if is_nullable else type_hint
131        )
132        if sparse_dims:
133            data_type = f"sparse_vector:{sparse_dims}"
134
135        # Get field config for index settings
136        config = get_field_config(field_info)
137        index_type = config.index if config else None
138        unique = config.unique if config else False
139        tokenizer = config.tokenizer if config else None
140        metric = config.metric if config else None
141
142        # Auto-create vector index for Vector fields (regardless of Field config)
143        if vec_dims and not index_type:
144            index_type = "vector"
145
146        # Auto-create sparse index for SparseVector fields
147        if sparse_dims and not index_type:
148            index_type = "sparse"
149
150        return PropertySchema(
151            name=field_name,
152            data_type=data_type,
153            nullable=nullable,
154            index_type=index_type,
155            unique=unique,
156            tokenizer=tokenizer,
157            metric=metric,
158        )
159
160    def _generate_label_schema(self, model: type[UniNode]) -> LabelSchema:
161        """Generate schema for a node model."""
162        label = model.__label__
163
164        properties = {}
165        for field_name in model.get_property_fields():
166            prop_schema = self._generate_property_schema(model, field_name)
167            properties[field_name] = prop_schema
168
169        return LabelSchema(
170            name=label,
171            properties=properties,
172        )
173
174    def _generate_edge_type_schema(self, model: type[UniEdge]) -> EdgeTypeSchema:
175        """Generate schema for an edge model."""
176        edge_type = model.__edge_type__
177        from_labels = model.get_from_labels()
178        to_labels = model.get_to_labels()
179
180        # If from/to not specified, allow any labels
181        if not from_labels:
182            from_labels = list(self._node_models.keys())
183        if not to_labels:
184            to_labels = list(self._node_models.keys())
185
186        properties = {}
187        for field_name in model.get_property_fields():
188            prop_schema = self._generate_property_schema(model, field_name)
189            properties[field_name] = prop_schema
190
191        return EdgeTypeSchema(
192            name=edge_type,
193            from_labels=from_labels,
194            to_labels=to_labels,
195            properties=properties,
196        )
197
198    def generate(self) -> DatabaseSchema:
199        """Generate the complete database schema."""
200        if self._schema is not None:
201            return self._schema
202
203        schema = DatabaseSchema()
204
205        # Generate label schemas
206        for label, model in self._node_models.items():
207            schema.labels[label] = self._generate_label_schema(model)
208
209        # Generate edge type schemas
210        for edge_type_name, edge_model in self._edge_models.items():
211            schema.edge_types[edge_type_name] = self._generate_edge_type_schema(
212                edge_model
213            )
214
215        # Edge types implied by `Relationship(...)` fields, for which no
216        # explicit `UniEdge` model was registered.
217        #
218        # These used to declare *every* registered label on both endpoints,
219        # discarding the two things the declaration actually states: the model
220        # that owns the field, and the model its annotation points at. That is
221        # not merely imprecise -- an edge type whose endpoints span several
222        # labels makes `properties(b)` on an unlabelled `b` come back empty, so
223        # every relationship target loaded as a propertyless node.
224        for model in self._node_models.values():
225            for rel_name, rel_config in model.get_relationship_fields().items():
226                edge_type = rel_config.edge_type
227                if edge_type in self._edge_models:
228                    # An explicit UniEdge model declares its own endpoints.
229                    continue
230
231                owner = model.__label__
232                target = self._relationship_target_label(model, rel_name)
233                if target is None:
234                    # Unresolvable annotation (e.g. a forward reference to a
235                    # model that was never registered). Fall back to the old
236                    # permissive shape rather than guessing wrong.
237                    src, dst = set(self._node_models), set(self._node_models)
238                elif rel_config.direction == "incoming":
239                    src, dst = {target}, {owner}
240                elif rel_config.direction == "both":
241                    src, dst = {owner, target}, {owner, target}
242                else:
243                    src, dst = {owner}, {target}
244
245                existing = schema.edge_types.get(edge_type)
246                if existing is None:
247                    schema.edge_types[edge_type] = EdgeTypeSchema(
248                        name=edge_type,
249                        from_labels=sorted(src),
250                        to_labels=sorted(dst),
251                    )
252                else:
253                    # Several models may declare the same edge type; the
254                    # endpoint sets union.
255                    existing.from_labels = sorted(set(existing.from_labels) | src)
256                    existing.to_labels = sorted(set(existing.to_labels) | dst)
257
258        self._schema = schema
259        return schema
260
261    def _relationship_target_label(self, model: type[Any], rel_name: str) -> str | None:
262        """Resolve the label a relationship field points at.
263
264        The descriptor carries `target_type` as either a model class or an
265        unresolved forward-reference string; a string is matched against
266        registered models by class name, since `__label__` may differ from it.
267        Returns None when the target cannot be resolved.
268        """
269        descriptor = getattr(model, rel_name, None)
270        target = getattr(descriptor, "target_type", None)
271        if target is None:
272            return None
273        if isinstance(target, str):
274            # A quoted annotation may be a bare name (`"Book"`) or a whole
275            # expression the metaclass could not evaluate (`"Bio | None"`,
276            # `"list[Book]"`). Pull identifiers out and match any of them
277            # against registered models, skipping typing spelling.
278            noise = {"None", "Optional", "Union", "list", "List", "set", "Set"}
279            for token in re.findall(r"[A-Za-z_][A-Za-z0-9_]*", target):
280                if token in noise:
281                    continue
282                for label, candidate in self._node_models.items():
283                    if candidate.__name__ == token or label == token:
284                        return label
285            return None
286        return getattr(target, "__label__", None)
287
288    def apply_to_database(self, db: uni_db.Uni) -> None:
289        """Apply the generated schema to a database using SchemaBuilder.
290
291        Uses db.schema() for atomic schema application with additive-only
292        semantics. Creates labels, edge types, properties, and indexes.
293        """
294        schema = self.generate()
295
296        # Build the full schema using SchemaBuilder, skipping existing labels/edge types
297        builder = db.schema()
298        has_changes = False
299
300        for label, label_schema in schema.labels.items():
301            if db.label_exists(label):
302                continue  # Additive-only: skip existing labels
303            lb = builder.label(label)
304            for prop in label_schema.properties.values():
305                # Check for vector type
306                if prop.data_type.startswith("vector:"):
307                    dims = int(prop.data_type.split(":")[1])
308                    lb = lb.vector(prop.name, dims)
309                elif prop.nullable:
310                    lb = lb.property_nullable(prop.name, prop.data_type)
311                else:
312                    lb = lb.property(prop.name, prop.data_type)
313
314                # Add indexes (not vector — vector is handled by .vector())
315                if prop.index_type and prop.index_type in ("btree", "hash"):
316                    lb = lb.index(prop.name, prop.index_type)
317            builder = lb.done()
318            has_changes = True
319
320        for edge_type, edge_schema in schema.edge_types.items():
321            if db.edge_type_exists(edge_type):
322                continue  # Skip existing edge types
323            eb = builder.edge_type(
324                edge_type, edge_schema.from_labels, edge_schema.to_labels
325            )
326            for prop in edge_schema.properties.values():
327                if prop.nullable:
328                    eb = eb.property_nullable(prop.name, prop.data_type)
329                else:
330                    eb = eb.property(prop.name, prop.data_type)
331            builder = eb.done()
332            has_changes = True
333
334        if has_changes:
335            builder.apply()
336
337        # Create vector and fulltext indexes via schema builder
338        for label, label_schema in schema.labels.items():
339            for prop in label_schema.properties.values():
340                if prop.index_type == "vector":
341                    metric = prop.metric or "l2"
342                    try:
343                        db.schema().label(label).index(
344                            prop.name, {"type": "vector", "metric": metric}
345                        ).apply()
346                    except Exception:
347                        pass  # Index may already exist
348                elif prop.index_type == "sparse":
349                    try:
350                        cfg = {"type": "sparse"}
351                        if prop.data_type.startswith("sparse_vector:"):
352                            cfg["dimensions"] = int(prop.data_type.split(":")[1])
353                        db.schema().label(label).index(prop.name, cfg).apply()
354                    except Exception:
355                        pass  # Index may already exist
356                elif prop.index_type == "fulltext":
357                    try:
358                        db.schema().label(label).index(prop.name, "fulltext").apply()
359                    except Exception:
360                        pass  # Index may already exist
361
362    async def async_apply_to_database(self, db: uni_db.AsyncUni) -> None:
363        """Apply the generated schema to an async database.
364
365        Async variant of apply_to_database using AsyncSchemaBuilder.
366        """
367        schema = self.generate()
368
369        # Build the full schema using AsyncSchemaBuilder, skipping existing labels/edge types
370        builder = db.schema()
371        has_changes = False
372
373        for label, label_schema in schema.labels.items():
374            if await db.label_exists(label):
375                continue
376            lb = builder.label(label)
377            for prop in label_schema.properties.values():
378                if prop.data_type.startswith("vector:"):
379                    dims = int(prop.data_type.split(":")[1])
380                    lb = lb.vector(prop.name, dims)
381                elif prop.nullable:
382                    lb = lb.property_nullable(prop.name, prop.data_type)
383                else:
384                    lb = lb.property(prop.name, prop.data_type)
385
386                if prop.index_type and prop.index_type in ("btree", "hash"):
387                    lb = lb.index(prop.name, prop.index_type)
388            builder = lb.done()
389            has_changes = True
390
391        for edge_type, edge_schema in schema.edge_types.items():
392            if await db.edge_type_exists(edge_type):
393                continue
394            eb = builder.edge_type(
395                edge_type, edge_schema.from_labels, edge_schema.to_labels
396            )
397            for prop in edge_schema.properties.values():
398                if prop.nullable:
399                    eb = eb.property_nullable(prop.name, prop.data_type)
400                else:
401                    eb = eb.property(prop.name, prop.data_type)
402            builder = eb.done()
403            has_changes = True
404
405        if has_changes:
406            await builder.apply()
407
408        # Create vector and fulltext indexes via schema builder
409        for label, label_schema in schema.labels.items():
410            for prop in label_schema.properties.values():
411                if prop.index_type == "vector":
412                    metric = prop.metric or "l2"
413                    try:
414                        await (
415                            db.schema()
416                            .label(label)
417                            .index(prop.name, {"type": "vector", "metric": metric})
418                            .apply()
419                        )
420                    except Exception:
421                        pass  # Index may already exist
422                elif prop.index_type == "sparse":
423                    try:
424                        cfg = {"type": "sparse"}
425                        if prop.data_type.startswith("sparse_vector:"):
426                            cfg["dimensions"] = int(prop.data_type.split(":")[1])
427                        await db.schema().label(label).index(prop.name, cfg).apply()
428                    except Exception:
429                        pass  # Index may already exist
430                elif prop.index_type == "fulltext":
431                    try:
432                        await (
433                            db.schema()
434                            .label(label)
435                            .index(prop.name, "fulltext")
436                            .apply()
437                        )
438                    except Exception:
439                        pass  # Index may already exist

Generates Uni database schema from registered models.

def register_node(self, model: type[UniNode]) -> None:
74    def register_node(self, model: type[UniNode]) -> None:
75        """Register a node model for schema generation."""
76        label = model.__label__
77        if not label:
78            raise SchemaError(f"Model {model.__name__} has no __label__", model)
79        self._node_models[label] = model
80        self._schema = None  # Invalidate cached schema

Register a node model for schema generation.

def register_edge(self, model: type[UniEdge]) -> None:
82    def register_edge(self, model: type[UniEdge]) -> None:
83        """Register an edge model for schema generation."""
84        edge_type = model.__edge_type__
85        if not edge_type:
86            raise SchemaError(f"Model {model.__name__} has no __edge_type__", model)
87        self._edge_models[edge_type] = model
88        self._schema = None

Register an edge model for schema generation.

def register( self, *models: type[UniNode] | type[UniEdge]) -> None:
 90    def register(self, *models: type[UniNode] | type[UniEdge]) -> None:
 91        """Register multiple models."""
 92        for model in models:
 93            if issubclass(model, UniEdge):
 94                self.register_edge(model)
 95            elif issubclass(model, UniNode):
 96                self.register_node(model)
 97            else:
 98                raise SchemaError(
 99                    f"Model {model.__name__} must be a subclass of UniNode or UniEdge"
100                )

Register multiple models.

def generate(self) -> DatabaseSchema:
198    def generate(self) -> DatabaseSchema:
199        """Generate the complete database schema."""
200        if self._schema is not None:
201            return self._schema
202
203        schema = DatabaseSchema()
204
205        # Generate label schemas
206        for label, model in self._node_models.items():
207            schema.labels[label] = self._generate_label_schema(model)
208
209        # Generate edge type schemas
210        for edge_type_name, edge_model in self._edge_models.items():
211            schema.edge_types[edge_type_name] = self._generate_edge_type_schema(
212                edge_model
213            )
214
215        # Edge types implied by `Relationship(...)` fields, for which no
216        # explicit `UniEdge` model was registered.
217        #
218        # These used to declare *every* registered label on both endpoints,
219        # discarding the two things the declaration actually states: the model
220        # that owns the field, and the model its annotation points at. That is
221        # not merely imprecise -- an edge type whose endpoints span several
222        # labels makes `properties(b)` on an unlabelled `b` come back empty, so
223        # every relationship target loaded as a propertyless node.
224        for model in self._node_models.values():
225            for rel_name, rel_config in model.get_relationship_fields().items():
226                edge_type = rel_config.edge_type
227                if edge_type in self._edge_models:
228                    # An explicit UniEdge model declares its own endpoints.
229                    continue
230
231                owner = model.__label__
232                target = self._relationship_target_label(model, rel_name)
233                if target is None:
234                    # Unresolvable annotation (e.g. a forward reference to a
235                    # model that was never registered). Fall back to the old
236                    # permissive shape rather than guessing wrong.
237                    src, dst = set(self._node_models), set(self._node_models)
238                elif rel_config.direction == "incoming":
239                    src, dst = {target}, {owner}
240                elif rel_config.direction == "both":
241                    src, dst = {owner, target}, {owner, target}
242                else:
243                    src, dst = {owner}, {target}
244
245                existing = schema.edge_types.get(edge_type)
246                if existing is None:
247                    schema.edge_types[edge_type] = EdgeTypeSchema(
248                        name=edge_type,
249                        from_labels=sorted(src),
250                        to_labels=sorted(dst),
251                    )
252                else:
253                    # Several models may declare the same edge type; the
254                    # endpoint sets union.
255                    existing.from_labels = sorted(set(existing.from_labels) | src)
256                    existing.to_labels = sorted(set(existing.to_labels) | dst)
257
258        self._schema = schema
259        return schema

Generate the complete database schema.

def apply_to_database(self, db: Uni) -> None:
288    def apply_to_database(self, db: uni_db.Uni) -> None:
289        """Apply the generated schema to a database using SchemaBuilder.
290
291        Uses db.schema() for atomic schema application with additive-only
292        semantics. Creates labels, edge types, properties, and indexes.
293        """
294        schema = self.generate()
295
296        # Build the full schema using SchemaBuilder, skipping existing labels/edge types
297        builder = db.schema()
298        has_changes = False
299
300        for label, label_schema in schema.labels.items():
301            if db.label_exists(label):
302                continue  # Additive-only: skip existing labels
303            lb = builder.label(label)
304            for prop in label_schema.properties.values():
305                # Check for vector type
306                if prop.data_type.startswith("vector:"):
307                    dims = int(prop.data_type.split(":")[1])
308                    lb = lb.vector(prop.name, dims)
309                elif prop.nullable:
310                    lb = lb.property_nullable(prop.name, prop.data_type)
311                else:
312                    lb = lb.property(prop.name, prop.data_type)
313
314                # Add indexes (not vector — vector is handled by .vector())
315                if prop.index_type and prop.index_type in ("btree", "hash"):
316                    lb = lb.index(prop.name, prop.index_type)
317            builder = lb.done()
318            has_changes = True
319
320        for edge_type, edge_schema in schema.edge_types.items():
321            if db.edge_type_exists(edge_type):
322                continue  # Skip existing edge types
323            eb = builder.edge_type(
324                edge_type, edge_schema.from_labels, edge_schema.to_labels
325            )
326            for prop in edge_schema.properties.values():
327                if prop.nullable:
328                    eb = eb.property_nullable(prop.name, prop.data_type)
329                else:
330                    eb = eb.property(prop.name, prop.data_type)
331            builder = eb.done()
332            has_changes = True
333
334        if has_changes:
335            builder.apply()
336
337        # Create vector and fulltext indexes via schema builder
338        for label, label_schema in schema.labels.items():
339            for prop in label_schema.properties.values():
340                if prop.index_type == "vector":
341                    metric = prop.metric or "l2"
342                    try:
343                        db.schema().label(label).index(
344                            prop.name, {"type": "vector", "metric": metric}
345                        ).apply()
346                    except Exception:
347                        pass  # Index may already exist
348                elif prop.index_type == "sparse":
349                    try:
350                        cfg = {"type": "sparse"}
351                        if prop.data_type.startswith("sparse_vector:"):
352                            cfg["dimensions"] = int(prop.data_type.split(":")[1])
353                        db.schema().label(label).index(prop.name, cfg).apply()
354                    except Exception:
355                        pass  # Index may already exist
356                elif prop.index_type == "fulltext":
357                    try:
358                        db.schema().label(label).index(prop.name, "fulltext").apply()
359                    except Exception:
360                        pass  # Index may already exist

Apply the generated schema to a database using SchemaBuilder.

Uses db.schema() for atomic schema application with additive-only semantics. Creates labels, edge types, properties, and indexes.

async def async_apply_to_database(self, db: AsyncUni) -> None:
362    async def async_apply_to_database(self, db: uni_db.AsyncUni) -> None:
363        """Apply the generated schema to an async database.
364
365        Async variant of apply_to_database using AsyncSchemaBuilder.
366        """
367        schema = self.generate()
368
369        # Build the full schema using AsyncSchemaBuilder, skipping existing labels/edge types
370        builder = db.schema()
371        has_changes = False
372
373        for label, label_schema in schema.labels.items():
374            if await db.label_exists(label):
375                continue
376            lb = builder.label(label)
377            for prop in label_schema.properties.values():
378                if prop.data_type.startswith("vector:"):
379                    dims = int(prop.data_type.split(":")[1])
380                    lb = lb.vector(prop.name, dims)
381                elif prop.nullable:
382                    lb = lb.property_nullable(prop.name, prop.data_type)
383                else:
384                    lb = lb.property(prop.name, prop.data_type)
385
386                if prop.index_type and prop.index_type in ("btree", "hash"):
387                    lb = lb.index(prop.name, prop.index_type)
388            builder = lb.done()
389            has_changes = True
390
391        for edge_type, edge_schema in schema.edge_types.items():
392            if await db.edge_type_exists(edge_type):
393                continue
394            eb = builder.edge_type(
395                edge_type, edge_schema.from_labels, edge_schema.to_labels
396            )
397            for prop in edge_schema.properties.values():
398                if prop.nullable:
399                    eb = eb.property_nullable(prop.name, prop.data_type)
400                else:
401                    eb = eb.property(prop.name, prop.data_type)
402            builder = eb.done()
403            has_changes = True
404
405        if has_changes:
406            await builder.apply()
407
408        # Create vector and fulltext indexes via schema builder
409        for label, label_schema in schema.labels.items():
410            for prop in label_schema.properties.values():
411                if prop.index_type == "vector":
412                    metric = prop.metric or "l2"
413                    try:
414                        await (
415                            db.schema()
416                            .label(label)
417                            .index(prop.name, {"type": "vector", "metric": metric})
418                            .apply()
419                        )
420                    except Exception:
421                        pass  # Index may already exist
422                elif prop.index_type == "sparse":
423                    try:
424                        cfg = {"type": "sparse"}
425                        if prop.data_type.startswith("sparse_vector:"):
426                            cfg["dimensions"] = int(prop.data_type.split(":")[1])
427                        await db.schema().label(label).index(prop.name, cfg).apply()
428                    except Exception:
429                        pass  # Index may already exist
430                elif prop.index_type == "fulltext":
431                    try:
432                        await (
433                            db.schema()
434                            .label(label)
435                            .index(prop.name, "fulltext")
436                            .apply()
437                        )
438                    except Exception:
439                        pass  # Index may already exist

Apply the generated schema to an async database.

Async variant of apply_to_database using AsyncSchemaBuilder.

@dataclass
class DatabaseSchema:
58@dataclass
59class DatabaseSchema:
60    """Complete database schema generated from models."""
61
62    labels: dict[str, LabelSchema] = field(default_factory=dict)
63    edge_types: dict[str, EdgeTypeSchema] = field(default_factory=dict)

Complete database schema generated from models.

DatabaseSchema( labels: dict[str, LabelSchema] = <factory>, edge_types: dict[str, EdgeTypeSchema] = <factory>)
labels: dict[str, LabelSchema]
edge_types: dict[str, EdgeTypeSchema]
@dataclass
class LabelSchema:
40@dataclass
41class LabelSchema:
42    """Schema for a vertex label."""
43
44    name: str
45    properties: dict[str, PropertySchema] = field(default_factory=dict)

Schema for a vertex label.

LabelSchema( name: str, properties: dict[str, PropertySchema] = <factory>)
name: str
properties: dict[str, PropertySchema]
@dataclass
class EdgeTypeSchema:
48@dataclass
49class EdgeTypeSchema:
50    """Schema for an edge type."""
51
52    name: str
53    from_labels: list[str] = field(default_factory=list)
54    to_labels: list[str] = field(default_factory=list)
55    properties: dict[str, PropertySchema] = field(default_factory=dict)

Schema for an edge type.

EdgeTypeSchema( name: str, from_labels: list[str] = <factory>, to_labels: list[str] = <factory>, properties: dict[str, PropertySchema] = <factory>)
name: str
from_labels: list[str]
to_labels: list[str]
properties: dict[str, PropertySchema]
@dataclass
class PropertySchema:
27@dataclass
28class PropertySchema:
29    """Schema for a single property."""
30
31    name: str
32    data_type: str
33    nullable: bool = False
34    index_type: str | None = None
35    unique: bool = False
36    tokenizer: str | None = None
37    metric: str | None = None

Schema for a single property.

PropertySchema( name: str, data_type: str, nullable: bool = False, index_type: str | None = None, unique: bool = False, tokenizer: str | None = None, metric: str | None = None)
name: str
data_type: str
nullable: bool = False
index_type: str | None = None
unique: bool = False
tokenizer: str | None = None
metric: str | None = None
def generate_schema( *models: type[UniNode] | type[UniEdge]) -> DatabaseSchema:
442def generate_schema(*models: type[UniNode] | type[UniEdge]) -> DatabaseSchema:
443    """Generate a database schema from the given models."""
444    generator = SchemaGenerator()
445    generator.register(*models)
446    return generator.generate()

Generate a database schema from the given models.

class UniDatabase:
15class UniDatabase:
16    """
17    Thin wrapper around uni-db UniBuilder for ergonomic database creation.
18
19    Example:
20        >>> db = UniDatabase.open("./path").cache_size(1024*1024).build()
21        >>> db = UniDatabase.temporary().build()
22        >>> db = UniDatabase.in_memory().build()
23    """
24
25    def __init__(self, builder: uni_db.UniBuilder) -> None:
26        self._builder = builder
27
28    @classmethod
29    def open(cls, path: str) -> UniDatabase:
30        """Open or create a database at the given path."""
31        import uni_db
32
33        return cls(uni_db.UniBuilder.open(path))
34
35    @classmethod
36    def create(cls, path: str) -> UniDatabase:
37        """Create a new database at the given path."""
38        import uni_db
39
40        return cls(uni_db.UniBuilder.create(path))
41
42    @classmethod
43    def open_existing(cls, path: str) -> UniDatabase:
44        """Open an existing database (must already exist)."""
45        import uni_db
46
47        return cls(uni_db.UniBuilder.open_existing(path))
48
49    @classmethod
50    def temporary(cls) -> UniDatabase:
51        """Create an ephemeral in-memory database."""
52        import uni_db
53
54        return cls(uni_db.UniBuilder.temporary())
55
56    @classmethod
57    def in_memory(cls) -> UniDatabase:
58        """Create a persistent in-memory database."""
59        import uni_db
60
61        return cls(uni_db.UniBuilder.in_memory())
62
63    def cache_size(self, bytes_: int) -> UniDatabase:
64        """Set the cache size in bytes."""
65        self._builder = self._builder.cache_size(bytes_)
66        return self
67
68    def parallelism(self, n: int) -> UniDatabase:
69        """Set the parallelism level."""
70        self._builder = self._builder.parallelism(n)
71        return self
72
73    def build(self) -> uni_db.Uni:
74        """Build and return the database instance."""
75        return self._builder.build()

Thin wrapper around uni-db UniBuilder for ergonomic database creation.

Example:

db = UniDatabase.open("./path").cache_size(1024*1024).build() db = UniDatabase.temporary().build() db = UniDatabase.in_memory().build()

UniDatabase(builder: UniBuilder)
25    def __init__(self, builder: uni_db.UniBuilder) -> None:
26        self._builder = builder
@classmethod
def open(cls, path: str) -> UniDatabase:
28    @classmethod
29    def open(cls, path: str) -> UniDatabase:
30        """Open or create a database at the given path."""
31        import uni_db
32
33        return cls(uni_db.UniBuilder.open(path))

Open or create a database at the given path.

@classmethod
def create(cls, path: str) -> UniDatabase:
35    @classmethod
36    def create(cls, path: str) -> UniDatabase:
37        """Create a new database at the given path."""
38        import uni_db
39
40        return cls(uni_db.UniBuilder.create(path))

Create a new database at the given path.

@classmethod
def open_existing(cls, path: str) -> UniDatabase:
42    @classmethod
43    def open_existing(cls, path: str) -> UniDatabase:
44        """Open an existing database (must already exist)."""
45        import uni_db
46
47        return cls(uni_db.UniBuilder.open_existing(path))

Open an existing database (must already exist).

@classmethod
def temporary(cls) -> UniDatabase:
49    @classmethod
50    def temporary(cls) -> UniDatabase:
51        """Create an ephemeral in-memory database."""
52        import uni_db
53
54        return cls(uni_db.UniBuilder.temporary())

Create an ephemeral in-memory database.

@classmethod
def in_memory(cls) -> UniDatabase:
56    @classmethod
57    def in_memory(cls) -> UniDatabase:
58        """Create a persistent in-memory database."""
59        import uni_db
60
61        return cls(uni_db.UniBuilder.in_memory())

Create a persistent in-memory database.

def cache_size(self, bytes_: int) -> UniDatabase:
63    def cache_size(self, bytes_: int) -> UniDatabase:
64        """Set the cache size in bytes."""
65        self._builder = self._builder.cache_size(bytes_)
66        return self

Set the cache size in bytes.

def parallelism(self, n: int) -> UniDatabase:
68    def parallelism(self, n: int) -> UniDatabase:
69        """Set the parallelism level."""
70        self._builder = self._builder.parallelism(n)
71        return self

Set the parallelism level.

def build(self) -> Uni:
73    def build(self) -> uni_db.Uni:
74        """Build and return the database instance."""
75        return self._builder.build()

Build and return the database instance.

class AsyncUniDatabase:
 78class AsyncUniDatabase:
 79    """
 80    Thin wrapper around uni-db AsyncUniBuilder for ergonomic async database creation.
 81
 82    Example:
 83        >>> db = await AsyncUniDatabase.open("./path").build()
 84        >>> db = await AsyncUniDatabase.temporary().build()
 85    """
 86
 87    def __init__(self, builder: uni_db.AsyncUniBuilder) -> None:
 88        self._builder = builder
 89
 90    @classmethod
 91    def open(cls, path: str) -> AsyncUniDatabase:
 92        """Open or create a database at the given path."""
 93        import uni_db
 94
 95        return cls(uni_db.AsyncUniBuilder.open(path))
 96
 97    @classmethod
 98    def temporary(cls) -> AsyncUniDatabase:
 99        """Create an ephemeral in-memory database."""
100        import uni_db
101
102        return cls(uni_db.AsyncUniBuilder.temporary())
103
104    @classmethod
105    def in_memory(cls) -> AsyncUniDatabase:
106        """Create a persistent in-memory database."""
107        import uni_db
108
109        return cls(uni_db.AsyncUniBuilder.in_memory())
110
111    def cache_size(self, bytes_: int) -> AsyncUniDatabase:
112        """Set the cache size in bytes."""
113        self._builder = self._builder.cache_size(bytes_)
114        return self
115
116    def parallelism(self, n: int) -> AsyncUniDatabase:
117        """Set the parallelism level."""
118        self._builder = self._builder.parallelism(n)
119        return self
120
121    async def build(self) -> uni_db.AsyncUni:
122        """Build and return the async database instance."""
123        return await self._builder.build()

Thin wrapper around uni-db AsyncUniBuilder for ergonomic async database creation.

Example:

db = await AsyncUniDatabase.open("./path").build() db = await AsyncUniDatabase.temporary().build()

AsyncUniDatabase(builder: AsyncUniBuilder)
87    def __init__(self, builder: uni_db.AsyncUniBuilder) -> None:
88        self._builder = builder
@classmethod
def open(cls, path: str) -> AsyncUniDatabase:
90    @classmethod
91    def open(cls, path: str) -> AsyncUniDatabase:
92        """Open or create a database at the given path."""
93        import uni_db
94
95        return cls(uni_db.AsyncUniBuilder.open(path))

Open or create a database at the given path.

@classmethod
def temporary(cls) -> AsyncUniDatabase:
 97    @classmethod
 98    def temporary(cls) -> AsyncUniDatabase:
 99        """Create an ephemeral in-memory database."""
100        import uni_db
101
102        return cls(uni_db.AsyncUniBuilder.temporary())

Create an ephemeral in-memory database.

@classmethod
def in_memory(cls) -> AsyncUniDatabase:
104    @classmethod
105    def in_memory(cls) -> AsyncUniDatabase:
106        """Create a persistent in-memory database."""
107        import uni_db
108
109        return cls(uni_db.AsyncUniBuilder.in_memory())

Create a persistent in-memory database.

def cache_size(self, bytes_: int) -> AsyncUniDatabase:
111    def cache_size(self, bytes_: int) -> AsyncUniDatabase:
112        """Set the cache size in bytes."""
113        self._builder = self._builder.cache_size(bytes_)
114        return self

Set the cache size in bytes.

def parallelism(self, n: int) -> AsyncUniDatabase:
116    def parallelism(self, n: int) -> AsyncUniDatabase:
117        """Set the parallelism level."""
118        self._builder = self._builder.parallelism(n)
119        return self

Set the parallelism level.

async def build(self) -> AsyncUni:
121    async def build(self) -> uni_db.AsyncUni:
122        """Build and return the async database instance."""
123        return await self._builder.build()

Build and return the async database instance.

def before_create(func: ~F) -> ~F:
40def before_create(func: F) -> F:
41    """
42    Mark a method to be called before the entity is created in the database.
43
44    The method is called after validation but before the INSERT operation.
45    Useful for setting timestamps, generating IDs, or final validation.
46
47    Example:
48        >>> class Person(UniNode):
49        ...     name: str
50        ...     created_at: datetime | None = None
51        ...
52        ...     @before_create
53        ...     def set_created_at(self):
54        ...         self.created_at = datetime.now()
55    """
56    return _mark_hook(_BEFORE_CREATE)(func)

Mark a method to be called before the entity is created in the database.

The method is called after validation but before the INSERT operation. Useful for setting timestamps, generating IDs, or final validation.

Example:

class Person(UniNode): ... name: str ... created_at: datetime | None = None ... ... @before_create ... def set_created_at(self): ... self.created_at = datetime.now()

def after_create(func: ~F) -> ~F:
59def after_create(func: F) -> F:
60    """
61    Mark a method to be called after the entity is created in the database.
62
63    The method is called after the INSERT operation completes successfully.
64    The entity will have its vid/eid assigned at this point.
65
66    Example:
67        >>> class Person(UniNode):
68        ...     name: str
69        ...
70        ...     @after_create
71        ...     def log_creation(self):
72        ...         logger.info(f"Created person {self.name} with vid={self.vid}")
73    """
74    return _mark_hook(_AFTER_CREATE)(func)

Mark a method to be called after the entity is created in the database.

The method is called after the INSERT operation completes successfully. The entity will have its vid/eid assigned at this point.

Example:

class Person(UniNode): ... name: str ... ... @after_create ... def log_creation(self): ... logger.info(f"Created person {self.name} with vid={self.vid}")

def before_update(func: ~F) -> ~F:
77def before_update(func: F) -> F:
78    """
79    Mark a method to be called before the entity is updated in the database.
80
81    The method is called before the UPDATE operation.
82    Useful for validation or updating timestamps.
83
84    Example:
85        >>> class Person(UniNode):
86        ...     name: str
87        ...     updated_at: datetime | None = None
88        ...
89        ...     @before_update
90        ...     def validate_and_timestamp(self):
91        ...         if not self.name:
92        ...             raise ValueError("Name cannot be empty")
93        ...         self.updated_at = datetime.now()
94    """
95    return _mark_hook(_BEFORE_UPDATE)(func)

Mark a method to be called before the entity is updated in the database.

The method is called before the UPDATE operation. Useful for validation or updating timestamps.

Example:

class Person(UniNode): ... name: str ... updated_at: datetime | None = None ... ... @before_update ... def validate_and_timestamp(self): ... if not self.name: ... raise ValueError("Name cannot be empty") ... self.updated_at = datetime.now()

def after_update(func: ~F) -> ~F:
 98def after_update(func: F) -> F:
 99    """
100    Mark a method to be called after the entity is updated in the database.
101
102    The method is called after the UPDATE operation completes successfully.
103
104    Example:
105        >>> class Person(UniNode):
106        ...     name: str
107        ...
108        ...     @after_update
109        ...     def notify_change(self):
110        ...         events.emit("person_updated", self.vid)
111    """
112    return _mark_hook(_AFTER_UPDATE)(func)

Mark a method to be called after the entity is updated in the database.

The method is called after the UPDATE operation completes successfully.

Example:

class Person(UniNode): ... name: str ... ... @after_update ... def notify_change(self): ... events.emit("person_updated", self.vid)

def before_delete(func: ~F) -> ~F:
115def before_delete(func: F) -> F:
116    """
117    Mark a method to be called before the entity is deleted from the database.
118
119    The method is called before the DELETE operation.
120    Useful for cleanup or validation.
121
122    Example:
123        >>> class Person(UniNode):
124        ...     name: str
125        ...
126        ...     @before_delete
127        ...     def cleanup(self):
128        ...         # Remove related data
129        ...         pass
130    """
131    return _mark_hook(_BEFORE_DELETE)(func)

Mark a method to be called before the entity is deleted from the database.

The method is called before the DELETE operation. Useful for cleanup or validation.

Example:

class Person(UniNode): ... name: str ... ... @before_delete ... def cleanup(self): ... # Remove related data ... pass

def after_delete(func: ~F) -> ~F:
134def after_delete(func: F) -> F:
135    """
136    Mark a method to be called after the entity is deleted from the database.
137
138    The method is called after the DELETE operation completes successfully.
139    The entity's vid/eid will be cleared at this point.
140
141    Example:
142        >>> class Person(UniNode):
143        ...     name: str
144        ...
145        ...     @after_delete
146        ...     def log_deletion(self):
147        ...         logger.info(f"Deleted person {self.name}")
148    """
149    return _mark_hook(_AFTER_DELETE)(func)

Mark a method to be called after the entity is deleted from the database.

The method is called after the DELETE operation completes successfully. The entity's vid/eid will be cleared at this point.

Example:

class Person(UniNode): ... name: str ... ... @after_delete ... def log_deletion(self): ... logger.info(f"Deleted person {self.name}")

def before_load(func: ~F) -> ~F:
152def before_load(func: F) -> F:
153    """
154    Mark a method to be called before the entity is loaded from the database.
155
156    This is a class method that receives the raw property dictionary.
157    Can be used to transform data before model instantiation.
158
159    Example:
160        >>> class Person(UniNode):
161        ...     name: str
162        ...
163        ...     @classmethod
164        ...     @before_load
165        ...     def transform_data(cls, props: dict) -> dict:
166        ...         # Normalize name
167        ...         if 'name' in props:
168        ...             props['name'] = props['name'].strip()
169        ...         return props
170    """
171    return _mark_hook(_BEFORE_LOAD)(func)

Mark a method to be called before the entity is loaded from the database.

This is a class method that receives the raw property dictionary. Can be used to transform data before model instantiation.

Example:

class Person(UniNode): ... name: str ... ... @classmethod ... @before_load ... def transform_data(cls, props: dict) -> dict: ... # Normalize name ... if 'name' in props: ... props['name'] = props['name'].strip() ... return props

def after_load(func: ~F) -> ~F:
174def after_load(func: F) -> F:
175    """
176    Mark a method to be called after the entity is loaded from the database.
177
178    The method is called after the entity is instantiated from database data.
179    Useful for computing derived values or initializing non-persisted state.
180
181    Example:
182        >>> class Person(UniNode):
183        ...     first_name: str
184        ...     last_name: str
185        ...     full_name: str | None = None
186        ...
187        ...     @after_load
188        ...     def compute_full_name(self):
189        ...         self.full_name = f"{self.first_name} {self.last_name}"
190    """
191    return _mark_hook(_AFTER_LOAD)(func)

Mark a method to be called after the entity is loaded from the database.

The method is called after the entity is instantiated from database data. Useful for computing derived values or initializing non-persisted state.

Example:

class Person(UniNode): ... first_name: str ... last_name: str ... full_name: str | None = None ... ... @after_load ... def compute_full_name(self): ... self.full_name = f"{self.first_name} {self.last_name}"

class UniPydanticError(builtins.Exception):
15class UniPydanticError(Exception):
16    """Base exception for all uni-pydantic errors."""

Base exception for all uni-pydantic errors.

class SchemaError(uni_pydantic.UniPydanticError):
19class SchemaError(UniPydanticError):
20    """Error related to schema definition or generation."""
21
22    def __init__(self, message: str, model: type | None = None) -> None:
23        self.model = model
24        super().__init__(message)

Error related to schema definition or generation.

SchemaError(message: str, model: type | None = None)
22    def __init__(self, message: str, model: type | None = None) -> None:
23        self.model = model
24        super().__init__(message)
model
class TypeMappingError(uni_pydantic.SchemaError):
27class TypeMappingError(SchemaError):
28    """Error mapping Python type to Uni DataType."""
29
30    def __init__(self, python_type: Any, message: str | None = None) -> None:
31        self.python_type = python_type
32        msg = message or f"Cannot map Python type {python_type!r} to Uni DataType"
33        super().__init__(msg)

Error mapping Python type to Uni DataType.

TypeMappingError(python_type: Any, message: str | None = None)
30    def __init__(self, python_type: Any, message: str | None = None) -> None:
31        self.python_type = python_type
32        msg = message or f"Cannot map Python type {python_type!r} to Uni DataType"
33        super().__init__(msg)
python_type
class ValidationError(uni_pydantic.UniPydanticError):
36class ValidationError(UniPydanticError):
37    """Validation error for model instances."""

Validation error for model instances.

class SessionError(uni_pydantic.UniPydanticError):
40class SessionError(UniPydanticError):
41    """Error related to session operations."""

Error related to session operations.

class NotRegisteredError(uni_pydantic.SessionError):
44class NotRegisteredError(SessionError):
45    """Model type not registered with session."""
46
47    def __init__(self, model: type[UniNode] | type[UniEdge]) -> None:
48        self.model = model
49        super().__init__(
50            f"Model {model.__name__!r} is not registered with this session. "
51            f"Call session.register({model.__name__}) first."
52        )

Model type not registered with session.

NotRegisteredError( model: type[UniNode] | type[UniEdge])
47    def __init__(self, model: type[UniNode] | type[UniEdge]) -> None:
48        self.model = model
49        super().__init__(
50            f"Model {model.__name__!r} is not registered with this session. "
51            f"Call session.register({model.__name__}) first."
52        )
model
class NotPersisted(uni_pydantic.SessionError):
55class NotPersisted(SessionError):
56    """Operation requires a persisted entity."""
57
58    def __init__(self, entity: UniNode | UniEdge) -> None:
59        self.entity = entity
60        super().__init__(
61            f"Entity {entity!r} is not persisted. Call session.add() and commit() first."
62        )

Operation requires a persisted entity.

NotPersisted(entity: UniNode | UniEdge)
58    def __init__(self, entity: UniNode | UniEdge) -> None:
59        self.entity = entity
60        super().__init__(
61            f"Entity {entity!r} is not persisted. Call session.add() and commit() first."
62        )
entity
class NotTrackedError(uni_pydantic.SessionError):
65class NotTrackedError(SessionError):
66    """Entity is not tracked by this session."""

Entity is not tracked by this session.

class TransactionError(uni_pydantic.SessionError):
69class TransactionError(SessionError):
70    """Error related to transaction operations."""

Error related to transaction operations.

class QueryError(uni_pydantic.UniPydanticError):
73class QueryError(UniPydanticError):
74    """Error executing a query."""

Error executing a query.

class RelationshipError(uni_pydantic.UniPydanticError):
77class RelationshipError(UniPydanticError):
78    """Error related to relationship operations."""

Error related to relationship operations.

class LazyLoadError(uni_pydantic.RelationshipError):
81class LazyLoadError(RelationshipError):
82    """Error lazy-loading a relationship."""
83
84    def __init__(self, field_name: str, reason: str) -> None:
85        self.field_name = field_name
86        super().__init__(f"Cannot lazy-load relationship '{field_name}': {reason}")

Error lazy-loading a relationship.

LazyLoadError(field_name: str, reason: str)
84    def __init__(self, field_name: str, reason: str) -> None:
85        self.field_name = field_name
86        super().__init__(f"Cannot lazy-load relationship '{field_name}': {reason}")
field_name
class BulkLoadError(uni_pydantic.UniPydanticError):
89class BulkLoadError(UniPydanticError):
90    """Error during bulk loading operations."""

Error during bulk loading operations.

class CypherInjectionError(uni_pydantic.QueryError):
93class CypherInjectionError(QueryError):
94    """Property name validation failure — potential Cypher injection."""
95
96    def __init__(self, name: str, reason: str | None = None) -> None:
97        self.name = name
98        msg = reason or f"Invalid property name {name!r}: possible Cypher injection"
99        super().__init__(msg)

Property name validation failure — potential Cypher injection.

CypherInjectionError(name: str, reason: str | None = None)
96    def __init__(self, name: str, reason: str | None = None) -> None:
97        self.name = name
98        msg = reason or f"Invalid property name {name!r}: possible Cypher injection"
99        super().__init__(msg)
name