userver: en/testsuite/databases/pgsql/discover.py Source File
Loading...
Searching...
No Matches
discover.py
1import collections
2import dataclasses
3import hashlib
4import itertools
5import logging
6import pathlib
7from collections.abc import Iterable
8from typing import DefaultDict
9
10from . import exceptions, utils
11
12logger = logging.getLogger(__name__)
13
14SINGLE_SHARD = -1
15DB_NAME_MAX = 31
16
17
18@dataclasses.dataclass(frozen=True)
20 db_name: str
21 shard: int
22
23
24@dataclasses.dataclass
26 name: ShardName
27 files: list[pathlib.Path] | None = None
28 pg_migrations: list[pathlib.Path] | None = None
29
30
31@dataclasses.dataclass
33 files: list[pathlib.Path]
34 pg_migrations: list[pathlib.Path]
35
36 def extend(self, other: ShardFiles) -> None:
37 if other.files:
38 self.files.extend(other.files)
39 if other.pg_migrations:
40 self.pg_migrations.extend(other.pg_migrations)
41
42
43ShardPathesDict = dict[int, ShardFileInfo]
44
45
46@dataclasses.dataclass(frozen=True)
47class PgShard:
48 shard_id: int
49 pretty_name: str
50 dbname: str
51 files: list[pathlib.Path]
52 migrations: list[pathlib.Path]
53
54 def get_schema_hash(self) -> str:
55 return utils.get_files_hash(
56 itertools.chain(self.files, self.migrations),
57 )
58
59
60@dataclasses.dataclass(frozen=True)
62 service_name: str | None
63 dbname: str
64 shards: list[PgShard]
65
66
67def find_schemas(
68 service_name: str | None,
69 schema_dirs: list[pathlib.Path],
70) -> dict[str, PgShardedDatabase]:
71 """Read database schemas from directories ``schema_dirs``. ::
72
73 |- schema_path/
74 |- database1.sql
75 |- database2.sql
76
77 :param service_name: service name used as prefix for database name if not
78 empty, e.g. "servicename_dbname".
79 :param schema_dirs: list of pathes to scan for schemas
80 :returns: :py:class:`Dict[str, PgShardedDatabase]` where key is
81 database name as stored in :py:attr:`PgShard.dbname`
82 """
83 result: dict[str, PgShardedDatabase] = {}
84 for path in schema_dirs:
85 if not path.is_dir():
86 continue
87 schemas = _find_databases_schemas(service_name, path)
88 for dbname in schemas.keys() & result.keys():
90 f'Database {dbname} is declared twice',
91 )
92 result.update(schemas)
93 return result
94
95
96def _find_databases_schemas(
97 service_name: str | None,
98 schema_path: pathlib.Path,
99) -> dict[str, PgShardedDatabase]:
100 logger.debug('Looking up for PostgreSQL schemas at %s', schema_path)
101 shard_files_map = _build_shard_files_map(schema_path)
102 result = {}
103 for dbname, shards in shard_files_map.items():
104 _raise_if_invalid_shards(dbname, shards)
105 pg_shards = []
106 for shard_id, shard_files in sorted(shards.items()):
107 pg_shards.append(
108 _create_pgshard(
109 dbname,
110 service_name=service_name,
111 shard_id=shard_id,
112 files=sorted(shard_files.files),
113 migrations=sorted(shard_files.pg_migrations),
114 ),
115 )
116 result[dbname] = PgShardedDatabase(
117 service_name=service_name,
118 dbname=dbname,
119 shards=pg_shards,
120 )
121 return result
122
123
124def _build_shard_files_map(
125 root_path: pathlib.Path,
126) -> DefaultDict[str, ShardPathesDict]:
127 result: DefaultDict[str, ShardPathesDict]
128 result = collections.defaultdict(
129 lambda: collections.defaultdict(lambda: ShardFileInfo([], [])),
130 )
131 for shard in _find_shard_files(root_path):
132 result[shard.name.db_name][shard.name.shard].extend(shard)
133 return result
134
135
136def _find_shard_files(schema_path: pathlib.Path) -> Iterable[ShardFiles]:
137 for entry in schema_path.iterdir():
138 shard_files = _get_shard_schema_files(entry)
139 if shard_files is not None:
140 yield shard_files
141
142
143def _get_shard_schema_files(path: pathlib.Path) -> ShardFiles | None:
144 shard_name = _parse_shard_name(path.stem)
145 if path.is_file():
146 if path.suffix == '.sql':
147 return ShardFiles(shard_name, files=[path])
148 elif path.is_dir():
149 if path.joinpath('migrations').is_dir():
150 return ShardFiles(shard_name, pg_migrations=[path])
151 return ShardFiles(shard_name, files=utils.scan_sql_directory(path))
152 return None
153
154
155def _raise_if_invalid_shards(dbname: str, shards: ShardPathesDict) -> None:
156 if SINGLE_SHARD in shards:
157 if len(shards) != 1:
159 'Postgresql database %s has single shard configuration '
160 'while defined as multishard' % (dbname,),
161 )
162 else:
163 if set(shards.keys()) != set(range(len(shards))):
165 'Postgresql database %s is missing fixtures '
166 'for some shards' % (dbname,),
167 )
168
169
170def _create_pgshard(
171 dbname: str,
172 service_name: str | None = None,
173 shard_id: int = SINGLE_SHARD,
174 files: list[pathlib.Path] | None = None,
175 migrations: list[pathlib.Path] | None = None,
176) -> PgShard:
177 if files is None:
178 files = []
179 if migrations is None:
180 migrations = []
181 if shard_id == SINGLE_SHARD:
182 actual_shard_id = 0
183 pretty_name = dbname
184 else:
185 actual_shard_id = shard_id
186 pretty_name = '%s@%d' % (dbname, shard_id)
187
188 sharded_dbname = _database_name(service_name, dbname, shard_id)
189 return PgShard(
190 shard_id=actual_shard_id,
191 pretty_name=pretty_name,
192 dbname=sharded_dbname,
193 files=files,
194 migrations=migrations,
195 )
196
197
198_names_used = {}
199
200
201def _database_name(service_name: str | None, dbname: str, shard_id: int):
202 dbkey = (service_name, dbname)
203 suffix = ''
204 if shard_id != SINGLE_SHARD:
205 suffix = f'_{shard_id}'
206 prefix = ''
207 if service_name is not None:
208 prefix = f'{service_name}_'
209 name = _normalize_name(prefix + dbname)
210 dbname = _normalize_name(name + suffix)
211 if len(dbname) > DB_NAME_MAX:
212 dbname = _shortened(name, suffix)
213 if dbname not in _names_used:
214 _names_used[dbname] = dbkey
215 elif _names_used[dbname] != dbkey:
217 f'Database name conflict for {dbkey} and {_names_used[dbname]}'
218 )
219 return dbname
220
221
222def _shortened(name: str, suffix: str):
223 short_name = ''.join([part[:1] for part in name.split('_')])
224 name_hash = hashlib.sha1(name.encode('utf-8')).hexdigest()
225 hash_len = DB_NAME_MAX - len(short_name) - len(suffix) - 1
226 name_hash = name_hash[:hash_len]
227 dbname = f'{short_name}_{name_hash}{suffix}'
228 if len(dbname) > DB_NAME_MAX:
230 f'Dbname cannot be shortened {name}{suffix}'
231 )
232 return dbname
233
234
235def _parse_shard_name(name) -> ShardName:
236 parts = name.rsplit('@', 1)
237 if len(parts) == 2:
238 try:
239 shard_id = int(parts[1])
240 except (ValueError, TypeError):
241 pass
242 else:
243 return ShardName(parts[0], shard_id)
244 return ShardName(name, SINGLE_SHARD)
245
246
247def _normalize_name(name):
248 return name.replace('.', '_').replace('-', '_')