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 byrollback().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.