metaobjects 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. metaobjects/__init__.py +75 -0
  2. metaobjects/agent_context/__init__.py +55 -0
  3. metaobjects/agent_context/_content/README.md +14 -0
  4. metaobjects/agent_context/_content/servers/csharp.meta.json +5 -0
  5. metaobjects/agent_context/_content/servers/java.meta.json +5 -0
  6. metaobjects/agent_context/_content/servers/kotlin.meta.json +5 -0
  7. metaobjects/agent_context/_content/servers/python.meta.json +5 -0
  8. metaobjects/agent_context/_content/servers/typescript.meta.json +5 -0
  9. metaobjects/agent_context/_content/skills/metaobjects-authoring/SKILL.md +301 -0
  10. metaobjects/agent_context/_content/skills/metaobjects-codegen/SKILL.md +99 -0
  11. metaobjects/agent_context/_content/skills/metaobjects-codegen/references/csharp.md +87 -0
  12. metaobjects/agent_context/_content/skills/metaobjects-codegen/references/java.md +94 -0
  13. metaobjects/agent_context/_content/skills/metaobjects-codegen/references/kotlin.md +110 -0
  14. metaobjects/agent_context/_content/skills/metaobjects-codegen/references/typescript.md +135 -0
  15. metaobjects/agent_context/_content/skills/metaobjects-prompts/SKILL.md +148 -0
  16. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/csharp.md +110 -0
  17. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/java.md +108 -0
  18. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/kotlin.md +130 -0
  19. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/python.md +116 -0
  20. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/typescript.md +150 -0
  21. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/SKILL.md +130 -0
  22. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/java.md +96 -0
  23. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/kotlin.md +99 -0
  24. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/react.md +86 -0
  25. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/tanstack.md +119 -0
  26. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/typescript.md +92 -0
  27. metaobjects/agent_context/_content/skills/metaobjects-verify/SKILL.md +107 -0
  28. metaobjects/agent_context/_content/skills/metaobjects-verify/references/migration.md +72 -0
  29. metaobjects/agent_context/_content/templates/always-on.md.mustache +27 -0
  30. metaobjects/agent_context/assemble.py +133 -0
  31. metaobjects/agent_context/content_root.py +54 -0
  32. metaobjects/agent_context/scaffold.py +191 -0
  33. metaobjects/agent_context/types.py +44 -0
  34. metaobjects/attr_class_map.py +23 -0
  35. metaobjects/cli.py +696 -0
  36. metaobjects/codegen/__init__.py +0 -0
  37. metaobjects/codegen/config.py +11 -0
  38. metaobjects/codegen/constants.py +13 -0
  39. metaobjects/codegen/extract_delegate_emitter.py +384 -0
  40. metaobjects/codegen/extract_schema_emitter.py +139 -0
  41. metaobjects/codegen/format.py +31 -0
  42. metaobjects/codegen/fr010_field_mapping.py +220 -0
  43. metaobjects/codegen/generator.py +62 -0
  44. metaobjects/codegen/generator_registry.py +163 -0
  45. metaobjects/codegen/generators/__init__.py +0 -0
  46. metaobjects/codegen/generators/entity_model.py +263 -0
  47. metaobjects/codegen/generators/extractor_generator.py +317 -0
  48. metaobjects/codegen/generators/filter_allowlist_generator.py +309 -0
  49. metaobjects/codegen/generators/m2m_codegen.py +192 -0
  50. metaobjects/codegen/generators/output_parser_generator.py +272 -0
  51. metaobjects/codegen/generators/output_prompt_generator.py +192 -0
  52. metaobjects/codegen/generators/payload_vo_generator.py +672 -0
  53. metaobjects/codegen/generators/render_helper_generator.py +451 -0
  54. metaobjects/codegen/generators/router_generator.py +635 -0
  55. metaobjects/codegen/generators/template_generator.py +70 -0
  56. metaobjects/codegen/generators/tph_plan.py +120 -0
  57. metaobjects/codegen/generators/trace_helper_generator.py +336 -0
  58. metaobjects/codegen/instance_artifacts.py +15 -0
  59. metaobjects/codegen/output_format_spec_emitter.py +79 -0
  60. metaobjects/codegen/overwrite_policy.py +27 -0
  61. metaobjects/codegen/runner.py +110 -0
  62. metaobjects/codegen/runtime/__init__.py +6 -0
  63. metaobjects/codegen/runtime/filter_parser.py +193 -0
  64. metaobjects/codegen/type_map.py +84 -0
  65. metaobjects/core_types.py +809 -0
  66. metaobjects/datatype.py +19 -0
  67. metaobjects/documentation/__init__.py +28 -0
  68. metaobjects/documentation/doc_constants.py +20 -0
  69. metaobjects/documentation/doc_provider.py +20 -0
  70. metaobjects/documentation/doc_schema.py +24 -0
  71. metaobjects/errors.py +124 -0
  72. metaobjects/loader/__init__.py +0 -0
  73. metaobjects/loader/merge.py +287 -0
  74. metaobjects/loader/meta_data_loader.py +245 -0
  75. metaobjects/loader/sources/__init__.py +24 -0
  76. metaobjects/loader/sources/directory_source.py +50 -0
  77. metaobjects/loader/sources/file_source.py +41 -0
  78. metaobjects/loader/sources/meta_data_source.py +67 -0
  79. metaobjects/loader/sources/uri_source.py +56 -0
  80. metaobjects/loader/validate_discriminator.py +181 -0
  81. metaobjects/loader/validate_field_readonly.py +146 -0
  82. metaobjects/loader/validate_source_parameter_ref.py +159 -0
  83. metaobjects/loader/validate_source_physical_names.py +140 -0
  84. metaobjects/loader/validation_passes.py +1513 -0
  85. metaobjects/meta/__init__.py +1 -0
  86. metaobjects/meta/core/__init__.py +0 -0
  87. metaobjects/meta/core/attr/__init__.py +0 -0
  88. metaobjects/meta/core/attr/attr_constants.py +31 -0
  89. metaobjects/meta/core/attr/meta_attr.py +136 -0
  90. metaobjects/meta/core/field/__init__.py +0 -0
  91. metaobjects/meta/core/field/field_constants.py +105 -0
  92. metaobjects/meta/core/field/meta_field.py +76 -0
  93. metaobjects/meta/core/identity/__init__.py +0 -0
  94. metaobjects/meta/core/identity/identity_constants.py +19 -0
  95. metaobjects/meta/core/identity/meta_identity.py +8 -0
  96. metaobjects/meta/core/object/__init__.py +0 -0
  97. metaobjects/meta/core/object/meta_object.py +65 -0
  98. metaobjects/meta/core/object/meta_object_aware.py +43 -0
  99. metaobjects/meta/core/object/object_class_registry.py +56 -0
  100. metaobjects/meta/core/object/object_constants.py +13 -0
  101. metaobjects/meta/core/object/object_extract.py +400 -0
  102. metaobjects/meta/core/object/value_object.py +70 -0
  103. metaobjects/meta/core/relationship/__init__.py +0 -0
  104. metaobjects/meta/core/relationship/derive_m2m_fields.py +180 -0
  105. metaobjects/meta/core/relationship/meta_relationship.py +54 -0
  106. metaobjects/meta/core/relationship/relationship_constants.py +51 -0
  107. metaobjects/meta/core/validator/__init__.py +0 -0
  108. metaobjects/meta/core/validator/validator_constants.py +18 -0
  109. metaobjects/meta/meta_data.py +206 -0
  110. metaobjects/meta/meta_root.py +8 -0
  111. metaobjects/meta/persistence/__init__.py +0 -0
  112. metaobjects/meta/persistence/db/__init__.py +1 -0
  113. metaobjects/meta/persistence/db/db_constants.py +41 -0
  114. metaobjects/meta/persistence/db/db_provider.py +60 -0
  115. metaobjects/meta/persistence/origin/__init__.py +0 -0
  116. metaobjects/meta/persistence/origin/meta_origin.py +8 -0
  117. metaobjects/meta/persistence/origin/origin_constants.py +20 -0
  118. metaobjects/meta/persistence/source/__init__.py +0 -0
  119. metaobjects/meta/persistence/source/meta_source.py +137 -0
  120. metaobjects/meta/persistence/source/source_constants.py +115 -0
  121. metaobjects/meta/presentation/__init__.py +0 -0
  122. metaobjects/meta/presentation/layout/__init__.py +0 -0
  123. metaobjects/meta/presentation/layout/layout_constants.py +13 -0
  124. metaobjects/meta/presentation/layout/meta_layout.py +8 -0
  125. metaobjects/meta/presentation/view/__init__.py +0 -0
  126. metaobjects/meta/presentation/view/meta_view.py +8 -0
  127. metaobjects/meta/presentation/view/view_constants.py +22 -0
  128. metaobjects/meta/template/__init__.py +0 -0
  129. metaobjects/meta/template/meta_template.py +46 -0
  130. metaobjects/meta/template/template_constants.py +112 -0
  131. metaobjects/meta/template/template_provider.py +43 -0
  132. metaobjects/parser.py +380 -0
  133. metaobjects/parser_yaml.py +82 -0
  134. metaobjects/provider.py +111 -0
  135. metaobjects/py.typed +0 -0
  136. metaobjects/registry.py +210 -0
  137. metaobjects/registry_manifest.py +223 -0
  138. metaobjects/render/__init__.py +74 -0
  139. metaobjects/render/email_document.py +14 -0
  140. metaobjects/render/escapers.py +109 -0
  141. metaobjects/render/extract/__init__.py +59 -0
  142. metaobjects/render/extract/coerce.py +279 -0
  143. metaobjects/render/extract/extract.py +211 -0
  144. metaobjects/render/extract/extract_map.py +61 -0
  145. metaobjects/render/extract/json_forgiving_reader.py +203 -0
  146. metaobjects/render/extract/locate.py +65 -0
  147. metaobjects/render/extract/normalize.py +96 -0
  148. metaobjects/render/extract/strip.py +20 -0
  149. metaobjects/render/extract/types.py +332 -0
  150. metaobjects/render/extract/xml_forgiving_reader.py +162 -0
  151. metaobjects/render/filesystem_provider.py +51 -0
  152. metaobjects/render/prompt/__init__.py +32 -0
  153. metaobjects/render/prompt/output_format_renderer.py +340 -0
  154. metaobjects/render/prompt/output_format_spec.py +28 -0
  155. metaobjects/render/prompt/prompt_field.py +29 -0
  156. metaobjects/render/prompt/prompt_overrides.py +29 -0
  157. metaobjects/render/prompt/prompt_style.py +38 -0
  158. metaobjects/render/renderer.py +358 -0
  159. metaobjects/render/verify.py +266 -0
  160. metaobjects/runtime/__init__.py +39 -0
  161. metaobjects/runtime/llm_recorder.py +210 -0
  162. metaobjects/runtime/n2m_resolver.py +155 -0
  163. metaobjects/runtime/object_manager.py +715 -0
  164. metaobjects/runtime/tph.py +50 -0
  165. metaobjects/serializer_json.py +172 -0
  166. metaobjects/shared/__init__.py +0 -0
  167. metaobjects/shared/base_types.py +16 -0
  168. metaobjects/shared/separators.py +4 -0
  169. metaobjects/shared/structural.py +9 -0
  170. metaobjects/source/__init__.py +79 -0
  171. metaobjects/source/error_source.py +266 -0
  172. metaobjects/source/json_path.py +106 -0
  173. metaobjects/source/semantic_diff.py +98 -0
  174. metaobjects/source/yaml_positions.py +174 -0
  175. metaobjects/super_resolve.py +128 -0
  176. metaobjects/yaml_desugar.py +481 -0
  177. metaobjects-0.9.0.dist-info/METADATA +97 -0
  178. metaobjects-0.9.0.dist-info/RECORD +181 -0
  179. metaobjects-0.9.0.dist-info/WHEEL +4 -0
  180. metaobjects-0.9.0.dist-info/entry_points.txt +2 -0
  181. metaobjects-0.9.0.dist-info/licenses/LICENSE +189 -0
