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,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,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
|