modelable 1.0.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.
Potentially problematic release.
This version of modelable might be problematic. Click here for more details.
- modelable/__init__.py +1 -0
- modelable/__main__.py +3 -0
- modelable/_pydantic_py314_compat.py +31 -0
- modelable/cli.py +41 -0
- modelable/commands/__init__.py +1 -0
- modelable/commands/apicurio.py +84 -0
- modelable/commands/codegen.py +241 -0
- modelable/commands/common.py +43 -0
- modelable/commands/compile.py +237 -0
- modelable/commands/create.py +164 -0
- modelable/commands/diff.py +82 -0
- modelable/commands/graph.py +53 -0
- modelable/commands/llm.py +564 -0
- modelable/commands/lsp.py +15 -0
- modelable/commands/runtime.py +37 -0
- modelable/commands/scenario.py +104 -0
- modelable/commands/spec.py +197 -0
- modelable/commands/workspace.py +240 -0
- modelable/compat/__init__.py +11 -0
- modelable/compat/checker.py +179 -0
- modelable/compat/diff.py +169 -0
- modelable/compiler/__init__.py +3 -0
- modelable/compiler/compiler.py +19 -0
- modelable/compiler/workspace.py +346 -0
- modelable/diagnostics/__init__.py +3 -0
- modelable/diagnostics/model.py +27 -0
- modelable/emitters/__init__.py +0 -0
- modelable/emitters/base.py +22 -0
- modelable/emitters/csharp.py +245 -0
- modelable/emitters/dbt_yaml.py +290 -0
- modelable/emitters/diagnostics.py +25 -0
- modelable/emitters/fhir.py +694 -0
- modelable/emitters/fhir_validator.py +36 -0
- modelable/emitters/go.py +334 -0
- modelable/emitters/java.py +264 -0
- modelable/emitters/json_schema.py +458 -0
- modelable/emitters/markdown.py +252 -0
- modelable/emitters/odcs.py +355 -0
- modelable/emitters/openlineage.py +315 -0
- modelable/emitters/openmetadata.py +258 -0
- modelable/emitters/python.py +282 -0
- modelable/emitters/rust.py +643 -0
- modelable/emitters/shapes.py +261 -0
- modelable/emitters/sql.py +266 -0
- modelable/emitters/targets.py +141 -0
- modelable/emitters/typescript.py +352 -0
- modelable/expressions/__init__.py +0 -0
- modelable/expressions/cel.py +547 -0
- modelable/governance/__init__.py +3 -0
- modelable/governance/checker.py +271 -0
- modelable/governance/por.py +46 -0
- modelable/grammar/__init__.py +1 -0
- modelable/grammar/modelable.lark +257 -0
- modelable/graph/__init__.py +5 -0
- modelable/graph/export.py +442 -0
- modelable/llm/__init__.py +43 -0
- modelable/llm/chat.py +255 -0
- modelable/llm/config.py +87 -0
- modelable/llm/context.py +194 -0
- modelable/llm/engine.py +976 -0
- modelable/llm/importers.py +1077 -0
- modelable/llm/provenance.py +84 -0
- modelable/llm/providers.py +182 -0
- modelable/llm/qa.py +126 -0
- modelable/llm/recommendations.py +33 -0
- modelable/llm/redaction.py +19 -0
- modelable/llm/render.py +279 -0
- modelable/llm/update_plan.py +101 -0
- modelable/llm/validation_help.py +10 -0
- modelable/lsp/__init__.py +3 -0
- modelable/lsp/__main__.py +4 -0
- modelable/lsp/code_actions.py +210 -0
- modelable/lsp/completion.py +480 -0
- modelable/lsp/definition.py +343 -0
- modelable/lsp/diagnostics.py +31 -0
- modelable/lsp/document_symbols.py +197 -0
- modelable/lsp/federation.py +261 -0
- modelable/lsp/folding.py +33 -0
- modelable/lsp/formatting.py +64 -0
- modelable/lsp/highlight.py +30 -0
- modelable/lsp/hover.py +370 -0
- modelable/lsp/inlay_hints.py +158 -0
- modelable/lsp/references.py +511 -0
- modelable/lsp/rename.py +564 -0
- modelable/lsp/semantic_tokens.py +412 -0
- modelable/lsp/server.py +370 -0
- modelable/lsp/workspace.py +83 -0
- modelable/lsp/workspace_symbols.py +104 -0
- modelable/parser/__init__.py +94 -0
- modelable/parser/ir.py +451 -0
- modelable/parser/parse.py +47 -0
- modelable/parser/transformer.py +798 -0
- modelable/parser/wire.py +68 -0
- modelable/planner/__init__.py +0 -0
- modelable/planner/lineage.py +91 -0
- modelable/planner/planner.py +134 -0
- modelable/planner/plans.py +122 -0
- modelable/py.typed +0 -0
- modelable/registry/__init__.py +9 -0
- modelable/registry/apicurio.py +166 -0
- modelable/registry/base.py +18 -0
- modelable/registry/factory.py +18 -0
- modelable/registry/index.py +419 -0
- modelable/registry/local.py +26 -0
- modelable/registry/oci.py +22 -0
- modelable/registry/resolver.py +213 -0
- modelable/registry/schema.sql +119 -0
- modelable/registry/signature.py +26 -0
- modelable/release.py +125 -0
- modelable/runtime/__init__.py +5 -0
- modelable/runtime/adapter/__init__.py +17 -0
- modelable/runtime/adapter/base.py +18 -0
- modelable/runtime/adapter/postgres.py +82 -0
- modelable/specs/__init__.py +23 -0
- modelable/specs/tracking.py +220 -0
- modelable/validation/__init__.py +3 -0
- modelable/validation/semantic.py +659 -0
- modelable-1.0.0.dist-info/METADATA +61 -0
- modelable-1.0.0.dist-info/RECORD +122 -0
- modelable-1.0.0.dist-info/WHEEL +4 -0
- modelable-1.0.0.dist-info/entry_points.txt +2 -0
- modelable-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from dataclasses import field as dc_field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from modelable.compiler.workspace import Workspace
|
|
9
|
+
from modelable.emitters.base import EmittedArtifact, compute_content_hash
|
|
10
|
+
from modelable.emitters.diagnostics import type_loss
|
|
11
|
+
from modelable.emitters.shapes import TypeShape
|
|
12
|
+
from modelable.parser.ir import DirectMapping, DomainDef, MdlFile, ModelVersion, ProjectionVersion
|
|
13
|
+
from modelable.registry.resolver import resolve_model_ref
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class _FieldSpec:
|
|
18
|
+
index: int
|
|
19
|
+
name: str
|
|
20
|
+
annotation: str
|
|
21
|
+
optional: bool
|
|
22
|
+
serde_attrs: list[str] = dc_field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def emit_rust(workspace: Workspace, out_dir: Path) -> list[EmittedArtifact]:
|
|
26
|
+
"""Emit Rust source files for every model and projection version."""
|
|
27
|
+
postgres_sources = _adapter_bound_sources(workspace.mdl, "postgres")
|
|
28
|
+
clickhouse_sources = _adapter_bound_sources(workspace.mdl, "clickhouse")
|
|
29
|
+
artifacts: list[EmittedArtifact] = []
|
|
30
|
+
for domain in workspace.mdl.domains:
|
|
31
|
+
for model_name, versions in domain.models.items():
|
|
32
|
+
for version in versions:
|
|
33
|
+
artifacts.append(_emit_model(domain, model_name, version, out_dir))
|
|
34
|
+
for projection_name, versions in domain.projections.items():
|
|
35
|
+
for version in versions:
|
|
36
|
+
source = version.source.model
|
|
37
|
+
artifacts.append(
|
|
38
|
+
_emit_projection(
|
|
39
|
+
domain,
|
|
40
|
+
projection_name,
|
|
41
|
+
version,
|
|
42
|
+
out_dir,
|
|
43
|
+
workspace.mdl,
|
|
44
|
+
sqlx_fromrow=source in postgres_sources,
|
|
45
|
+
clickhouse_row=source in clickhouse_sources,
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
return artifacts
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _adapter_bound_sources(mdl: MdlFile, adapter_type: str) -> set[str]:
|
|
52
|
+
"""Return fully-qualified model names (domain.Model) bound to the given adapter type.
|
|
53
|
+
|
|
54
|
+
Handles two-level indirection: a model binding may reference a connector binding by
|
|
55
|
+
name (e.g. adapter: my-ch-conn), and the connector binding carries the actual
|
|
56
|
+
adapter type (e.g. adapter: clickhouse).
|
|
57
|
+
"""
|
|
58
|
+
adapter_types: dict[str, str] = {b.name: b.adapter for b in mdl.bindings if b.adapter}
|
|
59
|
+
sources: set[str] = set()
|
|
60
|
+
for b in mdl.bindings:
|
|
61
|
+
if not b.model:
|
|
62
|
+
continue
|
|
63
|
+
resolved = adapter_types.get(b.adapter, b.adapter)
|
|
64
|
+
if resolved == adapter_type:
|
|
65
|
+
sources.add(b.model)
|
|
66
|
+
return sources
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _artifact_id(domain: str, name: str, version: int) -> str:
|
|
70
|
+
return f"{domain}.{name}.v{version}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _pascalize(value: str) -> str:
|
|
74
|
+
parts = [part for part in re.split(r"[^A-Za-z0-9]+", value) if part]
|
|
75
|
+
return "".join(part[:1].upper() + part[1:] for part in parts) or "Generated"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _snake_case(value: str) -> str:
|
|
79
|
+
text = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", value)
|
|
80
|
+
text = re.sub(r"[^A-Za-z0-9]+", "_", text)
|
|
81
|
+
text = text.strip("_").lower()
|
|
82
|
+
return text or "generated"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _stable_type_name(domain: str, name: str, version: int) -> str:
|
|
86
|
+
return f"{_pascalize(domain)}{_pascalize(name)}V{version}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _emit_model(domain: DomainDef, model_name: str, version: ModelVersion, out_dir: Path) -> EmittedArtifact:
|
|
90
|
+
artifact_id = _artifact_id(domain.name, model_name, version.version)
|
|
91
|
+
type_name = _stable_type_name(domain.name, model_name, version.version)
|
|
92
|
+
nested_definitions: dict[str, list[str]] = {}
|
|
93
|
+
field_specs = _field_specs_from_model_fields(
|
|
94
|
+
version.fields, owner_type=type_name, path=[], definitions=nested_definitions
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
needs_serde_with = _any_needs_serde_with(field_specs)
|
|
98
|
+
needs_uuid = _any_needs_uuid(field_specs)
|
|
99
|
+
needs_serde_json = _any_needs_serde_json(field_specs)
|
|
100
|
+
lines = _header_lines(serde_with=needs_serde_with, uuid=needs_uuid, serde_json=needs_serde_json)
|
|
101
|
+
lines.extend(_render_struct_definition(type_name, field_specs))
|
|
102
|
+
lines.extend(_render_nested_definitions(nested_definitions))
|
|
103
|
+
|
|
104
|
+
text = "\n".join(lines) + "\n"
|
|
105
|
+
return EmittedArtifact(
|
|
106
|
+
target="rust",
|
|
107
|
+
ref=f"{domain.name}.{model_name}@{version.version}",
|
|
108
|
+
artifact_id=artifact_id,
|
|
109
|
+
path=out_dir / _module_path(domain.name, type_name),
|
|
110
|
+
content=text,
|
|
111
|
+
content_hash=compute_content_hash(text),
|
|
112
|
+
warnings=[],
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _emit_projection(
|
|
117
|
+
domain: DomainDef,
|
|
118
|
+
projection_name: str,
|
|
119
|
+
version: ProjectionVersion,
|
|
120
|
+
out_dir: Path,
|
|
121
|
+
mdl,
|
|
122
|
+
*,
|
|
123
|
+
sqlx_fromrow: bool = False,
|
|
124
|
+
clickhouse_row: bool = False,
|
|
125
|
+
) -> EmittedArtifact:
|
|
126
|
+
artifact_id = _artifact_id(domain.name, projection_name, version.version)
|
|
127
|
+
type_name = _stable_type_name(domain.name, projection_name, version.version)
|
|
128
|
+
nested_definitions: dict[str, list[str]] = {}
|
|
129
|
+
|
|
130
|
+
field_specs: list[_FieldSpec] = []
|
|
131
|
+
warnings: list[str] = []
|
|
132
|
+
for index, field in enumerate(version.fields):
|
|
133
|
+
field_shape = _resolve_projection_field_shape(field, version, mdl)
|
|
134
|
+
if field_shape is None:
|
|
135
|
+
warnings.append(type_loss(f"{domain.name}.{projection_name}.{field.name}"))
|
|
136
|
+
field_specs.append(_FieldSpec(index=index, name=field.name, annotation="String", optional=False))
|
|
137
|
+
continue
|
|
138
|
+
wire = _resolve_merged_projection_wire(field, version, mdl)
|
|
139
|
+
annotation = _shape_annotation(
|
|
140
|
+
field_shape,
|
|
141
|
+
owner_type=type_name,
|
|
142
|
+
path=[field.name],
|
|
143
|
+
definitions=nested_definitions,
|
|
144
|
+
rust_hint=wire.get("rust"),
|
|
145
|
+
clickhouse_hint=wire.get("clickhouse"),
|
|
146
|
+
)
|
|
147
|
+
optional = field_shape.optional or field_shape.nullable
|
|
148
|
+
serde_attrs = _serde_attrs_for_field(wire, field_shape, clickhouse=clickhouse_row)
|
|
149
|
+
field_specs.append(
|
|
150
|
+
_FieldSpec(index=index, name=field.name, annotation=annotation, optional=optional, serde_attrs=serde_attrs)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
needs_serde_with = _any_needs_serde_with(field_specs)
|
|
154
|
+
needs_uuid = _any_needs_uuid(field_specs)
|
|
155
|
+
needs_serde_json = _any_needs_serde_json(field_specs) or any(
|
|
156
|
+
_projection_field_is_json_passthrough_to_string(f, version, mdl) for f in version.fields
|
|
157
|
+
)
|
|
158
|
+
storage_gated = sqlx_fromrow or clickhouse_row
|
|
159
|
+
extra_derives: list[str] = []
|
|
160
|
+
if sqlx_fromrow:
|
|
161
|
+
extra_derives.append("sqlx::FromRow")
|
|
162
|
+
if clickhouse_row:
|
|
163
|
+
extra_derives.append("clickhouse::Row")
|
|
164
|
+
lines = _header_lines(
|
|
165
|
+
serde_with=needs_serde_with,
|
|
166
|
+
sqlx=sqlx_fromrow,
|
|
167
|
+
clickhouse=clickhouse_row,
|
|
168
|
+
uuid=needs_uuid,
|
|
169
|
+
serde_json=needs_serde_json,
|
|
170
|
+
)
|
|
171
|
+
lines.extend(
|
|
172
|
+
_render_struct_definition(type_name, field_specs, extra_derives=extra_derives, storage_gated=storage_gated)
|
|
173
|
+
)
|
|
174
|
+
lines.extend(_render_nested_definitions(nested_definitions))
|
|
175
|
+
lines.extend(_emit_from_impl(type_name, domain.name, version, mdl, storage_gated=storage_gated))
|
|
176
|
+
|
|
177
|
+
text = "\n".join(lines) + "\n"
|
|
178
|
+
return EmittedArtifact(
|
|
179
|
+
target="rust",
|
|
180
|
+
ref=f"{domain.name}.{projection_name}@{version.version}",
|
|
181
|
+
artifact_id=artifact_id,
|
|
182
|
+
path=out_dir / _module_path(domain.name, type_name),
|
|
183
|
+
content=text,
|
|
184
|
+
content_hash=compute_content_hash(text),
|
|
185
|
+
warnings=warnings,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _projection_field_is_json_passthrough_to_string(proj_field, version: ProjectionVersion, mdl: MdlFile) -> bool:
|
|
190
|
+
"""True if this projection field maps a map<K, json> (or bare json) source
|
|
191
|
+
field to a @wire(clickhouse: "string") String target — i.e. needs a
|
|
192
|
+
generated serde_json::to_string conversion in the From impl, and a
|
|
193
|
+
serde_json::Value-shaped header requirement even though the projection's
|
|
194
|
+
own field type is plain String.
|
|
195
|
+
"""
|
|
196
|
+
if not isinstance(proj_field.mapping, DirectMapping):
|
|
197
|
+
return False
|
|
198
|
+
field_shape = _resolve_projection_field_shape(proj_field, version, mdl)
|
|
199
|
+
if field_shape is None:
|
|
200
|
+
return False
|
|
201
|
+
is_json_value = (field_shape.kind == "primitive" and field_shape.ref == "json") or (
|
|
202
|
+
field_shape.kind == "map"
|
|
203
|
+
and field_shape.value is not None
|
|
204
|
+
and field_shape.value.kind == "primitive"
|
|
205
|
+
and field_shape.value.ref == "json"
|
|
206
|
+
)
|
|
207
|
+
if not is_json_value:
|
|
208
|
+
return False
|
|
209
|
+
wire = _resolve_merged_projection_wire(proj_field, version, mdl)
|
|
210
|
+
ch_hint = wire.get("clickhouse")
|
|
211
|
+
return ch_hint is not None and getattr(ch_hint, "encoding", None) == "string"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _emit_from_impl(
|
|
215
|
+
proj_type_name: str,
|
|
216
|
+
proj_domain: str,
|
|
217
|
+
version: ProjectionVersion,
|
|
218
|
+
mdl: MdlFile,
|
|
219
|
+
*,
|
|
220
|
+
storage_gated: bool = False,
|
|
221
|
+
) -> list[str]:
|
|
222
|
+
"""Emit impl From<SourceModel> for Projection.
|
|
223
|
+
|
|
224
|
+
Only generated for single-source projections (no joins) where the source model
|
|
225
|
+
is in the same domain as the projection. The caller is responsible for placing
|
|
226
|
+
both modules under a common parent so that super:: paths resolve.
|
|
227
|
+
"""
|
|
228
|
+
if version.joins:
|
|
229
|
+
return []
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
src_domain_str, src_model_name = version.source.model.rsplit(".", 1)
|
|
233
|
+
except ValueError:
|
|
234
|
+
return []
|
|
235
|
+
|
|
236
|
+
# Only generate when source and projection share the same domain (super:: path is valid)
|
|
237
|
+
if src_domain_str != proj_domain:
|
|
238
|
+
return []
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
resolved = resolve_model_ref(mdl, version.source.model, version.source.version)
|
|
242
|
+
except LookupError:
|
|
243
|
+
return []
|
|
244
|
+
|
|
245
|
+
src_version = resolved.version
|
|
246
|
+
src_type_name = _stable_type_name(src_domain_str, src_model_name, src_version.version)
|
|
247
|
+
src_module = _snake_case(src_type_name)
|
|
248
|
+
|
|
249
|
+
lines: list[str] = [""]
|
|
250
|
+
if storage_gated:
|
|
251
|
+
lines.append('#[cfg(feature = "storage")]')
|
|
252
|
+
lines.append(f"use super::{src_module}::{src_type_name};")
|
|
253
|
+
if storage_gated:
|
|
254
|
+
lines.append('#[cfg(feature = "storage")]')
|
|
255
|
+
lines.append(f"impl From<{src_type_name}> for {proj_type_name} {{")
|
|
256
|
+
lines.append(f" fn from(src: {src_type_name}) -> Self {{")
|
|
257
|
+
lines.append(" Self {")
|
|
258
|
+
|
|
259
|
+
for proj_field in version.fields:
|
|
260
|
+
rust_name = _field_name(proj_field.name)
|
|
261
|
+
if isinstance(proj_field.mapping, DirectMapping):
|
|
262
|
+
src_rust_name = _field_name(proj_field.mapping.source_field)
|
|
263
|
+
field_shape = _resolve_projection_field_shape(proj_field, version, mdl)
|
|
264
|
+
if _projection_field_is_json_passthrough_to_string(proj_field, version, mdl):
|
|
265
|
+
lines.append(
|
|
266
|
+
f" {rust_name}: serde_json::to_string(&src.{src_rust_name}).unwrap_or_default(),"
|
|
267
|
+
)
|
|
268
|
+
elif field_shape is not None and _shape_involves_object(field_shape):
|
|
269
|
+
lines.append(f" {rust_name}: Default::default(), // nested struct — provide manual impl")
|
|
270
|
+
else:
|
|
271
|
+
lines.append(f" {rust_name}: src.{src_rust_name}.into(),")
|
|
272
|
+
else:
|
|
273
|
+
lines.append(f" {rust_name}: Default::default(), // computed — provide manual impl")
|
|
274
|
+
|
|
275
|
+
lines.append(" }")
|
|
276
|
+
lines.append(" }")
|
|
277
|
+
lines.append("}")
|
|
278
|
+
return lines
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _serde_attrs_for_field(wire: dict, shape: TypeShape, *, clickhouse: bool = False) -> list[str]:
|
|
282
|
+
"""Return per-field #[serde(...)] attributes derived from @wire hints."""
|
|
283
|
+
rust_hint = wire.get("rust")
|
|
284
|
+
json_hint = wire.get("json")
|
|
285
|
+
# u64-as-string: rust.type is overridden to u64 and json serialization is string.
|
|
286
|
+
if (
|
|
287
|
+
rust_hint is not None
|
|
288
|
+
and getattr(rust_hint, "type", None)
|
|
289
|
+
and json_hint is not None
|
|
290
|
+
and getattr(json_hint, "encoding", None) == "string"
|
|
291
|
+
and shape.kind == "primitive"
|
|
292
|
+
):
|
|
293
|
+
return ['#[serde(with = "serde_with::rust::display_fromstr")]']
|
|
294
|
+
# ClickHouse UUID encoding hint.
|
|
295
|
+
if clickhouse:
|
|
296
|
+
ch_hint = wire.get("clickhouse")
|
|
297
|
+
if ch_hint is not None and getattr(ch_hint, "encoding", None) == "uuid":
|
|
298
|
+
return ['#[serde(with = "clickhouse::serde::uuid")]']
|
|
299
|
+
return []
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _any_needs_serde_with(field_specs: list[_FieldSpec]) -> bool:
|
|
303
|
+
return any(any("serde_with" in attr for attr in spec.serde_attrs) for spec in field_specs)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _any_needs_uuid(field_specs: list[_FieldSpec]) -> bool:
|
|
307
|
+
return any("uuid::Uuid" in spec.annotation for spec in field_specs)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _any_needs_serde_json(field_specs: list[_FieldSpec]) -> bool:
|
|
311
|
+
return any("serde_json::Value" in spec.annotation for spec in field_specs)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _shape_involves_object(shape: TypeShape) -> bool:
|
|
315
|
+
"""Return True if the shape contains an inline object type.
|
|
316
|
+
|
|
317
|
+
Inline object fields generate distinct named types per-struct (e.g.
|
|
318
|
+
CustomerV1Address vs CustomerViewV1Address). Those types don't implement
|
|
319
|
+
From/Into for each other, so the generated From impl must fall back to
|
|
320
|
+
Default::default() rather than emitting .into().
|
|
321
|
+
"""
|
|
322
|
+
if shape.kind == "object":
|
|
323
|
+
return True
|
|
324
|
+
if shape.element is not None and _shape_involves_object(shape.element):
|
|
325
|
+
return True
|
|
326
|
+
return bool(shape.value is not None and _shape_involves_object(shape.value))
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _header_lines(
|
|
330
|
+
*,
|
|
331
|
+
serde_with: bool = False,
|
|
332
|
+
sqlx: bool = False,
|
|
333
|
+
clickhouse: bool = False,
|
|
334
|
+
uuid: bool = False,
|
|
335
|
+
serde_json: bool = False,
|
|
336
|
+
) -> list[str]:
|
|
337
|
+
lines = [
|
|
338
|
+
"// @generated by Modelable",
|
|
339
|
+
"use std::collections::HashMap;",
|
|
340
|
+
"",
|
|
341
|
+
]
|
|
342
|
+
if clickhouse:
|
|
343
|
+
lines.insert(1, "// requires: clickhouse (https://docs.rs/clickhouse)")
|
|
344
|
+
if sqlx:
|
|
345
|
+
lines.insert(1, "// requires: sqlx (https://docs.rs/sqlx)")
|
|
346
|
+
if serde_with:
|
|
347
|
+
lines.insert(1, "// requires: serde_with (https://docs.rs/serde_with)")
|
|
348
|
+
if uuid:
|
|
349
|
+
lines.insert(1, "// requires: uuid (https://docs.rs/uuid)")
|
|
350
|
+
if serde_json:
|
|
351
|
+
lines.insert(1, "// requires: serde_json (https://docs.rs/serde_json)")
|
|
352
|
+
return lines
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _render_nested_definitions(definitions: dict[str, list[str]]) -> list[str]:
|
|
356
|
+
lines: list[str] = []
|
|
357
|
+
for definition in definitions.values():
|
|
358
|
+
lines.append("")
|
|
359
|
+
lines.extend(definition)
|
|
360
|
+
return lines
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _module_path(domain: str, type_name: str) -> Path:
|
|
364
|
+
return Path(*_package_name(domain).split(".")) / _module_filename(type_name)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _package_name(domain: str) -> str:
|
|
368
|
+
parts = [part.lower() for part in re.split(r"[^A-Za-z0-9]+", domain) if part]
|
|
369
|
+
return ".".join(parts) or "modelable"
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _module_filename(type_name: str) -> str:
|
|
373
|
+
return f"{_snake_case(type_name)}.rs"
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _render_struct_definition(
|
|
377
|
+
type_name: str,
|
|
378
|
+
field_specs: list[_FieldSpec],
|
|
379
|
+
*,
|
|
380
|
+
extra_derives: list[str] | None = None,
|
|
381
|
+
storage_gated: bool = False,
|
|
382
|
+
) -> list[str]:
|
|
383
|
+
derives = ["Debug", "Clone", "PartialEq", "serde::Serialize", "serde::Deserialize"]
|
|
384
|
+
if extra_derives:
|
|
385
|
+
derives.extend(extra_derives)
|
|
386
|
+
lines = []
|
|
387
|
+
if storage_gated:
|
|
388
|
+
lines.append('#[cfg(feature = "storage")]')
|
|
389
|
+
lines.append(f"#[derive({', '.join(derives)})]")
|
|
390
|
+
lines.append(f"pub struct {type_name} {{")
|
|
391
|
+
|
|
392
|
+
for spec in sorted(field_specs, key=lambda s: (s.optional, s.index)):
|
|
393
|
+
for attr in spec.serde_attrs:
|
|
394
|
+
lines.append(f" {attr}")
|
|
395
|
+
annotation = spec.annotation
|
|
396
|
+
if spec.optional and not annotation.startswith("Option<"):
|
|
397
|
+
annotation = f"Option<{annotation}>"
|
|
398
|
+
lines.append(f" pub {_field_name(spec.name)}: {annotation},")
|
|
399
|
+
lines.append("}")
|
|
400
|
+
if not field_specs:
|
|
401
|
+
lines[-1] = f"pub struct {type_name} {{}}"
|
|
402
|
+
return lines
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _field_name(value: str) -> str:
|
|
406
|
+
text = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", value)
|
|
407
|
+
text = re.sub(r"[^A-Za-z0-9]+", "_", text)
|
|
408
|
+
text = text.strip("_").lower()
|
|
409
|
+
return text or "field"
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _enum_member_name(value: str) -> str:
|
|
413
|
+
name = _pascalize(value)
|
|
414
|
+
if name and name[0].isdigit():
|
|
415
|
+
name = f"_{name}"
|
|
416
|
+
return name or "Unknown"
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _render_enum_definition(type_name: str, values: list[str]) -> list[str]:
|
|
420
|
+
derives = ["Debug", "Clone", "PartialEq", "serde::Serialize", "serde::Deserialize"]
|
|
421
|
+
lines = [
|
|
422
|
+
f"#[derive({', '.join(derives)})]",
|
|
423
|
+
f"pub enum {type_name} {{",
|
|
424
|
+
]
|
|
425
|
+
for v in values:
|
|
426
|
+
member = _enum_member_name(v)
|
|
427
|
+
if member != v:
|
|
428
|
+
lines.append(f' #[serde(rename = "{v}")]')
|
|
429
|
+
lines.append(f" {member},")
|
|
430
|
+
lines.append("}")
|
|
431
|
+
return lines
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _field_specs_from_model_fields(
|
|
435
|
+
fields,
|
|
436
|
+
*,
|
|
437
|
+
owner_type: str,
|
|
438
|
+
path: list[str],
|
|
439
|
+
definitions: dict[str, list[str]],
|
|
440
|
+
) -> list[_FieldSpec]:
|
|
441
|
+
specs: list[_FieldSpec] = []
|
|
442
|
+
for index, field in enumerate(fields):
|
|
443
|
+
shape = TypeShape.from_field_type(field.type, optional=field.optional)
|
|
444
|
+
wire = field.wire_targets()
|
|
445
|
+
annotation = _shape_annotation(
|
|
446
|
+
shape,
|
|
447
|
+
owner_type=owner_type,
|
|
448
|
+
path=[*path, field.name],
|
|
449
|
+
definitions=definitions,
|
|
450
|
+
rust_hint=wire.get("rust"),
|
|
451
|
+
)
|
|
452
|
+
is_optional = shape.optional or shape.nullable
|
|
453
|
+
serde_attrs = _serde_attrs_for_field(wire, shape)
|
|
454
|
+
# Optional arrays use Vec<T> + #[serde(default)] — Option<Vec<T>> forces unwrap before iteration.
|
|
455
|
+
if is_optional and shape.kind == "array":
|
|
456
|
+
is_optional = False
|
|
457
|
+
serde_attrs = ["#[serde(default)]", *serde_attrs]
|
|
458
|
+
annotation = _shape_base_annotation(
|
|
459
|
+
shape,
|
|
460
|
+
owner_type=owner_type,
|
|
461
|
+
path=[*path, field.name],
|
|
462
|
+
definitions=definitions,
|
|
463
|
+
rust_hint=wire.get("rust"),
|
|
464
|
+
)
|
|
465
|
+
specs.append(
|
|
466
|
+
_FieldSpec(
|
|
467
|
+
index=index, name=field.name, annotation=annotation, optional=is_optional, serde_attrs=serde_attrs
|
|
468
|
+
)
|
|
469
|
+
)
|
|
470
|
+
return specs
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _field_specs_from_object_fields(
|
|
474
|
+
fields,
|
|
475
|
+
*,
|
|
476
|
+
owner_type: str,
|
|
477
|
+
path: list[str],
|
|
478
|
+
definitions: dict[str, list[str]],
|
|
479
|
+
) -> list[_FieldSpec]:
|
|
480
|
+
specs: list[_FieldSpec] = []
|
|
481
|
+
for index, field in enumerate(fields):
|
|
482
|
+
wire = field.wire_targets or {}
|
|
483
|
+
annotation = _shape_annotation(
|
|
484
|
+
field.shape,
|
|
485
|
+
owner_type=owner_type,
|
|
486
|
+
path=[*path, field.name],
|
|
487
|
+
definitions=definitions,
|
|
488
|
+
rust_hint=wire.get("rust"),
|
|
489
|
+
)
|
|
490
|
+
default_none = field.optional or field.shape.optional or field.shape.nullable
|
|
491
|
+
serde_attrs = _serde_attrs_for_field(wire, field.shape)
|
|
492
|
+
specs.append(
|
|
493
|
+
_FieldSpec(
|
|
494
|
+
index=index, name=field.name, annotation=annotation, optional=default_none, serde_attrs=serde_attrs
|
|
495
|
+
)
|
|
496
|
+
)
|
|
497
|
+
return specs
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _shape_annotation(
|
|
501
|
+
shape: TypeShape,
|
|
502
|
+
*,
|
|
503
|
+
owner_type: str,
|
|
504
|
+
path: list[str],
|
|
505
|
+
definitions: dict[str, list[str]],
|
|
506
|
+
rust_hint=None,
|
|
507
|
+
clickhouse_hint=None,
|
|
508
|
+
) -> str:
|
|
509
|
+
base = _shape_base_annotation(
|
|
510
|
+
shape,
|
|
511
|
+
owner_type=owner_type,
|
|
512
|
+
path=path,
|
|
513
|
+
definitions=definitions,
|
|
514
|
+
rust_hint=rust_hint,
|
|
515
|
+
clickhouse_hint=clickhouse_hint,
|
|
516
|
+
)
|
|
517
|
+
if shape.optional or shape.nullable:
|
|
518
|
+
return f"Option<{base}>"
|
|
519
|
+
return base
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _shape_base_annotation(
|
|
523
|
+
shape: TypeShape,
|
|
524
|
+
*,
|
|
525
|
+
owner_type: str,
|
|
526
|
+
path: list[str],
|
|
527
|
+
definitions: dict[str, list[str]],
|
|
528
|
+
rust_hint=None,
|
|
529
|
+
clickhouse_hint=None,
|
|
530
|
+
) -> str:
|
|
531
|
+
clickhouse_string = clickhouse_hint is not None and getattr(clickhouse_hint, "encoding", None) == "string"
|
|
532
|
+
if shape.kind == "primitive":
|
|
533
|
+
if rust_hint is not None and getattr(rust_hint, "type", None) and (shape.ref or "string") == "int":
|
|
534
|
+
return rust_hint.type
|
|
535
|
+
if shape.ref == "json" and clickhouse_string:
|
|
536
|
+
return "String"
|
|
537
|
+
return _primitive_to_rust(shape.ref or "string")
|
|
538
|
+
if shape.kind == "decimal":
|
|
539
|
+
return "String"
|
|
540
|
+
if shape.kind == "array":
|
|
541
|
+
element = shape.element or TypeShape(kind="primitive", ref="object")
|
|
542
|
+
element_type = _shape_annotation(
|
|
543
|
+
element,
|
|
544
|
+
owner_type=owner_type,
|
|
545
|
+
path=[*path, "Item"],
|
|
546
|
+
definitions=definitions,
|
|
547
|
+
rust_hint=rust_hint,
|
|
548
|
+
)
|
|
549
|
+
return f"Vec<{element_type}>"
|
|
550
|
+
if shape.kind == "map":
|
|
551
|
+
value = shape.value or TypeShape(kind="primitive", ref="object")
|
|
552
|
+
if value.kind == "primitive" and value.ref == "json" and clickhouse_string:
|
|
553
|
+
return "String"
|
|
554
|
+
value_type = _shape_annotation(
|
|
555
|
+
value,
|
|
556
|
+
owner_type=owner_type,
|
|
557
|
+
path=[*path, "Value"],
|
|
558
|
+
definitions=definitions,
|
|
559
|
+
)
|
|
560
|
+
return f"HashMap<String, {value_type}>"
|
|
561
|
+
if shape.kind == "ref":
|
|
562
|
+
return "String"
|
|
563
|
+
if shape.kind == "enum":
|
|
564
|
+
enum_type_name = _nested_type_name(owner_type, path)
|
|
565
|
+
if enum_type_name not in definitions:
|
|
566
|
+
definitions[enum_type_name] = _render_enum_definition(enum_type_name, list(shape.enum_values))
|
|
567
|
+
return enum_type_name
|
|
568
|
+
if shape.kind == "named":
|
|
569
|
+
return _pascalize(shape.ref or "Named")
|
|
570
|
+
if shape.kind == "object":
|
|
571
|
+
type_name = _nested_type_name(owner_type, path)
|
|
572
|
+
if type_name not in definitions:
|
|
573
|
+
definitions[type_name] = _render_struct_definition(
|
|
574
|
+
type_name,
|
|
575
|
+
_field_specs_from_object_fields(
|
|
576
|
+
shape.fields,
|
|
577
|
+
owner_type=owner_type,
|
|
578
|
+
path=path,
|
|
579
|
+
definitions=definitions,
|
|
580
|
+
),
|
|
581
|
+
)
|
|
582
|
+
return type_name
|
|
583
|
+
return "String"
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _primitive_to_rust(kind: str) -> str:
|
|
587
|
+
mapping = {
|
|
588
|
+
"string": "String",
|
|
589
|
+
"bool": "bool",
|
|
590
|
+
"int": "i64",
|
|
591
|
+
"float": "f64",
|
|
592
|
+
"uuid": "uuid::Uuid",
|
|
593
|
+
"timestamp": "String",
|
|
594
|
+
"date": "String",
|
|
595
|
+
"time": "String",
|
|
596
|
+
"duration": "String",
|
|
597
|
+
"binary": "Vec<u8>",
|
|
598
|
+
"json": "serde_json::Value",
|
|
599
|
+
}
|
|
600
|
+
return mapping.get(kind, "String")
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _nested_type_name(owner_type: str, path: list[str]) -> str:
|
|
604
|
+
suffix = "".join(_pascalize(part) for part in path)
|
|
605
|
+
return f"{owner_type}{suffix}" if suffix else owner_type
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _resolve_projection_field_shape(field, projection: ProjectionVersion, mdl):
|
|
609
|
+
if not isinstance(field.mapping, DirectMapping):
|
|
610
|
+
return None
|
|
611
|
+
try:
|
|
612
|
+
source_domain, source_model = projection.source.model.rsplit(".", 1)
|
|
613
|
+
except ValueError:
|
|
614
|
+
return None
|
|
615
|
+
try:
|
|
616
|
+
resolved = resolve_model_ref(mdl, f"{source_domain}.{source_model}", projection.source.version)
|
|
617
|
+
except LookupError:
|
|
618
|
+
return None
|
|
619
|
+
source_mv = resolved.version
|
|
620
|
+
for src_field in source_mv.fields:
|
|
621
|
+
if src_field.name == field.mapping.source_field:
|
|
622
|
+
return TypeShape.from_field_type(src_field.type, optional=src_field.optional)
|
|
623
|
+
return None
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _resolve_merged_projection_wire(field, projection: ProjectionVersion, mdl) -> dict:
|
|
627
|
+
"""Merge wire targets from the source entity field and the projection field.
|
|
628
|
+
|
|
629
|
+
Projection-level annotations win; entity-level annotations provide defaults.
|
|
630
|
+
This ensures e.g. @wire(rust.type: "u64") on an entity timestamp field is
|
|
631
|
+
inherited by projection fields that map it, without repeating the hint.
|
|
632
|
+
"""
|
|
633
|
+
if not isinstance(field.mapping, DirectMapping):
|
|
634
|
+
return field.wire_targets()
|
|
635
|
+
try:
|
|
636
|
+
source_domain, source_model = projection.source.model.rsplit(".", 1)
|
|
637
|
+
resolved = resolve_model_ref(mdl, f"{source_domain}.{source_model}", projection.source.version)
|
|
638
|
+
except ValueError, LookupError:
|
|
639
|
+
return field.wire_targets()
|
|
640
|
+
for src_field in resolved.version.fields:
|
|
641
|
+
if src_field.name == field.mapping.source_field:
|
|
642
|
+
return {**src_field.wire_targets(), **field.wire_targets()}
|
|
643
|
+
return field.wire_targets()
|