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