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,400 @@
|
|
|
1
|
+
"""Phase B (metadata-driven extract) runtime entry point — the Python keystone
|
|
2
|
+
that turns dirty LLM text into a populated, typed object graph.
|
|
3
|
+
|
|
4
|
+
This is the runtime bridge between the two halves of the standard:
|
|
5
|
+
|
|
6
|
+
* the extract **engine** (:mod:`metaobjects.render.extract`: ``ExtractSchema`` /
|
|
7
|
+
``FieldSpec`` / ``extract``) — a low-level, descriptor-driven module that parses
|
|
8
|
+
dirty JSON/XML into a forgiving ``dict``/``list`` tree + a ``ExtractionReport``. It
|
|
9
|
+
knows nothing of the runtime object model.
|
|
10
|
+
* the Phase A runtime **object model** (:mod:`metaobjects.meta.core.object`:
|
|
11
|
+
``MetaObject.new_instance()`` + the ``MetaField`` get/set SPI + ``@objectRef``) —
|
|
12
|
+
instantiates the right backing type (``ValueObject`` or a registered/bound class)
|
|
13
|
+
with the correct back-reference.
|
|
14
|
+
|
|
15
|
+
**Siting.** ``render.extract`` is a LOWER layer and stays metadata-free by design
|
|
16
|
+
(coupling it to the metadata model would invert the layering). The metadata-driven
|
|
17
|
+
bridge therefore lives HERE, in :mod:`metaobjects.meta.core.object` — alongside the
|
|
18
|
+
Phase A object model, the natural home for a runtime API that needs BOTH ``MetaObject``
|
|
19
|
+
and the extract engine. This mirrors the JVM layering, where the extract engine sits
|
|
20
|
+
in the metadata-free ``render`` module and the runtime extract bridge lives in the
|
|
21
|
+
``om`` runtime module (which depends on both); and the TS layering, where the bridge
|
|
22
|
+
lives in ``runtime-ts`` rather than the published ``render`` engine package. The edges
|
|
23
|
+
are one-way: this module → ``render.extract`` and this module → ``meta``; the engine
|
|
24
|
+
depends on neither, so there is no cycle.
|
|
25
|
+
|
|
26
|
+
**Reflection-free.** Assembly uses ``MetaObject.new_instance()`` (the
|
|
27
|
+
``ObjectClassRegistry`` resolves the bound type, else a ``ValueObject``) and the
|
|
28
|
+
``MetaField`` set-by-name SPI — no ``importlib`` / dynamic class resolution
|
|
29
|
+
(ADR-0001).
|
|
30
|
+
|
|
31
|
+
**Never throws.** Lost/malformed fields are classified in the report, never raised.
|
|
32
|
+
Opt into strictness with :func:`or_throw`, which raises :class:`ExtractError` iff a
|
|
33
|
+
required field was lost.
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from metaobjects.meta.core.field import field_constants as fc
|
|
38
|
+
from metaobjects.meta.core.field.meta_field import MetaField
|
|
39
|
+
from metaobjects.meta.core.validator import validator_constants as vc
|
|
40
|
+
from metaobjects.meta.template.template_constants import TEMPLATE_ATTR_XML_TEXT
|
|
41
|
+
from metaobjects.meta.meta_data import MetaData
|
|
42
|
+
from metaobjects.shared.base_types import TYPE_VALIDATOR
|
|
43
|
+
from metaobjects.render.extract import (
|
|
44
|
+
FieldKind,
|
|
45
|
+
FieldSpec,
|
|
46
|
+
Format,
|
|
47
|
+
ExtractOptions,
|
|
48
|
+
ExtractSchema,
|
|
49
|
+
ExtractionResult,
|
|
50
|
+
extract,
|
|
51
|
+
)
|
|
52
|
+
from metaobjects.shared.structural import KEY_IS_ARRAY
|
|
53
|
+
|
|
54
|
+
from .meta_object import MetaObject
|
|
55
|
+
|
|
56
|
+
#: Maximum nested-object recursion depth. Mirrors the render
|
|
57
|
+
#: ``OutputFormatRenderer.MAX_NEST_DEPTH`` (and FR-012) — must stay identical
|
|
58
|
+
#: cross-port (Java ``MetaObjectExtractor.MAX_NEST_DEPTH = 8``). At or beyond this depth
|
|
59
|
+
#: a nested OBJECT field becomes an opaque STRING leaf instead of recursing.
|
|
60
|
+
MAX_NEST_DEPTH = 8
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# =============================================================================
|
|
64
|
+
# ExtractError + or_throw — the opt-in strictness gate
|
|
65
|
+
# =============================================================================
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ExtractError(Exception):
|
|
69
|
+
"""Raised by :func:`or_throw` when a extraction lost one or more ``@required``
|
|
70
|
+
fields. ``lost_required`` lists the field paths that were absent/uncoercible."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, lost_required: list[str]) -> None:
|
|
73
|
+
self.lost_required = list(lost_required)
|
|
74
|
+
joined = ", ".join(self.lost_required)
|
|
75
|
+
super().__init__(f"extract lost required field(s): {joined}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def or_throw(result: ExtractionResult[object]) -> object:
|
|
79
|
+
"""Opt into strictness: return ``result.data`` iff no ``@required`` field was
|
|
80
|
+
lost, else raise :class:`ExtractError`. Mirrors the cross-port ``ExtractionResult.
|
|
81
|
+
orThrow()`` / TS free ``orThrow``. Extract itself NEVER raises — this is the
|
|
82
|
+
explicit gate a caller reaches for when a lost-required field should fail."""
|
|
83
|
+
if result.report.has_lost_required():
|
|
84
|
+
raise ExtractError(result.report.lost_required())
|
|
85
|
+
return result.data
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# =============================================================================
|
|
89
|
+
# Public API
|
|
90
|
+
# =============================================================================
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def extract_object(
|
|
94
|
+
mo: MetaObject,
|
|
95
|
+
text: str | None,
|
|
96
|
+
format: Format = Format.JSON,
|
|
97
|
+
opts: ExtractOptions | None = None,
|
|
98
|
+
) -> ExtractionResult[object]:
|
|
99
|
+
"""Extract ``text`` into a typed object graph described by ``mo``.
|
|
100
|
+
|
|
101
|
+
Pipeline: build a ``ExtractSchema`` from ``mo`` (driven entirely by metadata),
|
|
102
|
+
run the engine to get a forgiving ``dict``/``list`` tree + report, then assemble
|
|
103
|
+
that tree into a populated object graph.
|
|
104
|
+
|
|
105
|
+
:param mo: the object describing the expected shape (the single source of truth)
|
|
106
|
+
:param text: the dirty model output
|
|
107
|
+
:param format: JSON (default) or XML — drives the engine's locate/parse
|
|
108
|
+
:param opts: bounded runtime overrides (aliases/normalizers/on_field/tolerance)
|
|
109
|
+
:returns: the assembled object (a ``ValueObject`` unless a type is bound for the
|
|
110
|
+
FQN) + report. NEVER ``None``; NEVER raises.
|
|
111
|
+
"""
|
|
112
|
+
schema = extract_schema_for(mo, format)
|
|
113
|
+
outcome = extract(text, schema, opts)
|
|
114
|
+
obj = assemble(mo, outcome.data)
|
|
115
|
+
return ExtractionResult(data=obj, report=outcome.report)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# =============================================================================
|
|
119
|
+
# extract_schema_for — metadata -> ExtractSchema
|
|
120
|
+
# =============================================================================
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def extract_schema_for(mo: MetaObject, format: Format = Format.JSON) -> ExtractSchema:
|
|
124
|
+
"""Build a ``ExtractSchema`` for ``mo`` by walking its effective fields and
|
|
125
|
+
mapping each ``MetaField`` to a ``FieldSpec``. Recurses into nested OBJECT fields
|
|
126
|
+
via ``@objectRef``, guarded against cycles/over-depth by an identity visited-set
|
|
127
|
+
keyed on ``id(MetaObject)`` + a depth counter bounded by :data:`MAX_NEST_DEPTH`."""
|
|
128
|
+
return _extract_schema_for(mo, format, set(), 0)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _extract_schema_for(
|
|
132
|
+
mo: MetaObject, format: Format, visited: set[int], depth: int
|
|
133
|
+
) -> ExtractSchema:
|
|
134
|
+
visited.add(id(mo))
|
|
135
|
+
fields = [_field_spec_for(f, mo, format, visited, depth) for f in mo.fields()]
|
|
136
|
+
visited.discard(id(mo))
|
|
137
|
+
# root_name is the simple (short) name; the engine's XML locate uses it as the root tag.
|
|
138
|
+
return ExtractSchema(format, mo.name, fields)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _field_spec_for(
|
|
142
|
+
field: MetaField,
|
|
143
|
+
owner: MetaObject,
|
|
144
|
+
format: Format,
|
|
145
|
+
visited: set[int],
|
|
146
|
+
depth: int,
|
|
147
|
+
) -> FieldSpec:
|
|
148
|
+
name = field.name
|
|
149
|
+
required = _is_required(field)
|
|
150
|
+
array = _is_array(field)
|
|
151
|
+
|
|
152
|
+
# --- Enum (scalar or array) ----------------------------------------------
|
|
153
|
+
if field.sub_type == fc.FIELD_SUBTYPE_ENUM:
|
|
154
|
+
values = _enum_values(field)
|
|
155
|
+
aliases = _enum_aliases(field)
|
|
156
|
+
cd = _own_attr_string(field, fc.FIELD_ATTR_COERCE_DEFAULT)
|
|
157
|
+
dv = _own_attr_string(field, fc.FIELD_ATTR_DEFAULT)
|
|
158
|
+
normalize = _resolve_normalize(field, owner)
|
|
159
|
+
if array:
|
|
160
|
+
return FieldSpec.enum_array(name, required, values, aliases, cd, normalize, dv)
|
|
161
|
+
return FieldSpec.enum_field(name, required, values, aliases, cd, normalize, dv)
|
|
162
|
+
|
|
163
|
+
# --- Nested object (single or array) --------------------------------------
|
|
164
|
+
if field.sub_type == fc.FIELD_SUBTYPE_OBJECT or field.object_ref is not None:
|
|
165
|
+
ref = _resolve_object_ref(field)
|
|
166
|
+
if ref is None or id(ref) in visited or depth + 1 >= MAX_NEST_DEPTH:
|
|
167
|
+
# Opaque leaf — never recurse into an unresolved ref / a cycle / past the
|
|
168
|
+
# depth bound.
|
|
169
|
+
return FieldSpec.scalar(name, FieldKind.STRING, required)
|
|
170
|
+
nested = _extract_schema_for(ref, format, visited, depth + 1)
|
|
171
|
+
return FieldSpec.object_(name, required, array, nested)
|
|
172
|
+
|
|
173
|
+
# --- Scalar (carry generalized @default) ----------------------------------
|
|
174
|
+
kind = _scalar_kind(field.sub_type)
|
|
175
|
+
if array:
|
|
176
|
+
# Scalar array: the engine coerces each element; no per-element default fill.
|
|
177
|
+
return FieldSpec.scalar_array(name, kind, required)
|
|
178
|
+
# @xmlText: a non-array scalar marked to receive its element's XML text content
|
|
179
|
+
# (JAXB @XmlValue / Jackson @JacksonXmlText / .NET [XmlText]) instead of a same-named
|
|
180
|
+
# child. Mirrors the TS fieldSpecFor textContentField branch. No effect for JSON.
|
|
181
|
+
if _text_content(field):
|
|
182
|
+
return FieldSpec.text_content_field(name, kind, required)
|
|
183
|
+
# Numeric range: source the bound from the field's numeric validator (@min/@max) — the single
|
|
184
|
+
# source of truth — so the engine clamps (lenient) / rejects (strict) out-of-range values.
|
|
185
|
+
if kind in (FieldKind.INT, FieldKind.LONG, FieldKind.DOUBLE):
|
|
186
|
+
num_min = _numeric_bound(field, vc.VALIDATOR_ATTR_MIN)
|
|
187
|
+
num_max = _numeric_bound(field, vc.VALIDATOR_ATTR_MAX)
|
|
188
|
+
if num_min is not None or num_max is not None:
|
|
189
|
+
return FieldSpec.range_(name, kind, required, num_min, num_max)
|
|
190
|
+
dv = _own_attr_string(field, fc.FIELD_ATTR_DEFAULT)
|
|
191
|
+
return FieldSpec.scalar(name, kind, required, dv)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _numeric_bound(field: MetaField, attr_name: str) -> float | None:
|
|
195
|
+
"""Read a numeric bound (@min/@max) from the field's ``validator.numeric`` child — the
|
|
196
|
+
canonical range source — or ``None`` if there is no such validator/bound."""
|
|
197
|
+
for c in field.children():
|
|
198
|
+
if c.type == TYPE_VALIDATOR and c.sub_type == vc.VALIDATOR_SUBTYPE_NUMERIC:
|
|
199
|
+
val = c.attr(attr_name)
|
|
200
|
+
if isinstance(val, (int, float)) and not isinstance(val, bool):
|
|
201
|
+
return float(val)
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# =============================================================================
|
|
206
|
+
# assemble — extracted dict/list tree -> object graph
|
|
207
|
+
# =============================================================================
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def assemble(mo: MetaObject, data: dict[str, object] | None) -> object:
|
|
211
|
+
"""Assemble a extracted ``dict`` (the engine's forgiving tree) into a populated
|
|
212
|
+
object graph described by ``mo``.
|
|
213
|
+
|
|
214
|
+
``mo.new_instance()`` yields the bound type (or a ``ValueObject``) with the
|
|
215
|
+
back-ref already set. Each field's value is written via the ``MetaField`` SPI:
|
|
216
|
+
|
|
217
|
+
* scalar / enum / scalar-array / enum-array → ``set_value`` (engine already
|
|
218
|
+
coerced; arrays arrive as a list);
|
|
219
|
+
* OBJECT non-array → recursively assemble the child ``dict``, then ``set_value``;
|
|
220
|
+
* OBJECT array → recursively assemble each element ``dict`` into a list, then
|
|
221
|
+
``set_value``.
|
|
222
|
+
|
|
223
|
+
Cycle/depth-guarded identically to :func:`extract_schema_for`. NEVER raises on
|
|
224
|
+
lost/malformed data — those were classified in the report during the engine pass.
|
|
225
|
+
"""
|
|
226
|
+
return _assemble(mo, data, set(), 0)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _assemble(
|
|
230
|
+
mo: MetaObject, data: dict[str, object] | None, visited: set[int], depth: int
|
|
231
|
+
) -> object:
|
|
232
|
+
o = mo.new_instance()
|
|
233
|
+
if data is None:
|
|
234
|
+
return o
|
|
235
|
+
visited.add(id(mo))
|
|
236
|
+
for field in mo.fields():
|
|
237
|
+
name = field.name
|
|
238
|
+
value = data.get(name)
|
|
239
|
+
|
|
240
|
+
is_object_field = (
|
|
241
|
+
field.sub_type == fc.FIELD_SUBTYPE_OBJECT or field.object_ref is not None
|
|
242
|
+
)
|
|
243
|
+
# Enum fields are string-backed scalars/arrays — treat them as scalar assignment.
|
|
244
|
+
if not is_object_field or field.sub_type == fc.FIELD_SUBTYPE_ENUM:
|
|
245
|
+
# scalar / enum / scalar-array / enum-array: the engine already coerced it.
|
|
246
|
+
if value is not None:
|
|
247
|
+
field.set_value(o, value)
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
# Nested object — guard cycles/depth (mirror the schema guard).
|
|
251
|
+
ref = _resolve_object_ref(field)
|
|
252
|
+
cyclic_or_deep = (
|
|
253
|
+
ref is None or id(ref) in visited or depth + 1 >= MAX_NEST_DEPTH
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
if _is_array(field):
|
|
257
|
+
# Array-of-objects: map each element dict -> assembled child.
|
|
258
|
+
if isinstance(value, list) and ref is not None and not cyclic_or_deep:
|
|
259
|
+
children: list[object] = [
|
|
260
|
+
_assemble(ref, elem, visited, depth + 1)
|
|
261
|
+
for elem in value
|
|
262
|
+
if isinstance(elem, dict)
|
|
263
|
+
]
|
|
264
|
+
field.set_value(o, children)
|
|
265
|
+
elif isinstance(value, list):
|
|
266
|
+
# ref unresolved / cyclic / over-deep: empty list (no recursion).
|
|
267
|
+
field.set_value(o, [])
|
|
268
|
+
# absent array stays absent (the engine reports it).
|
|
269
|
+
else:
|
|
270
|
+
# Single object.
|
|
271
|
+
if ref is not None and not cyclic_or_deep and isinstance(value, dict):
|
|
272
|
+
child = _assemble(ref, value, visited, depth + 1)
|
|
273
|
+
field.set_value(o, child)
|
|
274
|
+
# else leave unset.
|
|
275
|
+
visited.discard(id(mo))
|
|
276
|
+
return o
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# =============================================================================
|
|
280
|
+
# Field-spec helpers (mirror codegen fr010_field_mapping + the Java/TS reader)
|
|
281
|
+
# =============================================================================
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _scalar_kind(sub_type: str) -> FieldKind:
|
|
285
|
+
"""The engine ``FieldKind`` for a scalar field subtype. Unknown subtypes fall
|
|
286
|
+
back to STRING. Mirrors the cross-port ``scalarKind`` ordering."""
|
|
287
|
+
if sub_type in (
|
|
288
|
+
fc.FIELD_SUBTYPE_STRING,
|
|
289
|
+
fc.FIELD_SUBTYPE_UUID,
|
|
290
|
+
fc.FIELD_SUBTYPE_DATE,
|
|
291
|
+
fc.FIELD_SUBTYPE_TIME,
|
|
292
|
+
fc.FIELD_SUBTYPE_TIMESTAMP,
|
|
293
|
+
):
|
|
294
|
+
return FieldKind.STRING
|
|
295
|
+
if sub_type == fc.FIELD_SUBTYPE_INT:
|
|
296
|
+
return FieldKind.INT
|
|
297
|
+
if sub_type in (fc.FIELD_SUBTYPE_LONG, fc.FIELD_SUBTYPE_CURRENCY):
|
|
298
|
+
return FieldKind.LONG
|
|
299
|
+
if sub_type in (
|
|
300
|
+
fc.FIELD_SUBTYPE_DOUBLE,
|
|
301
|
+
fc.FIELD_SUBTYPE_FLOAT,
|
|
302
|
+
fc.FIELD_SUBTYPE_DECIMAL,
|
|
303
|
+
):
|
|
304
|
+
return FieldKind.DOUBLE
|
|
305
|
+
if sub_type == fc.FIELD_SUBTYPE_BOOLEAN:
|
|
306
|
+
return FieldKind.BOOLEAN
|
|
307
|
+
return FieldKind.STRING
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _is_array(field: MetaField) -> bool:
|
|
311
|
+
"""Array-ness from either form: the node property (programmatic build) or the
|
|
312
|
+
``@isArray`` attr (how metadata loads from JSON). Mirrors ``fr010_field_mapping``."""
|
|
313
|
+
return bool(field.is_array) or field.attr(KEY_IS_ARRAY) is True
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _is_required(field: MetaField) -> bool:
|
|
317
|
+
"""True iff ``@required`` is explicitly the bool ``True`` or the string ``"true"``."""
|
|
318
|
+
v = field.attr(fc.FIELD_ATTR_REQUIRED)
|
|
319
|
+
if v is True:
|
|
320
|
+
return True
|
|
321
|
+
return isinstance(v, str) and v.lower() == "true"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _text_content(field: MetaField) -> bool:
|
|
325
|
+
"""True iff ``@xmlText`` is explicitly the bool ``True`` or the string ``"true"`` —
|
|
326
|
+
the XML text-content extract marker (the field receives its element's text body).
|
|
327
|
+
Mirrors ``_is_required`` and the TS ``xmlText(field)`` own-attr check."""
|
|
328
|
+
v = field.attr(TEMPLATE_ATTR_XML_TEXT)
|
|
329
|
+
if v is True:
|
|
330
|
+
return True
|
|
331
|
+
return isinstance(v, str) and v.lower() == "true"
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _enum_values(field: MetaField) -> list[str]:
|
|
335
|
+
"""The string members of an enum field's ``@values`` attr (empty when absent)."""
|
|
336
|
+
v = field.attr(fc.FIELD_ATTR_VALUES)
|
|
337
|
+
if isinstance(v, (list, tuple)):
|
|
338
|
+
return [str(x) for x in v]
|
|
339
|
+
return []
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _enum_aliases(field: MetaField) -> dict[str, str]:
|
|
343
|
+
"""The ``@enumAlias`` map of an enum field, or ``{}`` when absent/empty."""
|
|
344
|
+
raw = field.attr(fc.FIELD_ATTR_ENUM_ALIAS)
|
|
345
|
+
if not isinstance(raw, dict):
|
|
346
|
+
return {}
|
|
347
|
+
return {str(k): str(v) for k, v in raw.items() if v is not None}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _resolve_normalize(field: MetaField, owner: MetaObject | None) -> str:
|
|
351
|
+
"""Resolve the enum normalization mode: field-level ``@normalize``, else the
|
|
352
|
+
owning object's ``@normalize``, else the global default (``"strip"``). Mirrors the
|
|
353
|
+
cross-port ``resolveNormalize``."""
|
|
354
|
+
field_mode = _own_attr_string(field, fc.FIELD_ATTR_NORMALIZE)
|
|
355
|
+
if field_mode is not None:
|
|
356
|
+
return field_mode
|
|
357
|
+
if owner is not None:
|
|
358
|
+
owner_mode = _own_attr_string(owner, fc.FIELD_ATTR_NORMALIZE)
|
|
359
|
+
if owner_mode is not None:
|
|
360
|
+
return owner_mode
|
|
361
|
+
return fc.NORMALIZE_DEFAULT
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _own_attr_string(node: MetaData, attr: str) -> str | None:
|
|
365
|
+
"""The own (non-inherited) string value of an attr on a node, or ``None`` when
|
|
366
|
+
absent/empty."""
|
|
367
|
+
v = node.attr(attr)
|
|
368
|
+
if isinstance(v, str):
|
|
369
|
+
return v if v else None
|
|
370
|
+
return None if v is None else str(v)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _resolve_object_ref(field: MetaField) -> MetaObject | None:
|
|
374
|
+
"""Resolve a field's ``@objectRef`` to its ``MetaObject`` by walking to the tree
|
|
375
|
+
root and matching on ``resolution_key()`` (the package-folded FQN the objectRef
|
|
376
|
+
uses), with a short-name fallback. Returns ``None`` when unresolvable (→ the
|
|
377
|
+
opaque-leaf / leave-unset guard). Reflection-free: a pure metadata-tree walk."""
|
|
378
|
+
ref = field.object_ref
|
|
379
|
+
if ref is None:
|
|
380
|
+
return None
|
|
381
|
+
root = _root_of(field)
|
|
382
|
+
if root is None:
|
|
383
|
+
return None
|
|
384
|
+
objects = [c for c in root.children() if isinstance(c, MetaObject)]
|
|
385
|
+
direct = next((o for o in objects if o.resolution_key() == ref), None)
|
|
386
|
+
if direct is not None:
|
|
387
|
+
return direct
|
|
388
|
+
# Short-name fallback: a bare ref or the trailing segment after the last "::".
|
|
389
|
+
from metaobjects.shared.separators import PACKAGE_SEP
|
|
390
|
+
|
|
391
|
+
short = ref.rsplit(PACKAGE_SEP, 1)[-1] if PACKAGE_SEP in ref else ref
|
|
392
|
+
return next((o for o in objects if o.name == short), None)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _root_of(node: MetaData) -> MetaData | None:
|
|
396
|
+
"""Walk the parent chain to the tree root (the node whose ``parent`` is None)."""
|
|
397
|
+
cur: MetaData | None = node
|
|
398
|
+
while cur is not None and cur.parent is not None:
|
|
399
|
+
cur = cur.parent
|
|
400
|
+
return cur
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""ValueObject — the dict-backed default backing object for ``object.value``.
|
|
2
|
+
|
|
3
|
+
Idiomatic Python port of Java's ``ValueObject`` / the TS ``ValueObject``: a
|
|
4
|
+
string-keyed mapping that holds its ``MetaObject`` back-reference and supports
|
|
5
|
+
both declared fields and arbitrary overflow keys. THE unbound default produced
|
|
6
|
+
by ``MetaObject.new_instance()`` when no native type is registered for the
|
|
7
|
+
object's resolution key. Reflection-free.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING, Optional
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .meta_object import MetaObject
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ValueObject:
|
|
18
|
+
"""A dict-backed backing object — declared fields AND overflow keys.
|
|
19
|
+
|
|
20
|
+
Implements the ``MetaObjectAware`` contract structurally
|
|
21
|
+
(``get_meta_data`` / ``set_meta_data``) without importing the protocol.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, mo: "Optional[MetaObject]" = None) -> None:
|
|
25
|
+
"""Construct over an optional MetaObject back-reference (set at new_instance)."""
|
|
26
|
+
self._values: dict[str, object] = {}
|
|
27
|
+
self._meta_data: Optional[MetaObject] = mo
|
|
28
|
+
|
|
29
|
+
# -- MetaObjectAware ------------------------------------------------
|
|
30
|
+
|
|
31
|
+
def get_meta_data(self) -> "Optional[MetaObject]":
|
|
32
|
+
"""The MetaObject describing this instance, or None if not attached."""
|
|
33
|
+
return self._meta_data
|
|
34
|
+
|
|
35
|
+
def set_meta_data(self, mo: "MetaObject") -> None:
|
|
36
|
+
"""Attach the MetaObject describing this instance (back-reference)."""
|
|
37
|
+
self._meta_data = mo
|
|
38
|
+
|
|
39
|
+
# -- dict-backed value access (declared fields + overflow keys) -----
|
|
40
|
+
|
|
41
|
+
def get(self, name: str) -> object:
|
|
42
|
+
"""Read a value by key. Returns None for an unset key."""
|
|
43
|
+
return self._values.get(name)
|
|
44
|
+
|
|
45
|
+
def set(self, name: str, value: object) -> None:
|
|
46
|
+
"""Write a value by key. Any string key is accepted (overflow-friendly)."""
|
|
47
|
+
self._values[name] = value
|
|
48
|
+
|
|
49
|
+
def has(self, name: str) -> bool:
|
|
50
|
+
"""True if the key has been set."""
|
|
51
|
+
return name in self._values
|
|
52
|
+
|
|
53
|
+
def delete(self, name: str) -> bool:
|
|
54
|
+
"""Remove a key; True if it was present."""
|
|
55
|
+
if name in self._values:
|
|
56
|
+
del self._values[name]
|
|
57
|
+
return True
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
def keys(self) -> list[str]:
|
|
61
|
+
"""Insertion-ordered keys currently held."""
|
|
62
|
+
return list(self._values.keys())
|
|
63
|
+
|
|
64
|
+
def to_dict(self) -> dict[str, object]:
|
|
65
|
+
"""A shallow-copy snapshot of the backing map (insertion-ordered)."""
|
|
66
|
+
return dict(self._values)
|
|
67
|
+
|
|
68
|
+
def __repr__(self) -> str:
|
|
69
|
+
mo = self._meta_data.name if self._meta_data is not None else None
|
|
70
|
+
return f"ValueObject(meta={mo!r}, {self._values!r})"
|
|
File without changes
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""M:N junction FK derivation — the single source of truth for which junction
|
|
2
|
+
columns are the SOURCE side and the TARGET side of a many-to-many relationship.
|
|
3
|
+
|
|
4
|
+
A M:N relationship (``@cardinality: "many"``, ``@objectRef: <target>``,
|
|
5
|
+
``@through: <junction>``) does NOT restate its FK columns. They are derived from
|
|
6
|
+
the junction entity's two ``identity.reference`` children — one resolving to the
|
|
7
|
+
source entity, one to the target — exactly as 1:N FK direction is declared.
|
|
8
|
+
|
|
9
|
+
Three modes (see the FR-018 design + the TS reference ``derive-m2m-fields.ts``):
|
|
10
|
+
1. Hetero (source != target): the reference resolving to the source entity
|
|
11
|
+
gives source_field; the one resolving to the target gives target_field.
|
|
12
|
+
2. Directed self-join (source == target, @sourceRefField set): both references
|
|
13
|
+
resolve to the same entity, so @sourceRefField names the source-side FK
|
|
14
|
+
field; the OTHER reference is the target side.
|
|
15
|
+
3. Symmetric self-join (source == target, @symmetric: true): undirected; the
|
|
16
|
+
two references are taken in declaration order (source_field = first,
|
|
17
|
+
target_field = second). Resolution unions both at read time.
|
|
18
|
+
Ambiguous (source == target, neither @sourceRefField nor @symmetric) → raise.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
from ...meta_data import MetaData
|
|
25
|
+
from ....shared.base_types import TYPE_IDENTITY
|
|
26
|
+
from ....shared.separators import PACKAGE_SEP
|
|
27
|
+
from ..identity.identity_constants import (
|
|
28
|
+
IDENTITY_ATTR_FIELDS,
|
|
29
|
+
IDENTITY_REFERENCE_ATTR_REFERENCES,
|
|
30
|
+
IDENTITY_SUBTYPE_REFERENCE,
|
|
31
|
+
)
|
|
32
|
+
from .meta_relationship import MetaRelationship
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class M2MDerivationError(Exception):
|
|
36
|
+
"""Raised when a M:N relationship's junction FK fields cannot be derived."""
|
|
37
|
+
|
|
38
|
+
code = "ERR_INVALID_RELATIONSHIP"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class M2MFields:
|
|
43
|
+
"""The derived source/target junction FK fields for a M:N relationship."""
|
|
44
|
+
|
|
45
|
+
source_field: str
|
|
46
|
+
target_field: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _strip_package(name: str) -> str:
|
|
50
|
+
"""Return the bare object name from a (possibly package-qualified) reference."""
|
|
51
|
+
idx = name.rfind(PACKAGE_SEP)
|
|
52
|
+
return name[idx + len(PACKAGE_SEP):] if idx >= 0 else name
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _reference_children(junction: MetaData) -> list[MetaData]:
|
|
56
|
+
"""The junction's own identity.reference children (declaration order)."""
|
|
57
|
+
return [
|
|
58
|
+
c
|
|
59
|
+
for c in junction.own_children()
|
|
60
|
+
if c.type == TYPE_IDENTITY and c.sub_type == IDENTITY_SUBTYPE_REFERENCE
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _ref_fk_field(ref: MetaData) -> str | None:
|
|
65
|
+
"""First @fields entry of a reference (the physical FK column on the junction)."""
|
|
66
|
+
fields = ref.attr(IDENTITY_ATTR_FIELDS)
|
|
67
|
+
if isinstance(fields, (list, tuple)) and fields:
|
|
68
|
+
first = fields[0]
|
|
69
|
+
return first if isinstance(first, str) else None
|
|
70
|
+
if isinstance(fields, str) and fields:
|
|
71
|
+
return fields.split(",")[0].strip() or None
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _ref_target_entity(ref: MetaData) -> str | None:
|
|
76
|
+
"""The @references target-entity name of a reference (bare, package-stripped)."""
|
|
77
|
+
v = ref.attr(IDENTITY_REFERENCE_ATTR_REFERENCES)
|
|
78
|
+
return _strip_package(v) if isinstance(v, str) and v else None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def derive_m2m_fields(
|
|
82
|
+
rel: MetaRelationship,
|
|
83
|
+
source: MetaData,
|
|
84
|
+
object_index: dict[str, MetaData],
|
|
85
|
+
) -> M2MFields:
|
|
86
|
+
"""Derive the source/target junction FK fields for a M:N relationship.
|
|
87
|
+
|
|
88
|
+
*object_index* is a bare-name → object map of the loaded model's top-level
|
|
89
|
+
objects (the Python loader's resolution surface; mirrors the TS
|
|
90
|
+
``root.findObject``). Raises :class:`M2MDerivationError` when the junction is
|
|
91
|
+
missing/malformed or the self-join is ambiguous.
|
|
92
|
+
"""
|
|
93
|
+
through_name = rel.through()
|
|
94
|
+
if through_name is None:
|
|
95
|
+
raise M2MDerivationError(
|
|
96
|
+
f'relationship "{source.name}.{rel.name}" is missing @through '
|
|
97
|
+
f"(required for M:N derivation)"
|
|
98
|
+
)
|
|
99
|
+
junction = object_index.get(_strip_package(through_name))
|
|
100
|
+
if junction is None:
|
|
101
|
+
raise M2MDerivationError(
|
|
102
|
+
f'relationship "{source.name}.{rel.name}" @through "{through_name}" '
|
|
103
|
+
f"does not resolve to an entity"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
target_name = rel.object_ref()
|
|
107
|
+
if target_name is None:
|
|
108
|
+
raise M2MDerivationError(
|
|
109
|
+
f'relationship "{source.name}.{rel.name}" is missing @objectRef '
|
|
110
|
+
f"(the M:N target)"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
refs = _reference_children(junction)
|
|
114
|
+
if len(refs) != 2:
|
|
115
|
+
raise M2MDerivationError(
|
|
116
|
+
f'junction "{through_name}" for relationship "{source.name}.{rel.name}" '
|
|
117
|
+
f"must declare exactly two identity.reference children "
|
|
118
|
+
f"(found {len(refs)})"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
is_self_join = _strip_package(target_name) == source.name
|
|
122
|
+
|
|
123
|
+
if not is_self_join:
|
|
124
|
+
# Hetero: match each reference by the entity it resolves to.
|
|
125
|
+
source_ref = next(
|
|
126
|
+
(r for r in refs if _ref_target_entity(r) == source.name), None
|
|
127
|
+
)
|
|
128
|
+
target_ref = next(
|
|
129
|
+
(r for r in refs if _ref_target_entity(r) == _strip_package(target_name)),
|
|
130
|
+
None,
|
|
131
|
+
)
|
|
132
|
+
source_field = _ref_fk_field(source_ref) if source_ref is not None else None
|
|
133
|
+
target_field = _ref_fk_field(target_ref) if target_ref is not None else None
|
|
134
|
+
if source_field is None or target_field is None:
|
|
135
|
+
raise M2MDerivationError(
|
|
136
|
+
f'junction "{through_name}" for relationship '
|
|
137
|
+
f'"{source.name}.{rel.name}" must declare one identity.reference '
|
|
138
|
+
f'to "{source.name}" and one to "{_strip_package(target_name)}"'
|
|
139
|
+
)
|
|
140
|
+
return M2MFields(source_field=source_field, target_field=target_field)
|
|
141
|
+
|
|
142
|
+
# Self-join: both references resolve to the same entity.
|
|
143
|
+
if rel.symmetric():
|
|
144
|
+
# Undirected: take references in declaration order; union at read time.
|
|
145
|
+
a = _ref_fk_field(refs[0])
|
|
146
|
+
b = _ref_fk_field(refs[1])
|
|
147
|
+
if a is None or b is None:
|
|
148
|
+
raise M2MDerivationError(
|
|
149
|
+
f'symmetric junction "{through_name}" for '
|
|
150
|
+
f'"{source.name}.{rel.name}" has a reference with no @fields'
|
|
151
|
+
)
|
|
152
|
+
return M2MFields(source_field=a, target_field=b)
|
|
153
|
+
|
|
154
|
+
source_ref_field = rel.source_ref_field()
|
|
155
|
+
if source_ref_field is None:
|
|
156
|
+
raise M2MDerivationError(
|
|
157
|
+
f'self-join relationship "{source.name}.{rel.name}" through '
|
|
158
|
+
f'"{through_name}" is ambiguous: set @sourceRefField (directed) or '
|
|
159
|
+
f"@symmetric (undirected)"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Directed self-join: @sourceRefField names the source-side FK; the other
|
|
163
|
+
# reference is the target side.
|
|
164
|
+
source_ref = next(
|
|
165
|
+
(r for r in refs if _ref_fk_field(r) == source_ref_field), None
|
|
166
|
+
)
|
|
167
|
+
if source_ref is None:
|
|
168
|
+
raise M2MDerivationError(
|
|
169
|
+
f'@sourceRefField "{source_ref_field}" on "{source.name}.{rel.name}" '
|
|
170
|
+
f"does not match any identity.reference FK field on junction "
|
|
171
|
+
f'"{through_name}"'
|
|
172
|
+
)
|
|
173
|
+
target_ref = next((r for r in refs if r is not source_ref), None)
|
|
174
|
+
target_field = _ref_fk_field(target_ref) if target_ref is not None else None
|
|
175
|
+
if target_field is None:
|
|
176
|
+
raise M2MDerivationError(
|
|
177
|
+
f'junction "{through_name}" for "{source.name}.{rel.name}" has no '
|
|
178
|
+
f"distinct target-side reference"
|
|
179
|
+
)
|
|
180
|
+
return M2MFields(source_field=source_ref_field, target_field=target_field)
|