Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

---

## v26.06.75 (2026-06-07)

### Changed (data — unified `@transactional` for every backend)

There is now **one** `@transactional` annotation for both relational and document services,
imported from `pyfly.data` (Spring's uniform-annotation model). It dispatches at call time to the
transaction manager the service exposes:

- relational `async_sessionmaker` on `self._session_factory` → SQLAlchemy transaction (propagation,
isolation, read-only, `rollback_for`/`no_rollback_for`, repository session patching);
- MongoDB client on `self._motor_client` → Mongo session + transaction (session injected as the
`session` kwarg, commit/abort with `rollback_for`/`no_rollback_for` parity).

The decorator is backend-neutral (`pyfly.data.transactional`) and imports neither SQLAlchemy nor
Motor at module scope; each backend supplies a lazily-imported runner (`run_relational_transaction`
/ `run_mongo_transaction`). `from pyfly.data.relational.sqlalchemy import transactional` still works
(same object); **`mongo_transactional` is now a deprecated alias** of `@transactional`.

### Fixed / tested

- The MongoDB transaction path — previously **completely untested** — now has behavior tests
(commit, abort on `rollback_for`, commit-and-re-raise on `no_rollback_for`, session injection,
missing-client error) and gained `rollback_for`/`no_rollback_for` parity with relational.
- Relational isolation is now verified (a test asserts the `isolation_level` execution option
actually reaches the session — previously only the enum/metadata were checked).
- Docs (`data.md`, `data-document.md`) + the companion `implement-data-repository` skill updated to
teach the single annotation; the doc's broken `@mongo_transactional(client)` example is corrected.

## v26.06.74 (2026-06-07)

### Docs (data — query-mechanism decision guide + Spring-Data parity matrix)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<a href="https://github.com/fireflyframework"><img src="https://img.shields.io/badge/Firefly_Framework-official-ff6600?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyeiIvPjwvc3ZnPg==" alt="Firefly Framework"></a>
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.12%2B-blue?logo=python&logoColor=white" alt="Python 3.12+"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License: Apache 2.0"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.74-brightgreen" alt="Version: 26.06.74"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.75-brightgreen" alt="Version: 26.06.75"></a>
<a href="#"><img src="https://img.shields.io/badge/type--checked-mypy%20strict-blue?logo=python&logoColor=white" alt="Type Checked: mypy strict"></a>
<a href="#"><img src="https://img.shields.io/badge/code%20style-ruff-purple?logo=ruff&logoColor=white" alt="Code Style: Ruff"></a>
<a href="#"><img src="https://img.shields.io/badge/async-first-brightgreen" alt="Async First"></a>
Expand Down
54 changes: 29 additions & 25 deletions docs/modules/data-document.md
Original file line number Diff line number Diff line change
Expand Up @@ -797,44 +797,48 @@ Source files:

## Transaction Management

### mongo_transactional Decorator
### The unified `@transactional` decorator

The `@mongo_transactional` decorator provides declarative async transaction management for MongoDB, mirroring the `@reactive_transactional` decorator from the SQLAlchemy adapter:
MongoDB uses the **same** `@transactional` annotation as the relational adapter — import it from
`pyfly.data`. On a service exposing a Motor client as `self._motor_client`, it opens a Mongo
session + transaction, injects the session as the `session` keyword argument, commits on success,
and aborts on error (honouring `rollback_for` / `no_rollback_for`, like the relational backend):

```python
from pyfly.data.document.mongodb import mongo_transactional
from motor.motor_asyncio import AsyncIOMotorClient

client: AsyncIOMotorClient = ...


@mongo_transactional(client)
async def transfer_funds(from_id: str, to_id: str, amount: float) -> None:
from_account = await AccountDocument.get(from_id)
to_account = await AccountDocument.get(to_id)
from pyfly.container import service
from pyfly.data import transactional

from_account.balance -= amount
to_account.balance += amount

await from_account.save()
await to_account.save()
# Transaction is committed automatically on success
# Transaction is aborted automatically on exception
@service
class AccountService:
def __init__(self, motor_client) -> None:
self._motor_client = motor_client # selects the MongoDB transaction manager

@transactional()
async def transfer_funds(self, from_id: str, to_id: str, amount: float, *, session=None) -> None:
from_account = await AccountDocument.get(from_id)
to_account = await AccountDocument.get(to_id)
from_account.balance -= amount
to_account.balance += amount
await from_account.save()
await to_account.save()
# Committed automatically on success; aborted automatically on exception.
```

**How it works:**

1. Starts a new Motor session via `client.start_session()`.
2. Begins a transaction on the session via `session.start_transaction()`.
3. Calls the wrapped function with all original arguments.
4. On success: the transaction is committed (via the `async with` context manager).
5. On exception: the transaction is aborted and the exception is re-raised.
1. Resolves the Motor client from `self._motor_client`.
2. Opens a session (`client.start_session()`) and a transaction (`session.start_transaction()`).
3. Injects the live `ClientSession` as the `session` keyword argument and calls the function.
4. On success the transaction commits; on an exception in `rollback_for` it aborts; an exception
in `no_rollback_for` commits and is then re-raised (mirroring the relational semantics).

Unlike `@reactive_transactional` (which injects the session as the first argument), `@mongo_transactional` does not inject the session. The Beanie document operations automatically participate in the active transaction through Motor's session context.
> **Deprecated:** `from pyfly.data.document.mongodb import mongo_transactional` still works but is
> a thin alias of `@transactional`. Prefer `@transactional`.

### Replica Set Requirement

MongoDB transactions require a replica set deployment. Standalone MongoDB instances do not support multi-document transactions. If you attempt to use `@mongo_transactional` against a standalone instance, MongoDB will raise an error.
MongoDB transactions require a replica set deployment. Standalone MongoDB instances do not support multi-document transactions. If you attempt to use `@transactional` (or the deprecated `@mongo_transactional`) against a standalone instance, MongoDB will raise an error.

For local development, you can run a single-node replica set:

Expand Down
2 changes: 1 addition & 1 deletion docs/modules/data.md
Original file line number Diff line number Diff line change
Expand Up @@ -1076,7 +1076,7 @@ Some capabilities are **backend-specific** today:
| Soft delete (`SoftDeleteRepository`) | ✅ | ❌ not yet |
| Optimistic locking (`VersionedMixin` / `@Version`) | ✅ | ❌ not yet |
| Auditing auto-population | ✅ `created/updated_at` **and** `created/updated_by` | ⚠️ timestamps at insert only |
| `@transactional` (propagation, isolation, read-only, `rollback_for`) | ✅ | ⚠️ basic commit/abort (`@mongo_transactional`, replica set required) |
| `@transactional` — one annotation, both backends (`pyfly.data`) | ✅ propagation / isolation / read-only / `rollback_for` | ✅ commit/abort + `rollback_for` (replica set; propagation/isolation are relational-only) |

**Not yet implemented on either backend** (so you don't reach for them): `@Modifying`-style
declarative bulk `UPDATE`/`DELETE`; `Slice` (count-less paging) and streaming/reactive result
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ name = "pyfly"
# CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4);
# git tag, GitHub release and human-readable display use leading-zero form
# (v26.05.04) to match the Java/.NET/Go siblings.
version = "26.6.74"
version = "26.6.75"
description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more."
readme = "README.md"
license = "Apache-2.0"
Expand Down
2 changes: 1 addition & 1 deletion src/pyfly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
# limitations under the License.
"""PyFly — Enterprise Python Framework."""

__version__ = "26.06.74"
__version__ = "26.06.75"
8 changes: 6 additions & 2 deletions src/pyfly/data/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,27 +36,31 @@
from pyfly.data.query import query
from pyfly.data.query_parser import QueryMethodParser
from pyfly.data.specification import Specification
from pyfly.data.transactional import Isolation, Propagation, transactional

__all__ = [
"BaseFilterUtils",
"BaseRepositoryPostProcessor",
"CrudRepository",
"DERIVED_PREFIXES",
"Isolation",
"Mapper",
"default_mapper",
"mapping",
"Order",
"Page",
"Pageable",
"PagingRepository",
"Propagation",
"QueryMethodCompilerPort",
"QueryMethodParser",
"RepositoryPort",
"SessionPort",
"Sort",
"Specification",
"default_mapper",
"is_projection",
"mapping",
"projection",
"projection_fields",
"query",
"transactional",
]
92 changes: 55 additions & 37 deletions src/pyfly/data/document/mongodb/transactional.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,51 +11,69 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""MongoDB transactional decorator — wraps async functions in a Mongo session + transaction."""
"""MongoDB execution for the unified ``@transactional`` decorator.

Use the backend-neutral ``@transactional`` from :mod:`pyfly.data` on document services too — it
dispatches here when the service exposes a ``_motor_client``. ``mongo_transactional`` is kept as a
**deprecated** alias of ``@transactional`` for backward compatibility.
"""

from __future__ import annotations

import functools
from collections.abc import Callable
from typing import Any, TypeVar

F = TypeVar("F", bound=Callable[..., Any])

from typing import Any

def mongo_transactional(func: F) -> F:
"""Wrap an async function in a MongoDB session and transaction.
from pyfly.data.transactional import transactional

The decorated function receives an extra ``session`` keyword argument
bound to the active ``ClientSession``. If the function completes
without error the transaction is committed; otherwise it is aborted.

Requires a :class:`motor.motor_asyncio.AsyncIOMotorClient` to be
resolvable from the first positional argument's ``_motor_client``
attribute (typically ``self`` in a service bean).
async def run_mongo_transaction(
func: Callable[..., Any],
args: tuple[Any, ...],
kwargs: dict[str, Any],
*,
rollback_for: tuple[type[BaseException], ...] = (Exception,),
no_rollback_for: tuple[type[BaseException], ...] = (),
) -> Any:
"""Execute *func* inside a MongoDB transaction (the document arm of ``@transactional``).

Example::
Resolves the Motor client from ``self._motor_client``, opens a session + transaction, injects
the session as the ``session`` keyword argument, and commits on success. On error it aborts —
except for exception types in ``no_rollback_for`` (or any ``Exception`` not in ``rollback_for``),
which commit and then re-raise, mirroring the relational ``@transactional`` semantics. A
``BaseException`` that is not an ``Exception`` (cancellation/shutdown) always aborts.
"""
self_arg = args[0] if args else None
motor_client = getattr(self_arg, "_motor_client", None)
if motor_client is None:
raise RuntimeError(
f"{func.__qualname__}: cannot resolve Motor client. Ensure the service has a '_motor_client' attribute."
)

class OrderService:
def __init__(self, motor_client):
self._motor_client = motor_client
async with await motor_client.start_session() as session:
kwargs["session"] = session
# session.start_transaction() commits on a clean context exit and aborts when an exception
# escapes it. To honour no_rollback_for we let such exceptions exit cleanly (commit) and
# re-raise them afterwards, rather than driving abort/commit manually (which would depend
# on Motor's lazy-vs-eager start_transaction semantics).
deferred: BaseException | None = None
result: Any = None
async with session.start_transaction():
try:
result = await func(*args, **kwargs)
except BaseException as exc:
if not isinstance(exc, Exception):
raise # cancellation/shutdown -> abort
if isinstance(exc, tuple(no_rollback_for)):
deferred = exc # commit, then surface
elif isinstance(exc, tuple(rollback_for)):
raise # -> abort
else:
deferred = exc # not in rollback_for -> commit, then surface
if deferred is not None:
raise deferred
return result

@mongo_transactional
async def place_order(self, order, *, session=None):
...
"""

@functools.wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
# Resolve the Motor client from the first arg (self)
self_arg = args[0] if args else None
motor_client = getattr(self_arg, "_motor_client", None)
if motor_client is None:
raise RuntimeError(
f"{func.__qualname__}: cannot resolve Motor client. Ensure the service has a '_motor_client' attribute."
)

async with await motor_client.start_session() as session, session.start_transaction():
kwargs["session"] = session
return await func(*args, **kwargs)

return wrapper # type: ignore[return-value]
# Backward-compatibility alias. Prefer the unified `@transactional` from `pyfly.data`.
mongo_transactional = transactional
"""Deprecated alias of :func:`pyfly.data.transactional.transactional`. Use ``@transactional``."""
Loading
Loading