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