@@ -0,0 +1,635 @@
1
+ """FastAPI router codegen — one ``<entity>_router.py`` per writable entity
2
+ (``source.rdb`` with ``@kind="table"``).
3
+
4
+ FR-008 §2.3. Conforms to the cross-port REST API contract
5
+ (see ``docs/features/api-contract.md``):
6
+
7
+ * Routes: ``/api/<entity-plural-lowercase>`` (e.g. ``/api/authors``).
8
+ * 5 CRUD verbs: GET list, GET by id, POST create, PATCH+PUT update, DELETE.
9
+ * ``?withCount=1`` switches list response to ``{"rows", "total"}``.
10
+ * ``?sort=field:asc|desc`` parsed via a static per-entity allowlist
11
+ (HTTP 400 envelope ``{"error": "invalid_sort"}`` on unknown field).
12
+ * ``?limit=N&offset=N`` pagination with defaults (limit=50, offset=0).
13
+ * HTTP 404 envelope: ``{"error": "not_found"}``.
14
+
15
+ View / materializedView / storedProc / tableFunction kinds are skipped
16
+ (read-only — would need a different router shape).
17
+
18
+ Filter operators are wired by delegating to the per-entity
19
+ ``<entity_snake>_filter_allowlist.py`` module (FR-009 §3.5) and the
20
+ shared ``metaobjects.codegen.runtime.filter_parser`` helper. Only fields
21
+ with ``@filterable: true`` appear in the allowlist; everything else
22
+ returns ``invalid_filter_field`` per the cross-port wire envelope. The
23
+ generated ``list`` handler accepts the FastAPI ``Request`` to read raw
24
+ query params, calls ``parse_filter`` against the allowlist, and threads
25
+ the resulting predicate list through the repository ``Protocol``.
26
+
27
+ The generated router declares a ``Protocol`` interface for the consumer's
28
+ repository — the consumer wires SQLAlchemy / asyncpg / etc. via FastAPI's
29
+ ``app.dependency_overrides`` mechanism. This keeps the generator framework-
30
+ neutral (no SQLAlchemy import in the emitted module) and lets the consumer
31
+ pick their preferred persistence layer.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ from metaobjects.codegen.constants import generated_header
36
+ from metaobjects.codegen.format import ruff_format
37
+ from metaobjects.codegen.generator import EmittedFile, GenContext, Generator, per_entity
38
+ from metaobjects.codegen.generators.m2m_codegen import (
39
+ M2mDescriptor,
40
+ build_object_index,
41
+ resolve_m2m_descriptors,
42
+ )
43
+ from metaobjects.codegen.generators.tph_plan import TphPlan, is_tph_subtype, tph_plan_for
44
+ from metaobjects.codegen.instance_artifacts import emits_instance_artifacts
45
+ from metaobjects.meta.core.field import field_constants as fc
46
+ from metaobjects.meta.core.field.meta_field import MetaField
47
+ from metaobjects.meta.core.object.meta_object import MetaObject
48
+ from metaobjects.meta.persistence.source.meta_source import MetaSource
49
+ from metaobjects.meta.persistence.source.source_constants import SOURCE_KIND_TABLE
50
+ from metaobjects.shared.base_types import TYPE_SOURCE
51
+ from metaobjects.shared.separators import PACKAGE_SEP
52
+
53
+
54
+ def _effective_fqn(entity: MetaObject) -> str:
55
+ """``package::name``, resolving package from the nearest ancestor that carries
56
+ one. Mirrors the entity-model generator's helper of the same name."""
57
+ pkg = entity.package
58
+ parent = entity.parent
59
+ while pkg is None and parent is not None:
60
+ pkg = parent.package
61
+ parent = parent.parent
62
+ return f"{pkg}{PACKAGE_SEP}{entity.name}" if pkg else entity.name
63
+
64
+
65
+ def _snake_case(name: str) -> str:
66
+ """``Author`` → ``author``; ``AuthorBrief`` → ``author_brief``.
67
+
68
+ Used for both the file name (``author_router.py``) and the path-parameter
69
+ name (``/{author_id}``). Trivial PascalCase → snake_case (no acronym
70
+ handling — matches the cross-port pluralization rule).
71
+ """
72
+ out: list[str] = []
73
+ for i, ch in enumerate(name):
74
+ if ch.isupper() and i > 0:
75
+ out.append("_")
76
+ out.append(ch.lower())
77
+ return "".join(out)
78
+
79
+
80
+ def _plural_lowercase(name: str) -> str:
81
+ """``Author`` → ``authors``. Cross-port-aligned trivial pluralization
82
+ (TS / Java / Kotlin / C# use the same rule for the default route segment).
83
+ Consumers needing irregular plurals can hand-edit the generated file."""
84
+ return name.lower() + "s"
85
+
86
+
87
+ def _primary_source_rdb(entity: MetaObject) -> MetaSource | None:
88
+ """Return the entity's ``source.rdb`` child (own only), or ``None``."""
89
+ for c in entity.own_children():
90
+ if c.type == TYPE_SOURCE and isinstance(c, MetaSource):
91
+ return c
92
+ return None
93
+
94
+
95
+ def _scalar_fields(entity: MetaObject) -> list[MetaField]:
96
+ """All effective fields minus ObjectField — same gate the Java/Kotlin
97
+ controllers use for the sort allowlist (object fields have no plain
98
+ column to sort on)."""
99
+ return [f for f in entity.fields() if f.sub_type != fc.FIELD_SUBTYPE_OBJECT]
100
+
101
+
102
+ class RouterGenerator:
103
+ """``object.entity`` + ``source.rdb @kind="table"`` → one
104
+ ``<entity_snake>_router.py`` per writable entity (FastAPI ``APIRouter``).
105
+
106
+ EXTENSION SEAM (open-for-extension). Adopters subclass this and override one of
107
+ the protected ``_emit_*`` hooks to customize the emitted router without forking.
108
+ The factory ``router_generator()`` and the module-level ``render_router()`` both
109
+ delegate to a default instance, so the default suite stays byte-identical.
110
+
111
+ Override points:
112
+
113
+ * ``_emit_repository_protocol(repo_class, m2m)`` — the consumer-implemented
114
+ ``Repository`` ``Protocol`` block (CRUD finders + M:N ``find_related_*``).
115
+ * ``_emit_route_handler(name, ...)`` — the CRUD route handlers (list / get /
116
+ create / update / delete), keyed by ``name``.
117
+ * ``_emit_m2m_route(d, snake, pk_param, repo_class)`` — one M:N traversal route.
118
+ * ``render_router(entity, object_index)`` — the whole module (last resort).
119
+
120
+ Skips entities without a ``source.rdb`` child and read-only kinds
121
+ (view / materializedView / storedProc / tableFunction).
122
+ """
123
+
124
+ name = "router-generator"
125
+
126
+ def _emit_repository_protocol(
127
+ self, repo_class: str, m2m: list[M2mDescriptor]
128
+ ) -> list[str]:
129
+ """The repository ``Protocol`` block. Returns/accepts ``Any`` so this module
130
+ stays decoupled from the entity-model import. Override to add custom finder
131
+ signatures or change the seam shape."""
132
+ lines: list[str] = [
133
+ f"class {repo_class}(Protocol):",
134
+ ' """GENERATED — consumer implements with their preferred persistence layer."""',
135
+ " def list(",
136
+ " self,",
137
+ " limit: int,",
138
+ " offset: int,",
139
+ " sort: _SortClause | None,",
140
+ " filters: list[FilterPredicate],",
141
+ " ) -> list[Any]: ...",
142
+ " def count(self, filters: list[FilterPredicate]) -> int: ...",
143
+ " def find_by_id(self, id: int) -> Any | None: ...",
144
+ " def create(self, dto: Any) -> Any: ...",
145
+ " def update(self, id: int, dto: Any) -> Any | None: ...",
146
+ " def delete(self, id: int) -> bool: ...",
147
+ ]
148
+ for d in m2m:
149
+ lines.append(
150
+ f" def find_related_{d.relation_name}(self, id: int) -> list[Any]: ..."
151
+ )
152
+ return lines
153
+
154
+ def _emit_route_handler(
155
+ self,
156
+ name: str,
157
+ *,
158
+ snake: str,
159
+ plural: str,
160
+ pk_param: str,
161
+ repo_class: str,
162
+ fields_const: str,
163
+ ops_const: str,
164
+ ) -> list[str]:
165
+ """One CRUD route handler block, dispatched by *name*
166
+ (``list`` / ``get`` / ``create`` / ``update`` / ``delete``). Override to
167
+ change a handler's body / decorators / response shape."""
168
+ if name == "list":
169
+ return [
170
+ '@router.get("")',
171
+ f"def list_{plural}(",
172
+ " request: Request,",
173
+ f" repo: Annotated[{repo_class}, Depends(get_repository)],",
174
+ " limit: int | None = Query(None),",
175
+ " offset: int | None = Query(None),",
176
+ " sort: str | None = Query(None),",
177
+ ' with_count: int | None = Query(None, alias="withCount"),',
178
+ ") -> Any:",
179
+ " actual_limit = limit if limit is not None else 50",
180
+ " actual_offset = offset if offset is not None else 0",
181
+ " sort_clause: _SortClause | None = None",
182
+ " if sort is not None:",
183
+ " sort_clause = _parse_sort(sort)",
184
+ " if sort_clause is None:",
185
+ ' return JSONResponse(status_code=400, content={"error": "invalid_sort"})',
186
+ f" filter_result = parse_filter(request.query_params, {fields_const}, {ops_const})",
187
+ " if filter_result.error_envelope is not None:",
188
+ " return JSONResponse(status_code=400, content=filter_result.error_envelope)",
189
+ " predicates = filter_result.predicates",
190
+ " rows = repo.list(actual_limit, actual_offset, sort_clause, predicates)",
191
+ " if with_count == 1:",
192
+ " total = repo.count(predicates)",
193
+ ' return {"rows": rows, "total": total}',
194
+ " return rows",
195
+ ]
196
+ if name == "get":
197
+ return [
198
+ f'@router.get("/{{{pk_param}}}")',
199
+ f"def get_{snake}(",
200
+ f" {pk_param}: int,",
201
+ f" repo: Annotated[{repo_class}, Depends(get_repository)],",
202
+ ") -> Any:",
203
+ f" row = repo.find_by_id({pk_param})",
204
+ " if row is None:",
205
+ ' return JSONResponse(status_code=404, content={"error": "not_found"})',
206
+ " return row",
207
+ ]
208
+ if name == "create":
209
+ return [
210
+ '@router.post("", status_code=status.HTTP_201_CREATED)',
211
+ f"def create_{snake}(",
212
+ " dto: dict[str, Any],",
213
+ f" repo: Annotated[{repo_class}, Depends(get_repository)],",
214
+ ") -> Any:",
215
+ " return repo.create(dto)",
216
+ ]
217
+ if name == "update":
218
+ return [
219
+ f'@router.patch("/{{{pk_param}}}")',
220
+ f'@router.put("/{{{pk_param}}}")',
221
+ f"def update_{snake}(",
222
+ f" {pk_param}: int,",
223
+ " dto: dict[str, Any],",
224
+ f" repo: Annotated[{repo_class}, Depends(get_repository)],",
225
+ ") -> Any:",
226
+ f" saved = repo.update({pk_param}, dto)",
227
+ " if saved is None:",
228
+ ' return JSONResponse(status_code=404, content={"error": "not_found"})',
229
+ " return saved",
230
+ ]
231
+ if name == "delete":
232
+ return [
233
+ f'@router.delete("/{{{pk_param}}}", status_code=status.HTTP_204_NO_CONTENT)',
234
+ f"def delete_{snake}(",
235
+ f" {pk_param}: int,",
236
+ f" repo: Annotated[{repo_class}, Depends(get_repository)],",
237
+ ") -> None:",
238
+ f" if not repo.delete({pk_param}):",
239
+ ' return JSONResponse(status_code=404, content={"error": "not_found"})',
240
+ ]
241
+ raise ValueError(f"unknown route handler '{name}'")
242
+
243
+ def _emit_m2m_route(
244
+ self, d: M2mDescriptor, snake: str, pk_param: str, repo_class: str
245
+ ) -> list[str]:
246
+ """One M:N traversal route ``GET /{id}/<relationName>`` — a thin pass-through
247
+ to the repository's ``find_related_*`` finder. Override to add filtering /
248
+ pagination on the traversal."""
249
+ return [
250
+ f'@router.get("/{{{pk_param}}}/{d.relation_name}")',
251
+ f"def list_{snake}_{d.relation_name}(",
252
+ f" {pk_param}: int,",
253
+ f" repo: Annotated[{repo_class}, Depends(get_repository)],",
254
+ ") -> list[Any]:",
255
+ f" return repo.find_related_{d.relation_name}({pk_param})",
256
+ ]
257
+
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 = st.route_segment # handler-name suffix = URL segment (e.g. "bridge"), matches the route
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
+
463
+ def render_router(
464
+ self, entity: MetaObject, object_index: dict[str, MetaObject] | None = None
465
+ ) -> str | None:
466
+ """Render an entity as a FastAPI ``APIRouter`` module.
467
+
468
+ Returns ``None`` when the entity has no ``source.rdb`` child or the source
469
+ is not a writable table (view / materializedView / storedProc / tableFunction
470
+ are skipped — read-only kinds need a different shape).
471
+
472
+ When *object_index* is supplied, each M:N navigation on the entity
473
+ (``relationship.* @cardinality:"many" + @through``) also emits a FastAPI
474
+ traversal route ``GET /<source-plural>/{id}/<relationName>`` returning the
475
+ related target rows, plus a typed ``find_related_<relation>`` finder on the
476
+ repository ``Protocol`` seam (the consumer joins through the junction). The
477
+ source URL segment is the ENTITY name pluralized (cross-port grammar), NOT
478
+ the physical ``@table``. Without an index, only CRUD is emitted (back-compat).
479
+ """
480
+ if not emits_instance_artifacts(entity):
481
+ 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
486
+ src = _primary_source_rdb(entity)
487
+ if src is None:
488
+ return None
489
+ if src.effective_kind() != SOURCE_KIND_TABLE:
490
+ return None
491
+
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
+
499
+ m2m: list[M2mDescriptor] = (
500
+ resolve_m2m_descriptors(entity, object_index)
501
+ if object_index is not None
502
+ else []
503
+ )
504
+
505
+ short_name = entity.name
506
+ snake = _snake_case(short_name)
507
+ plural = _plural_lowercase(short_name)
508
+ pk_param = f"{snake}_id"
509
+ repo_class = f"{short_name}Repository"
510
+ sort_fields = [f.name for f in _scalar_fields(entity)]
511
+ upper = short_name.upper()
512
+ fields_const = f"{upper}_FILTER_FIELDS"
513
+ ops_const = f"{upper}_FILTER_OPS_BY_FIELD"
514
+ allowlist_module = f"{snake}_filter_allowlist"
515
+
516
+ sort_set_body = "set()" if not sort_fields else (
517
+ "{\n" + "".join(f' "{name}",\n' for name in sort_fields) + "}"
518
+ )
519
+
520
+ parts: list[str] = []
521
+ parts.append(
522
+ generated_header(short_name, _effective_fqn(entity)).rstrip() + "\n"
523
+ + f'"""GENERATED — REST router for {short_name} entity. Implements the cross-port API contract."""\n'
524
+ )
525
+ parts.append("from __future__ import annotations")
526
+ parts.append("")
527
+ parts.append("from typing import Annotated, Any, Protocol")
528
+ parts.append("")
529
+ parts.append("from fastapi import APIRouter, Depends, Query, Request, status")
530
+ parts.append("from fastapi.responses import JSONResponse")
531
+ parts.append("from pydantic import BaseModel")
532
+ parts.append("")
533
+ parts.append("from metaobjects.codegen.runtime.filter_parser import (")
534
+ parts.append(" FilterPredicate,")
535
+ parts.append(" parse_filter,")
536
+ parts.append(")")
537
+ parts.append("")
538
+ parts.append(f"from .{allowlist_module} import {fields_const}, {ops_const}")
539
+ parts.append("")
540
+ parts.append(f'router = APIRouter(prefix="/api/{plural}", tags=["{plural}"])')
541
+ parts.append("")
542
+ parts.append("")
543
+ # Sort allowlist + parse helper — per-entity, closed over the allowlist set so
544
+ # callers don't need to thread the set through a runtime argument.
545
+ parts.append("class _SortClause(BaseModel):")
546
+ parts.append(' """GENERATED — parsed sort directive (field + asc/desc)."""')
547
+ parts.append(" field: str")
548
+ parts.append(" direction: str")
549
+ parts.append("")
550
+ parts.append("")
551
+ parts.append(f"_SORT_ALLOWLIST: set[str] = {sort_set_body}")
552
+ parts.append("")
553
+ parts.append("")
554
+ parts.append('def _parse_sort(raw: str) -> _SortClause | None:')
555
+ parts.append(' """Parse `field:asc|desc`; return None for malformed / disallowed input."""')
556
+ parts.append(' parts = raw.split(":", 1)')
557
+ parts.append(" if not parts or parts[0] not in _SORT_ALLOWLIST:")
558
+ parts.append(" return None")
559
+ parts.append(' direction = parts[1].lower() if len(parts) == 2 else "asc"')
560
+ parts.append(' if direction not in ("asc", "desc"):')
561
+ parts.append(" return None")
562
+ parts.append(" return _SortClause(field=parts[0], direction=direction)")
563
+ parts.append("")
564
+ parts.append("")
565
+ parts.extend(self._emit_repository_protocol(repo_class, m2m))
566
+ parts.append("")
567
+ parts.append("")
568
+ parts.append(f"def get_repository() -> {repo_class}:")
569
+ parts.append(' """GENERATED — consumer overrides via `app.dependency_overrides[get_repository]`."""')
570
+ parts.append(' raise NotImplementedError("Override get_repository via FastAPI dependency_overrides in the consumer app")')
571
+ parts.append("")
572
+ parts.append("")
573
+
574
+ _handler_kwargs = dict(
575
+ snake=snake,
576
+ plural=plural,
577
+ pk_param=pk_param,
578
+ repo_class=repo_class,
579
+ fields_const=fields_const,
580
+ ops_const=ops_const,
581
+ )
582
+ for i, hname in enumerate(("list", "get", "create", "update", "delete")):
583
+ if i > 0:
584
+ parts.append("")
585
+ parts.append("")
586
+ parts.extend(self._emit_route_handler(hname, **_handler_kwargs))
587
+ parts.append("")
588
+
589
+ # FR-018 — M:N traversal routes: GET /{id}/<relationName> returns the
590
+ # related target rows reached through the junction. The repository seam
591
+ # owns the join (derived source/target FK columns + symmetric union-on-read);
592
+ # the route is a thin pass-through returning the related collection (empty
593
+ # array for an orphan source — never a 404).
594
+ for d in m2m:
595
+ parts.append("")
596
+ parts.extend(self._emit_m2m_route(d, snake, pk_param, repo_class))
597
+ parts.append("")
598
+
599
+ return "\n".join(parts)
600
+
601
+ def generate(self, ctx: GenContext) -> list[EmittedFile]:
602
+ index = build_object_index(ctx.entities)
603
+
604
+ def emit(entity: MetaObject, _c: GenContext) -> list[EmittedFile]:
605
+ source = self.render_router(entity, index)
606
+ if source is None:
607
+ return []
608
+ snake = _snake_case(entity.name)
609
+ return [
610
+ EmittedFile(
611
+ path=f"{snake}_router.py",
612
+ content=ruff_format(source),
613
+ )
614
+ ]
615
+
616
+ return per_entity(emit)(ctx)
617
+
618
+
619
+ def render_router(
620
+ entity: MetaObject, object_index: dict[str, MetaObject] | None = None
621
+ ) -> str | None:
622
+ """Module-level back-compat wrapper. Delegates to a default
623
+ :class:`RouterGenerator` instance so existing callers (and tests) are
624
+ unaffected. Subclass :class:`RouterGenerator` to customize."""
625
+ return RouterGenerator().render_router(entity, object_index)
626
+
627
+
628
+ def router_generator() -> Generator:
629
+ """Generator factory: ``object.entity`` + ``source.rdb @kind="table"`` → one
630
+ ``<entity_snake>_router.py`` per writable entity.
631
+
632
+ Returns a :class:`RouterGenerator` (subclassable extension seam). Skips entities
633
+ without a ``source.rdb`` child and read-only kinds (view / materializedView /
634
+ storedProc / tableFunction)."""
635
+ return RouterGenerator()