|
39 | 39 | from metaobjects.apidocs.naming import snake_case as _snake_case |
40 | 40 | from metaobjects.codegen.constants import generated_header |
41 | 41 | from metaobjects.codegen.format import ruff_format |
| 42 | +from metaobjects.codegen.fr010_field_mapping import is_required |
42 | 43 | from metaobjects.codegen.generator import EmittedFile, GenContext, Generator, per_entity |
43 | 44 | from metaobjects.codegen.generators.m2m_codegen import ( |
44 | 45 | M2mDescriptor, |
@@ -89,6 +90,24 @@ def _scalar_fields(entity: MetaObject) -> list[MetaField]: |
89 | 90 | return [f for f in entity.fields() if f.sub_type != fc.FIELD_SUBTYPE_OBJECT] |
90 | 91 |
|
91 | 92 |
|
| 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 | + |
92 | 111 | def _pk_py_type(entity: MetaObject) -> PyType: |
93 | 112 | """The Python type of the entity's primary-key path/id parameter, derived |
94 | 113 | from the PK field's declared subtype via ``type_map.py_type_for`` — the same |
@@ -300,6 +319,12 @@ def _emit_route_handler( |
300 | 319 | " dto: dict[str, Any],", |
301 | 320 | f" repo: Annotated[{repo_class}, Depends(get_repository)],", |
302 | 321 | ") -> 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"})', |
303 | 328 | f" saved = repo.update({pk_param}, dto)", |
304 | 329 | " if saved is None:", |
305 | 330 | ' return JSONResponse(status_code=404, content={"error": "not_found"})', |
@@ -385,9 +410,18 @@ def _render_tph_router(self, entity: MetaObject, plan: TphPlan) -> str: |
385 | 410 | if f.name not in seen: |
386 | 411 | seen.add(f.name) |
387 | 412 | 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) |
391 | 425 |
|
392 | 426 | h = generated_header(short_name, _effective_fqn(entity)).rstrip() |
393 | 427 | parts: list[str] = [] |
@@ -427,6 +461,9 @@ def _render_tph_router(self, entity: MetaObject, plan: TphPlan) -> str: |
427 | 461 | parts.append(f"_SORT_ALLOWLIST: set[str] = {sort_set_body}") |
428 | 462 | parts.append("") |
429 | 463 | parts.append("") |
| 464 | + parts.append(f"_REQUIRED_FIELDS: frozenset[str] = {required_set_body}") |
| 465 | + parts.append("") |
| 466 | + parts.append("") |
430 | 467 | parts.append("def _parse_sort(raw: str) -> _SortClause | None:") |
431 | 468 | parts.append(' """Parse `field:asc|desc`; return None for malformed / disallowed input."""') |
432 | 469 | parts.append(' parts = raw.split(":", 1)') |
@@ -511,6 +548,10 @@ def list_sig(fn: str, route: str) -> list[str]: |
511 | 548 | parts.append(" dto: dict[str, Any],") |
512 | 549 | parts.append(f" repo: Annotated[{repo_class}, Depends(get_repository)],") |
513 | 550 | 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"})') |
514 | 555 | parts.append(f' saved = repo.update("{val}", {pk_param}, dto)') |
515 | 556 | parts.append(" if saved is None:") |
516 | 557 | parts.append(' return JSONResponse(status_code=404, content={"error": "not_found"})') |
@@ -600,9 +641,10 @@ def render_router( |
600 | 641 | ops_const = f"{upper}_FILTER_OPS_BY_FIELD" |
601 | 642 | allowlist_module = f"{snake}_filter_allowlist" |
602 | 643 |
|
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) |
606 | 648 |
|
607 | 649 | parts: list[str] = [] |
608 | 650 | parts.append( |
@@ -642,6 +684,9 @@ def render_router( |
642 | 684 | parts.append(f"_SORT_ALLOWLIST: set[str] = {sort_set_body}") |
643 | 685 | parts.append("") |
644 | 686 | parts.append("") |
| 687 | + parts.append(f"_REQUIRED_FIELDS: frozenset[str] = {required_set_body}") |
| 688 | + parts.append("") |
| 689 | + parts.append("") |
645 | 690 | parts.append('def _parse_sort(raw: str) -> _SortClause | None:') |
646 | 691 | parts.append(' """Parse `field:asc|desc`; return None for malformed / disallowed input."""') |
647 | 692 | parts.append(' parts = raw.split(":", 1)') |
|
0 commit comments