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/sql.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""SQL files as migrations: placeholder rendering, statement splitting, execution.
|
|
2
|
+
|
|
3
|
+
The ClickHouse HTTP interface accepts one statement per request, so a SQL file
|
|
4
|
+
holding several statements is split here and run one statement at a time.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from itertools import chain
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Iterator
|
|
14
|
+
|
|
15
|
+
_PLACEHOLDER = re.compile(r"\{(\w+)\}")
|
|
16
|
+
_HEREDOC_OPEN = re.compile(r"\$(\w*)\$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Statement:
|
|
21
|
+
"""One statement from a SQL file.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
sql: The statement text, without its trailing semicolon or the comments
|
|
25
|
+
above it.
|
|
26
|
+
line: 1-based line in the file where the statement text begins.
|
|
27
|
+
comments: Comment lines directly above the statement, without their
|
|
28
|
+
comment markers. Waivers are written here.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
sql: str
|
|
32
|
+
line: int
|
|
33
|
+
comments: tuple[str, ...] = ()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SqlStatementError(RuntimeError):
|
|
37
|
+
"""A statement in a SQL migration file failed.
|
|
38
|
+
|
|
39
|
+
The message names the file, the statement and its line, then the first line
|
|
40
|
+
of the database's own error, so `ch-migrate up` can show it without a traceback.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run_sql(path: str, **values: Any) -> None:
|
|
46
|
+
"""Run every statement in a SQL file under migrations/sql/, one at a time.
|
|
47
|
+
|
|
48
|
+
`{db}`, `{cluster}` and `{on_cluster}` are filled in from the environment;
|
|
49
|
+
keyword arguments add to or override them. Other braces are left alone.
|
|
50
|
+
|
|
51
|
+
Example:
|
|
52
|
+
run_sql("history/tables/logs/2026_10_02_1430_a1b2c3_add_status.up.sql")
|
|
53
|
+
"""
|
|
54
|
+
from alembic import context, op
|
|
55
|
+
|
|
56
|
+
statements = load_statements(path, **values)
|
|
57
|
+
if not statements:
|
|
58
|
+
# An empty file is almost always a migration someone forgot to fill in.
|
|
59
|
+
# Applying it would record the revision as done with nothing changed.
|
|
60
|
+
raise ValueError(f"SQL file has no statements: migrations/sql/{path}")
|
|
61
|
+
if context.is_offline_mode():
|
|
62
|
+
for statement in statements:
|
|
63
|
+
op.execute(statement.sql.replace(":", r"\:"))
|
|
64
|
+
else:
|
|
65
|
+
connection = op.get_bind()
|
|
66
|
+
for index, statement in enumerate(statements, start=1):
|
|
67
|
+
try:
|
|
68
|
+
connection.exec_driver_sql(statement.sql.replace("%", "%%"))
|
|
69
|
+
except Exception as exc:
|
|
70
|
+
where = f"statement {index} of {len(statements)}, line {statement.line}"
|
|
71
|
+
raise SqlStatementError(
|
|
72
|
+
f"migrations/sql/{path} ({where}): {_database_reason(exc)}"
|
|
73
|
+
) from exc
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_statements(path: str, **values: Any) -> list[Statement]:
|
|
77
|
+
"""Read, render and split a SQL file under migrations/sql/."""
|
|
78
|
+
sql_path = Path.cwd() / "migrations" / "sql" / path
|
|
79
|
+
if not sql_path.exists():
|
|
80
|
+
raise FileNotFoundError(f"SQL file not found: {sql_path}")
|
|
81
|
+
return split_statements(render_placeholders(sql_path.read_text(), default_values(values)))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def default_values(overrides: dict[str, Any]) -> dict[str, Any]:
|
|
85
|
+
"""Placeholder values from the environment, with explicit overrides applied."""
|
|
86
|
+
from ch_migrate.helpers import get_cluster, get_db, on_cluster
|
|
87
|
+
|
|
88
|
+
values: dict[str, Any] = {
|
|
89
|
+
"db": get_db(),
|
|
90
|
+
"cluster": get_cluster() or "",
|
|
91
|
+
"on_cluster": on_cluster(),
|
|
92
|
+
}
|
|
93
|
+
values.update(overrides)
|
|
94
|
+
return values
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def render_placeholders(sql: str, values: dict[str, Any]) -> str:
|
|
98
|
+
"""Replace `{name}` for each known name; leave every other brace as written."""
|
|
99
|
+
|
|
100
|
+
def replace(match: re.Match[str]) -> str:
|
|
101
|
+
name = match.group(1)
|
|
102
|
+
return str(values[name]) if name in values else match.group(0)
|
|
103
|
+
|
|
104
|
+
return _PLACEHOLDER.sub(replace, sql)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def split_statements(sql: str) -> list[Statement]:
|
|
108
|
+
"""Split SQL text into statements on semicolons outside quotes and comments."""
|
|
109
|
+
statements: list[Statement] = []
|
|
110
|
+
start, line = 0, 1
|
|
111
|
+
for end in chain(_statement_ends(sql), (len(sql),)):
|
|
112
|
+
chunk = sql[start:end]
|
|
113
|
+
offset, comments = _leading_comments(chunk)
|
|
114
|
+
text = chunk[offset:].strip()
|
|
115
|
+
if text:
|
|
116
|
+
statements.append(Statement(text, line + chunk.count("\n", 0, offset), tuple(comments)))
|
|
117
|
+
line += sql.count("\n", start, end + 1)
|
|
118
|
+
start = end + 1
|
|
119
|
+
return statements
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def clean_database_error(message: str) -> str:
|
|
123
|
+
"""Drop the driver's "Orig exception:" wrapper and the trailing server version."""
|
|
124
|
+
message = re.sub(r"^Orig exception:\s*", "", message.strip())
|
|
125
|
+
return re.sub(r"\s*\(version \d[^()]*(?:\([^()]*\))?\)\s*$", "", message)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _database_reason(exc: Exception) -> str:
|
|
129
|
+
"""First line of a driver error, without the wrapper and server-version noise."""
|
|
130
|
+
lines = str(exc).strip().splitlines()
|
|
131
|
+
return clean_database_error(lines[0]) if lines else type(exc).__name__
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _statement_ends(sql: str) -> Iterator[int]:
|
|
135
|
+
"""Offsets of the semicolons that end statements."""
|
|
136
|
+
i = 0
|
|
137
|
+
while i < len(sql):
|
|
138
|
+
skip_to = _skip_quoted_or_comment(sql, i)
|
|
139
|
+
if skip_to is not None:
|
|
140
|
+
i = skip_to
|
|
141
|
+
continue
|
|
142
|
+
if sql[i] == ";":
|
|
143
|
+
yield i
|
|
144
|
+
i += 1
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _skip_quoted_or_comment(sql: str, i: int) -> int | None:
|
|
148
|
+
"""If a string, quoted identifier, comment or heredoc starts at i, return its end."""
|
|
149
|
+
char = sql[i]
|
|
150
|
+
if char in "'\"`":
|
|
151
|
+
return _end_of_quoted(sql, i, char)
|
|
152
|
+
if sql.startswith("--", i) or sql.startswith("#!", i) or sql.startswith("# ", i):
|
|
153
|
+
newline = sql.find("\n", i)
|
|
154
|
+
return len(sql) if newline == -1 else newline + 1
|
|
155
|
+
if sql.startswith("/*", i):
|
|
156
|
+
close = sql.find("*/", i + 2)
|
|
157
|
+
return len(sql) if close == -1 else close + 2
|
|
158
|
+
if char == "$":
|
|
159
|
+
match = _HEREDOC_OPEN.match(sql, i)
|
|
160
|
+
if match:
|
|
161
|
+
close = sql.find(match.group(0), match.end())
|
|
162
|
+
return len(sql) if close == -1 else close + len(match.group(0))
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _end_of_quoted(sql: str, i: int, quote: str) -> int:
|
|
167
|
+
"""End offset (exclusive) of a quoted run, honouring backslash and doubled quotes."""
|
|
168
|
+
j = i + 1
|
|
169
|
+
while j < len(sql):
|
|
170
|
+
if sql[j] == "\\":
|
|
171
|
+
j += 2
|
|
172
|
+
continue
|
|
173
|
+
if sql[j] == quote:
|
|
174
|
+
if sql.startswith(quote * 2, j):
|
|
175
|
+
j += 2
|
|
176
|
+
continue
|
|
177
|
+
return j + 1
|
|
178
|
+
j += 1
|
|
179
|
+
return len(sql)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _leading_comments(chunk: str) -> tuple[int, list[str]]:
|
|
183
|
+
"""Skip whitespace and comments at the start of chunk.
|
|
184
|
+
|
|
185
|
+
Returns the offset of the first SQL character and the comment texts seen,
|
|
186
|
+
keeping only the comments in the contiguous block directly above the SQL.
|
|
187
|
+
"""
|
|
188
|
+
comments: list[str] = []
|
|
189
|
+
i = 0
|
|
190
|
+
at_line_start = True # a line comment consumes its own newline
|
|
191
|
+
while i < len(chunk):
|
|
192
|
+
run_end = i
|
|
193
|
+
while run_end < len(chunk) and chunk[run_end].isspace():
|
|
194
|
+
run_end += 1
|
|
195
|
+
newlines = chunk.count("\n", i, run_end)
|
|
196
|
+
if newlines >= (1 if at_line_start else 2):
|
|
197
|
+
comments = [] # a blank line separates a comment block from the SQL
|
|
198
|
+
i = run_end
|
|
199
|
+
end = _skip_quoted_or_comment(chunk, i) if i < len(chunk) else None
|
|
200
|
+
if end is None or chunk[i] in "'\"`$":
|
|
201
|
+
break
|
|
202
|
+
comments.extend(_comment_text(chunk[i:end]).splitlines())
|
|
203
|
+
at_line_start = chunk[end - 1] == "\n"
|
|
204
|
+
i = end
|
|
205
|
+
return i, comments
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _comment_text(comment: str) -> str:
|
|
209
|
+
"""Strip comment markers from one comment."""
|
|
210
|
+
text = comment.strip()
|
|
211
|
+
for marker in ("--", "#!", "#"):
|
|
212
|
+
if text.startswith(marker):
|
|
213
|
+
return text[len(marker) :].strip()
|
|
214
|
+
if text.startswith("/*"):
|
|
215
|
+
return text[2:-2].strip() if text.endswith("*/") else text[2:].strip()
|
|
216
|
+
return text
|
ch_migrate/statements.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Extract migration SQL without importing or executing revision modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from ch_migrate.rebase import RevisionGraph
|
|
12
|
+
from ch_migrate.sql import split_statements
|
|
13
|
+
|
|
14
|
+
MigrationDirection = Literal["upgrade", "downgrade"]
|
|
15
|
+
_FSTRING_POSITIONS = sys.version_info >= (3, 12)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class MigrationStatement:
|
|
20
|
+
sql: str
|
|
21
|
+
source: str
|
|
22
|
+
line: int
|
|
23
|
+
comments: tuple[str, ...]
|
|
24
|
+
direction: MigrationDirection
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def migration_statements(path: Path) -> list[MigrationStatement]:
|
|
28
|
+
"""Collect literal SQL in upgrade/downgrade bodies, preserving its source."""
|
|
29
|
+
content = path.read_text()
|
|
30
|
+
root = path.parent.parent
|
|
31
|
+
if root.name == "migrations":
|
|
32
|
+
root = root.parent
|
|
33
|
+
source = _Source(path, root, content, content.splitlines())
|
|
34
|
+
result: list[MigrationStatement] = []
|
|
35
|
+
for function in ast.parse(content).body:
|
|
36
|
+
if not isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
37
|
+
continue
|
|
38
|
+
if function.name not in ("upgrade", "downgrade"):
|
|
39
|
+
continue
|
|
40
|
+
direction: MigrationDirection = "upgrade" if function.name == "upgrade" else "downgrade"
|
|
41
|
+
calls = sorted(
|
|
42
|
+
(node for node in ast.walk(function) if isinstance(node, ast.Call)),
|
|
43
|
+
key=lambda node: (node.lineno, node.col_offset),
|
|
44
|
+
)
|
|
45
|
+
for call in calls:
|
|
46
|
+
if _call_name(call) in ("read_sql", "run_sql"):
|
|
47
|
+
result.extend(_file_statements(call, source, direction))
|
|
48
|
+
elif _call_name(call) == "op.execute":
|
|
49
|
+
result.extend(_inline_statements(call, source, direction))
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def pending_revisions(graph: RevisionGraph, heads: set[str]) -> set[str]:
|
|
54
|
+
"""Subtract applied heads and their ancestors, as status resolves them."""
|
|
55
|
+
applied: set[str] = set()
|
|
56
|
+
unknown = heads - graph.migrations.keys()
|
|
57
|
+
if unknown:
|
|
58
|
+
raise ValueError("Database revisions are missing locally: " + ", ".join(sorted(unknown)))
|
|
59
|
+
for head in heads:
|
|
60
|
+
applied.update(graph.walk_to_root(head))
|
|
61
|
+
return graph.migrations.keys() - applied
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class _Source:
|
|
66
|
+
path: Path
|
|
67
|
+
root: Path
|
|
68
|
+
content: str
|
|
69
|
+
lines: list[str]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _call_name(call: ast.Call) -> str:
|
|
73
|
+
if isinstance(call.func, ast.Name):
|
|
74
|
+
return call.func.id
|
|
75
|
+
if isinstance(call.func, ast.Attribute) and isinstance(call.func.value, ast.Name):
|
|
76
|
+
if call.func.attr in ("run_sql", "read_sql"):
|
|
77
|
+
return call.func.attr
|
|
78
|
+
return f"{call.func.value.id}.{call.func.attr}"
|
|
79
|
+
return ""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _file_statements(
|
|
83
|
+
call: ast.Call, source: _Source, direction: MigrationDirection
|
|
84
|
+
) -> list[MigrationStatement]:
|
|
85
|
+
if not call.args or not isinstance(call.args[0], ast.Constant):
|
|
86
|
+
return []
|
|
87
|
+
path_value = call.args[0].value
|
|
88
|
+
if not isinstance(path_value, str):
|
|
89
|
+
return []
|
|
90
|
+
sql_path = source.path.parent.parent / "sql" / path_value
|
|
91
|
+
statements = split_statements(sql_path.read_text())
|
|
92
|
+
filename = sql_path.relative_to(source.root).as_posix()
|
|
93
|
+
return [
|
|
94
|
+
MigrationStatement(item.sql, filename, item.line, item.comments, direction)
|
|
95
|
+
for item in statements
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _inline_statements(
|
|
100
|
+
call: ast.Call, source: _Source, direction: MigrationDirection
|
|
101
|
+
) -> list[MigrationStatement]:
|
|
102
|
+
if not call.args:
|
|
103
|
+
return []
|
|
104
|
+
text = _literal_sql(call.args[0], source.content)
|
|
105
|
+
if text is None:
|
|
106
|
+
return []
|
|
107
|
+
comments = _python_comments(source.lines, call.lineno)
|
|
108
|
+
filename = source.path.relative_to(source.root).as_posix()
|
|
109
|
+
result = []
|
|
110
|
+
for index, item in enumerate(split_statements(text)):
|
|
111
|
+
# Inline expressions point to their AST call site, including escaped-newline strings.
|
|
112
|
+
attached = comments + item.comments if index == 0 else item.comments
|
|
113
|
+
result.append(MigrationStatement(item.sql, filename, call.lineno, attached, direction))
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _literal_sql(node: ast.AST, content: str) -> str | None:
|
|
118
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
119
|
+
return node.value
|
|
120
|
+
if not isinstance(node, ast.JoinedStr):
|
|
121
|
+
return None
|
|
122
|
+
pieces = []
|
|
123
|
+
for value in node.values:
|
|
124
|
+
if isinstance(value, ast.Constant):
|
|
125
|
+
pieces.append(value.value)
|
|
126
|
+
elif isinstance(value, ast.FormattedValue):
|
|
127
|
+
# Before 3.12, f-string expression nodes carry wrong source positions.
|
|
128
|
+
segment = ast.get_source_segment(content, value.value) if _FSTRING_POSITIONS else None
|
|
129
|
+
expression = segment or ast.unparse(value.value)
|
|
130
|
+
conversion = "" if value.conversion == -1 else "!" + chr(value.conversion)
|
|
131
|
+
format_spec = ""
|
|
132
|
+
if value.format_spec is not None:
|
|
133
|
+
format_spec = ":" + (_literal_sql(value.format_spec, content) or "")
|
|
134
|
+
pieces.append("{" + expression + conversion + format_spec + "}")
|
|
135
|
+
return "".join(pieces)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _python_comments(lines: list[str], lineno: int) -> tuple[str, ...]:
|
|
139
|
+
comments = []
|
|
140
|
+
index = lineno - 2
|
|
141
|
+
while index >= 0 and lines[index].lstrip().startswith("#"):
|
|
142
|
+
comments.append(lines[index].lstrip()[1:].strip())
|
|
143
|
+
index -= 1
|
|
144
|
+
return tuple(reversed(comments))
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
-- Bootstrap SQL Reference
|
|
2
|
+
-- This file documents the SQL structure created by `ch-migrate bootstrap`
|
|
3
|
+
-- The actual SQL is generated dynamically by bootstrap.py
|
|
4
|
+
--
|
|
5
|
+
-- IMPORTANT: This file is NOT executed directly. It serves as documentation.
|
|
6
|
+
-- To run bootstrap, use: ch-migrate bootstrap <environment>
|
|
7
|
+
|
|
8
|
+
-- =============================================================================
|
|
9
|
+
-- DATABASE
|
|
10
|
+
-- =============================================================================
|
|
11
|
+
|
|
12
|
+
CREATE DATABASE IF NOT EXISTS {db};
|
|
13
|
+
|
|
14
|
+
-- =============================================================================
|
|
15
|
+
-- ROLES (always created)
|
|
16
|
+
-- =============================================================================
|
|
17
|
+
|
|
18
|
+
-- Migration role: full access for schema changes and data operations
|
|
19
|
+
CREATE ROLE IF NOT EXISTS {project}_migration_role;
|
|
20
|
+
GRANT ALL ON {db}.* TO {project}_migration_role;
|
|
21
|
+
GRANT CREATE TEMPORARY TABLE ON *.* TO {project}_migration_role;
|
|
22
|
+
GRANT CURRENT GRANTS(SELECT ON system.*) TO {project}_migration_role WITH GRANT OPTION;
|
|
23
|
+
-- ClickHouse Cloud requires explicit grants for individual system tables.
|
|
24
|
+
GRANT SELECT ON system.grants TO {project}_migration_role;
|
|
25
|
+
|
|
26
|
+
-- =============================================================================
|
|
27
|
+
-- USERS (always created)
|
|
28
|
+
-- =============================================================================
|
|
29
|
+
|
|
30
|
+
-- Migration user
|
|
31
|
+
CREATE USER IF NOT EXISTS {migration_user}
|
|
32
|
+
IDENTIFIED BY '{migration_password}';
|
|
33
|
+
GRANT {project}_migration_role TO {migration_user};
|
|
34
|
+
|
|
35
|
+
-- =============================================================================
|
|
36
|
+
-- OPTIONAL: MCP User (only if mcp_user_name configured in config.yaml)
|
|
37
|
+
-- =============================================================================
|
|
38
|
+
|
|
39
|
+
-- CREATE ROLE IF NOT EXISTS {project}_readonly_role;
|
|
40
|
+
-- GRANT SELECT ON {db}.* TO {project}_readonly_role;
|
|
41
|
+
-- GRANT SHOW TABLES ON {db}.* TO {project}_readonly_role;
|
|
42
|
+
--
|
|
43
|
+
-- CREATE USER IF NOT EXISTS {mcp_user_name}
|
|
44
|
+
-- IDENTIFIED BY '{mcp_password}';
|
|
45
|
+
-- GRANT {project}_readonly_role TO {mcp_user_name};
|
|
46
|
+
|
|
47
|
+
-- =============================================================================
|
|
48
|
+
-- OPTIONAL: Dict Reader (only if dict_reader_name configured in config.yaml)
|
|
49
|
+
-- =============================================================================
|
|
50
|
+
|
|
51
|
+
-- CREATE ROLE IF NOT EXISTS {project}_dict_role;
|
|
52
|
+
-- SELECT grants added per-table when dictionaries are created
|
|
53
|
+
--
|
|
54
|
+
-- CREATE USER IF NOT EXISTS {dict_reader_name}
|
|
55
|
+
-- IDENTIFIED BY '{dict_reader_password}';
|
|
56
|
+
-- GRANT {project}_dict_role TO {dict_reader_name};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# ClickHouse Alembic Configuration
|
|
2
|
+
# Generated by ch-migrate init
|
|
3
|
+
|
|
4
|
+
[alembic]
|
|
5
|
+
script_location = migrations
|
|
6
|
+
prepend_sys_path = .
|
|
7
|
+
version_path_separator = os
|
|
8
|
+
file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d_%%(slug)s
|
|
9
|
+
|
|
10
|
+
# Logging configuration
|
|
11
|
+
[loggers]
|
|
12
|
+
keys = root,sqlalchemy,alembic
|
|
13
|
+
|
|
14
|
+
[handlers]
|
|
15
|
+
keys = console
|
|
16
|
+
|
|
17
|
+
[formatters]
|
|
18
|
+
keys = generic
|
|
19
|
+
|
|
20
|
+
[logger_root]
|
|
21
|
+
level = WARN
|
|
22
|
+
handlers = console
|
|
23
|
+
qualname =
|
|
24
|
+
|
|
25
|
+
[logger_sqlalchemy]
|
|
26
|
+
level = WARN
|
|
27
|
+
handlers =
|
|
28
|
+
qualname = sqlalchemy.engine
|
|
29
|
+
|
|
30
|
+
[logger_alembic]
|
|
31
|
+
level = INFO
|
|
32
|
+
handlers =
|
|
33
|
+
qualname = alembic
|
|
34
|
+
|
|
35
|
+
[handler_console]
|
|
36
|
+
class = StreamHandler
|
|
37
|
+
args = (sys.stderr,)
|
|
38
|
+
level = NOTSET
|
|
39
|
+
formatter = generic
|
|
40
|
+
|
|
41
|
+
[formatter_generic]
|
|
42
|
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
43
|
+
datefmt = %H:%M:%S
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# ClickHouse Migration Configuration
|
|
2
|
+
# Generated by ch-migrate init
|
|
3
|
+
|
|
4
|
+
project:
|
|
5
|
+
name: {project_name}
|
|
6
|
+
|
|
7
|
+
# Default settings inherited by all environments
|
|
8
|
+
defaults:
|
|
9
|
+
port: 8443
|
|
10
|
+
secure: true
|
|
11
|
+
admin_user: default
|
|
12
|
+
# Optional: cluster name for ON CLUSTER DDL (self-hosted deployments)
|
|
13
|
+
# cluster: my_cluster
|
|
14
|
+
# Optional: dict_reader for dictionary sources
|
|
15
|
+
# dict_reader_name: dict_reader
|
|
16
|
+
# Optional: mcp_user for read-only MCP tool access
|
|
17
|
+
# mcp_user_name: mcp_reader
|
|
18
|
+
|
|
19
|
+
# Environment-specific configuration
|
|
20
|
+
# Passwords loaded from:
|
|
21
|
+
# - SSM directly (if ssm: paths configured below)
|
|
22
|
+
# - .env.local / environment variables (otherwise)
|
|
23
|
+
environments:
|
|
24
|
+
dev:
|
|
25
|
+
host: your-dev-instance.clickhouse.cloud
|
|
26
|
+
database: {project_name}_dev
|
|
27
|
+
migration_user: migration_dev
|
|
28
|
+
# Optional: AWS region for SSM lookups (uses AWS default if not set)
|
|
29
|
+
# aws_region: us-east-1
|
|
30
|
+
# Optional SSM paths (if set, fetches from SSM directly)
|
|
31
|
+
# Supports JSON key extraction: /path/to/param#json_key
|
|
32
|
+
# ssm:
|
|
33
|
+
# admin_password: /{project_name}/dev/admin_password
|
|
34
|
+
# migration_password: /{project_name}/dev/credentials#password
|
|
35
|
+
# dict_reader_password: /{project_name}/dev/dict_reader_password
|
|
36
|
+
# mcp_password: /{project_name}/dev/mcp_password
|
|
37
|
+
|
|
38
|
+
staging:
|
|
39
|
+
host: your-staging-instance.clickhouse.cloud
|
|
40
|
+
database: {project_name}_staging
|
|
41
|
+
migration_user: migration_staging
|
|
42
|
+
|
|
43
|
+
production:
|
|
44
|
+
host: your-prod-instance.clickhouse.cloud
|
|
45
|
+
database: {project_name}
|
|
46
|
+
migration_user: migration_prod
|
|
47
|
+
|
|
48
|
+
# Optional: Pre/post migration hooks
|
|
49
|
+
# SQL statements executed before/after each migration run.
|
|
50
|
+
# Use {db} placeholder for the database name.
|
|
51
|
+
# hooks:
|
|
52
|
+
# pre_migrate:
|
|
53
|
+
# - "SELECT 1" # validation query
|
|
54
|
+
# post_migrate:
|
|
55
|
+
# - "SYSTEM RELOAD DICTIONARY {db}.dict_regions ON CLUSTER default"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# ClickHouse credentials - DO NOT COMMIT THIS FILE
|
|
2
|
+
# Copy to .env.local and fill in your passwords
|
|
3
|
+
#
|
|
4
|
+
# Alternative: Use SSM paths in config.yaml (fetches directly from SSM)
|
|
5
|
+
# See: https://github.com/DRYCodeWorks/ch-migrate#secrets
|
|
6
|
+
|
|
7
|
+
# Dev environment
|
|
8
|
+
CH_DEV_MIGRATION_PASSWORD=your-dev-migration-password
|
|
9
|
+
CH_DEV_ADMIN_PASSWORD=your-dev-admin-password
|
|
10
|
+
# Optional: only needed if dict_reader_name is configured
|
|
11
|
+
# CH_DEV_DICT_READER_PASSWORD=your-dev-dict-reader-password
|
|
12
|
+
# Optional: only needed if mcp_user_name is configured
|
|
13
|
+
# CH_DEV_MCP_PASSWORD=your-dev-mcp-password
|
|
14
|
+
|
|
15
|
+
# Staging environment
|
|
16
|
+
CH_STAGING_MIGRATION_PASSWORD=your-staging-migration-password
|
|
17
|
+
CH_STAGING_ADMIN_PASSWORD=your-staging-admin-password
|
|
18
|
+
# CH_STAGING_DICT_READER_PASSWORD=your-staging-dict-reader-password
|
|
19
|
+
# CH_STAGING_MCP_PASSWORD=your-staging-mcp-password
|
|
20
|
+
|
|
21
|
+
# Production environment
|
|
22
|
+
CH_PRODUCTION_MIGRATION_PASSWORD=your-prod-migration-password
|
|
23
|
+
CH_PRODUCTION_ADMIN_PASSWORD=your-prod-admin-password
|
|
24
|
+
# CH_PRODUCTION_DICT_READER_PASSWORD=your-prod-dict-reader-password
|
|
25
|
+
# CH_PRODUCTION_MCP_PASSWORD=your-prod-mcp-password
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""${message}
|
|
2
|
+
|
|
3
|
+
Revision ID: ${up_revision}
|
|
4
|
+
Revises: ${down_revision | comma,n}
|
|
5
|
+
Create Date: ${create_date}
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from alembic import op
|
|
9
|
+
|
|
10
|
+
from ch_migrate import get_db, read_sql
|
|
11
|
+
|
|
12
|
+
# revision identifiers
|
|
13
|
+
revision = ${repr(up_revision)}
|
|
14
|
+
down_revision = ${repr(down_revision)}
|
|
15
|
+
branch_labels = ${repr(branch_labels)}
|
|
16
|
+
depends_on = ${repr(depends_on)}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def upgrade() -> None:
|
|
20
|
+
db = get_db()
|
|
21
|
+
# TODO: Add your upgrade SQL here
|
|
22
|
+
# op.execute(read_sql("history/tables/my_table/001_{}.sql".format(revision), db=db))
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def downgrade() -> None:
|
|
27
|
+
db = get_db()
|
|
28
|
+
# TODO: Add your downgrade SQL here
|
|
29
|
+
# op.execute(f"DROP TABLE IF EXISTS {db}.my_table")
|
|
30
|
+
pass
|
ch_migrate/ui.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""One output style for every command: steps, results, warnings and errors.
|
|
2
|
+
|
|
3
|
+
Each line starts with a fixed marker (→ step, ✓ done, ! warning, ✗ error) so the
|
|
4
|
+
output reads the same in a terminal, a CI log or a pipe. Rich drops colour when
|
|
5
|
+
output is not a terminal or NO_COLOR is set; the markers and words stay.
|
|
6
|
+
|
|
7
|
+
Text between backticks in steps, results and hints is shown as a command,
|
|
8
|
+
highlighted in a terminal, with the backticks removed. Errors, warnings and
|
|
9
|
+
details print exactly as written, because they often carry database messages
|
|
10
|
+
that use backticks for identifiers. Nothing is parsed as Rich markup.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import sys
|
|
16
|
+
from typing import NoReturn
|
|
17
|
+
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.text import Text
|
|
20
|
+
|
|
21
|
+
# soft_wrap: never hard-wrap at the terminal width, so paths and SQL stay greppable.
|
|
22
|
+
out = Console(highlight=False, soft_wrap=True)
|
|
23
|
+
err = Console(stderr=True, highlight=False, soft_wrap=True)
|
|
24
|
+
|
|
25
|
+
_COMMAND_STYLE = "bold cyan"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def step(message: str) -> None:
|
|
29
|
+
"""Something is starting or in progress."""
|
|
30
|
+
out.print(_marked("→ ", "cyan", message))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def success(message: str) -> None:
|
|
34
|
+
"""Something finished as intended."""
|
|
35
|
+
out.print(_marked("✓ ", "green", message))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def detail(message: str, *, stderr: bool = False) -> None:
|
|
39
|
+
"""An indented line under the step, result or error above it."""
|
|
40
|
+
(err if stderr else out).print(Text(" " + message, style="dim"))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def hint(message: str, *, stderr: bool = False) -> None:
|
|
44
|
+
"""What to do next; commands in backticks are highlighted."""
|
|
45
|
+
(err if stderr else out).print(_inline(message))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def warn(message: str) -> None:
|
|
49
|
+
"""Worth reading, but the command carries on."""
|
|
50
|
+
err.print(Text.assemble(("! ", "yellow"), (message, "yellow")))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def error(message: str) -> None:
|
|
54
|
+
"""The command could not do what was asked."""
|
|
55
|
+
err.print(Text.assemble(("✗ ", "bold red"), message))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def fail(message: str, *hints: str) -> NoReturn:
|
|
59
|
+
"""Print an error, then any hints, and exit with status 1."""
|
|
60
|
+
error(message)
|
|
61
|
+
for line in hints:
|
|
62
|
+
hint(line, stderr=True)
|
|
63
|
+
sys.exit(1)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _marked(marker: str, marker_style: str, message: str) -> Text:
|
|
67
|
+
# Text(marker, style=...) would make the style the base for the whole line.
|
|
68
|
+
text = Text()
|
|
69
|
+
text.append(marker, style=marker_style)
|
|
70
|
+
text.append_text(_inline(message))
|
|
71
|
+
return text
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _inline(message: str) -> Text:
|
|
75
|
+
"""Render `command` spans highlighted, everything else as plain text."""
|
|
76
|
+
text = Text()
|
|
77
|
+
for index, part in enumerate(message.split("`")):
|
|
78
|
+
text.append(part, style=_COMMAND_STYLE if index % 2 else "")
|
|
79
|
+
return text
|