Skip to content

Commit aaf54ff

Browse files
authored
Merge pull request #5 from d-ganchar/save_db_structure
save_db_structure
2 parents ba00b7e + bc543e3 commit aaf54ff

File tree

5 files changed

+173
-61
lines changed

5 files changed

+173
-61
lines changed

.github/workflows/ci_cd.yml

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -48,22 +48,6 @@ jobs:
4848
runs-on: ubuntu-22.04
4949
permissions: write-all
5050
needs: [ build ]
51-
52-
services:
53-
clickhouse:
54-
image: clickhouse/clickhouse-server:23.4
55-
env:
56-
CLICKHOUSE_USER: thedus_tests
57-
CLICKHOUSE_PASSWORD: thedus_tests
58-
ports:
59-
- 9000:9000
60-
- 8123:8123
61-
options: >-
62-
--health-cmd "wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1"
63-
--health-interval 10s
64-
--health-timeout 5s
65-
--health-retries 5
66-
6751
strategy:
6852
fail-fast: false
6953
matrix:
@@ -78,6 +62,17 @@ jobs:
7862
- name: Install dependencies
7963
run: uv sync --dev
8064

65+
- uses: hoverkraft-tech/compose-action@v2.0.2
66+
with:
67+
services: |
68+
clickhouse
69+
up-flags: "-d"
70+
71+
- uses: iFaxity/wait-on-action@v1.2.1
72+
with:
73+
resource: http://localhost:8123
74+
timeout: 30000
75+
8176
- name: Download thedus dist
8277
uses: actions/download-artifact@v4
8378
with:

docker-compose.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ version: '3.8'
22

33
services:
44
clickhouse:
5-
image: clickhouse/clickhouse-server:23.4.2.11-alpine
5+
image: clickhouse/clickhouse-server:25.4.2.31
66
container_name: thedus_clickhouse
77
logging:
88
driver: none
@@ -15,5 +15,6 @@ services:
1515
timeout: 2s
1616
retries: 16
1717
environment:
18+
CLICKHOUSE_DB: thedus_tests
1819
CLICKHOUSE_USER: thedus_tests
1920
CLICKHOUSE_PASSWORD: thedus_tests

pyproject.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ readme = "README.md"
99
requires-python = ">=3.9, < 3.14"
1010
dependencies = [
1111
"rich>=13.9, < 14",
12-
"typer>=0.15, < 0.16",
13-
"clickhouse-driver>=0.2.9, < 0.3",
14-
"ripley==0.1.0b0"
12+
"typer>=0.15, < 1",
13+
"clickhouse-driver>=0.2.9, < 1",
14+
"ripley==0.3.0a0",
15+
"textual>=3.1.1, < 4.0",
1516
]
1617

1718
classifiers = [

tests/test_cli.py

Lines changed: 101 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,40 +2,39 @@
22
import os
33
import subprocess
44
import unittest
5-
6-
import platform
75
from typing import List
86

9-
from parameterized import parameterized
107
from clickhouse_driver import Client
8+
from ripley import from_clickhouse
9+
from parameterized import parameterized
1110

1211

13-
os.environ['CLICKHOUSE_USER'] = 'thedus_tests'
14-
os.environ['CLICKHOUSE_PASSWORD'] = 'thedus_tests'
12+
_CLICKHOUSE_DB = 'thedus_tests'
13+
_THEDUS_DIR = os.path.join(os.path.dirname(__file__), 'migrations')
1514

16-
17-
def init_clickhouse(database: str = 'default') -> Client:
18-
return Client(
19-
host='localhost',
20-
port=9000,
21-
user=os.environ['CLICKHOUSE_USER'],
22-
password=os.environ['CLICKHOUSE_PASSWORD'],
23-
database=database,
24-
)
15+
os.environ['THEDUS_DIR'] = _THEDUS_DIR
16+
os.environ['CLICKHOUSE_DB'] = _CLICKHOUSE_DB
17+
os.environ['CLICKHOUSE_PASSWORD'] = _CLICKHOUSE_DB
18+
os.environ['CLICKHOUSE_USER'] = _CLICKHOUSE_DB
2519

2620

2721
class BaseCliTest(unittest.TestCase):
2822
maxDiff = 10000
29-
clickhouse = init_clickhouse()
23+
clickhouse = Client(
24+
host='localhost',
25+
port=9000,
26+
user=_CLICKHOUSE_DB,
27+
password=_CLICKHOUSE_DB,
28+
database=_CLICKHOUSE_DB,
29+
)
3030

3131
@property
3232
def db_name(self):
33-
minor, major, _ = platform.python_version().split('.')
34-
return 'thedus_' + '_'.join([minor, major])
33+
return _CLICKHOUSE_DB
3534

3635
@property
3736
def thedus_dir(self) -> str:
38-
return os.environ['THEDUS_DIR']
37+
return _THEDUS_DIR
3938

4039
@property
4140
def test_tables(self) -> List[str]:
@@ -46,21 +45,17 @@ def test_tables(self) -> List[str]:
4645
]
4746

