userver: en/testsuite/databases/mysql/control.py Source File
Loading...
Searching...
No Matches
control.py
1import contextlib
2import dataclasses
3import logging
4import pathlib
5
6import pymysql
7import pymysql.constants
8
9from testsuite.environment import shell
10from testsuite.utils import cached_property
11
12from . import classes, exceptions
13
14logger = logging.getLogger(__name__)
15
16MYSQL_HELPER = pathlib.Path(__file__).parent.joinpath('scripts/mysql-helper')
17
18
19@dataclasses.dataclass(frozen=True)
21 body: str
22 source: str
23 path: str | None
24
25
27 """MySQL database connection wrapper."""
28
29 def __init__(self, connection, conninfo, tables):
30 self._connection = connection
31 self._conninfo = conninfo
32 self._tables: list[str] = tables
33
34 @property
35 def conninfo(self) -> classes.ConnectionInfo:
36 """:py:class:`classes.ConnectionInfo` instance."""
37 return self._conninfo
38
39 def cursor(self, **kwargs) -> pymysql.cursors.Cursor:
40 """Returns cursor instance."""
41 return self._connection.cursor(**kwargs)
42
43 def dict_cursor(self, **kwargs) -> pymysql.cursors.Cursor:
44 """Return dictionary cursor, pymysql.cursors.DictCursor."""
45 kwargs['cursor'] = pymysql.cursors.DictCursor
46 return self.cursor(**kwargs)
47
48 def commit(self) -> None:
49 self._connection.commit()
50
51 def _truncate_non_empty_tables(self) -> list[str] | None:
52 cursor = self.cursor()
53 if self._tables:
54 with contextlib.closing(cursor):
55 queries = []
56 for table in self._tables:
57 queries.append(
58 f"select '{table}' as name, count(*) as c from {table}"
59 )
60 subquery = ' union '.join(queries)
61 query = f'select name from ({subquery}) tables where c>0;'
62 cursor.execute(query)
63 tables = cursor.fetchall()
64 return [table for (table,) in tables]
65 return None
66
67 def apply_queries(
68 self,
69 queries: list[MysqlQuery],
70 keep_tables: list[str] | None = None,
71 truncate_non_empty: bool = False,
72 ) -> None:
73 if not keep_tables:
74 keep_tables = []
75 with self.cursor() as cursor:
76 if truncate_non_empty:
77 tables = self._truncate_non_empty_tables()
78 else:
79 tables = self._tables
80
81 if tables:
82 truncate_table_sql = []
83 for table in tables:
84 if table not in keep_tables:
85 truncate_table_sql.append(f'truncate table {table};')
86 truncate_tables_sql = ' '.join(truncate_table_sql)
87 cursor.execute(
88 'set foreign_key_checks=0;'
89 f'{truncate_tables_sql}'
90 'set foreign_key_checks=1;',
91 )
92 for query in queries:
93 try:
94 cursor.execute(query.body, args=[])
95 except pymysql.Error as exc:
96 error_message = (
97 f'MySQL apply query error\nQuery from: {query.source}\n'
98 )
99 if query.path:
100 error_message += f'File path: {query.path}\n'
101 error_message += '\n' + str(exc)
102 raise exceptions.MysqlError(error_message)
103 self.commit()
104
105
107 def __init__(self, conninfo, verbose: bool = False):
108 self._conninfo = conninfo
109 self._cache: dict = {}
110 self._master_connection = None
111
112 def get_master_connection(self):
113 if self._master_connection is None:
114 self._master_connection = self._connect(self._conninfo)
115 return self._master_connection
116
117 def get_conninfo(self, dbname: str) -> classes.ConnectionInfo:
118 return self._conninfo.replace(dbname=dbname)
119
120 def get_connection(self, dbname):
121 if dbname not in self._cache:
122 self._cache[dbname] = self._create_connection(dbname)
123 return self._cache[dbname]
124
125 def _create_connection(self, dbname):
126 return self._connect(self.get_conninfo(dbname))
127
128 def _connect(self, conninfo: classes.ConnectionInfo):
129 return pymysql.connect(
130 host=conninfo.hostname,
131 port=conninfo.port,
132 user=conninfo.user,
133 password=conninfo.password or '',
134 database=conninfo.dbname,
135 client_flag=pymysql.constants.CLIENT.MULTI_STATEMENTS,
136 )
137
138
140 _migrations_run: set[tuple[str, str]]
141 _initialized: set[str]
142
143 def __init__(self, connections: ConnectionCache, verbose: bool = False):
144 self._need_save_tables = True
145 self._connections = connections
146 self._verbose = verbose
147 self._migrations_run = set()
148 self._initialized = set()
149 self._tables: dict[str, list[str]] = dict()
150
151 def get_connection(self, dbname: str, create_db: bool = True):
152 if dbname not in self._initialized:
153 if create_db:
154 self._initdb(dbname)
155 self._initialized.add(dbname)
156 return self._connections.get_connection(dbname)
157
158 def wrapper_for(self, dbname: str):
159 return ConnectionWrapper(
160 self._connections.get_connection(dbname),
161 self._connections.get_conninfo(dbname),
162 self._tables.get(dbname),
163 )
164
165 def run_migration(self, dbname: str, path: str):
166 key = dbname, path
167 if key in self._migrations_run:
168 return
169 logger.debug(
170 'Running mysql script %s against database %s',
171 path,
172 dbname,
173 )
174 conninfo = self._connections.get_conninfo(dbname)
175 _run_script(conninfo, ['-e', f'source {path}'], verbose=self._verbose)
176 self._migrations_run.add(key)
177 self._need_save_tables = True
178
179 @cached_property
180 def known_databases(self):
181 connection = self._connections.get_master_connection()
182 cursor = connection.cursor()
183 cursor.execute('show databases')
184 return {row[0] for row in cursor.fetchall()}
185
186 def _initdb(self, dbname: str):
187 connection = self._connections.get_master_connection()
188 with connection.cursor() as cursor:
189 if dbname in self.known_databases:
190 cursor.execute(f'DROP DATABASE IF EXISTS `{dbname}`')
191 cursor.execute(f'CREATE DATABASE `{dbname}`')
192 connection.commit()
193 self._initialized.add(dbname)
194
195 def save_tables(self, dbname: str) -> None:
196 if not self._need_save_tables:
197 return
198 connection = self._connections.get_connection(dbname)
199 cursor = connection.cursor()
200 with contextlib.closing(cursor):
201 cursor.execute('show tables')
202 self._tables[dbname] = [table for (table,) in cursor.fetchall()]
203 self._need_save_tables = False
204
205
207 def __init__(
208 self,
209 databases: classes.DatabasesDict,
210 state: DatabasesState,
211 ):
212 self._databases = databases
213 self._state = state
214
215 def get_wrappers(self):
216 return {
217 alias: self._state.wrapper_for(dbconfig.dbname)
218 for alias, dbconfig in self._databases.items()
219 }
220
221 def run_migrations(self):
222 for dbconfig in self._databases.values():
223 self._run_database_migrations(dbconfig)
224
225 def _run_database_migrations(self, dbconfig):
226 self._state.get_connection(dbconfig.dbname, create_db=dbconfig.create)
227 for path in dbconfig.migrations:
228 self._state.run_migration(dbconfig.dbname, path)
229 self._state.save_tables(dbconfig.dbname)
230
231
232def _build_mysql_args(conninfo: classes.ConnectionInfo) -> list[str]:
233 result = ['--protocol=tcp']
234 if conninfo.hostname:
235 result.append(f'--host={conninfo.hostname}')
236 if conninfo.port:
237 result.append(f'--port={conninfo.port}')
238 if conninfo.user:
239 result.append(f'--user={conninfo.user}')
240 if conninfo.password:
241 result.append(f'--password={conninfo.password}')
242 if conninfo.dbname:
243 result.append(f'--database={conninfo.dbname}')
244 return result
245
246
247def _run_script(
248 conninfo: classes.ConnectionInfo,
249 args: list[str],
250 verbose: bool,
251):
252 command = [str(MYSQL_HELPER), *_build_mysql_args(conninfo), *args]
253 shell.execute(command, verbose=verbose, command_alias='mysql/script')