Skip to content

Commit 73cbc30

Browse files
dmealingclaude
andcommitted
feat(python): FR-017 Tier 4 — TPH polymorphic api-contract codegen (FastAPI routes)
Python codegen now emits the table-per-hierarchy polymorphic REST surface, mirroring TS/C#: - new codegen/generators/tph_plan.py (mirrors C# TphPlan): detect the @Discriminator base, collect concrete subtypes (name-sorted) with route_segment = @discriminatorValue lowercased, is_tph_base / is_tph_subtype helpers. - router_generator: a TPH discriminator base now emits ONE router with a polymorphic collection at the base path (GET /auths, GET /auths/{id}) PLUS a full per-subtype CRUD set at /auths/<segment> (GET list, POST create — discriminator injected from the URL, GET/PATCH/PUT/DELETE by id). Per-subtype literal routes are registered before the polymorphic /{id:int} so segments aren't shadowed. The repo Protocol is subtype-keyed (the @discriminatorValue, or None for the base). A concrete subtype emits no standalone router (folded into the base). Non-TPH entities are unchanged. Conformance (generated lane, in-memory single-table repo seam, no Testcontainers — mirrors the M:N lane): generated_tph_app.py boots the generated Auth router over HTTP with an in-memory TPH-scoped repo; test_api_contract_tph_generated.py drives the 4 shared fixtures/api-contract-conformance/tph scenarios (polymorphic list/get, per-subtype list/create, per-subtype update/delete, cross-subtype 404) — 4/4 green. Regression: single-entity + M:N generated lanes + all codegen tests 211/211 green. NB harness gotcha: the FastAPI dependency override must be a no-arg closure (`lambda: repo`), not `lambda r=repo: r` — a parameter-bearing override gets mis-bound on body requests (POST/PATCH). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent dcb4826 commit 73cbc30

4 files changed

Lines changed: 611 additions & 0 deletions

File tree

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

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
build_object_index,
4141
resolve_m2m_descriptors,
4242
)
43+
from metaobjects.codegen.generators.tph_plan import TphPlan, is_tph_subtype, tph_plan_for
4344
from metaobjects.codegen.instance_artifacts import emits_instance_artifacts
4445
from metaobjects.meta.core.field import field_constants as fc
4546
from metaobjects.meta.core.field.meta_field import MetaField
@@ -254,6 +255,211 @@ def _emit_m2m_route(
254255
f" return repo.find_related_{d.relation_name}({pk_param})",
255256
]
256257

