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.

Files changed (122) hide show
  1. modelable/__init__.py +1 -0
  2. modelable/__main__.py +3 -0
  3. modelable/_pydantic_py314_compat.py +31 -0
  4. modelable/cli.py +41 -0
  5. modelable/commands/__init__.py +1 -0
  6. modelable/commands/apicurio.py +84 -0
  7. modelable/commands/codegen.py +241 -0
  8. modelable/commands/common.py +43 -0
  9. modelable/commands/compile.py +237 -0
  10. modelable/commands/create.py +164 -0
  11. modelable/commands/diff.py +82 -0
  12. modelable/commands/graph.py +53 -0
  13. modelable/commands/llm.py +564 -0
  14. modelable/commands/lsp.py +15 -0
  15. modelable/commands/runtime.py +37 -0
  16. modelable/commands/scenario.py +104 -0
  17. modelable/commands/spec.py +197 -0
  18. modelable/commands/workspace.py +240 -0
  19. modelable/compat/__init__.py +11 -0
  20. modelable/compat/checker.py +179 -0
  21. modelable/compat/diff.py +169 -0
  22. modelable/compiler/__init__.py +3 -0
  23. modelable/compiler/compiler.py +19 -0
  24. modelable/compiler/workspace.py +346 -0
  25. modelable/diagnostics/__init__.py +3 -0
  26. modelable/diagnostics/model.py +27 -0
  27. modelable/emitters/__init__.py +0 -0
  28. modelable/emitters/base.py +22 -0
  29. modelable/emitters/csharp.py +245 -0
  30. modelable/emitters/dbt_yaml.py +290 -0
  31. modelable/emitters/diagnostics.py +25 -0
  32. modelable/emitters/fhir.py +694 -0
  33. modelable/emitters/fhir_validator.py +36 -0
  34. modelable/emitters/go.py +334 -0
  35. modelable/emitters/java.py +264 -0
  36. modelable/emitters/json_schema.py +458 -0
  37. modelable/emitters/markdown.py +252 -0
  38. modelable/emitters/odcs.py +355 -0
  39. modelable/emitters/openlineage.py +315 -0
  40. modelable/emitters/openmetadata.py +258 -0
  41. modelable/emitters/python.py +282 -0
  42. modelable/emitters/rust.py +643 -0
  43. modelable/emitters/shapes.py +261 -0
  44. modelable/emitters/sql.py +266 -0
  45. modelable/emitters/targets.py +141 -0
  46. modelable/emitters/typescript.py +352 -0
  47. modelable/expressions/__init__.py +0 -0
  48. modelable/expressions/cel.py +547 -0
  49. modelable/governance/__init__.py +3 -0
  50. modelable/governance/checker.py +271 -0
  51. modelable/governance/por.py +46 -0
  52. modelable/grammar/__init__.py +1 -0
  53. modelable/grammar/modelable.lark +257 -0
  54. modelable/graph/__init__.py +5 -0
  55. modelable/graph/export.py +442 -0
  56. modelable/llm/__init__.py +43 -0
  57. modelable/llm/chat.py +255 -0
  58. modelable/llm/config.py +87 -0
  59. modelable/llm/context.py +194 -0
  60. modelable/llm/engine.py +976 -0
  61. modelable/llm/importers.py +1077 -0
  62. modelable/llm/provenance.py +84 -0
  63. modelable/llm/providers.py +182 -0
  64. modelable/llm/qa.py +126 -0
  65. modelable/llm/recommendations.py +33 -0
  66. modelable/llm/redaction.py +19 -0
  67. modelable/llm/render.py +279 -0
  68. modelable/llm/update_plan.py +101 -0
  69. modelable/llm/validation_help.py +10 -0
  70. modelable/lsp/__init__.py +3 -0
  71. modelable/lsp/__main__.py +4 -0
  72. modelable/lsp/code_actions.py +210 -0
  73. modelable/lsp/completion.py +480 -0
  74. modelable/lsp/definition.py +343 -0
  75. modelable/lsp/diagnostics.py +31 -0
  76. modelable/lsp/document_symbols.py +197 -0
  77. modelable/lsp/federation.py +261 -0
  78. modelable/lsp/folding.py +33 -0
  79. modelable/lsp/formatting.py +64 -0
  80. modelable/lsp/highlight.py +30 -0
  81. modelable/lsp/hover.py +370 -0
  82. modelable/lsp/inlay_hints.py +158 -0
  83. modelable/lsp/references.py +511 -0
  84. modelable/lsp/rename.py +564 -0
  85. modelable/lsp/semantic_tokens.py +412 -0
  86. modelable/lsp/server.py +370 -0
  87. modelable/lsp/workspace.py +83 -0
  88. modelable/lsp/workspace_symbols.py +104 -0
  89. modelable/parser/__init__.py +94 -0
  90. modelable/parser/ir.py +451 -0
  91. modelable/parser/parse.py +47 -0
  92. modelable/parser/transformer.py +798 -0
  93. modelable/parser/wire.py +68 -0
  94. modelable/planner/__init__.py +0 -0
  95. modelable/planner/lineage.py +91 -0
  96. modelable/planner/planner.py +134 -0
  97. modelable/planner/plans.py +122 -0
  98. modelable/py.typed +0 -0
  99. modelable/registry/__init__.py +9 -0
  100. modelable/registry/apicurio.py +166 -0
  101. modelable/registry/base.py +18 -0
  102. modelable/registry/factory.py +18 -0
  103. modelable/registry/index.py +419 -0
  104. modelable/registry/local.py +26 -0
  105. modelable/registry/oci.py +22 -0
  106. modelable/registry/resolver.py +213 -0
  107. modelable/registry/schema.sql +119 -0
  108. modelable/registry/signature.py +26 -0
  109. modelable/release.py +125 -0
  110. modelable/runtime/__init__.py +5 -0
  111. modelable/runtime/adapter/__init__.py +17 -0
  112. modelable/runtime/adapter/base.py +18 -0
  113. modelable/runtime/adapter/postgres.py +82 -0
  114. modelable/specs/__init__.py +23 -0
  115. modelable/specs/tracking.py +220 -0
  116. modelable/validation/__init__.py +3 -0
  117. modelable/validation/semantic.py +659 -0
  118. modelable-1.0.0.dist-info/METADATA +61 -0
  119. modelable-1.0.0.dist-info/RECORD +122 -0
  120. modelable-1.0.0.dist-info/WHEEL +4 -0
  121. modelable-1.0.0.dist-info/entry_points.txt +2 -0
  122. 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()