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.
@@ -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