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,1077 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from modelable.llm.render import render_model_version
|
|
12
|
+
from modelable.parser.ir import (
|
|
13
|
+
AnnClassification,
|
|
14
|
+
AnnKey,
|
|
15
|
+
Annotation,
|
|
16
|
+
AnnOwner,
|
|
17
|
+
AnnPii,
|
|
18
|
+
ArrayType,
|
|
19
|
+
ChangeKind,
|
|
20
|
+
DecimalType,
|
|
21
|
+
DomainDef,
|
|
22
|
+
EnumType,
|
|
23
|
+
FieldDef,
|
|
24
|
+
FieldType,
|
|
25
|
+
MdlFile,
|
|
26
|
+
ModelKind,
|
|
27
|
+
ModelVersion,
|
|
28
|
+
NamedType,
|
|
29
|
+
ObjectType,
|
|
30
|
+
PrimitiveType,
|
|
31
|
+
RefType,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class ImportedModel:
|
|
37
|
+
source_format: str
|
|
38
|
+
source_name: str
|
|
39
|
+
domain_name: str
|
|
40
|
+
model_name: str
|
|
41
|
+
model_version: ModelVersion
|
|
42
|
+
warnings: list[str] = field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
def to_mdl(self) -> str:
|
|
45
|
+
return render_model_version(self.domain_name, self.model_name, self.model_version, owner="imported")
|
|
46
|
+
|
|
47
|
+
def to_workspace(self) -> MdlFile:
|
|
48
|
+
return MdlFile(domains=[DomainDef(name=self.domain_name, models={self.model_name: [self.model_version]})])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def import_from_text(
|
|
52
|
+
source_text: str, source_format: str, *, domain_name: str | None = None, source_name: str | None = None
|
|
53
|
+
) -> ImportedModel:
|
|
54
|
+
source_format = source_format.lower()
|
|
55
|
+
if source_format == "json-schema":
|
|
56
|
+
return _import_json_schema(source_text, domain_name=domain_name)
|
|
57
|
+
if source_format == "openapi":
|
|
58
|
+
return _import_openapi(source_text, domain_name=domain_name)
|
|
59
|
+
if source_format == "avro":
|
|
60
|
+
return _import_avro(source_text, domain_name=domain_name)
|
|
61
|
+
if source_format == "protobuf":
|
|
62
|
+
return _import_protobuf(source_text, domain_name=domain_name)
|
|
63
|
+
if source_format in {"sql", "ddl"}:
|
|
64
|
+
return _import_sql(source_text, domain_name=domain_name)
|
|
65
|
+
if source_format == "dbt":
|
|
66
|
+
return _import_dbt(source_text, domain_name=domain_name, source_name=source_name)
|
|
67
|
+
if source_format == "fhir":
|
|
68
|
+
return _import_fhir(source_text, domain_name=domain_name, source_name=source_name)
|
|
69
|
+
if source_format == "odcs":
|
|
70
|
+
return _import_odcs(source_text, domain_name=domain_name, source_name=source_name)
|
|
71
|
+
raise ValueError(f"Unsupported source format: {source_format}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def import_from_path(
|
|
75
|
+
path: str | Path, source_format: str, *, domain_name: str | None = None, source_name: str | None = None
|
|
76
|
+
) -> ImportedModel:
|
|
77
|
+
return import_from_text(
|
|
78
|
+
Path(path).read_text(encoding="utf-8"), source_format, domain_name=domain_name, source_name=source_name
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _import_json_schema(source_text: str, *, domain_name: str | None) -> ImportedModel:
|
|
83
|
+
schema = json.loads(source_text)
|
|
84
|
+
modelable = schema.get("x-modelable") or {}
|
|
85
|
+
title = modelable.get("name") or schema.get("title") or "ImportedModel"
|
|
86
|
+
domain = domain_name or modelable.get("domain") or _guess_domain_name(title)
|
|
87
|
+
model_name = _sanitize_ident(title)
|
|
88
|
+
fields, warnings = _fields_from_json_schema(schema)
|
|
89
|
+
version = ModelVersion(
|
|
90
|
+
model_kind=ModelKind.entity,
|
|
91
|
+
version=_coerce_int(modelable.get("version")) or 1,
|
|
92
|
+
change_kind=ChangeKind.additive,
|
|
93
|
+
fields=fields,
|
|
94
|
+
)
|
|
95
|
+
return ImportedModel("json-schema", title, domain, model_name, version, warnings)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _import_openapi(source_text: str, *, domain_name: str | None) -> ImportedModel:
|
|
99
|
+
doc = json.loads(source_text)
|
|
100
|
+
schema = doc.get("components", {}).get("schemas", {})
|
|
101
|
+
if schema:
|
|
102
|
+
name, payload = next(iter(schema.items()))
|
|
103
|
+
else:
|
|
104
|
+
name, payload = "OpenApiModel", doc
|
|
105
|
+
domain = domain_name or _guess_domain_name(name)
|
|
106
|
+
model_name = _sanitize_ident(name)
|
|
107
|
+
fields, warnings = _fields_from_json_schema(payload)
|
|
108
|
+
version = ModelVersion(model_kind=ModelKind.entity, version=1, change_kind=ChangeKind.additive, fields=fields)
|
|
109
|
+
return ImportedModel("openapi", name, domain, model_name, version, warnings)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _import_avro(source_text: str, *, domain_name: str | None) -> ImportedModel:
|
|
113
|
+
doc = json.loads(source_text)
|
|
114
|
+
name = doc.get("name") or doc.get("type") or "AvroRecord"
|
|
115
|
+
domain = domain_name or _guess_domain_name(name)
|
|
116
|
+
fields: list[FieldDef] = []
|
|
117
|
+
warnings: list[str] = []
|
|
118
|
+
for item in doc.get("fields", []):
|
|
119
|
+
fields.append(_field_from_avro(item, warnings))
|
|
120
|
+
version = ModelVersion(model_kind=ModelKind.entity, version=1, change_kind=ChangeKind.additive, fields=fields)
|
|
121
|
+
return ImportedModel("avro", name, domain, _sanitize_ident(name), version, warnings)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _import_protobuf(source_text: str, *, domain_name: str | None) -> ImportedModel:
|
|
125
|
+
message_match = re.search(r"message\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{(?P<body>.*?)\}", source_text, re.DOTALL)
|
|
126
|
+
name = message_match.group(1) if message_match else "ProtoMessage"
|
|
127
|
+
body = message_match.group("body") if message_match else source_text
|
|
128
|
+
domain = domain_name or _guess_domain_name(name)
|
|
129
|
+
fields: list[FieldDef] = []
|
|
130
|
+
warnings: list[str] = []
|
|
131
|
+
for line in body.splitlines():
|
|
132
|
+
line = line.strip().rstrip(";")
|
|
133
|
+
if not line or line.startswith("//"):
|
|
134
|
+
continue
|
|
135
|
+
match = re.match(
|
|
136
|
+
r"(optional|required|repeated)?\s*([A-Za-z_][A-Za-z0-9_<>,.]*)\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\d+", line
|
|
137
|
+
)
|
|
138
|
+
if not match:
|
|
139
|
+
warnings.append(f"Skipped unsupported protobuf line: {line}")
|
|
140
|
+
continue
|
|
141
|
+
label, type_name, field_name = match.groups()
|
|
142
|
+
field = FieldDef(name=field_name, type=_primitive_or_named_type(type_name), optional=label == "optional")
|
|
143
|
+
fields.append(field)
|
|
144
|
+
version = ModelVersion(model_kind=ModelKind.entity, version=1, change_kind=ChangeKind.additive, fields=fields)
|
|
145
|
+
return ImportedModel("protobuf", name, domain, _sanitize_ident(name), version, warnings)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _import_sql(source_text: str, *, domain_name: str | None) -> ImportedModel:
|
|
149
|
+
match = re.search(
|
|
150
|
+
r"create\s+table\s+([A-Za-z_][A-Za-z0-9_\.]*)\s*\((?P<body>.*?)\)\s*;?", source_text, re.IGNORECASE | re.DOTALL
|
|
151
|
+
)
|
|
152
|
+
table_name = match.group(1) if match else "ImportedTable"
|
|
153
|
+
body = match.group("body") if match else source_text
|
|
154
|
+
domain = domain_name or _guess_domain_name(table_name)
|
|
155
|
+
fields: list[FieldDef] = []
|
|
156
|
+
warnings: list[str] = []
|
|
157
|
+
primary_key: set[str] = set()
|
|
158
|
+
for chunk in _split_sql_columns(body):
|
|
159
|
+
lower = chunk.lower()
|
|
160
|
+
if lower.startswith("primary key"):
|
|
161
|
+
primary_key.update(re.findall(r"[A-Za-z_][A-Za-z0-9_]*", chunk))
|
|
162
|
+
continue
|
|
163
|
+
parts = chunk.split()
|
|
164
|
+
if len(parts) < 2:
|
|
165
|
+
warnings.append(f"Skipped unsupported SQL column: {chunk}")
|
|
166
|
+
continue
|
|
167
|
+
field_name = parts[0]
|
|
168
|
+
type_tokens: list[str] = []
|
|
169
|
+
for token in parts[1:]:
|
|
170
|
+
if token.upper() in {
|
|
171
|
+
"NOT",
|
|
172
|
+
"NULL",
|
|
173
|
+
"PRIMARY",
|
|
174
|
+
"KEY",
|
|
175
|
+
"DEFAULT",
|
|
176
|
+
"REFERENCES",
|
|
177
|
+
"CONSTRAINT",
|
|
178
|
+
"UNIQUE",
|
|
179
|
+
"CHECK",
|
|
180
|
+
}:
|
|
181
|
+
break
|
|
182
|
+
type_tokens.append(token)
|
|
183
|
+
type_name = " ".join(type_tokens) if type_tokens else parts[1]
|
|
184
|
+
optional = "NOT NULL" not in chunk.upper()
|
|
185
|
+
field = FieldDef(name=field_name, type=_sql_type_to_field_type(type_name), optional=optional)
|
|
186
|
+
fields.append(field)
|
|
187
|
+
for field in fields:
|
|
188
|
+
if field.name in primary_key:
|
|
189
|
+
field.annotations.append(AnnKey())
|
|
190
|
+
field.optional = False
|
|
191
|
+
version = ModelVersion(model_kind=ModelKind.entity, version=1, change_kind=ChangeKind.additive, fields=fields)
|
|
192
|
+
return ImportedModel("sql", table_name, domain, _sanitize_ident(_basename_name(table_name)), version, warnings)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _import_dbt(source_text: str, *, domain_name: str | None, source_name: str | None = None) -> ImportedModel:
|
|
196
|
+
stripped = source_text.strip()
|
|
197
|
+
if stripped.startswith("{") and stripped.endswith("}"):
|
|
198
|
+
try:
|
|
199
|
+
doc = json.loads(stripped)
|
|
200
|
+
if "nodes" in doc:
|
|
201
|
+
return _import_dbt_manifest(doc, domain_name=domain_name, source_name=source_name)
|
|
202
|
+
except json.JSONDecodeError:
|
|
203
|
+
pass
|
|
204
|
+
|
|
205
|
+
doc = yaml.safe_load(source_text) or {}
|
|
206
|
+
models = doc.get("models") or []
|
|
207
|
+
if not models:
|
|
208
|
+
return _import_dbt_source_yaml(doc, domain_name=domain_name, source_name=source_name)
|
|
209
|
+
model, versioned_model = _select_dbt_model(models, source_name=source_name)
|
|
210
|
+
name = model.get("name") or "DbtModel"
|
|
211
|
+
domain = domain_name or _guess_domain_name(name)
|
|
212
|
+
warnings: list[str] = []
|
|
213
|
+
columns = _dbt_columns_for_selected_model(model, versioned_model)
|
|
214
|
+
unique_keys = _dbt_unique_keys(model) | _dbt_unique_keys(versioned_model or {})
|
|
215
|
+
fields = _fields_from_dbt_columns(columns, warnings, unique_keys=unique_keys)
|
|
216
|
+
version = ModelVersion(
|
|
217
|
+
model_kind=ModelKind.entity,
|
|
218
|
+
version=_dbt_modelable_version(model, versioned_model),
|
|
219
|
+
change_kind=ChangeKind.additive,
|
|
220
|
+
fields=fields,
|
|
221
|
+
)
|
|
222
|
+
return ImportedModel("dbt", name, domain, _sanitize_ident(name), version, warnings)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _import_dbt_source_yaml(
|
|
226
|
+
doc: dict[str, Any], *, domain_name: str | None, source_name: str | None = None
|
|
227
|
+
) -> ImportedModel:
|
|
228
|
+
sources = doc.get("sources") or []
|
|
229
|
+
tables: list[dict[str, Any]] = []
|
|
230
|
+
for source in sources:
|
|
231
|
+
if not isinstance(source, dict):
|
|
232
|
+
continue
|
|
233
|
+
for table in source.get("tables") or []:
|
|
234
|
+
if isinstance(table, dict):
|
|
235
|
+
tables.append(table)
|
|
236
|
+
if not tables:
|
|
237
|
+
raise ValueError("dbt schema document does not declare any models or source tables")
|
|
238
|
+
|
|
239
|
+
if source_name is not None:
|
|
240
|
+
table = next((item for item in tables if item.get("name") == source_name), None)
|
|
241
|
+
if table is None:
|
|
242
|
+
raise ValueError(f"dbt source table '{source_name}' not found in source")
|
|
243
|
+
else:
|
|
244
|
+
table = tables[0]
|
|
245
|
+
|
|
246
|
+
name = table.get("name") or "DbtSource"
|
|
247
|
+
domain = domain_name or _guess_domain_name(name)
|
|
248
|
+
warnings: list[str] = []
|
|
249
|
+
fields = _fields_from_dbt_columns(table.get("columns") or [], warnings, unique_keys=_dbt_unique_keys(table))
|
|
250
|
+
|
|
251
|
+
version = ModelVersion(model_kind=ModelKind.entity, version=1, change_kind=ChangeKind.additive, fields=fields)
|
|
252
|
+
return ImportedModel("dbt", name, domain, _sanitize_ident(name), version, warnings)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _import_dbt_manifest(
|
|
256
|
+
doc: dict[str, Any], *, domain_name: str | None, source_name: str | None = None
|
|
257
|
+
) -> ImportedModel:
|
|
258
|
+
nodes = doc.get("nodes") or {}
|
|
259
|
+
models = [node for node in nodes.values() if node.get("resource_type") == "model" and "name" in node]
|
|
260
|
+
sources_doc = doc.get("sources") or {}
|
|
261
|
+
sources = {
|
|
262
|
+
source["name"]: source
|
|
263
|
+
for source in sources_doc.values()
|
|
264
|
+
if source.get("resource_type") == "source" and "name" in source
|
|
265
|
+
}
|
|
266
|
+
if not models and not sources:
|
|
267
|
+
raise ValueError("dbt manifest does not declare any models or source tables")
|
|
268
|
+
|
|
269
|
+
if source_name is not None:
|
|
270
|
+
model, _ = _select_dbt_model(models, source_name=source_name, allow_missing=True)
|
|
271
|
+
source_name_base, requested_version = _parse_dbt_source_name(source_name)
|
|
272
|
+
source = sources.get(source_name_base) if model is None and requested_version is None else None
|
|
273
|
+
if model is None and source is None:
|
|
274
|
+
raise ValueError(f"dbt model or source table '{source_name}' not found in manifest")
|
|
275
|
+
else:
|
|
276
|
+
model = _select_dbt_model(models, source_name=None)[0] if models else None
|
|
277
|
+
source = sources[sorted(sources.keys())[0]] if model is None else None
|
|
278
|
+
|
|
279
|
+
selected = model or source
|
|
280
|
+
name = selected["name"]
|
|
281
|
+
domain = domain_name or _guess_domain_name(name)
|
|
282
|
+
warnings: list[str] = []
|
|
283
|
+
fields = _fields_from_dbt_columns(selected.get("columns") or {}, warnings, unique_keys=_dbt_unique_keys(selected))
|
|
284
|
+
|
|
285
|
+
version = ModelVersion(
|
|
286
|
+
model_kind=ModelKind.entity,
|
|
287
|
+
version=_dbt_modelable_version(selected, None),
|
|
288
|
+
change_kind=ChangeKind.additive,
|
|
289
|
+
fields=fields,
|
|
290
|
+
)
|
|
291
|
+
return ImportedModel("dbt", name, domain, _sanitize_ident(name), version, warnings)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _select_dbt_model(
|
|
295
|
+
models: list[dict[str, Any]], *, source_name: str | None, allow_missing: bool = False
|
|
296
|
+
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
|
297
|
+
if source_name is None:
|
|
298
|
+
model = sorted(models, key=lambda item: str(item.get("name") or ""))[0]
|
|
299
|
+
return model, _select_dbt_model_version(model, requested_version=None)
|
|
300
|
+
|
|
301
|
+
model_name, requested_version = _parse_dbt_source_name(source_name)
|
|
302
|
+
matching = [item for item in models if item.get("name") == model_name]
|
|
303
|
+
if not matching:
|
|
304
|
+
if allow_missing:
|
|
305
|
+
return None, None
|
|
306
|
+
raise ValueError(f"dbt model '{source_name}' not found in source")
|
|
307
|
+
|
|
308
|
+
model = _select_manifest_model_version(matching, requested_version=requested_version)
|
|
309
|
+
if model is not None:
|
|
310
|
+
return model, None
|
|
311
|
+
|
|
312
|
+
model = matching[0]
|
|
313
|
+
versioned_model = _select_dbt_model_version(model, requested_version=requested_version)
|
|
314
|
+
return model, versioned_model
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _select_manifest_model_version(
|
|
318
|
+
models: list[dict[str, Any]], *, requested_version: int | None
|
|
319
|
+
) -> dict[str, Any] | None:
|
|
320
|
+
versioned_models = [item for item in models if _dbt_resource_version(item) is not None]
|
|
321
|
+
if not versioned_models:
|
|
322
|
+
return None
|
|
323
|
+
if requested_version is not None:
|
|
324
|
+
return next((item for item in versioned_models if _dbt_resource_version(item) == requested_version), None)
|
|
325
|
+
latest_version = next(
|
|
326
|
+
(_coerce_int(item.get("latest_version")) for item in versioned_models if item.get("latest_version")), None
|
|
327
|
+
)
|
|
328
|
+
if latest_version is not None:
|
|
329
|
+
latest = next((item for item in versioned_models if _dbt_resource_version(item) == latest_version), None)
|
|
330
|
+
if latest is not None:
|
|
331
|
+
return latest
|
|
332
|
+
return max(versioned_models, key=lambda item: _dbt_resource_version(item) or 0)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _select_dbt_model_version(model: dict[str, Any], *, requested_version: int | None) -> dict[str, Any] | None:
|
|
336
|
+
versions = [item for item in model.get("versions") or [] if isinstance(item, dict)]
|
|
337
|
+
if not versions:
|
|
338
|
+
if requested_version is not None:
|
|
339
|
+
raise ValueError(f"dbt model '{model.get('name')}' does not declare version {requested_version}")
|
|
340
|
+
return None
|
|
341
|
+
if requested_version is not None:
|
|
342
|
+
version = next((item for item in versions if _dbt_resource_version(item) == requested_version), None)
|
|
343
|
+
if version is None:
|
|
344
|
+
raise ValueError(f"dbt model '{model.get('name')}' does not declare version {requested_version}")
|
|
345
|
+
return version
|
|
346
|
+
latest_version = _coerce_int(model.get("latest_version"))
|
|
347
|
+
if latest_version is not None:
|
|
348
|
+
version = next((item for item in versions if _dbt_resource_version(item) == latest_version), None)
|
|
349
|
+
if version is not None:
|
|
350
|
+
return version
|
|
351
|
+
return max(versions, key=lambda item: _dbt_resource_version(item) or 0)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _dbt_columns_for_selected_model(
|
|
355
|
+
model: dict[str, Any], versioned_model: dict[str, Any] | None
|
|
356
|
+
) -> list[dict[str, Any]]:
|
|
357
|
+
if versioned_model is None:
|
|
358
|
+
return _dbt_columns_as_list(model.get("columns") or [])
|
|
359
|
+
return _merge_dbt_columns(model.get("columns") or [], versioned_model.get("columns") or [])
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _merge_dbt_columns(base_columns: Any, override_columns: Any) -> list[dict[str, Any]]:
|
|
363
|
+
merged: dict[str, dict[str, Any]] = {}
|
|
364
|
+
order: list[str] = []
|
|
365
|
+
for column in _dbt_columns_as_list(base_columns):
|
|
366
|
+
name = column.get("name")
|
|
367
|
+
if not name:
|
|
368
|
+
continue
|
|
369
|
+
name = str(name)
|
|
370
|
+
order.append(name)
|
|
371
|
+
merged[name] = column
|
|
372
|
+
for column in _dbt_columns_as_list(override_columns):
|
|
373
|
+
name = column.get("name")
|
|
374
|
+
if not name:
|
|
375
|
+
continue
|
|
376
|
+
name = str(name)
|
|
377
|
+
if name not in merged:
|
|
378
|
+
order.append(name)
|
|
379
|
+
merged[name] = {**merged.get(name, {}), **column}
|
|
380
|
+
return [merged[name] for name in order]
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _dbt_columns_as_list(columns: Any) -> list[dict[str, Any]]:
|
|
384
|
+
if isinstance(columns, dict):
|
|
385
|
+
return [{"name": key, **value} if isinstance(value, dict) else {"name": key} for key, value in columns.items()]
|
|
386
|
+
return [item for item in columns if isinstance(item, dict)]
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _dbt_modelable_version(model: dict[str, Any], versioned_model: dict[str, Any] | None) -> int:
|
|
390
|
+
return _dbt_resource_version(versioned_model or model) or 1
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _dbt_resource_version(resource: dict[str, Any]) -> int | None:
|
|
394
|
+
return _coerce_int(resource.get("version") or resource.get("v"))
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _parse_dbt_source_name(source_name: str) -> tuple[str, int | None]:
|
|
398
|
+
model_name, sep, version_text = source_name.partition("@")
|
|
399
|
+
if not sep:
|
|
400
|
+
return source_name, None
|
|
401
|
+
version = _coerce_int(version_text)
|
|
402
|
+
if version is None:
|
|
403
|
+
raise ValueError(f"dbt source name '{source_name}' must use an integer @version suffix")
|
|
404
|
+
return model_name, version
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _coerce_int(value: Any) -> int | None:
|
|
408
|
+
if isinstance(value, int):
|
|
409
|
+
return value
|
|
410
|
+
if isinstance(value, str) and value.isdigit():
|
|
411
|
+
return int(value)
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _fields_from_dbt_columns(
|
|
416
|
+
columns: Any, warnings: list[str], *, unique_keys: set[str] | None = None
|
|
417
|
+
) -> list[FieldDef]:
|
|
418
|
+
fields: list[FieldDef] = []
|
|
419
|
+
column_items = columns.items() if isinstance(columns, dict) else ((None, column) for column in columns)
|
|
420
|
+
for column_name, column in column_items:
|
|
421
|
+
if not isinstance(column, dict):
|
|
422
|
+
continue
|
|
423
|
+
if "name" not in column and column_name is not None:
|
|
424
|
+
column = {"name": column_name, **column}
|
|
425
|
+
fields.append(_field_from_dbt_column(column, warnings, unique_keys=unique_keys or set()))
|
|
426
|
+
return fields
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _field_from_dbt_column(column: dict[str, Any], warnings: list[str], *, unique_keys: set[str]) -> FieldDef:
|
|
430
|
+
name = column["name"]
|
|
431
|
+
data_type = column.get("data_type")
|
|
432
|
+
field_type: FieldType
|
|
433
|
+
if data_type:
|
|
434
|
+
field_type = _sql_type_to_field_type(data_type)
|
|
435
|
+
else:
|
|
436
|
+
warnings.append(f"Column '{name}' has no data_type; defaulting to string")
|
|
437
|
+
field_type = PrimitiveType(kind="string")
|
|
438
|
+
|
|
439
|
+
constraint_types = {
|
|
440
|
+
constraint.get("type") for constraint in column.get("constraints") or [] if isinstance(constraint, dict)
|
|
441
|
+
}
|
|
442
|
+
data_test_types = _dbt_data_test_types(column)
|
|
443
|
+
annotations: list[Annotation] = []
|
|
444
|
+
meta = column.get("meta") or {}
|
|
445
|
+
if "primary_key" in constraint_types or name in unique_keys or _metadata_flag(meta.get("modelable_key")):
|
|
446
|
+
annotations.append(AnnKey())
|
|
447
|
+
optional = (
|
|
448
|
+
"not_null" not in constraint_types
|
|
449
|
+
and "not_null" not in data_test_types
|
|
450
|
+
and "primary_key" not in constraint_types
|
|
451
|
+
and not any(isinstance(ann, AnnKey) for ann in annotations)
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
if meta.get("modelable_pii"):
|
|
455
|
+
annotations.append(AnnPii())
|
|
456
|
+
classification = meta.get("modelable_classification")
|
|
457
|
+
if classification:
|
|
458
|
+
annotations.append(AnnClassification(level=str(classification)))
|
|
459
|
+
owner = meta.get("modelable_owner")
|
|
460
|
+
if owner:
|
|
461
|
+
annotations.append(AnnOwner(team=str(owner)))
|
|
462
|
+
|
|
463
|
+
return FieldDef(name=name, type=field_type, optional=optional, annotations=annotations)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _dbt_unique_keys(resource: dict[str, Any]) -> set[str]:
|
|
467
|
+
config = resource.get("config") or {}
|
|
468
|
+
raw = config.get("unique_key") if isinstance(config, dict) else None
|
|
469
|
+
if raw is None:
|
|
470
|
+
raw = resource.get("unique_key")
|
|
471
|
+
if isinstance(raw, str):
|
|
472
|
+
return {raw}
|
|
473
|
+
if isinstance(raw, list):
|
|
474
|
+
return {str(item) for item in raw}
|
|
475
|
+
return set()
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _dbt_data_test_types(column: dict[str, Any]) -> set[str]:
|
|
479
|
+
tests = []
|
|
480
|
+
for key in ("data_tests", "tests"):
|
|
481
|
+
value = column.get(key) or []
|
|
482
|
+
if isinstance(value, list):
|
|
483
|
+
tests.extend(value)
|
|
484
|
+
names: set[str] = set()
|
|
485
|
+
for item in tests:
|
|
486
|
+
if isinstance(item, str):
|
|
487
|
+
names.add(item)
|
|
488
|
+
elif isinstance(item, dict):
|
|
489
|
+
names.update(str(key) for key in item)
|
|
490
|
+
return names
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
_FHIR_PRIMITIVE_TYPES = {
|
|
494
|
+
"string": "string",
|
|
495
|
+
"code": "string",
|
|
496
|
+
"id": "string",
|
|
497
|
+
"markdown": "string",
|
|
498
|
+
"uri": "string",
|
|
499
|
+
"url": "string",
|
|
500
|
+
"canonical": "string",
|
|
501
|
+
"oid": "string",
|
|
502
|
+
"boolean": "bool",
|
|
503
|
+
"integer": "int",
|
|
504
|
+
"integer64": "int",
|
|
505
|
+
"positiveInt": "int",
|
|
506
|
+
"unsignedInt": "int",
|
|
507
|
+
"decimal": "float",
|
|
508
|
+
"dateTime": "timestamp",
|
|
509
|
+
"instant": "timestamp",
|
|
510
|
+
"date": "date",
|
|
511
|
+
"time": "time",
|
|
512
|
+
"base64Binary": "binary",
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _import_fhir(source_text: str, *, domain_name: str | None, source_name: str | None = None) -> ImportedModel:
|
|
517
|
+
doc = json.loads(source_text)
|
|
518
|
+
if doc.get("resourceType") != "StructureDefinition":
|
|
519
|
+
raise ValueError("FHIR source must be a StructureDefinition resource")
|
|
520
|
+
resource_type = doc.get("type") or doc.get("name") or "FhirResource"
|
|
521
|
+
name = doc.get("name") or resource_type
|
|
522
|
+
domain = domain_name or _guess_domain_name(resource_type)
|
|
523
|
+
|
|
524
|
+
elements = (doc.get("snapshot") or {}).get("element") or (doc.get("differential") or {}).get("element") or []
|
|
525
|
+
extension_values = _fhir_direct_extension_value_elements(resource_type, elements)
|
|
526
|
+
fields, warnings = _fhir_elements_to_fields(resource_type, elements, extension_values)
|
|
527
|
+
|
|
528
|
+
version = ModelVersion(model_kind=ModelKind.entity, version=1, change_kind=ChangeKind.additive, fields=fields)
|
|
529
|
+
return ImportedModel("fhir", name, domain, _sanitize_ident(resource_type), version, warnings)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _fhir_direct_extension_value_elements(
|
|
533
|
+
resource_type: str, elements: list[dict[str, Any]]
|
|
534
|
+
) -> dict[str, dict[str, Any]]:
|
|
535
|
+
values: dict[str, dict[str, Any]] = {}
|
|
536
|
+
prefix = f"{resource_type}.extension:"
|
|
537
|
+
for element in elements:
|
|
538
|
+
element_id = str(element.get("id") or "")
|
|
539
|
+
if not element_id.startswith(prefix):
|
|
540
|
+
continue
|
|
541
|
+
suffix = element_id[len(prefix) :]
|
|
542
|
+
slice_name, sep, child_path = suffix.partition(".")
|
|
543
|
+
if not sep or not child_path.startswith("value"):
|
|
544
|
+
continue
|
|
545
|
+
path = element.get("path")
|
|
546
|
+
if path == f"{resource_type}.extension.value[x]" or str(path).startswith(f"{resource_type}.extension.value"):
|
|
547
|
+
values[slice_name] = element
|
|
548
|
+
return values
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _fhir_elements_to_fields(
|
|
552
|
+
resource_type: str, elements: list[dict[str, Any]], extension_values: dict[str, dict[str, Any]]
|
|
553
|
+
) -> tuple[list[FieldDef], list[str]]:
|
|
554
|
+
warnings: list[str] = []
|
|
555
|
+
fields: list[FieldDef] = []
|
|
556
|
+
|
|
557
|
+
top_level: dict[str, dict[str, Any]] = {}
|
|
558
|
+
extension_slices: dict[str, dict[str, Any]] = {}
|
|
559
|
+
child_groups: dict[str, list[dict[str, Any]]] = {}
|
|
560
|
+
|
|
561
|
+
for element in elements:
|
|
562
|
+
path = element.get("path", "")
|
|
563
|
+
segments = path.split(".")
|
|
564
|
+
if len(segments) < 2 or segments[0] != resource_type:
|
|
565
|
+
continue
|
|
566
|
+
|
|
567
|
+
if len(segments) == 2:
|
|
568
|
+
field_name = segments[1]
|
|
569
|
+
if element.get("sliceName"):
|
|
570
|
+
sliced_name = _sanitize_field_ident(str(element["sliceName"]))
|
|
571
|
+
extension_slices[sliced_name] = element
|
|
572
|
+
else:
|
|
573
|
+
if field_name.endswith("[x]"):
|
|
574
|
+
field_name = field_name[: -len("[x]")]
|
|
575
|
+
top_level[field_name] = element
|
|
576
|
+
elif len(segments) >= 3:
|
|
577
|
+
parent = segments[1]
|
|
578
|
+
if ":" not in segments[1]:
|
|
579
|
+
if parent not in child_groups:
|
|
580
|
+
child_groups[parent] = []
|
|
581
|
+
child_groups[parent].append(element)
|
|
582
|
+
|
|
583
|
+
for field_name, element in top_level.items():
|
|
584
|
+
if field_name in child_groups:
|
|
585
|
+
children = child_groups[field_name]
|
|
586
|
+
nested_type_name = _fhir_type_name_for(element)
|
|
587
|
+
if nested_type_name is not None:
|
|
588
|
+
warnings.append(
|
|
589
|
+
f"Expanding complex type '{nested_type_name}' for field '{field_name}' into inline fields"
|
|
590
|
+
)
|
|
591
|
+
sub_fields = _fhir_build_nested_fields(resource_type, field_name, children, elements)
|
|
592
|
+
field = _field_from_fhir_element(field_name, element, warnings)
|
|
593
|
+
if isinstance(field.type, ArrayType):
|
|
594
|
+
field.type = ArrayType(item=ObjectType(fields=sub_fields))
|
|
595
|
+
else:
|
|
596
|
+
field.type = ObjectType(fields=sub_fields)
|
|
597
|
+
fields.append(field)
|
|
598
|
+
else:
|
|
599
|
+
fields.append(_field_from_fhir_element(field_name, element, warnings))
|
|
600
|
+
|
|
601
|
+
for slice_name, element in extension_slices.items():
|
|
602
|
+
path = element.get("path", "")
|
|
603
|
+
warnings.append(f"Sliced element '{path}' imported as field '{slice_name}'")
|
|
604
|
+
if path == f"{resource_type}.extension":
|
|
605
|
+
value_element = extension_values.get(slice_name)
|
|
606
|
+
if value_element is not None:
|
|
607
|
+
fields.append(_field_from_fhir_extension_slice(slice_name, element, value_element, warnings))
|
|
608
|
+
continue
|
|
609
|
+
fields.append(_field_from_fhir_element(slice_name, element, warnings))
|
|
610
|
+
|
|
611
|
+
for parent_name, children in child_groups.items():
|
|
612
|
+
if parent_name not in top_level and parent_name not in extension_slices:
|
|
613
|
+
sub_fields = _fhir_build_nested_fields(resource_type, parent_name, children, elements)
|
|
614
|
+
fields.append(
|
|
615
|
+
FieldDef(
|
|
616
|
+
name=parent_name,
|
|
617
|
+
type=ObjectType(fields=sub_fields),
|
|
618
|
+
optional=True,
|
|
619
|
+
)
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
return fields, warnings
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _fhir_type_name_for(element: dict[str, Any]) -> str | None:
|
|
626
|
+
types = element.get("type") or []
|
|
627
|
+
if types:
|
|
628
|
+
code = types[0].get("code", "") if isinstance(types[0], dict) else ""
|
|
629
|
+
if code and code not in _FHIR_PRIMITIVE_TYPES:
|
|
630
|
+
return code
|
|
631
|
+
return None
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _fhir_build_nested_fields(
|
|
635
|
+
resource_type: str, parent_name: str, child_elements: list[dict[str, Any]], all_elements: list[dict[str, Any]]
|
|
636
|
+
) -> list[FieldDef]:
|
|
637
|
+
parent_path = f"{resource_type}.{parent_name}"
|
|
638
|
+
element_by_path = {e.get("path", ""): e for e in all_elements}
|
|
639
|
+
|
|
640
|
+
children: dict[str, dict[str, Any]] = {}
|
|
641
|
+
deeper: dict[str, list[dict[str, Any]]] = {}
|
|
642
|
+
|
|
643
|
+
for element in child_elements:
|
|
644
|
+
path = element.get("path", "")
|
|
645
|
+
if not path.startswith(parent_path + "."):
|
|
646
|
+
continue
|
|
647
|
+
suffix = path[len(parent_path) + 1 :]
|
|
648
|
+
segments = suffix.split(".")
|
|
649
|
+
child_name = segments[0]
|
|
650
|
+
if child_name.endswith("[x]"):
|
|
651
|
+
child_name = child_name[:-3]
|
|
652
|
+
|
|
653
|
+
if len(segments) == 1:
|
|
654
|
+
exact_path = f"{parent_path}.{segments[0]}"
|
|
655
|
+
exact = element_by_path.get(exact_path)
|
|
656
|
+
if exact is not None:
|
|
657
|
+
children[child_name] = exact
|
|
658
|
+
else:
|
|
659
|
+
if child_name not in deeper:
|
|
660
|
+
deeper[child_name] = []
|
|
661
|
+
deeper[child_name].append(element)
|
|
662
|
+
|
|
663
|
+
fields: list[FieldDef] = []
|
|
664
|
+
for child_name, element in children.items():
|
|
665
|
+
field = _field_from_fhir_element(child_name, element, [])
|
|
666
|
+
if child_name in deeper:
|
|
667
|
+
sub_fields = _fhir_build_nested_fields(
|
|
668
|
+
resource_type, f"{parent_name}.{child_name}", deeper[child_name], all_elements
|
|
669
|
+
)
|
|
670
|
+
if isinstance(field.type, ArrayType):
|
|
671
|
+
field.type = ArrayType(item=ObjectType(fields=sub_fields))
|
|
672
|
+
else:
|
|
673
|
+
field.type = ObjectType(fields=sub_fields)
|
|
674
|
+
fields.append(field)
|
|
675
|
+
return fields
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _import_odcs(source_text: str, *, domain_name: str | None, source_name: str | None = None) -> ImportedModel:
|
|
679
|
+
doc = yaml.safe_load(source_text) or {}
|
|
680
|
+
schema = doc.get("schema") or doc.get("schemas") or []
|
|
681
|
+
if isinstance(schema, dict):
|
|
682
|
+
schema = schema.get("objects") or schema.get("tables") or [schema]
|
|
683
|
+
if not isinstance(schema, list) or not schema:
|
|
684
|
+
raise ValueError("ODCS document does not declare a schema")
|
|
685
|
+
|
|
686
|
+
if source_name is not None:
|
|
687
|
+
item = next((entry for entry in schema if isinstance(entry, dict) and entry.get("name") == source_name), None)
|
|
688
|
+
if item is None:
|
|
689
|
+
raise ValueError(f"ODCS schema '{source_name}' not found in source")
|
|
690
|
+
else:
|
|
691
|
+
item = next((entry for entry in schema if isinstance(entry, dict)), None)
|
|
692
|
+
if not isinstance(item, dict):
|
|
693
|
+
raise ValueError("ODCS schema does not contain a supported object")
|
|
694
|
+
|
|
695
|
+
name = str(item.get("name") or item.get("physicalName") or "OdcsModel")
|
|
696
|
+
domain = domain_name or _guess_domain_name(name)
|
|
697
|
+
properties = item.get("properties") or item.get("fields") or item.get("columns") or []
|
|
698
|
+
if isinstance(properties, dict):
|
|
699
|
+
properties = [
|
|
700
|
+
{"name": key, **value} if isinstance(value, dict) else {"name": key} for key, value in properties.items()
|
|
701
|
+
]
|
|
702
|
+
|
|
703
|
+
fields: list[FieldDef] = []
|
|
704
|
+
warnings: list[str] = []
|
|
705
|
+
for prop in properties:
|
|
706
|
+
if not isinstance(prop, dict) or not prop.get("name"):
|
|
707
|
+
warnings.append(f"Skipped unsupported ODCS property: {prop}")
|
|
708
|
+
continue
|
|
709
|
+
fields.append(_field_from_odcs_property(prop, warnings))
|
|
710
|
+
|
|
711
|
+
version = ModelVersion(
|
|
712
|
+
model_kind=ModelKind.entity,
|
|
713
|
+
version=_odcs_model_version(doc),
|
|
714
|
+
change_kind=ChangeKind.additive,
|
|
715
|
+
fields=fields,
|
|
716
|
+
)
|
|
717
|
+
return ImportedModel("odcs", name, domain, _sanitize_ident(name), version, warnings)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _field_from_odcs_property(prop: dict[str, Any], warnings: list[str]) -> FieldDef:
|
|
721
|
+
name = str(prop["name"])
|
|
722
|
+
custom = _custom_properties_map(prop.get("customProperties"))
|
|
723
|
+
type_name = str(
|
|
724
|
+
custom.get("modelableType")
|
|
725
|
+
or prop.get("logicalType")
|
|
726
|
+
or prop.get("physicalType")
|
|
727
|
+
or prop.get("type")
|
|
728
|
+
or prop.get("dataType")
|
|
729
|
+
or "string"
|
|
730
|
+
)
|
|
731
|
+
annotations: list[Annotation] = []
|
|
732
|
+
if (
|
|
733
|
+
_metadata_flag(prop.get("primaryKey"))
|
|
734
|
+
or _metadata_flag(prop.get("primary_key"))
|
|
735
|
+
or _metadata_flag(prop.get("key"))
|
|
736
|
+
):
|
|
737
|
+
annotations.append(AnnKey())
|
|
738
|
+
if (
|
|
739
|
+
_metadata_flag(prop.get("pii"))
|
|
740
|
+
or _metadata_flag(prop.get("personalData"))
|
|
741
|
+
or _metadata_flag(custom.get("modelablePii"))
|
|
742
|
+
):
|
|
743
|
+
annotations.append(AnnPii())
|
|
744
|
+
classification = (
|
|
745
|
+
prop.get("modelable_classification")
|
|
746
|
+
or prop.get("classificationLevel")
|
|
747
|
+
or prop.get("classification")
|
|
748
|
+
or custom.get("modelableClassification")
|
|
749
|
+
)
|
|
750
|
+
if classification and str(classification).lower() not in {"string", "number", "integer", "boolean"}:
|
|
751
|
+
annotations.append(AnnClassification(level=str(classification)))
|
|
752
|
+
owner = prop.get("owner") or custom.get("modelableOwner")
|
|
753
|
+
if owner:
|
|
754
|
+
annotations.append(AnnOwner(team=str(owner)))
|
|
755
|
+
optional = not (_metadata_flag(prop.get("required")) or any(isinstance(ann, AnnKey) for ann in annotations))
|
|
756
|
+
return FieldDef(
|
|
757
|
+
name=name,
|
|
758
|
+
type=_modelable_type_to_field_type(type_name, warnings, enum_values=custom.get("modelableEnum")),
|
|
759
|
+
optional=optional,
|
|
760
|
+
annotations=annotations,
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _odcs_model_version(doc: dict[str, Any]) -> int:
|
|
765
|
+
version = _coerce_int(doc.get("version"))
|
|
766
|
+
if version is not None:
|
|
767
|
+
return version
|
|
768
|
+
name = str(doc.get("name") or "")
|
|
769
|
+
match = re.search(r"\.v(\d+)$", name)
|
|
770
|
+
if match:
|
|
771
|
+
return int(match.group(1))
|
|
772
|
+
return 1
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _custom_properties_map(custom_properties: Any) -> dict[str, Any]:
|
|
776
|
+
if isinstance(custom_properties, dict):
|
|
777
|
+
return custom_properties
|
|
778
|
+
if not isinstance(custom_properties, list):
|
|
779
|
+
return {}
|
|
780
|
+
result: dict[str, Any] = {}
|
|
781
|
+
for item in custom_properties:
|
|
782
|
+
if isinstance(item, dict) and "property" in item:
|
|
783
|
+
result[str(item["property"])] = item.get("value")
|
|
784
|
+
return result
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _metadata_flag(value: Any) -> bool:
|
|
788
|
+
if isinstance(value, str):
|
|
789
|
+
return value.strip().lower() in {"1", "true", "yes", "y", "on"}
|
|
790
|
+
return bool(value)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _modelable_type_to_field_type(type_name: str, warnings: list[str], *, enum_values: Any = None) -> FieldType:
|
|
794
|
+
normalized = type_name.strip()
|
|
795
|
+
lower = normalized.lower()
|
|
796
|
+
if lower.startswith("enum(") and normalized.endswith(")"):
|
|
797
|
+
values = normalized[len("enum(") : -1]
|
|
798
|
+
return EnumType(values=[value.strip() for value in values.split(",") if value.strip()])
|
|
799
|
+
if isinstance(enum_values, list):
|
|
800
|
+
return EnumType(values=[str(value) for value in enum_values])
|
|
801
|
+
if lower.startswith("array<") and normalized.endswith(">"):
|
|
802
|
+
return ArrayType(item=_modelable_type_to_field_type(normalized[len("array<") : -1], warnings))
|
|
803
|
+
if lower.startswith("ref<") and normalized.endswith(">"):
|
|
804
|
+
return RefType(target=normalized[len("ref<") : -1])
|
|
805
|
+
decimal_match = re.fullmatch(r"decimal\((\d+)\s*,\s*(\d+)\)", lower)
|
|
806
|
+
if decimal_match:
|
|
807
|
+
return DecimalType(precision=int(decimal_match.group(1)), scale=int(decimal_match.group(2)))
|
|
808
|
+
return _odcs_type_to_field_type(type_name, warnings)
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def _odcs_type_to_field_type(type_name: str, warnings: list[str]) -> FieldType:
|
|
812
|
+
normalized = type_name.strip().lower()
|
|
813
|
+
if normalized in {"string", "text", "varchar"}:
|
|
814
|
+
return PrimitiveType(kind="string")
|
|
815
|
+
if normalized in {"integer", "int", "long", "bigint"}:
|
|
816
|
+
return PrimitiveType(kind="int")
|
|
817
|
+
if normalized in {"number", "float", "double"}:
|
|
818
|
+
return PrimitiveType(kind="float")
|
|
819
|
+
if normalized in {"boolean", "bool"}:
|
|
820
|
+
return PrimitiveType(kind="bool")
|
|
821
|
+
if normalized in {"date", "time", "timestamp", "uuid", "binary"}:
|
|
822
|
+
return PrimitiveType(kind=normalized)
|
|
823
|
+
warnings.append(f"Falling back to named type for ODCS type: {type_name}")
|
|
824
|
+
return NamedType(name=_sanitize_ident(type_name))
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _field_from_fhir_element(field_name: str, element: dict[str, Any], warnings: list[str]) -> FieldDef:
|
|
828
|
+
path = element.get("path", field_name)
|
|
829
|
+
types = element.get("type") or []
|
|
830
|
+
field_type: FieldType
|
|
831
|
+
if not types:
|
|
832
|
+
warnings.append(f"Element '{path}' has no declared type; defaulting to string")
|
|
833
|
+
field_type = PrimitiveType(kind="string")
|
|
834
|
+
else:
|
|
835
|
+
if len(types) > 1:
|
|
836
|
+
codes = ", ".join(str(item.get("code", "?")) for item in types)
|
|
837
|
+
warnings.append(f"Element '{path}' has multiple types ({codes}); using the first")
|
|
838
|
+
field_type = _fhir_type_to_field_type(types[0], path, warnings)
|
|
839
|
+
max_cardinality = str(element.get("max", "1"))
|
|
840
|
+
if max_cardinality == "*" or (max_cardinality.isdigit() and int(max_cardinality) > 1):
|
|
841
|
+
field_type = ArrayType(item=field_type)
|
|
842
|
+
|
|
843
|
+
binding = element.get("binding") or {}
|
|
844
|
+
if binding.get("strength") == "required" and binding.get("valueSet"):
|
|
845
|
+
warnings.append(
|
|
846
|
+
f"Element '{path}' has a required binding to {binding['valueSet']}; "
|
|
847
|
+
"represent as enum(...) manually if a fixed value set is known"
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
optional = element.get("min", 0) == 0
|
|
851
|
+
annotations: list[Annotation] = []
|
|
852
|
+
if field_name == "id":
|
|
853
|
+
annotations.append(AnnKey())
|
|
854
|
+
return FieldDef(name=field_name, type=field_type, optional=optional, annotations=annotations)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _field_from_fhir_extension_slice(
|
|
858
|
+
field_name: str, slice_element: dict[str, Any], value_element: dict[str, Any], warnings: list[str]
|
|
859
|
+
) -> FieldDef:
|
|
860
|
+
for type_entry in slice_element.get("type") or []:
|
|
861
|
+
if not isinstance(type_entry, dict) or type_entry.get("code") != "Extension":
|
|
862
|
+
continue
|
|
863
|
+
profiles = type_entry.get("profile") or []
|
|
864
|
+
if profiles:
|
|
865
|
+
warnings.append(
|
|
866
|
+
f"Element '{slice_element.get('path', field_name)}' uses FHIR Extension profile {profiles[0]}; review manually"
|
|
867
|
+
)
|
|
868
|
+
break
|
|
869
|
+
field = _field_from_fhir_element(field_name, value_element, warnings)
|
|
870
|
+
field.optional = slice_element.get("min", 0) == 0
|
|
871
|
+
max_cardinality = str(slice_element.get("max", "1"))
|
|
872
|
+
if max_cardinality == "*" or (max_cardinality.isdigit() and int(max_cardinality) > 1):
|
|
873
|
+
field.type = ArrayType(item=field.type)
|
|
874
|
+
return field
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _fhir_type_to_field_type(type_entry: dict[str, Any], path: str, warnings: list[str]) -> FieldType:
|
|
878
|
+
code = type_entry.get("code", "")
|
|
879
|
+
if code in _FHIR_PRIMITIVE_TYPES:
|
|
880
|
+
return PrimitiveType(kind=_FHIR_PRIMITIVE_TYPES[code])
|
|
881
|
+
if code == "Reference":
|
|
882
|
+
targets = type_entry.get("targetProfile") or []
|
|
883
|
+
if targets:
|
|
884
|
+
return RefType(target=str(targets[0]).rsplit("/", 1)[-1])
|
|
885
|
+
warnings.append(f"Element '{path}' is an untyped Reference; falling back to named type")
|
|
886
|
+
return NamedType(name="Reference")
|
|
887
|
+
if code == "Extension":
|
|
888
|
+
profiles = type_entry.get("profile") or []
|
|
889
|
+
if profiles:
|
|
890
|
+
warnings.append(f"Element '{path}' uses FHIR Extension profile {profiles[0]}; review manually")
|
|
891
|
+
return NamedType(name="Extension")
|
|
892
|
+
warnings.append(f"Element '{path}' has unsupported FHIR type '{code}'; falling back to named type")
|
|
893
|
+
return NamedType(name=code or "Unknown")
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def _fields_from_json_schema(schema: dict) -> tuple[list[FieldDef], list[str]]:
|
|
897
|
+
warnings: list[str] = []
|
|
898
|
+
properties = schema.get("properties", {})
|
|
899
|
+
required = set(schema.get("required", []))
|
|
900
|
+
key_field_name = next(
|
|
901
|
+
(name for name in properties if name in required and (name.lower() == "id" or name.lower().endswith("id"))),
|
|
902
|
+
None,
|
|
903
|
+
)
|
|
904
|
+
fields: list[FieldDef] = []
|
|
905
|
+
for name, prop in properties.items():
|
|
906
|
+
field_type = _field_type_from_json_schema(prop, warnings)
|
|
907
|
+
annotations: list = []
|
|
908
|
+
modelable_field = prop.get("x-modelable-field") or {}
|
|
909
|
+
if name == key_field_name or name in schema.get("x-modelable-key-fields", []) or modelable_field.get("key"):
|
|
910
|
+
annotations.append(AnnKey())
|
|
911
|
+
if modelable_field.get("pii"):
|
|
912
|
+
annotations.append(AnnPii())
|
|
913
|
+
classification = prop.get("x-modelable-classification")
|
|
914
|
+
if classification:
|
|
915
|
+
annotations.append(AnnClassification(level=str(classification)))
|
|
916
|
+
owner = modelable_field.get("owner")
|
|
917
|
+
if owner:
|
|
918
|
+
annotations.append(AnnOwner(team=str(owner)))
|
|
919
|
+
fields.append(
|
|
920
|
+
FieldDef(
|
|
921
|
+
name=name,
|
|
922
|
+
type=field_type,
|
|
923
|
+
optional=name not in required,
|
|
924
|
+
annotations=annotations,
|
|
925
|
+
)
|
|
926
|
+
)
|
|
927
|
+
return fields, warnings
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _field_type_from_json_schema(prop: dict, warnings: list[str]):
|
|
931
|
+
if "enum" in prop:
|
|
932
|
+
return EnumType(values=[str(item) for item in prop["enum"]])
|
|
933
|
+
if prop.get("type") == "array":
|
|
934
|
+
return ArrayType(item=_field_type_from_json_schema(prop.get("items", {}), warnings))
|
|
935
|
+
if prop.get("type") == "object" and "properties" in prop:
|
|
936
|
+
return ObjectType(
|
|
937
|
+
fields=[
|
|
938
|
+
FieldDef(
|
|
939
|
+
name=name,
|
|
940
|
+
type=_field_type_from_json_schema(child, warnings),
|
|
941
|
+
optional=name not in set(prop.get("required", [])),
|
|
942
|
+
)
|
|
943
|
+
for name, child in prop["properties"].items()
|
|
944
|
+
]
|
|
945
|
+
)
|
|
946
|
+
if prop.get("x-modelable-ref"):
|
|
947
|
+
return RefType(target=str(prop["x-modelable-ref"]))
|
|
948
|
+
if prop.get("type") == "integer":
|
|
949
|
+
return PrimitiveType(kind="int")
|
|
950
|
+
if prop.get("type") == "number":
|
|
951
|
+
return PrimitiveType(kind="float")
|
|
952
|
+
if prop.get("type") == "boolean":
|
|
953
|
+
return PrimitiveType(kind="bool")
|
|
954
|
+
if prop.get("type") == "string":
|
|
955
|
+
fmt = prop.get("format")
|
|
956
|
+
if fmt == "date-time":
|
|
957
|
+
return PrimitiveType(kind="timestamp")
|
|
958
|
+
if fmt == "date":
|
|
959
|
+
return PrimitiveType(kind="date")
|
|
960
|
+
if fmt == "time":
|
|
961
|
+
return PrimitiveType(kind="time")
|
|
962
|
+
if fmt == "uuid":
|
|
963
|
+
return PrimitiveType(kind="uuid")
|
|
964
|
+
return PrimitiveType(kind="string")
|
|
965
|
+
if prop.get("type") == "object" and prop.get("additionalProperties"):
|
|
966
|
+
return ObjectType(fields=[])
|
|
967
|
+
warnings.append(f"Falling back to named type for schema fragment: {prop}")
|
|
968
|
+
return NamedType(name=prop.get("title") or "Unknown")
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def _field_from_avro(item: dict, warnings: list[str]) -> FieldDef:
|
|
972
|
+
name = item["name"]
|
|
973
|
+
field_type = item.get("type")
|
|
974
|
+
optional = False
|
|
975
|
+
if isinstance(field_type, list) and "null" in field_type:
|
|
976
|
+
optional = True
|
|
977
|
+
field_type = next((entry for entry in field_type if entry != "null"), "string")
|
|
978
|
+
return FieldDef(name=name, type=_avro_type_to_field_type(field_type, warnings), optional=optional)
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def _avro_type_to_field_type(field_type, warnings: list[str]):
|
|
982
|
+
if isinstance(field_type, dict):
|
|
983
|
+
kind = field_type.get("type")
|
|
984
|
+
if kind == "record":
|
|
985
|
+
return ObjectType(fields=[_field_from_avro(child, warnings) for child in field_type.get("fields", [])])
|
|
986
|
+
if kind == "enum":
|
|
987
|
+
return EnumType(values=[str(value) for value in field_type.get("symbols", [])])
|
|
988
|
+
if field_type == "string":
|
|
989
|
+
return PrimitiveType(kind="string")
|
|
990
|
+
if field_type == "bytes":
|
|
991
|
+
return PrimitiveType(kind="binary")
|
|
992
|
+
if field_type == "int" or field_type == "long":
|
|
993
|
+
return PrimitiveType(kind="int")
|
|
994
|
+
if field_type == "float" or field_type == "double":
|
|
995
|
+
return PrimitiveType(kind="float")
|
|
996
|
+
if field_type == "boolean":
|
|
997
|
+
return PrimitiveType(kind="bool")
|
|
998
|
+
warnings.append(f"Falling back to named type for Avro field type: {field_type}")
|
|
999
|
+
return NamedType(name=str(field_type))
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def _sql_type_to_field_type(type_name: str):
|
|
1003
|
+
normalized = type_name.strip().lower()
|
|
1004
|
+
if normalized.startswith("varchar") or normalized in {"text", "char", "character varying"}:
|
|
1005
|
+
return PrimitiveType(kind="string")
|
|
1006
|
+
if normalized in {"int", "integer", "bigint", "smallint"}:
|
|
1007
|
+
return PrimitiveType(kind="int")
|
|
1008
|
+
if normalized in {"float", "double", "real", "numeric", "decimal"} or normalized.startswith("decimal"):
|
|
1009
|
+
match = re.search(r"\((\d+)\s*,\s*(\d+)\)", normalized)
|
|
1010
|
+
if match:
|
|
1011
|
+
return DecimalType(precision=int(match.group(1)), scale=int(match.group(2)))
|
|
1012
|
+
return PrimitiveType(kind="float")
|
|
1013
|
+
if normalized in {"bool", "boolean"}:
|
|
1014
|
+
return PrimitiveType(kind="bool")
|
|
1015
|
+
if normalized in {"timestamp", "timestamptz", "datetime"}:
|
|
1016
|
+
return PrimitiveType(kind="timestamp")
|
|
1017
|
+
if normalized == "date":
|
|
1018
|
+
return PrimitiveType(kind="date")
|
|
1019
|
+
if normalized == "time":
|
|
1020
|
+
return PrimitiveType(kind="time")
|
|
1021
|
+
if normalized == "uuid":
|
|
1022
|
+
return PrimitiveType(kind="uuid")
|
|
1023
|
+
return NamedType(name=_sanitize_ident(type_name))
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def _split_sql_columns(body: str) -> list[str]:
|
|
1027
|
+
chunks: list[str] = []
|
|
1028
|
+
current = []
|
|
1029
|
+
depth = 0
|
|
1030
|
+
for char in body:
|
|
1031
|
+
if char == "," and depth == 0:
|
|
1032
|
+
chunk = "".join(current).strip()
|
|
1033
|
+
if chunk:
|
|
1034
|
+
chunks.append(chunk)
|
|
1035
|
+
current = []
|
|
1036
|
+
continue
|
|
1037
|
+
if char == "(":
|
|
1038
|
+
depth += 1
|
|
1039
|
+
elif char == ")" and depth > 0:
|
|
1040
|
+
depth -= 1
|
|
1041
|
+
current.append(char)
|
|
1042
|
+
chunk = "".join(current).strip()
|
|
1043
|
+
if chunk:
|
|
1044
|
+
chunks.append(chunk)
|
|
1045
|
+
return chunks
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def _primitive_or_named_type(type_name: str):
|
|
1049
|
+
normalized = type_name.lower()
|
|
1050
|
+
if normalized in {"string", "bytes"}:
|
|
1051
|
+
return PrimitiveType(kind="string" if normalized == "string" else "binary")
|
|
1052
|
+
if normalized in {"int32", "int64", "uint32", "uint64", "sint32", "sint64", "fixed32", "fixed64"}:
|
|
1053
|
+
return PrimitiveType(kind="int")
|
|
1054
|
+
if normalized in {"double", "float"}:
|
|
1055
|
+
return PrimitiveType(kind="float")
|
|
1056
|
+
if normalized == "bool":
|
|
1057
|
+
return PrimitiveType(kind="bool")
|
|
1058
|
+
return NamedType(name=_sanitize_ident(type_name))
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def _guess_domain_name(text: str) -> str:
|
|
1062
|
+
return _sanitize_ident(_basename_name(text).replace("-", "_").lower())
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def _basename_name(text: str) -> str:
|
|
1066
|
+
return text.rsplit(".", 1)[-1]
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def _sanitize_ident(text: str) -> str:
|
|
1070
|
+
parts = re.split(r"[^A-Za-z0-9]+", text)
|
|
1071
|
+
cleaned = "".join(part[:1].upper() + part[1:] for part in parts if part)
|
|
1072
|
+
return cleaned or "ImportedModel"
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def _sanitize_field_ident(text: str) -> str:
|
|
1076
|
+
ident = _sanitize_ident(text)
|
|
1077
|
+
return ident[:1].lower() + ident[1:]
|