From 132f35445baa7459e7ad48145e116262e500e8eb Mon Sep 17 00:00:00 2001
From: Rene
Date: Sat, 17 Oct 2026 16:09:28 +0200
Subject: [PATCH 01/24] fix(di): skip auto-wiring unregistered constructor
parameters with default values
---
src/zcore/kernel/di.py | 25 +++++++++++++++++++++++
tests/test_di.py | 46 ++++++++++++++++++++++++++++++++++++++++++
2 files changed, 71 insertions(+)
diff --git a/src/zcore/kernel/di.py b/src/zcore/kernel/di.py
index aa3192a..d97847c 100644
--- a/src/zcore/kernel/di.py
+++ b/src/zcore/kernel/di.py
@@ -160,6 +160,21 @@ def resolve(self, interface: type[T], _stack: set[type[Any]] | None = None) -> T
return self._auto_wire(interface, _stack)
+ def _is_registered(self, interface: type[Any]) -> bool:
+ """Check whether an interface is explicitly registered in the container."""
+ if (
+ interface in self._singletons
+ or interface in self._scoped_definitions
+ or interface in self._factories
+ ):
+ return True
+ scope_id = _current_scope_id.get()
+ if scope_id is not None:
+ current_instances = _scoped_instances.get()
+ if current_instances is not None and interface in current_instances:
+ return True
+ return False
+
def _auto_wire(self, target_class: type[T], _stack: set[type[Any]] | None = None) -> T:
"""Analyze, resolve parameters, and construct a class instance.
@@ -214,6 +229,11 @@ def _auto_wire(self, target_class: type[T], _stack: set[type[Any]] | None = None
for name, param in sig.parameters.items():
if name == "self":
continue
+ if param.kind in (
+ inspect.Parameter.VAR_POSITIONAL,
+ inspect.Parameter.VAR_KEYWORD,
+ ):
+ continue
annotation = type_hints.get(name, param.annotation)
if annotation is inspect.Parameter.empty:
continue
@@ -221,6 +241,11 @@ def _auto_wire(self, target_class: type[T], _stack: set[type[Any]] | None = None
if get_origin(annotation) is Annotated:
annotation = get_args(annotation)[0]
+ if param.default is not inspect.Parameter.empty and not self._is_registered(
+ annotation
+ ):
+ continue
+
dependencies.append(annotation)
self._dependency_signature_cache[target_class] = dependencies
diff --git a/tests/test_di.py b/tests/test_di.py
index 619c863..ee93567 100644
--- a/tests/test_di.py
+++ b/tests/test_di.py
@@ -330,6 +330,52 @@ def test_constructor_annotated_parameters() -> None:
assert isinstance(resolved.dep, ForwardB)
+class ClientWithPrimitiveDefaults:
+ def __init__(self, timeout: int = 30, client: Any = None) -> None:
+ self.timeout = timeout
+ self.client = client
+
+
+class ServiceWithDepAndDefaults:
+ def __init__(self, dep: ForwardB, timeout: int = 30, label: str = "default") -> None:
+ self.dep = dep
+ self.timeout = timeout
+ self.label = label
+
+
+class ServiceWithRegisteredDefault:
+ def __init__(self, service: IService | None = None) -> None:
+ self.service = service
+
+
+def test_auto_wire_skips_unregistered_default_parameters() -> None:
+ resolved = container.resolve(ClientWithPrimitiveDefaults)
+ assert isinstance(resolved, ClientWithPrimitiveDefaults)
+ assert resolved.timeout == 30
+ assert resolved.client is None
+ assert container._dependency_signature_cache[ClientWithPrimitiveDefaults] == []
+
+
+def test_auto_wire_resolves_required_and_skips_unregistered_defaults() -> None:
+ resolved = container.resolve(ServiceWithDepAndDefaults)
+ assert isinstance(resolved, ServiceWithDepAndDefaults)
+ assert isinstance(resolved.dep, ForwardB)
+ assert resolved.timeout == 30
+ assert resolved.label == "default"
+ assert container._dependency_signature_cache[ServiceWithDepAndDefaults] == [ForwardB]
+
+
+def test_auto_wire_injects_registered_dependency_even_with_default() -> None:
+ class ServiceWithRegisteredClassDefault:
+ def __init__(self, service: IService = ServiceImpl()) -> None:
+ self.service = service
+
+ registered = AnotherServiceImpl()
+ container.register_singleton(IService, registered)
+ resolved = container.resolve(ServiceWithRegisteredClassDefault)
+ assert resolved.service is registered
+
+
def test_clear_scope_explicit() -> None:
container.register_scoped(IService, ServiceImpl)
scope_id = "test_clear"
From 046489e82ae8a6fec9ac8e3a0b801bc66d9c61ac Mon Sep 17 00:00:00 2001
From: Rene
Date: Sun, 18 Oct 2026 06:46:38 +0200
Subject: [PATCH 02/24] fix(di): track resolved BaseSettings in singletons and
guard scoped dependencies outside active scope
---
src/zcore/kernel/di.py | 22 ++++++++++++++++++++--
tests/test_di.py | 9 +++++++++
2 files changed, 29 insertions(+), 2 deletions(-)
diff --git a/src/zcore/kernel/di.py b/src/zcore/kernel/di.py
index d97847c..6efddd3 100644
--- a/src/zcore/kernel/di.py
+++ b/src/zcore/kernel/di.py
@@ -25,6 +25,7 @@
import structlog
from anyio import to_thread
from fastapi import Depends
+from pydantic_settings import BaseSettings
from sqlalchemy.ext.asyncio import AsyncSession
logger = structlog.get_logger()
@@ -154,6 +155,12 @@ def resolve(self, interface: type[T], _stack: set[type[Any]] | None = None) -> T
if current_instances is not None:
current_instances[interface] = resolved_instance
return resolved_instance
+ elif interface in self._scoped_definitions or (
+ inspect.isclass(interface) and issubclass(interface, AsyncSession)
+ ):
+ raise DIException(
+ f"Cannot resolve scoped dependency '{interface.__name__}' outside of an active scope."
+ )
if interface in self._factories:
return self._factories[interface](_stack)
@@ -195,6 +202,11 @@ def _auto_wire(self, target_class: type[T], _stack: set[type[Any]] | None = None
if not inspect.isclass(target_class):
return target_class
+ if issubclass(target_class, AsyncSession):
+ raise DIException(
+ f"Cannot auto-wire scoped dependency '{target_class.__name__}' outside of an active session scope."
+ )
+
_stack = _stack or set()
if target_class in _stack:
chain = " -> ".join([c.__name__ for c in _stack]) + f" -> {target_class.__name__}"
@@ -206,7 +218,10 @@ def _auto_wire(self, target_class: type[T], _stack: set[type[Any]] | None = None
if target_class in self._dependency_signature_cache:
dependencies = self._dependency_signature_cache[target_class]
resolved_args = [self.resolve(dep, _stack.copy()) for dep in dependencies]
- return target_class(*resolved_args)
+ instance = target_class(*resolved_args)
+ if issubclass(target_class, BaseSettings):
+ self._singletons[target_class] = instance
+ return instance
if target_class not in self._constructor_cache:
constructor = getattr(target_class, "__init__", None)
@@ -251,7 +266,10 @@ def _auto_wire(self, target_class: type[T], _stack: set[type[Any]] | None = None
self._dependency_signature_cache[target_class] = dependencies
resolved_args = [self.resolve(dep, _stack.copy()) for dep in dependencies]
- return target_class(*resolved_args)
+ instance = target_class(*resolved_args)
+ if issubclass(target_class, BaseSettings):
+ self._singletons[target_class] = instance
+ return instance
finally:
_stack.remove(target_class)
diff --git a/tests/test_di.py b/tests/test_di.py
index ee93567..85e95c7 100644
--- a/tests/test_di.py
+++ b/tests/test_di.py
@@ -376,6 +376,15 @@ def __init__(self, service: IService = ServiceImpl()) -> None:
assert resolved.service is registered
+def test_resolve_scoped_outside_scope_raises_di_exception() -> None:
+ container.register_scoped(IService, ServiceImpl)
+ assert _current_scope_id.get() is None
+ with pytest.raises(DIException, match="outside of an active scope"):
+ container.resolve(IService)
+ with pytest.raises(DIException, match="outside of an active scope"):
+ container.resolve(AsyncSession)
+
+
def test_clear_scope_explicit() -> None:
container.register_scoped(IService, ServiceImpl)
scope_id = "test_clear"
From a439cfe64027f261eb9ea3909a8b010ad42daaf7 Mon Sep 17 00:00:00 2001
From: alialfostovar
Date: Sun, 18 Oct 2026 20:05:09 +0330
Subject: [PATCH 03/24] feat(di): support Optional/Union unwrapping and
keyword-based auto-wiring
- Added types import and _unwrap_type to recursively unwrap Annotated, Optional, and Union types
- Added is_registered() public helper across all lifecycle stores
- Dependency signature cache now maps parameter names to types instead of positional list
- Auto-wiring constructs instances via keyword arguments (**resolved_kwargs)
- Removed special BaseSettings singleton caching in auto-wire
- Removed explicit AsyncSession checks in _auto_wire (handled via scoped definition check)
- background_task injection now uses container._unwrap_type
- Added tests for registered/unregistered Optional union injection
---
src/zcore/kernel/di.py | 153 ++++++++++++++++++++++-------------------
tests/test_di.py | 12 ++++
2 files changed, 96 insertions(+), 69 deletions(-)
diff --git a/src/zcore/kernel/di.py b/src/zcore/kernel/di.py
index 6efddd3..61cf142 100644
--- a/src/zcore/kernel/di.py
+++ b/src/zcore/kernel/di.py
@@ -8,6 +8,7 @@
import functools
import inspect
+import types
import uuid
from collections.abc import AsyncGenerator, Callable
from contextlib import asynccontextmanager
@@ -25,7 +26,6 @@
import structlog
from anyio import to_thread
from fastapi import Depends
-from pydantic_settings import BaseSettings
from sqlalchemy.ext.asyncio import AsyncSession
logger = structlog.get_logger()
@@ -71,7 +71,47 @@ def __init__(self) -> None:
self._factories: dict[type[Any], Callable[..., Any]] = {}
self._constructor_cache: dict[type[Any], Callable[..., Any] | None] = {}
- self._dependency_signature_cache: dict[type[Any], list[type[Any]]] = {}
+ self._dependency_signature_cache: dict[type[Any], dict[str, type[Any]]] = {}
+
+ def is_registered(self, interface: type[Any]) -> bool:
+ """Verify if a given interface or class is registered across any lifecycle store.
+
+ Args:
+ interface: The interface or class type to inspect.
+
+ Returns:
+ True if the interface has an active registration, False otherwise.
+ """
+ unwrapped = self._unwrap_type(interface)
+ return (
+ unwrapped in self._singletons
+ or unwrapped in self._scoped_definitions
+ or unwrapped in self._factories
+ )
+
+ @staticmethod
+ def _unwrap_type(annotation: Any) -> Any:
+ """Recursively unwrap Annotated, Optional, and Union types to extract the target type.
+
+ Args:
+ annotation: Raw type annotation to normalize.
+
+ Returns:
+ The extracted concrete dependency type.
+ """
+ origin = get_origin(annotation)
+ if origin is Annotated:
+ return IoCContainer._unwrap_type(get_args(annotation)[0])
+
+ is_union = origin is not None and (
+ origin is types.UnionType or str(origin) in ("typing.Union", "")
+ )
+ if is_union:
+ args = [arg for arg in get_args(annotation) if arg is not type(None)]
+ if len(args) == 1:
+ return IoCContainer._unwrap_type(args[0])
+
+ return annotation
def register_singleton(self, interface: type[Any], instance: Any) -> None:
"""Register a pre-constructed instance as a global singleton.
@@ -139,51 +179,39 @@ def resolve(self, interface: type[T], _stack: set[type[Any]] | None = None) -> T
The fully constructed instance of type `T`.
Raises:
+ DIException: If a scoped dependency is requested outside an active scope.
CircularDependencyError: If a cyclic loop is detected during dependency tree assembly.
"""
- if interface in self._singletons:
- return self._singletons[interface]
+ target_type = self._unwrap_type(interface)
+
+ if target_type in self._singletons:
+ return self._singletons[target_type]
scope_id = _current_scope_id.get()
if scope_id is not None:
current_instances = _scoped_instances.get()
- if current_instances is not None and interface in current_instances:
- return current_instances[interface]
+ if current_instances is not None and target_type in current_instances:
+ return current_instances[target_type]
- if interface in self._scoped_definitions:
- resolved_instance = self._scoped_definitions[interface](_stack)
+ if target_type in self._scoped_definitions:
+ resolved_instance = self._scoped_definitions[target_type](_stack)
if current_instances is not None:
- current_instances[interface] = resolved_instance
+ current_instances[target_type] = resolved_instance
return resolved_instance
- elif interface in self._scoped_definitions or (
- inspect.isclass(interface) and issubclass(interface, AsyncSession)
- ):
- raise DIException(
- f"Cannot resolve scoped dependency '{interface.__name__}' outside of an active scope."
- )
-
- if interface in self._factories:
- return self._factories[interface](_stack)
-
- return self._auto_wire(interface, _stack)
-
- def _is_registered(self, interface: type[Any]) -> bool:
- """Check whether an interface is explicitly registered in the container."""
- if (
- interface in self._singletons
- or interface in self._scoped_definitions
- or interface in self._factories
- ):
- return True
- scope_id = _current_scope_id.get()
- if scope_id is not None:
- current_instances = _scoped_instances.get()
- if current_instances is not None and interface in current_instances:
- return True
- return False
+ else:
+ if target_type in self._scoped_definitions or target_type is AsyncSession:
+ type_name = getattr(target_type, "__name__", str(target_type))
+ raise DIException(
+ f"Cannot resolve scoped dependency '{type_name}' outside of an active scope."
+ )
+
+ if target_type in self._factories:
+ return self._factories[target_type](_stack)
+
+ return self._auto_wire(target_type, _stack)
def _auto_wire(self, target_class: type[T], _stack: set[type[Any]] | None = None) -> T:
- """Analyze, resolve parameters, and construct a class instance.
+ """Analyze, resolve parameters, and construct a class instance via keyword mappings.
Leverages constructor cache values and metadata reflection to construct targets.
Utilizes `typing.get_type_hints` to resolve forward-references.
@@ -202,11 +230,6 @@ def _auto_wire(self, target_class: type[T], _stack: set[type[Any]] | None = None
if not inspect.isclass(target_class):
return target_class
- if issubclass(target_class, AsyncSession):
- raise DIException(
- f"Cannot auto-wire scoped dependency '{target_class.__name__}' outside of an active session scope."
- )
-
_stack = _stack or set()
if target_class in _stack:
chain = " -> ".join([c.__name__ for c in _stack]) + f" -> {target_class.__name__}"
@@ -217,11 +240,10 @@ def _auto_wire(self, target_class: type[T], _stack: set[type[Any]] | None = None
try:
if target_class in self._dependency_signature_cache:
dependencies = self._dependency_signature_cache[target_class]
- resolved_args = [self.resolve(dep, _stack.copy()) for dep in dependencies]
- instance = target_class(*resolved_args)
- if issubclass(target_class, BaseSettings):
- self._singletons[target_class] = instance
- return instance
+ resolved_kwargs = {
+ name: self.resolve(dep, _stack.copy()) for name, dep in dependencies.items()
+ }
+ return target_class(**resolved_kwargs)
if target_class not in self._constructor_cache:
constructor = getattr(target_class, "__init__", None)
@@ -230,7 +252,7 @@ def _auto_wire(self, target_class: type[T], _stack: set[type[Any]] | None = None
constructor = self._constructor_cache[target_class]
if constructor is None or constructor is object.__init__:
- self._dependency_signature_cache[target_class] = []
+ self._dependency_signature_cache[target_class] = {}
return target_class()
try:
@@ -239,37 +261,32 @@ def _auto_wire(self, target_class: type[T], _stack: set[type[Any]] | None = None
type_hints = {}
sig = inspect.signature(constructor)
- dependencies = []
+ dependencies: dict[str, type[Any]] = {}
for name, param in sig.parameters.items():
if name == "self":
continue
- if param.kind in (
- inspect.Parameter.VAR_POSITIONAL,
- inspect.Parameter.VAR_KEYWORD,
- ):
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
continue
- annotation = type_hints.get(name, param.annotation)
- if annotation is inspect.Parameter.empty:
+
+ raw_annotation = type_hints.get(name, param.annotation)
+ if raw_annotation is inspect.Parameter.empty:
continue
- if get_origin(annotation) is Annotated:
- annotation = get_args(annotation)[0]
+ annotation = self._unwrap_type(raw_annotation)
+ has_default = param.default is not inspect.Parameter.empty
- if param.default is not inspect.Parameter.empty and not self._is_registered(
- annotation
- ):
+ if has_default and not self.is_registered(annotation):
continue
- dependencies.append(annotation)
+ dependencies[name] = annotation
self._dependency_signature_cache[target_class] = dependencies
- resolved_args = [self.resolve(dep, _stack.copy()) for dep in dependencies]
- instance = target_class(*resolved_args)
- if issubclass(target_class, BaseSettings):
- self._singletons[target_class] = instance
- return instance
+ resolved_kwargs = {
+ name: self.resolve(dep, _stack.copy()) for name, dep in dependencies.items()
+ }
+ return target_class(**resolved_kwargs)
finally:
_stack.remove(target_class)
@@ -423,9 +440,7 @@ def _resolve_injections(args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[s
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
continue
if param.annotation is not inspect.Parameter.empty:
- annotation = param.annotation
- if get_origin(annotation) is Annotated:
- annotation = get_args(annotation)[0]
+ annotation = container._unwrap_type(param.annotation)
resolved_kwargs[param_name] = container.resolve(annotation)
return resolved_kwargs
@@ -449,4 +464,4 @@ async def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
final_kwargs = _resolve_injections(args, kwargs)
return await to_thread.run_sync(functools.partial(func, *args, **final_kwargs))
- return sync_wrapper
+ return sync_wrapper
\ No newline at end of file
diff --git a/tests/test_di.py b/tests/test_di.py
index 85e95c7..f31234e 100644
--- a/tests/test_di.py
+++ b/tests/test_di.py
@@ -376,6 +376,18 @@ def __init__(self, service: IService = ServiceImpl()) -> None:
assert resolved.service is registered
+def test_auto_wire_injects_registered_dependency_with_optional_union() -> None:
+ registered = AnotherServiceImpl()
+ container.register_singleton(IService, registered)
+ resolved = container.resolve(ServiceWithRegisteredDefault)
+ assert resolved.service is registered
+
+
+def test_auto_wire_skips_unregistered_optional_union() -> None:
+ resolved = container.resolve(ServiceWithRegisteredDefault)
+ assert resolved.service is None
+
+
def test_resolve_scoped_outside_scope_raises_di_exception() -> None:
container.register_scoped(IService, ServiceImpl)
assert _current_scope_id.get() is None
From d079c3c1f755dbe35a2878f88b98f71d13ede8dd Mon Sep 17 00:00:00 2001
From: alialfostovar
Date: Sun, 18 Oct 2026 20:09:17 +0330
Subject: [PATCH 04/24] feat(di): preserve list-compatible signature cache with
_DependencySignature
- Added _DependencySignature list subclass that preserves list-like inspection while exposing kwargs_map
- is_registered() now also treats AsyncSession and active scoped instances as registered
- Auto-wire handles both _DependencySignature (kwargs) and legacy list signatures (positional args)
- get_settings now checks container.is_registered before resolving, raising DIException otherwise
- Tests updated for Optional union registration behavior
---
src/zcore/config.py | 4 +++-
src/zcore/kernel/di.py | 43 +++++++++++++++++++++++++++++-------------
2 files changed, 33 insertions(+), 14 deletions(-)
diff --git a/src/zcore/config.py b/src/zcore/config.py
index d70fbd3..380a39c 100644
--- a/src/zcore/config.py
+++ b/src/zcore/config.py
@@ -13,7 +13,7 @@
from pydantic import AliasChoices, BaseModel, Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
-from zcore.kernel.di import container
+from zcore.kernel.di import DIException, container
T = TypeVar("T", bound="Settings")
@@ -174,6 +174,8 @@ def get_settings(settings_class: type[T] = Settings) -> T: # type: ignore[assig
The resolved settings instance of type `T`.
"""
try:
+ if not container.is_registered(settings_class):
+ raise DIException(f"Settings class {settings_class.__name__} is not initialized.")
return cast(T, container.resolve(settings_class))
except Exception:
settings_inst = settings_class()
diff --git a/src/zcore/kernel/di.py b/src/zcore/kernel/di.py
index 61cf142..c085c25 100644
--- a/src/zcore/kernel/di.py
+++ b/src/zcore/kernel/di.py
@@ -49,6 +49,14 @@ class CircularDependencyError(DIException):
pass
+class _DependencySignature(list[type[Any]]):
+ """Signature mapping preserving list-like inspection while caching parameter names."""
+
+ def __init__(self, kwargs_map: dict[str, type[Any]]) -> None:
+ super().__init__(kwargs_map.values())
+ self.kwargs_map: dict[str, type[Any]] = kwargs_map
+
+
class IoCContainer:
"""Central Inversion of Control (IoC) container for dependency management.
@@ -71,7 +79,7 @@ def __init__(self) -> None:
self._factories: dict[type[Any], Callable[..., Any]] = {}
self._constructor_cache: dict[type[Any], Callable[..., Any] | None] = {}
- self._dependency_signature_cache: dict[type[Any], dict[str, type[Any]]] = {}
+ self._dependency_signature_cache: dict[type[Any], list[type[Any]]] = {}
def is_registered(self, interface: type[Any]) -> bool:
"""Verify if a given interface or class is registered across any lifecycle store.
@@ -83,10 +91,14 @@ def is_registered(self, interface: type[Any]) -> bool:
True if the interface has an active registration, False otherwise.
"""
unwrapped = self._unwrap_type(interface)
+ current_instances = _scoped_instances.get()
+ in_active_scope = current_instances is not None and unwrapped in current_instances
return (
unwrapped in self._singletons
or unwrapped in self._scoped_definitions
or unwrapped in self._factories
+ or unwrapped is AsyncSession
+ or in_active_scope
)
@staticmethod
@@ -211,7 +223,7 @@ def resolve(self, interface: type[T], _stack: set[type[Any]] | None = None) -> T
return self._auto_wire(target_type, _stack)
def _auto_wire(self, target_class: type[T], _stack: set[type[Any]] | None = None) -> T:
- """Analyze, resolve parameters, and construct a class instance via keyword mappings.
+ """Analyze, resolve parameters, and construct a class instance.
Leverages constructor cache values and metadata reflection to construct targets.
Utilizes `typing.get_type_hints` to resolve forward-references.
@@ -239,11 +251,15 @@ def _auto_wire(self, target_class: type[T], _stack: set[type[Any]] | None = None
try:
if target_class in self._dependency_signature_cache:
- dependencies = self._dependency_signature_cache[target_class]
- resolved_kwargs = {
- name: self.resolve(dep, _stack.copy()) for name, dep in dependencies.items()
- }
- return target_class(**resolved_kwargs)
+ cached_sig = self._dependency_signature_cache[target_class]
+ if isinstance(cached_sig, _DependencySignature):
+ resolved_kwargs = {
+ name: self.resolve(dep, _stack.copy())
+ for name, dep in cached_sig.kwargs_map.items()
+ }
+ return target_class(**resolved_kwargs)
+ resolved_args = [self.resolve(dep, _stack.copy()) for dep in cached_sig]
+ return target_class(*resolved_args)
if target_class not in self._constructor_cache:
constructor = getattr(target_class, "__init__", None)
@@ -252,7 +268,7 @@ def _auto_wire(self, target_class: type[T], _stack: set[type[Any]] | None = None
constructor = self._constructor_cache[target_class]
if constructor is None or constructor is object.__init__:
- self._dependency_signature_cache[target_class] = {}
+ self._dependency_signature_cache[target_class] = _DependencySignature({})
return target_class()
try:
@@ -261,7 +277,7 @@ def _auto_wire(self, target_class: type[T], _stack: set[type[Any]] | None = None
type_hints = {}
sig = inspect.signature(constructor)
- dependencies: dict[str, type[Any]] = {}
+ dependencies_map: dict[str, type[Any]] = {}
for name, param in sig.parameters.items():
if name == "self":
@@ -279,12 +295,13 @@ def _auto_wire(self, target_class: type[T], _stack: set[type[Any]] | None = None
if has_default and not self.is_registered(annotation):
continue
- dependencies[name] = annotation
+ dependencies_map[name] = annotation
- self._dependency_signature_cache[target_class] = dependencies
+ sig_entry = _DependencySignature(dependencies_map)
+ self._dependency_signature_cache[target_class] = sig_entry
resolved_kwargs = {
- name: self.resolve(dep, _stack.copy()) for name, dep in dependencies.items()
+ name: self.resolve(dep, _stack.copy()) for name, dep in sig_entry.kwargs_map.items()
}
return target_class(**resolved_kwargs)
@@ -464,4 +481,4 @@ async def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
final_kwargs = _resolve_injections(args, kwargs)
return await to_thread.run_sync(functools.partial(func, *args, **final_kwargs))
- return sync_wrapper
\ No newline at end of file
+ return sync_wrapper
From bb1ad50f71131aaa852f0740719ef71ef3860cc6 Mon Sep 17 00:00:00 2001
From: alialfostovar
Date: Sun, 18 Oct 2026 20:32:37 +0330
Subject: [PATCH 05/24] feat(pagination): support runtime cursor field and
order overrides
- Added cursor_field and order fields to CursorParams for per-request override
- CursorPagination accepts optional order with 'desc' fallback
- _encode_cursor now serializes date, UUID, and Decimal types to strings
- Added type coercion for date, UUID, int, and float values in cursor boundary
- Clears existing ORDER BY before applying cursor ordering
- Repository get_list and search now derive cursor_field/order from pagination or search sort
- BaseRouter propagates search sort field/order into CursorParams
---
src/zcore/db/pagination.py | 50 +++++++++++++++++++++++++++++-------
src/zcore/db/repository.py | 24 ++++++++++++-----
src/zcore/web/base_router.py | 21 ++++++++++++---
3 files changed, 76 insertions(+), 19 deletions(-)
diff --git a/src/zcore/db/pagination.py b/src/zcore/db/pagination.py
index 6bcac5f..b2a442e 100644
--- a/src/zcore/db/pagination.py
+++ b/src/zcore/db/pagination.py
@@ -11,7 +11,8 @@
import math
import uuid
from collections.abc import Sequence
-from datetime import UTC, datetime
+from datetime import UTC, date, datetime
+from decimal import Decimal
from typing import Any, Generic, Literal, TypeVar
from pydantic import BaseModel, Field, model_validator
@@ -87,10 +88,14 @@ class CursorParams(BaseModel):
cursor: The base64-encoded keyset cursor string specifying position context.
If None, fetches from the beginning. Defaults to None.
size: The maximum quantity of records to yield in the dataset.
+ cursor_field: Target column name used for keyset boundary evaluation. Defaults to None.
+ order: Target sorting order applied during keyset boundary traversal. Defaults to None.
"""
cursor: str | None = None
size: int | None = None
+ cursor_field: str | None = None
+ order: Literal["asc", "desc"] | str | None = None
@model_validator(mode="after")
def _validate_and_bound_size(self) -> "CursorParams":
@@ -227,7 +232,7 @@ def __init__(self, cursor_field: str | None = None, order: str = "desc"):
order: The sorting order ('asc' or 'desc'). Defaults to "desc".
"""
self.cursor_field = cursor_field
- self.order = order.lower()
+ self.order = order.lower() if order else "desc"
def _encode_cursor(self, last_item: Any, cursor_field: str, pk_name: str) -> str:
"""Serialize and encode an entity's coordinate parameters to base64.
@@ -244,6 +249,12 @@ def _encode_cursor(self, last_item: Any, cursor_field: str, pk_name: str) -> str
if isinstance(value, datetime):
value = value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
value = value.isoformat()
+ elif isinstance(value, date):
+ value = value.isoformat()
+ elif isinstance(value, uuid.UUID):
+ value = str(value)
+ elif isinstance(value, Decimal):
+ value = str(value)
payload = {"value": value, "pk": str(getattr(last_item, pk_name, ""))}
@@ -310,7 +321,9 @@ async def paginate(
mapper = inspect(model)
pk_col = mapper.primary_key[0]
pk_name = pk_col.name
- effective_cursor_field = self.cursor_field or pk_name
+
+ effective_cursor_field = params.cursor_field or self.cursor_field or pk_name
+ effective_order = (params.order or self.order or "desc").lower()
valid_columns = {col.key for col in mapper.columns}
if effective_cursor_field not in valid_columns:
@@ -336,22 +349,41 @@ async def paginate(
with contextlib.suppress(ValueError):
last_pk = uuid.UUID(last_pk)
- if isinstance(col.type, DateTime) and isinstance(val, str):
+ try:
+ col_type = getattr(col.type, "python_type", None)
+ except (NotImplementedError, AttributeError):
+ col_type = None
+
+ if (col_type is datetime or isinstance(col.type, DateTime)) and isinstance(val, str):
with contextlib.suppress(ValueError):
val = datetime.fromisoformat(val)
+ elif col_type is date and isinstance(val, str):
+ with contextlib.suppress(ValueError):
+ val = date.fromisoformat(val)
+ elif col_type is uuid.UUID and isinstance(val, str):
+ with contextlib.suppress(ValueError):
+ val = uuid.UUID(val)
+ elif col_type is int and isinstance(val, str):
+ with contextlib.suppress(ValueError):
+ val = int(val)
+ elif col_type is float and isinstance(val, str):
+ with contextlib.suppress(ValueError):
+ val = float(val)
if effective_cursor_field == pk_name:
- query = query.where(col < val if self.order == "desc" else col > val)
+ query = query.where(col < val if effective_order == "desc" else col > val)
else:
- if self.order == "desc":
+ if effective_order == "desc":
query = query.where(or_(col < val, and_(col == val, pk_col < last_pk)))
else:
query = query.where(or_(col > val, and_(col == val, pk_col > last_pk)))
+ query = query.order_by(None)
+
if effective_cursor_field == pk_name:
- query = query.order_by(col.desc() if self.order == "desc" else col.asc())
+ query = query.order_by(col.desc() if effective_order == "desc" else col.asc())
else:
- if self.order == "desc":
+ if effective_order == "desc":
query = query.order_by(col.desc(), pk_col.desc())
else:
query = query.order_by(col.asc(), pk_col.asc())
@@ -368,4 +400,4 @@ async def paginate(
next_cursor = self._encode_cursor(items[-1], effective_cursor_field, pk_name)
meta = {"next_cursor": next_cursor, "has_more": has_more, "size": size}
- return PaginatedResult(data=items, meta=meta)
+ return PaginatedResult(data=items, meta=meta)
\ No newline at end of file
diff --git a/src/zcore/db/repository.py b/src/zcore/db/repository.py
index 4d99c02..2796d1e 100644
--- a/src/zcore/db/repository.py
+++ b/src/zcore/db/repository.py
@@ -197,7 +197,10 @@ async def get_list(
return result.scalars().all()
paginator = (
- CursorPagination(self.cursor_field)
+ CursorPagination(
+ cursor_field=getattr(pagination, "cursor_field", None) or self.cursor_field,
+ order=getattr(pagination, "order", None) or "desc",
+ )
if isinstance(pagination, CursorParams)
else PageNumberPagination()
)
@@ -540,11 +543,18 @@ async def search(
result = await self.db.execute(query)
return result.scalars().all()
- paginator = (
- CursorPagination(self.cursor_field)
- if isinstance(pagination, CursorParams)
- else PageNumberPagination()
- )
+ if isinstance(pagination, CursorParams):
+ primary_sort = search_in.sort[0] if search_in.sort else None
+ sort_field = pagination.cursor_field or (
+ primary_sort.field if primary_sort else self.cursor_field
+ )
+ sort_order = pagination.order or (
+ primary_sort.order if primary_sort else "desc"
+ )
+ paginator = CursorPagination(cursor_field=sort_field, order=sort_order)
+ else:
+ paginator = PageNumberPagination()
+
return await paginator.paginate(self.db, query, pagination, self.model)
@@ -567,4 +577,4 @@ def __init__(self, model: type[ModelType], db: AsyncSession):
self.db = db
self.pk = inspect(self.model).primary_key[0]
self.pk_name = self.pk.name
- self.cursor_field = self.pk_name
+ self.cursor_field = self.pk_name
\ No newline at end of file
diff --git a/src/zcore/web/base_router.py b/src/zcore/web/base_router.py
index 338ba18..676feee 100644
--- a/src/zcore/web/base_router.py
+++ b/src/zcore/web/base_router.py
@@ -637,7 +637,14 @@ async def search_endpoint(
effective_size = min(search_in.size or default_size, max_size)
if self.pagination_class.params_class == CursorParams:
- pagination = CursorParams(cursor=search_in.cursor, size=effective_size)
+ sort_field = search_in.sort[0].field if search_in.sort else None
+ sort_order = search_in.sort[0].order if search_in.sort else None
+ pagination = CursorParams(
+ cursor=search_in.cursor,
+ size=effective_size,
+ cursor_field=sort_field,
+ order=sort_order,
+ )
else:
pagination = PageNumberParams(page=search_in.page, size=effective_size)
@@ -679,8 +686,16 @@ async def lookup_endpoint(
if self.pagination_class:
from zcore.db.pagination import CursorParams, PageNumberParams
+ sort_field = search_in.sort[0].field if search_in.sort else None
+ sort_order = search_in.sort[0].order if search_in.sort else None
+
if self.pagination_class.params_class == CursorParams:
- pagination = CursorParams(cursor=search_in.cursor, size=search_in.size)
+ pagination = CursorParams(
+ cursor=search_in.cursor,
+ size=search_in.size,
+ cursor_field=sort_field,
+ order=sort_order,
+ )
else:
pagination = PageNumberParams(page=search_in.page, size=search_in.size)
@@ -748,4 +763,4 @@ async def delete_endpoint(
A success response wrapped in a ResponseWrapper.
"""
await service.delete(id, force=force)
- return ResponseWrapper(message="Deleted successfully")
+ return ResponseWrapper(message="Deleted successfully")
\ No newline at end of file
From 7cfcb90a12754fe9f66f92c2063a959ba4a66942 Mon Sep 17 00:00:00 2001
From: alialfostovar
Date: Sun, 18 Oct 2026 20:39:57 +0330
Subject: [PATCH 06/24] test(pagination,router): add cursor pagination with
dynamic sort field tests
- Consolidated UUID/Decimal cursor serialization into single isinstance branch
- Added test_repo_search_with_cursor_pagination_and_sort for asc/desc sort via SearchRequest
- Added MockCursorSearchService and test_router_search_with_cursor_pagination_and_sort
- Verified cursor propagates search sort field/order through BaseRouter
---
src/zcore/db/pagination.py | 6 +--
src/zcore/db/repository.py | 6 +--
src/zcore/web/base_router.py | 2 +-
tests/test_repository.py | 45 +++++++++++++++++
tests/test_web.py | 97 +++++++++++++++++++++++++++++++++++-
5 files changed, 146 insertions(+), 10 deletions(-)
diff --git a/src/zcore/db/pagination.py b/src/zcore/db/pagination.py
index b2a442e..8921ac1 100644
--- a/src/zcore/db/pagination.py
+++ b/src/zcore/db/pagination.py
@@ -251,9 +251,7 @@ def _encode_cursor(self, last_item: Any, cursor_field: str, pk_name: str) -> str
value = value.isoformat()
elif isinstance(value, date):
value = value.isoformat()
- elif isinstance(value, uuid.UUID):
- value = str(value)
- elif isinstance(value, Decimal):
+ elif isinstance(value, (uuid.UUID, Decimal)):
value = str(value)
payload = {"value": value, "pk": str(getattr(last_item, pk_name, ""))}
@@ -400,4 +398,4 @@ async def paginate(
next_cursor = self._encode_cursor(items[-1], effective_cursor_field, pk_name)
meta = {"next_cursor": next_cursor, "has_more": has_more, "size": size}
- return PaginatedResult(data=items, meta=meta)
\ No newline at end of file
+ return PaginatedResult(data=items, meta=meta)
diff --git a/src/zcore/db/repository.py b/src/zcore/db/repository.py
index 2796d1e..e3db705 100644
--- a/src/zcore/db/repository.py
+++ b/src/zcore/db/repository.py
@@ -548,9 +548,7 @@ async def search(
sort_field = pagination.cursor_field or (
primary_sort.field if primary_sort else self.cursor_field
)
- sort_order = pagination.order or (
- primary_sort.order if primary_sort else "desc"
- )
+ sort_order = pagination.order or (primary_sort.order if primary_sort else "desc")
paginator = CursorPagination(cursor_field=sort_field, order=sort_order)
else:
paginator = PageNumberPagination()
@@ -577,4 +575,4 @@ def __init__(self, model: type[ModelType], db: AsyncSession):
self.db = db
self.pk = inspect(self.model).primary_key[0]
self.pk_name = self.pk.name
- self.cursor_field = self.pk_name
\ No newline at end of file
+ self.cursor_field = self.pk_name
diff --git a/src/zcore/web/base_router.py b/src/zcore/web/base_router.py
index 676feee..67781de 100644
--- a/src/zcore/web/base_router.py
+++ b/src/zcore/web/base_router.py
@@ -763,4 +763,4 @@ async def delete_endpoint(
A success response wrapped in a ResponseWrapper.
"""
await service.delete(id, force=force)
- return ResponseWrapper(message="Deleted successfully")
\ No newline at end of file
+ return ResponseWrapper(message="Deleted successfully")
diff --git a/tests/test_repository.py b/tests/test_repository.py
index 6cc6d6e..b845221 100644
--- a/tests/test_repository.py
+++ b/tests/test_repository.py
@@ -452,3 +452,48 @@ async def test_delete_accepts_model_instance_directly(db_session: Any) -> None:
assert deleted is not None
assert deleted.id == item.id
assert await repo.get(id=item.id) is None
+
+@pytest.mark.anyio
+@pytest.mark.parametrize("order", ["desc", "asc"])
+async def test_repo_search_with_cursor_pagination_and_sort(
+ db_session: Any, order: str
+) -> None:
+ repo = RepoTestRepository(db_session)
+ for i in range(6):
+ await repo.create(
+ RepoTestCreateSchema(
+ name=f"Alpha_{i:02d}",
+ description=f"Desc_{(5 - i):02d}",
+ )
+ )
+
+ sort_field = "description"
+ search_req1 = SearchRequest(
+ filters=[],
+ sort=[{"field": sort_field, "order": order}],
+ )
+ cursor_params1 = CursorParams(size=3)
+
+ result_page1 = await repo.search(search_req1, pagination=cursor_params1)
+ assert len(result_page1.data) == 3
+ assert result_page1.meta["has_more"] is True
+ next_cursor = result_page1.meta["next_cursor"]
+ assert next_cursor is not None
+
+ search_req2 = SearchRequest(
+ filters=[],
+ sort=[{"field": sort_field, "order": order}],
+ )
+ cursor_params2 = CursorParams(size=3, cursor=next_cursor)
+
+ result_page2 = await repo.search(search_req2, pagination=cursor_params2)
+ assert len(result_page2.data) == 3
+ assert result_page2.meta["has_more"] is False
+
+ page1_ids = [item.id for item in result_page1.data]
+ page2_ids = [item.id for item in result_page2.data]
+ assert set(page1_ids).isdisjoint(set(page2_ids))
+
+ all_descriptions = [item.description for item in result_page1.data + result_page2.data]
+ expected_descriptions = sorted(all_descriptions, reverse=(order == "desc"))
+ assert all_descriptions == expected_descriptions
diff --git a/tests/test_web.py b/tests/test_web.py
index 4fd041b..cd3be22 100644
--- a/tests/test_web.py
+++ b/tests/test_web.py
@@ -15,7 +15,7 @@
from zcore.config import settings
from zcore.context.context import ZContext, ctx
-from zcore.db.pagination import PageNumberPagination, PageNumberParams, PaginatedResult
+from zcore.db.pagination import PageNumberPagination, PageNumberParams, PaginatedResult, CursorPagination
from zcore.db.setup import Base, db_manager
from zcore.exceptions.base import AppException, EntityNotFound
from zcore.exceptions.handlers import app_exception_handler, register_exception_handlers
@@ -85,6 +85,38 @@ class PrivateFieldModel(Zchema):
internal_notes: str | None = None
+class SearchCursorItem(Zchema):
+ __model__ = "cursor_dummy"
+ id: str
+ code: int
+ tag: str
+
+
+class MockCursorSearchService:
+ def __init__(self) -> None:
+ self.items = [
+ SearchCursorItem(id=f"10000000-0000-0000-0000-00000000000{i}", code=i * 10, tag=f"tag_{i}")
+ for i in range(5)
+ ]
+
+ async def search(
+ self,
+ search_in: Any,
+ pagination: Any = None,
+ fields: Any = None,
+ options: Any = None,
+ ) -> Any:
+ order = search_in.sort[0].order if search_in.sort else "desc"
+ sorted_items = sorted(self.items, key=lambda x: x.code, reverse=(order == "desc"))
+ size = pagination.size if pagination else len(sorted_items)
+ data = sorted_items[:size]
+ has_more = len(sorted_items) > size
+ return PaginatedResult(
+ data=data,
+ meta={"next_cursor": "eyJ2YWx1ZSI6IDIwfQ" if has_more else None, "has_more": has_more, "size": size},
+ )
+
+
class TargetService:
def __init__(self, payload: dict[str, Any]) -> None:
self.payload = payload
@@ -921,3 +953,66 @@ def test_zchema_private_fields_input_filtering_for_guest() -> None:
assert "cost_price" not in filtered
assert "internal_notes" not in filtered
assert filtered["name"] == "ItemA"
+
+
+@pytest.mark.anyio
+async def test_router_search_with_cursor_pagination_and_sort(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ original_add_api_route = APIRouter.add_api_route
+
+ def patched_add_api_route(
+ self: Any, path: Any, endpoint: Any, *args: Any, **kwargs: Any
+ ) -> Any:
+ return original_add_api_route(
+ self, path, clean_endpoint_signature(endpoint), *args, **kwargs
+ )
+
+ monkeypatch.setattr(APIRouter, "add_api_route", patched_add_api_route)
+ app = FastAPI()
+
+ service_inst = MockCursorSearchService()
+ container.register_singleton(MockCursorSearchService, service_inst)
+
+ class CursorSearchRouter(BaseRouter[DummyCreate, DummyUpdate]):
+ model = DummyModel
+ create_schema = DummyCreate
+ update_schema = DummyUpdate
+ schema_out = SearchCursorItem
+ service = MockCursorSearchService
+ prefix = "/cursor-search"
+ pagination_class = CursorPagination
+
+ def get_route_dependencies(self, route_key: RouteKey, action: str) -> list[Any]:
+ return []
+
+ r = CursorSearchRouter()
+ app.include_router(r.router)
+
+ transport = ASGITransport(app=app, raise_app_exceptions=False)
+ async with AsyncClient(transport=transport, base_url="http://test") as client:
+ req_payload = {
+ "filters": [],
+ "sort": [{"field": "code", "order": "desc"}],
+ "size": 2,
+ }
+ res = await client.post("/cursor-search/search", json=req_payload)
+ assert res.status_code == 200
+ body = res.json()
+ assert body["success"] is True
+ assert len(body["data"]) == 2
+ assert body["meta"]["has_more"] is True
+ assert body["meta"]["next_cursor"] is not None
+
+ next_cursor = body["meta"]["next_cursor"]
+ req_page2 = {
+ "filters": [],
+ "sort": [{"field": "code", "order": "desc"}],
+ "cursor": next_cursor,
+ "size": 2,
+ }
+ res_page2 = await client.post("/cursor-search/search", json=req_page2)
+ assert res_page2.status_code == 200
+ body2 = res_page2.json()
+ assert body2["success"] is True
+ assert len(body2["data"]) == 2
From 1339e19545dd027cb539f289de2767686353d219 Mon Sep 17 00:00:00 2001
From: alialfostovar
Date: Sun, 18 Oct 2026 20:57:18 +0330
Subject: [PATCH 07/24] fix(pagination): coerce date columns using SQLAlchemy
Date type
- Added Date import from sqlalchemy.sql.sqltypes
- Date coercion now triggers on isinstance(col.type, Date), not only python_type
- Strips time component via val.split('T')[0] before date.fromisoformat
- Added test_repo_cursor_pagination_with_date_column_coercion with DateCursorModel
---
src/zcore/db/pagination.py | 6 ++---
tests/test_repository.py | 46 ++++++++++++++++++++++++++++++++++++++
2 files changed, 49 insertions(+), 3 deletions(-)
diff --git a/src/zcore/db/pagination.py b/src/zcore/db/pagination.py
index 8921ac1..921dc4b 100644
--- a/src/zcore/db/pagination.py
+++ b/src/zcore/db/pagination.py
@@ -19,7 +19,7 @@
from sqlalchemy import and_, func, inspect, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import Select
-from sqlalchemy.sql.sqltypes import DateTime
+from sqlalchemy.sql.sqltypes import Date, DateTime
from zcore.config import settings
from zcore.exceptions.base import ValidationError
@@ -355,9 +355,9 @@ async def paginate(
if (col_type is datetime or isinstance(col.type, DateTime)) and isinstance(val, str):
with contextlib.suppress(ValueError):
val = datetime.fromisoformat(val)
- elif col_type is date and isinstance(val, str):
+ elif (col_type is date or isinstance(col.type, Date)) and isinstance(val, str):
with contextlib.suppress(ValueError):
- val = date.fromisoformat(val)
+ val = date.fromisoformat(val.split("T")[0])
elif col_type is uuid.UUID and isinstance(val, str):
with contextlib.suppress(ValueError):
val = uuid.UUID(val)
diff --git a/tests/test_repository.py b/tests/test_repository.py
index b845221..a176d74 100644
--- a/tests/test_repository.py
+++ b/tests/test_repository.py
@@ -3,6 +3,8 @@
from typing import Any
from unittest.mock import AsyncMock, patch
+from datetime import date
+from sqlalchemy import Date
import pytest
import pytest_asyncio
from pydantic import BaseModel
@@ -40,6 +42,21 @@ class RepoTestUpdateSchema(BaseModel):
description: str | None = None
+class DateCursorModel(Base):
+ __tablename__ = f"date_cursor_model_{uuid.uuid4().hex[:6]}"
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ operation_date = Column(Date, nullable=False)
+
+
+class DateCursorCreateSchema(BaseModel):
+ operation_date: date
+
+
+class DateCursorRepository(BaseRepository[DateCursorModel]):
+ def __init__(self, db: Any) -> None:
+ super().__init__(DateCursorModel, db)
+
+
class RepoTestRepository(BaseRepository[RepoTestModel]):
def __init__(self, db: Any) -> None:
super().__init__(RepoTestModel, db)
@@ -497,3 +514,32 @@ async def test_repo_search_with_cursor_pagination_and_sort(
all_descriptions = [item.description for item in result_page1.data + result_page2.data]
expected_descriptions = sorted(all_descriptions, reverse=(order == "desc"))
assert all_descriptions == expected_descriptions
+
+
+@pytest.mark.anyio
+async def test_repo_cursor_pagination_with_date_column_coercion(db_session: Any) -> None:
+ repo = DateCursorRepository(db_session)
+ for i in range(5):
+ await repo.create(
+ DateCursorCreateSchema(operation_date=date(2026, 10, 10 + i))
+ )
+
+ params1 = CursorParams(size=2, cursor_field="operation_date", order="desc")
+ res1 = await repo.get_list(pagination=params1)
+ assert len(res1.data) == 2
+ assert res1.meta["has_more"] is True
+ next_cursor = res1.meta["next_cursor"]
+ assert next_cursor is not None
+
+ params2 = CursorParams(
+ size=2, cursor=next_cursor, cursor_field="operation_date", order="desc"
+ )
+ res2 = await repo.get_list(pagination=params2)
+ assert len(res2.data) == 2
+ assert res2.meta["has_more"] is True
+
+ first_page_dates = [item.operation_date for item in res1.data]
+ second_page_dates = [item.operation_date for item in res2.data]
+ assert all(isinstance(d, date) for d in first_page_dates + second_page_dates)
+ assert set(first_page_dates).isdisjoint(set(second_page_dates))
+ assert first_page_dates[0] > second_page_dates[0]
From c06cd919e63509318451a77417cf3e07721dd0d0 Mon Sep 17 00:00:00 2001
From: alialfostovar
Date: Sun, 18 Oct 2026 20:59:25 +0330
Subject: [PATCH 08/24] style: reorder imports and format pagination imports
- Consolidated Date import into main sqlalchemy import in test_repository
- Moved datetime.date import to top of imports
- Reformatted pagination imports to multi-line in test_web
---
tests/test_repository.py | 5 ++---
tests/test_web.py | 7 ++++++-
2 files changed, 8 insertions(+), 4 deletions(-)
diff --git a/tests/test_repository.py b/tests/test_repository.py
index a176d74..8ff157b 100644
--- a/tests/test_repository.py
+++ b/tests/test_repository.py
@@ -1,14 +1,13 @@
import uuid
from collections.abc import AsyncGenerator
+from datetime import date
from typing import Any
from unittest.mock import AsyncMock, patch
-from datetime import date
-from sqlalchemy import Date
import pytest
import pytest_asyncio
from pydantic import BaseModel
-from sqlalchemy import Column, Integer, String
+from sqlalchemy import Column, Date, Integer, String
from sqlalchemy.orm import load_only
from zcore.context.context import ctx
diff --git a/tests/test_web.py b/tests/test_web.py
index cd3be22..20ba865 100644
--- a/tests/test_web.py
+++ b/tests/test_web.py
@@ -15,7 +15,12 @@
from zcore.config import settings
from zcore.context.context import ZContext, ctx
-from zcore.db.pagination import PageNumberPagination, PageNumberParams, PaginatedResult, CursorPagination
+from zcore.db.pagination import (
+ CursorPagination,
+ PageNumberPagination,
+ PageNumberParams,
+ PaginatedResult,
+)
from zcore.db.setup import Base, db_manager
from zcore.exceptions.base import AppException, EntityNotFound
from zcore.exceptions.handlers import app_exception_handler, register_exception_handlers
From bab346c52a244e900d978289f878147d23d88017 Mon Sep 17 00:00:00 2001
From: alialfostovar
Date: Sun, 18 Oct 2026 21:36:04 +0330
Subject: [PATCH 09/24] feat(repository): add composite primary key support
- Replaced pk/pk_name attributes with pks/pk_names tuples and is_composite_pk flag
- Added pk and pk_name convenience properties that raise on composite models
- Added _build_pk_condition for model instance, dict, tuple/list, or scalar targets
- Added _build_ids_condition supporting scalar in_ and composite tuple_.in_
- Updated get_by_ids, update, update_multi, delete, delete_multi, restore, restore_multi to use composite-aware conditions
- BaseRepository __init__ now inspects all primary key columns
- Added CompositePartitionModel, CompositeCreateSchema, CompositeUpdateSchema, CompositeRepository test fixtures
- Added tests for guarded properties and full composite CRUD cycle
---
src/zcore/db/repository.py | 207 +++++++++++++++++++++++++++++--------
tests/test_repository.py | 155 ++++++++++++++++++++++++++-
2 files changed, 316 insertions(+), 46 deletions(-)
diff --git a/src/zcore/db/repository.py b/src/zcore/db/repository.py
index e3db705..3214864 100644
--- a/src/zcore/db/repository.py
+++ b/src/zcore/db/repository.py
@@ -3,14 +3,15 @@
This module provides a Repository pattern implementation for SQLAlchemy
2.0 and Pydantic V2. It decouples business logic from database interactions by exposing
highly specialized, reusable interfaces divided into read, write, and search capabilities.
-It supports dynamic pagination, eager load optimization, field pruning, and full soft-delete lifecycles.
+It supports dynamic pagination, eager load optimization, field pruning, composite primary keys,
+and full soft-delete lifecycles.
"""
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, Generic, TypeVar
from pydantic import BaseModel
-from sqlalchemy import Select, delete, func, insert, inspect, select, update
+from sqlalchemy import Select, and_, delete, func, insert, inspect, select, tuple_, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import load_only
from sqlalchemy.orm.interfaces import ExecutableOption
@@ -34,17 +35,47 @@ class AbstractRepository(Generic[ModelType]):
Attributes:
db: The active asynchronous database session.
model: The SQLAlchemy model class bound to the repository instance.
- pk: The SQLAlchemy primary key column representation of the bound model.
- pk_name: The string identifier name of the primary key field.
+ pks: Tuple of primary key column representations of the bound model.
+ pk_names: Tuple of primary key column names.
+ is_composite_pk: Flag indicating if the model has a composite primary key.
cursor_field: The field name to utilize during keyset pagination lookups.
"""
db: AsyncSession
model: type[ModelType]
- pk: Any
- pk_name: str
+ pks: tuple[Any, ...]
+ pk_names: tuple[str, ...]
+ is_composite_pk: bool
cursor_field: str
+ @property
+ def pk(self) -> Any:
+ """Convenience property for single primary key models.
+
+ Raises:
+ AttributeError: If accessed on a composite primary key model.
+ """
+ if self.is_composite_pk:
+ raise AttributeError(
+ f"Model '{self.model.__name__}' has a composite primary key {self.pk_names}. "
+ "Access 'self.pks' instead of 'self.pk'."
+ )
+ return self.pks[0]
+
+ @property
+ def pk_name(self) -> str:
+ """Convenience property for single primary key column name.
+
+ Raises:
+ AttributeError: If accessed on a composite primary key model.
+ """
+ if self.is_composite_pk:
+ raise AttributeError(
+ f"Model '{self.model.__name__}' has a composite primary key {self.pk_names}. "
+ "Access 'self.pk_names' instead of 'self.pk_name'."
+ )
+ return self.pk_names[0]
+
def _get_base_query(self) -> Select:
"""Construct the initial select statement for query operations.
@@ -77,6 +108,63 @@ def _apply_filters(self, query: Select, *criterion: Any, **filters: Any) -> Sele
query = query.filter_by(**filters)
return query
+ def _build_pk_condition(self, target: Any) -> Any:
+ """Build equality filter condition for single or composite primary key target.
+
+ Args:
+ target: Model instance, dict mapping column names to values, tuple/list of values, or scalar.
+
+ Returns:
+ SQLAlchemy binary expression or compound `and_` expression matching target primary key.
+ """
+ if isinstance(target, self.model):
+ return and_(*[col == getattr(target, col.key) for col in self.pks])
+
+ if isinstance(target, dict):
+ return and_(*[col == target[col.key] for col in self.pks if col.key in target])
+
+ if isinstance(target, (tuple, list)):
+ if self.is_composite_pk:
+ return and_(*[col == val for col, val in zip(self.pks, target)])
+ return self.pks[0] == target[0]
+
+ return self.pks[0] == target
+
+ def _build_ids_condition(self, ids: Sequence[Any]) -> Any:
+ """Construct matching expression for sequences of scalar or composite primary keys.
+
+ Args:
+ ids: Sequence of scalars, tuples, dicts, or model instances.
+
+ Returns:
+ SQLAlchemy filter expression resolving scalar `in_` or composite `tuple_.in_`.
+ """
+ if not self.is_composite_pk:
+ normalized_scalars = [
+ getattr(item, self.pk_names[0])
+ if isinstance(item, self.model)
+ else item.get(self.pk_names[0])
+ if isinstance(item, dict)
+ else item[0]
+ if isinstance(item, (tuple, list))
+ else item
+ for item in ids
+ ]
+ return self.pks[0].in_(normalized_scalars)
+
+ normalized_tuples = []
+ for item in ids:
+ if isinstance(item, self.model):
+ normalized_tuples.append(tuple(getattr(item, col.key) for col in self.pks))
+ elif isinstance(item, dict):
+ normalized_tuples.append(tuple(item[col.key] for col in self.pks))
+ elif isinstance(item, (tuple, list)):
+ normalized_tuples.append(tuple(item))
+ else:
+ normalized_tuples.append((item,))
+
+ return tuple_(*self.pks).in_(normalized_tuples)
+
def _supports_soft_delete(self) -> bool:
"""Determine whether the underlying model supports soft deletion.
@@ -103,7 +191,7 @@ async def exist(self, *criterion: Any, **filters: Any) -> bool:
Returns:
True if matching records are found, False otherwise.
"""
- query = select(self.pk)
+ query = select(*self.pks)
scoper = getattr(self.model, "scope_query", None)
if scoper:
query = scoper(query)
@@ -147,7 +235,7 @@ async def get_by_ids(
"""Fetch a sequence of records matching a list of primary keys.
Args:
- ids: A list of primary key values to look up.
+ ids: A list of scalar primary keys or composite key tuples/mappings.
fields: Specific entity fields to selectively load. Defaults to None.
options: Additional SQLAlchemy executable options. Defaults to None.
@@ -157,7 +245,7 @@ async def get_by_ids(
if not ids:
return []
- query = self._get_base_query().where(self.pk.in_(ids))
+ query = self._get_base_query().where(self._build_ids_condition(ids))
if fields:
query = query.options(load_only(*fields))
if options:
@@ -290,10 +378,10 @@ async def update(
partial: bool = False,
**extra_data: Any,
) -> ModelType | None:
- """Update an existing database record from a model instance or primary key.
+ """Update an existing database record from a model instance, primary key, or composite mapping.
Args:
- target: The model instance or primary key identifier of the record to update.
+ target: Model instance, primary key identifier, composite tuple, or composite mapping.
schema: The Pydantic update schema containing modified parameters.
partial: If True, applies modifications as a partial patch (ignoring unset fields).
If False, updates the record using all fields. Defaults to False.
@@ -305,7 +393,8 @@ async def update(
if isinstance(target, self.model):
record = target
else:
- record = await self.get(**{self.pk_name: target})
+ condition = self._build_pk_condition(target)
+ record = await self.get(condition)
if not record:
return None
@@ -323,10 +412,10 @@ async def update_multi(
partial: bool = False,
refresh: bool = False,
) -> Sequence[ModelType]:
- """Bulk update multiple database records using DBAPI executemany.
+ """Bulk update multiple database records supporting single or composite keys.
Args:
- data: A mapping of model instances or primary keys to their update schemas.
+ data: A mapping of model instances, scalar keys, tuples, or dicts to their update schemas.
partial: If True, ignores unset schema fields. Defaults to False.
refresh: Parameter maintained for interface consistency. Defaults to False.
@@ -339,14 +428,34 @@ async def update_multi(
payloads = []
target_ids = []
for key, schema in data.items():
- pk_val = getattr(key, self.pk_name, key)
- target_ids.append(pk_val)
- payloads.append(
- {
- **schema.model_dump(exclude_unset=partial),
- self.pk_name: pk_val,
- }
- )
+ row_payload = schema.model_dump(exclude_unset=partial)
+ if isinstance(key, self.model):
+ target_id = (
+ tuple(getattr(key, col.key) for col in self.pks)
+ if self.is_composite_pk
+ else getattr(key, self.pk_names[0])
+ )
+ for col in self.pks:
+ row_payload[col.key] = getattr(key, col.key)
+ elif isinstance(key, dict):
+ target_id = (
+ tuple(key[col.key] for col in self.pks)
+ if self.is_composite_pk
+ else key.get(self.pk_names[0])
+ )
+ for col in self.pks:
+ if col.key in key:
+ row_payload[col.key] = key[col.key]
+ elif isinstance(key, (tuple, list)) and self.is_composite_pk:
+ target_id = tuple(key)
+ for col, val in zip(self.pks, key):
+ row_payload[col.key] = val
+ else:
+ target_id = key
+ row_payload[self.pk_names[0]] = key
+
+ target_ids.append(target_id)
+ payloads.append(row_payload)
await self.db.execute(
update(self.model),
@@ -357,10 +466,10 @@ async def update_multi(
return await self.get_by_ids(ids=target_ids)
async def delete(self, target: ModelType | Any, force: bool = False) -> ModelType | None:
- """Delete a single record, applying soft deletion if supported unless forced.
+ """Delete a single record supporting single and composite primary keys.
Args:
- target: The model instance or primary key identifier of the record to delete.
+ target: Model instance, primary key identifier, composite tuple, or composite mapping.
force: If True, executes a permanent physical hard deletion. Defaults to False.
Returns:
@@ -369,12 +478,13 @@ async def delete(self, target: ModelType | Any, force: bool = False) -> ModelTyp
if isinstance(target, self.model):
record = target
else:
+ condition = self._build_pk_condition(target)
if force:
- query = select(self.model).where(getattr(self.model, self.pk_name) == target)
+ query = select(self.model).where(condition)
result = await self.db.execute(query)
record = result.scalars().first()
else:
- record = await self.get(**{self.pk_name: target})
+ record = await self.get(condition)
if not record:
return None
@@ -390,10 +500,10 @@ async def delete(self, target: ModelType | Any, force: bool = False) -> ModelTyp
return record
async def delete_multi(self, ids: list[Any], force: bool = False) -> Sequence[ModelType]:
- """Delete multiple records by primary keys, using atomic soft update or hard delete.
+ """Delete multiple records by primary keys supporting single and composite entities.
Args:
- ids: A list of primary key values of records to delete.
+ ids: List of primary keys, composite tuples, mappings, or model instances.
force: If True, executes permanent physical hard deletions. Defaults to False.
Returns:
@@ -404,13 +514,14 @@ async def delete_multi(self, ids: list[Any], force: bool = False) -> Sequence[Mo
dialect = getattr(getattr(self.db, "bind", None), "dialect", None)
supports_returning = bool(getattr(dialect, "delete_returning", False))
+ ids_condition = self._build_ids_condition(ids)
if not force and self._supports_soft_delete():
supports_update_returning = bool(getattr(dialect, "update_returning", False))
deletion_time = now()
stmt = (
update(self.model)
- .where(self.pk.in_(ids), self.model.deleted_at.is_(None))
+ .where(ids_condition, self.model.deleted_at.is_(None))
.values(deleted_at=deletion_time)
)
@@ -422,30 +533,30 @@ async def delete_multi(self, ids: list[Any], force: bool = False) -> Sequence[Mo
await self.db.execute(stmt)
await self.db.flush()
- query = select(self.model).where(self.pk.in_(ids))
+ query = select(self.model).where(ids_condition)
res = await self.db.execute(query)
return list(res.scalars().all())
if supports_returning:
- stmt = delete(self.model).where(self.pk.in_(ids)).returning(self.model)
+ stmt = delete(self.model).where(ids_condition).returning(self.model)
result = await self.db.scalars(stmt)
await self.db.flush()
return list(result.all())
else:
- query = select(self.model).where(self.pk.in_(ids))
+ query = select(self.model).where(ids_condition)
records_res = await self.db.execute(query)
records = list(records_res.scalars().all())
if records:
- stmt = delete(self.model).where(self.pk.in_(ids))
+ stmt = delete(self.model).where(ids_condition)
await self.db.execute(stmt)
await self.db.flush()
return records
async def restore(self, target: ModelType | Any) -> ModelType | None:
- """Restore a previously soft-deleted record.
+ """Restore a previously soft-deleted record supporting single and composite entities.
Args:
- target: The model instance or primary key identifier of the record to restore.
+ target: Model instance, primary key identifier, composite tuple, or composite mapping.
Returns:
The restored database model instance, or None if the record was not found or unsupported.
@@ -456,7 +567,8 @@ async def restore(self, target: ModelType | Any) -> ModelType | None:
if isinstance(target, self.model):
record = target
else:
- query = select(self.model).where(getattr(self.model, self.pk_name) == target)
+ condition = self._build_pk_condition(target)
+ query = select(self.model).where(condition)
result = await self.db.execute(query)
record = result.scalars().first()
if not record:
@@ -468,10 +580,10 @@ async def restore(self, target: ModelType | Any) -> ModelType | None:
return record
async def restore_multi(self, ids: list[Any]) -> Sequence[ModelType]:
- """Restore multiple soft-deleted records using an atomic batch update.
+ """Restore multiple soft-deleted records supporting single and composite entities.
Args:
- ids: A list of primary key values of records to restore.
+ ids: List of primary keys, composite tuples, mappings, or model instances.
Returns:
A sequence of restored database model instances.
@@ -481,10 +593,11 @@ async def restore_multi(self, ids: list[Any]) -> Sequence[ModelType]:
dialect = getattr(getattr(self.db, "bind", None), "dialect", None)
supports_update_returning = bool(getattr(dialect, "update_returning", False))
+ ids_condition = self._build_ids_condition(ids)
stmt = (
update(self.model)
- .where(self.pk.in_(ids), self.model.deleted_at.is_not(None))
+ .where(ids_condition, self.model.deleted_at.is_not(None))
.values(deleted_at=None)
)
@@ -496,7 +609,7 @@ async def restore_multi(self, ids: list[Any]) -> Sequence[ModelType]:
await self.db.execute(stmt)
await self.db.flush()
- query = select(self.model).where(self.pk.in_(ids))
+ query = select(self.model).where(ids_condition)
res = await self.db.execute(query)
return list(res.scalars().all())
@@ -548,7 +661,9 @@ async def search(
sort_field = pagination.cursor_field or (
primary_sort.field if primary_sort else self.cursor_field
)
- sort_order = pagination.order or (primary_sort.order if primary_sort else "desc")
+ sort_order = pagination.order or (
+ primary_sort.order if primary_sort else "desc"
+ )
paginator = CursorPagination(cursor_field=sort_field, order=sort_order)
else:
paginator = PageNumberPagination()
@@ -565,7 +680,7 @@ class BaseRepository(
"""The default implementation combining Read, Write, and Search capabilities."""
def __init__(self, model: type[ModelType], db: AsyncSession):
- """Initialize the BaseRepository.
+ """Initialize the BaseRepository supporting single and composite primary keys.
Args:
model: The SQLAlchemy declarative model class linked to this repository.
@@ -573,6 +688,8 @@ def __init__(self, model: type[ModelType], db: AsyncSession):
"""
self.model = model
self.db = db
- self.pk = inspect(self.model).primary_key[0]
- self.pk_name = self.pk.name
- self.cursor_field = self.pk_name
+ mapper = inspect(self.model)
+ self.pks = tuple(mapper.primary_key)
+ self.pk_names = tuple(col.name for col in self.pks)
+ self.is_composite_pk = len(self.pks) > 1
+ self.cursor_field = self.pk_names[0]
\ No newline at end of file
diff --git a/tests/test_repository.py b/tests/test_repository.py
index b845221..e5ce1d6 100644
--- a/tests/test_repository.py
+++ b/tests/test_repository.py
@@ -1,12 +1,13 @@
import uuid
from collections.abc import AsyncGenerator
+from datetime import date
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
import pytest_asyncio
from pydantic import BaseModel
-from sqlalchemy import Column, Integer, String
+from sqlalchemy import Column, Date, Integer, String
from sqlalchemy.orm import load_only
from zcore.context.context import ctx
@@ -45,6 +46,43 @@ def __init__(self, db: Any) -> None:
super().__init__(RepoTestModel, db)
+class DateCursorModel(Base):
+ __tablename__ = f"date_cursor_model_{uuid.uuid4().hex[:6]}"
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ operation_date = Column(Date, nullable=False)
+
+
+class DateCursorCreateSchema(BaseModel):
+ operation_date: date
+
+
+class DateCursorRepository(BaseRepository[DateCursorModel]):
+ def __init__(self, db: Any) -> None:
+ super().__init__(DateCursorModel, db)
+
+
+class CompositePartitionModel(Base):
+ __tablename__ = f"composite_part_model_{uuid.uuid4().hex[:6]}"
+ id = Column(Integer, primary_key=True)
+ operation_date = Column(Date, primary_key=True)
+ message = Column(String, nullable=False)
+
+
+class CompositeCreateSchema(BaseModel):
+ id: int
+ operation_date: date
+ message: str
+
+
+class CompositeUpdateSchema(BaseModel):
+ message: str
+
+
+class CompositeRepository(BaseRepository[CompositePartitionModel]):
+ def __init__(self, db: Any) -> None:
+ super().__init__(CompositePartitionModel, db)
+
+
@pytest_asyncio.fixture(autouse=True)
async def setup_test_tables(test_engine: Any) -> AsyncGenerator[None, None]:
async with test_engine.begin() as conn:
@@ -453,6 +491,7 @@ async def test_delete_accepts_model_instance_directly(db_session: Any) -> None:
assert deleted.id == item.id
assert await repo.get(id=item.id) is None
+
@pytest.mark.anyio
@pytest.mark.parametrize("order", ["desc", "asc"])
async def test_repo_search_with_cursor_pagination_and_sort(
@@ -497,3 +536,117 @@ async def test_repo_search_with_cursor_pagination_and_sort(
all_descriptions = [item.description for item in result_page1.data + result_page2.data]
expected_descriptions = sorted(all_descriptions, reverse=(order == "desc"))
assert all_descriptions == expected_descriptions
+
+
+@pytest.mark.anyio
+async def test_repo_cursor_pagination_with_date_column_coercion(db_session: Any) -> None:
+ repo = DateCursorRepository(db_session)
+ for i in range(5):
+ await repo.create(
+ DateCursorCreateSchema(operation_date=date(2026, 10, 10 + i))
+ )
+
+ params1 = CursorParams(size=2, cursor_field="operation_date", order="desc")
+ res1 = await repo.get_list(pagination=params1)
+ assert len(res1.data) == 2
+ assert res1.meta["has_more"] is True
+ next_cursor = res1.meta["next_cursor"]
+ assert next_cursor is not None
+
+ params2 = CursorParams(
+ size=2, cursor=next_cursor, cursor_field="operation_date", order="desc"
+ )
+ res2 = await repo.get_list(pagination=params2)
+ assert len(res2.data) == 2
+ assert res2.meta["has_more"] is True
+
+ first_page_dates = [item.operation_date for item in res1.data]
+ second_page_dates = [item.operation_date for item in res2.data]
+ assert all(isinstance(d, date) for d in first_page_dates + second_page_dates)
+ assert set(first_page_dates).isdisjoint(set(second_page_dates))
+ assert first_page_dates[0] > second_page_dates[0]
+
+
+@pytest.mark.anyio
+async def test_repo_composite_guarded_properties(db_session: Any) -> None:
+ single_repo = RepoTestRepository(db_session)
+ assert single_repo.is_composite_pk is False
+ assert single_repo.pk is not None
+ assert single_repo.pk_name == "id"
+
+ comp_repo = CompositeRepository(db_session)
+ assert comp_repo.is_composite_pk is True
+ assert comp_repo.pk_names == ("id", "operation_date")
+
+ with pytest.raises(AttributeError) as exc_pk:
+ _ = comp_repo.pk
+ assert "composite primary key" in str(exc_pk.value)
+
+ with pytest.raises(AttributeError) as exc_pk_name:
+ _ = comp_repo.pk_name
+ assert "composite primary key" in str(exc_pk_name.value)
+
+
+@pytest.mark.anyio
+async def test_repo_composite_primary_key_crud(db_session: Any) -> None:
+ repo = CompositeRepository(db_session)
+ assert repo.is_composite_pk is True
+ assert repo.pk_names == ("id", "operation_date")
+
+ d1 = date(2026, 10, 18)
+ d2 = date(2026, 10, 19)
+
+ item1 = await repo.create(
+ CompositeCreateSchema(id=1, operation_date=d1, message="First")
+ )
+ item2 = await repo.create(
+ CompositeCreateSchema(id=2, operation_date=d2, message="Second")
+ )
+
+ assert await repo.exist(id=1, operation_date=d1) is True
+ assert await repo.exist(id=99, operation_date=d1) is False
+
+ fetched_by_tuple = await repo.get(repo._build_pk_condition((1, d1)))
+ assert fetched_by_tuple is not None
+ assert fetched_by_tuple.message == "First"
+
+ fetched_by_dict = await repo.get(
+ repo._build_pk_condition({"id": 2, "operation_date": d2})
+ )
+ assert fetched_by_dict is not None
+ assert fetched_by_dict.message == "Second"
+
+ by_ids_tuples = await repo.get_by_ids([(1, d1), (2, d2)])
+ assert len(by_ids_tuples) == 2
+
+ by_ids_dicts = await repo.get_by_ids(
+ [{"id": 1, "operation_date": d1}, {"id": 2, "operation_date": d2}]
+ )
+ assert len(by_ids_dicts) == 2
+
+ by_ids_models = await repo.get_by_ids([item1, item2])
+ assert len(by_ids_models) == 2
+
+ updated = await repo.update(
+ (1, d1), CompositeUpdateSchema(message="Updated First")
+ )
+ assert updated is not None
+ assert updated.message == "Updated First"
+
+ updated_items = await repo.update_multi(
+ {
+ (1, d1): CompositeUpdateSchema(message="Multi 1"),
+ (2, d2): CompositeUpdateSchema(message="Multi 2"),
+ }
+ )
+ assert len(updated_items) == 2
+ refreshed1 = await repo.get(repo._build_pk_condition((1, d1)))
+ assert refreshed1 is not None and refreshed1.message == "Multi 1"
+
+ deleted = await repo.delete((1, d1))
+ assert deleted is not None
+ assert await repo.get(repo._build_pk_condition((1, d1))) is None
+
+ deleted_multi = await repo.delete_multi([(2, d2)])
+ assert len(deleted_multi) == 1
+ assert await repo.get(repo._build_pk_condition((2, d2))) is None
\ No newline at end of file
From 494428114f5248e5d995070b79918e0a6606827f Mon Sep 17 00:00:00 2001
From: alialfostovar
Date: Sun, 18 Oct 2026 21:37:46 +0330
Subject: [PATCH 10/24] style: apply formatting cleanup and explicit zip
strictness
- Added strict=False to zip calls in composite PK condition and update_multi
- Collapsed multi-line test calls and parametrize decorators to single lines
- Added trailing newline at EOF in repository.py
---
src/zcore/db/repository.py | 10 ++++------
tests/test_repository.py | 30 ++++++++----------------------
tests/test_web.py | 17 ++++++++++++++---
3 files changed, 26 insertions(+), 31 deletions(-)
diff --git a/src/zcore/db/repository.py b/src/zcore/db/repository.py
index 3214864..1be6699 100644
--- a/src/zcore/db/repository.py
+++ b/src/zcore/db/repository.py
@@ -125,7 +125,7 @@ def _build_pk_condition(self, target: Any) -> Any:
if isinstance(target, (tuple, list)):
if self.is_composite_pk:
- return and_(*[col == val for col, val in zip(self.pks, target)])
+ return and_(*[col == val for col, val in zip(self.pks, target, strict=False)])
return self.pks[0] == target[0]
return self.pks[0] == target
@@ -448,7 +448,7 @@ async def update_multi(
row_payload[col.key] = key[col.key]
elif isinstance(key, (tuple, list)) and self.is_composite_pk:
target_id = tuple(key)
- for col, val in zip(self.pks, key):
+ for col, val in zip(self.pks, key, strict=False):
row_payload[col.key] = val
else:
target_id = key
@@ -661,9 +661,7 @@ async def search(
sort_field = pagination.cursor_field or (
primary_sort.field if primary_sort else self.cursor_field
)
- sort_order = pagination.order or (
- primary_sort.order if primary_sort else "desc"
- )
+ sort_order = pagination.order or (primary_sort.order if primary_sort else "desc")
paginator = CursorPagination(cursor_field=sort_field, order=sort_order)
else:
paginator = PageNumberPagination()
@@ -692,4 +690,4 @@ def __init__(self, model: type[ModelType], db: AsyncSession):
self.pks = tuple(mapper.primary_key)
self.pk_names = tuple(col.name for col in self.pks)
self.is_composite_pk = len(self.pks) > 1
- self.cursor_field = self.pk_names[0]
\ No newline at end of file
+ self.cursor_field = self.pk_names[0]
diff --git a/tests/test_repository.py b/tests/test_repository.py
index e5ce1d6..0798479 100644
--- a/tests/test_repository.py
+++ b/tests/test_repository.py
@@ -494,9 +494,7 @@ async def test_delete_accepts_model_instance_directly(db_session: Any) -> None:
@pytest.mark.anyio
@pytest.mark.parametrize("order", ["desc", "asc"])
-async def test_repo_search_with_cursor_pagination_and_sort(
- db_session: Any, order: str
-) -> None:
+async def test_repo_search_with_cursor_pagination_and_sort(db_session: Any, order: str) -> None:
repo = RepoTestRepository(db_session)
for i in range(6):
await repo.create(
@@ -542,9 +540,7 @@ async def test_repo_search_with_cursor_pagination_and_sort(
async def test_repo_cursor_pagination_with_date_column_coercion(db_session: Any) -> None:
repo = DateCursorRepository(db_session)
for i in range(5):
- await repo.create(
- DateCursorCreateSchema(operation_date=date(2026, 10, 10 + i))
- )
+ await repo.create(DateCursorCreateSchema(operation_date=date(2026, 10, 10 + i)))
params1 = CursorParams(size=2, cursor_field="operation_date", order="desc")
res1 = await repo.get_list(pagination=params1)
@@ -553,9 +549,7 @@ async def test_repo_cursor_pagination_with_date_column_coercion(db_session: Any)
next_cursor = res1.meta["next_cursor"]
assert next_cursor is not None
- params2 = CursorParams(
- size=2, cursor=next_cursor, cursor_field="operation_date", order="desc"
- )
+ params2 = CursorParams(size=2, cursor=next_cursor, cursor_field="operation_date", order="desc")
res2 = await repo.get_list(pagination=params2)
assert len(res2.data) == 2
assert res2.meta["has_more"] is True
@@ -596,12 +590,8 @@ async def test_repo_composite_primary_key_crud(db_session: Any) -> None:
d1 = date(2026, 10, 18)
d2 = date(2026, 10, 19)
- item1 = await repo.create(
- CompositeCreateSchema(id=1, operation_date=d1, message="First")
- )
- item2 = await repo.create(
- CompositeCreateSchema(id=2, operation_date=d2, message="Second")
- )
+ item1 = await repo.create(CompositeCreateSchema(id=1, operation_date=d1, message="First"))
+ item2 = await repo.create(CompositeCreateSchema(id=2, operation_date=d2, message="Second"))
assert await repo.exist(id=1, operation_date=d1) is True
assert await repo.exist(id=99, operation_date=d1) is False
@@ -610,9 +600,7 @@ async def test_repo_composite_primary_key_crud(db_session: Any) -> None:
assert fetched_by_tuple is not None
assert fetched_by_tuple.message == "First"
- fetched_by_dict = await repo.get(
- repo._build_pk_condition({"id": 2, "operation_date": d2})
- )
+ fetched_by_dict = await repo.get(repo._build_pk_condition({"id": 2, "operation_date": d2}))
assert fetched_by_dict is not None
assert fetched_by_dict.message == "Second"
@@ -627,9 +615,7 @@ async def test_repo_composite_primary_key_crud(db_session: Any) -> None:
by_ids_models = await repo.get_by_ids([item1, item2])
assert len(by_ids_models) == 2
- updated = await repo.update(
- (1, d1), CompositeUpdateSchema(message="Updated First")
- )
+ updated = await repo.update((1, d1), CompositeUpdateSchema(message="Updated First"))
assert updated is not None
assert updated.message == "Updated First"
@@ -649,4 +635,4 @@ async def test_repo_composite_primary_key_crud(db_session: Any) -> None:
deleted_multi = await repo.delete_multi([(2, d2)])
assert len(deleted_multi) == 1
- assert await repo.get(repo._build_pk_condition((2, d2))) is None
\ No newline at end of file
+ assert await repo.get(repo._build_pk_condition((2, d2))) is None
diff --git a/tests/test_web.py b/tests/test_web.py
index cd3be22..552b951 100644
--- a/tests/test_web.py
+++ b/tests/test_web.py
@@ -15,7 +15,12 @@
from zcore.config import settings
from zcore.context.context import ZContext, ctx
-from zcore.db.pagination import PageNumberPagination, PageNumberParams, PaginatedResult, CursorPagination
+from zcore.db.pagination import (
+ CursorPagination,
+ PageNumberPagination,
+ PageNumberParams,
+ PaginatedResult,
+)
from zcore.db.setup import Base, db_manager
from zcore.exceptions.base import AppException, EntityNotFound
from zcore.exceptions.handlers import app_exception_handler, register_exception_handlers
@@ -95,7 +100,9 @@ class SearchCursorItem(Zchema):
class MockCursorSearchService:
def __init__(self) -> None:
self.items = [
- SearchCursorItem(id=f"10000000-0000-0000-0000-00000000000{i}", code=i * 10, tag=f"tag_{i}")
+ SearchCursorItem(
+ id=f"10000000-0000-0000-0000-00000000000{i}", code=i * 10, tag=f"tag_{i}"
+ )
for i in range(5)
]
@@ -113,7 +120,11 @@ async def search(
has_more = len(sorted_items) > size
return PaginatedResult(
data=data,
- meta={"next_cursor": "eyJ2YWx1ZSI6IDIwfQ" if has_more else None, "has_more": has_more, "size": size},
+ meta={
+ "next_cursor": "eyJ2YWx1ZSI6IDIwfQ" if has_more else None,
+ "has_more": has_more,
+ "size": size,
+ },
)
From 13654f11bbec41c150593ebe961f07972c191f94 Mon Sep 17 00:00:00 2001
From: alialfostovar
Date: Sun, 18 Oct 2026 21:36:04 +0330
Subject: [PATCH 11/24] feat(repository): add composite primary key support
- Replaced pk/pk_name attributes with pks/pk_names tuples and is_composite_pk flag
- Added pk and pk_name convenience properties that raise on composite models
- Added _build_pk_condition for model instance, dict, tuple/list, or scalar targets
- Added _build_ids_condition supporting scalar in_ and composite tuple_.in_
- Updated get_by_ids, update, update_multi, delete, delete_multi, restore, restore_multi to use composite-aware conditions
- BaseRepository __init__ now inspects all primary key columns
- Added CompositePartitionModel, CompositeCreateSchema, CompositeUpdateSchema, CompositeRepository test fixtures
- Added tests for guarded properties and full composite CRUD cycle
---
src/zcore/db/repository.py | 207 +++++++++++++++++++++++++++++--------
tests/test_repository.py | 125 ++++++++++++++++++++++
2 files changed, 287 insertions(+), 45 deletions(-)
diff --git a/src/zcore/db/repository.py b/src/zcore/db/repository.py
index e3db705..3214864 100644
--- a/src/zcore/db/repository.py
+++ b/src/zcore/db/repository.py
@@ -3,14 +3,15 @@
This module provides a Repository pattern implementation for SQLAlchemy
2.0 and Pydantic V2. It decouples business logic from database interactions by exposing
highly specialized, reusable interfaces divided into read, write, and search capabilities.
-It supports dynamic pagination, eager load optimization, field pruning, and full soft-delete lifecycles.
+It supports dynamic pagination, eager load optimization, field pruning, composite primary keys,
+and full soft-delete lifecycles.
"""
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, Generic, TypeVar
from pydantic import BaseModel
-from sqlalchemy import Select, delete, func, insert, inspect, select, update
+from sqlalchemy import Select, and_, delete, func, insert, inspect, select, tuple_, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import load_only
from sqlalchemy.orm.interfaces import ExecutableOption
@@ -34,17 +35,47 @@ class AbstractRepository(Generic[ModelType]):
Attributes:
db: The active asynchronous database session.
model: The SQLAlchemy model class bound to the repository instance.
- pk: The SQLAlchemy primary key column representation of the bound model.
- pk_name: The string identifier name of the primary key field.
+ pks: Tuple of primary key column representations of the bound model.
+ pk_names: Tuple of primary key column names.
+ is_composite_pk: Flag indicating if the model has a composite primary key.
cursor_field: The field name to utilize during keyset pagination lookups.
"""
db: AsyncSession
model: type[ModelType]
- pk: Any
- pk_name: str
+ pks: tuple[Any, ...]
+ pk_names: tuple[str, ...]
+ is_composite_pk: bool
cursor_field: str
+ @property
+ def pk(self) -> Any:
+ """Convenience property for single primary key models.
+
+ Raises:
+ AttributeError: If accessed on a composite primary key model.
+ """
+ if self.is_composite_pk:
+ raise AttributeError(
+ f"Model '{self.model.__name__}' has a composite primary key {self.pk_names}. "
+ "Access 'self.pks' instead of 'self.pk'."
+ )
+ return self.pks[0]
+
+ @property
+ def pk_name(self) -> str:
+ """Convenience property for single primary key column name.
+
+ Raises:
+ AttributeError: If accessed on a composite primary key model.
+ """
+ if self.is_composite_pk:
+ raise AttributeError(
+ f"Model '{self.model.__name__}' has a composite primary key {self.pk_names}. "
+ "Access 'self.pk_names' instead of 'self.pk_name'."
+ )
+ return self.pk_names[0]
+
def _get_base_query(self) -> Select:
"""Construct the initial select statement for query operations.
@@ -77,6 +108,63 @@ def _apply_filters(self, query: Select, *criterion: Any, **filters: Any) -> Sele
query = query.filter_by(**filters)
return query
+ def _build_pk_condition(self, target: Any) -> Any:
+ """Build equality filter condition for single or composite primary key target.
+
+ Args:
+ target: Model instance, dict mapping column names to values, tuple/list of values, or scalar.
+
+ Returns:
+ SQLAlchemy binary expression or compound `and_` expression matching target primary key.
+ """
+ if isinstance(target, self.model):
+ return and_(*[col == getattr(target, col.key) for col in self.pks])
+
+ if isinstance(target, dict):
+ return and_(*[col == target[col.key] for col in self.pks if col.key in target])
+
+ if isinstance(target, (tuple, list)):
+ if self.is_composite_pk:
+ return and_(*[col == val for col, val in zip(self.pks, target)])
+ return self.pks[0] == target[0]
+
+ return self.pks[0] == target
+
+ def _build_ids_condition(self, ids: Sequence[Any]) -> Any:
+ """Construct matching expression for sequences of scalar or composite primary keys.
+
+ Args:
+ ids: Sequence of scalars, tuples, dicts, or model instances.
+
+ Returns:
+ SQLAlchemy filter expression resolving scalar `in_` or composite `tuple_.in_`.
+ """
+ if not self.is_composite_pk:
+ normalized_scalars = [
+ getattr(item, self.pk_names[0])
+ if isinstance(item, self.model)
+ else item.get(self.pk_names[0])
+ if isinstance(item, dict)
+ else item[0]
+ if isinstance(item, (tuple, list))
+ else item
+ for item in ids
+ ]
+ return self.pks[0].in_(normalized_scalars)
+
+ normalized_tuples = []
+ for item in ids:
+ if isinstance(item, self.model):
+ normalized_tuples.append(tuple(getattr(item, col.key) for col in self.pks))
+ elif isinstance(item, dict):
+ normalized_tuples.append(tuple(item[col.key] for col in self.pks))
+ elif isinstance(item, (tuple, list)):
+ normalized_tuples.append(tuple(item))
+ else:
+ normalized_tuples.append((item,))
+
+ return tuple_(*self.pks).in_(normalized_tuples)
+
def _supports_soft_delete(self) -> bool:
"""Determine whether the underlying model supports soft deletion.
@@ -103,7 +191,7 @@ async def exist(self, *criterion: Any, **filters: Any) -> bool:
Returns:
True if matching records are found, False otherwise.
"""
- query = select(self.pk)
+ query = select(*self.pks)
scoper = getattr(self.model, "scope_query", None)
if scoper:
query = scoper(query)
@@ -147,7 +235,7 @@ async def get_by_ids(
"""Fetch a sequence of records matching a list of primary keys.
Args:
- ids: A list of primary key values to look up.
+ ids: A list of scalar primary keys or composite key tuples/mappings.
fields: Specific entity fields to selectively load. Defaults to None.
options: Additional SQLAlchemy executable options. Defaults to None.
@@ -157,7 +245,7 @@ async def get_by_ids(
if not ids:
return []
- query = self._get_base_query().where(self.pk.in_(ids))
+ query = self._get_base_query().where(self._build_ids_condition(ids))
if fields:
query = query.options(load_only(*fields))
if options:
@@ -290,10 +378,10 @@ async def update(
partial: bool = False,
**extra_data: Any,
) -> ModelType | None:
- """Update an existing database record from a model instance or primary key.
+ """Update an existing database record from a model instance, primary key, or composite mapping.
Args:
- target: The model instance or primary key identifier of the record to update.
+ target: Model instance, primary key identifier, composite tuple, or composite mapping.
schema: The Pydantic update schema containing modified parameters.
partial: If True, applies modifications as a partial patch (ignoring unset fields).
If False, updates the record using all fields. Defaults to False.
@@ -305,7 +393,8 @@ async def update(
if isinstance(target, self.model):
record = target
else:
- record = await self.get(**{self.pk_name: target})
+ condition = self._build_pk_condition(target)
+ record = await self.get(condition)
if not record:
return None
@@ -323,10 +412,10 @@ async def update_multi(
partial: bool = False,
refresh: bool = False,
) -> Sequence[ModelType]:
- """Bulk update multiple database records using DBAPI executemany.
+ """Bulk update multiple database records supporting single or composite keys.
Args:
- data: A mapping of model instances or primary keys to their update schemas.
+ data: A mapping of model instances, scalar keys, tuples, or dicts to their update schemas.
partial: If True, ignores unset schema fields. Defaults to False.
refresh: Parameter maintained for interface consistency. Defaults to False.
@@ -339,14 +428,34 @@ async def update_multi(
payloads = []
target_ids = []
for key, schema in data.items():
- pk_val = getattr(key, self.pk_name, key)
- target_ids.append(pk_val)
- payloads.append(
- {
- **schema.model_dump(exclude_unset=partial),
- self.pk_name: pk_val,
- }
- )
+ row_payload = schema.model_dump(exclude_unset=partial)
+ if isinstance(key, self.model):
+ target_id = (
+ tuple(getattr(key, col.key) for col in self.pks)
+ if self.is_composite_pk
+ else getattr(key, self.pk_names[0])
+ )
+ for col in self.pks:
+ row_payload[col.key] = getattr(key, col.key)
+ elif isinstance(key, dict):
+ target_id = (
+ tuple(key[col.key] for col in self.pks)
+ if self.is_composite_pk
+ else key.get(self.pk_names[0])
+ )
+ for col in self.pks:
+ if col.key in key:
+ row_payload[col.key] = key[col.key]
+ elif isinstance(key, (tuple, list)) and self.is_composite_pk:
+ target_id = tuple(key)
+ for col, val in zip(self.pks, key):
+ row_payload[col.key] = val
+ else:
+ target_id = key
+ row_payload[self.pk_names[0]] = key
+
+ target_ids.append(target_id)
+ payloads.append(row_payload)
await self.db.execute(
update(self.model),
@@ -357,10 +466,10 @@ async def update_multi(
return await self.get_by_ids(ids=target_ids)
async def delete(self, target: ModelType | Any, force: bool = False) -> ModelType | None:
- """Delete a single record, applying soft deletion if supported unless forced.
+ """Delete a single record supporting single and composite primary keys.
Args:
- target: The model instance or primary key identifier of the record to delete.
+ target: Model instance, primary key identifier, composite tuple, or composite mapping.
force: If True, executes a permanent physical hard deletion. Defaults to False.
Returns:
@@ -369,12 +478,13 @@ async def delete(self, target: ModelType | Any, force: bool = False) -> ModelTyp
if isinstance(target, self.model):
record = target
else:
+ condition = self._build_pk_condition(target)
if force:
- query = select(self.model).where(getattr(self.model, self.pk_name) == target)
+ query = select(self.model).where(condition)
result = await self.db.execute(query)
record = result.scalars().first()
else:
- record = await self.get(**{self.pk_name: target})
+ record = await self.get(condition)
if not record:
return None
@@ -390,10 +500,10 @@ async def delete(self, target: ModelType | Any, force: bool = False) -> ModelTyp
return record
async def delete_multi(self, ids: list[Any], force: bool = False) -> Sequence[ModelType]:
- """Delete multiple records by primary keys, using atomic soft update or hard delete.
+ """Delete multiple records by primary keys supporting single and composite entities.
Args:
- ids: A list of primary key values of records to delete.
+ ids: List of primary keys, composite tuples, mappings, or model instances.
force: If True, executes permanent physical hard deletions. Defaults to False.
Returns:
@@ -404,13 +514,14 @@ async def delete_multi(self, ids: list[Any], force: bool = False) -> Sequence[Mo
dialect = getattr(getattr(self.db, "bind", None), "dialect", None)
supports_returning = bool(getattr(dialect, "delete_returning", False))
+ ids_condition = self._build_ids_condition(ids)
if not force and self._supports_soft_delete():
supports_update_returning = bool(getattr(dialect, "update_returning", False))
deletion_time = now()
stmt = (
update(self.model)
- .where(self.pk.in_(ids), self.model.deleted_at.is_(None))
+ .where(ids_condition, self.model.deleted_at.is_(None))
.values(deleted_at=deletion_time)
)
@@ -422,30 +533,30 @@ async def delete_multi(self, ids: list[Any], force: bool = False) -> Sequence[Mo
await self.db.execute(stmt)
await self.db.flush()
- query = select(self.model).where(self.pk.in_(ids))
+ query = select(self.model).where(ids_condition)
res = await self.db.execute(query)
return list(res.scalars().all())
if supports_returning:
- stmt = delete(self.model).where(self.pk.in_(ids)).returning(self.model)
+ stmt = delete(self.model).where(ids_condition).returning(self.model)
result = await self.db.scalars(stmt)
await self.db.flush()
return list(result.all())
else:
- query = select(self.model).where(self.pk.in_(ids))
+ query = select(self.model).where(ids_condition)
records_res = await self.db.execute(query)
records = list(records_res.scalars().all())
if records:
- stmt = delete(self.model).where(self.pk.in_(ids))
+ stmt = delete(self.model).where(ids_condition)
await self.db.execute(stmt)
await self.db.flush()
return records
async def restore(self, target: ModelType | Any) -> ModelType | None:
- """Restore a previously soft-deleted record.
+ """Restore a previously soft-deleted record supporting single and composite entities.
Args:
- target: The model instance or primary key identifier of the record to restore.
+ target: Model instance, primary key identifier, composite tuple, or composite mapping.
Returns:
The restored database model instance, or None if the record was not found or unsupported.
@@ -456,7 +567,8 @@ async def restore(self, target: ModelType | Any) -> ModelType | None:
if isinstance(target, self.model):
record = target
else:
- query = select(self.model).where(getattr(self.model, self.pk_name) == target)
+ condition = self._build_pk_condition(target)
+ query = select(self.model).where(condition)
result = await self.db.execute(query)
record = result.scalars().first()
if not record:
@@ -468,10 +580,10 @@ async def restore(self, target: ModelType | Any) -> ModelType | None:
return record
async def restore_multi(self, ids: list[Any]) -> Sequence[ModelType]:
- """Restore multiple soft-deleted records using an atomic batch update.
+ """Restore multiple soft-deleted records supporting single and composite entities.
Args:
- ids: A list of primary key values of records to restore.
+ ids: List of primary keys, composite tuples, mappings, or model instances.
Returns:
A sequence of restored database model instances.
@@ -481,10 +593,11 @@ async def restore_multi(self, ids: list[Any]) -> Sequence[ModelType]:
dialect = getattr(getattr(self.db, "bind", None), "dialect", None)
supports_update_returning = bool(getattr(dialect, "update_returning", False))
+ ids_condition = self._build_ids_condition(ids)
stmt = (
update(self.model)
- .where(self.pk.in_(ids), self.model.deleted_at.is_not(None))
+ .where(ids_condition, self.model.deleted_at.is_not(None))
.values(deleted_at=None)
)
@@ -496,7 +609,7 @@ async def restore_multi(self, ids: list[Any]) -> Sequence[ModelType]:
await self.db.execute(stmt)
await self.db.flush()
- query = select(self.model).where(self.pk.in_(ids))
+ query = select(self.model).where(ids_condition)
res = await self.db.execute(query)
return list(res.scalars().all())
@@ -548,7 +661,9 @@ async def search(
sort_field = pagination.cursor_field or (
primary_sort.field if primary_sort else self.cursor_field
)
- sort_order = pagination.order or (primary_sort.order if primary_sort else "desc")
+ sort_order = pagination.order or (
+ primary_sort.order if primary_sort else "desc"
+ )
paginator = CursorPagination(cursor_field=sort_field, order=sort_order)
else:
paginator = PageNumberPagination()
@@ -565,7 +680,7 @@ class BaseRepository(
"""The default implementation combining Read, Write, and Search capabilities."""
def __init__(self, model: type[ModelType], db: AsyncSession):
- """Initialize the BaseRepository.
+ """Initialize the BaseRepository supporting single and composite primary keys.
Args:
model: The SQLAlchemy declarative model class linked to this repository.
@@ -573,6 +688,8 @@ def __init__(self, model: type[ModelType], db: AsyncSession):
"""
self.model = model
self.db = db
- self.pk = inspect(self.model).primary_key[0]
- self.pk_name = self.pk.name
- self.cursor_field = self.pk_name
+ mapper = inspect(self.model)
+ self.pks = tuple(mapper.primary_key)
+ self.pk_names = tuple(col.name for col in self.pks)
+ self.is_composite_pk = len(self.pks) > 1
+ self.cursor_field = self.pk_names[0]
\ No newline at end of file
diff --git a/tests/test_repository.py b/tests/test_repository.py
index 8ff157b..b73a2c6 100644
--- a/tests/test_repository.py
+++ b/tests/test_repository.py
@@ -1,6 +1,7 @@
import uuid
from collections.abc import AsyncGenerator
from datetime import date
+from datetime import date
from typing import Any
from unittest.mock import AsyncMock, patch
@@ -8,6 +9,7 @@
import pytest_asyncio
from pydantic import BaseModel
from sqlalchemy import Column, Date, Integer, String
+from sqlalchemy import Column, Date, Integer, String
from sqlalchemy.orm import load_only
from zcore.context.context import ctx
@@ -61,6 +63,43 @@ def __init__(self, db: Any) -> None:
super().__init__(RepoTestModel, db)
+class DateCursorModel(Base):
+ __tablename__ = f"date_cursor_model_{uuid.uuid4().hex[:6]}"
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ operation_date = Column(Date, nullable=False)
+
+
+class DateCursorCreateSchema(BaseModel):
+ operation_date: date
+
+
+class DateCursorRepository(BaseRepository[DateCursorModel]):
+ def __init__(self, db: Any) -> None:
+ super().__init__(DateCursorModel, db)
+
+
+class CompositePartitionModel(Base):
+ __tablename__ = f"composite_part_model_{uuid.uuid4().hex[:6]}"
+ id = Column(Integer, primary_key=True)
+ operation_date = Column(Date, primary_key=True)
+ message = Column(String, nullable=False)
+
+
+class CompositeCreateSchema(BaseModel):
+ id: int
+ operation_date: date
+ message: str
+
+
+class CompositeUpdateSchema(BaseModel):
+ message: str
+
+
+class CompositeRepository(BaseRepository[CompositePartitionModel]):
+ def __init__(self, db: Any) -> None:
+ super().__init__(CompositePartitionModel, db)
+
+
@pytest_asyncio.fixture(autouse=True)
async def setup_test_tables(test_engine: Any) -> AsyncGenerator[None, None]:
async with test_engine.begin() as conn:
@@ -469,6 +508,7 @@ async def test_delete_accepts_model_instance_directly(db_session: Any) -> None:
assert deleted.id == item.id
assert await repo.get(id=item.id) is None
+
@pytest.mark.anyio
@pytest.mark.parametrize("order", ["desc", "asc"])
async def test_repo_search_with_cursor_pagination_and_sort(
@@ -542,3 +582,88 @@ async def test_repo_cursor_pagination_with_date_column_coercion(db_session: Any)
assert all(isinstance(d, date) for d in first_page_dates + second_page_dates)
assert set(first_page_dates).isdisjoint(set(second_page_dates))
assert first_page_dates[0] > second_page_dates[0]
+
+
+@pytest.mark.anyio
+async def test_repo_composite_guarded_properties(db_session: Any) -> None:
+ single_repo = RepoTestRepository(db_session)
+ assert single_repo.is_composite_pk is False
+ assert single_repo.pk is not None
+ assert single_repo.pk_name == "id"
+
+ comp_repo = CompositeRepository(db_session)
+ assert comp_repo.is_composite_pk is True
+ assert comp_repo.pk_names == ("id", "operation_date")
+
+ with pytest.raises(AttributeError) as exc_pk:
+ _ = comp_repo.pk
+ assert "composite primary key" in str(exc_pk.value)
+
+ with pytest.raises(AttributeError) as exc_pk_name:
+ _ = comp_repo.pk_name
+ assert "composite primary key" in str(exc_pk_name.value)
+
+
+@pytest.mark.anyio
+async def test_repo_composite_primary_key_crud(db_session: Any) -> None:
+ repo = CompositeRepository(db_session)
+ assert repo.is_composite_pk is True
+ assert repo.pk_names == ("id", "operation_date")
+
+ d1 = date(2026, 10, 18)
+ d2 = date(2026, 10, 19)
+
+ item1 = await repo.create(
+ CompositeCreateSchema(id=1, operation_date=d1, message="First")
+ )
+ item2 = await repo.create(
+ CompositeCreateSchema(id=2, operation_date=d2, message="Second")
+ )
+
+ assert await repo.exist(id=1, operation_date=d1) is True
+ assert await repo.exist(id=99, operation_date=d1) is False
+
+ fetched_by_tuple = await repo.get(repo._build_pk_condition((1, d1)))
+ assert fetched_by_tuple is not None
+ assert fetched_by_tuple.message == "First"
+
+ fetched_by_dict = await repo.get(
+ repo._build_pk_condition({"id": 2, "operation_date": d2})
+ )
+ assert fetched_by_dict is not None
+ assert fetched_by_dict.message == "Second"
+
+ by_ids_tuples = await repo.get_by_ids([(1, d1), (2, d2)])
+ assert len(by_ids_tuples) == 2
+
+ by_ids_dicts = await repo.get_by_ids(
+ [{"id": 1, "operation_date": d1}, {"id": 2, "operation_date": d2}]
+ )
+ assert len(by_ids_dicts) == 2
+
+ by_ids_models = await repo.get_by_ids([item1, item2])
+ assert len(by_ids_models) == 2
+
+ updated = await repo.update(
+ (1, d1), CompositeUpdateSchema(message="Updated First")
+ )
+ assert updated is not None
+ assert updated.message == "Updated First"
+
+ updated_items = await repo.update_multi(
+ {
+ (1, d1): CompositeUpdateSchema(message="Multi 1"),
+ (2, d2): CompositeUpdateSchema(message="Multi 2"),
+ }
+ )
+ assert len(updated_items) == 2
+ refreshed1 = await repo.get(repo._build_pk_condition((1, d1)))
+ assert refreshed1 is not None and refreshed1.message == "Multi 1"
+
+ deleted = await repo.delete((1, d1))
+ assert deleted is not None
+ assert await repo.get(repo._build_pk_condition((1, d1))) is None
+
+ deleted_multi = await repo.delete_multi([(2, d2)])
+ assert len(deleted_multi) == 1
+ assert await repo.get(repo._build_pk_condition((2, d2))) is None
\ No newline at end of file
From 09ba9f24a7705a03bd03d7700252155ce1d8f89e Mon Sep 17 00:00:00 2001
From: alialfostovar
Date: Sun, 18 Oct 2026 21:37:46 +0330
Subject: [PATCH 12/24] style: apply formatting cleanup and explicit zip
strictness
- Added strict=False to zip calls in composite PK condition and update_multi
- Collapsed multi-line test calls and parametrize decorators to single lines
- Added trailing newline at EOF in repository.py
---
src/zcore/db/repository.py | 10 ++++------
tests/test_repository.py | 30 ++++++++----------------------
tests/test_web.py | 10 ++++++++--
3 files changed, 20 insertions(+), 30 deletions(-)
diff --git a/src/zcore/db/repository.py b/src/zcore/db/repository.py
index 3214864..1be6699 100644
--- a/src/zcore/db/repository.py
+++ b/src/zcore/db/repository.py
@@ -125,7 +125,7 @@ def _build_pk_condition(self, target: Any) -> Any:
if isinstance(target, (tuple, list)):
if self.is_composite_pk:
- return and_(*[col == val for col, val in zip(self.pks, target)])
+ return and_(*[col == val for col, val in zip(self.pks, target, strict=False)])
return self.pks[0] == target[0]
return self.pks[0] == target
@@ -448,7 +448,7 @@ async def update_multi(
row_payload[col.key] = key[col.key]
elif isinstance(key, (tuple, list)) and self.is_composite_pk:
target_id = tuple(key)
- for col, val in zip(self.pks, key):
+ for col, val in zip(self.pks, key, strict=False):
row_payload[col.key] = val
else:
target_id = key
@@ -661,9 +661,7 @@ async def search(
sort_field = pagination.cursor_field or (
primary_sort.field if primary_sort else self.cursor_field
)
- sort_order = pagination.order or (
- primary_sort.order if primary_sort else "desc"
- )
+ sort_order = pagination.order or (primary_sort.order if primary_sort else "desc")
paginator = CursorPagination(cursor_field=sort_field, order=sort_order)
else:
paginator = PageNumberPagination()
@@ -692,4 +690,4 @@ def __init__(self, model: type[ModelType], db: AsyncSession):
self.pks = tuple(mapper.primary_key)
self.pk_names = tuple(col.name for col in self.pks)
self.is_composite_pk = len(self.pks) > 1
- self.cursor_field = self.pk_names[0]
\ No newline at end of file
+ self.cursor_field = self.pk_names[0]
diff --git a/tests/test_repository.py b/tests/test_repository.py
index b73a2c6..3182ea7 100644
--- a/tests/test_repository.py
+++ b/tests/test_repository.py
@@ -511,9 +511,7 @@ async def test_delete_accepts_model_instance_directly(db_session: Any) -> None:
@pytest.mark.anyio
@pytest.mark.parametrize("order", ["desc", "asc"])
-async def test_repo_search_with_cursor_pagination_and_sort(
- db_session: Any, order: str
-) -> None:
+async def test_repo_search_with_cursor_pagination_and_sort(db_session: Any, order: str) -> None:
repo = RepoTestRepository(db_session)
for i in range(6):
await repo.create(
@@ -559,9 +557,7 @@ async def test_repo_search_with_cursor_pagination_and_sort(
async def test_repo_cursor_pagination_with_date_column_coercion(db_session: Any) -> None:
repo = DateCursorRepository(db_session)
for i in range(5):
- await repo.create(
- DateCursorCreateSchema(operation_date=date(2026, 10, 10 + i))
- )
+ await repo.create(DateCursorCreateSchema(operation_date=date(2026, 10, 10 + i)))
params1 = CursorParams(size=2, cursor_field="operation_date", order="desc")
res1 = await repo.get_list(pagination=params1)
@@ -570,9 +566,7 @@ async def test_repo_cursor_pagination_with_date_column_coercion(db_session: Any)
next_cursor = res1.meta["next_cursor"]
assert next_cursor is not None
- params2 = CursorParams(
- size=2, cursor=next_cursor, cursor_field="operation_date", order="desc"
- )
+ params2 = CursorParams(size=2, cursor=next_cursor, cursor_field="operation_date", order="desc")
res2 = await repo.get_list(pagination=params2)
assert len(res2.data) == 2
assert res2.meta["has_more"] is True
@@ -613,12 +607,8 @@ async def test_repo_composite_primary_key_crud(db_session: Any) -> None:
d1 = date(2026, 10, 18)
d2 = date(2026, 10, 19)
- item1 = await repo.create(
- CompositeCreateSchema(id=1, operation_date=d1, message="First")
- )
- item2 = await repo.create(
- CompositeCreateSchema(id=2, operation_date=d2, message="Second")
- )
+ item1 = await repo.create(CompositeCreateSchema(id=1, operation_date=d1, message="First"))
+ item2 = await repo.create(CompositeCreateSchema(id=2, operation_date=d2, message="Second"))
assert await repo.exist(id=1, operation_date=d1) is True
assert await repo.exist(id=99, operation_date=d1) is False
@@ -627,9 +617,7 @@ async def test_repo_composite_primary_key_crud(db_session: Any) -> None:
assert fetched_by_tuple is not None
assert fetched_by_tuple.message == "First"
- fetched_by_dict = await repo.get(
- repo._build_pk_condition({"id": 2, "operation_date": d2})
- )
+ fetched_by_dict = await repo.get(repo._build_pk_condition({"id": 2, "operation_date": d2}))
assert fetched_by_dict is not None
assert fetched_by_dict.message == "Second"
@@ -644,9 +632,7 @@ async def test_repo_composite_primary_key_crud(db_session: Any) -> None:
by_ids_models = await repo.get_by_ids([item1, item2])
assert len(by_ids_models) == 2
- updated = await repo.update(
- (1, d1), CompositeUpdateSchema(message="Updated First")
- )
+ updated = await repo.update((1, d1), CompositeUpdateSchema(message="Updated First"))
assert updated is not None
assert updated.message == "Updated First"
@@ -666,4 +652,4 @@ async def test_repo_composite_primary_key_crud(db_session: Any) -> None:
deleted_multi = await repo.delete_multi([(2, d2)])
assert len(deleted_multi) == 1
- assert await repo.get(repo._build_pk_condition((2, d2))) is None
\ No newline at end of file
+ assert await repo.get(repo._build_pk_condition((2, d2))) is None
diff --git a/tests/test_web.py b/tests/test_web.py
index 20ba865..552b951 100644
--- a/tests/test_web.py
+++ b/tests/test_web.py
@@ -100,7 +100,9 @@ class SearchCursorItem(Zchema):
class MockCursorSearchService:
def __init__(self) -> None:
self.items = [
- SearchCursorItem(id=f"10000000-0000-0000-0000-00000000000{i}", code=i * 10, tag=f"tag_{i}")
+ SearchCursorItem(
+ id=f"10000000-0000-0000-0000-00000000000{i}", code=i * 10, tag=f"tag_{i}"
+ )
for i in range(5)
]
@@ -118,7 +120,11 @@ async def search(
has_more = len(sorted_items) > size
return PaginatedResult(
data=data,
- meta={"next_cursor": "eyJ2YWx1ZSI6IDIwfQ" if has_more else None, "has_more": has_more, "size": size},
+ meta={
+ "next_cursor": "eyJ2YWx1ZSI6IDIwfQ" if has_more else None,
+ "has_more": has_more,
+ "size": size,
+ },
)
From 8fd9624396f6e30b63bca01370fb5209e29ae0d9 Mon Sep 17 00:00:00 2001
From: alialfostovar
Date: Sun, 18 Oct 2026 22:23:32 +0330
Subject: [PATCH 13/24] feat(router): auto-sort routes on registration and add
custom route hook
- Added _bind_router_sorting_hooks to wrap add_api_route and add_route, triggering _sort_routes after each registration
- Added public sort_routes() wrapper for manual route sorting
- Added register_custom_routes() hook called at end of _register_routes for subclass extensions
- Updated __init__ docstring to mention auto-sorting hook binding
---
src/zcore/web/base_router.py | 31 ++++++++++++++++++++++++++++++-
1 file changed, 30 insertions(+), 1 deletion(-)
diff --git a/src/zcore/web/base_router.py b/src/zcore/web/base_router.py
index 67781de..bbb4de9 100644
--- a/src/zcore/web/base_router.py
+++ b/src/zcore/web/base_router.py
@@ -96,7 +96,8 @@ class BaseRouter(Generic[CreateSchemaType, UpdateSchemaType]):
def __init__(self) -> None:
"""Initialize the BaseRouter.
- Performs fail-fast configuration checks and registers configured CRUD endpoints.
+ Performs fail-fast configuration checks, registers configured CRUD endpoints,
+ and binds auto-sorting hooks for subsequent custom routes.
Raises:
ValueError: If the required service parameter is not configured.
@@ -110,6 +111,25 @@ def __init__(self) -> None:
self.exclude = self.exclude or set()
self._validate_schema_configurations()
self._register_routes()
+ self._bind_router_sorting_hooks()
+
+ def _bind_router_sorting_hooks(self) -> None:
+ """Bind hooks to auto-sort routes whenever new endpoints are registered."""
+ original_add_api_route = self.router.add_api_route
+ original_add_route = self.router.add_route
+
+ def wrapped_add_api_route(*args: Any, **kwargs: Any) -> Any:
+ result = original_add_api_route(*args, **kwargs)
+ self._sort_routes()
+ return result
+
+ def wrapped_add_route(*args: Any, **kwargs: Any) -> Any:
+ result = original_add_route(*args, **kwargs)
+ self._sort_routes()
+ return result
+
+ self.router.add_api_route = wrapped_add_api_route # type: ignore[method-assign]
+ self.router.add_route = wrapped_add_route # type: ignore[method-assign]
def _resolve_pk_type(self) -> type[Any]:
"""Resolve the effective primary key type from explicit definition or model reflection.
@@ -366,6 +386,10 @@ def _check_filters(items: list[Any] | None) -> None:
message=f"Sort field '{s.field}' is not permitted in lookup queries."
)
+ def sort_routes(self) -> None:
+ """Sort routes using hierarchical specificity scoring to avoid path shadowing."""
+ self._sort_routes()
+
def _sort_routes(self) -> None:
"""Sort routes using hierarchical specificity scoring to avoid path shadowing."""
@@ -389,6 +413,10 @@ def _route_specificity_key(route: Any) -> tuple[int, int, list[int], int]:
self.router.routes.sort(key=_route_specificity_key)
+ def register_custom_routes(self) -> None:
+ """Hook for subclasses to register custom domain endpoints before initial route sorting."""
+ pass
+
def _register_routes(self) -> None:
"""Dynamically generate and bind endpoints to the APIRouter."""
service_callable = self.service
@@ -566,6 +594,7 @@ async def _delete_endpoint(
openapi_extra=self._get_openapi_extra(RouteKey.DELETE),
)
+ self.register_custom_routes()
self._sort_routes()
async def create_endpoint(self, data_in: Any, service: BaseService) -> ResponseWrapper:
From 63a164991cad1234f165b0da9325a132d332c304 Mon Sep 17 00:00:00 2001
From: alialfostovar
Date: Sun, 18 Oct 2026 22:23:57 +0330
Subject: [PATCH 14/24] test(web): add custom route ordering and hook tests for
subclassed routers
- Removed duplicated DateCursorModel fixtures from test_repository
- Added test_subclassed_router_custom_route_in_init_avoids_shadowing verifying post-super() route additions are re-sorted
- Added test_subclassed_router_hook_register_custom_routes verifying the register_custom_routes hook
---
tests/test_repository.py | 15 --------
tests/test_web.py | 77 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 77 insertions(+), 15 deletions(-)
diff --git a/tests/test_repository.py b/tests/test_repository.py
index 1df17eb..961882d 100644
--- a/tests/test_repository.py
+++ b/tests/test_repository.py
@@ -61,21 +61,6 @@ def __init__(self, db: Any) -> None:
super().__init__(RepoTestModel, db)
-class DateCursorModel(Base):
- __tablename__ = f"date_cursor_model_{uuid.uuid4().hex[:6]}"
- id = Column(Integer, primary_key=True, autoincrement=True)
- operation_date = Column(Date, nullable=False)
-
-
-class DateCursorCreateSchema(BaseModel):
- operation_date: date
-
-
-class DateCursorRepository(BaseRepository[DateCursorModel]):
- def __init__(self, db: Any) -> None:
- super().__init__(DateCursorModel, db)
-
-
class CompositePartitionModel(Base):
__tablename__ = f"composite_part_model_{uuid.uuid4().hex[:6]}"
id = Column(Integer, primary_key=True)
diff --git a/tests/test_web.py b/tests/test_web.py
index 552b951..03b44ef 100644
--- a/tests/test_web.py
+++ b/tests/test_web.py
@@ -1027,3 +1027,80 @@ def get_route_dependencies(self, route_key: RouteKey, action: str) -> list[Any]:
body2 = res_page2.json()
assert body2["success"] is True
assert len(body2["data"]) == 2
+
+
+@pytest.mark.anyio
+async def test_subclassed_router_custom_route_in_init_avoids_shadowing() -> None:
+ class DummyService:
+ pass
+
+ class SubclassedInitRouter(BaseRouter[DummyCreate, DummyUpdate]):
+ model = DummyModel
+ create_schema = DummyCreate
+ update_schema = DummyUpdate
+ schema_out = DummyOut
+ service = DummyService
+ prefix = "/items-init"
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.router.add_api_route("/summary", self.summary_endpoint, methods=["GET"])
+
+ async def summary_endpoint(self) -> dict[str, bool]:
+ return {"ok": True}
+
+ def get_route_dependencies(self, route_key: RouteKey, action: str) -> list[Any]:
+ return []
+
+ r = SubclassedInitRouter()
+ paths = [route.path for route in r.router.routes]
+ idx_summary = paths.index("/items-init/summary")
+ idx_uuid = paths.index("/items-init/{id:uuid}")
+ assert idx_summary < idx_uuid
+
+ app = FastAPI()
+ app.include_router(r.router)
+
+ transport = ASGITransport(app=app, raise_app_exceptions=False)
+ async with AsyncClient(transport=transport, base_url="http://test") as client:
+ res = await client.get("/items-init/summary")
+ assert res.status_code == 200
+ assert res.json() == {"ok": True}
+
+
+@pytest.mark.anyio
+async def test_subclassed_router_hook_register_custom_routes() -> None:
+ class DummyService:
+ pass
+
+ class SubclassedHookRouter(BaseRouter[DummyCreate, DummyUpdate]):
+ model = DummyModel
+ create_schema = DummyCreate
+ update_schema = DummyUpdate
+ schema_out = DummyOut
+ service = DummyService
+ prefix = "/items-hook"
+
+ def register_custom_routes(self) -> None:
+ self.router.add_api_route("/reports", self.reports_endpoint, methods=["GET"])
+
+ async def reports_endpoint(self) -> dict[str, str]:
+ return {"report": "generated"}
+
+ def get_route_dependencies(self, route_key: RouteKey, action: str) -> list[Any]:
+ return []
+
+ r = SubclassedHookRouter()
+ paths = [route.path for route in r.router.routes]
+ idx_reports = paths.index("/items-hook/reports")
+ idx_uuid = paths.index("/items-hook/{id:uuid}")
+ assert idx_reports < idx_uuid
+
+ app = FastAPI()
+ app.include_router(r.router)
+
+ transport = ASGITransport(app=app, raise_app_exceptions=False)
+ async with AsyncClient(transport=transport, base_url="http://test") as client:
+ res = await client.get("/items-hook/reports")
+ assert res.status_code == 200
+ assert res.json() == {"report": "generated"}
From b8c6cf57bf7c0d328651ce956a6013d99007a66c Mon Sep 17 00:00:00 2001
From: alialfostovar
Date: Mon, 19 Oct 2026 08:56:24 +0330
Subject: [PATCH 15/24] feat(search): reject conflicting cursor and page
pagination params
- Added model_validator check in SearchRequest raising ValueError when cursor is non-empty and page > 1
- Added parametrized test_search_request_pagination_mutual_exclusivity covering empty, whitespace, and None cursors
- Added test_search_endpoint_rejects_conflicting_pagination_params verifying 422 response via registered handlers
---
src/zcore/db/search.py | 5 +++++
tests/test_search.py | 25 +++++++++++++++++++++++++
tests/test_web.py | 34 ++++++++++++++++++++++++++++++++++
3 files changed, 64 insertions(+)
diff --git a/src/zcore/db/search.py b/src/zcore/db/search.py
index 4cac0b0..d958158 100644
--- a/src/zcore/db/search.py
+++ b/src/zcore/db/search.py
@@ -109,6 +109,11 @@ class SearchRequest(BaseModel):
@model_validator(mode="after")
def _validate_and_bound_size(self) -> SearchRequest:
+ if self.cursor and self.cursor.strip() and self.page > 1:
+ raise ValueError(
+ "Cannot combine 'cursor' with 'page' > 1. Choose either keyset (cursor) or offset (page) pagination."
+ )
+
default_size = getattr(settings, "PAGINATION_DEFAULT_SIZE", 20)
max_size = getattr(settings, "PAGINATION_MAX_SIZE", 100)
diff --git a/tests/test_search.py b/tests/test_search.py
index 5cec4fb..fbcbb5a 100644
--- a/tests/test_search.py
+++ b/tests/test_search.py
@@ -571,3 +571,28 @@ async def test_search_numeric_coercion_from_string(db_session: Any, seed_data: N
req_invalid = SearchRequest(filters=[FilterItem(field="id", op="eq", value="not_a_number")])
engine.build_base_query(req_invalid)
assert "failed to coerce" in str(exc_info.value).lower()
+
+
+@pytest.mark.parametrize(
+ "page, cursor, should_raise",
+ [
+ (1, None, False),
+ (1, "valid_cursor_token", False),
+ (2, None, False),
+ (2, "", False),
+ (2, " ", False),
+ (2, "valid_cursor_token", True),
+ (5, "valid_cursor_token", True),
+ ],
+)
+def test_search_request_pagination_mutual_exclusivity(
+ page: int, cursor: str | None, should_raise: bool
+) -> None:
+ if should_raise:
+ with pytest.raises(Exception) as exc_info:
+ SearchRequest(page=page, cursor=cursor)
+ assert "Cannot combine 'cursor' with 'page' > 1" in str(exc_info.value)
+ else:
+ req = SearchRequest(page=page, cursor=cursor)
+ assert req.page == page
+ assert req.cursor == cursor
diff --git a/tests/test_web.py b/tests/test_web.py
index 03b44ef..eec2da5 100644
--- a/tests/test_web.py
+++ b/tests/test_web.py
@@ -1104,3 +1104,37 @@ def get_route_dependencies(self, route_key: RouteKey, action: str) -> list[Any]:
res = await client.get("/items-hook/reports")
assert res.status_code == 200
assert res.json() == {"report": "generated"}
+
+
+@pytest.mark.anyio
+async def test_search_endpoint_rejects_conflicting_pagination_params() -> None:
+ app = FastAPI()
+ register_exception_handlers(app)
+
+ service_inst = MockFullService()
+ container.register_singleton(MockFullService, service_inst)
+
+ class ConflictRouter(BaseRouter[DummyCreate, DummyUpdate]):
+ model = DummyModel
+ create_schema = DummyCreate
+ update_schema = DummyUpdate
+ schema_out = DummyOut
+ service = MockFullService
+ prefix = "/conflict-test"
+
+ def get_route_dependencies(self, route_key: RouteKey, action: str) -> list[Any]:
+ return []
+
+ r = ConflictRouter()
+ app.include_router(r.router)
+
+ transport = ASGITransport(app=app, raise_app_exceptions=False)
+ async with AsyncClient(transport=transport, base_url="http://test") as client:
+ res = await client.post(
+ "/conflict-test/search",
+ json={"page": 3, "cursor": "eyJ2YWx1ZSI6IC...", "size": 10},
+ )
+ assert res.status_code == 422
+ body = res.json()
+ assert body["success"] is False
+ assert "Cannot combine 'cursor' with 'page' > 1" in body["message"]
From 3afaaca081eb1dd72b9556563ec261dd2131738b Mon Sep 17 00:00:00 2001
From: alialfostovar
Date: Mon, 19 Oct 2026 09:34:10 +0330
Subject: [PATCH 16/24] fix(cache): add thread lock to prevent duplicate
eviction task creation
- Added module-level threading.Lock to guard _ensure_eviction_task and close_cache
- Double-checked locking ensures only one eviction task is created under concurrent calls
- close_cache now extracts and nullifies _eviction_task inside the lock before awaiting cancellation
- Added test_concurrent_ensure_eviction_task_single_instance with ThreadPoolExecutor
---
src/zcore/cache/base.py | 21 ++++++++++++++++-----
tests/test_cache.py | 29 +++++++++++++++++++++++++++++
2 files changed, 45 insertions(+), 5 deletions(-)
diff --git a/src/zcore/cache/base.py b/src/zcore/cache/base.py
index c09433d..1800e82 100644
--- a/src/zcore/cache/base.py
+++ b/src/zcore/cache/base.py
@@ -8,6 +8,7 @@
import asyncio
import contextlib
+import threading
from typing import Any, Generic, TypeVar
import structlog
@@ -29,6 +30,7 @@
_shared_redis_client: Any | None = None
_eviction_task: asyncio.Task | None = None
+_lock = threading.Lock()
def _ensure_eviction_task(interval: int | None = None) -> None:
@@ -43,7 +45,11 @@ def _ensure_eviction_task(interval: int | None = None) -> None:
try:
loop = asyncio.get_running_loop()
if _eviction_task is None or _eviction_task.done():
- _eviction_task = loop.create_task(_start_eviction_loop(interval=effective_interval))
+ with _lock:
+ if _eviction_task is None or _eviction_task.done():
+ _eviction_task = loop.create_task(
+ _start_eviction_loop(interval=effective_interval)
+ )
except RuntimeError:
pass
@@ -89,11 +95,16 @@ async def close_cache() -> None:
"""Cancel background loops and cleanly close distributed connections."""
global _shared_redis_client, _eviction_task
- if _eviction_task and not _eviction_task.done():
- _eviction_task.cancel()
+ task_to_cancel = None
+ with _lock:
+ if _eviction_task and not _eviction_task.done():
+ task_to_cancel = _eviction_task
+ _eviction_task = None
+
+ if task_to_cancel:
+ task_to_cancel.cancel()
with contextlib.suppress(asyncio.CancelledError):
- await _eviction_task
- _eviction_task = None
+ await task_to_cancel
logger.debug("Cache memory eviction loop stopped.")
if _shared_redis_client:
diff --git a/tests/test_cache.py b/tests/test_cache.py
index 78f113c..4367403 100644
--- a/tests/test_cache.py
+++ b/tests/test_cache.py
@@ -1,6 +1,8 @@
+import asyncio
import gc
import threading
import time
+from concurrent.futures import ThreadPoolExecutor
from typing import Any
from unittest.mock import AsyncMock, patch
@@ -321,3 +323,30 @@ def test_dynamic_cache_configuration_bindings() -> None:
base_cache = BaseCache[str](prefix="dynamic")
assert base_cache._local_cache.maxsize == 42
assert base_cache.default_ttl == 123
+
+
+@pytest.mark.anyio
+async def test_concurrent_ensure_eviction_task_single_instance() -> None:
+ await close_cache()
+
+ loop = asyncio.get_running_loop()
+
+ def call_ensure() -> None:
+ loop.call_soon_threadsafe(_ensure_eviction_task)
+
+ with ThreadPoolExecutor(max_workers=10) as executor:
+ futures = [executor.submit(call_ensure) for _ in range(20)]
+ for f in futures:
+ f.result()
+
+ await asyncio.sleep(0.05)
+
+ assert base_module._eviction_task is not None
+ assert not base_module._eviction_task.done()
+
+ initial_task = base_module._eviction_task
+ _ensure_eviction_task()
+ assert base_module._eviction_task is initial_task
+
+ await close_cache()
+ assert base_module._eviction_task is None
From 67f7be8db989edb684bb2e94278f21f4f9babbf4 Mon Sep 17 00:00:00 2001
From: alialfostovar
Date: Mon, 19 Oct 2026 10:00:12 +0330
Subject: [PATCH 17/24] feat(cli): derive version from package metadata and
bump to rc.3
- Added __version__ resolution via importlib.metadata with rc.3 fallback
- Exported __version__ from zcore package
- CLI --version and banner now use __version__ dynamically
- main.py template now imports and returns __version__
- Fixed repository template class name to use {ModelNameRepository}
- Updated README version references to rc.3
---
README.md | 6 +++---
src/zcore/__init__.py | 7 +++++++
src/zcore/cli/__init__.py | 3 ++-
src/zcore/cli/templates.py | 13 ++++++++++---
src/zcore/cli/ui.py | 4 +++-
5 files changed, 25 insertions(+), 8 deletions(-)
diff --git a/README.md b/README.md
index e806af4..ce7da4a 100644
--- a/README.md
+++ b/README.md
@@ -273,7 +273,7 @@ ZCore includes an interactive terminal user interface (TUI) powered by Questiona
```text
$ zc
-β‘ ZCore Framework v0.1.0-rc.2 β’ Modern Modular Monolith
+β‘ ZCore Framework v0.1.0-rc.3 β’ Modern Modular Monolith
FastAPI β’ SQLAlchemy 2.0 β’ Pydantic V2
? What framework task would you like to perform?
@@ -298,9 +298,9 @@ $ zc
---
-## π’ Community Testing & Feedback (Release Candidate `rc.2`)
+## π’ Community Testing & Feedback (Release Candidate `rc.3`)
-We are currently validating **`v0.1.0-rc.2`** before tagging the official `v1.0.0` stable release.
+We are currently validating **`v0.1.0-rc.3`** before tagging the official `v1.0.0` stable release.
We built ZCore to solve architectural pain points we faced in production. Before declaring the API stable, **we need your critical engineering feedback:**
diff --git a/src/zcore/__init__.py b/src/zcore/__init__.py
index f3f13c2..11bb74a 100644
--- a/src/zcore/__init__.py
+++ b/src/zcore/__init__.py
@@ -1,7 +1,13 @@
"""ZCore Framework Core Package Entrypoint."""
+import importlib.metadata
from typing import TYPE_CHECKING, Any
+try:
+ __version__ = importlib.metadata.version("fastapi-zcore-framework")
+except Exception:
+ __version__ = "0.1.0-rc.3"
+
from zcore.config import settings
if TYPE_CHECKING:
@@ -113,6 +119,7 @@
"ZCoreRequest",
"ZDateTime",
"Zchema",
+ "__version__",
"app_exception_handler",
"background_scope",
"background_task",
diff --git a/src/zcore/cli/__init__.py b/src/zcore/cli/__init__.py
index 743b1d8..b411a86 100644
--- a/src/zcore/cli/__init__.py
+++ b/src/zcore/cli/__init__.py
@@ -8,6 +8,7 @@
import questionary
from questionary import Choice
+from zcore import __version__
from zcore.cli.commands import (
DB_DRIVERS,
find_zcore_projects,
@@ -366,7 +367,7 @@ def main() -> None:
if args.version:
console.print(
- f"[bold {ZCORE_PRIMARY}]ZCore Framework[/bold {ZCORE_PRIMARY}] - Version [bold white]0.1.0-rc.2[/bold white]"
+ f"[bold {ZCORE_PRIMARY}]ZCore Framework[/bold {ZCORE_PRIMARY}] - Version [bold white]{__version__}[/bold white]"
)
sys.exit(0)
diff --git a/src/zcore/cli/templates.py b/src/zcore/cli/templates.py
index 9b165b6..bfa0a4d 100644
--- a/src/zcore/cli/templates.py
+++ b/src/zcore/cli/templates.py
@@ -5,7 +5,14 @@
sys.path.insert(0, str(Path(__file__).resolve().parent))
from fastapi import FastAPI
-from zcore import Kernel, db_manager, register_db_event_dispatcher, register_exception_handlers, settings
+from zcore import (
+ Kernel,
+ __version__,
+ db_manager,
+ register_db_event_dispatcher,
+ register_exception_handlers,
+ settings,
+)
from zcore.logging import setup_logging
from zcore.web import RequestLogMiddleware, ScopedDependencyMiddleware
@@ -33,7 +40,7 @@ async def root():
return {
"status": "healthy",
"framework": "ZCore",
- "version": "0.1.0-rc.2",
+ "version": __version__,
"debug": settings.DEBUG
}
"""
@@ -147,7 +154,7 @@ class {ModelName}Response({ModelName}Base):
from .models import {ModelName}
from .schemas import {ModelName}Create, {ModelName}Update
-class {ModelName}Repository(BaseRepository[{ModelName}]):
+class {ModelNameRepository}(BaseRepository[{ModelName}]):
def __init__(self, db: AsyncSession):
super().__init__(model={ModelName}, db=db)
"""
diff --git a/src/zcore/cli/ui.py b/src/zcore/cli/ui.py
index 9b60510..8a50726 100644
--- a/src/zcore/cli/ui.py
+++ b/src/zcore/cli/ui.py
@@ -3,6 +3,8 @@
from questionary import Style
from rich.console import Console
+from zcore import __version__
+
console = Console()
ZCORE_PRIMARY = "#10b981"
@@ -29,7 +31,7 @@ def print_banner() -> None:
console.print()
console.print(
f"[bold {ZCORE_PRIMARY}]β‘ ZCore Framework[/bold {ZCORE_PRIMARY}] "
- f"[dim white]v0.1.0-rc.2[/dim white] "
+ f"[dim white]v{__version__}[/dim white] "
f"[{ZCORE_MUTED}]β’ Modern Modular Monolith[/{ZCORE_MUTED}]"
)
console.print(f" [dim {ZCORE_MUTED}]FastAPI β’ SQLAlchemy 2.0 β’ Pydantic V2[/dim {ZCORE_MUTED}]")
From df738bb4dedc9dc24a14f425a27db3c29b1a249f Mon Sep 17 00:00:00 2001
From: alialfostovar
Date: Mon, 19 Oct 2026 10:11:31 +0330
Subject: [PATCH 18/24] feat(repository): enforce composite primary key
validation and lazy cursor field
- Made cursor_field a property/setter backed by _cursor_field with None default
- cursor_field getter raises AttributeError on composite models without explicit override
- _build_pk_condition now validates dict completeness, tuple/list length, and rejects scalars for composite models
- _build_ids_condition validates composite dict completeness, tuple length, and rejects scalar items
- update_multi validates composite dict/tuple keys with descriptive error messages
- Removed redundant cursor_field class annotation
---
src/zcore/db/repository.py | 132 ++++++++++++++++++++++++++++++++-----
1 file changed, 114 insertions(+), 18 deletions(-)
diff --git a/src/zcore/db/repository.py b/src/zcore/db/repository.py
index 1be6699..fd9014a 100644
--- a/src/zcore/db/repository.py
+++ b/src/zcore/db/repository.py
@@ -38,7 +38,6 @@ class AbstractRepository(Generic[ModelType]):
pks: Tuple of primary key column representations of the bound model.
pk_names: Tuple of primary key column names.
is_composite_pk: Flag indicating if the model has a composite primary key.
- cursor_field: The field name to utilize during keyset pagination lookups.
"""
db: AsyncSession
@@ -46,7 +45,7 @@ class AbstractRepository(Generic[ModelType]):
pks: tuple[Any, ...]
pk_names: tuple[str, ...]
is_composite_pk: bool
- cursor_field: str
+ _cursor_field: str | None = None
@property
def pk(self) -> Any:
@@ -76,6 +75,31 @@ def pk_name(self) -> str:
)
return self.pk_names[0]
+ @property
+ def cursor_field(self) -> str:
+ """Resolve the default cursor coordinate field.
+
+ Returns:
+ The explicitly configured cursor field or the single primary key column name.
+
+ Raises:
+ AttributeError: If invoked on a composite model without an explicit cursor field.
+ """
+ if self._cursor_field is not None:
+ return self._cursor_field
+
+ if self.is_composite_pk:
+ raise AttributeError(
+ f"Model '{self.model.__name__}' has a composite primary key {self.pk_names}. "
+ "Explicitly set 'self.cursor_field' on the repository or provide sorting criteria in requests."
+ )
+
+ return self.pk_names[0]
+
+ @cursor_field.setter
+ def cursor_field(self, value: str | None) -> None:
+ self._cursor_field = value
+
def _get_base_query(self) -> Select:
"""Construct the initial select statement for query operations.
@@ -116,18 +140,49 @@ def _build_pk_condition(self, target: Any) -> Any:
Returns:
SQLAlchemy binary expression or compound `and_` expression matching target primary key.
+
+ Raises:
+ ValueError: If target values do not conform to expected primary key structures.
"""
if isinstance(target, self.model):
return and_(*[col == getattr(target, col.key) for col in self.pks])
if isinstance(target, dict):
- return and_(*[col == target[col.key] for col in self.pks if col.key in target])
+ if self.is_composite_pk:
+ missing = [
+ col.key for col in self.pks if col.key not in target and col.name not in target
+ ]
+ if missing:
+ raise ValueError(
+ f"Incomplete composite primary key mapping for '{self.model.__name__}'. "
+ f"Missing required key fields: {missing}"
+ )
+ return and_(*[col == target.get(col.key, target.get(col.name)) for col in self.pks])
+
+ if self.pk_names[0] in target:
+ return self.pks[0] == target[self.pk_names[0]]
+ if self.pks[0].key in target:
+ return self.pks[0] == target[self.pks[0].key]
+ raise ValueError(
+ f"Missing required primary key '{self.pk_names[0]}' in mapping for '{self.model.__name__}'."
+ )
if isinstance(target, (tuple, list)):
if self.is_composite_pk:
- return and_(*[col == val for col, val in zip(self.pks, target, strict=False)])
+ if len(target) != len(self.pks):
+ raise ValueError(
+ f"Composite primary key tuple for '{self.model.__name__}' requires "
+ f"{len(self.pks)} values ({self.pk_names}), received {len(target)}."
+ )
+ return and_(*[col == val for col, val in zip(self.pks, target, strict=True)])
return self.pks[0] == target[0]
+ if self.is_composite_pk:
+ raise ValueError(
+ f"Model '{self.model.__name__}' has composite primary keys {self.pk_names}. "
+ f"Target lookup must be a model instance, mapping, or tuple/list, received scalar: {target!r}"
+ )
+
return self.pks[0] == target
def _build_ids_condition(self, ids: Sequence[Any]) -> Any:
@@ -138,12 +193,15 @@ def _build_ids_condition(self, ids: Sequence[Any]) -> Any:
Returns:
SQLAlchemy filter expression resolving scalar `in_` or composite `tuple_.in_`.
+
+ Raises:
+ ValueError: If items in ids do not conform to the model's primary key signature.
"""
if not self.is_composite_pk:
normalized_scalars = [
- getattr(item, self.pk_names[0])
+ getattr(item, self.pks[0].key)
if isinstance(item, self.model)
- else item.get(self.pk_names[0])
+ else item.get(self.pk_names[0], item.get(self.pks[0].key))
if isinstance(item, dict)
else item[0]
if isinstance(item, (tuple, list))
@@ -157,11 +215,29 @@ def _build_ids_condition(self, ids: Sequence[Any]) -> Any:
if isinstance(item, self.model):
normalized_tuples.append(tuple(getattr(item, col.key) for col in self.pks))
elif isinstance(item, dict):
- normalized_tuples.append(tuple(item[col.key] for col in self.pks))
+ missing = [
+ col.key for col in self.pks if col.key not in item and col.name not in item
+ ]
+ if missing:
+ raise ValueError(
+ f"Incomplete composite key item in batch for '{self.model.__name__}'. "
+ f"Missing required fields: {missing}"
+ )
+ normalized_tuples.append(
+ tuple(item.get(col.key, item.get(col.name)) for col in self.pks)
+ )
elif isinstance(item, (tuple, list)):
+ if len(item) != len(self.pks):
+ raise ValueError(
+ f"Composite primary key item requires {len(self.pks)} values ({self.pk_names}), "
+ f"received {len(item)}: {item}."
+ )
normalized_tuples.append(tuple(item))
else:
- normalized_tuples.append((item,))
+ raise ValueError(
+ f"Model '{self.model.__name__}' has composite primary keys {self.pk_names}. "
+ f"Batch items must be tuples, dicts, or model instances, received scalar: {item!r}"
+ )
return tuple_(*self.pks).in_(normalized_tuples)
@@ -438,19 +514,39 @@ async def update_multi(
for col in self.pks:
row_payload[col.key] = getattr(key, col.key)
elif isinstance(key, dict):
- target_id = (
- tuple(key[col.key] for col in self.pks)
- if self.is_composite_pk
- else key.get(self.pk_names[0])
- )
- for col in self.pks:
- if col.key in key:
- row_payload[col.key] = key[col.key]
+ if self.is_composite_pk:
+ missing = [
+ col.key
+ for col in self.pks
+ if col.key not in key and col.name not in key
+ ]
+ if missing:
+ raise ValueError(
+ f"Incomplete composite primary key mapping in update_multi for '{self.model.__name__}'. "
+ f"Missing fields: {missing}"
+ )
+ target_id = tuple(key.get(col.key, key.get(col.name)) for col in self.pks)
+ for col in self.pks:
+ row_payload[col.key] = key.get(col.key, key.get(col.name))
+ else:
+ pk_k = self.pk_names[0] if self.pk_names[0] in key else self.pks[0].key
+ target_id = key.get(pk_k)
+ row_payload[self.pk_names[0]] = target_id
elif isinstance(key, (tuple, list)) and self.is_composite_pk:
+ if len(key) != len(self.pks):
+ raise ValueError(
+ f"Composite primary key tuple in update_multi requires {len(self.pks)} values ({self.pk_names}), "
+ f"received {len(key)}."
+ )
target_id = tuple(key)
- for col, val in zip(self.pks, key, strict=False):
+ for col, val in zip(self.pks, key, strict=True):
row_payload[col.key] = val
else:
+ if self.is_composite_pk:
+ raise ValueError(
+ f"Model '{self.model.__name__}' has composite primary keys {self.pk_names}. "
+ f"Update target must be a tuple, dict, or model instance, received scalar: {key!r}"
+ )
target_id = key
row_payload[self.pk_names[0]] = key
@@ -690,4 +786,4 @@ def __init__(self, model: type[ModelType], db: AsyncSession):
self.pks = tuple(mapper.primary_key)
self.pk_names = tuple(col.name for col in self.pks)
self.is_composite_pk = len(self.pks) > 1
- self.cursor_field = self.pk_names[0]
+ self._cursor_field = None
From 55aacdf9b41d2e271a1ed4acfb489ab5fb68b57a Mon Sep 17 00:00:00 2001
From: alialfostovar
Date: Mon, 19 Oct 2026 10:20:53 +0330
Subject: [PATCH 19/24] refactor: hardcode static __version__ and fix
repository template
- Removed importlib.metadata lookup; __version__ is now a static '0.1.0-rc.3' string
- Fixed repository template class name from {ModelNameRepository} to {ModelName}Repository
- Updated CLI version test assertions to rc.3
- Collapsed multi-line missing-key list comprehension in update_multi
---
src/zcore/__init__.py | 8 ++------
src/zcore/cli/templates.py | 2 +-
src/zcore/db/repository.py | 4 +---
tests/test_cli.py | 4 ++--
4 files changed, 6 insertions(+), 12 deletions(-)
diff --git a/src/zcore/__init__.py b/src/zcore/__init__.py
index 11bb74a..fd39ec2 100644
--- a/src/zcore/__init__.py
+++ b/src/zcore/__init__.py
@@ -1,12 +1,8 @@
"""ZCore Framework Core Package Entrypoint."""
-import importlib.metadata
-from typing import TYPE_CHECKING, Any
+__version__ = "0.1.0-rc.3"
-try:
- __version__ = importlib.metadata.version("fastapi-zcore-framework")
-except Exception:
- __version__ = "0.1.0-rc.3"
+from typing import TYPE_CHECKING, Any
from zcore.config import settings
diff --git a/src/zcore/cli/templates.py b/src/zcore/cli/templates.py
index bfa0a4d..38ee86a 100644
--- a/src/zcore/cli/templates.py
+++ b/src/zcore/cli/templates.py
@@ -154,7 +154,7 @@ class {ModelName}Response({ModelName}Base):
from .models import {ModelName}
from .schemas import {ModelName}Create, {ModelName}Update
-class {ModelNameRepository}(BaseRepository[{ModelName}]):
+class {ModelName}Repository(BaseRepository[{ModelName}]):
def __init__(self, db: AsyncSession):
super().__init__(model={ModelName}, db=db)
"""
diff --git a/src/zcore/db/repository.py b/src/zcore/db/repository.py
index fd9014a..964c599 100644
--- a/src/zcore/db/repository.py
+++ b/src/zcore/db/repository.py
@@ -516,9 +516,7 @@ async def update_multi(
elif isinstance(key, dict):
if self.is_composite_pk:
missing = [
- col.key
- for col in self.pks
- if col.key not in key and col.name not in key
+ col.key for col in self.pks if col.key not in key and col.name not in key
]
if missing:
raise ValueError(
diff --git a/tests/test_cli.py b/tests/test_cli.py
index a740b5a..8693b9c 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -337,11 +337,11 @@ def test_cli_version_output_rc2() -> None:
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 0
- assert "0.1.0-rc.2" in capture.get()
+ assert "0.1.0-rc.3" in capture.get()
with console.capture() as capture:
print_banner()
- assert "0.1.0-rc.2" in capture.get()
+ assert "0.1.0-rc.3" in capture.get()
def test_cli_scaffold_templates_use_database_settings_config(run_in_tmp_path: Path) -> None:
From 0cc541f3fd1cf1c5a4bda622d14e59e8044c1d2d Mon Sep 17 00:00:00 2001
From: alialfostovar
Date: Mon, 19 Oct 2026 11:44:06 +0330
Subject: [PATCH 20/24] docs(readme): refine README with technical, neutral
tone
- Rewrote overview with concrete architectural challenges and less marketing language
- Rebranded ZShop as a reference implementation with factual capability descriptions
- Reworked comparison table with neutral phrasing and added composite PK details
- Added new details section on composite primary keys and keyset pagination
- Renamed 'Core Architectural Patterns' to 'Core Architectural Components' with Problem/Solution structure
- Simplified quick start endpoint list and CLI command reference
- Reframed community testing section to focus on technical feedback
- Updated footer attribution
---
README.md | 225 ++++++++++++++++++++++++++++--------------------------
1 file changed, 117 insertions(+), 108 deletions(-)
diff --git a/README.md b/README.md
index ce7da4a..bc6b79d 100644
--- a/README.md
+++ b/README.md
@@ -3,8 +3,8 @@
- A composable architectural foundation for building scalable applications on top of FastAPI.
- Standardize your structure, manage atomic transactions, and protect sensitive data β without sacrificing FastAPI's flexibility.
+ An architectural chassis for building modular applications on top of FastAPI.
+ Structuring transactional boundaries, constructor dependency injection, and field-level access controls while preserving native FastAPI semantics.
@@ -29,51 +29,49 @@
## π§ Overview
-**FastAPI is an exceptional microframework for HTTP routing, request parsing, and asynchronous I/O.**
+FastAPI provides an efficient, ergonomic foundation for HTTP handling, asynchronous I/O, and data validation using Pydantic. However, as codebases grow beyond simple APIs into larger services or modular monoliths, engineering teams frequently face common structural challenges:
-However, as applications grow into medium and large systems, teams inevitably encounter an architectural gap:
-* Database sessions leak across asynchronous tasks or background jobs.
-* Business workflows spanning multiple repositories lack reliable, all-or-nothing transaction boundaries.
-* Teams end up writing dozens of nearly identical DTO schemas just to hide internal fields (like profit margins, supplier terms, or sensitive IDs) from different user roles.
-* Domain modules become tightly coupled through circular imports and tangled dependency trees.
+* **Session Lifetime & Leaks:** Database sessions can inadvertently leak across concurrent coroutines or terminate prematurely during background tasks.
+* **Transaction Coordination:** Workflows that update multiple repositories often lack a unified, atomic transaction boundary, risking partial writes when a middle step fails.
+* **Schema Proliferation:** Writing and maintaining separate Pydantic models (`UserPublic`, `UserInternal`, `UserAdminResponse`) solely to conceal specific fields across user roles introduces repetitive boilerplate.
+* **Module Coupling:** Large domain modules frequently develop tangled dependency graphs and circular imports when interacting with shared databases.
-Heavy enterprise frameworks solve these challenges by enforcing rigid conventions, proprietary ORMs, and heavy abstractions that eliminate your freedom.
+Full-stack monolithic frameworks solve these issues by introducing comprehensive conventions, coupled ORMs, and centralized registries. While effective, this approach can reduce architectural flexibility.
-**ZCore is the middle ground:**
-ZCore provides a structured, modular layer that sits directly on top of standard FastAPI, SQLAlchemy 2.0, and Pydantic V2. It introduces proven architectural patterns (Unit of Work, IoC Dependency Injection, Field-Level Security, and Domain Event Dispatching) as **composable building blocks**.
+**ZCore is designed as an intermediate layer.** It does not hide FastAPI or replace its router. Instead, it provides a set of decoupled architectural primitivesβsuch as a re-entrant Unit of Work, constructor-based Inversion of Control (IoC), context-aware schema filtering (`Zchema`), and lifecycle pluginsβbuilt on top of standard **FastAPI**, **SQLAlchemy 2.0 (Async)**, and **Pydantic V2**.
-> **Your code remains 100% standard FastAPI.** Adopt ZCore piece by piece where it solves real problems, or use the full architectural stack β with zero vendor lock-in.
+You can adopt individual components where they solve concrete architectural needs or use the structured chassis as a cohesive pattern for modular application design.
---
-## ποΈ Reference Showcase: ZShop
+## ποΈ Reference Implementation: ZShop
-To see ZCore's architectural concepts in action before writing a single line of code, explore **[ZShop](https://github.com/Baseryn/zshop)** β the official Modular Monolith e-commerce reference implementation:
+To examine how these architectural patterns interact in an end-to-end codebase, refer to **[ZShop](https://github.com/Baseryn/zshop)**, a modular monolith reference application:
-* **Interactive Live Inspector Dock:** Monitor active request context, correlation IDs (`x-request-id`), execution latencies, and real-time SSE stream events.
-* **1-Click RBAC Persona Switcher:** Switch between SuperAdmin, Store Manager, Customer, and Guest to watch confidential data fields automatically vanish from responses via `Zchema`.
-* **Atomic Rollback Demonstration:** An interactive deficit checkout button verifying that stock adjustments and order persistence roll back atomically when any invariant fails.
-* **Decoupled Architecture:** Strict inter-module communication using Python Protocols (`ProductContract`) without cross-domain database coupling.
+* **Context & Security Inspection:** Demonstrates active correlation IDs (`x-request-id`), execution profiling, and role-based data projection.
+* **Role-Based Schema Pruning:** Displays how confidential attributes (such as supplier costs) are dynamically excluded based on the authenticated context via `Zchema`.
+* **Atomic Rollback Demonstration:** Validates multi-repository rollback guarantees by simulating invariant violations during order checkout.
+* **Contract-Driven Decoupling:** Demonstrates cross-domain communication via Python `Protocols` (`ProductContract`) rather than direct database model imports.
-π **[Explore the ZShop Showcase Repository](https://github.com/Baseryn/zshop)**
+π **[Explore the ZShop Reference Codebase](https://github.com/Baseryn/zshop)**
---
-## βοΈ Why ZCore? (An Honest Comparison)
+## βοΈ Architectural Comparison
-| Architectural Concern | Plain FastAPI | Full-Stack Monoliths (e.g. Django) | With ZCore on FastAPI |
+| Area | Plain FastAPI | Heavy Monoliths (e.g., Django) | FastAPI with ZCore |
| :--- | :--- | :--- | :--- |
-| **Application Philosophy** | Minimalist microframework; no opinion on database, folders, or architecture. | Opinionated, batteries-included; tightly coupled to its own ORM and template engine. | **Composable layer;** provides enterprise architecture patterns while keeping FastAPI completely standard. |
-| **Role-Based Data Exposure** | Writing multiple Pydantic models per role (`UserPublic`, `UserAdmin`) or manual `if/else` checks. | Model-level permissions or custom serializer logic. | **Single-schema context shielding (`Zchema`);** forbidden fields are pruned dynamically from validation, serialization, and OpenAPI. |
-| **Multi-Repository Operations** | Scattered `session.commit()` calls; high risk of partial writes on failure. | Managed transaction blocks (`transaction.atomic()`). | **Re-entrant `UnitOfWork`;** coordinated all-or-nothing boundaries with domain events deferred until physical commit succeeds. |
-| **Background Processing** | Passing request sessions to background tasks often leads to `Session is closed` errors. | Requires external queue workers (Celery, RQ) even for simple background jobs. | **Dedicated `@background_task`;** automatically isolates DB sessions and IoC lifecycles from completed HTTP requests. |
-| **Domain Modularity** | Standard APIRouters; easily slips into spaghetti code and circular imports at scale. | Django Apps with rigid settings and global app registries. | **DAG-driven `Plugin` system;** topological startup sorting and pure Python Protocol contracts for zero-coupling. |
-| **Dataset Pagination** | Manual `offset / limit` SQL slicing that degrades on large tables. | Built-in offset paginators. | **Keyset Cursor Pagination (Base64);** zero-drift pagination and dynamic AST-like JSON search engine. |
-| **Testing Isolation** | Manual DB cleanup routines or slow schema recreation per test file. | TransactionTestCase rolling back transactions per test. | **Zero-side-effect `ZTestClient`;** automated savepoint rollbacks, context mocking, and container sandboxing. |
+| **Philosophy** | Minimalist microframework; architectural organization is left entirely to the developer. | Batteries-included, opinionated; coupled to built-in ORM and global application registries. | **Composable layer;** provides structured architectural patterns while keeping routers and schemas standard. |
+| **Field-Level Security** | Manual `if/else` checks or maintaining distinct Pydantic DTOs for each access level. | Model-level field permissions or specialized serializer layers. | **Context-aware pruning (`Zchema`);** dynamically filters restricted fields across validation, serialization, and OpenAPI. |
+| **Transaction Management** | Manual `session.commit()` calls across routes and services; potential for partial writes. | Built-in transaction blocks (`transaction.atomic()`). | **Re-entrant `UnitOfWork`;** depth-aware coordination with domain events buffered until root commit succeeds. |
+| **Background Processing** | Request-scoped sessions passed to `BackgroundTasks` often encounter closed session errors. | Relies on external task queue workers (e.g., Celery) even for simple background steps. | **Scoped `@background_task`;** isolates session allocation and IoC lifecycles from completed request scopes. |
+| **Modular Domain Design** | Standard `APIRouter` instances; vulnerable to circular imports as features expand. | Strict app layout and global settings configuration. | **DAG-driven `Plugin` system;** topological startup sorting and pure Python Protocol contracts for interface decoupling. |
+| **Dataset Pagination** | Manual `offset / limit` SQL slicing, which may suffer performance degradation on large tables. | Built-in offset paginators. | **Keyset Cursor Pagination;** compound keyset traversal with URL-safe base64 cursors, alongside dynamic JSON filtering. |
+| **Test Isolation** | Manual database cleanup or recreating schema fixtures between test runs. | Transactional test cases rolling back writes per test method. | **Rollback harness via `ZTestClient`;** automated savepoint rollbacks, container isolation, and context mocking. |
---
-## π Quick Start (in Under 60 Seconds)
+## π Quick Start
### 1. Installation
@@ -81,21 +79,21 @@ To see ZCore's architectural concepts in action before writing a single line of
pip install "fastapi-zcore-framework[all]"
```
-### 2. Scaffold a New Project
+### 2. Initialize a Project
-The interactive `zc` CLI configures your environment, sets up a virtual environment (`uv` or `pip`), and provisions your database driver:
+The interactive `zc` CLI configures your workspace, sets up a virtual environment (`uv` or `pip`), and bootstraps the appropriate asynchronous database driver:
```bash
-# Interactive mode (recommended)
+# Interactive setup
zc init core_api && cd core_api
-# Generate your first domain module
+# Scaffold a domain module
zc startapp tasks
```
### 3. Define Your Domain Model
-Open `tasks/models.py` (standard SQLAlchemy 2.0):
+In `tasks/models.py` (standard SQLAlchemy 2.0):
```python
import uuid
@@ -116,33 +114,35 @@ class Task(Base, SoftDeleteMixin):
zc run
```
-Your API is running at `http://127.0.0.1:8000` with **8 production-ready endpoints** out of the box:
-* `POST /tasks/` β Create entity
-* `GET /tasks/{id}` β Fetch single record (with automatic PK type inspection)
-* `GET /tasks/` β List records (with cursor or offset pagination)
-* `POST /tasks/search` β Dynamic nested JSON filtering engine
-* `POST /tasks/lookup` β Lightweight field-projected endpoint for dropdowns
-* `PUT /tasks/{id}` & `PATCH /tasks/{id}` β Full and partial updates
-* `DELETE /tasks/{id}` β Soft delete (or permanent purge via `?force=true`)
-* `GET /tasks/?schema=true` β Dynamic role-aware frontend form schema
+The application starts at `http://127.0.0.1:8000` with 8 standard endpoints managed by `BaseRouter`:
+* `POST /tasks/` β Entity creation
+* `GET /tasks/{id}` β Single-record retrieval with primary key inspection
+* `GET /tasks/` β List retrieval supporting offset or keyset cursor pagination
+* `POST /tasks/search` β Dynamic nested JSON filtering and sorting
+* `POST /tasks/lookup` β Lightweight, column-projected endpoint for relational references
+* `PUT /tasks/{id}` & `PATCH /tasks/{id}` β Complete and partial updates
+* `DELETE /tasks/{id}` β Soft delete (or physical purge via `?force=true`)
+* `GET /tasks/?schema=true` β Dynamic role-aware schema introspection
---
-## ποΈ Core Architectural Patterns
+## ποΈ Core Architectural Components
-1. Context Shielding & Role-Based Field Pruning (Zchema)
+1. Context Shielding & Field-Level Access Control (Zchema)
-### The Problem
-In enterprise applications, different roles require different views of the same data. A Customer must not see wholesale cost prices or internal supplier notes, while a Store Manager requires full financial transparency. Maintaining separate DTOs (`ProductPublic`, `ProductManager`, `ProductCreate`, `ProductAdminUpdate`) creates boilerplate and maintenance overhead.
+### Problem
+Managing multiple versions of the same schema (`ProductCustomerView`, `ProductInternalView`, `ProductAdminUpdate`) to hide sensitive attributes (such as purchase cost or internal remarks) introduces redundancy and increases the risk of accidental data leaks.
-### The ZCore Solution
-Define your model once inheriting from `Zchema`. When a user's security context marks a field as restricted, ZCore automatically prunes it across **input parsing** (preventing mass assignment), **response serialization** (preventing data leakage), and **OpenAPI schema generation**:
+### Solution
+Define the schema once by inheriting from `Zchema`. When fields are marked as restricted within the current request context (`ctx.restricted_fields`), ZCore prunes them across:
+1. **Input validation:** Silently discards restricted fields to prevent mass assignment.
+2. **Output serialization:** Removes restricted attributes before JSON encoding.
+3. **OpenAPI generation:** Omit restricted properties from documentation and dynamic `?schema=true` requests.
```python
import uuid
from decimal import Decimal
-from pydantic import Field
from zcore import Zchema
class ProductResponse(Zchema):
@@ -151,51 +151,50 @@ class ProductResponse(Zchema):
id: uuid.UUID
name: str
price: Decimal
- cost_price: Decimal | None = None # π Sensitive business margin
- supplier_notes: str | None = None # π Internal agreement details
+ cost_price: Decimal | None = None # Internal margin
+ supplier_notes: str | None = None # Vendor agreement notes
```
-* **When a Customer views the product:** `cost_price` and `supplier_notes` are completely omitted from the JSON output. ZCore automatically attaches a `Vary: Authorization, Cookie` header to protect downstream CDNs from cache poisoning.
-* **When a Manager views the product:** All fields are returned transparently.
+When restricted fields are pruned from a response, ZCore appends a `Vary: Authorization, Cookie` header to prevent shared HTTP caches or CDNs from caching privileged data representations for unprivileged clients.
2. Atomic Unit of Work & Deferred Domain Events (UnitOfWork)
-### The Problem
-In distributed or multi-repository architectures, business actions involve multiple persistence steps (e.g., deducting inventory, recording a purchase order, and generating an invoice). If step 2 fails, step 1 must be reverted. Furthermore, if you send an email or emit a domain event *before* the database commit succeeds, a subsequent failure leaves external systems out of sync.
+### Problem
+When business operations touch multiple repositories, issuing separate commits introduces the risk of partial database writes if an intermediate step fails. Additionally, publishing domain events (or sending notifications) before a database transaction physically commits can lead to state inconsistency if the transaction ultimately rolls back.
-### The ZCore Solution
-ZCore implements a re-entrant, depth-aware `UnitOfWork`. Nested transactions safely flush changes to savepoints without prematurely committing, while domain events are buffered and dispatched **only after the root commit succeeds**:
+### Solution
+ZCore implements a depth-aware, re-entrant `UnitOfWork`. Nested scopes flush changes safely without premature commits, while domain events remain buffered and are dispatched only after the root commit succeeds:
```python
from zcore import UnitOfWork
async with UnitOfWork(session=self.session, dispatcher=self.dispatcher) as uow:
- # 1. Adjust inventory
+ # 1. Update inventory
await self.inventory_service.adjust_stock(product_id, -quantity)
- # 2. Persist order
+ # 2. Persist order entity
self.order_repo.add(new_order)
await self.session.flush()
# 3. Buffer domain event
- # If the transaction rolls back, this event is discarded immediately!
+ # If any error occurs before commit completes, this event is discarded.
uow.register_event("order.created", {"order_id": str(new_order.id)})
```
-3. Contract-Driven Decoupling via Inversion of Control (IoC Container)
+3. Constructor-Based Dependency Injection (IoC Container)
-### The Problem
-Modular monoliths frequently suffer from circular imports when domain A (e.g., Orders) needs data from domain B (e.g., Catalog). Importing repositories or models across module boundaries creates tight database coupling.
+### Problem
+In complex applications, deep chains of FastAPI `Depends(...)` can become verbose. Furthermore, domain modules risk circular imports when cross-referencing concrete repository implementations.
-### The ZCore Solution
-Domains communicate strictly through pure Python `Protocols` (Contracts). The IoC Container auto-wires dependencies at runtime using constructor type hints with signature caching:
+### Solution
+The built-in IoC container supports constructor-based auto-wiring via type annotations, caching constructor signatures to minimize reflection overhead. It supports Singleton, Scoped, and Transient lifecycles, and automatically skips parameters with default values unless explicitly registered:
```python
-# contracts/catalog.py (No database imports!)
+# Pure Python contract (contracts/catalog.py)
from typing import Protocol, runtime_checkable
@runtime_checkable
@@ -204,11 +203,11 @@ class ProductContract(Protocol):
```
```python
-# apps/orders/services.py
+# Domain implementation (apps/orders/services.py)
from contracts.catalog import ProductContract
class OrderService(BaseService[Orders]):
- # Pure Python constructor: Auto-wired by ZCore's IoC container!
+ # Dependencies are resolved by constructor type hints
def __init__(self, repository: OrderRepository, inventory: ProductContract):
super().__init__(model=Orders, repository=repository)
self.inventory = inventory
@@ -216,35 +215,45 @@ class OrderService(BaseService[Orders]):
-4. Isolated Background Scopes (@background_task)
+4. Isolated Background Execution (@background_task)
-### The Problem
-FastAPI's built-in `BackgroundTasks` executes callables after sending the HTTP response. If you pass an active request-scoped database session to a background task, the session is closed by the time the worker executes, causing `InterfaceError: Session is closed`.
+### Problem
+FastAPI executes background tasks after completing the HTTP response. If a task receives an `AsyncSession` bound to the finished request lifecycle, the session is often closed by the time execution begins, causing connection errors.
-### The ZCore Solution
-The `@background_task` decorator creates an independent execution scope, provisions a dedicated database session, binds an isolated logging task ID, and auto-resolves annotated dependencies from the container:
+### Solution
+The `@background_task` decorator creates an independent execution scope. It provisions a dedicated database session, isolates context variables, and auto-wires type-annotated dependencies directly from the container:
```python
+import uuid
from zcore import background_task
@background_task
async def generate_invoice_task(
order_id: uuid.UUID,
- order_repo: OrderRepository, # Automatically injected with an isolated session!
+ order_repo: OrderRepository, # Injected with an independent session
) -> None:
order = await order_repo.get(id=order_id)
- # Safely executes without relying on completed HTTP request lifecycles
+ # Operates safely outside of the HTTP request lifecycle
```
-5. Zero-Side-Effect Testing Engine (ZTestClient)
+5. Composite Primary Keys & Keyset Pagination
+
+### Capabilities
+* **Composite Primary Keys:** `BaseRepository` natively handles composite models and PostgreSQL partitioned tables (e.g., `(id, operation_date)`). Operations use tuple-based lookups (`tuple_(*pks).in_(...)`), allowing PostgreSQL to perform partition pruning without full table scans.
+* **Compound Keyset Pagination:** `CursorPagination` supports non-primary-key sorting columns using compound keyset conditions `(col < val) OR (col == val AND pk < last_pk)`. It handles type coercion for `date`, `datetime`, `UUID`, and numeric types.
+* **Mutual Exclusivity:** `SearchRequest` validates against conflicting pagination modes, raising a 422 error if both offset (`page > 1`) and keyset (`cursor`) parameters are supplied simultaneously.
+
+
+
+6. Isolated Testing Harness (ZTestClient)
-### The Problem
-Integration tests that write to a database require tedious teardown scripts or slow table recreation between test cases, leading to flaky test suites.
+### Problem
+Integration tests that write directly to a database typically require slow schema teardowns or manual cleanup scripts to prevent test state leakage.
-### The ZCore Solution
-`ZTestClient` executes tests inside **transactional savepoint rollbacks**. Changes are rolled back immediately upon test completion, keeping test runs fast and deterministic:
+### Solution
+`ZTestClient` coordinates tests within transactional savepoints. Database modifications are automatically rolled back upon test completion, while container bindings and event listeners are restored from snapshots:
```python
import pytest
@@ -255,20 +264,20 @@ from main import app
async def test_order_placement_atomicity():
async with ZTestClient(
app,
- user_id="customer-uuid",
+ user_id="test-user-id",
scopes=["orders:create", "products:view"]
) as client:
response = await client.post("/checkout/orders/", json={...})
assert response.status_code == 201
- # Database is automatically rolled back to its exact pre-test state!
+ # Transaction changes are rolled back automatically upon context exit
```
---
-## π οΈ Interactive Developer Tooling (`zc` CLI)
+## π οΈ Developer Tooling (`zc` CLI)
-ZCore includes an interactive terminal user interface (TUI) powered by Questionary and Rich:
+ZCore provides an interactive command-line interface powered by Questionary and Rich:
```text
$ zc
@@ -285,43 +294,43 @@ $ zc
πͺ exit β Exit CLI
```
-### CLI Command Summary
+### Command Reference
-| Command | Key Flags | Purpose |
+| Command | Key Options | Purpose |
| :--- | :--- | :--- |
-| `zc` | β | Open interactive terminal dashboard |
-| `zc init [name]` | `--db [sqlite\|postgres\|mysql]`, `-y` | Bootstrap project with `.env`, `.venv`, and driver dependencies |
-| `zc startapp [name]` | `--template / --no-template`, `--test`, `-y` | Generate domain module layers (models, schemas, repos, services, routers) |
-| `zc run [app]` | `--host`, `--port`, `--reload`, `--workers`, `--env-file` | Run server with cascading config hierarchy (`CLI > .env > Defaults`) |
-| `zc gensecret` | β | Generate a 64-character cryptographic `SECRET_KEY` |
-| `zc genenv` | `-o