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.
- datalex_cli/__init__.py +1 -0
- datalex_cli/datalex_cli.py +658 -0
- datalex_cli/main.py +2925 -0
- datalex_cli-0.1.1.dist-info/METADATA +228 -0
- datalex_cli-0.1.1.dist-info/RECORD +64 -0
- datalex_cli-0.1.1.dist-info/WHEEL +5 -0
- datalex_cli-0.1.1.dist-info/entry_points.txt +2 -0
- datalex_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
- datalex_cli-0.1.1.dist-info/top_level.txt +2 -0
- datalex_core/__init__.py +94 -0
- datalex_core/_schemas/datalex/common.schema.json +127 -0
- datalex_core/_schemas/datalex/domain.schema.json +24 -0
- datalex_core/_schemas/datalex/entity.schema.json +158 -0
- datalex_core/_schemas/datalex/model.schema.json +141 -0
- datalex_core/_schemas/datalex/policy.schema.json +70 -0
- datalex_core/_schemas/datalex/project.schema.json +82 -0
- datalex_core/_schemas/datalex/snippet.schema.json +24 -0
- datalex_core/_schemas/datalex/source.schema.json +104 -0
- datalex_core/_schemas/datalex/term.schema.json +30 -0
- datalex_core/canonical.py +166 -0
- datalex_core/completion.py +204 -0
- datalex_core/connectors/__init__.py +39 -0
- datalex_core/connectors/base.py +417 -0
- datalex_core/connectors/bigquery.py +229 -0
- datalex_core/connectors/databricks.py +262 -0
- datalex_core/connectors/mysql.py +266 -0
- datalex_core/connectors/postgres.py +309 -0
- datalex_core/connectors/redshift.py +298 -0
- datalex_core/connectors/snowflake.py +336 -0
- datalex_core/connectors/sqlserver.py +425 -0
- datalex_core/datalex/__init__.py +26 -0
- datalex_core/datalex/diff.py +188 -0
- datalex_core/datalex/errors.py +85 -0
- datalex_core/datalex/loader.py +512 -0
- datalex_core/datalex/migrate_layout.py +382 -0
- datalex_core/datalex/parse_cache.py +102 -0
- datalex_core/datalex/project.py +214 -0
- datalex_core/datalex/types.py +224 -0
- datalex_core/dbt/__init__.py +18 -0
- datalex_core/dbt/emit.py +344 -0
- datalex_core/dbt/manifest.py +329 -0
- datalex_core/dbt/profiles.py +185 -0
- datalex_core/dbt/sync.py +279 -0
- datalex_core/dbt/warehouse.py +215 -0
- datalex_core/dialects/__init__.py +15 -0
- datalex_core/dialects/_common.py +48 -0
- datalex_core/dialects/base.py +47 -0
- datalex_core/dialects/postgres.py +164 -0
- datalex_core/dialects/registry.py +36 -0
- datalex_core/dialects/snowflake.py +129 -0
- datalex_core/diffing.py +358 -0
- datalex_core/docs_generator.py +797 -0
- datalex_core/doctor.py +181 -0
- datalex_core/generators.py +478 -0
- datalex_core/importers.py +1176 -0
- datalex_core/issues.py +23 -0
- datalex_core/loader.py +21 -0
- datalex_core/migrate.py +316 -0
- datalex_core/modeling.py +679 -0
- datalex_core/packages.py +430 -0
- datalex_core/policy.py +1037 -0
- datalex_core/resolver.py +456 -0
- datalex_core/schema.py +54 -0
- 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
|
datalex_core/migrate.py
ADDED
|
@@ -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
|