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,346 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from modelable.diagnostics.model import Diagnostic
8
+ from modelable.expressions.cel import CelContext, parse_cel, validate_cel_expr
9
+ from modelable.parser.ir import ComputedMapping, MdlFile
10
+ from modelable.parser.parse import parse_text_to_ir
11
+ from modelable.planner.planner import expand_auto_projections
12
+ from modelable.registry.resolver import resolve_model_ref, validate_references
13
+ from modelable.validation.semantic import validate_diagnostics
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class WorkspaceSource:
18
+ path: Path | None
19
+ uri: str
20
+ text: str
21
+ mdl: MdlFile
22
+ errors: list[Diagnostic]
23
+ content_hash: str
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Workspace:
28
+ sources: list[WorkspaceSource]
29
+ mdl: MdlFile
30
+ errors: list[Diagnostic]
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class WorkspaceDocumentSource:
35
+ path: Path | None
36
+ uri: str
37
+ text: str
38
+
39
+
40
+ def discover_mdl_files(path: str | Path) -> list[Path]:
41
+ """Return .mdl files from a file or directory in deterministic order."""
42
+ root = Path(path)
43
+ if root.is_file():
44
+ if root.suffix != ".mdl":
45
+ raise FileNotFoundError(f"{root} is not a .mdl file")
46
+ return [root]
47
+
48
+ files = sorted(root.rglob("*.mdl"), key=lambda item: item.as_posix())
49
+ if not files:
50
+ raise FileNotFoundError(f"No .mdl files found under {root}")
51
+ return files
52
+
53
+
54
+ def load_workspace(path: str | Path) -> Workspace:
55
+ """Parse and validate all local .mdl files under path."""
56
+ sources = [
57
+ WorkspaceDocumentSource(
58
+ path=mdl_path,
59
+ uri=mdl_path.resolve().as_uri(),
60
+ text=mdl_path.read_text(encoding="utf-8"),
61
+ )
62
+ for mdl_path in discover_mdl_files(path)
63
+ ]
64
+ return load_workspace_from_sources(sources)
65
+
66
+
67
+ def load_workspace_from_sources(sources: list[WorkspaceDocumentSource]) -> Workspace:
68
+ workspace_sources: list[WorkspaceSource] = []
69
+ errors: list[Diagnostic] = []
70
+ merged = MdlFile()
71
+
72
+ for source in sources:
73
+ source_location = str(source.path) if source.path is not None else source.uri
74
+ mdl = parse_text_to_ir(source.text, path=source_location)
75
+ source_errors = validate_diagnostics(mdl, path=source_location)
76
+ workspace_sources.append(
77
+ WorkspaceSource(
78
+ path=source.path,
79
+ uri=source.uri,
80
+ text=source.text,
81
+ mdl=mdl,
82
+ errors=source_errors,
83
+ content_hash=_content_hash(source.text),
84
+ )
85
+ )
86
+ errors.extend(source_errors)
87
+ merged.domains.extend(mdl.domains)
88
+ _merge_bindings(merged.bindings, mdl.bindings)
89
+ if mdl.workspace is not None:
90
+ merged.workspace = mdl.workspace
91
+
92
+ auto_projection_errors = expand_auto_projections(merged)
93
+ errors.extend(
94
+ Diagnostic(code="SEM", message=error, severity="error", path="<workspace>") for error in auto_projection_errors
95
+ )
96
+
97
+ errors.extend(_validate_merged_workspace(workspace_sources, merged))
98
+ errors.extend(_validate_cel(merged))
99
+ return Workspace(sources=workspace_sources, mdl=merged, errors=errors)
100
+
101
+
102
+ def _validate_merged_workspace(sources: list[WorkspaceSource], merged: MdlFile) -> list[Diagnostic]:
103
+ errors: list[Diagnostic] = []
104
+ domains: dict[str, Path | None] = {}
105
+ model_versions: dict[tuple[str, str, int], Path | None] = {}
106
+ projection_versions: dict[tuple[str, str, int], Path | None] = {}
107
+ generated_projection_versions: dict[tuple[str, str, int], Path | None] = {}
108
+
109
+ for source in sources:
110
+ for domain in source.mdl.domains:
111
+ previous_domain_path = domains.get(domain.name)
112
+ if previous_domain_path is not None:
113
+ errors.append(
114
+ Diagnostic(
115
+ code="SEM",
116
+ message=(f"duplicate domain '{domain.name}' also defined in {previous_domain_path}"),
117
+ severity="error",
118
+ path=str(source.path) if source.path is not None else source.uri,
119
+ )
120
+ )
121
+ else:
122
+ domains[domain.name] = source.path
123
+
124
+ for model_name, versions in domain.models.items():
125
+ for version in versions:
126
+ key = (domain.name, model_name, version.version)
127
+ previous_model_path = model_versions.get(key)
128
+ if previous_model_path is not None:
129
+ errors.append(
130
+ Diagnostic(
131
+ code="SEM",
132
+ message=(
133
+ "duplicate model version "
134
+ f"{domain.name}.{model_name}@{version.version} "
135
+ f"also defined in {previous_model_path}"
136
+ ),
137
+ severity="error",
138
+ path=str(source.path) if source.path is not None else source.uri,
139
+ )
140
+ )
141
+ else:
142
+ model_versions[key] = source.path
143
+
144
+ for projection_name, versions in domain.projections.items():
145
+ for version in versions:
146
+ # Skip auto-generated projections when checking for explicit
147
+ # projection conflicts — they are validated separately.
148
+ if version.auto_generated:
149
+ continue
150
+ key = (domain.name, projection_name, version.version)
151
+ previous_projection_path = projection_versions.get(key)
152
+ if previous_projection_path is not None:
153
+ errors.append(
154
+ Diagnostic(
155
+ code="SEM",
156
+ message=(
157
+ "duplicate projection version "
158
+ f"{domain.name}.{projection_name}@{version.version} "
159
+ f"also defined in {previous_projection_path}"
160
+ ),
161
+ severity="error",
162
+ path=str(source.path) if source.path is not None else source.uri,
163
+ )
164
+ )
165
+ else:
166
+ projection_versions[key] = source.path
167
+
168
+ for auto_projection in domain.auto_projections:
169
+ for target in auto_projection.targets:
170
+ projection_name = _generated_projection_name(auto_projection.model, target.kind)
171
+ key = (domain.name, projection_name, auto_projection.version)
172
+
173
+ previous_generated_path = generated_projection_versions.get(key)
174
+ if previous_generated_path is not None:
175
+ errors.append(
176
+ Diagnostic(
177
+ code="SEM",
178
+ message=(
179
+ "generated projection name "
180
+ f"{domain.name}.{projection_name}@{auto_projection.version} "
181
+ f"conflicts with auto projection declared in "
182
+ f"{previous_generated_path}"
183
+ ),
184
+ severity="error",
185
+ path=str(source.path) if source.path is not None else source.uri,
186
+ )
187
+ )
188
+ else:
189
+ generated_projection_versions[key] = source.path
190
+
191
+ explicit_projection_path = projection_versions.get(key)
192
+ if explicit_projection_path is not None:
193
+ errors.append(
194
+ Diagnostic(
195
+ code="SEM",
196
+ message=(
197
+ "generated projection name "
198
+ f"{domain.name}.{projection_name}@{auto_projection.version} "
199
+ f"conflicts with explicit projection declared in "
200
+ f"{explicit_projection_path}"
201
+ ),
202
+ severity="error",
203
+ path=str(source.path) if source.path is not None else source.uri,
204
+ )
205
+ )
206
+
207
+ errors.extend(
208
+ Diagnostic(code="SEM", message=error, severity="error", path="<workspace>")
209
+ for error in validate_references(merged)
210
+ )
211
+ errors.extend(_validate_bindings(merged))
212
+ return errors
213
+
214
+
215
+ def _merge_bindings(existing: list, incoming: list) -> None:
216
+ """Merge incoming bindings into existing, deduplicating identical definitions.
217
+
218
+ Two bindings are considered identical if they share the same name, adapter,
219
+ model, model_version, and table. Identical duplicates are silently dropped.
220
+ Conflicting duplicates (same name, different adapter) are kept so that
221
+ _validate_bindings can report them.
222
+ """
223
+ seen: set[tuple] = {(b.name, b.adapter, b.model, b.model_version, b.table) for b in existing}
224
+ for b in incoming:
225
+ key = (b.name, b.adapter, b.model, b.model_version, b.table)
226
+ if key not in seen:
227
+ existing.append(b)
228
+ seen.add(key)
229
+
230
+
231
+ def _validate_bindings(merged: MdlFile) -> list[Diagnostic]:
232
+ """Detect binding names that appear with conflicting definitions."""
233
+ errors: list[Diagnostic] = []
234
+ seen: dict[str, str] = {} # name → adapter
235
+ for b in merged.bindings:
236
+ if b.name in seen:
237
+ if seen[b.name] != b.adapter:
238
+ errors.append(
239
+ Diagnostic(
240
+ code="SEM",
241
+ message=(
242
+ f"binding '{b.name}' is declared with conflicting adapter "
243
+ f"'{b.adapter}' (previously '{seen[b.name]}')"
244
+ ),
245
+ severity="error",
246
+ path="<workspace>",
247
+ )
248
+ )
249
+ else:
250
+ seen[b.name] = b.adapter
251
+ return errors
252
+
253
+
254
+ def _validate_cel(merged: MdlFile) -> list[Diagnostic]:
255
+ """Validate CEL expressions in all projections across the merged workspace."""
256
+ errors: list[Diagnostic] = []
257
+
258
+ for domain in merged.domains:
259
+ for projection_name, versions in domain.projections.items():
260
+ for pv in versions:
261
+ fqn = f"{domain.name}.{projection_name}@{pv.version}"
262
+
263
+ # Build alias -> set[field_name] from source and all joins
264
+ source_fields: dict[str, set[str]] = {}
265
+ all_sources = [(pv.source.model, pv.source.version, pv.source.alias)]
266
+ for join in pv.joins:
267
+ all_sources.append((join.model, join.version, join.alias))
268
+ for model_ref, version_spec, alias in all_sources:
269
+ try:
270
+ resolved = resolve_model_ref(merged, model_ref, version_spec)
271
+ source_fields[alias] = {f.name for f in resolved.version.fields}
272
+ except LookupError:
273
+ pass
274
+
275
+ ctx = CelContext(
276
+ source_fields=source_fields,
277
+ has_group_by=bool(pv.group_by),
278
+ fqn=fqn,
279
+ )
280
+
281
+ for proj_field in pv.fields:
282
+ if not isinstance(proj_field.mapping, ComputedMapping):
283
+ continue
284
+ expression = proj_field.mapping.expression
285
+ ast, parse_errors = parse_cel(expression)
286
+ for err in parse_errors:
287
+ errors.append(
288
+ Diagnostic(
289
+ code="CEL",
290
+ message=f"{fqn}.{proj_field.name}: {err}",
291
+ severity="error",
292
+ path="<workspace>",
293
+ )
294
+ )
295
+ if ast is not None:
296
+ result = validate_cel_expr(ast, ctx)
297
+ for err in result.errors:
298
+ errors.append(
299
+ Diagnostic(
300
+ code="CEL",
301
+ message=f"{proj_field.name}: {err}",
302
+ severity="error",
303
+ path="<workspace>",
304
+ )
305
+ )
306
+
307
+ for join in pv.joins:
308
+ if not join.on:
309
+ continue
310
+ ast, parse_errors = parse_cel(join.on)
311
+ for err in parse_errors:
312
+ errors.append(
313
+ Diagnostic(
314
+ code="CEL",
315
+ message=f"{fqn} join on: {err}",
316
+ severity="error",
317
+ path="<workspace>",
318
+ )
319
+ )
320
+ if ast is not None:
321
+ result = validate_cel_expr(ast, ctx)
322
+ for err in result.errors:
323
+ errors.append(
324
+ Diagnostic(
325
+ code="CEL",
326
+ message=f"{fqn} join on: {err}",
327
+ severity="error",
328
+ path="<workspace>",
329
+ )
330
+ )
331
+
332
+ return errors
333
+
334
+
335
+ def _generated_projection_name(model_name: str, kind: str) -> str:
336
+ suffixes = {
337
+ "db": "Db",
338
+ "request": "Request",
339
+ "reply": "Reply",
340
+ "event": "Event",
341
+ }
342
+ return f"{model_name}{suffixes[kind]}"
343
+
344
+
345
+ def _content_hash(text: str) -> str:
346
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
@@ -0,0 +1,3 @@
1
+ from modelable.diagnostics.model import Diagnostic, DiagnosticSeverity, render_diagnostic
2
+
3
+ __all__ = ["Diagnostic", "DiagnosticSeverity", "render_diagnostic"]
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Literal
5
+
6
+ DiagnosticSeverity = Literal["error", "warning", "information"]
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Diagnostic:
11
+ code: str
12
+ message: str
13
+ severity: DiagnosticSeverity
14
+ path: str
15
+ line: int | None = None
16
+ column: int | None = None
17
+ end_line: int | None = None
18
+ end_column: int | None = None
19
+
20
+
21
+ def render_diagnostic(diagnostic: Diagnostic) -> str:
22
+ location = diagnostic.path
23
+ if diagnostic.line is not None:
24
+ location += f":{diagnostic.line}"
25
+ if diagnostic.column is not None:
26
+ location += f":{diagnostic.column}"
27
+ return f"{diagnostic.code}: {location}: {diagnostic.message}"
File without changes
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass
10
+ class EmittedArtifact:
11
+ target: str
12
+ ref: str # "domain.Name@version"
13
+ artifact_id: str # "domain.Name.vVersion"
14
+ path: Path
15
+ content: dict[str, object] | str
16
+ content_hash: str
17
+ warnings: list[str] = field(default_factory=list)
18
+
19
+
20
+ def compute_content_hash(content: dict[str, object] | str) -> str:
21
+ payload = json.dumps(content, indent=2, ensure_ascii=False) + "\n" if isinstance(content, dict) else content
22
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
@@ -0,0 +1,245 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from modelable.compiler.workspace import Workspace
7
+ from modelable.emitters.base import EmittedArtifact, compute_content_hash
8
+ from modelable.emitters.diagnostics import type_loss
9
+ from modelable.emitters.shapes import TypeShape
10
+ from modelable.parser.ir import DirectMapping, DomainDef, ModelVersion, ProjectionVersion
11
+ from modelable.registry.resolver import resolve_model_ref
12
+
13
+
14
+ def emit_csharp(workspace: Workspace, out_dir: Path) -> list[EmittedArtifact]:
15
+ """Emit C# source files for every model and projection version."""
16
+ artifacts: list[EmittedArtifact] = []
17
+ for domain in workspace.mdl.domains:
18
+ for model_name, versions in domain.models.items():
19
+ for version in versions:
20
+ artifacts.append(_emit_model(domain, model_name, version, out_dir))
21
+ for projection_name, versions in domain.projections.items():
22
+ for version in versions:
23
+ artifacts.append(_emit_projection(domain, projection_name, version, out_dir, workspace.mdl))
24
+ return artifacts
25
+
26
+
27
+ def _artifact_id(domain: str, name: str, version: int) -> str:
28
+ return f"{domain}.{name}.v{version}"
29
+
30
+
31
+ def _pascalize(value: str) -> str:
32
+ parts = [part for part in re.split(r"[^A-Za-z0-9]+", value) if part]
33
+ return "".join(part[:1].upper() + part[1:] for part in parts) or "Generated"
34
+
35
+
36
+ def _namespace_name(domain: str) -> str:
37
+ return f"Modelable.{_pascalize(domain)}"
38
+
39
+
40
+ def _stable_type_name(domain: str, name: str, version: int) -> str:
41
+ return f"{_pascalize(domain)}{_pascalize(name)}V{version}"
42
+
43
+
44
+ def _emit_model(domain: DomainDef, model_name: str, version: ModelVersion, out_dir: Path) -> EmittedArtifact:
45
+ artifact_id = _artifact_id(domain.name, model_name, version.version)
46
+ type_name = _stable_type_name(domain.name, model_name, version.version)
47
+ lines = _header_lines(_namespace_name(domain.name))
48
+ nested_definitions: dict[str, list[str]] = {}
49
+ warnings: list[str] = []
50
+
51
+ lines.append(f"public sealed record {type_name}")
52
+ lines.append("{")
53
+ for field in version.fields:
54
+ shape = TypeShape.from_field_type(field.type, optional=field.optional)
55
+ csharp_type = _shape_to_csharp(shape, owner_type=type_name, path=[field.name], definitions=nested_definitions)
56
+ prefix = "required " if not (shape.optional or shape.nullable) else ""
57
+ lines.append(f" public {prefix}{csharp_type} {_property_name(field.name)} {{ get; init; }}")
58
+ lines.append("}")
59
+ lines.extend(_render_nested_definitions(nested_definitions))
60
+
61
+ text = "\n".join(lines) + "\n"
62
+ return EmittedArtifact(
63
+ target="csharp",
64
+ ref=f"{domain.name}.{model_name}@{version.version}",
65
+ artifact_id=artifact_id,
66
+ path=out_dir / f"{artifact_id}.cs",
67
+ content=text,
68
+ content_hash=compute_content_hash(text),
69
+ warnings=warnings,
70
+ )
71
+
72
+
73
+ def _emit_projection(
74
+ domain: DomainDef,
75
+ projection_name: str,
76
+ version: ProjectionVersion,
77
+ out_dir: Path,
78
+ mdl,
79
+ ) -> EmittedArtifact:
80
+ artifact_id = _artifact_id(domain.name, projection_name, version.version)
81
+ type_name = _stable_type_name(domain.name, projection_name, version.version)
82
+ lines = _header_lines(_namespace_name(domain.name))
83
+ nested_definitions: dict[str, list[str]] = {}
84
+ warnings: list[str] = []
85
+
86
+ lines.append(f"public sealed record {type_name}")
87
+ lines.append("{")
88
+ for field in version.fields:
89
+ field_shape = _resolve_projection_field_shape(field, version, mdl)
90
+ if field_shape is None:
91
+ warnings.append(type_loss(f"{domain.name}.{projection_name}.{field.name}"))
92
+ csharp_type = "object"
93
+ prefix = "required "
94
+ else:
95
+ csharp_type = _shape_to_csharp(
96
+ field_shape, owner_type=type_name, path=[field.name], definitions=nested_definitions
97
+ )
98
+ prefix = "required " if not (field_shape.optional or field_shape.nullable) else ""
99
+ lines.append(f" public {prefix}{csharp_type} {_property_name(field.name)} {{ get; init; }}")
100
+ lines.append("}")
101
+ lines.extend(_render_nested_definitions(nested_definitions))
102
+
103
+ text = "\n".join(lines) + "\n"
104
+ return EmittedArtifact(
105
+ target="csharp",
106
+ ref=f"{domain.name}.{projection_name}@{version.version}",
107
+ artifact_id=artifact_id,
108
+ path=out_dir / f"{artifact_id}.cs",
109
+ content=text,
110
+ content_hash=compute_content_hash(text),
111
+ warnings=warnings,
112
+ )
113
+
114
+
115
+ def _header_lines(namespace: str) -> list[str]:
116
+ return [
117
+ "#nullable enable",
118
+ "using System;",
119
+ "using System.Collections.Generic;",
120
+ "",
121
+ f"namespace {namespace};",
122
+ "",
123
+ ]
124
+
125
+
126
+ def _render_nested_definitions(definitions: dict[str, list[str]]) -> list[str]:
127
+ lines: list[str] = []
128
+ for definition in definitions.values():
129
+ lines.append("")
130
+ lines.extend(definition)
131
+ return lines
132
+
133
+
134
+ def _property_name(value: str) -> str:
135
+ return _pascalize(value)
136
+
137
+
138
+ def _shape_to_csharp(
139
+ shape: TypeShape,
140
+ *,
141
+ owner_type: str,
142
+ path: list[str],
143
+ definitions: dict[str, list[str]],
144
+ ) -> str:
145
+ base = _shape_base_to_csharp(shape, owner_type=owner_type, path=path, definitions=definitions)
146
+ if shape.optional or shape.nullable:
147
+ return f"{base}?"
148
+ return base
149
+
150
+
151
+ def _shape_base_to_csharp(
152
+ shape: TypeShape,
153
+ *,
154
+ owner_type: str,
155
+ path: list[str],
156
+ definitions: dict[str, list[str]],
157
+ ) -> str:
158
+ if shape.kind == "primitive":
159
+ return _primitive_to_csharp(shape.ref or "string")
160
+ if shape.kind == "decimal":
161
+ return "decimal"
162
+ if shape.kind == "array":
163
+ element = shape.element or TypeShape(kind="primitive", ref="object")
164
+ return (
165
+ f"List<{_shape_to_csharp(element, owner_type=owner_type, path=[*path, 'Item'], definitions=definitions)}>"
166
+ )
167
+ if shape.kind == "map":
168
+ value = shape.value or TypeShape(kind="primitive", ref="object")
169
+ return f"Dictionary<string, {_shape_to_csharp(value, owner_type=owner_type, path=[*path, 'Value'], definitions=definitions)}>"
170
+ if shape.kind == "ref":
171
+ return "string"
172
+ if shape.kind == "enum":
173
+ return "string"
174
+ if shape.kind == "named":
175
+ return _pascalize(shape.ref or "Named")
176
+ if shape.kind == "object":
177
+ type_name = _nested_type_name(owner_type, path)
178
+ if type_name not in definitions:
179
+ definitions[type_name] = _build_record_definition(
180
+ type_name, shape, owner_type=owner_type, path=path, definitions=definitions
181
+ )
182
+ return type_name
183
+ return "object"
184
+
185
+
186
+ def _primitive_to_csharp(kind: str) -> str:
187
+ mapping = {
188
+ "string": "string",
189
+ "bool": "bool",
190
+ "int": "int",
191
+ "float": "double",
192
+ "uuid": "Guid",
193
+ "timestamp": "DateTime",
194
+ "date": "DateOnly",
195
+ "time": "TimeOnly",
196
+ "duration": "TimeSpan",
197
+ "binary": "byte[]",
198
+ }
199
+ return mapping.get(kind, "string")
200
+
201
+
202
+ def _nested_type_name(owner_type: str, path: list[str]) -> str:
203
+ suffix = "".join(_pascalize(part) for part in path)
204
+ return f"{owner_type}{suffix}" if suffix else owner_type
205
+
206
+
207
+ def _build_record_definition(
208
+ type_name: str,
209
+ shape: TypeShape,
210
+ *,
211
+ owner_type: str,
212
+ path: list[str],
213
+ definitions: dict[str, list[str]],
214
+ ) -> list[str]:
215
+ lines = [f"public sealed record {type_name}", "{"]
216
+ for field in shape.fields:
217
+ child_shape = field.shape
218
+ child_type = _shape_to_csharp(
219
+ child_shape,
220
+ owner_type=owner_type,
221
+ path=[*path, field.name],
222
+ definitions=definitions,
223
+ )
224
+ prefix = "required " if not (child_shape.optional or child_shape.nullable) else ""
225
+ lines.append(f" public {prefix}{child_type} {_property_name(field.name)} {{ get; init; }}")
226
+ lines.append("}")
227
+ return lines
228
+
229
+
230
+ def _resolve_projection_field_shape(field, projection: ProjectionVersion, mdl):
231
+ if not isinstance(field.mapping, DirectMapping):
232
+ return None
233
+ try:
234
+ source_domain, source_model = projection.source.model.rsplit(".", 1)
235
+ except ValueError:
236
+ return None
237
+ try:
238
+ resolved = resolve_model_ref(mdl, f"{source_domain}.{source_model}", projection.source.version)
239
+ except LookupError:
240
+ return None
241
+ source_mv = resolved.version
242
+ for src_field in source_mv.fields:
243
+ if src_field.name == field.mapping.source_field:
244
+ return TypeShape.from_field_type(src_field.type, optional=src_field.optional)
245
+ return None