datalex-cli 0.1.1__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.
Files changed (64) hide show
  1. datalex_cli/__init__.py +1 -0
  2. datalex_cli/datalex_cli.py +658 -0
  3. datalex_cli/main.py +2925 -0
  4. datalex_cli-0.1.1.dist-info/METADATA +228 -0
  5. datalex_cli-0.1.1.dist-info/RECORD +64 -0
  6. datalex_cli-0.1.1.dist-info/WHEEL +5 -0
  7. datalex_cli-0.1.1.dist-info/entry_points.txt +2 -0
  8. datalex_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
  9. datalex_cli-0.1.1.dist-info/top_level.txt +2 -0
  10. datalex_core/__init__.py +94 -0
  11. datalex_core/_schemas/datalex/common.schema.json +127 -0
  12. datalex_core/_schemas/datalex/domain.schema.json +24 -0
  13. datalex_core/_schemas/datalex/entity.schema.json +158 -0
  14. datalex_core/_schemas/datalex/model.schema.json +141 -0
  15. datalex_core/_schemas/datalex/policy.schema.json +70 -0
  16. datalex_core/_schemas/datalex/project.schema.json +82 -0
  17. datalex_core/_schemas/datalex/snippet.schema.json +24 -0
  18. datalex_core/_schemas/datalex/source.schema.json +104 -0
  19. datalex_core/_schemas/datalex/term.schema.json +30 -0
  20. datalex_core/canonical.py +166 -0
  21. datalex_core/completion.py +204 -0
  22. datalex_core/connectors/__init__.py +39 -0
  23. datalex_core/connectors/base.py +417 -0
  24. datalex_core/connectors/bigquery.py +229 -0
  25. datalex_core/connectors/databricks.py +262 -0
  26. datalex_core/connectors/mysql.py +266 -0
  27. datalex_core/connectors/postgres.py +309 -0
  28. datalex_core/connectors/redshift.py +298 -0
  29. datalex_core/connectors/snowflake.py +336 -0
  30. datalex_core/connectors/sqlserver.py +425 -0
  31. datalex_core/datalex/__init__.py +26 -0
  32. datalex_core/datalex/diff.py +188 -0
  33. datalex_core/datalex/errors.py +85 -0
  34. datalex_core/datalex/loader.py +512 -0
  35. datalex_core/datalex/migrate_layout.py +382 -0
  36. datalex_core/datalex/parse_cache.py +102 -0
  37. datalex_core/datalex/project.py +214 -0
  38. datalex_core/datalex/types.py +224 -0
  39. datalex_core/dbt/__init__.py +18 -0
  40. datalex_core/dbt/emit.py +344 -0
  41. datalex_core/dbt/manifest.py +329 -0
  42. datalex_core/dbt/profiles.py +185 -0
  43. datalex_core/dbt/sync.py +279 -0
  44. datalex_core/dbt/warehouse.py +215 -0
  45. datalex_core/dialects/__init__.py +15 -0
  46. datalex_core/dialects/_common.py +48 -0
  47. datalex_core/dialects/base.py +47 -0
  48. datalex_core/dialects/postgres.py +164 -0
  49. datalex_core/dialects/registry.py +36 -0
  50. datalex_core/dialects/snowflake.py +129 -0
  51. datalex_core/diffing.py +358 -0
  52. datalex_core/docs_generator.py +797 -0
  53. datalex_core/doctor.py +181 -0
  54. datalex_core/generators.py +478 -0
  55. datalex_core/importers.py +1176 -0
  56. datalex_core/issues.py +23 -0
  57. datalex_core/loader.py +21 -0
  58. datalex_core/migrate.py +316 -0
  59. datalex_core/modeling.py +679 -0
  60. datalex_core/packages.py +430 -0
  61. datalex_core/policy.py +1037 -0
  62. datalex_core/resolver.py +456 -0
  63. datalex_core/schema.py +54 -0
  64. datalex_core/semantic.py +1561 -0
