Skip to content

Commit 1092e78

Browse files
dmealingclaude
andcommitted
feat(fr-035): present-key PATCH tristate — Python port
Green the cross-port `update-explicit-null-clears` gate on Python (both lanes). Python's write path already clears a present null (raw-dict merge through ObjectManager.update), so the only gap was the required-null rejection. - codegen router_generator: emit a module-level `_REQUIRED_FIELDS` frozenset (sibling of the filter allowlist) and guard the generated update handler — an explicit null on a @required key → 400 {"error":"validation"} before the repo call; a present null on a non-required field falls through and clears the column; an omitted required field is untouched. The set is derived from ALL @required fields including field.object/jsonb columns (not just scalars), and the guard is emitted on BOTH the non-TPH handler and the TPH per-subtype handlers (union of base + subtype required fields). - reference server (api_contract_server): the same required-null → 400 guard. - A shared `_py_set_literal(names, frozen=)` helper collapses the set/frozenset literal builder (was copy-pasted for the sort allowlists); byte-identical output, so existing sort goldens are unchanged. Validate-before-existence (guard runs before the repo/404 check) matches the TS generated lane (Zod safeParse → 400 precedes the DB update → 404), so the two ports agree on a required-null PATCH to a missing id. Gate green both Python lanes; full api-contract 46/46, codegen 289 pass (2 pre-existing unrelated staleness-nudge failures), TPH generated 5/5, ruff clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGQ7oSuNcjhsMHWwZzhBwr
1 parent fe592c1 commit 1092e78

2 files changed

Lines changed: 57 additions & 6 deletions

File tree