258+
def _emit_tph_list_body(
259+
self, subtype_expr: str, fields_const: str, ops_const: str, repo_var: str = "repo"
260+
) -> list[str]:
261+
"""The shared list-handler body (sort + filter parse → repo.list). *subtype_expr*
262+
is the Python literal threaded as the discriminator scope: ``None`` for the
263+
polymorphic base, or a quoted ``@discriminatorValue`` for a per-subtype route."""
264+
return [
265+
" actual_limit = limit if limit is not None else 50",
266+
" actual_offset = offset if offset is not None else 0",
267+
" sort_clause: _SortClause | None = None",
268+
" if sort is not None:",
269+
" sort_clause = _parse_sort(sort)",
270+
" if sort_clause is None:",
271+
' return JSONResponse(status_code=400, content={"error": "invalid_sort"})',
272+
f" filter_result = parse_filter(request.query_params, {fields_const}, {ops_const})",
273+
" if filter_result.error_envelope is not None:",
274+
" return JSONResponse(status_code=400, content=filter_result.error_envelope)",
275+
" predicates = filter_result.predicates",
276+
f" rows = {repo_var}.list({subtype_expr}, actual_limit, actual_offset, sort_clause, predicates)",
277+
" if with_count == 1:",
278+
f" total = {repo_var}.count({subtype_expr}, predicates)",
279+
' return {"rows": rows, "total": total}',
280+
" return rows",
281+
]
282+
283+
def _render_tph_router(self, entity: MetaObject, plan: TphPlan) -> str:
284+
"""FR-017 TPH: emit the discriminator base's router — a polymorphic collection
285+
at the base path + a full per-subtype CRUD set at /<base>/<segment>. The repo
286+
seam is subtype-keyed (the ``@discriminatorValue``, or ``None`` for the base);
287+
the consumer's repo applies the single-table discriminator scope."""
288+
short_name = entity.name
289+
snake = _snake_case(short_name)
290+
plural = _plural_lowercase(short_name)
291+
pk_param = f"{snake}_id"
292+
repo_class = f"{short_name}Repository"
293+
upper = short_name.upper()
294+
fields_const = f"{upper}_FILTER_FIELDS"
295+
ops_const = f"{upper}_FILTER_OPS_BY_FIELD"
296+
allowlist_module = f"{snake}_filter_allowlist"
297+
298+
# Sort allowlist = base scalar fields ∪ every subtype's own scalar fields, so a
299+
# per-subtype list can sort on a subtype-only column too. Stable order.
300+
sort_fields: list[str] = [f.name for f in _scalar_fields(entity)]
301+
seen = set(sort_fields)
302+
for st in plan.subtypes:
303+
for f in _scalar_fields(st.entity):
304+
if f.name not in seen:
305+
seen.add(f.name)
306+
sort_fields.append(f.name)
307+
sort_set_body = "set()" if not sort_fields else (
308+
"{\n" + "".join(f' "{name}",\n' for name in sort_fields) + "}"
309+
)
310+
311+
h = generated_header(short_name, _effective_fqn(entity)).rstrip()
312+
parts: list[str] = []
313+
parts.append(
314+
h + "\n"
315+
+ f'"""GENERATED — TPH polymorphic REST router for the {short_name} hierarchy '
316+
+ '(single-table inheritance: polymorphic base + per-subtype CRUD)."""\n'
317+
)
318+
parts.append("from __future__ import annotations")
319+
parts.append("")
320+
parts.append("from typing import Annotated, Any, Protocol")
321+
parts.append("")
322+
parts.append("from fastapi import APIRouter, Depends, Query, Request, status")
323+
parts.append("from fastapi.responses import JSONResponse")
324+
parts.append("from pydantic import BaseModel")
325+
parts.append("")
326+
parts.append("from metaobjects.codegen.runtime.filter_parser import (")
327+
parts.append(" FilterPredicate,")
328+
parts.append(" parse_filter,")
329+
parts.append(")")
330+
parts.append("")
331+
parts.append(f"from .{allowlist_module} import {fields_const}, {ops_const}")
332+
parts.append("")
333+
parts.append(f'router = APIRouter(prefix="/api/{plural}", tags=["{plural}"])')
334+
parts.append("")
335+
parts.append("")
336+
parts.append("class _SortClause(BaseModel):")
337+
parts.append(' """GENERATED — parsed sort directive (field + asc/desc)."""')
338+
parts.append(" field: str")
339+
parts.append(" direction: str")
340+
parts.append("")
341+
parts.append("")
342+
parts.append(f"_SORT_ALLOWLIST: set[str] = {sort_set_body}")
343+
parts.append("")
344+
parts.append("")
345+
parts.append("def _parse_sort(raw: str) -> _SortClause | None:")
346+
parts.append(' """Parse `field:asc|desc`; return None for malformed / disallowed input."""')
347+
parts.append(' parts = raw.split(":", 1)')
348+
parts.append(" if not parts or parts[0] not in _SORT_ALLOWLIST:")
349+
parts.append(" return None")
350+
parts.append(' direction = parts[1].lower() if len(parts) == 2 else "asc"')
351+
parts.append(' if direction not in ("asc", "desc"):')
352+
parts.append(" return None")
353+
parts.append(" return _SortClause(field=parts[0], direction=direction)")
354+
parts.append("")
355+
parts.append("")
356+
# Subtype-keyed repository Protocol (None == the polymorphic base).
357+
parts.append(f"class {repo_class}(Protocol):")
358+
parts.append(' """GENERATED — TPH seam. `subtype` is the @discriminatorValue, or None for')
359+
parts.append(' the polymorphic base; the consumer scopes the single table accordingly."""')
360+
parts.append(" def list(")
361+
parts.append(" self,")
362+
parts.append(" subtype: str | None,")
363+
parts.append(" limit: int,")
364+
parts.append(" offset: int,")
365+
parts.append(" sort: _SortClause | None,")
366+
parts.append(" filters: list[FilterPredicate],")
367+
parts.append(" ) -> list[Any]: ...")
368+
parts.append(" def count(self, subtype: str | None, filters: list[FilterPredicate]) -> int: ...")
369+
parts.append(" def find_by_id(self, subtype: str | None, id: int) -> Any | None: ...")
370+
parts.append(" def create(self, subtype: str, dto: Any) -> Any: ...")
371+
parts.append(" def update(self, subtype: str, id: int, dto: Any) -> Any | None: ...")
372+
parts.append(" def delete(self, subtype: str, id: int) -> bool: ...")
373+
parts.append("")
374+
parts.append("")
375+
parts.append(f"def get_repository() -> {repo_class}:")
376+
parts.append(' """GENERATED — consumer overrides via `app.dependency_overrides[get_repository]`."""')
377+
parts.append(' raise NotImplementedError("Override get_repository via FastAPI dependency_overrides in the consumer app")')
378+
parts.append("")
379+
parts.append("")
380+
381+
def list_sig(fn: str, route: str) -> list[str]:
382+
return [
383+
f'@router.get("{route}")',
384+
f"def {fn}(",
385+
" request: Request,",
386+
f" repo: Annotated[{repo_class}, Depends(get_repository)],",
387+
" limit: int | None = Query(None),",
388+
" offset: int | None = Query(None),",
389+
" sort: str | None = Query(None),",
390+
' with_count: int | None = Query(None, alias="withCount"),',
391+
") -> Any:",
392+
]
393+
394+
# --- Per-subtype routes FIRST (literal segments match before /{id:int}). ---
395+
for st in plan.subtypes:
396+
seg = st.route_segment
397+
val = st.value
398+
sfx = _snake_case(st.entity.name)
399+
parts.extend(list_sig(f"list_{plural}_{sfx}", f"/{seg}"))
400+
parts.extend(self._emit_tph_list_body(f'"{val}"', fields_const, ops_const))
401+
parts.append("")
402+
parts.append("")
403+
parts.append(f'@router.post("/{seg}", status_code=status.HTTP_201_CREATED)')
404+
parts.append(f"def create_{plural}_{sfx}(")
405+
parts.append(" dto: dict[str, Any],")
406+
parts.append(f" repo: Annotated[{repo_class}, Depends(get_repository)],")
407+
parts.append(") -> Any:")
408+
parts.append(f' return repo.create("{val}", dto)')
409+
parts.append("")
410+
parts.append("")
411+
parts.append(f'@router.get("/{seg}/{{{pk_param}}}")')
412+
parts.append(f"def get_{plural}_{sfx}(")
413+
parts.append(f" {pk_param}: int,")
414+
parts.append(f" repo: Annotated[{repo_class}, Depends(get_repository)],")
415+
parts.append(") -> Any:")
416+
parts.append(f' row = repo.find_by_id("{val}", {pk_param})')
417+
parts.append(" if row is None:")
418+
parts.append(' return JSONResponse(status_code=404, content={"error": "not_found"})')
419+
parts.append(" return row")
420+
parts.append("")
421+
parts.append("")
422+
parts.append(f'@router.patch("/{seg}/{{{pk_param}}}")')
423+
parts.append(f'@router.put("/{seg}/{{{pk_param}}}")')
424+
parts.append(f"def update_{plural}_{sfx}(")
425+
parts.append(f" {pk_param}: int,")
426+
parts.append(" dto: dict[str, Any],")
427+
parts.append(f" repo: Annotated[{repo_class}, Depends(get_repository)],")
428+
parts.append(") -> Any:")
429+
parts.append(f' saved = repo.update("{val}", {pk_param}, dto)')
430+
parts.append(" if saved is None:")
431+
parts.append(' return JSONResponse(status_code=404, content={"error": "not_found"})')
432+
parts.append(" return saved")
433+
parts.append("")
434+
parts.append("")
435+
parts.append(f'@router.delete("/{seg}/{{{pk_param}}}", status_code=status.HTTP_204_NO_CONTENT)')
436+
parts.append(f"def delete_{plural}_{sfx}(")
437+
parts.append(f" {pk_param}: int,")
438+
parts.append(f" repo: Annotated[{repo_class}, Depends(get_repository)],")
439+
parts.append(") -> None:")
440+
parts.append(f' if not repo.delete("{val}", {pk_param}):')
441+
parts.append(' return JSONResponse(status_code=404, content={"error": "not_found"})')
442+
parts.append("")
443+
parts.append("")
444+
445+
# --- Polymorphic base routes LAST (so /{id:int} doesn't shadow /<segment>). ---
446+
parts.extend(list_sig(f"list_{plural}", ""))
447+
parts.extend(self._emit_tph_list_body("None", fields_const, ops_const))
448+
parts.append("")
449+
parts.append("")
450+
parts.append(f'@router.get("/{{{pk_param}}}")')
451+
parts.append(f"def get_{snake}(")
452+
parts.append(f" {pk_param}: int,")
453+
parts.append(f" repo: Annotated[{repo_class}, Depends(get_repository)],")
454+
parts.append(") -> Any:")
455+
parts.append(f" row = repo.find_by_id(None, {pk_param})")
456+
parts.append(" if row is None:")
457+
parts.append(' return JSONResponse(status_code=404, content={"error": "not_found"})')
458+
parts.append(" return row")
459+
parts.append("")
460+
461+
return "\n".join(parts)
462+
257463
def render_router(
258464
self, entity: MetaObject, object_index: dict[str, MetaObject] | None = None
259465
) -> str | None:
@@ -273,12 +479,23 @@ def render_router(
273479
"""
274480
if not emits_instance_artifacts(entity):
275481
return None
482+
# FR-017 TPH: a concrete subtype is folded into its base's single table — it
483+
# emits no standalone router (its CRUD lives under the base's per-subtype segment).
484+
if object_index is not None and is_tph_subtype(entity):
485+
return None
276486
src = _primary_source_rdb(entity)
277487
if src is None:
278488
return None
279489
if src.effective_kind() != SOURCE_KIND_TABLE:
280490
return None
281491

492+
# FR-017 TPH: a discriminator base emits a polymorphic collection at the base
493+
# path PLUS a full per-subtype CRUD set at /<base>/<discriminatorValue lowercased>.
494+
if object_index is not None:
495+
plan = tph_plan_for(entity, object_index)
496+
if plan is not None:
497+
return self._render_tph_router(entity, plan)
498+
282499
m2m: list[M2mDescriptor] = (
283500
resolve_m2m_descriptors(entity, object_index)
284501
if object_index is not None
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""FR-017 Tier 4 — codegen-side descriptor for a table-per-hierarchy (TPH) base.
2+
3+
Mirrors the C# ``TphPlan`` / TS ``tph-discriminator.ts``: an ``object.entity``
4+
carrying ``@discriminator`` is the TPH base; concrete entities that ``extends`` it
5+
and declare ``@discriminatorValue`` are its subtypes, all sharing ONE physical
6+
table (single-table inheritance). The plan is the single source of truth every
7+
TPH-aware generator reads, so the route-segment rule and subtype set never drift.
8+
9+
The per-subtype REST route segment is the ``@discriminatorValue`` lowercased
10+
(``"Bridge"`` → ``bridge``, ``"PriorAuth"`` → ``priorauth``) — derived in exactly
11+
one place (:func:`route_segment`).
12+
"""
13+
from __future__ import annotations
14+
15+
from dataclasses import dataclass
16+
17+
from metaobjects.meta.core.object.meta_object import MetaObject
18+
from metaobjects.meta.core.object.object_constants import (
19+
OBJECT_ATTR_DISCRIMINATOR,
20+
OBJECT_ATTR_DISCRIMINATOR_VALUE,
21+
)
22+
23+
24+
@dataclass(frozen=True)
25+
class TphSubtypePlan:
26+
#: The concrete subtype entity.
27+
entity: MetaObject
28+
#: Its ``@discriminatorValue`` (e.g. ``"Bridge"``).
29+
value: str
30+
#: The per-subtype REST route segment (e.g. ``"bridge"``).
31+
route_segment: str
32+
33+
34+
@dataclass(frozen=True)
35+
class TphPlan:
36+
#: The discriminator base entity.
37+
base: MetaObject
38+
#: The discriminator field name (the base's ``@discriminator``).
39+
discriminator_field: str
40+
#: Concrete subtypes in stable (name-sorted) order.
41+
subtypes: list[TphSubtypePlan]
42+
43+
44+
def route_segment(discriminator_value: str) -> str:
45+
"""The per-subtype REST route segment — the ONE place this rule lives: the
46+
discriminator value lowercased."""
47+
return discriminator_value.lower()
48+
49+
50+
def _discriminator_root(obj: MetaObject) -> MetaObject | None:
51+
"""The nearest ``@discriminator``-bearing ancestor (or self), walking ``extends``."""
52+
cursor: MetaObject | None = obj
53+
while cursor is not None:
54+
field = cursor.attr(OBJECT_ATTR_DISCRIMINATOR) # own attr
55+
if isinstance(field, str) and field:
56+
return cursor
57+
cursor = cursor.super_data # type: ignore[assignment]
58+
return None
59+
60+
61+
def is_tph_subtype(obj: MetaObject) -> bool:
62+
"""True when *obj* is a concrete TPH subtype: it declares ``@discriminatorValue``
63+
and (transitively) extends a ``@discriminator``-bearing base. Such an entity emits
64+
NO standalone table/router — it is folded into the base's single table."""
65+
value = obj.attr(OBJECT_ATTR_DISCRIMINATOR_VALUE) # own attr
66+
if not isinstance(value, str) or not value:
67+
return False
68+
root = _discriminator_root(obj)
69+
return root is not None and root is not obj
70+
71+
72+
def tph_plan_for(base: MetaObject, object_index: dict[str, MetaObject]) -> TphPlan | None:
73+
"""The :class:`TphPlan` for a discriminator base, or ``None`` when *base* is not a
74+
discriminator base (no ``@discriminator``, or no concrete subtypes)."""
75+
disc_field = base.attr(OBJECT_ATTR_DISCRIMINATOR) # own attr
76+
if not isinstance(disc_field, str) or not disc_field:
77+
return None
78+
subtypes: list[TphSubtypePlan] = []
79+
for obj in object_index.values():
80+
if obj is base or getattr(obj, "is_abstract", False):
81+
continue
82+
value = obj.attr(OBJECT_ATTR_DISCRIMINATOR_VALUE) # own attr
83+
if not isinstance(value, str) or not value:
84+
continue
85+
# Walk the extends chain looking for `base`.
86+
cursor = obj.super_data
87+
found = False
88+
while cursor is not None:
89+
if cursor is base:
90+
found = True
91+
break
92+
cursor = cursor.super_data
93+
if found:
94+
subtypes.append(TphSubtypePlan(obj, value, route_segment(value)))
95+
if not subtypes:
96+
return None
97+
subtypes.sort(key=lambda s: s.entity.name)
98+
return TphPlan(base, disc_field, subtypes)
99+
100+
101+
def is_tph_base(obj: MetaObject, object_index: dict[str, MetaObject]) -> bool:
102+
"""True when *obj* carries ``@discriminator`` AND has at least one concrete subtype."""
103+
return tph_plan_for(obj, object_index) is not None

0 commit comments

Comments
 (0)