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.
- metaobjects/__init__.py +75 -0
- metaobjects/agent_context/__init__.py +55 -0
- metaobjects/agent_context/_content/README.md +14 -0
- metaobjects/agent_context/_content/servers/csharp.meta.json +5 -0
- metaobjects/agent_context/_content/servers/java.meta.json +5 -0
- metaobjects/agent_context/_content/servers/kotlin.meta.json +5 -0
- metaobjects/agent_context/_content/servers/python.meta.json +5 -0
- metaobjects/agent_context/_content/servers/typescript.meta.json +5 -0
- metaobjects/agent_context/_content/skills/metaobjects-authoring/SKILL.md +301 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/SKILL.md +99 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/references/csharp.md +87 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/references/java.md +94 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/references/kotlin.md +110 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/references/typescript.md +135 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/SKILL.md +148 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/csharp.md +110 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/java.md +108 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/kotlin.md +130 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/python.md +116 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/typescript.md +150 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/SKILL.md +130 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/java.md +96 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/kotlin.md +99 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/react.md +86 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/tanstack.md +119 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/typescript.md +92 -0
- metaobjects/agent_context/_content/skills/metaobjects-verify/SKILL.md +107 -0
- metaobjects/agent_context/_content/skills/metaobjects-verify/references/migration.md +72 -0
- metaobjects/agent_context/_content/templates/always-on.md.mustache +27 -0
- metaobjects/agent_context/assemble.py +133 -0
- metaobjects/agent_context/content_root.py +54 -0
- metaobjects/agent_context/scaffold.py +191 -0
- metaobjects/agent_context/types.py +44 -0
- metaobjects/attr_class_map.py +23 -0
- metaobjects/cli.py +696 -0
- metaobjects/codegen/__init__.py +0 -0
- metaobjects/codegen/config.py +11 -0
- metaobjects/codegen/constants.py +13 -0
- metaobjects/codegen/extract_delegate_emitter.py +384 -0
- metaobjects/codegen/extract_schema_emitter.py +139 -0
- metaobjects/codegen/format.py +31 -0
- metaobjects/codegen/fr010_field_mapping.py +220 -0
- metaobjects/codegen/generator.py +62 -0
- metaobjects/codegen/generator_registry.py +163 -0
- metaobjects/codegen/generators/__init__.py +0 -0
- metaobjects/codegen/generators/entity_model.py +263 -0
- metaobjects/codegen/generators/extractor_generator.py +317 -0
- metaobjects/codegen/generators/filter_allowlist_generator.py +309 -0
- metaobjects/codegen/generators/m2m_codegen.py +192 -0
- metaobjects/codegen/generators/output_parser_generator.py +272 -0
- metaobjects/codegen/generators/output_prompt_generator.py +192 -0
- metaobjects/codegen/generators/payload_vo_generator.py +672 -0
- metaobjects/codegen/generators/render_helper_generator.py +451 -0
- metaobjects/codegen/generators/router_generator.py +635 -0
- metaobjects/codegen/generators/template_generator.py +70 -0
- metaobjects/codegen/generators/tph_plan.py +120 -0
- metaobjects/codegen/generators/trace_helper_generator.py +336 -0
- metaobjects/codegen/instance_artifacts.py +15 -0
- metaobjects/codegen/output_format_spec_emitter.py +79 -0
- metaobjects/codegen/overwrite_policy.py +27 -0
- metaobjects/codegen/runner.py +110 -0
- metaobjects/codegen/runtime/__init__.py +6 -0
- metaobjects/codegen/runtime/filter_parser.py +193 -0
- metaobjects/codegen/type_map.py +84 -0
- metaobjects/core_types.py +809 -0
- metaobjects/datatype.py +19 -0
- metaobjects/documentation/__init__.py +28 -0
- metaobjects/documentation/doc_constants.py +20 -0
- metaobjects/documentation/doc_provider.py +20 -0
- metaobjects/documentation/doc_schema.py +24 -0
- metaobjects/errors.py +124 -0
- metaobjects/loader/__init__.py +0 -0
- metaobjects/loader/merge.py +287 -0
- metaobjects/loader/meta_data_loader.py +245 -0
- metaobjects/loader/sources/__init__.py +24 -0
- metaobjects/loader/sources/directory_source.py +50 -0
- metaobjects/loader/sources/file_source.py +41 -0
- metaobjects/loader/sources/meta_data_source.py +67 -0
- metaobjects/loader/sources/uri_source.py +56 -0
- metaobjects/loader/validate_discriminator.py +181 -0
- metaobjects/loader/validate_field_readonly.py +146 -0
- metaobjects/loader/validate_source_parameter_ref.py +159 -0
- metaobjects/loader/validate_source_physical_names.py +140 -0
- metaobjects/loader/validation_passes.py +1513 -0
- metaobjects/meta/__init__.py +1 -0
- metaobjects/meta/core/__init__.py +0 -0
- metaobjects/meta/core/attr/__init__.py +0 -0
- metaobjects/meta/core/attr/attr_constants.py +31 -0
- metaobjects/meta/core/attr/meta_attr.py +136 -0
- metaobjects/meta/core/field/__init__.py +0 -0
- metaobjects/meta/core/field/field_constants.py +105 -0
- metaobjects/meta/core/field/meta_field.py +76 -0
- metaobjects/meta/core/identity/__init__.py +0 -0
- metaobjects/meta/core/identity/identity_constants.py +19 -0
- metaobjects/meta/core/identity/meta_identity.py +8 -0
- metaobjects/meta/core/object/__init__.py +0 -0
- metaobjects/meta/core/object/meta_object.py +65 -0
- metaobjects/meta/core/object/meta_object_aware.py +43 -0
- metaobjects/meta/core/object/object_class_registry.py +56 -0
- metaobjects/meta/core/object/object_constants.py +13 -0
- metaobjects/meta/core/object/object_extract.py +400 -0
- metaobjects/meta/core/object/value_object.py +70 -0
- metaobjects/meta/core/relationship/__init__.py +0 -0
- metaobjects/meta/core/relationship/derive_m2m_fields.py +180 -0
- metaobjects/meta/core/relationship/meta_relationship.py +54 -0
- metaobjects/meta/core/relationship/relationship_constants.py +51 -0
- metaobjects/meta/core/validator/__init__.py +0 -0
- metaobjects/meta/core/validator/validator_constants.py +18 -0
- metaobjects/meta/meta_data.py +206 -0
- metaobjects/meta/meta_root.py +8 -0
- metaobjects/meta/persistence/__init__.py +0 -0
- metaobjects/meta/persistence/db/__init__.py +1 -0
- metaobjects/meta/persistence/db/db_constants.py +41 -0
- metaobjects/meta/persistence/db/db_provider.py +60 -0
- metaobjects/meta/persistence/origin/__init__.py +0 -0
- metaobjects/meta/persistence/origin/meta_origin.py +8 -0
- metaobjects/meta/persistence/origin/origin_constants.py +20 -0
- metaobjects/meta/persistence/source/__init__.py +0 -0
- metaobjects/meta/persistence/source/meta_source.py +137 -0
- metaobjects/meta/persistence/source/source_constants.py +115 -0
- metaobjects/meta/presentation/__init__.py +0 -0
- metaobjects/meta/presentation/layout/__init__.py +0 -0
- metaobjects/meta/presentation/layout/layout_constants.py +13 -0
- metaobjects/meta/presentation/layout/meta_layout.py +8 -0
- metaobjects/meta/presentation/view/__init__.py +0 -0
- metaobjects/meta/presentation/view/meta_view.py +8 -0
- metaobjects/meta/presentation/view/view_constants.py +22 -0
- metaobjects/meta/template/__init__.py +0 -0
- metaobjects/meta/template/meta_template.py +46 -0
- metaobjects/meta/template/template_constants.py +112 -0
- metaobjects/meta/template/template_provider.py +43 -0
- metaobjects/parser.py +380 -0
- metaobjects/parser_yaml.py +82 -0
- metaobjects/provider.py +111 -0
- metaobjects/py.typed +0 -0
- metaobjects/registry.py +210 -0
- metaobjects/registry_manifest.py +223 -0
- metaobjects/render/__init__.py +74 -0
- metaobjects/render/email_document.py +14 -0
- metaobjects/render/escapers.py +109 -0
- metaobjects/render/extract/__init__.py +59 -0
- metaobjects/render/extract/coerce.py +279 -0
- metaobjects/render/extract/extract.py +211 -0
- metaobjects/render/extract/extract_map.py +61 -0
- metaobjects/render/extract/json_forgiving_reader.py +203 -0
- metaobjects/render/extract/locate.py +65 -0
- metaobjects/render/extract/normalize.py +96 -0
- metaobjects/render/extract/strip.py +20 -0
- metaobjects/render/extract/types.py +332 -0
- metaobjects/render/extract/xml_forgiving_reader.py +162 -0
- metaobjects/render/filesystem_provider.py +51 -0
- metaobjects/render/prompt/__init__.py +32 -0
- metaobjects/render/prompt/output_format_renderer.py +340 -0
- metaobjects/render/prompt/output_format_spec.py +28 -0
- metaobjects/render/prompt/prompt_field.py +29 -0
- metaobjects/render/prompt/prompt_overrides.py +29 -0
- metaobjects/render/prompt/prompt_style.py +38 -0
- metaobjects/render/renderer.py +358 -0
- metaobjects/render/verify.py +266 -0
- metaobjects/runtime/__init__.py +39 -0
- metaobjects/runtime/llm_recorder.py +210 -0
- metaobjects/runtime/n2m_resolver.py +155 -0
- metaobjects/runtime/object_manager.py +715 -0
- metaobjects/runtime/tph.py +50 -0
- metaobjects/serializer_json.py +172 -0
- metaobjects/shared/__init__.py +0 -0
- metaobjects/shared/base_types.py +16 -0
- metaobjects/shared/separators.py +4 -0
- metaobjects/shared/structural.py +9 -0
- metaobjects/source/__init__.py +79 -0
- metaobjects/source/error_source.py +266 -0
- metaobjects/source/json_path.py +106 -0
- metaobjects/source/semantic_diff.py +98 -0
- metaobjects/source/yaml_positions.py +174 -0
- metaobjects/super_resolve.py +128 -0
- metaobjects/yaml_desugar.py +481 -0
- metaobjects-0.9.0.dist-info/METADATA +97 -0
- metaobjects-0.9.0.dist-info/RECORD +181 -0
- metaobjects-0.9.0.dist-info/WHEEL +4 -0
- metaobjects-0.9.0.dist-info/entry_points.txt +2 -0
- 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()
|