Skip to content

Design

Why SQLAlchemy v2 semantics?

Statement / Result separation

SQLAlchemy v2 separates building a query from executing it:

stmt   = select(User).where(User.age > 18)   # just a value — no I/O
result = session.scalars(stmt)               # execute against the DB
users  = result.all()                        # materialise results

This makes queries composable, testable, and readable. mongotic adopts the same pattern — Select, Update, and Delete objects carry no side-effects until they are passed to a Session method.

Familiarity

Developers who already know SQLAlchemy v2 can use mongotic without learning a new query language. The surface area is intentionally small: select, update, delete, session.scalars, session.execute, ScalarResult.


No multi-document transaction support

MongoDB transactions require a replica set or mongos. mongotic targets both standalone dev instances and production replica sets, so we do not wrap writes in MongoDB transactions.

Consequences:

  • Each individual document write is atomic (MongoDB guarantee).
  • Cross-document atomicity is not guaranteed — if you need all-or-nothing across multiple documents, manage sessions manually via pymongo.
  • flush() / commit() writes staged ops immediately. After the call, changes are persisted and cannot be undone by rollback().
  • rollback() discards only staged but not yet flushed changes.

This decision may be revisited in a future version when replica set targeting is a first-class concern.


Auto-tracking field mutations

MongoBaseModel uses a custom __setattr__ hook. When an instance is attached to a session (i.e., loaded via scalars() or get()), any field assignment is automatically staged as an update:

user = session.scalars(select(User).where(...)).first()
user.name = "New Name"   # staged — no explicit session.update() needed
session.commit()

This mirrors SQLAlchemy's unit-of-work pattern, where modified attributes are detected and flushed automatically.


commit() is an alias for flush()

In SQLAlchemy, commit() finalises a transaction. mongotic has no transactions, so commit() is simply an alias for flush() — it writes all staged ops to MongoDB immediately. The alias exists for API familiarity and to ease migration from SQLAlchemy-backed codebases.


Mapped[T] descriptors (v0.6+)

Static type checkers (pyright, mypy) cannot see through Pydantic v2's dataclass_transform to a metaclass __getattr__ fallback. Concretely, a field declared as name: str = Field(...) is statically typed as str at both class and instance level — so User.name == "x" was inferred as bool, and User.name.in_([...]) as an attribute error on str, even though both worked at runtime.

v0.6.0 introduces a SQLAlchemy 2.0-style Mapped[T] descriptor declared via mapped_field() (which subclasses Pydantic's FieldInfo so every existing field metadata option flows through). At class level the descriptor returns itself with operators that build ModelFieldOperation; at instance level it returns the underlying T via __get__/__set__ overloads. Pyright follows the descriptor protocol natively, so the class-level magic is fully visible to the type checker — no plugin required.

A legacy T = Field(...) declaration still runs in v0.6: the metaclass installs a Mapped descriptor for every model field regardless of how it was declared, so User.name == "x" keeps building a query expression. The declaration just won't type-check as one until the field annotation becomes Mapped[T]. A DeprecationWarning is emitted at class creation to flag the migration; the compatibility shim is planned for removal in v0.7.0.

See the migration guide for the substitution recipe and the rejected alternatives (a col() helper, loosening where()'s signature) that the descriptor approach replaces.