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,309 @@
1
+ """FastAPI filter-allowlist codegen — one ``<entity_snake>_filter_allowlist.py``
2
+ per writable entity (``source.rdb`` with ``@kind="table"``).
3
+
4
+ FR-009 §3.5 (cross-port). Emits a per-entity static allowlist:
5
+
6
+ * ``<ENTITY>_FILTER_FIELDS: frozenset[str]`` — the field names that are
7
+ filterable (gate against ``invalid_filter_field``).
8
+ * ``<ENTITY>_FILTER_OPS_BY_FIELD: dict[str, frozenset[str]]`` — the
9
+ per-field operator vocabulary (gate against ``invalid_filter_op``).
10
+
11
+ Authoring contract: only fields with ``@filterable: true`` appear in the
12
+ allowlist. If no field is marked filterable, the file is still emitted (with
13
+ empty frozensets) so the generated router can unconditionally delegate to
14
+ it without conditional codegen branching.
15
+
16
+ Operators-per-subtype mapping (FR-009 §5, identical across ports):
17
+
18
+ * ``string`` / ``enum`` → ``eq, ne, in, like, isNull``
19
+ * ``uuid`` → ``eq, ne, in, isNull`` (no ``like`` — not a substring type, no ordering)
20
+ * ``int / long / float / double / decimal / currency / date / timestamp / time``
21
+ → ``eq, ne, gt, gte, lt, lte, in, isNull``
22
+ * ``boolean`` → ``eq, isNull``
23
+
24
+ ``object`` fields are skipped — they have no SQL column surface that filters
25
+ can target.
26
+
27
+ Mirrors the Java ``SpringFilterAllowlistGenerator`` + Kotlin
28
+ ``KotlinFilterAllowlistGenerator`` + C# allowlist emission. View kinds
29
+ (``view`` / ``materializedView`` / ``storedProc`` / ``tableFunction``) get
30
+ no allowlist — they share the "no router emitted" gate with
31
+ ``router_generator``.
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 build_object_index
39
+ from metaobjects.codegen.generators.tph_plan import tph_plan_for
40
+ from metaobjects.codegen.instance_artifacts import emits_instance_artifacts
41
+ from metaobjects.meta.core.field import field_constants as fc
42
+ from metaobjects.meta.core.field.meta_field import MetaField
43
+ from metaobjects.meta.core.object.meta_object import MetaObject
44
+ from metaobjects.meta.persistence.source.meta_source import MetaSource
45
+ from metaobjects.meta.persistence.source.source_constants import SOURCE_KIND_TABLE
46
+ from metaobjects.shared.base_types import TYPE_SOURCE
47
+ from metaobjects.shared.separators import PACKAGE_SEP
48
+
49
+
50
+ # Operator sets — preserve insertion order (Python dict order == spec order).
51
+ _OPS_STRING: tuple[str, ...] = ("eq", "ne", "in", "like", "isNull")
52
+ # uuid: identity-comparison only — no `like` (not a substring type), no ordering.
53
+ _OPS_UUID: tuple[str, ...] = ("eq", "ne", "in", "isNull")
54
+ _OPS_NUMERIC: tuple[str, ...] = ("eq", "ne", "gt", "gte", "lt", "lte", "in", "isNull")
55
+ _OPS_BOOLEAN: tuple[str, ...] = ("eq", "isNull")
56
+
57
+
58
+ def _effective_fqn(entity: MetaObject) -> str:
59
+ """``package::name``, resolving package from the nearest ancestor that carries
60
+ one. Mirror of the router generator's helper of the same name."""
61
+ pkg = entity.package
62
+ parent = entity.parent
63
+ while pkg is None and parent is not None:
64
+ pkg = parent.package
65
+ parent = parent.parent
66
+ return f"{pkg}{PACKAGE_SEP}{entity.name}" if pkg else entity.name
67
+
68
+
69
+ def _snake_case(name: str) -> str:
70
+ """``Author`` → ``author``; ``AuthorBrief`` → ``author_brief``. Mirror of
71
+ the router generator's helper of the same name."""
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 _primary_source_rdb(entity: MetaObject) -> MetaSource | None:
81
+ """Return the entity's ``source.rdb`` child (own only), or ``None``."""
82
+ for c in entity.own_children():
83
+ if c.type == TYPE_SOURCE and isinstance(c, MetaSource):
84
+ return c
85
+ return None
86
+
87
+
88
+ def ops_for_subtype_ordered(sub_type: str | None) -> tuple[str, ...]:
89
+ """Return the operator tuple for ``sub_type`` per the FR-009 §5 matrix.
90
+
91
+ Ordered (canonical operator order), unlike the loader's
92
+ ``validation_passes.ops_for_subtype`` which returns an order-free
93
+ ``frozenset``. This is the single ordered source the codegen
94
+ filter-allowlist emit + the cross-port ``field.filter-ops`` conformance
95
+ capability consume.
96
+
97
+ Returns an empty tuple for subtypes outside the FR-009 vocabulary
98
+ (defensive — the allowlist effectively becomes a "field is unknown" gate
99
+ when the subtype is unrecognized).
100
+
101
+ Module-level back-compat shim; the override seam is
102
+ :meth:`FilterAllowlistGenerator._ops_for_field`.
103
+ """
104
+ if sub_type is None:
105
+ return ()
106
+ if sub_type in (fc.FIELD_SUBTYPE_STRING, fc.FIELD_SUBTYPE_ENUM):
107
+ return _OPS_STRING
108
+ if sub_type == fc.FIELD_SUBTYPE_UUID:
109
+ return _OPS_UUID
110
+ if sub_type in (
111
+ fc.FIELD_SUBTYPE_INT,
112
+ fc.FIELD_SUBTYPE_LONG,
113
+ fc.FIELD_SUBTYPE_FLOAT,
114
+ fc.FIELD_SUBTYPE_DOUBLE,
115
+ fc.FIELD_SUBTYPE_DECIMAL,
116
+ fc.FIELD_SUBTYPE_CURRENCY,
117
+ fc.FIELD_SUBTYPE_DATE,
118
+ fc.FIELD_SUBTYPE_TIMESTAMP,
119
+ fc.FIELD_SUBTYPE_TIME,
120
+ ):
121
+ return _OPS_NUMERIC
122
+ if sub_type == fc.FIELD_SUBTYPE_BOOLEAN:
123
+ return _OPS_BOOLEAN
124
+ return ()
125
+
126
+
127
+ def _is_filterable(field: MetaField) -> bool:
128
+ """True iff ``field`` carries ``@filterable: true`` as a metadata attribute.
129
+
130
+ Accepts a real boolean ``True`` or the string ``"true"`` (case-insensitive),
131
+ matching the Java/Kotlin/C# tolerance for either YAML-bool or raw-string
132
+ value forms.
133
+ """
134
+ raw = field.attr(fc.FIELD_ATTR_FILTERABLE)
135
+ if raw is True:
136
+ return True
137
+ if isinstance(raw, str):
138
+ return raw.lower() == "true"
139
+ return False
140
+
141
+
142
+ def _compute_filterable_ops(entity: MetaObject) -> dict[str, tuple[str, ...]]:
143
+ """Module-level back-compat wrapper. Delegates to a default
144
+ :class:`FilterAllowlistGenerator`."""
145
+ return FilterAllowlistGenerator()._compute_filterable_ops(entity)
146
+
147
+
148
+ class FilterAllowlistGenerator:
149
+ """``object.entity`` + ``source.rdb @kind="table"`` → one
150
+ ``<entity_snake>_filter_allowlist.py`` per writable entity (FR-009 §3.5).
151
+
152
+ EXTENSION SEAM (open-for-extension). Adopters subclass this and override one of
153
+ the protected hooks to customize the emitted allowlist without forking. The
154
+ factory ``filter_allowlist_generator()`` and the module-level
155
+ ``render_filter_allowlist()`` both delegate to a default instance, so the
156
+ default suite stays byte-identical.
157
+
158
+ Override points:
159
+
160
+ * ``_ops_for_field(field)`` — the operator tuple a filterable field is allowed
161
+ (defaults to the FR-009 §5 per-subtype matrix). Override to widen/narrow the
162
+ operator vocabulary for a custom field shape.
163
+ * ``_emit_constants(fields_const, ops_const, ops_by_field)`` — the emitted
164
+ ``frozenset`` / ``dict`` literal lines.
165
+ * ``render_filter_allowlist(entity)`` — the whole module (last resort).
166
+
167
+ Skips entities without a ``source.rdb`` child and read-only kinds
168
+ (view / materializedView / storedProc / tableFunction) — same gate as
169
+ ``RouterGenerator``, so the two generators emit in lock-step.
170
+ """
171
+
172
+ name = "filter-allowlist-generator"
173
+
174
+ def _ops_for_field(self, field: MetaField) -> tuple[str, ...]:
175
+ """The operator tuple allowed for ``field`` (FR-009 §5 per-subtype matrix by
176
+ default). Override to customize the per-field operator vocabulary; returning
177
+ an empty tuple excludes the field from the allowlist."""
178
+ return ops_for_subtype_ordered(field.sub_type)
179
+
180
+ def _compute_filterable_ops(
181
+ self, entity: MetaObject, object_index: dict[str, MetaObject] | None = None
182
+ ) -> dict[str, tuple[str, ...]]:
183
+ """Build the ``{field_name: op_tuple}`` map for ``entity`` (own + inherited
184
+ effective fields). Only fields with ``@filterable: true`` are included;
185
+ object fields and fields whose ``_ops_for_field`` yields no operators are
186
+ skipped. Preserves declaration order so the emitted source is deterministic.
187
+
188
+ FR-017 TPH: when ``entity`` is a discriminator base (``object_index`` given),
189
+ the union also folds in each subtype's own filterable fields, so the single
190
+ table's per-subtype routes (and the polymorphic base) can filter on a
191
+ subtype-only column. Non-TPH entities are unaffected."""
192
+ out: dict[str, tuple[str, ...]] = {}
193
+
194
+ def add(f: MetaField) -> None:
195
+ if f.sub_type == fc.FIELD_SUBTYPE_OBJECT or f.name in out or not _is_filterable(f):
196
+ return
197
+ ops = self._ops_for_field(f)
198
+ if ops:
199
+ out[f.name] = ops
200
+
201
+ for f in entity.fields():
202
+ add(f)
203
+ if object_index is not None:
204
+ plan = tph_plan_for(entity, object_index)
205
+ if plan is not None:
206
+ for st in plan.subtypes:
207
+ for f in st.entity.own_fields():
208
+ add(f)
209
+ return out
210
+
211
+ def _emit_constants(
212
+ self,
213
+ fields_const: str,
214
+ ops_const: str,
215
+ ops_by_field: dict[str, tuple[str, ...]],
216
+ ) -> list[str]:
217
+ """The ``<ENTITY>_FILTER_FIELDS`` frozenset + ``<ENTITY>_FILTER_OPS_BY_FIELD``
218
+ dict literal lines. Override to change the emitted data-structure shape."""
219
+ lines: list[str] = []
220
+ if not ops_by_field:
221
+ lines.append(f"{fields_const}: frozenset[str] = frozenset()")
222
+ lines.append("")
223
+ lines.append(f"{ops_const}: dict[str, frozenset[str]] = {{}}")
224
+ else:
225
+ lines.append(f"{fields_const}: frozenset[str] = frozenset({{")
226
+ for name in ops_by_field:
227
+ lines.append(f' "{name}",')
228
+ lines.append("})")
229
+ lines.append("")
230
+ lines.append(f"{ops_const}: dict[str, frozenset[str]] = {{")
231
+ for name, ops in ops_by_field.items():
232
+ ops_literal = ", ".join(f'"{op}"' for op in ops)
233
+ lines.append(f' "{name}": frozenset({{{ops_literal}}}),')
234
+ lines.append("}")
235
+ return lines
236
+
237
+ def render_filter_allowlist(
238
+ self, entity: MetaObject, object_index: dict[str, MetaObject] | None = None
239
+ ) -> str | None:
240
+ """Render the filter allowlist module for ``entity`` (or ``None`` to skip).
241
+
242
+ Returns ``None`` for entities without a ``source.rdb`` child and for
243
+ read-only kinds (``view`` / ``materializedView`` / ``storedProc`` /
244
+ ``tableFunction``) — these match the router generator's "no router"
245
+ gate, so emitting an allowlist would be pure noise.
246
+
247
+ ``object_index`` (optional) lets a TPH discriminator base fold in its
248
+ subtypes' filterable fields (see :meth:`_compute_filterable_ops`).
249
+ """
250
+ if not emits_instance_artifacts(entity):
251
+ return None
252
+ src = _primary_source_rdb(entity)
253
+ if src is None:
254
+ return None
255
+ if src.effective_kind() != SOURCE_KIND_TABLE:
256
+ return None
257
+
258
+ short_name = entity.name
259
+ upper = short_name.upper()
260
+ fields_const = f"{upper}_FILTER_FIELDS"
261
+ ops_const = f"{upper}_FILTER_OPS_BY_FIELD"
262
+
263
+ ops_by_field = self._compute_filterable_ops(entity, object_index)
264
+
265
+ parts: list[str] = []
266
+ parts.append(
267
+ generated_header(short_name, _effective_fqn(entity)).rstrip() + "\n"
268
+ + f'"""GENERATED — per-entity FR-009 filter allowlist for {short_name}.\n\n'
269
+ f"{fields_const} lists the filterable field names; {ops_const}\n"
270
+ f'constrains the operator vocabulary for each field by its subtype."""\n'
271
+ )
272
+ parts.append("from __future__ import annotations")
273
+ parts.append("")
274
+ parts.extend(self._emit_constants(fields_const, ops_const, ops_by_field))
275
+ parts.append("")
276
+ return "\n".join(parts)
277
+
278
+ def generate(self, ctx: GenContext) -> list[EmittedFile]:
279
+ index = build_object_index(ctx.entities)
280
+
281
+ def emit(entity: MetaObject, _c: GenContext) -> list[EmittedFile]:
282
+ source = self.render_filter_allowlist(entity, index)
283
+ if source is None:
284
+ return []
285
+ snake = _snake_case(entity.name)
286
+ return [
287
+ EmittedFile(
288
+ path=f"{snake}_filter_allowlist.py",
289
+ content=ruff_format(source),
290
+ )
291
+ ]
292
+
293
+ return per_entity(emit)(ctx)
294
+
295
+
296
+ def render_filter_allowlist(
297
+ entity: MetaObject, object_index: dict[str, MetaObject] | None = None
298
+ ) -> str | None:
299
+ """Module-level back-compat wrapper. Delegates to a default
300
+ :class:`FilterAllowlistGenerator`. Subclass it to customize."""
301
+ return FilterAllowlistGenerator().render_filter_allowlist(entity, object_index)
302
+
303
+
304
+ def filter_allowlist_generator() -> Generator:
305
+ """Generator factory: ``object.entity`` + ``source.rdb @kind="table"`` → one
306
+ ``<entity_snake>_filter_allowlist.py`` per writable entity.
307
+
308
+ Returns a :class:`FilterAllowlistGenerator` (subclassable extension seam)."""
309
+ return FilterAllowlistGenerator()
@@ -0,0 +1,192 @@
1
+ """Shared M:N (many-to-many) codegen descriptor — the build-time resolution of a
2
+ source entity's ``relationship.* @cardinality:"many" + @through`` children into
3
+ the concrete coordinates each Python generator needs (Pydantic nested collection,
4
+ FastAPI traversal route, and the physical junction/target column names the
5
+ repository seam joins on).
6
+
7
+ This mirrors the TS reference ``relation-resolver.ts`` (Unit 10): codegen derives
8
+ the descriptor at build time from the junction entity's two ``identity.reference``
9
+ children (the cross-port SSOT for FK direction) via the shared
10
+ ``derive_m2m_fields`` helper — the relationship never restates its FK columns.
11
+
12
+ The descriptor carries:
13
+ * ``relation_name`` — the navigation member name (route segment + Pydantic field).
14
+ * ``target_entity`` — the related entity's bare name (Pydantic element type).
15
+ * ``source_plural`` / ``target_plural`` — pluralized entity names for the
16
+ REST contract: the source URL segment is the ENTITY name pluralized
17
+ (``Person`` → ``persons``), NOT the physical ``@table`` (cross-port grammar).
18
+ * ``source_column`` / ``target_column`` — the PHYSICAL junction FK columns
19
+ (e.g. ``follower_id`` / ``followee_id``); the repository seam joins on these.
20
+ * ``junction_table`` / ``target_table`` / ``target_pk_column`` — physical names
21
+ for the join.
22
+ * ``symmetric`` — selects the union-on-read traversal.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass
27
+
28
+ from metaobjects.meta.core.field import field_constants as fc
29
+ from metaobjects.meta.core.field.meta_field import MetaField
30
+ from metaobjects.meta.meta_data import MetaData
31
+ from metaobjects.meta.core.identity.identity_constants import (
32
+ IDENTITY_ATTR_FIELDS,
33
+ IDENTITY_SUBTYPE_PRIMARY,
34
+ )
35
+ from metaobjects.meta.core.object.meta_object import MetaObject
36
+ from metaobjects.meta.core.relationship.derive_m2m_fields import (
37
+ M2MDerivationError,
38
+ derive_m2m_fields,
39
+ )
40
+ from metaobjects.meta.core.relationship.meta_relationship import MetaRelationship
41
+ from metaobjects.meta.core.relationship.relationship_constants import CARDINALITY_MANY
42
+ from metaobjects.meta.persistence.source.meta_source import MetaSource
43
+ from metaobjects.shared.base_types import TYPE_IDENTITY, TYPE_SOURCE
44
+ from metaobjects.shared.separators import PACKAGE_SEP
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class M2mDescriptor:
49
+ """Build-time coordinates of one M:N navigation on a source entity."""
50
+
51
+ relation_name: str
52
+ target_entity: str
53
+ source_plural: str
54
+ target_plural: str
55
+ junction_table: str
56
+ target_table: str
57
+ source_column: str
58
+ target_column: str
59
+ target_pk_column: str
60
+ symmetric: bool
61
+
62
+
63
+ def _strip_package(name: str) -> str:
64
+ idx = name.rfind(PACKAGE_SEP)
65
+ return name[idx + len(PACKAGE_SEP):] if idx >= 0 else name
66
+
67
+
68
+ def plural_lowercase(name: str) -> str:
69
+ """``Person`` → ``persons``. Cross-port-aligned trivial pluralization — the
70
+ SOURCE URL segment is the entity name pluralized, NOT the physical table."""
71
+ return name.lower() + "s"
72
+
73
+
74
+ def _primary_source_rdb(entity: MetaObject) -> MetaSource | None:
75
+ for c in entity.own_children():
76
+ if c.type == TYPE_SOURCE and isinstance(c, MetaSource):
77
+ return c
78
+ return None
79
+
80
+
81
+ def _physical_table(entity: MetaObject) -> str:
82
+ """The entity's physical SQL table name (``source.rdb`` physical name),
83
+ falling back to the entity name when no source is declared."""
84
+ src = _primary_source_rdb(entity)
85
+ if src is not None:
86
+ name = src.physical_name()
87
+ if name:
88
+ return name
89
+ return entity.name
90
+
91
+
92
+ def _column_of(field: MetaField | None, fallback: str) -> str:
93
+ """Physical column name for a metadata field name: its ``@column`` override,
94
+ else the field's own name. Mirrors the runtime ``object_manager._column_of``
95
+ (no implicit snake_case — the metadata field name IS the column unless
96
+ ``@column`` overrides)."""
97
+ if field is None:
98
+ return fallback
99
+ col = field.attr(fc.FIELD_ATTR_COLUMN)
100
+ return col if isinstance(col, str) and col else field.name
101
+
102
+
103
+ def _field_named(entity: MetaObject, field_name: str) -> MetaField | None:
104
+ for f in entity.fields():
105
+ if f.name == field_name:
106
+ return f
107
+ return None
108
+
109
+
110
+ def _pk_field_name(entity: MetaObject) -> str:
111
+ """The single PK field name (``identity.primary @fields``), default ``id``."""
112
+ for c in entity.children():
113
+ if c.type == TYPE_IDENTITY and c.sub_type == IDENTITY_SUBTYPE_PRIMARY:
114
+ fields = c.attr(IDENTITY_ATTR_FIELDS)
115
+ if isinstance(fields, (list, tuple)) and fields:
116
+ first = fields[0]
117
+ if isinstance(first, str):
118
+ return first
119
+ if isinstance(fields, str) and fields:
120
+ return fields.split(",")[0].strip() or "id"
121
+ return "id"
122
+
123
+
124
+ def m2m_relationships(entity: MetaObject) -> list[MetaRelationship]:
125
+ """The entity's own ``relationship.* @cardinality:"many" + @through`` children
126
+ (declaration order). 1:N relationships (no ``@through``) are excluded."""
127
+ out: list[MetaRelationship] = []
128
+ for c in entity.own_children():
129
+ if not isinstance(c, MetaRelationship):
130
+ continue
131
+ if c.cardinality() != CARDINALITY_MANY:
132
+ continue
133
+ if c.through() is None:
134
+ continue
135
+ out.append(c)
136
+ return out
137
+
138
+
139
+ def resolve_m2m_descriptors(
140
+ entity: MetaObject, object_index: dict[str, MetaObject]
141
+ ) -> list[M2mDescriptor]:
142
+ """Resolve every M:N navigation on *entity* into a build-time descriptor.
143
+
144
+ *object_index* is a bare-name → entity map of the loaded model. A relationship
145
+ that fails derivation (missing/malformed junction, ambiguous self-join) raises
146
+ :class:`M2MDerivationError` — the loader validates this; reaching it here is a
147
+ metadata error, surfaced loudly (never silently skipped).
148
+ """
149
+ descriptors: list[M2mDescriptor] = []
150
+ for rel in m2m_relationships(entity):
151
+ target_name = _strip_package(rel.object_ref() or "")
152
+ junction_name = _strip_package(rel.through() or "")
153
+ target = object_index.get(target_name)
154
+ junction = object_index.get(junction_name)
155
+ if target is None or junction is None:
156
+ raise M2MDerivationError(
157
+ f'M:N relationship "{entity.name}.{rel.name}": @objectRef '
158
+ f'"{target_name}" / @through "{junction_name}" must resolve to '
159
+ f"loaded entities"
160
+ )
161
+
162
+ # Widen to dict[str, MetaData] for the shared derivation helper (dict is
163
+ # invariant; MetaObject is a MetaData subtype).
164
+ meta_index: dict[str, MetaData] = dict(object_index)
165
+ fields = derive_m2m_fields(rel, entity, meta_index)
166
+ source_fk = _field_named(junction, fields.source_field)
167
+ target_fk = _field_named(junction, fields.target_field)
168
+
169
+ descriptors.append(
170
+ M2mDescriptor(
171
+ relation_name=rel.name,
172
+ target_entity=target.name,
173
+ source_plural=plural_lowercase(entity.name),
174
+ target_plural=plural_lowercase(target.name),
175
+ junction_table=_physical_table(junction),
176
+ target_table=_physical_table(target),
177
+ source_column=_column_of(source_fk, fields.source_field),
178
+ target_column=_column_of(target_fk, fields.target_field),
179
+ target_pk_column=_column_of(
180
+ _field_named(target, _pk_field_name(target)),
181
+ _pk_field_name(target),
182
+ ),
183
+ symmetric=rel.symmetric(),
184
+ )
185
+ )
186
+ return descriptors
187
+
188
+
189
+ def build_object_index(entities: list[MetaObject]) -> dict[str, MetaObject]:
190
+ """Bare-name → entity map (the codegen resolution surface, mirroring the
191
+ runtime resolver's ``object_index``)."""
192
+ return {e.name: e for e in entities}