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