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/lint.py ADDED
@@ -0,0 +1,601 @@
1
+ """Migration linting: static and runtime analysis rules for ch-migrate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from abc import ABC, abstractmethod
7
+ from dataclasses import dataclass, field
8
+ from enum import Enum
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from ch_migrate.mv_validate import MVValidationError, validate_mv_migrations
13
+ from ch_migrate.rebase import RevisionGraph, build_revision_graph
14
+ from ch_migrate.statements import MigrationStatement, migration_statements
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Data models
19
+ # ---------------------------------------------------------------------------
20
+
21
+
22
+ class Severity(str, Enum):
23
+ ERROR = "error"
24
+ WARN = "warn"
25
+ OFF = "off"
26
+
27
+
28
+ @dataclass
29
+ class LintResult:
30
+ rule: str
31
+ message: str
32
+ severity: Severity
33
+ file: str | None = None
34
+ line: int | None = None
35
+
36
+
37
+ @dataclass
38
+ class LintReport:
39
+ results: list[LintResult] = field(default_factory=list)
40
+
41
+ @property
42
+ def has_errors(self) -> bool:
43
+ return any(r.severity == Severity.ERROR for r in self.results)
44
+
45
+ @property
46
+ def error_count(self) -> int:
47
+ return sum(1 for r in self.results if r.severity == Severity.ERROR)
48
+
49
+ @property
50
+ def warning_count(self) -> int:
51
+ return sum(1 for r in self.results if r.severity == Severity.WARN)
52
+
53
+
54
+ @dataclass
55
+ class LintConfig:
56
+ """Lint configuration loaded from config.yaml."""
57
+
58
+ large_table_threshold: int = 100_000_000
59
+ rules: dict[str, Severity] = field(default_factory=dict)
60
+ mv_validation_cutoff: str | None = None
61
+
62
+ @classmethod
63
+ def from_config(cls, config: dict[str, Any]) -> LintConfig:
64
+ lint_section = config.get("lint", {})
65
+ if not lint_section:
66
+ return cls()
67
+
68
+ threshold = lint_section.get("large_table_threshold", 100_000_000)
69
+ rules_raw = lint_section.get("rules", {})
70
+ rules = {}
71
+ for name, level in rules_raw.items():
72
+ try:
73
+ rules[name] = Severity(level)
74
+ except ValueError:
75
+ pass
76
+
77
+ cutoff = lint_section.get("mv_validation_cutoff")
78
+
79
+ return cls(
80
+ large_table_threshold=threshold,
81
+ rules=rules,
82
+ mv_validation_cutoff=cutoff,
83
+ )
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Base class
88
+ # ---------------------------------------------------------------------------
89
+
90
+
91
+ class LintRule(ABC):
92
+ """Base class for lint rules.
93
+
94
+ Subclasses implement `check()` which receives migration SQL and context,
95
+ returning a list of LintResult. Each rule has a `name` used for config lookup.
96
+ """
97
+
98
+ name: str = ""
99
+ default_severity: Severity = Severity.WARN
100
+ requires_db: bool = False
101
+
102
+ def get_severity(self, config: LintConfig) -> Severity:
103
+ return config.rules.get(self.name, self.default_severity)
104
+
105
+ @abstractmethod
106
+ def check(
107
+ self,
108
+ sql: str,
109
+ *,
110
+ file_path: str | None = None,
111
+ config: LintConfig | None = None,
112
+ client: Any | None = None,
113
+ database: str | None = None,
114
+ graph: RevisionGraph | None = None,
115
+ ) -> list[LintResult]:
116
+ ...
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # ClickHouse reserved words
121
+ # ---------------------------------------------------------------------------
122
+
123
+ # Subset of CH reserved words that commonly collide with column names.
124
+ # Full list is version-dependent; these are the most common traps.
125
+ _CH_RESERVED_WORDS = frozenset({
126
+ "add", "after", "alias", "all", "alter", "and", "anti", "any", "array",
127
+ "as", "asc", "attach", "between", "both", "by", "case", "cast", "check",
128
+ "cluster", "collate", "column", "comment", "constraint", "create",
129
+ "cross", "cube", "current", "database", "databases", "date", "day",
130
+ "default", "delete", "desc", "describe", "detach", "dictionaries",
131
+ "dictionary", "distinct", "distributed", "drop", "else", "end", "engine",
132
+ "events", "except", "exists", "explain", "expression", "extract", "fetch",
133
+ "final", "first", "flush", "following", "for", "format", "from", "full",
134
+ "function", "global", "granularity", "group", "having", "hour", "if",
135
+ "ilike", "in", "index", "inject", "inner", "insert", "interval", "into",
136
+ "is", "join", "key", "kill", "last", "layout", "leading", "left", "like",
137
+ "limit", "live", "local", "logs", "materialize", "materialized", "max",
138
+ "merges", "min", "minute", "modify", "month", "move", "mutation", "no",
139
+ "not", "null", "nulls", "offset", "on", "optimize", "or", "order",
140
+ "outer", "outfile", "over", "partition", "populate", "preceding",
141
+ "primary", "prewhere", "projection", "quarter", "range", "reload",
142
+ "remove", "rename", "replace", "right", "rollup", "row", "rows",
143
+ "sample", "second", "select", "semi", "set", "settings", "show",
144
+ "source", "start", "stop", "system", "table", "tables", "temporary",
145
+ "test", "then", "ties", "timestamp", "to", "top", "totals", "trailing",
146
+ "trim", "truncate", "type", "unbounded", "union", "update", "use",
147
+ "using", "uuid", "values", "view", "volume", "watch", "week", "when",
148
+ "where", "window", "with", "year",
149
+ })
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # Static rules (no DB connection needed)
154
+ # ---------------------------------------------------------------------------
155
+
156
+
157
+ class DestructiveChangeRule(LintRule):
158
+ """Flags DROP TABLE and DROP COLUMN statements."""
159
+
160
+ name = "destructive_changes"
161
+ default_severity = Severity.WARN
162
+
163
+ _RE_DROP_TABLE = re.compile(
164
+ r"\bDROP\s+TABLE\b", re.IGNORECASE
165
+ )
166
+ _RE_DROP_COLUMN = re.compile(
167
+ r"\bDROP\s+COLUMN\b", re.IGNORECASE
168
+ )
169
+
170
+ def check(self, sql: str, **kwargs: Any) -> list[LintResult]:
171
+ config = kwargs.get("config") or LintConfig()
172
+ severity = self.get_severity(config)
173
+ if severity == Severity.OFF:
174
+ return []
175
+
176
+ results: list[LintResult] = []
177
+ file_path = kwargs.get("file_path")
178
+
179
+ for match in self._RE_DROP_TABLE.finditer(sql):
180
+ line = sql[:match.start()].count("\n") + 1
181
+ results.append(LintResult(
182
+ rule=self.name,
183
+ message="DROP TABLE is destructive and irreversible",
184
+ severity=severity,
185
+ file=file_path,
186
+ line=line,
187
+ ))
188
+
189
+ for match in self._RE_DROP_COLUMN.finditer(sql):
190
+ line = sql[:match.start()].count("\n") + 1
191
+ results.append(LintResult(
192
+ rule=self.name,
193
+ message="DROP COLUMN is destructive and irreversible",
194
+ severity=severity,
195
+ file=file_path,
196
+ line=line,
197
+ ))
198
+
199
+ return results
200
+
201
+
202
+ class IdempotencyRule(LintRule):
203
+ """Flags CREATE/DROP without IF EXISTS / IF NOT EXISTS."""
204
+
205
+ name = "idempotency"
206
+ default_severity = Severity.WARN
207
+
208
+ _RE_CREATE_NO_IF = re.compile(
209
+ r"\bCREATE\s+(?:OR\s+REPLACE\s+)?(?:TABLE|VIEW|MATERIALIZED\s+VIEW|DICTIONARY)\s+"
210
+ r"(?!IF\s+NOT\s+EXISTS\b)",
211
+ re.IGNORECASE,
212
+ )
213
+ _RE_DROP_NO_IF = re.compile(
214
+ r"\bDROP\s+(?:TABLE|VIEW|DICTIONARY)\s+(?!IF\s+EXISTS\b)",
215
+ re.IGNORECASE,
216
+ )
217
+
218
+ def check(self, sql: str, **kwargs: Any) -> list[LintResult]:
219
+ config = kwargs.get("config") or LintConfig()
220
+ severity = self.get_severity(config)
221
+ if severity == Severity.OFF:
222
+ return []
223
+
224
+ results: list[LintResult] = []
225
+ file_path = kwargs.get("file_path")
226
+
227
+ for match in self._RE_CREATE_NO_IF.finditer(sql):
228
+ # Skip CREATE OR REPLACE (already idempotent)
229
+ matched_text = match.group(0)
230
+ if re.search(r"OR\s+REPLACE", matched_text, re.IGNORECASE):
231
+ continue
232
+ line = sql[:match.start()].count("\n") + 1
233
+ results.append(LintResult(
234
+ rule=self.name,
235
+ message="CREATE without IF NOT EXISTS is not idempotent",
236
+ severity=severity,
237
+ file=file_path,
238
+ line=line,
239
+ ))
240
+
241
+ for match in self._RE_DROP_NO_IF.finditer(sql):
242
+ line = sql[:match.start()].count("\n") + 1
243
+ results.append(LintResult(
244
+ rule=self.name,
245
+ message="DROP without IF EXISTS is not idempotent",
246
+ severity=severity,
247
+ file=file_path,
248
+ line=line,
249
+ ))
250
+
251
+ return results
252
+
253
+
254
+ class ReservedWordRule(LintRule):
255
+ """Flags column names that are ClickHouse reserved words."""
256
+
257
+ name = "reserved_words"
258
+ default_severity = Severity.WARN
259
+
260
+ _RE_COLUMN_DEF = re.compile(
261
+ r"^\s+`?(\w+)`?\s+(?:Nullable|UInt|Int|Float|String|Date|Array|Tuple|Map|Bool|Enum)",
262
+ re.IGNORECASE | re.MULTILINE,
263
+ )
264
+
265
+ def check(self, sql: str, **kwargs: Any) -> list[LintResult]:
266
+ config = kwargs.get("config") or LintConfig()
267
+ severity = self.get_severity(config)
268
+ if severity == Severity.OFF:
269
+ return []
270
+
271
+ results: list[LintResult] = []
272
+ file_path = kwargs.get("file_path")
273
+
274
+ for match in self._RE_COLUMN_DEF.finditer(sql):
275
+ col_name = match.group(1)
276
+ if col_name.lower() in _CH_RESERVED_WORDS:
277
+ line = sql[:match.start()].count("\n") + 1
278
+ results.append(LintResult(
279
+ rule=self.name,
280
+ message=f"Column '{col_name}' is a ClickHouse reserved word",
281
+ severity=severity,
282
+ file=file_path,
283
+ line=line,
284
+ ))
285
+
286
+ return results
287
+
288
+
289
+ class MissingOnClusterRule(LintRule):
290
+ """Flags DDL without {on_cluster} when cluster is configured."""
291
+
292
+ name = "missing_on_cluster"
293
+ default_severity = Severity.OFF # Off by default — only relevant for clustered setups
294
+
295
+ _RE_DDL = re.compile(
296
+ r"\b(CREATE|ALTER|DROP)\s+(?:OR\s+REPLACE\s+)?"
297
+ r"(?:TABLE|VIEW|MATERIALIZED\s+VIEW|DICTIONARY)\b",
298
+ re.IGNORECASE,
299
+ )
300
+
301
+ def check(self, sql: str, **kwargs: Any) -> list[LintResult]:
302
+ config = kwargs.get("config") or LintConfig()
303
+ severity = self.get_severity(config)
304
+ if severity == Severity.OFF:
305
+ return []
306
+
307
+ results: list[LintResult] = []
308
+ file_path = kwargs.get("file_path")
309
+
310
+ for match in self._RE_DDL.finditer(sql):
311
+ # Check if ON CLUSTER or {on_cluster} appears nearby
312
+ rest = sql[match.end():match.end() + 200]
313
+ if not re.search(r"(?:ON\s+CLUSTER|{on_cluster})", rest, re.IGNORECASE):
314
+ line = sql[:match.start()].count("\n") + 1
315
+ stmt_type = match.group(0).strip()
316
+ results.append(LintResult(
317
+ rule=self.name,
318
+ message=f"{stmt_type} without ON CLUSTER or {{on_cluster}} placeholder",
319
+ severity=severity,
320
+ file=file_path,
321
+ line=line,
322
+ ))
323
+
324
+ return results
325
+
326
+
327
+ # ---------------------------------------------------------------------------
328
+ # Runtime rules (require DB connection)
329
+ # ---------------------------------------------------------------------------
330
+
331
+
332
+ class LargeTableMutationRule(LintRule):
333
+ """Flags ALTER on tables above a configurable row threshold."""
334
+
335
+ name = "large_table_mutation"
336
+ default_severity = Severity.WARN
337
+ requires_db = True
338
+
339
+ _RE_ALTER_TABLE = re.compile(
340
+ r"\bALTER\s+TABLE\s+(?:`?(\w+|\{[^}]+\})`?\.)?`?(\w+)`?",
341
+ re.IGNORECASE,
342
+ )
343
+
344
+ def check(self, sql: str, **kwargs: Any) -> list[LintResult]:
345
+ config = kwargs.get("config") or LintConfig()
346
+ severity = self.get_severity(config)
347
+ if severity == Severity.OFF:
348
+ return []
349
+
350
+ client = kwargs.get("client")
351
+ database = kwargs.get("database")
352
+ if not client or not database:
353
+ return []
354
+
355
+ results: list[LintResult] = []
356
+ file_path = kwargs.get("file_path")
357
+ threshold = config.large_table_threshold
358
+
359
+ for match in self._RE_ALTER_TABLE.finditer(sql):
360
+ db = match.group(1)
361
+ if db is None or db.startswith("{"):
362
+ db = database
363
+ table_name = match.group(2)
364
+ try:
365
+ result = client.query(
366
+ "SELECT count() FROM system.parts "
367
+ "WHERE database = {db:String} AND table = {tbl:String} AND active",
368
+ parameters={"db": db, "tbl": table_name},
369
+ )
370
+ if result.result_rows:
371
+ row_count = result.result_rows[0][0]
372
+ if row_count > threshold:
373
+ line = sql[:match.start()].count("\n") + 1
374
+ results.append(LintResult(
375
+ rule=self.name,
376
+ message=(
377
+ f"ALTER on '{table_name}' which has {row_count:,} parts "
378
+ f"(threshold: {threshold:,})"
379
+ ),
380
+ severity=severity,
381
+ file=file_path,
382
+ line=line,
383
+ ))
384
+ except Exception:
385
+ pass
386
+
387
+ return results
388
+
389
+
390
+ class MVDependencyRule(LintRule):
391
+ """Flags operations on tables that have materialized view dependencies."""
392
+
393
+ name = "mv_dependency"
394
+ default_severity = Severity.WARN
395
+ requires_db = True
396
+
397
+ _RE_DROP_TABLE = re.compile(
398
+ r"\bDROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:`?(\w+|\{[^}]+\})`?\.)?`?(\w+)`?",
399
+ re.IGNORECASE,
400
+ )
401
+ _RE_ALTER_TABLE = re.compile(
402
+ r"\bALTER\s+TABLE\s+(?:`?(\w+|\{[^}]+\})`?\.)?`?(\w+)`?",
403
+ re.IGNORECASE,
404
+ )
405
+
406
+ def check(self, sql: str, **kwargs: Any) -> list[LintResult]:
407
+ config = kwargs.get("config") or LintConfig()
408
+ severity = self.get_severity(config)
409
+ if severity == Severity.OFF:
410
+ return []
411
+
412
+ client = kwargs.get("client")
413
+ database = kwargs.get("database")
414
+ if not client or not database:
415
+ return []
416
+
417
+ results: list[LintResult] = []
418
+ file_path = kwargs.get("file_path")
419
+
420
+ from ch_migrate.introspect import get_dependencies
421
+
422
+ try:
423
+ dep_graph = get_dependencies(client, database)
424
+ except Exception:
425
+ return []
426
+
427
+ tables_to_check: list[tuple[str, re.Match[str]]] = []
428
+ for match in self._RE_DROP_TABLE.finditer(sql):
429
+ tables_to_check.append((match.group(2), match))
430
+ for match in self._RE_ALTER_TABLE.finditer(sql):
431
+ tables_to_check.append((match.group(2), match))
432
+
433
+ for table_name, match in tables_to_check:
434
+ affected = dep_graph.affected_by_drop(table_name)
435
+ if affected:
436
+ mv_names = [
437
+ n.name for n in affected if n.obj_type == "materialized_view"
438
+ ]
439
+ dict_names = [
440
+ n.name for n in affected if n.obj_type == "dictionary"
441
+ ]
442
+ if mv_names or dict_names:
443
+ line = sql[:match.start()].count("\n") + 1
444
+ deps = []
445
+ if mv_names:
446
+ deps.append(f"MVs: {', '.join(mv_names)}")
447
+ if dict_names:
448
+ deps.append(f"Dicts: {', '.join(dict_names)}")
449
+ results.append(LintResult(
450
+ rule=self.name,
451
+ message=(
452
+ f"'{table_name}' has dependent objects: {'; '.join(deps)}"
453
+ ),
454
+ severity=severity,
455
+ file=file_path,
456
+ line=line,
457
+ ))
458
+
459
+ return results
460
+
461
+
462
+ class MVDeclarationRule(LintRule):
463
+ """Flags CREATE MATERIALIZED VIEW without MV_DECLARATIONS or required grants.
464
+
465
+ When a migration creates a materialized view, ClickHouse requires the
466
+ inserting user to have INSERT on the target table. This rule enforces that
467
+ the migration declares its MV dependencies via MV_DECLARATIONS and that
468
+ companion grants exist in the migration batch.
469
+
470
+ Configurable via config.yaml:
471
+ lint:
472
+ rules:
473
+ mv_declarations: error # error (default), warn, or off
474
+ mv_validation_cutoff: "2026-03-25" # Optional grandfathering date
475
+ """
476
+
477
+ name = "mv_declarations"
478
+ default_severity = Severity.ERROR
479
+
480
+ def check(self, sql: str, **kwargs: Any) -> list[LintResult]:
481
+ config = kwargs.get("config") or LintConfig()
482
+ severity = self.get_severity(config)
483
+ graph: RevisionGraph | None = kwargs.get("graph")
484
+ if severity == Severity.OFF or graph is None or not graph.migrations:
485
+ return []
486
+ versions_dir = next(iter(graph.migrations.values())).path.parent
487
+ statements = kwargs.get("statements", {})
488
+ errors = validate_mv_migrations(versions_dir, cutoff_date=config.mv_validation_cutoff)
489
+ results = []
490
+ for error in errors:
491
+ origin = _mv_origin(error, statements.get(error.file, []))
492
+ if origin is not None:
493
+ results.append(
494
+ LintResult(self.name, error.message, severity, origin.source, origin.line)
495
+ )
496
+ return results
497
+
498
+
499
+ # ---------------------------------------------------------------------------
500
+ # Rule registry
501
+ # ---------------------------------------------------------------------------
502
+
503
+ STATIC_RULES: list[LintRule] = [
504
+ DestructiveChangeRule(),
505
+ IdempotencyRule(),
506
+ ReservedWordRule(),
507
+ MissingOnClusterRule(),
508
+ MVDeclarationRule(),
509
+ ]
510
+
511
+ RUNTIME_RULES: list[LintRule] = [
512
+ LargeTableMutationRule(),
513
+ MVDependencyRule(),
514
+ ]
515
+
516
+ ALL_RULES: list[LintRule] = STATIC_RULES + RUNTIME_RULES
517
+
518
+
519
+ # ---------------------------------------------------------------------------
520
+ # Public API
521
+ # ---------------------------------------------------------------------------
522
+
523
+
524
+ def lint_migrations(
525
+ versions_dir: Path,
526
+ *,
527
+ config: LintConfig | None = None,
528
+ client: Any | None = None,
529
+ database: str | None = None,
530
+ revisions: set[str] | None = None,
531
+ ) -> LintReport:
532
+ """Lint upgrade statements, optionally restricted to an explicit revision set."""
533
+ config = config or LintConfig()
534
+ graph = build_revision_graph(versions_dir)
535
+ scope = _LintScope(config, client, database, graph)
536
+ selected = {}
537
+ for migration in graph.migrations.values():
538
+ if revisions is None or migration.revision in revisions:
539
+ selected[migration.path.name] = [
540
+ statement
541
+ for statement in migration_statements(migration.path)
542
+ if statement.direction == "upgrade"
543
+ ]
544
+ report = LintReport()
545
+ rules = list(STATIC_RULES) + (RUNTIME_RULES if client is not None else [])
546
+ for statements in selected.values():
547
+ for statement in statements:
548
+ report.results.extend(_lint_statement(statement, rules, scope))
549
+ # Declaration checks need the whole grant batch, but report only selected upgrades.
550
+ report.results.extend(
551
+ MVDeclarationRule().check("", config=config, graph=graph, statements=selected)
552
+ )
553
+ return report
554
+
555
+
556
+ @dataclass(frozen=True)
557
+ class _LintScope:
558
+ config: LintConfig
559
+ client: Any
560
+ database: str | None
561
+ graph: RevisionGraph
562
+
563
+
564
+ def _lint_statement(
565
+ statement: MigrationStatement, rules: list[LintRule], scope: _LintScope
566
+ ) -> list[LintResult]:
567
+ results = []
568
+ for rule in rules:
569
+ if isinstance(rule, MVDeclarationRule) or rule.get_severity(scope.config) == Severity.OFF:
570
+ continue
571
+ findings = rule.check(
572
+ statement.sql,
573
+ file_path=statement.source,
574
+ config=scope.config,
575
+ client=scope.client,
576
+ database=scope.database,
577
+ graph=scope.graph,
578
+ )
579
+ for finding in findings:
580
+ finding.file = statement.source
581
+ finding.line = statement.line
582
+ results.extend(findings)
583
+ return results
584
+
585
+
586
+ def _mv_origin(
587
+ error: MVValidationError, statements: list[MigrationStatement]
588
+ ) -> MigrationStatement | None:
589
+ candidates = []
590
+ for statement in statements:
591
+ match = re.search(
592
+ r"\bCREATE\s+MATERIALIZED\s+VIEW\s+(?:IF\s+NOT\s+EXISTS\s+)?"
593
+ r"(?:(?:\{[^}]*\}|`[^`]+`|\w+)\.)?`?(\w+)`?",
594
+ statement.sql,
595
+ re.IGNORECASE,
596
+ )
597
+ if match:
598
+ candidates.append(statement)
599
+ if error.mv_name is None or match.group(1) == error.mv_name:
600
+ return statement
601
+ return candidates[0] if candidates else None