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,210 @@
1
+ """LLM-call trace recorder seam + base-row factory (AI LLM-call trace persistence).
2
+
3
+ Python port of the TS ``runtime-ts/src/llm-recorder.ts`` and Java
4
+ ``omdb/.../ai/`` recorder. ``build_llm_call_row`` produces the base trace row —
5
+ exactly the 18 ``metaobjects::ai::LlmCallBase`` fields — and the recorder
6
+ persists it through the runtime write path (:meth:`ObjectManager.create`).
7
+
8
+ The recorder does NOT extract a typed VO; the typed ``voRequest``/``voResponse``
9
+ columns are set by the generated per-entity ``record_<entity>`` helper (Slice 2)
10
+ or by the caller on the returned row, matching the TS/Java contract.
11
+
12
+ Cross-port note (Tier-2, driver-driven): the raw ``llmRequest``/``llmResponse``
13
+ columns are ``field.string`` pinned to a jsonb column. pg8000 binds a native
14
+ Python ``dict``/``list`` straight to jsonb (a raw ``str`` would not cast), so this
15
+ port stores the request/response as native JSON values — unlike TS (JSON string)
16
+ and Java (verbatim string). The typed ``voResponse`` contract is identical across
17
+ all ports.
18
+
19
+ The call carries the model response as raw TEXT (``llm_response_text``, mirroring
20
+ TS ``llmResponseText`` / Java ``llmResponseText``) — what the model actually
21
+ returned. For the raw ``llmResponse`` jsonb column we parse that text into a native
22
+ JSON value when it IS valid JSON (clean structured responses store as jsonb), and
23
+ otherwise wrap it as ``{"text": <raw>}`` so the column is always a valid jsonb
24
+ value (prose / non-JSON / truncated responses never break the bind). See
25
+ :func:`_json_or_wrap`.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ from dataclasses import dataclass
31
+ from typing import Any, Callable, Protocol
32
+
33
+ from .object_manager import ObjectManager
34
+
35
+ #: Call-outcome sentinels (mirror Java LlmCallInput.STATUS_*).
36
+ STATUS_OK = "ok"
37
+ STATUS_ERROR = "error"
38
+
39
+ #: A trace row keyed by metadata field name (the shape ObjectManager.create takes).
40
+ LlmCallRow = dict[str, Any]
41
+
42
+ # Base field names — the LlmCallBase contract (cross-port identical).
43
+ _F_TRACE_ID = "traceId"
44
+ _F_SPAN_ID = "spanId"
45
+ _F_PARENT_SPAN_ID = "parentSpanId"
46
+ _F_SESSION_ID = "sessionId"
47
+ _F_CALL_TYPE = "callType"
48
+ _F_SYSTEM = "system"
49
+ _F_REQUEST_MODEL = "requestModel"
50
+ _F_RESPONSE_MODEL = "responseModel"
51
+ _F_INPUT_TOKENS = "inputTokens"
52
+ _F_OUTPUT_TOKENS = "outputTokens"
53
+ _F_COST_MINOR = "costMinor"
54
+ _F_LATENCY_MS = "latencyMs"
55
+ _F_FINISH_REASON = "finishReason"
56
+ _F_STATUS = "status"
57
+ _F_ERROR_DETAIL = "errorDetail"
58
+ _F_STARTED_AT = "startedAt"
59
+ _F_LLM_REQUEST = "llmRequest"
60
+ _F_LLM_RESPONSE = "llmResponse"
61
+
62
+
63
+ @dataclass
64
+ class LlmCallInput:
65
+ """The call fields driving a base trace row.
66
+
67
+ ``llm_request`` is a STRUCTURED request object (dict/list/scalar) stored into
68
+ the raw ``llmRequest`` jsonb column as a native JSON value — see the module
69
+ docstring. ``llm_response_text`` is the raw model response TEXT (mirroring TS
70
+ ``llmResponseText`` / Java ``llmResponseText``); the raw ``llmResponse`` jsonb
71
+ column is derived from it by :func:`_json_or_wrap`. ``started_at`` is an
72
+ ISO-8601 string (or a native ``datetime``); the field.timestamp write codec
73
+ coerces it.
74
+ """
75
+
76
+ span_id: str
77
+ trace_id: str
78
+ call_type: str
79
+ started_at: Any
80
+ llm_request: Any
81
+ llm_response_text: str
82
+ status: str # STATUS_OK | STATUS_ERROR
83
+ error_detail: str | None
84
+ parent_span_id: str | None = None
85
+ session_id: str | None = None
86
+ system: str | None = None
87
+ request_model: str | None = None
88
+ response_model: str | None = None
89
+ input_tokens: int | None = None
90
+ output_tokens: int | None = None
91
+ cost_minor: int | None = None
92
+ latency_ms: int | None = None
93
+ finish_reason: str | None = None
94
+
95
+
96
+ class LlmCallRecorder(Protocol):
97
+ """Write-side seam for persisting a trace row. Implementations MUST NOT raise
98
+ on a persistence failure (telemetry never breaks the app)."""
99
+
100
+ def record(self, row: LlmCallRow) -> None: ...
101
+
102
+
103
+ class NullLlmCallRecorder:
104
+ """No-op recorder (testing / disabled tracing)."""
105
+
106
+ def record(self, row: LlmCallRow) -> None: # noqa: D401 - deliberate no-op
107
+ return None
108
+
109
+
110
+ class ObjectManagerLlmCallRecorder:
111
+ """Persist a trace row via :meth:`ObjectManager.create`. Never raises — a write
112
+ failure routes to ``on_error`` (default: swallow)."""
113
+
114
+ def __init__(
115
+ self,
116
+ om: ObjectManager,
117
+ entity_name: str,
118
+ on_error: Callable[[BaseException], None] | None = None,
119
+ ) -> None:
120
+ self._om = om
121
+ self._entity_name = entity_name
122
+ self._on_error = on_error if on_error is not None else (lambda _e: None)
123
+
124
+ def record(self, row: LlmCallRow) -> None:
125
+ try:
126
+ self._om.create(self._entity_name, row)
127
+ except Exception as err: # noqa: BLE001 - telemetry must never propagate
128
+ self._on_error(err)
129
+
130
+
131
+ def _json_or_wrap(text: str | None) -> Any:
132
+ """Coerce a raw response TEXT into an always-valid jsonb value for the raw
133
+ ``llmResponse`` column.
134
+
135
+ pg8000 binds a native ``dict``/``list``/scalar straight to jsonb (a raw ``str``
136
+ would not cast — see the module docstring), so clean JSON responses parse to
137
+ their native value and prose / non-JSON / truncated responses fall back to a
138
+ ``{"text": <raw>}`` wrapper that is always valid jsonb. ``None`` text → ``None``.
139
+ """
140
+ if text is None:
141
+ return None
142
+ try:
143
+ return json.loads(text)
144
+ except (json.JSONDecodeError, TypeError):
145
+ return {"text": text}
146
+
147
+
148
+ def build_llm_call_row(inp: LlmCallInput) -> LlmCallRow:
149
+ """Build the base trace row — key set is exactly LlmCallBase's 18 fields.
150
+
151
+ The typed ``voRequest``/``voResponse`` columns are NOT set here; the caller
152
+ (or the generated helper) adds them to the returned row.
153
+ """
154
+ return {
155
+ _F_TRACE_ID: inp.trace_id,
156
+ _F_SPAN_ID: inp.span_id,
157
+ _F_PARENT_SPAN_ID: inp.parent_span_id,
158
+ _F_SESSION_ID: inp.session_id,
159
+ _F_CALL_TYPE: inp.call_type,
160
+ _F_SYSTEM: inp.system,
161
+ _F_REQUEST_MODEL: inp.request_model,
162
+ _F_RESPONSE_MODEL: inp.response_model,
163
+ _F_INPUT_TOKENS: inp.input_tokens,
164
+ _F_OUTPUT_TOKENS: inp.output_tokens,
165
+ _F_COST_MINOR: inp.cost_minor,
166
+ _F_LATENCY_MS: inp.latency_ms,
167
+ _F_FINISH_REASON: inp.finish_reason,
168
+ _F_STATUS: inp.status,
169
+ _F_ERROR_DETAIL: inp.error_detail,
170
+ _F_STARTED_AT: inp.started_at,
171
+ # Raw request → the structured request object bound straight to jsonb.
172
+ _F_LLM_REQUEST: inp.llm_request,
173
+ # Raw response → the response TEXT parsed to native JSON when it parses,
174
+ # else wrapped as {"text": ...} so the jsonb bind is always valid.
175
+ _F_LLM_RESPONSE: _json_or_wrap(inp.llm_response_text),
176
+ }
177
+
178
+
179
+ def truncate_row(row: LlmCallRow, max_chars: int) -> LlmCallRow:
180
+ """Cap the raw ``llmRequest``/``llmResponse`` columns when they are strings.
181
+
182
+ Adopters compose this into a ``redact`` to bound trace-row size. Non-string
183
+ (native JSON) values pass through unchanged; all other fields are untouched.
184
+ """
185
+
186
+ def cap(v: Any) -> Any:
187
+ return v[:max_chars] if isinstance(v, str) and len(v) > max_chars else v
188
+
189
+ return {**row, _F_LLM_REQUEST: cap(row.get(_F_LLM_REQUEST)), _F_LLM_RESPONSE: cap(row.get(_F_LLM_RESPONSE))}
190
+
191
+
192
+ def persist_llm_call_row(
193
+ recorder: LlmCallRecorder,
194
+ row: LlmCallRow,
195
+ redact: Callable[[LlmCallRow], LlmCallRow] | None = None,
196
+ ) -> None:
197
+ """Shared persist step: redact then record. Used by record_llm_call AND the
198
+ generated typed helper, so redaction applies on both paths."""
199
+ recorder.record(redact(row) if redact is not None else row)
200
+
201
+
202
+ def record_llm_call(
203
+ inp: LlmCallInput,
204
+ recorder: LlmCallRecorder,
205
+ redact: Callable[[LlmCallRow], LlmCallRow] | None = None,
206
+ ) -> tuple[str, str | None]:
207
+ """Persist one base trace row (envelope + raw I/O). Generic — does not extract.
208
+ Returns ``(status, error_detail)``."""
209
+ persist_llm_call_row(recorder, build_llm_call_row(inp), redact)
210
+ return inp.status, inp.error_detail
@@ -0,0 +1,155 @@
1
+ """Generic, metadata-driven M:N (many-to-many) query resolver.
2
+
3
+ A M:N relationship declares only the slim FR-018 vocabulary on the source
4
+ entity: ``@cardinality: "many"`` + ``@objectRef: <target>`` + ``@through:
5
+ <junction>`` (plus optional ``@sourceRefField`` / ``@symmetric`` for self-joins).
6
+ It does NOT restate the junction FK columns — those are DERIVED from the junction
7
+ entity's two ``identity.reference`` children via the shared ``derive_m2m_fields``
8
+ helper (the SSOT for FK direction, the same one the loader validator + every
9
+ other port use).
10
+
11
+ Resolution has three modes (mirrors the TS reference ``n2m-resolver.ts``):
12
+
13
+ 1. Hetero (source != target): junction WHERE sourceField (=|IN) source.pk,
14
+ collect targetField, then target WHERE pk IN (...).
15
+ 2. Directed self-join (``@sourceRefField``): identical traversal; the helper
16
+ has already picked which junction FK is the source side.
17
+ 3. Symmetric self-join (``@symmetric: true``): single-row storage, union on
18
+ read — junction WHERE sourceField (=|IN) id OR targetField (=|IN) id; for
19
+ each row the related id is whichever FK column is NOT the source id (a
20
+ self-loop row where both columns are the source yields the source itself).
21
+ """
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+ from typing import TYPE_CHECKING, Any
26
+
27
+ from ..meta.core.object.meta_object import MetaObject
28
+ from ..meta.core.relationship.derive_m2m_fields import (
29
+ M2MDerivationError,
30
+ derive_m2m_fields,
31
+ )
32
+ from ..meta.core.relationship.meta_relationship import MetaRelationship
33
+ from ..meta.core.relationship.relationship_constants import CARDINALITY_MANY
34
+
35
+ if TYPE_CHECKING: # pragma: no cover
36
+ from ..meta.meta_root import MetaRoot
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class N2mDescriptor:
41
+ """The resolved coordinates of a M:N traversal.
42
+
43
+ ``source_field`` / ``target_field`` are the *metadata field names* on the
44
+ junction (the runtime maps them to physical columns); ``symmetric`` selects
45
+ the union-on-read read path.
46
+ """
47
+
48
+ source_entity_name: str
49
+ target_entity_name: str
50
+ junction_entity_name: str
51
+ source_field: str
52
+ target_field: str
53
+ symmetric: bool
54
+
55
+
56
+ class N2mResolutionError(Exception):
57
+ """Raised when a named M:N relationship cannot be resolved."""
58
+
59
+ code = "ERR_INVALID_RELATIONSHIP"
60
+
61
+
62
+ def resolve_n2m_descriptor(
63
+ source_entity: MetaObject,
64
+ relation_name: str,
65
+ object_index: dict[str, MetaObject],
66
+ ) -> N2mDescriptor | None:
67
+ """Resolve a M:N relationship on *source_entity* by name.
68
+
69
+ Returns ``None`` when the named relationship is not M:N (no ``@through``) —
70
+ the caller should fall through to its 1:1 / 1:N relation path. Raises
71
+ :class:`N2mResolutionError` when the relationship IS M:N but malformed.
72
+
73
+ *object_index* is a bare-name → object map of the loaded model's top-level
74
+ objects (mirrors the TS ``root.findObject`` resolution surface).
75
+ """
76
+ for child in source_entity.own_children():
77
+ if not isinstance(child, MetaRelationship):
78
+ continue
79
+ if child.name != relation_name:
80
+ continue
81
+ if child.cardinality() != CARDINALITY_MANY:
82
+ continue
83
+ if child.through() is None:
84
+ # Cardinality-many but no junction → 1:N, not M:N.
85
+ return None
86
+
87
+ target_name = child.object_ref()
88
+ junction_name = child.through()
89
+ if not target_name or not junction_name:
90
+ raise N2mResolutionError(
91
+ f"M:N relationship '{relation_name}' on '{source_entity.name}' "
92
+ f"requires @objectRef + @through"
93
+ )
94
+
95
+ try:
96
+ fields = derive_m2m_fields(child, source_entity, object_index)
97
+ except M2MDerivationError as e:
98
+ raise N2mResolutionError(
99
+ f"M:N relationship '{relation_name}' on '{source_entity.name}': {e}"
100
+ ) from e
101
+
102
+ return N2mDescriptor(
103
+ source_entity_name=source_entity.name,
104
+ target_entity_name=_strip_package(target_name),
105
+ junction_entity_name=_strip_package(junction_name),
106
+ source_field=fields.source_field,
107
+ target_field=fields.target_field,
108
+ symmetric=child.symmetric(),
109
+ )
110
+ return None
111
+
112
+
113
+ def collect_symmetric_target_ids(
114
+ join_rows: list[dict[str, Any]],
115
+ source_col: str,
116
+ target_col: str,
117
+ source_ids: set[Any],
118
+ ) -> list[Any]:
119
+ """Symmetric union-on-read: for each junction row, the related id is whichever
120
+ of (source_col, target_col) is NOT a source id. A self-loop row (both columns
121
+ the source id) yields the source id itself.
122
+
123
+ Membership is compared by string-coerced key: the source ids come from the
124
+ in-process source record while the junction FK values come straight off the
125
+ driver, where a BIGINT key may arrive as a differing native type. Comparing
126
+ by ``str()`` bridges any number-vs-string mismatch — exactly the TS reference
127
+ behaviour.
128
+ """
129
+ source_keys = {str(v) for v in source_ids}
130
+ seen: dict[str, Any] = {}
131
+ for row in join_rows:
132
+ a = row.get(source_col)
133
+ b = row.get(target_col)
134
+ a_is_source = a is not None and str(a) in source_keys
135
+ other = b if a_is_source else a
136
+ if other is None:
137
+ continue
138
+ seen.setdefault(str(other), other)
139
+ return list(seen.values())
140
+
141
+
142
+ def collect_column_ids(join_rows: list[dict[str, Any]], col: str) -> list[Any]:
143
+ """Distinct non-null values of one junction column (declaration order)."""
144
+ seen: dict[str, Any] = {}
145
+ for row in join_rows:
146
+ v = row.get(col)
147
+ if v is None:
148
+ continue
149
+ seen.setdefault(str(v), v)
150
+ return list(seen.values())
151
+
152
+
153
+ def _strip_package(name: str) -> str:
154
+ idx = name.rfind("::")
155
+ return name[idx + 2:] if idx >= 0 else name