4847
def setUp(self):
49-
thedus_dir = os.path.join(os.path.dirname(__file__), 'migrations')
5048
os.environ['THEDUS_ENV'] = ''
51-
os.environ['THEDUS_DIR'] = thedus_dir
52-
os.environ['CLICKHOUSE_DB'] = self.db_name
5349

54-
with os.scandir(thedus_dir) as entries:
50+
with os.scandir(self.thedus_dir) as entries:
5551
for entry in entries:
5652
if entry.name == '.gitignore':
5753
continue
5854
os.remove(entry.path)
5955

60-
self.clickhouse = init_clickhouse()
61-
self.clickhouse.execute(f'DROP DATABASE IF EXISTS {self.db_name}')
62-
self.clickhouse.execute(f'CREATE DATABASE IF NOT EXISTS {self.db_name}')
63-
self.clickhouse = init_clickhouse(self.db_name)
56+
clickhouse = from_clickhouse(self.clickhouse)
57+
for table in clickhouse.get_tables_by_db(_CLICKHOUSE_DB):
58+
self.clickhouse.execute(f"DROP TABLE {table.full_name}")
6459

6560
for file_name, up, down, skip_env in (
6661
(
@@ -269,3 +264,83 @@ def test_downgrade_to_revision(self):
269264
self.check_thedus_output(result, ['rollback 20250101000000_0_create_tbl_metrics', 'done'])
270265
result = subprocess.getoutput('thedus downgrade')
271266
self.check_thedus_output(result, ['done'])
267+
268+
269+
class TestSaveDbStructure(BaseCliTest):
270+
def test_save_db_structure(self):
271+
self.clickhouse.execute("""
272+
CREATE TABLE votes
273+
(
274+
`Id` UInt32,
275+
`PostId` Int32,
276+
`VoteTypeId` UInt8,
277+
`CreationDate` DateTime64(3, 'UTC'),
278+
`UserId` Int32,
279+
`BountyAmount` UInt8
280+
)
281+
ENGINE = MergeTree
282+
ORDER BY (VoteTypeId, CreationDate, PostId)
283+
""")
284+
285+
self.clickhouse.execute("""
286+
CREATE TABLE up_down_votes_per_day
287+
(
288+
`Day` Date,
289+
`UpVotes` UInt32,
290+
`DownVotes` UInt32
291+
)
292+
ENGINE = SummingMergeTree
293+
ORDER BY Day
294+
""")
295+
296+
self.clickhouse.execute("""
297+
CREATE MATERIALIZED VIEW up_down_votes_per_day_mv TO up_down_votes_per_day AS
298+
SELECT toStartOfDay(CreationDate)::Date AS Day,
299+
countIf(VoteTypeId = 2) AS UpVotes,
300+
countIf(VoteTypeId = 3) AS DownVotes
301+
FROM votes
302+
GROUP BY Day
303+
""")
304+
305+
self.clickhouse.execute("""
306+
CREATE VIEW upvotes_per_user AS
307+
SELECT toDate(CreationDate) AS Day,
308+
UserId,
309+
count() AS user_votes
310+
FROM votes
311+
GROUP BY Day, UserId
312+
""")
313+
314+
result = subprocess.run(
315+
'thedus save-db-structure',
316+
shell=True,
317+
cwd=self.thedus_dir,
318+
stdout=subprocess.PIPE,
319+
stderr=subprocess.STDOUT,
320+
text=True,
321+
env=os.environ,
322+
)
323+
324+
filename = f'{_CLICKHOUSE_DB}.sql'
325+
self.assertEqual(result.stdout[32:], f'./{filename} created\n')
326+
327+
with codecs.open(os.path.join(self.thedus_dir, filename)) as file:
328+
self.assertEqual(
329+
(
330+
f"CREATE TABLE {_CLICKHOUSE_DB}.up_down_votes_per_day "
331+
"(`Day` Date, `UpVotes` UInt32, `DownVotes` UInt32) "
332+
"ENGINE = SummingMergeTree ORDER BY Day SETTINGS index_granularity = 8192;\n"
333+
f"CREATE TABLE {_CLICKHOUSE_DB}.votes (`Id` UInt32, `PostId` Int32, `VoteTypeId` UInt8, "
334+
"`CreationDate` DateTime64(3, 'UTC'), `UserId` Int32, `BountyAmount` UInt8) ENGINE = MergeTree "
335+
"ORDER BY (VoteTypeId, CreationDate, PostId) SETTINGS index_granularity = 8192;\n"
336+
"CREATE MATERIALIZED VIEW "
337+
f"{_CLICKHOUSE_DB}.up_down_votes_per_day_mv TO {_CLICKHOUSE_DB}.up_down_votes_per_day "
338+
"(`Day` Date, `UpVotes` UInt64, `DownVotes` UInt64) AS "
339+
"SELECT CAST(toStartOfDay(CreationDate), 'Date') AS Day, countIf(VoteTypeId = 2) AS UpVotes, "
340+
f"countIf(VoteTypeId = 3) AS DownVotes FROM {_CLICKHOUSE_DB}.votes GROUP BY Day;\n"
341+
f"CREATE VIEW {_CLICKHOUSE_DB}.upvotes_per_user (`Day` Date, `UserId` Int32, `user_votes` UInt64) "
342+
f"AS SELECT toDate(CreationDate) AS Day, UserId, count() AS user_votes FROM {_CLICKHOUSE_DB}.votes "
343+
"GROUP BY Day, UserId"
344+
),
345+
file.read(),
346+
)

thedus/run.py

Lines changed: 55 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import os
1+
import codecs
22
import logging
3+
import os
34
from datetime import timezone, datetime
45
from logging.config import dictConfig
56

@@ -9,10 +10,9 @@
910
from ripley import ClickhouseProtocol, from_clickhouse
1011
from typing_extensions import Annotated
1112

13+
from .cmd.clickhouse_cmd import StateCmd, UpgradeCmd, DowngradeCmd
1214
from .env_manager import EnvManager as Env
1315
from .migration_file_manager import MigrationFileManager as Migrations
14-
from .cmd.clickhouse_cmd import StateCmd, UpgradeCmd, DowngradeCmd
15-
1616

1717
dictConfig({
1818
'version': 1,
@@ -40,7 +40,7 @@
4040
})
4141

4242

43-
def init_clickhouse() -> ClickhouseProtocol:
43+
def init_clickhouse(create_migration_log: bool=True) -> ClickhouseProtocol:
4444
host = Env.get_clickhouse_host()
4545
db = Env.get_clickhouse_db()
4646

@@ -53,17 +53,19 @@ def init_clickhouse() -> ClickhouseProtocol:
5353
database=db,
5454
))
5555

