Replace affects_params with three-tier auto-scoping
Remove affects_params lambda. Scoping is now automatic:
Tier 1 - Argument name matching:
If the mutation's args overlap with the context's params by name,
the invalidation is auto-scoped. No developer annotation needed.
@client(context=UserContext)
def user_profile(request, user_id: int) -> UserShape: ...
@client(affects=UserContext)
def update_profile(request, user_id: int, name: str) -> dict: ...
# user_id matches → invalidate: [{context: "user", params: {user_id: 5}}]
Tier 2 - Auth inference (Edge-side, not implemented in framework)
Tier 3 - Broad fallback when no param names match
Also adds function-level affects targeting:
@client(affects='user_profile') # only user_profile, not user_orders
def update_name(request, user_id: int, name: str) -> dict: ...
Function names resolve to their parent context for param lookup.
v1 runtime refetches the whole context regardless, but the protocol
carries the function-level signal for Edge and future optimization.
273 Django tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -159,21 +159,67 @@ def _check_auth_requirement(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_affects_target(target_name: str) -> tuple[str, str, str | None]:
|
||||||
|
"""
|
||||||
|
Determine whether an affects target is a context name or function name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
("context", "user", None) — full context invalidation
|
||||||
|
("function", "user_profile", "user") — function within context
|
||||||
|
"""
|
||||||
|
groups = get_context_groups()
|
||||||
|
|
||||||
|
# Check if it's a context name directly
|
||||||
|
if target_name in groups:
|
||||||
|
return ("context", target_name, None)
|
||||||
|
|
||||||
|
# Check if it's a function name within a context
|
||||||
|
for ctx_name, fn_names in groups.items():
|
||||||
|
if target_name in fn_names:
|
||||||
|
return ("function", target_name, ctx_name)
|
||||||
|
|
||||||
|
# Not a context or context function — treat as context name anyway
|
||||||
|
# (it might be a non-context function or an as-yet-unregistered context)
|
||||||
|
return ("context", target_name, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_context_param_names(context_name: str) -> set[str]:
|
||||||
|
"""
|
||||||
|
Get the set of parameter names used by functions in a context.
|
||||||
|
|
||||||
|
Returns the union of all Input field names across context functions.
|
||||||
|
"""
|
||||||
|
groups = get_context_groups()
|
||||||
|
fn_names = groups.get(context_name, [])
|
||||||
|
param_names: set[str] = set()
|
||||||
|
|
||||||
|
for fn_name in fn_names:
|
||||||
|
fn_cls = get_function(fn_name)
|
||||||
|
if fn_cls is None:
|
||||||
|
continue
|
||||||
|
input_cls = getattr(fn_cls, "Input", None)
|
||||||
|
if input_cls and input_cls is not BaseModel and hasattr(input_cls, "model_fields"):
|
||||||
|
param_names.update(input_cls.model_fields.keys())
|
||||||
|
|
||||||
|
return param_names
|
||||||
|
|
||||||
|
|
||||||
def _resolve_invalidation(
|
def _resolve_invalidation(
|
||||||
view_class: type | None,
|
view_class: type | None,
|
||||||
request: HttpRequest | None = None,
|
input_data: dict[str, Any] | None = None,
|
||||||
) -> list[str | dict[str, Any]] | None:
|
) -> list[str | dict[str, Any]] | None:
|
||||||
"""
|
"""
|
||||||
Resolve the invalidation targets from a function's affects metadata.
|
Resolve invalidation targets with three-tier auto-scoping.
|
||||||
|
|
||||||
If affects_params is declared, calls it with the request to produce
|
Tier 1: Argument name matching — if the mutation's input args overlap
|
||||||
scoped invalidation entries.
|
with the context's params by name, auto-scope.
|
||||||
|
Tier 2: Auth inference — Edge-side concern, not handled here.
|
||||||
|
Tier 3: Broad fallback — invalidate all instances.
|
||||||
|
|
||||||
Returns a list suitable for both JSON body and header serialization:
|
Also handles function-level targeting: affects='user_profile' resolves
|
||||||
- Simple: ["user", "notifications"]
|
to the function name (v1: runtime refetches the whole context anyway).
|
||||||
- Scoped: [{"context": "user", "params": {"user_id": 5}}]
|
|
||||||
- Mixed: ["notifications", {"context": "user", "params": {"user_id": 5}}]
|
|
||||||
|
|
||||||
|
Returns a list suitable for both JSON body and header serialization.
|
||||||
Returns None if no invalidation needed.
|
Returns None if no invalidation needed.
|
||||||
"""
|
"""
|
||||||
if view_class is None:
|
if view_class is None:
|
||||||
@@ -184,35 +230,41 @@ def _resolve_invalidation(
|
|||||||
if not affects:
|
if not affects:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Resolve context names from affects targets
|
result = []
|
||||||
context_names = []
|
seen = set()
|
||||||
|
|
||||||
for target in affects:
|
for target in affects:
|
||||||
if target["type"] == "context":
|
if target["type"] == "context":
|
||||||
context_names.append(target["name"])
|
target_name = target["name"]
|
||||||
elif target["type"] == "function" and target.get("context"):
|
elif target["type"] == "function" and target.get("context"):
|
||||||
context_names.append(target["context"])
|
# Function-level: use the function name as the invalidation key
|
||||||
|
target_name = target["name"]
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
if not context_names:
|
if target_name in seen:
|
||||||
return None
|
continue
|
||||||
|
seen.add(target_name)
|
||||||
|
|
||||||
# Dedupe while preserving order
|
# Resolve the context this target belongs to (for param lookup)
|
||||||
context_names = list(dict.fromkeys(context_names))
|
resolved = _resolve_affects_target(target_name)
|
||||||
|
ctx_for_params = resolved[2] if resolved[0] == "function" else resolved[1]
|
||||||
|
|
||||||
# If affects_params is declared, produce scoped entries
|
# Tier 1: argument name matching
|
||||||
affects_params_fn = meta.get("affects_params")
|
if input_data and ctx_for_params:
|
||||||
if affects_params_fn and request is not None:
|
context_params = _get_context_param_names(ctx_for_params)
|
||||||
try:
|
matched = {
|
||||||
params = affects_params_fn(request)
|
k: v for k, v in input_data.items()
|
||||||
if params and isinstance(params, dict):
|
if k in context_params
|
||||||
return [
|
}
|
||||||
{"context": name, "params": params}
|
if matched:
|
||||||
for name in context_names
|
result.append({"context": target_name, "params": matched})
|
||||||
]
|
continue
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"affects_params callable failed: {e}")
|
|
||||||
# Fall through to broad invalidation
|
|
||||||
|
|
||||||
return context_names
|
# Tier 3: broad fallback
|
||||||
|
result.append(target_name)
|
||||||
|
|
||||||
|
return result if result else None
|
||||||
|
|
||||||
|
|
||||||
def _format_invalidate_header(
|
def _format_invalidate_header(
|
||||||
@@ -568,7 +620,7 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
|
|||||||
# Build response with server-driven invalidation (both transports)
|
# Build response with server-driven invalidation (both transports)
|
||||||
view_class = get_function(fn_name)
|
view_class = get_function(fn_name)
|
||||||
response_data = {"result": result.data}
|
response_data = {"result": result.data}
|
||||||
invalidate_contexts = _resolve_invalidation(view_class, request)
|
invalidate_contexts = _resolve_invalidation(view_class, input_data)
|
||||||
|
|
||||||
if invalidate_contexts:
|
if invalidate_contexts:
|
||||||
response_data["invalidate"] = invalidate_contexts
|
response_data["invalidate"] = invalidate_contexts
|
||||||
|
|||||||
@@ -251,7 +251,6 @@ def client(
|
|||||||
*,
|
*,
|
||||||
context: ContextMode = False,
|
context: ContextMode = False,
|
||||||
affects: AffectsMode = None,
|
affects: AffectsMode = None,
|
||||||
affects_params: Callable[[Any], dict[str, Any]] | None = None,
|
|
||||||
websocket: bool = False,
|
websocket: bool = False,
|
||||||
auth: bool | str | Callable[[Any], bool] | None = None,
|
auth: bool | str | Callable[[Any], bool] | None = None,
|
||||||
) -> type[ServerFunction] | Callable[[Callable], type[ServerFunction]]:
|
) -> type[ServerFunction] | Callable[[Callable], type[ServerFunction]]:
|
||||||
@@ -264,16 +263,14 @@ def client(
|
|||||||
- ReactContext instance: groups functions into a named context.
|
- ReactContext instance: groups functions into a named context.
|
||||||
- GlobalContext: reserved, auto-mounted at root, SSR-hydrated.
|
- GlobalContext: reserved, auto-mounted at root, SSR-hydrated.
|
||||||
|
|
||||||
affects: Declare which contexts this mutation invalidates.
|
affects: Declare which contexts or functions this mutation invalidates.
|
||||||
- A ReactContext instance or list of them
|
- A ReactContext instance or context name string: invalidates entire context
|
||||||
|
- A function name string: invalidates just that function within its context
|
||||||
|
- A list of the above: invalidates multiple targets
|
||||||
Mutually exclusive with context=.
|
Mutually exclusive with context=.
|
||||||
|
|
||||||
affects_params: Callable that extracts scoped invalidation params.
|
Scoping is automatic: if the mutation's arguments overlap with the
|
||||||
Called with the request after function execution.
|
context's params by name, the invalidation is scoped to those values.
|
||||||
Returns a dict of params that scope the invalidation.
|
|
||||||
Produces: invalidate: [{context: "user", params: {user_id: 5}}]
|
|
||||||
And header: X-Mizan-Invalidate: user;user_id=5
|
|
||||||
Requires affects= to be set.
|
|
||||||
|
|
||||||
websocket: Enable WebSocket RPC transport (default: False).
|
websocket: Enable WebSocket RPC transport (default: False).
|
||||||
|
|
||||||
@@ -289,12 +286,17 @@ def client(
|
|||||||
@client(context=UserContext)
|
@client(context=UserContext)
|
||||||
def user_profile(request, user_id: int) -> ProfileOutput: ...
|
def user_profile(request, user_id: int) -> ProfileOutput: ...
|
||||||
|
|
||||||
|
# Broad invalidation — all UserContext instances
|
||||||
@client(affects=UserContext)
|
@client(affects=UserContext)
|
||||||
def edit_profile(request, name: str) -> dict: ...
|
def reset_all_profiles(request) -> dict: ...
|
||||||
|
|
||||||
# Scoped: only invalidate user context for this specific user
|
# Auto-scoped — user_id matches, only invalidates UserContext(user_id=5)
|
||||||
@client(affects=UserContext, affects_params=lambda req: {'user_id': req.user.pk})
|
@client(affects=UserContext)
|
||||||
def update_avatar(request, url: str) -> dict: ...
|
def update_profile(request, user_id: int, name: str) -> dict: ...
|
||||||
|
|
||||||
|
# Function-level — only user_profile refetches, not user_orders
|
||||||
|
@client(affects='user_profile')
|
||||||
|
def update_name(request, user_id: int, name: str) -> dict: ...
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A ServerFunction class that wraps the function
|
A ServerFunction class that wraps the function
|
||||||
@@ -310,12 +312,6 @@ def client(
|
|||||||
"A function cannot be both a context reader and a mutation."
|
"A function cannot be both a context reader and a mutation."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate affects_params
|
|
||||||
if affects_params is not None and affects is None:
|
|
||||||
raise ValueError(
|
|
||||||
"affects_params= requires affects= to be set."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate auth parameter
|
# Validate auth parameter
|
||||||
if auth is not None:
|
if auth is not None:
|
||||||
if isinstance(auth, str) and auth not in _VALID_AUTH_STRINGS:
|
if isinstance(auth, str) and auth not in _VALID_AUTH_STRINGS:
|
||||||
@@ -326,14 +322,14 @@ def client(
|
|||||||
|
|
||||||
def decorator(fn: Callable) -> type[ServerFunction]:
|
def decorator(fn: Callable) -> type[ServerFunction]:
|
||||||
return _create_server_function(
|
return _create_server_function(
|
||||||
fn, context=resolved_context, affects=affects, affects_params=affects_params,
|
fn, context=resolved_context, affects=affects,
|
||||||
websocket=websocket, auth=auth,
|
websocket=websocket, auth=auth,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Support both @client and @client(...)
|
# Support both @client and @client(...)
|
||||||
if fn is not None:
|
if fn is not None:
|
||||||
return _create_server_function(
|
return _create_server_function(
|
||||||
fn, context=resolved_context, affects=affects, affects_params=affects_params,
|
fn, context=resolved_context, affects=affects,
|
||||||
websocket=websocket, auth=auth,
|
websocket=websocket, auth=auth,
|
||||||
)
|
)
|
||||||
return decorator
|
return decorator
|
||||||
@@ -372,7 +368,6 @@ def _create_server_function(
|
|||||||
*,
|
*,
|
||||||
context: str | Literal[False] = False,
|
context: str | Literal[False] = False,
|
||||||
affects: str | type["ServerFunction"] | list[str | type["ServerFunction"]] | None = None,
|
affects: str | type["ServerFunction"] | list[str | type["ServerFunction"]] | None = None,
|
||||||
affects_params: Callable | None = None,
|
|
||||||
websocket: bool = False,
|
websocket: bool = False,
|
||||||
auth: bool | str | None = None,
|
auth: bool | str | None = None,
|
||||||
) -> type[ServerFunction]:
|
) -> type[ServerFunction]:
|
||||||
@@ -476,8 +471,6 @@ def _create_server_function(
|
|||||||
normalized_affects = _normalize_affects(affects)
|
normalized_affects = _normalize_affects(affects)
|
||||||
if normalized_affects:
|
if normalized_affects:
|
||||||
meta["affects"] = normalized_affects
|
meta["affects"] = normalized_affects
|
||||||
if affects_params is not None:
|
|
||||||
meta["affects_params"] = affects_params
|
|
||||||
|
|
||||||
# WebSocket: enable WebSocket transport
|
# WebSocket: enable WebSocket transport
|
||||||
if websocket:
|
if websocket:
|
||||||
|
|||||||
@@ -829,55 +829,112 @@ class ServerDrivenInvalidationTests(TestCase):
|
|||||||
self.assertEqual(data["invalidate"], ["user", "notifications"])
|
self.assertEqual(data["invalidate"], ["user", "notifications"])
|
||||||
self.assertEqual(response["X-Mizan-Invalidate"], "user, notifications")
|
self.assertEqual(response["X-Mizan-Invalidate"], "user, notifications")
|
||||||
|
|
||||||
def test_scoped_invalidation_with_affects_params(self):
|
def test_auto_scoped_invalidation(self):
|
||||||
"""affects_params produces scoped invalidation in body and header."""
|
"""Mutation args overlapping with context params auto-scope."""
|
||||||
from mizan.client.executor import function_call_view
|
from mizan.client.executor import function_call_view
|
||||||
from django.contrib.auth.models import User
|
|
||||||
|
|
||||||
UserCtx = ReactContext("user")
|
UserCtx = ReactContext("user")
|
||||||
|
|
||||||
@client(
|
@client(context=UserCtx)
|
||||||
affects=UserCtx,
|
def user_profile(request: HttpRequest, user_id: int) -> ValidOutput:
|
||||||
affects_params=lambda req: {"user_id": getattr(req.user, "pk", 0)},
|
|
||||||
auth=True,
|
|
||||||
)
|
|
||||||
def update_avatar(request: HttpRequest, url: str) -> ValidOutput:
|
|
||||||
return ValidOutput(valid=True)
|
return ValidOutput(valid=True)
|
||||||
|
|
||||||
register(update_avatar, "update_avatar")
|
@client(affects=UserCtx)
|
||||||
|
def update_profile(request: HttpRequest, user_id: int, name: str) -> ValidOutput:
|
||||||
|
return ValidOutput(valid=True)
|
||||||
|
|
||||||
|
register(user_profile, "user_profile")
|
||||||
|
register(update_profile, "update_profile")
|
||||||
|
|
||||||
request = self.factory.post(
|
request = self.factory.post(
|
||||||
"/api/mizan/call/",
|
"/api/mizan/call/",
|
||||||
json.dumps({"fn": "update_avatar", "args": {"url": "https://example.com/pic.jpg"}}),
|
json.dumps({"fn": "update_profile", "args": {"user_id": 5, "name": "Ryth"}}),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
# Create a mock user with pk
|
request.user = AnonymousUser()
|
||||||
user = MagicMock()
|
|
||||||
user.pk = 42
|
|
||||||
user.is_authenticated = True
|
|
||||||
request.user = user
|
|
||||||
request._dont_enforce_csrf_checks = True
|
request._dont_enforce_csrf_checks = True
|
||||||
|
|
||||||
response = function_call_view(request)
|
response = function_call_view(request)
|
||||||
data = json.loads(response.content)
|
data = json.loads(response.content)
|
||||||
|
|
||||||
# Scoped invalidation in JSON body
|
# Auto-scoped: user_id matched between mutation args and context params
|
||||||
self.assertEqual(len(data["invalidate"]), 1)
|
self.assertEqual(len(data["invalidate"]), 1)
|
||||||
self.assertEqual(data["invalidate"][0]["context"], "user")
|
self.assertEqual(data["invalidate"][0]["context"], "user")
|
||||||
self.assertEqual(data["invalidate"][0]["params"]["user_id"], 42)
|
self.assertEqual(data["invalidate"][0]["params"]["user_id"], 5)
|
||||||
|
self.assertEqual(response["X-Mizan-Invalidate"], "user;user_id=5")
|
||||||
|
|
||||||
# Scoped invalidation in header
|
def test_broad_invalidation_no_matching_args(self):
|
||||||
self.assertEqual(response["X-Mizan-Invalidate"], "user;user_id=42")
|
"""Mutation with no matching args falls back to broad invalidation."""
|
||||||
|
from mizan.client.executor import function_call_view
|
||||||
|
|
||||||
def test_affects_params_without_affects_raises(self):
|
UserCtx = ReactContext("user")
|
||||||
"""affects_params without affects raises ValueError."""
|
|
||||||
with self.assertRaises(ValueError) as cm:
|
|
||||||
|
|
||||||
@client(affects_params=lambda req: {"user_id": 1})
|
@client(context=UserCtx)
|
||||||
def bad(request: HttpRequest) -> ValidOutput:
|
def user_profile(request: HttpRequest, user_id: int) -> ValidOutput:
|
||||||
return ValidOutput(valid=True)
|
return ValidOutput(valid=True)
|
||||||
|
|
||||||
self.assertIn("requires affects", str(cm.exception))
|
# url doesn't match any context param
|
||||||
|
@client(affects=UserCtx)
|
||||||
|
def update_avatar(request: HttpRequest, url: str) -> ValidOutput:
|
||||||
|
return ValidOutput(valid=True)
|
||||||
|
|
||||||
|
register(user_profile, "user_profile")
|
||||||
|
register(update_avatar, "update_avatar")
|
||||||
|
|
||||||
|
request = self.factory.post(
|
||||||
|
"/api/mizan/call/",
|
||||||
|
json.dumps({"fn": "update_avatar", "args": {"url": "pic.jpg"}}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
request.user = AnonymousUser()
|
||||||
|
request._dont_enforce_csrf_checks = True
|
||||||
|
|
||||||
|
response = function_call_view(request)
|
||||||
|
data = json.loads(response.content)
|
||||||
|
|
||||||
|
# Broad: no param match
|
||||||
|
self.assertEqual(data["invalidate"], ["user"])
|
||||||
|
self.assertEqual(response["X-Mizan-Invalidate"], "user")
|
||||||
|
|
||||||
|
def test_function_level_affects(self):
|
||||||
|
"""affects='user_profile' targets a specific function, not the whole context."""
|
||||||
|
from mizan.client.executor import function_call_view
|
||||||
|
|
||||||
|
UserCtx = ReactContext("user")
|
||||||
|
|
||||||
|
@client(context=UserCtx)
|
||||||
|
def user_profile(request: HttpRequest, user_id: int) -> ValidOutput:
|
||||||
|
return ValidOutput(valid=True)
|
||||||
|
|
||||||
|
@client(context=UserCtx)
|
||||||
|
def user_orders(request: HttpRequest, user_id: int) -> ValidOutput:
|
||||||
|
return ValidOutput(valid=True)
|
||||||
|
|
||||||
|
# Targets user_profile specifically, not the whole 'user' context
|
||||||
|
@client(affects="user_profile")
|
||||||
|
def update_name(request: HttpRequest, user_id: int, name: str) -> ValidOutput:
|
||||||
|
return ValidOutput(valid=True)
|
||||||
|
|
||||||
|
register(user_profile, "user_profile")
|
||||||
|
register(user_orders, "user_orders")
|
||||||
|
register(update_name, "update_name")
|
||||||
|
|
||||||
|
request = self.factory.post(
|
||||||
|
"/api/mizan/call/",
|
||||||
|
json.dumps({"fn": "update_name", "args": {"user_id": 7, "name": "Ryth"}}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
request.user = AnonymousUser()
|
||||||
|
request._dont_enforce_csrf_checks = True
|
||||||
|
|
||||||
|
response = function_call_view(request)
|
||||||
|
data = json.loads(response.content)
|
||||||
|
|
||||||
|
# Function-level + auto-scoped
|
||||||
|
self.assertEqual(len(data["invalidate"]), 1)
|
||||||
|
self.assertEqual(data["invalidate"][0]["context"], "user_profile")
|
||||||
|
self.assertEqual(data["invalidate"][0]["params"]["user_id"], 7)
|
||||||
|
self.assertEqual(response["X-Mizan-Invalidate"], "user_profile;user_id=7")
|
||||||
|
|
||||||
def test_mutation_without_affects_has_no_invalidate(self):
|
def test_mutation_without_affects_has_no_invalidate(self):
|
||||||
"""Mutation without affects= returns result only."""
|
"""Mutation without affects= returns result only."""
|
||||||
|
|||||||
Reference in New Issue
Block a user