server/python/src/metaobjects/codegen/generators/router_generator.py

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from metaobjects.apidocs.naming import snake_case as _snake_case
4040
from metaobjects.codegen.constants import generated_header
4141
from metaobjects.codegen.format import ruff_format
42+
from metaobjects.codegen.fr010_field_mapping import is_required
4243
from metaobjects.codegen.generator import EmittedFile, GenContext, Generator, per_entity
4344
from metaobjects.codegen.generators.m2m_codegen import (
4445
M2mDescriptor,
@@ -89,6 +90,24 @@ def _scalar_fields(entity: MetaObject) -> list[MetaField]:
8990
return [f for f in entity.fields() if f.sub_type != fc.FIELD_SUBTYPE_OBJECT]
9091

9192

93+
def _required_field_names(entity: MetaObject) -> list[str]:
94+
"""Every @required field name — scalar AND object/jsonb (unlike
95+
_scalar_fields, which drops object fields for the sort allowlist). FR-035
96+
PATCH-2 guards present-null on any of these, and a @required jsonb column can
97+
be nulled just like a scalar one."""
98+
return [f.name for f in entity.fields() if is_required(f)]
99+
100+
101+
def _py_set_literal(names: list[str], *, frozen: bool = False) -> str:
102+
"""A Python set/frozenset literal from field names, matching the generated
103+
allowlist idiom (one quoted name per line). Empty → ``frozenset()`` / ``set()``."""
104+
empty = "frozenset()" if frozen else "set()"
105+
if not names:
106+
return empty
107+
body = "{\n" + "".join(f' "{name}",\n' for name in names) + "}"
108+
return f"frozenset({body})" if frozen else body
109+
110+
92111
def _pk_py_type(entity: MetaObject) -> PyType:
93112
"""The Python type of the entity's primary-key path/id parameter, derived
94113
from the PK field's declared subtype via ``type_map.py_type_for`` — the same
@@ -300,6 +319,12 @@ def _emit_route_handler(
300319
" dto: dict[str, Any],",
301320
f" repo: Annotated[{repo_class}, Depends(get_repository)],",
302321
") -> Any:",
322+
" # FR-035 PATCH-2: an explicit null on a @required field is a 400 —",
323+
" # a present null on a NON-required field falls through and clears it,",
324+
" # and an OMITTED required field is untouched (never a 400).",
325+
" for _k in _REQUIRED_FIELDS:",
326+
" if _k in dto and dto[_k] is None:",
327+
' return JSONResponse(status_code=400, content={"error": "validation"})',
303328
f" saved = repo.update({pk_param}, dto)",
304329
" if saved is None:",
305330
' return JSONResponse(status_code=404, content={"error": "not_found"})',
@@ -385,9 +410,18 @@ def _render_tph_router(self, entity: MetaObject, plan: TphPlan) -> str:
385410
if f.name not in seen:
386411
seen.add(f.name)
387412
sort_fields.append(f.name)
388-
sort_set_body = "set()" if not sort_fields else (
389-
"{\n" + "".join(f' "{name}",\n' for name in sort_fields) + "}"
390-
)
413+
sort_set_body = _py_set_literal(sort_fields)
414+
# FR-035 PATCH-2: @required fields across the base AND every subtype — an
415+
# explicit null on any of these is a 400 (the per-subtype update handlers
416+
# guard against it before the repo call). Union, stable order.
417+
required_names: list[str] = _required_field_names(entity)
418+
req_seen = set(required_names)
419+
for st in plan.subtypes:
420+
for name in _required_field_names(st.entity):
421+
if name not in req_seen:
422+
req_seen.add(name)
423+
required_names.append(name)
424+
required_set_body = _py_set_literal(required_names, frozen=True)
391425

392426
h = generated_header(short_name, _effective_fqn(entity)).rstrip()
393427
parts: list[str] = []
@@ -427,6 +461,9 @@ def _render_tph_router(self, entity: MetaObject, plan: TphPlan) -> str:
427461
parts.append(f"_SORT_ALLOWLIST: set[str] = {sort_set_body}")
428462
parts.append("")
429463
parts.append("")
464+
parts.append(f"_REQUIRED_FIELDS: frozenset[str] = {required_set_body}")
465+
parts.append("")
466+
parts.append("")
430467
parts.append("def _parse_sort(raw: str) -> _SortClause | None:")
431468
parts.append(' """Parse `field:asc|desc`; return None for malformed / disallowed input."""')
432469
parts.append(' parts = raw.split(":", 1)')
@@ -511,6 +548,10 @@ def list_sig(fn: str, route: str) -> list[str]:
511548
parts.append(" dto: dict[str, Any],")
512549
parts.append(f" repo: Annotated[{repo_class}, Depends(get_repository)],")
513550
parts.append(") -> Any:")
551+
parts.append(" # FR-035 PATCH-2: an explicit null on a @required field is a 400.")
552+
parts.append(" for _k in _REQUIRED_FIELDS:")
553+
parts.append(" if _k in dto and dto[_k] is None:")
554+
parts.append(' return JSONResponse(status_code=400, content={"error": "validation"})')
514555
parts.append(f' saved = repo.update("{val}", {pk_param}, dto)')
515556
parts.append(" if saved is None:")
516557
parts.append(' return JSONResponse(status_code=404, content={"error": "not_found"})')
@@ -600,9 +641,10 @@ def render_router(
600641
ops_const = f"{upper}_FILTER_OPS_BY_FIELD"
601642
allowlist_module = f"{snake}_filter_allowlist"
602643

603-
sort_set_body = "set()" if not sort_fields else (
604-
"{\n" + "".join(f' "{name}",\n' for name in sort_fields) + "}"
605-
)
644+
sort_set_body = _py_set_literal(sort_fields)
645+
# FR-035 PATCH-2: an explicit null on a @required field (scalar or jsonb)
646+
# is a 400 — the update handler guards these before the repo call.
647+
required_set_body = _py_set_literal(_required_field_names(entity), frozen=True)
606648

607649
parts: list[str] = []
608650
parts.append(
@@ -642,6 +684,9 @@ def render_router(
642684
parts.append(f"_SORT_ALLOWLIST: set[str] = {sort_set_body}")
643685
parts.append("")
644686
parts.append("")
687+
parts.append(f"_REQUIRED_FIELDS: frozenset[str] = {required_set_body}")
688+
parts.append("")
689+
parts.append("")
645690
parts.append('def _parse_sort(raw: str) -> _SortClause | None:')
646691
parts.append(' """Parse `field:asc|desc`; return None for malformed / disallowed input."""')
647692
parts.append(' parts = raw.split(":", 1)')

server/python/tests/integration/api_contract_server.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,12 @@ def create_author(dto: dict[str, Any]) -> Any:
418418
@app.patch("/api/authors/{author_id}")
419419
@app.put("/api/authors/{author_id}")
420420
def update_author(author_id: int, dto: dict[str, Any]) -> Any:
421+
# FR-035 PATCH-2: an explicit null on a @required field (name / createdAt,
422+
# per meta.json) is a 400 — a present null on the nullable bio clears it,
423+
# and an omitted required field is simply untouched (never a 400).
424+
for _k in ("name", "createdAt"):
425+
if _k in dto and dto[_k] is None:
426+
return JSONResponse(status_code=400, content={"error": "validation"})
421427
saved = repo.update(author_id, dto)
422428
if saved is None:
423429
return JSONResponse(status_code=404, content={"error": "not_found"})

0 commit comments

Comments
 (0)