datalex_core/issues.py ADDED
@@ -0,0 +1,23 @@
1
+ from dataclasses import dataclass
2
+ from typing import Iterable, List
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class Issue:
7
+ severity: str
8
+ code: str
9
+ message: str
10
+ path: str = "/"
11
+
12
+
13
+ def has_errors(issues: Iterable[Issue]) -> bool:
14
+ return any(issue.severity == "error" for issue in issues)
15
+
16
+
17
+ def to_lines(issues: List[Issue]) -> List[str]:
18
+ lines = []
19
+ for issue in issues:
20
+ lines.append(
21
+ f"[{issue.severity.upper()}] {issue.code} {issue.path}: {issue.message}"
22
+ )
23
+ return lines
datalex_core/loader.py ADDED
@@ -0,0 +1,21 @@
1
+ from pathlib import Path
2
+ from typing import Any, Dict
3
+
4
+ import yaml
5
+
6
+
7
+ def load_yaml_model(path: str) -> Dict[str, Any]:
8
+ model_path = Path(path)
9
+ if not model_path.exists():
10
+ raise FileNotFoundError(f"Model file not found: {path}")
11
+
12
+ with model_path.open("r", encoding="utf-8") as handle:
13
+ data = yaml.safe_load(handle)
14
+
15
+ if data is None:
16
+ return {}
17
+
18
+ if not isinstance(data, dict):
19
+ raise ValueError("Model YAML must parse to an object/map at root.")
20
+
21
+ return data
@@ -0,0 +1,316 @@
1
+ """SQL migration script generator.
2
+
3
+ Compares two model versions and produces ALTER TABLE / CREATE / DROP
4
+ statements that migrate the database schema from old to new.
5
+
6
+ Supports Postgres, Snowflake, BigQuery, and Databricks dialects.
7
+ """
8
+
9
+ from typing import Any, Dict, List, Optional, Tuple
10
+
11
+ from datalex_core.canonical import compile_model
12
+ from datalex_core.generators import _qualified_name, _sql_type, _to_snake, _format_default, SUPPORTED_DIALECTS
13
+
14
+
15
+ def _index_entities(model: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
16
+ return {str(e.get("name", "")): e for e in model.get("entities", [])}
17
+
18
+
19
+ def _index_fields(entity: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
20
+ return {str(f.get("name", "")): f for f in entity.get("fields", [])}
21
+
22
+
23
+ def _index_indexes(model: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
24
+ return {str(idx.get("name", "")): idx for idx in model.get("indexes", [])}
25
+
26
+
27
+ def _add_column_sql(
28
+ entity: Dict[str, Any],
29
+ field: Dict[str, Any],
30
+ dialect: str,
31
+ ) -> str:
32
+ qualified = _qualified_name(entity, dialect)
33
+ fname = str(field.get("name", ""))
34
+ col_type = _sql_type(str(field.get("type", "string")), dialect)
35
+ nullable = bool(field.get("nullable", True))
36
+
37
+ parts = [f'ALTER TABLE {qualified} ADD COLUMN "{fname}" {col_type}']
38
+
39
+ default_val = field.get("default")
40
+ if "default" in field:
41
+ formatted = _format_default(default_val, dialect)
42
+ if formatted is not None:
43
+ parts.append(f"DEFAULT {formatted}")
44
+
45
+ if not nullable:
46
+ parts.append("NOT NULL")
47
+
48
+ return " ".join(parts) + ";"
49
+
50
+
51
+ def _drop_column_sql(
52
+ entity: Dict[str, Any],
53
+ field_name: str,
54
+ dialect: str,
55
+ ) -> str:
56
+ qualified = _qualified_name(entity, dialect)
57
+ return f'ALTER TABLE {qualified} DROP COLUMN "{field_name}";'
58
+
59
+
60
+ def _alter_column_type_sql(
61
+ entity: Dict[str, Any],
62
+ field_name: str,
63
+ new_type: str,
64
+ dialect: str,
65
+ ) -> str:
66
+ qualified = _qualified_name(entity, dialect)
67
+ sql_type = _sql_type(new_type, dialect)
68
+ if dialect == "bigquery":
69
+ return f'ALTER TABLE {qualified} ALTER COLUMN "{field_name}" SET DATA TYPE {sql_type};'
70
+ return f'ALTER TABLE {qualified} ALTER COLUMN "{field_name}" TYPE {sql_type};'
71
+
72
+
73
+ def _alter_column_nullable_sql(
74
+ entity: Dict[str, Any],
75
+ field_name: str,
76
+ new_nullable: bool,
77
+ dialect: str,
78
+ ) -> str:
79
+ qualified = _qualified_name(entity, dialect)
80
+ if new_nullable:
81
+ return f'ALTER TABLE {qualified} ALTER COLUMN "{field_name}" DROP NOT NULL;'
82
+ return f'ALTER TABLE {qualified} ALTER COLUMN "{field_name}" SET NOT NULL;'
83
+
84
+
85
+ def _alter_column_default_sql(
86
+ entity: Dict[str, Any],
87
+ field_name: str,
88
+ new_default: Any,
89
+ has_default: bool,
90
+ dialect: str,
91
+ ) -> str:
92
+ qualified = _qualified_name(entity, dialect)
93
+ if not has_default:
94
+ return f'ALTER TABLE {qualified} ALTER COLUMN "{field_name}" DROP DEFAULT;'
95
+ formatted = _format_default(new_default, dialect)
96
+ return f'ALTER TABLE {qualified} ALTER COLUMN "{field_name}" SET DEFAULT {formatted};'
97
+
98
+
99
+ def _create_table_sql(entity: Dict[str, Any], dialect: str) -> str:
100
+ qualified = _qualified_name(entity, dialect)
101
+ fields = entity.get("fields", [])
102
+ column_lines: List[str] = []
103
+ pk_fields: List[str] = []
104
+
105
+ for field in fields:
106
+ if field.get("computed") is True:
107
+ continue
108
+ fname = str(field.get("name", ""))
109
+ col_type = _sql_type(str(field.get("type", "string")), dialect)
110
+ nullable = bool(field.get("nullable", True))
111
+ unique = bool(field.get("unique", False))
112
+ primary_key = bool(field.get("primary_key", False))
113
+
114
+ parts = [f' "{fname}"', col_type]
115
+
116
+ default_val = field.get("default")
117
+ if "default" in field:
118
+ formatted = _format_default(default_val, dialect)
119
+ if formatted is not None:
120
+ parts.append(f"DEFAULT {formatted}")
121
+
122
+ if not nullable:
123
+ parts.append("NOT NULL")
124
+ if unique:
125
+ parts.append("UNIQUE")
126
+ if primary_key:
127
+ pk_fields.append(fname)
128
+
129
+ column_lines.append(" ".join(parts))
130
+
131
+ if pk_fields:
132
+ pk_cols = ", ".join([f'"{c}"' for c in pk_fields])
133
+ column_lines.append(f" PRIMARY KEY ({pk_cols})")
134
+
135
+ return f"CREATE TABLE {qualified} (\n" + ",\n".join(column_lines) + "\n);"
136
+
137
+
138
+ def _drop_table_sql(entity: Dict[str, Any], dialect: str) -> str:
139
+ qualified = _qualified_name(entity, dialect)
140
+ return f"DROP TABLE IF EXISTS {qualified};"
141
+
142
+
143
+ def _create_index_sql(idx: Dict[str, Any], entity_map: Dict[str, Dict[str, Any]], dialect: str) -> str:
144
+ idx_name = str(idx.get("name", ""))
145
+ idx_entity = str(idx.get("entity", ""))
146
+ idx_fields = idx.get("fields", [])
147
+ idx_unique = bool(idx.get("unique", False))
148
+ entity_obj = entity_map.get(idx_entity, {"name": idx_entity})
149
+ qualified = _qualified_name(entity_obj, dialect)
150
+ cols = ", ".join([f'"{f}"' for f in idx_fields])
151
+ unique_kw = "UNIQUE " if idx_unique else ""
152
+ return f'CREATE {unique_kw}INDEX "{idx_name}" ON {qualified} ({cols});'
153
+
154
+
155
+ def _drop_index_sql(idx_name: str, dialect: str) -> str:
156
+ if dialect == "snowflake":
157
+ return f'DROP INDEX IF EXISTS "{idx_name}";'
158
+ return f'DROP INDEX IF EXISTS "{idx_name}";'
159
+
160
+
161
+ def generate_migration(
162
+ old_model: Dict[str, Any],
163
+ new_model: Dict[str, Any],
164
+ dialect: str = "postgres",
165
+ ) -> str:
166
+ """Generate SQL migration script from old_model to new_model.
167
+
168
+ Returns a string of SQL statements that, when executed, transform the
169
+ database schema from the old model state to the new model state.
170
+ """
171
+ dialect = dialect.lower()
172
+ if dialect not in SUPPORTED_DIALECTS:
173
+ raise ValueError(f"Unsupported dialect: {dialect}")
174
+
175
+ old_canonical = compile_model(old_model)
176
+ new_canonical = compile_model(new_model)
177
+
178
+ old_entities = _index_entities(old_canonical)
179
+ new_entities = _index_entities(new_canonical)
180
+
181
+ old_indexes = _index_indexes(old_canonical)
182
+ new_indexes = _index_indexes(new_canonical)
183
+
184
+ statements: List[str] = []
185
+ comments: List[str] = []
186
+
187
+ old_version = old_model.get("model", {}).get("version", "?")
188
+ new_version = new_model.get("model", {}).get("version", "?")
189
+ model_name = new_model.get("model", {}).get("name", "unknown")
190
+
191
+ statements.append(f"-- Migration: {model_name} v{old_version} -> v{new_version}")
192
+ statements.append(f"-- Dialect: {dialect}")
193
+ statements.append(f"-- Generated by DataLex datalex migrate")
194
+ statements.append("")
195
+
196
+ # --- Dropped entities ---
197
+ removed_entity_names = sorted(set(old_entities.keys()) - set(new_entities.keys()))
198
+ drop_stmts: List[str] = []
199
+ for name in removed_entity_names:
200
+ entity = old_entities[name]
201
+ entity_type = str(entity.get("type", "table"))
202
+ if entity_type in ("view", "materialized_view", "external_table", "snapshot"):
203
+ continue
204
+ drop_stmts.append(_drop_table_sql(entity, dialect))
205
+ if drop_stmts:
206
+ statements.append("-- ========== DROP TABLES ==========")
207
+ statements.extend(drop_stmts)
208
+
209
+ # --- New entities ---
210
+ added_entity_names = sorted(set(new_entities.keys()) - set(old_entities.keys()))
211
+ create_stmts: List[str] = []
212
+ for name in added_entity_names:
213
+ entity = new_entities[name]
214
+ entity_type = str(entity.get("type", "table"))
215
+ if entity_type in ("view", "materialized_view", "external_table", "snapshot"):
216
+ continue
217
+ create_stmts.append(_create_table_sql(entity, dialect))
218
+ if create_stmts:
219
+ statements.append("")
220
+ statements.append("-- ========== CREATE TABLES ==========")
221
+ statements.extend(create_stmts)
222
+
223
+ # --- Altered entities ---
224
+ common_entity_names = sorted(set(old_entities.keys()) & set(new_entities.keys()))
225
+ alter_statements: List[str] = []
226
+
227
+ for name in common_entity_names:
228
+ old_entity = old_entities[name]
229
+ new_entity = new_entities[name]
230
+ entity_type = str(new_entity.get("type", "table"))
231
+ if entity_type in ("view", "materialized_view", "external_table", "snapshot"):
232
+ continue
233
+
234
+ old_fields = _index_fields(old_entity)
235
+ new_fields = _index_fields(new_entity)
236
+
237
+ entity_alters: List[str] = []
238
+
239
+ # Added fields
240
+ for fname in sorted(set(new_fields.keys()) - set(old_fields.keys())):
241
+ field = new_fields[fname]
242
+ if field.get("computed") is True:
243
+ continue
244
+ entity_alters.append(_add_column_sql(new_entity, field, dialect))
245
+
246
+ # Removed fields
247
+ for fname in sorted(set(old_fields.keys()) - set(new_fields.keys())):
248
+ entity_alters.append(_drop_column_sql(new_entity, fname, dialect))
249
+
250
+ # Changed fields
251
+ for fname in sorted(set(old_fields.keys()) & set(new_fields.keys())):
252
+ old_f = old_fields[fname]
253
+ new_f = new_fields[fname]
254
+
255
+ if old_f.get("computed") is True or new_f.get("computed") is True:
256
+ continue
257
+
258
+ old_type = str(old_f.get("type", "string"))
259
+ new_type = str(new_f.get("type", "string"))
260
+ if old_type != new_type:
261
+ entity_alters.append(_alter_column_type_sql(new_entity, fname, new_type, dialect))
262
+
263
+ old_nullable = bool(old_f.get("nullable", True))
264
+ new_nullable = bool(new_f.get("nullable", True))
265
+ if old_nullable != new_nullable:
266
+ entity_alters.append(_alter_column_nullable_sql(new_entity, fname, new_nullable, dialect))
267
+
268
+ old_has_default = "default" in old_f
269
+ new_has_default = "default" in new_f
270
+ old_default = old_f.get("default")
271
+ new_default = new_f.get("default")
272
+ if old_has_default != new_has_default or old_default != new_default:
273
+ entity_alters.append(_alter_column_default_sql(new_entity, fname, new_default, new_has_default, dialect))
274
+
275
+ if entity_alters:
276
+ alter_statements.append(f"-- Alter: {name}")
277
+ alter_statements.extend(entity_alters)
278
+
279
+ if alter_statements:
280
+ statements.append("")
281
+ statements.append("-- ========== ALTER TABLES ==========")
282
+ statements.extend(alter_statements)
283
+
284
+ # --- Indexes ---
285
+ if dialect == "bigquery":
286
+ pass # BigQuery doesn't support CREATE INDEX
287
+ else:
288
+ removed_idx_names = sorted(set(old_indexes.keys()) - set(new_indexes.keys()))
289
+ added_idx_names = sorted(set(new_indexes.keys()) - set(old_indexes.keys()))
290
+
291
+ idx_statements: List[str] = []
292
+ for idx_name in removed_idx_names:
293
+ idx_statements.append(_drop_index_sql(idx_name, dialect))
294
+ for idx_name in added_idx_names:
295
+ idx_statements.append(_create_index_sql(new_indexes[idx_name], new_entities, dialect))
296
+
297
+ if idx_statements:
298
+ statements.append("")
299
+ statements.append("-- ========== INDEXES ==========")
300
+ statements.extend(idx_statements)
301
+
302
+ return "\n".join(statements) + "\n"
303
+
304
+
305
+ def write_migration(
306
+ old_model: Dict[str, Any],
307
+ new_model: Dict[str, Any],
308
+ out_path: str,
309
+ dialect: str = "postgres",
310
+ ) -> str:
311
+ """Generate and write migration SQL to a file. Returns the path."""
312
+ from pathlib import Path
313
+ sql = generate_migration(old_model, new_model, dialect=dialect)
314
+ Path(out_path).parent.mkdir(parents=True, exist_ok=True)
315
+ Path(out_path).write_text(sql, encoding="utf-8")
316
+ return out_path