56-
clickhouse.exec("""CREATE TABLE IF NOT EXISTS thedus_migration_log
57-
(
58-
command String,
59-
revision String,
60-
environment String,
61-
version UInt64,
62-
is_skipped UInt8 default 0,
63-
created_at Datetime default now()
64-
)
65-
ENGINE = Log
66-
""")
56+
if create_migration_log:
57+
clickhouse.exec("""
58+
CREATE TABLE IF NOT EXISTS thedus_migration_log
59+
(
60+
command String,
61+
revision String,
62+
environment String,
63+
version UInt64,
64+
is_skipped UInt8 default 0,
65+
created_at Datetime default now()
66+
)
67+
ENGINE = Log
68+
""")
6769

6870
return clickhouse
6971
except (Error, ConnectionRefusedError) as error:
@@ -168,5 +170,43 @@ def downgrade(
168170
logging.info('done')
169171

170172

173+
@app.command(help='Saves a database structure to a file, system.tables.create_table_query is used to generate the DDL')
174+
def save_db_structure(
175+
db_name: Annotated[
176+
str,
177+
typer.Argument(
178+
metavar='TEXT',
179+
help='Database name'
180+
),
181+
] = Env.get_clickhouse_db(),
182+
file_path: Annotated[
183+
str,
184+
typer.Argument(
185+
metavar='TEXT',
186+
help='Full path to output file. default: "./$CLICKHOUSE_DB.sql"'
187+
),
188+
] = f'./{Env.get_clickhouse_db()}.sql',
189+
):
190+
if os.path.exists(file_path):
191+
logging.error(f'File {file_path} already exists')
192+
exit(1)
193+
194+
clickhouse = init_clickhouse(False)
195+
tables = clickhouse.get_tables_by_db(db_name)
196+
tables = sorted(
197+
tables,
198+
key=lambda t: (t.engine.lower() in ('view', 'materializedview'), t.metadata_modification_time)
199+
)
200+
201+
if not tables:
202+
logging.warning('"%s" objects not found', db_name)
203+
exit(0)
204+
205+
with codecs.open(file_path, 'w') as file:
206+
file.write(';\n'.join([t.create_table_query for t in tables]))
207+
208+
logging.info('%s created', file_path)
209+
210+
171211
if __name__ == '__main__':
172212
app()

0 commit comments

Comments
 (0)