4040 build_object_index ,
4141 resolve_m2m_descriptors ,
4242)
43+ from metaobjects .codegen .generators .tph_plan import TphPlan , is_tph_subtype , tph_plan_for
4344from metaobjects .codegen .instance_artifacts import emits_instance_artifacts
4445from metaobjects .meta .core .field import field_constants as fc
4546from 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
0 commit comments