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,659 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from modelable.compat.diff import compare_model_versions
7
+ from modelable.diagnostics.model import Diagnostic
8
+ from modelable.parser.ir import (
9
+ AnnWire,
10
+ ChangeKind,
11
+ ClassificationLevel,
12
+ ComputedMapping,
13
+ DecimalType,
14
+ EnumType,
15
+ FieldDef,
16
+ MdlFile,
17
+ ModelKind,
18
+ ModelVersion,
19
+ ObjectType,
20
+ PrimitiveType,
21
+ )
22
+ from modelable.registry.resolver import resolve_model_ref
23
+
24
+ _VALID_CLASSIFICATION_LEVELS = {level.value for level in ClassificationLevel}
25
+ _CLASSIFICATION_LEVELS_DISPLAY = ", ".join(sorted(_VALID_CLASSIFICATION_LEVELS))
26
+ _VALID_WIRE_TARGETS = {"json", "rust", "clickhouse"}
27
+ _VALID_JSON_ENCODINGS = {"string"}
28
+ _VALID_CLICKHOUSE_ENCODINGS = {"uuid", "string", "u8"}
29
+ _VALID_RUST_CASE_VALUES = {
30
+ "snake_case",
31
+ "SCREAMING_SNAKE_CASE",
32
+ "camelCase",
33
+ "PascalCase",
34
+ "kebab-case",
35
+ "lowercase",
36
+ "UPPERCASE",
37
+ }
38
+ _VALID_TS_FIELD_CASE_VALUES = {
39
+ "snake_case",
40
+ "SCREAMING_SNAKE_CASE",
41
+ "camelCase",
42
+ "PascalCase",
43
+ }
44
+
45
+ _AGGREGATE_FUNCTIONS = ("count", "sum", "min", "max", "avg")
46
+ _AGGREGATE_PATTERN = re.compile(
47
+ r"\b(" + "|".join(_AGGREGATE_FUNCTIONS) + r")\s*\(",
48
+ re.IGNORECASE,
49
+ )
50
+ _SCALAR_MAX_MIN = frozenset({"max", "min"})
51
+
52
+
53
+ def _is_scalar_max_min(expression: str, match: re.Match) -> bool:
54
+ """Return True when max/min is called with 2+ args (scalar greatest/least)."""
55
+ if match.group(1).lower() not in _SCALAR_MAX_MIN:
56
+ return False
57
+ depth = 1
58
+ for ch in expression[match.end() :]:
59
+ if ch == "(":
60
+ depth += 1
61
+ elif ch == ")":
62
+ depth -= 1
63
+ if depth == 0:
64
+ return False
65
+ elif ch == "," and depth == 1:
66
+ return True
67
+ return False
68
+
69
+
70
+ def validate(mdl: MdlFile) -> list[str]:
71
+ """Return semantic validation errors. An empty list means the file is valid."""
72
+ return [diagnostic.message for diagnostic in validate_diagnostics(mdl)]
73
+
74
+
75
+ def validate_diagnostics(mdl: MdlFile, path: str | Path | None = None) -> list[Diagnostic]:
76
+ diagnostics: list[Diagnostic] = []
77
+ for domain in mdl.domains:
78
+ if not domain.owner:
79
+ diagnostics.append(
80
+ _diag(
81
+ "SEM",
82
+ f"domain '{domain.name}' must have an owner attribute",
83
+ path,
84
+ )
85
+ )
86
+ _validate_models(domain.name, domain.models, diagnostics, path)
87
+ _validate_projections(domain.name, domain.projections, diagnostics, path, mdl)
88
+ return diagnostics
89
+
90
+
91
+ def _validate_classification_level(
92
+ fqn: str,
93
+ field_name: str,
94
+ level: str,
95
+ diagnostics: list[Diagnostic],
96
+ path: str | Path | None,
97
+ ) -> None:
98
+ if level not in _VALID_CLASSIFICATION_LEVELS:
99
+ diagnostics.append(
100
+ _diag(
101
+ "SEM",
102
+ f"{fqn}: field '{field_name}' has invalid classification level '{level}'. "
103
+ f"Valid levels are: {_CLASSIFICATION_LEVELS_DISPLAY}",
104
+ path,
105
+ )
106
+ )
107
+
108
+
109
+ def _validate_models(
110
+ domain_name,
111
+ models,
112
+ diagnostics: list[Diagnostic],
113
+ path: str | Path | None,
114
+ ) -> None:
115
+ for model_name, versions in models.items():
116
+ fqn = f"{domain_name}.{model_name}"
117
+ version_numbers = [version.version for version in versions]
118
+
119
+ for index in range(1, len(version_numbers)):
120
+ previous = version_numbers[index - 1]
121
+ current = version_numbers[index]
122
+ if current <= previous:
123
+ diagnostics.append(
124
+ _diag(
125
+ "SEM",
126
+ f"{fqn}: versions must be strictly ascending, but found {previous} followed by {current}",
127
+ path,
128
+ )
129
+ )
130
+
131
+ for version in versions:
132
+ if (
133
+ version.model_kind in (ModelKind.entity, ModelKind.aggregate, ModelKind.event)
134
+ and not version.has_version_header
135
+ ):
136
+ diagnostics.append(
137
+ _diag(
138
+ "SEM",
139
+ f"{fqn}: {version.model_kind.value} must have a version header (e.g. @ 1 (additive))",
140
+ path,
141
+ )
142
+ )
143
+ elif (
144
+ version.model_kind in (ModelKind.entity, ModelKind.aggregate, ModelKind.event)
145
+ and not version.has_change_kind
146
+ ):
147
+ diagnostics.append(
148
+ _diag(
149
+ "SEM",
150
+ f"{fqn}@{version.version}: {version.model_kind.value} must have a change kind (additive) or (breaking)",
151
+ path,
152
+ )
153
+ )
154
+ _validate_declaration_wire_annotations(f"{fqn}@{version.version}", version, diagnostics, path)
155
+ key_fields = [field for field in version.fields if field.is_key]
156
+ if version.model_kind in (ModelKind.entity, ModelKind.aggregate):
157
+ if len(key_fields) != 1:
158
+ diagnostics.append(
159
+ _diag(
160
+ "SEM",
161
+ f"{fqn}@{version.version}: {version.model_kind.value} must have exactly one @key field",
162
+ path,
163
+ )
164
+ )
165
+ elif key_fields:
166
+ diagnostics.append(
167
+ _diag(
168
+ "SEM",
169
+ f"{fqn}@{version.version}: {version.model_kind.value} must not have an @key field",
170
+ path,
171
+ )
172
+ )
173
+ for field in version.fields:
174
+ _validate_field_annotations(
175
+ f"{fqn}@{version.version}",
176
+ field,
177
+ diagnostics,
178
+ path,
179
+ field_path=[field.name],
180
+ field_type=field.type,
181
+ )
182
+
183
+ for index in range(1, len(versions)):
184
+ previous = versions[index - 1]
185
+ current = versions[index]
186
+ _validate_change_kind(fqn, previous, current, diagnostics, path)
187
+
188
+
189
+ def _validate_projections(
190
+ domain_name,
191
+ projections,
192
+ diagnostics: list[Diagnostic],
193
+ path: str | Path | None,
194
+ mdl: MdlFile,
195
+ ) -> None:
196
+ for projection_name, versions in projections.items():
197
+ fqn = f"{domain_name}.{projection_name}"
198
+ for version in versions:
199
+ _validate_declaration_wire_annotations(f"{fqn}@{version.version}", version, diagnostics, path)
200
+ has_group_by = bool(version.group_by)
201
+ for field in version.fields:
202
+ mapping = field.mapping
203
+ if not isinstance(mapping, ComputedMapping):
204
+ continue
205
+
206
+ aggregate_match = _AGGREGATE_PATTERN.search(mapping.expression)
207
+ if aggregate_match and not has_group_by and not _is_scalar_max_min(mapping.expression, aggregate_match):
208
+ diagnostics.append(
209
+ _diag(
210
+ "SEM",
211
+ f"{fqn}@{version.version}: field '{field.name}' uses "
212
+ f"aggregation function '{aggregate_match.group(1)}' "
213
+ "but the projection has no group by clause",
214
+ path,
215
+ )
216
+ )
217
+ for field in version.fields:
218
+ source_type = _resolve_projection_field_type(field, version, mdl)
219
+ _validate_field_annotations(
220
+ f"{fqn}@{version.version}",
221
+ field,
222
+ diagnostics,
223
+ path,
224
+ field_path=[field.name],
225
+ field_type=source_type,
226
+ )
227
+
228
+
229
+ def _validate_change_kind(
230
+ fqn: str,
231
+ previous: ModelVersion,
232
+ current: ModelVersion,
233
+ diagnostics: list[Diagnostic],
234
+ path: str | Path | None,
235
+ ) -> None:
236
+ changes = compare_model_versions(previous, current)
237
+ incompatible_changes: list[str] = []
238
+
239
+ for change in changes:
240
+ if change.kind == "added_field":
241
+ field = _find_field(current, change.field_name)
242
+ if field is None or not field.optional:
243
+ incompatible_changes.append(f"added required field {change.field_name}")
244
+ continue
245
+
246
+ if change.kind == "nullability_changed":
247
+ if change.from_optional is False and change.to_optional is True:
248
+ continue
249
+ incompatible_changes.append(f"nullability change {change.field_name}")
250
+ continue
251
+
252
+ incompatible_changes.append(f"{change.kind} {change.field_name}")
253
+
254
+ context = f"{fqn}@{current.version}"
255
+ if current.change_kind == ChangeKind.additive:
256
+ if incompatible_changes:
257
+ diagnostics.append(
258
+ _diag(
259
+ "COMPAT",
260
+ f"{context}: additive declaration includes incompatible changes: "
261
+ + ", ".join(incompatible_changes),
262
+ path,
263
+ )
264
+ )
265
+ elif current.change_kind == ChangeKind.breaking and not incompatible_changes:
266
+ diagnostics.append(
267
+ _diag(
268
+ "COMPAT",
269
+ f"{context}: breaking declaration must include at least one incompatible change",
270
+ path,
271
+ )
272
+ )
273
+
274
+
275
+ def _validate_declaration_wire_annotations(
276
+ fqn: str,
277
+ version,
278
+ diagnostics: list[Diagnostic],
279
+ path: str | Path | None,
280
+ ) -> None:
281
+ try:
282
+ version.wire_targets()
283
+ except ValueError as exc:
284
+ diagnostics.append(_diag("SEM", f"{fqn}: has conflicting @wire annotations: {exc}", path))
285
+ return
286
+ for annotation in version.annotations:
287
+ if annotation.kind != "wire":
288
+ continue
289
+ for target_name, hint in annotation.targets.items():
290
+ if target_name not in _VALID_WIRE_TARGETS:
291
+ diagnostics.append(
292
+ _diag(
293
+ "SEM",
294
+ f"{fqn}: has unknown wire target '{target_name}'. "
295
+ f"Valid targets are: {', '.join(sorted(_VALID_WIRE_TARGETS))}",
296
+ path,
297
+ )
298
+ )
299
+ continue
300
+ if (
301
+ target_name != "json"
302
+ or hint.field_case is None
303
+ or hint.encoding is not None
304
+ or hint.type is not None
305
+ or hint.case is not None
306
+ or hint.overrides
307
+ ):
308
+ diagnostics.append(
309
+ _diag(
310
+ "SEM",
311
+ f"{fqn}: only @wire(json.fieldCase: ...) is supported on model/projection declarations",
312
+ path,
313
+ )
314
+ )
315
+ continue
316
+ if hint.field_case not in _VALID_TS_FIELD_CASE_VALUES:
317
+ diagnostics.append(
318
+ _diag(
319
+ "SEM",
320
+ f"{fqn}: unsupported json.fieldCase '{hint.field_case}'. "
321
+ f"Valid values are: {', '.join(sorted(_VALID_TS_FIELD_CASE_VALUES))}",
322
+ path,
323
+ )
324
+ )
325
+
326
+
327
+ def _find_field(version: ModelVersion, field_name: str):
328
+ return next((field for field in version.fields if field.name == field_name), None)
329
+
330
+
331
+ def _validate_field_annotations(
332
+ fqn: str,
333
+ field: FieldDef,
334
+ diagnostics: list[Diagnostic],
335
+ path: str | Path | None,
336
+ *,
337
+ field_path: list[str],
338
+ field_type=None,
339
+ ) -> None:
340
+ field_label = ".".join(field_path)
341
+ try:
342
+ field.wire_targets()
343
+ except ValueError as exc:
344
+ diagnostics.append(_diag("SEM", f"{fqn}: field '{field_label}' has conflicting @wire annotations: {exc}", path))
345
+ return
346
+ for annotation in field.annotations:
347
+ if annotation.kind == "classification":
348
+ _validate_classification_level(
349
+ fqn,
350
+ field_label,
351
+ annotation.level,
352
+ diagnostics,
353
+ path,
354
+ )
355
+ elif annotation.kind == "wire":
356
+ _validate_wire_hints(
357
+ fqn,
358
+ field,
359
+ annotation,
360
+ diagnostics,
361
+ path,
362
+ field_label=field_label,
363
+ field_type=field_type,
364
+ )
365
+ if isinstance(field_type, ObjectType):
366
+ for child in field_type.fields:
367
+ _validate_field_annotations(
368
+ fqn,
369
+ child,
370
+ diagnostics,
371
+ path,
372
+ field_path=[*field_path, child.name],
373
+ field_type=child.type,
374
+ )
375
+
376
+
377
+ def _validate_wire_hints(
378
+ fqn: str,
379
+ field: FieldDef,
380
+ annotation: AnnWire,
381
+ diagnostics: list[Diagnostic],
382
+ path: str | Path | None,
383
+ *,
384
+ field_label: str | None = None,
385
+ field_type=None,
386
+ ) -> None:
387
+ label = field_label or field.name
388
+ for target_name, hint in annotation.targets.items():
389
+ if target_name not in _VALID_WIRE_TARGETS:
390
+ diagnostics.append(
391
+ _diag(
392
+ "SEM",
393
+ f"{fqn}: field '{label}' has unknown wire target '{target_name}'. "
394
+ f"Valid targets are: {', '.join(sorted(_VALID_WIRE_TARGETS))}",
395
+ path,
396
+ )
397
+ )
398
+ continue
399
+
400
+ if target_name == "json":
401
+ _validate_json_wire_hint(
402
+ fqn,
403
+ field,
404
+ hint,
405
+ diagnostics,
406
+ path,
407
+ field_label=label,
408
+ field_type=field_type,
409
+ )
410
+ elif target_name == "rust":
411
+ _validate_rust_wire_hint(
412
+ fqn,
413
+ field,
414
+ hint,
415
+ diagnostics,
416
+ path,
417
+ field_label=label,
418
+ field_type=field_type,
419
+ )
420
+ elif target_name == "clickhouse":
421
+ _validate_clickhouse_wire_hint(
422
+ fqn,
423
+ field,
424
+ hint,
425
+ diagnostics,
426
+ path,
427
+ field_label=label,
428
+ field_type=field_type,
429
+ )
430
+
431
+
432
+ def _validate_json_wire_hint(
433
+ fqn: str,
434
+ field: FieldDef,
435
+ hint,
436
+ diagnostics: list[Diagnostic],
437
+ path: str | Path | None,
438
+ *,
439
+ field_label: str | None = None,
440
+ field_type=None,
441
+ ) -> None:
442
+ label = field_label or field.name
443
+ if hint.field_case is not None:
444
+ diagnostics.append(
445
+ _diag(
446
+ "SEM",
447
+ f"{fqn}: field '{label}' may not use @wire(json.fieldCase: ...) — "
448
+ "json.fieldCase is only valid on model/projection declarations",
449
+ path,
450
+ )
451
+ )
452
+ return
453
+ is_enum = isinstance(field_type, EnumType)
454
+
455
+ if hint.encoding is None:
456
+ # json.case / json.overrides on enum fields are valid without an encoding
457
+ if is_enum and (hint.case is not None or hint.overrides):
458
+ return
459
+ diagnostics.append(
460
+ _diag(
461
+ "SEM",
462
+ f"{fqn}: field '{label}' has @wire(json: ...) without an encoding",
463
+ path,
464
+ )
465
+ )
466
+ return
467
+ if hint.encoding not in _VALID_JSON_ENCODINGS:
468
+ diagnostics.append(
469
+ _diag(
470
+ "SEM",
471
+ f"{fqn}: field '{label}' has unsupported json wire encoding '{hint.encoding}'. "
472
+ f"Valid encodings are: {', '.join(sorted(_VALID_JSON_ENCODINGS))}",
473
+ path,
474
+ )
475
+ )
476
+ return
477
+ # hint.type is a Rust-specific modifier that doesn't belong on the json target
478
+ if hint.type is not None:
479
+ diagnostics.append(
480
+ _diag(
481
+ "SEM",
482
+ f"{fqn}: field '{label}' may not use rust.type on a json wire hint",
483
+ path,
484
+ )
485
+ )
486
+ return
487
+ # json.case / json.overrides are valid JSON modifiers but only on enum fields
488
+ if (hint.case is not None or hint.overrides) and not is_enum:
489
+ diagnostics.append(
490
+ _diag(
491
+ "SEM",
492
+ f"{fqn}: field '{label}' uses @wire(json.case / json.overrides) on a non-enum field",
493
+ path,
494
+ )
495
+ )
496
+ return
497
+ if (
498
+ field_type is not None
499
+ and not is_enum
500
+ and not (
501
+ (isinstance(field_type, PrimitiveType) and field_type.kind == "int") or isinstance(field_type, DecimalType)
502
+ )
503
+ ):
504
+ diagnostics.append(
505
+ _diag(
506
+ "SEM",
507
+ f"{fqn}: field '{label}' only supports @wire(json: ...) on int, decimal, or enum fields",
508
+ path,
509
+ )
510
+ )
511
+
512
+
513
+ def _validate_rust_wire_hint(
514
+ fqn: str,
515
+ field: FieldDef,
516
+ hint,
517
+ diagnostics: list[Diagnostic],
518
+ path: str | Path | None,
519
+ *,
520
+ field_label: str | None = None,
521
+ field_type=None,
522
+ ) -> None:
523
+ label = field_label or field.name
524
+ if hint.encoding is not None:
525
+ diagnostics.append(
526
+ _diag(
527
+ "SEM",
528
+ f"{fqn}: field '{label}' may not use an encoding on rust wire hints",
529
+ path,
530
+ )
531
+ )
532
+ return
533
+ if (
534
+ hint.type is not None
535
+ and field_type is not None
536
+ and not (isinstance(field_type, PrimitiveType) and field_type.kind == "int")
537
+ ):
538
+ diagnostics.append(
539
+ _diag(
540
+ "SEM",
541
+ f"{fqn}: field '{label}' only supports rust.type on int fields",
542
+ path,
543
+ )
544
+ )
545
+ if hint.case is not None and hint.case not in _VALID_RUST_CASE_VALUES:
546
+ diagnostics.append(
547
+ _diag(
548
+ "SEM",
549
+ f"{fqn}: field '{label}' has unsupported rust.case '{hint.case}'. "
550
+ f"Valid values are: {', '.join(sorted(_VALID_RUST_CASE_VALUES))}",
551
+ path,
552
+ )
553
+ )
554
+ if hint.overrides:
555
+ if field_type is None or not isinstance(field_type, EnumType):
556
+ diagnostics.append(
557
+ _diag(
558
+ "SEM",
559
+ f"{fqn}: field '{label}' only supports rust.overrides on enum fields",
560
+ path,
561
+ )
562
+ )
563
+ else:
564
+ invalid_keys = sorted(set(hint.overrides) - set(field_type.values))
565
+ if invalid_keys:
566
+ diagnostics.append(
567
+ _diag(
568
+ "SEM",
569
+ f"{fqn}: field '{label}' has rust.overrides entries for unknown enum members: "
570
+ + ", ".join(invalid_keys),
571
+ path,
572
+ )
573
+ )
574
+
575
+
576
+ def _validate_clickhouse_wire_hint(
577
+ fqn: str,
578
+ field: FieldDef,
579
+ hint,
580
+ diagnostics: list[Diagnostic],
581
+ path: str | Path | None,
582
+ *,
583
+ field_label: str | None = None,
584
+ field_type=None,
585
+ ) -> None:
586
+ label = field_label or field.name
587
+ if hint.encoding is None:
588
+ diagnostics.append(
589
+ _diag(
590
+ "SEM",
591
+ f"{fqn}: field '{label}' has @wire(clickhouse: ...) without an encoding",
592
+ path,
593
+ )
594
+ )
595
+ return
596
+ if hint.encoding not in _VALID_CLICKHOUSE_ENCODINGS:
597
+ diagnostics.append(
598
+ _diag(
599
+ "SEM",
600
+ f"{fqn}: field '{label}' has unsupported clickhouse wire encoding '{hint.encoding}'. "
601
+ f"Valid encodings are: {', '.join(sorted(_VALID_CLICKHOUSE_ENCODINGS))}",
602
+ path,
603
+ )
604
+ )
605
+ return
606
+
607
+
608
+ def _resolve_projection_field_type(field, projection, mdl):
609
+ if not hasattr(field, "mapping"):
610
+ return getattr(field, "type", None)
611
+ mapping = field.mapping
612
+ if isinstance(mapping, ComputedMapping):
613
+ return None
614
+ if mapping.source_alias == projection.source.alias:
615
+ source_ref = projection.source
616
+ else:
617
+ source_ref = next((j for j in projection.joins if j.alias == mapping.source_alias), None)
618
+ if source_ref is None:
619
+ return None
620
+ try:
621
+ source_domain, source_model = source_ref.model.rsplit(".", 1)
622
+ except ValueError:
623
+ return None
624
+ try:
625
+ resolved = resolve_model_ref(mdl, f"{source_domain}.{source_model}", source_ref.version)
626
+ except LookupError:
627
+ return None
628
+ return _resolve_field_type_from_version(
629
+ mdl,
630
+ resolved.version,
631
+ mapping.source_field,
632
+ )
633
+
634
+
635
+ def _resolve_field_type_from_version(mdl: MdlFile, version, field_name: str):
636
+ if hasattr(version, "fields"):
637
+ field = next((item for item in version.fields if item.name == field_name), None)
638
+ if field is None:
639
+ return None
640
+ field_type = getattr(field, "type", None)
641
+ if field_type is not None:
642
+ return field_type
643
+ mapping = getattr(field, "mapping", None)
644
+ if mapping is None or mapping.kind != "direct":
645
+ return None
646
+ try:
647
+ source_domain, source_model = version.source.model.rsplit(".", 1)
648
+ except ValueError, AttributeError:
649
+ return None
650
+ try:
651
+ resolved = resolve_model_ref(mdl, f"{source_domain}.{source_model}", version.source.version)
652
+ except LookupError:
653
+ return None
654
+ return _resolve_field_type_from_version(mdl, resolved.version, mapping.source_field)
655
+ return None
656
+
657
+
658
+ def _diag(code: str, message: str, path: str | Path | None) -> Diagnostic:
659
+ return Diagnostic(code=code, message=message, severity="error", path=str(path or "<workspace>"))