ch-migrate-cli 0.5.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.
- ch_migrate/__init__.py +68 -0
- ch_migrate/authoring.py +163 -0
- ch_migrate/bootstrap.py +379 -0
- ch_migrate/cli.py +1049 -0
- ch_migrate/config.py +116 -0
- ch_migrate/connection.py +83 -0
- ch_migrate/deps.py +123 -0
- ch_migrate/diff.py +232 -0
- ch_migrate/display.py +450 -0
- ch_migrate/downgrade.py +103 -0
- ch_migrate/env.py +226 -0
- ch_migrate/helpers.py +162 -0
- ch_migrate/hooks.py +74 -0
- ch_migrate/introspect.py +732 -0
- ch_migrate/lint.py +601 -0
- ch_migrate/mv_validate.py +554 -0
- ch_migrate/py.typed +0 -0
- ch_migrate/rebase.py +308 -0
- ch_migrate/runner.py +188 -0
- ch_migrate/scaffold.py +253 -0
- ch_migrate/secrets.py +162 -0
- ch_migrate/skills/ch-migrate/SKILL.md +250 -0
- ch_migrate/sql.py +216 -0
- ch_migrate/statements.py +144 -0
- ch_migrate/templates/bootstrap/init_users.sql +56 -0
- ch_migrate/templates/project/alembic.ini.template +43 -0
- ch_migrate/templates/project/config.yaml.template +55 -0
- ch_migrate/templates/project/env.local.example.template +25 -0
- ch_migrate/templates/project/script.py.mako.template +30 -0
- ch_migrate/ui.py +79 -0
- ch_migrate_cli-0.5.0.dist-info/METADATA +448 -0
- ch_migrate_cli-0.5.0.dist-info/RECORD +36 -0
- ch_migrate_cli-0.5.0.dist-info/WHEEL +4 -0
- ch_migrate_cli-0.5.0.dist-info/entry_points.txt +2 -0
- ch_migrate_cli-0.5.0.dist-info/licenses/LICENSE +21 -0
- clickhouse_alembic/__init__.py +59 -0
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
"""Materialized view declaration validation for ch-migrate.
|
|
2
|
+
|
|
3
|
+
When a migration creates a MATERIALIZED VIEW, ClickHouse 24.2+ requires the
|
|
4
|
+
inserting user to have INSERT on the target table. Without it, MVs silently
|
|
5
|
+
fail — no errors, data simply doesn't land. This module enforces that migrations
|
|
6
|
+
declare their MV dependencies and include the required companion grants.
|
|
7
|
+
|
|
8
|
+
Usage in migrations:
|
|
9
|
+
|
|
10
|
+
MV_DECLARATIONS = [{
|
|
11
|
+
"mv_name": "my_new_mv",
|
|
12
|
+
"source_table": "logs",
|
|
13
|
+
"target_table": "my_new_aggregate",
|
|
14
|
+
"inserting_users": ["intake_writer"],
|
|
15
|
+
# Optional fields for additional validation:
|
|
16
|
+
"select_users": ["readonly_user"],
|
|
17
|
+
"source_rls_users": ["intake_writer"],
|
|
18
|
+
"target_rls_policies": ["default_deny", "full_access"],
|
|
19
|
+
}]
|
|
20
|
+
|
|
21
|
+
Escape hatch:
|
|
22
|
+
|
|
23
|
+
# skip_mv_validation: MV is created by a different mechanism (e.g. replicated from another cluster)
|
|
24
|
+
skip_mv_validation = True
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import ast
|
|
30
|
+
import re
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Data models
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class MVDeclaration:
|
|
43
|
+
"""A single materialized view declaration from a migration."""
|
|
44
|
+
|
|
45
|
+
mv_name: str
|
|
46
|
+
source_table: str
|
|
47
|
+
target_table: str
|
|
48
|
+
inserting_users: list[str]
|
|
49
|
+
# Optional validation fields
|
|
50
|
+
select_users: list[str] = field(default_factory=list)
|
|
51
|
+
source_rls_users: list[str] = field(default_factory=list)
|
|
52
|
+
target_rls_policies: list[str] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_dict(cls, d: dict[str, Any]) -> MVDeclaration:
|
|
56
|
+
return cls(
|
|
57
|
+
mv_name=d.get("mv_name", ""),
|
|
58
|
+
source_table=d.get("source_table", ""),
|
|
59
|
+
target_table=d.get("target_table", ""),
|
|
60
|
+
inserting_users=d.get("inserting_users", []),
|
|
61
|
+
select_users=d.get("select_users", []),
|
|
62
|
+
source_rls_users=d.get("source_rls_users", []),
|
|
63
|
+
target_rls_policies=d.get("target_rls_policies", []),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class MVValidationError:
|
|
69
|
+
"""A validation error found during MV declaration checking."""
|
|
70
|
+
|
|
71
|
+
file: str
|
|
72
|
+
message: str
|
|
73
|
+
mv_name: str | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# AST helpers
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _load_module_attribute(path: Path, attr_name: str) -> Any:
|
|
82
|
+
"""Safely extract a module-level attribute from a Python file using AST.
|
|
83
|
+
|
|
84
|
+
Returns None if the attribute is not found or cannot be parsed.
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
content = path.read_text()
|
|
88
|
+
tree = ast.parse(content)
|
|
89
|
+
except (SyntaxError, ValueError):
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
for node in ast.iter_child_nodes(tree):
|
|
93
|
+
if isinstance(node, ast.Assign):
|
|
94
|
+
for target in node.targets:
|
|
95
|
+
if isinstance(target, ast.Name) and target.id == attr_name:
|
|
96
|
+
try:
|
|
97
|
+
return ast.literal_eval(node.value)
|
|
98
|
+
except (ValueError, TypeError):
|
|
99
|
+
return None
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _extract_create_date(path: Path) -> str | None:
|
|
104
|
+
"""Extract Create Date from a migration file's docstring header."""
|
|
105
|
+
content = path.read_text()
|
|
106
|
+
match = re.search(r"^Create Date:\s*(.+)$", content, re.MULTILINE)
|
|
107
|
+
return match.group(1).strip() if match else None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
# SQL pattern matching
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
# Detects CREATE MATERIALIZED VIEW (with or without IF NOT EXISTS)
|
|
115
|
+
_RE_CREATE_MV = re.compile(
|
|
116
|
+
r"\bCREATE\s+MATERIALIZED\s+VIEW\b",
|
|
117
|
+
re.IGNORECASE,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Matches GRANT INSERT ON [db.]table TO user1[, user2, ...]
|
|
121
|
+
# Handles: {db}.table, db.table, table (no db prefix)
|
|
122
|
+
# Handles backtick-quoted identifiers and comma-separated user lists
|
|
123
|
+
_RE_GRANT_INSERT = re.compile(
|
|
124
|
+
r"GRANT\s+INSERT\s+ON\s+"
|
|
125
|
+
r"(?:(?:\{[^}]*\}|`?\w+`?)\.)?`?(\w+)`?"
|
|
126
|
+
r"\s+TO\s+(.+?)(?:;|\n|\Z)",
|
|
127
|
+
re.IGNORECASE,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Matches GRANT SELECT ON [db.]table TO user1[, user2, ...]
|
|
131
|
+
_RE_GRANT_SELECT = re.compile(
|
|
132
|
+
r"GRANT\s+SELECT(?:\([^)]*\))?\s+ON\s+"
|
|
133
|
+
r"(?:(?:\{[^}]*\}|`?\w+`?)\.)?`?(\w+)`?"
|
|
134
|
+
r"\s+TO\s+(.+?)(?:;|\n|\Z)",
|
|
135
|
+
re.IGNORECASE,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Matches CREATE ROW POLICY ... ON [db.]table
|
|
139
|
+
# Captures: (policy_name_fragment, table_name)
|
|
140
|
+
_RE_CREATE_ROW_POLICY = re.compile(
|
|
141
|
+
r"CREATE\s+ROW\s+POLICY\s+(?:IF\s+NOT\s+EXISTS\s+|OR\s+REPLACE\s+)?"
|
|
142
|
+
r"(\S+)\s+ON\s+(?:(?:\{[^}]*\}|`?\w+`?)\.)?`?(\w+)`?",
|
|
143
|
+
re.IGNORECASE,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Matches ROW POLICY with USING 1 (permissive) — for source RLS checks
|
|
147
|
+
_RE_ROW_POLICY_PERMISSIVE = re.compile(
|
|
148
|
+
r"CREATE\s+ROW\s+POLICY\s+(?:IF\s+NOT\s+EXISTS\s+|OR\s+REPLACE\s+)?"
|
|
149
|
+
r"\S+\s+ON\s+(?:(?:\{[^}]*\}|`?\w+`?)\.)?`?(\w+)`?"
|
|
150
|
+
r"[^;]*?USING\s+1\s+TO\s+(.+?)(?:;|\Z)",
|
|
151
|
+
re.IGNORECASE | re.DOTALL,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _sql_has_create_mv(sql: str) -> bool:
|
|
156
|
+
"""Check if SQL contains a CREATE MATERIALIZED VIEW statement."""
|
|
157
|
+
return bool(_RE_CREATE_MV.search(sql))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _parse_user_list(users_str: str) -> list[str]:
|
|
161
|
+
"""Parse a comma-separated user list from a GRANT statement.
|
|
162
|
+
|
|
163
|
+
Handles backtick-quoted identifiers and strips whitespace.
|
|
164
|
+
"""
|
|
165
|
+
return [
|
|
166
|
+
u.strip().strip("`").lower()
|
|
167
|
+
for u in users_str.split(",")
|
|
168
|
+
if u.strip().strip("`")
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _find_grant_inserts(content: str) -> list[tuple[str, str]]:
|
|
173
|
+
"""Find all GRANT INSERT ON table TO user patterns.
|
|
174
|
+
|
|
175
|
+
Returns list of (table_name, user_name) tuples, lowercased.
|
|
176
|
+
Handles comma-separated user lists (e.g., TO user_a, user_b).
|
|
177
|
+
"""
|
|
178
|
+
results: list[tuple[str, str]] = []
|
|
179
|
+
for m in _RE_GRANT_INSERT.finditer(content):
|
|
180
|
+
table = m.group(1).lower()
|
|
181
|
+
for user in _parse_user_list(m.group(2)):
|
|
182
|
+
results.append((table, user))
|
|
183
|
+
return results
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _find_grant_selects(content: str) -> list[tuple[str, str]]:
|
|
187
|
+
"""Find all GRANT SELECT ON table TO user patterns.
|
|
188
|
+
|
|
189
|
+
Returns list of (table_name, user_name) tuples, lowercased.
|
|
190
|
+
Handles comma-separated user lists (e.g., TO user_a, user_b).
|
|
191
|
+
"""
|
|
192
|
+
results: list[tuple[str, str]] = []
|
|
193
|
+
for m in _RE_GRANT_SELECT.finditer(content):
|
|
194
|
+
table = m.group(1).lower()
|
|
195
|
+
for user in _parse_user_list(m.group(2)):
|
|
196
|
+
results.append((table, user))
|
|
197
|
+
return results
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _find_row_policies(content: str) -> list[tuple[str, str]]:
|
|
201
|
+
"""Find all CREATE ROW POLICY ... ON table patterns.
|
|
202
|
+
|
|
203
|
+
Returns list of (policy_name, table_name) tuples, lowercased.
|
|
204
|
+
"""
|
|
205
|
+
return [
|
|
206
|
+
(m.group(1).lower(), m.group(2).lower())
|
|
207
|
+
for m in _RE_CREATE_ROW_POLICY.finditer(content)
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _find_permissive_row_policies(content: str) -> list[tuple[str, str]]:
|
|
212
|
+
"""Find ROW POLICY ... ON table USING 1 TO user patterns.
|
|
213
|
+
|
|
214
|
+
Returns list of (table_name, user_or_users_string) tuples.
|
|
215
|
+
"""
|
|
216
|
+
return [
|
|
217
|
+
(m.group(1).lower(), m.group(2).lower().strip())
|
|
218
|
+
for m in _RE_ROW_POLICY_PERMISSIVE.finditer(content)
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
# Migration SQL extraction
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _read_migration_sql_from_content(content: str, path: Path) -> str:
|
|
228
|
+
"""Extract SQL from migration file content.
|
|
229
|
+
|
|
230
|
+
Extracts SQL string literals from op.execute() calls and read_sql()/run_sql()
|
|
231
|
+
file references. Falls back to the full file content if no SQL is found.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
content: The migration file's text content.
|
|
235
|
+
path: The migration file path (used to resolve read_sql references).
|
|
236
|
+
"""
|
|
237
|
+
sql_parts: list[str] = []
|
|
238
|
+
|
|
239
|
+
for match in re.finditer(
|
|
240
|
+
r'op\.execute\(\s*(?:f?"""(.*?)"""|f?"([^"]*)")', content, re.DOTALL
|
|
241
|
+
):
|
|
242
|
+
sql_parts.append(match.group(1) or match.group(2) or "")
|
|
243
|
+
|
|
244
|
+
for match in re.finditer(r'(?:read_sql|run_sql)\(\s*["\']([^"\']+)["\']', content):
|
|
245
|
+
sql_path = path.parent.parent / "sql" / match.group(1)
|
|
246
|
+
if sql_path.exists():
|
|
247
|
+
sql_parts.append(sql_path.read_text())
|
|
248
|
+
|
|
249
|
+
return "\n".join(sql_parts) if sql_parts else content
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _read_migration_sql(path: Path) -> str:
|
|
253
|
+
"""Extract SQL from op.execute() calls and read_sql()/run_sql() files.
|
|
254
|
+
|
|
255
|
+
Convenience wrapper that reads the file and delegates to
|
|
256
|
+
_read_migration_sql_from_content.
|
|
257
|
+
"""
|
|
258
|
+
return _read_migration_sql_from_content(path.read_text(), path)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
# Validation logic
|
|
263
|
+
# ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _validate_required_fields(
|
|
267
|
+
decl: dict[str, Any], file_name: str
|
|
268
|
+
) -> tuple[list[MVValidationError], MVDeclaration | None]:
|
|
269
|
+
"""Validate required fields in an MV declaration dict.
|
|
270
|
+
|
|
271
|
+
Returns (errors, parsed_declaration). If errors, declaration may be None.
|
|
272
|
+
"""
|
|
273
|
+
errors: list[MVValidationError] = []
|
|
274
|
+
|
|
275
|
+
mv_name = decl.get("mv_name")
|
|
276
|
+
if not mv_name:
|
|
277
|
+
errors.append(
|
|
278
|
+
MVValidationError(
|
|
279
|
+
file=file_name,
|
|
280
|
+
message="MV_DECLARATIONS entry missing required field 'mv_name'",
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
return errors, None
|
|
284
|
+
|
|
285
|
+
for required in ("source_table", "target_table", "inserting_users"):
|
|
286
|
+
if required not in decl or not decl[required]:
|
|
287
|
+
errors.append(
|
|
288
|
+
MVValidationError(
|
|
289
|
+
file=file_name,
|
|
290
|
+
mv_name=mv_name,
|
|
291
|
+
message=f"MV_DECLARATIONS['{mv_name}'] missing required field '{required}'",
|
|
292
|
+
)
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
if errors:
|
|
296
|
+
return errors, None
|
|
297
|
+
|
|
298
|
+
return errors, MVDeclaration.from_dict(decl)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _check_grant_inserts(
|
|
302
|
+
decl: MVDeclaration,
|
|
303
|
+
grant_inserts: list[tuple[str, str]],
|
|
304
|
+
file_name: str,
|
|
305
|
+
) -> list[MVValidationError]:
|
|
306
|
+
"""Check GRANT INSERT ON target_table TO user for each inserting_user."""
|
|
307
|
+
errors: list[MVValidationError] = []
|
|
308
|
+
target = decl.target_table.lower()
|
|
309
|
+
|
|
310
|
+
for user in decl.inserting_users:
|
|
311
|
+
has_grant = any(
|
|
312
|
+
table == target and u == user.lower() for table, u in grant_inserts
|
|
313
|
+
)
|
|
314
|
+
if not has_grant:
|
|
315
|
+
errors.append(
|
|
316
|
+
MVValidationError(
|
|
317
|
+
file=file_name,
|
|
318
|
+
mv_name=decl.mv_name,
|
|
319
|
+
message=(
|
|
320
|
+
f"Missing: GRANT INSERT ON {{db}}.{decl.target_table} "
|
|
321
|
+
f"TO {user}"
|
|
322
|
+
),
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
return errors
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _check_grant_selects(
|
|
330
|
+
decl: MVDeclaration,
|
|
331
|
+
grant_selects: list[tuple[str, str]],
|
|
332
|
+
file_name: str,
|
|
333
|
+
) -> list[MVValidationError]:
|
|
334
|
+
"""Check GRANT SELECT ON target_table TO user for each select_user."""
|
|
335
|
+
errors: list[MVValidationError] = []
|
|
336
|
+
target = decl.target_table.lower()
|
|
337
|
+
|
|
338
|
+
for user in decl.select_users:
|
|
339
|
+
has_grant = any(
|
|
340
|
+
table == target and u == user.lower() for table, u in grant_selects
|
|
341
|
+
)
|
|
342
|
+
if not has_grant:
|
|
343
|
+
errors.append(
|
|
344
|
+
MVValidationError(
|
|
345
|
+
file=file_name,
|
|
346
|
+
mv_name=decl.mv_name,
|
|
347
|
+
message=(
|
|
348
|
+
f"Missing: GRANT SELECT ON {{db}}.{decl.target_table} "
|
|
349
|
+
f"TO {user}"
|
|
350
|
+
),
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
return errors
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _check_source_rls(
|
|
358
|
+
decl: MVDeclaration,
|
|
359
|
+
permissive_policies: list[tuple[str, str]],
|
|
360
|
+
file_name: str,
|
|
361
|
+
) -> list[MVValidationError]:
|
|
362
|
+
"""Check CREATE ROW POLICY ON source_table USING 1 TO user."""
|
|
363
|
+
errors: list[MVValidationError] = []
|
|
364
|
+
source = decl.source_table.lower()
|
|
365
|
+
|
|
366
|
+
for user in decl.source_rls_users:
|
|
367
|
+
has_policy = any(
|
|
368
|
+
table == source
|
|
369
|
+
and user.lower() in [u.strip() for u in users_str.split(",")]
|
|
370
|
+
for table, users_str in permissive_policies
|
|
371
|
+
)
|
|
372
|
+
if not has_policy:
|
|
373
|
+
errors.append(
|
|
374
|
+
MVValidationError(
|
|
375
|
+
file=file_name,
|
|
376
|
+
mv_name=decl.mv_name,
|
|
377
|
+
message=(
|
|
378
|
+
f"Missing: CREATE ROW POLICY ... ON {{db}}.{decl.source_table} "
|
|
379
|
+
f"USING 1 TO {user} (permissive policy for MV source reads)"
|
|
380
|
+
),
|
|
381
|
+
)
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
return errors
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _check_target_rls(
|
|
388
|
+
decl: MVDeclaration,
|
|
389
|
+
row_policies: list[tuple[str, str]],
|
|
390
|
+
file_name: str,
|
|
391
|
+
) -> list[MVValidationError]:
|
|
392
|
+
"""Check that named RLS policies exist on the target table."""
|
|
393
|
+
errors: list[MVValidationError] = []
|
|
394
|
+
target = decl.target_table.lower()
|
|
395
|
+
|
|
396
|
+
for policy_name in decl.target_rls_policies:
|
|
397
|
+
has_policy = any(
|
|
398
|
+
table == target and policy_name.lower() in policy
|
|
399
|
+
for policy, table in row_policies
|
|
400
|
+
)
|
|
401
|
+
if not has_policy:
|
|
402
|
+
errors.append(
|
|
403
|
+
MVValidationError(
|
|
404
|
+
file=file_name,
|
|
405
|
+
mv_name=decl.mv_name,
|
|
406
|
+
message=(
|
|
407
|
+
f"Missing: CREATE ROW POLICY containing '{policy_name}' "
|
|
408
|
+
f"ON {{db}}.{decl.target_table}"
|
|
409
|
+
),
|
|
410
|
+
)
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
return errors
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# ---------------------------------------------------------------------------
|
|
417
|
+
# Public API
|
|
418
|
+
# ---------------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def validate_mv_migrations(
|
|
422
|
+
versions_dir: Path,
|
|
423
|
+
*,
|
|
424
|
+
cutoff_date: str | None = None,
|
|
425
|
+
) -> list[MVValidationError]:
|
|
426
|
+
"""Validate all migrations with CREATE MATERIALIZED VIEW.
|
|
427
|
+
|
|
428
|
+
Checks that migrations creating MVs include MV_DECLARATIONS and that
|
|
429
|
+
the required companion grants exist in the migration batch (all .py files
|
|
430
|
+
in versions_dir).
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
versions_dir: Path to migrations/versions/ directory.
|
|
434
|
+
cutoff_date: Optional date string (YYYY-MM-DD or full timestamp).
|
|
435
|
+
Migrations with Create Date before this are exempt (grandfathering).
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
List of validation errors. Empty list means all checks passed.
|
|
439
|
+
"""
|
|
440
|
+
errors: list[MVValidationError] = []
|
|
441
|
+
migration_files = sorted(versions_dir.glob("*.py"))
|
|
442
|
+
|
|
443
|
+
if not migration_files:
|
|
444
|
+
return errors
|
|
445
|
+
|
|
446
|
+
# Read each file once and cache results.
|
|
447
|
+
file_cache: dict[Path, tuple[str, str]] = {} # path -> (raw_content, extracted_sql)
|
|
448
|
+
batch_content_parts: list[str] = []
|
|
449
|
+
for path in migration_files:
|
|
450
|
+
raw = path.read_text()
|
|
451
|
+
sql = _read_migration_sql_from_content(raw, path)
|
|
452
|
+
file_cache[path] = (raw, sql)
|
|
453
|
+
batch_content_parts.append(raw)
|
|
454
|
+
if sql:
|
|
455
|
+
batch_content_parts.append(sql)
|
|
456
|
+
|
|
457
|
+
batch_content = "\n".join(batch_content_parts)
|
|
458
|
+
|
|
459
|
+
# Pre-compute batch-level indexes for grant/policy checks
|
|
460
|
+
grant_inserts = _find_grant_inserts(batch_content)
|
|
461
|
+
grant_selects = _find_grant_selects(batch_content)
|
|
462
|
+
row_policies = _find_row_policies(batch_content)
|
|
463
|
+
permissive_policies = _find_permissive_row_policies(batch_content)
|
|
464
|
+
|
|
465
|
+
# Validate each migration that creates an MV
|
|
466
|
+
for path in migration_files:
|
|
467
|
+
_, sql = file_cache[path]
|
|
468
|
+
if not sql or not _sql_has_create_mv(sql):
|
|
469
|
+
continue
|
|
470
|
+
|
|
471
|
+
file_name = path.name
|
|
472
|
+
|
|
473
|
+
# Grandfathering: skip migrations before cutoff date
|
|
474
|
+
if cutoff_date:
|
|
475
|
+
create_date = _extract_create_date(path)
|
|
476
|
+
if create_date and create_date < cutoff_date:
|
|
477
|
+
continue
|
|
478
|
+
|
|
479
|
+
# Escape hatch
|
|
480
|
+
skip = _load_module_attribute(path, "skip_mv_validation")
|
|
481
|
+
if skip is True:
|
|
482
|
+
continue
|
|
483
|
+
|
|
484
|
+
# Require MV_DECLARATIONS
|
|
485
|
+
declarations = _load_module_attribute(path, "MV_DECLARATIONS")
|
|
486
|
+
if declarations is None:
|
|
487
|
+
errors.append(
|
|
488
|
+
MVValidationError(
|
|
489
|
+
file=file_name,
|
|
490
|
+
message=(
|
|
491
|
+
"Migration creates a MATERIALIZED VIEW but is missing "
|
|
492
|
+
"MV_DECLARATIONS. Add MV_DECLARATIONS = [...] or "
|
|
493
|
+
"skip_mv_validation = True (with a comment explaining why)."
|
|
494
|
+
),
|
|
495
|
+
)
|
|
496
|
+
)
|
|
497
|
+
continue
|
|
498
|
+
|
|
499
|
+
if not isinstance(declarations, list):
|
|
500
|
+
errors.append(
|
|
501
|
+
MVValidationError(
|
|
502
|
+
file=file_name,
|
|
503
|
+
message="MV_DECLARATIONS must be a list of dicts.",
|
|
504
|
+
)
|
|
505
|
+
)
|
|
506
|
+
continue
|
|
507
|
+
|
|
508
|
+
if not declarations:
|
|
509
|
+
errors.append(
|
|
510
|
+
MVValidationError(
|
|
511
|
+
file=file_name,
|
|
512
|
+
message="MV_DECLARATIONS is empty.",
|
|
513
|
+
)
|
|
514
|
+
)
|
|
515
|
+
continue
|
|
516
|
+
|
|
517
|
+
# Validate each declaration
|
|
518
|
+
for decl_dict in declarations:
|
|
519
|
+
if not isinstance(decl_dict, dict):
|
|
520
|
+
errors.append(
|
|
521
|
+
MVValidationError(
|
|
522
|
+
file=file_name,
|
|
523
|
+
message="Each MV_DECLARATIONS entry must be a dict.",
|
|
524
|
+
)
|
|
525
|
+
)
|
|
526
|
+
continue
|
|
527
|
+
|
|
528
|
+
field_errors, decl = _validate_required_fields(decl_dict, file_name)
|
|
529
|
+
errors.extend(field_errors)
|
|
530
|
+
if decl is None:
|
|
531
|
+
continue
|
|
532
|
+
|
|
533
|
+
# Core check: GRANT INSERT on target for each inserting user
|
|
534
|
+
errors.extend(
|
|
535
|
+
_check_grant_inserts(decl, grant_inserts, file_name)
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
# Optional checks (only if the declaration includes them)
|
|
539
|
+
if decl.select_users:
|
|
540
|
+
errors.extend(
|
|
541
|
+
_check_grant_selects(decl, grant_selects, file_name)
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
if decl.source_rls_users:
|
|
545
|
+
errors.extend(
|
|
546
|
+
_check_source_rls(decl, permissive_policies, file_name)
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
if decl.target_rls_policies:
|
|
550
|
+
errors.extend(
|
|
551
|
+
_check_target_rls(decl, row_policies, file_name)
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
return errors
|
ch_migrate/py.typed
ADDED
|
File without changes
|