psql-vcs 0.0.1a0__tar.gz
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.
- psql_vcs-0.0.1a0/PKG-INFO +21 -0
- psql_vcs-0.0.1a0/README.md +0 -0
- psql_vcs-0.0.1a0/pyproject.toml +32 -0
- psql_vcs-0.0.1a0/setup.cfg +4 -0
- psql_vcs-0.0.1a0/src/psql_vcs/__init__.py +14 -0
- psql_vcs-0.0.1a0/src/psql_vcs/models/__init__.py +9 -0
- psql_vcs-0.0.1a0/src/psql_vcs/models/migrator_args.py +63 -0
- psql_vcs-0.0.1a0/src/psql_vcs/models/schemas.py +5 -0
- psql_vcs-0.0.1a0/src/psql_vcs/modules/__init__.py +10 -0
- psql_vcs-0.0.1a0/src/psql_vcs/modules/connector.py +33 -0
- psql_vcs-0.0.1a0/src/psql_vcs/modules/logger.py +7 -0
- psql_vcs-0.0.1a0/src/psql_vcs/modules/postgres_schema_processing.py +420 -0
- psql_vcs-0.0.1a0/src/psql_vcs.egg-info/PKG-INFO +21 -0
- psql_vcs-0.0.1a0/src/psql_vcs.egg-info/SOURCES.txt +15 -0
- psql_vcs-0.0.1a0/src/psql_vcs.egg-info/dependency_links.txt +1 -0
- psql_vcs-0.0.1a0/src/psql_vcs.egg-info/requires.txt +5 -0
- psql_vcs-0.0.1a0/src/psql_vcs.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: psql_vcs
|
|
3
|
+
Version: 0.0.1a0
|
|
4
|
+
Summary: Postgres version control system
|
|
5
|
+
Author-email: Beloborod <me@beloborod.ru>
|
|
6
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
7
|
+
Classifier: Programming Language :: Python
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: results>=2.0.1783086339
|
|
18
|
+
Requires-Dist: psycopg-binary>=3.3.4
|
|
19
|
+
Requires-Dist: psycopg>=3.3.4
|
|
20
|
+
Requires-Dist: pydantic>=2.13.4
|
|
21
|
+
Requires-Dist: psycopg-pool>=3.3.1
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=83.0.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "psql_vcs"
|
|
7
|
+
description = "Postgres version control system"
|
|
8
|
+
version = "0.0.1a0"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Beloborod", email = "me@beloborod.ru" }
|
|
13
|
+
]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"results>=2.0.1783086339",
|
|
16
|
+
"psycopg-binary>=3.3.4",
|
|
17
|
+
"psycopg>=3.3.4",
|
|
18
|
+
"pydantic>=2.13.4",
|
|
19
|
+
"psycopg-pool>=3.3.1",
|
|
20
|
+
]
|
|
21
|
+
license = { file = "LICENSE.txt" }
|
|
22
|
+
classifiers = [
|
|
23
|
+
"License :: OSI Approved :: MIT License",
|
|
24
|
+
"Programming Language :: Python",
|
|
25
|
+
"Programming Language :: Python :: 3",
|
|
26
|
+
"Programming Language :: Python :: 3.9",
|
|
27
|
+
"Programming Language :: Python :: 3.10",
|
|
28
|
+
"Programming Language :: Python :: 3.11",
|
|
29
|
+
"Programming Language :: Python :: 3.12",
|
|
30
|
+
"Programming Language :: Python :: 3.13",
|
|
31
|
+
"Operating System :: OS Independent",
|
|
32
|
+
]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .modules import setup_logger, PostgresMigrator
|
|
2
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
setup_logger()
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
'PostgresMigrator'
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
__version__ = version(__name__)
|
|
13
|
+
except PackageNotFoundError:
|
|
14
|
+
__version__ = "0.0.1a0"
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from ipaddress import IPv4Address
|
|
2
|
+
from pydantic import PostgresDsn
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class AuthArgs:
|
|
8
|
+
migration_server_host: str | IPv4Address
|
|
9
|
+
migration_server_port: int
|
|
10
|
+
migration_server_username: str
|
|
11
|
+
migration_server_password: str
|
|
12
|
+
target_database: str
|
|
13
|
+
target_server_host: str | IPv4Address | None = None
|
|
14
|
+
target_server_port: int | None = None
|
|
15
|
+
target_server_username: str | None = None
|
|
16
|
+
target_server_password: str | None = None
|
|
17
|
+
migration_server_main_database: str | None = None
|
|
18
|
+
migration_server_migrations_database: str | None = None
|
|
19
|
+
migration_server_test_database: str | None = None
|
|
20
|
+
target_server_main_database: str | None = None
|
|
21
|
+
migration_name: str | None = None
|
|
22
|
+
|
|
23
|
+
def __post_init__(self):
|
|
24
|
+
if self.target_server_host is None:
|
|
25
|
+
self.target_server_host = self.migration_server_host
|
|
26
|
+
if self.target_server_port is None:
|
|
27
|
+
self.target_server_port = self.migration_server_port
|
|
28
|
+
if self.target_server_username is None:
|
|
29
|
+
self.target_server_username = self.migration_server_username
|
|
30
|
+
if self.target_server_password is None:
|
|
31
|
+
self.target_server_password = self.migration_server_password
|
|
32
|
+
if self.migration_server_main_database is None:
|
|
33
|
+
self.migration_server_main_database = 'postgres'
|
|
34
|
+
if self.migration_server_migrations_database is None:
|
|
35
|
+
self.migration_server_migrations_database = 'psql_vcs_migrations_db'
|
|
36
|
+
if self.migration_server_test_database is None:
|
|
37
|
+
self.migration_server_test_database = 'psql_vcs_test_db'
|
|
38
|
+
if self.target_server_main_database is None:
|
|
39
|
+
self.target_server_main_database = self.migration_server_main_database
|
|
40
|
+
if self.migration_name is None:
|
|
41
|
+
self.migration_name = self.target_database
|
|
42
|
+
|
|
43
|
+
def __repr__(self):
|
|
44
|
+
return f"<AuthArgs {id(self)}>"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class URLArgs:
|
|
49
|
+
migrations_main_database_url: str | PostgresDsn
|
|
50
|
+
migrations_database_url: str | PostgresDsn
|
|
51
|
+
migrations_test_database_url: str | PostgresDsn
|
|
52
|
+
target_database_url: str | PostgresDsn
|
|
53
|
+
target_server_main_database_url: str | PostgresDsn
|
|
54
|
+
migration_name: str | None = None
|
|
55
|
+
|
|
56
|
+
def __post_init__(self):
|
|
57
|
+
if self.migration_name is None:
|
|
58
|
+
dsn = PostgresDsn(self.target_database_url) \
|
|
59
|
+
if isinstance(self.target_database_url, str) else self.target_database_url
|
|
60
|
+
self.migration_name = dsn.path
|
|
61
|
+
|
|
62
|
+
def __repr__(self):
|
|
63
|
+
return f"<URLArgs {id(self)}>"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from psycopg import connect, Connection, OperationalError, sql
|
|
2
|
+
from psycopg_pool import ConnectionPool
|
|
3
|
+
from pydantic import PostgresDsn
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PostgresRequester:
|
|
7
|
+
def __init__(self, database_url: str | PostgresDsn):
|
|
8
|
+
|
|
9
|
+
if isinstance(database_url, str):
|
|
10
|
+
database_url = PostgresDsn(database_url)
|
|
11
|
+
|
|
12
|
+
self._dsn = database_url.encoded_string()
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
with connect(self._dsn, connect_timeout=5) as conn:
|
|
16
|
+
conn.execute("SELECT 1")
|
|
17
|
+
except OperationalError as e:
|
|
18
|
+
raise RuntimeError(f"Failed to connect to database: {e}") from None
|
|
19
|
+
|
|
20
|
+
self._pool = ConnectionPool(self._dsn, min_size=2, max_size=10, max_idle=300, max_lifetime=3600)
|
|
21
|
+
self._no_trigger_pool = ConnectionPool(self._dsn, min_size=1, max_size=4, max_idle=300, max_lifetime=3600)
|
|
22
|
+
|
|
23
|
+
def __del__(self):
|
|
24
|
+
if hasattr(self, '_pool'): self._pool.close()
|
|
25
|
+
if hasattr(self, '_no_trigger_pool'): self._no_trigger_pool.close()
|
|
26
|
+
|
|
27
|
+
def __repr__(self):
|
|
28
|
+
return f"<PostgresRequester host={self._dsn.split('@')[-1].split('/')[0]} password=***>"
|
|
29
|
+
|
|
30
|
+
def get_connection(self) -> Connection:
|
|
31
|
+
connection = self._pool.getconn()
|
|
32
|
+
connection.autocommit = True
|
|
33
|
+
return connection
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from psycopg import Error as PsycopgError
|
|
3
|
+
from psycopg.rows import dict_row
|
|
4
|
+
from psycopg import sql
|
|
5
|
+
from psycopg.types.json import Jsonb
|
|
6
|
+
from typing import cast, LiteralString
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
import results
|
|
9
|
+
from pydantic import PostgresDsn
|
|
10
|
+
from ..models import AuthArgs, URLArgs, CurrentSchema
|
|
11
|
+
from . import PostgresRequester
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PostgresMigrator:
|
|
18
|
+
def __init__(self, args: AuthArgs | URLArgs):
|
|
19
|
+
if isinstance(args, AuthArgs):
|
|
20
|
+
self.main_migrations_dsn_obj = PostgresDsn.build(
|
|
21
|
+
scheme="postgresql",
|
|
22
|
+
host=args.migration_server_host,
|
|
23
|
+
port=args.migration_server_port,
|
|
24
|
+
username=args.migration_server_username,
|
|
25
|
+
password=args.migration_server_password,
|
|
26
|
+
path=args.migration_server_main_database
|
|
27
|
+
)
|
|
28
|
+
self.migrations_dsn_obj = PostgresDsn.build(
|
|
29
|
+
scheme="postgresql",
|
|
30
|
+
host=args.migration_server_host,
|
|
31
|
+
port=args.migration_server_port,
|
|
32
|
+
username=args.migration_server_username,
|
|
33
|
+
password=args.migration_server_password,
|
|
34
|
+
path=args.migration_server_migrations_database
|
|
35
|
+
)
|
|
36
|
+
self.test_dsn_obj = PostgresDsn.build(
|
|
37
|
+
scheme="postgresql",
|
|
38
|
+
host=args.migration_server_host,
|
|
39
|
+
port=args.migration_server_port,
|
|
40
|
+
username=args.migration_server_username,
|
|
41
|
+
password=args.migration_server_password,
|
|
42
|
+
path=args.migration_server_test_database
|
|
43
|
+
)
|
|
44
|
+
self.target_dsn_obj = PostgresDsn.build(
|
|
45
|
+
scheme="postgresql",
|
|
46
|
+
host=args.target_server_host,
|
|
47
|
+
port=args.target_server_port,
|
|
48
|
+
username=args.target_server_username,
|
|
49
|
+
password=args.target_server_password,
|
|
50
|
+
path=args.target_database
|
|
51
|
+
)
|
|
52
|
+
self.target_main_dns_obj = PostgresDsn.build(
|
|
53
|
+
scheme="postgresql",
|
|
54
|
+
host=args.target_server_host,
|
|
55
|
+
port=args.target_server_port,
|
|
56
|
+
username=args.target_server_username,
|
|
57
|
+
password=args.target_server_password,
|
|
58
|
+
path=args.target_server_main_database
|
|
59
|
+
)
|
|
60
|
+
else:
|
|
61
|
+
self.main_migrations_dsn_obj = PostgresDsn(args.migrations_main_database_url) \
|
|
62
|
+
if isinstance(args.migrations_main_database_url, str) else args.migrations_main_database_url
|
|
63
|
+
self.migrations_dsn_obj = PostgresDsn(args.migrations_database_url) \
|
|
64
|
+
if isinstance(args.migrations_database_url, str) else args.migrations_database_url
|
|
65
|
+
self.test_dsn_obj = PostgresDsn(args.migrations_test_database_url) \
|
|
66
|
+
if isinstance(args.migrations_test_database_url, str) else args.migrations_test_database_url
|
|
67
|
+
self.target_dsn_obj = PostgresDsn(args.target_database_url) \
|
|
68
|
+
if isinstance(args.target_database_url, str) else args.target_database_url
|
|
69
|
+
self.target_main_dsn_obj = PostgresDsn(args.target_server_main_database_url) \
|
|
70
|
+
if isinstance(args.target_server_main_database_url, str) else args.target_server_main_database_url
|
|
71
|
+
|
|
72
|
+
self.migration_name = args.migration_name
|
|
73
|
+
|
|
74
|
+
main_migrations_requester = PostgresRequester(self.target_main_dns_obj)
|
|
75
|
+
with main_migrations_requester.get_connection() as connection:
|
|
76
|
+
with connection.cursor() as cursor:
|
|
77
|
+
cursor.execute(
|
|
78
|
+
"""
|
|
79
|
+
SELECT EXISTS (
|
|
80
|
+
SELECT 1
|
|
81
|
+
FROM pg_database
|
|
82
|
+
WHERE datname = %s
|
|
83
|
+
);
|
|
84
|
+
""", (self.migrations_dsn_obj.path.lstrip('/'),)
|
|
85
|
+
)
|
|
86
|
+
exists = cursor.fetchone()[0]
|
|
87
|
+
if not exists:
|
|
88
|
+
cursor.execute(
|
|
89
|
+
sql.SQL(
|
|
90
|
+
"""
|
|
91
|
+
CREATE DATABASE {};
|
|
92
|
+
"""
|
|
93
|
+
).format(sql.Identifier(self.migrations_dsn_obj.path.lstrip('/')))
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
requester = PostgresRequester(self.migrations_dsn_obj)
|
|
97
|
+
with requester.get_connection() as connection:
|
|
98
|
+
with connection.cursor() as cursor:
|
|
99
|
+
cursor.execute(
|
|
100
|
+
"""
|
|
101
|
+
CREATE SCHEMA IF NOT EXISTS migrations;
|
|
102
|
+
""")
|
|
103
|
+
cursor.execute(
|
|
104
|
+
"""
|
|
105
|
+
CREATE
|
|
106
|
+
EXTENSION IF NOT EXISTS "uuid-ossp";
|
|
107
|
+
"""
|
|
108
|
+
)
|
|
109
|
+
cursor.execute(
|
|
110
|
+
"""
|
|
111
|
+
CREATE TABLE IF NOT EXISTS migrations.schemas
|
|
112
|
+
(
|
|
113
|
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
114
|
+
name CHARACTER VARYING (40) NOT NULL,
|
|
115
|
+
step SMALLINT NOT NULL,
|
|
116
|
+
schema JSONB NOT NULL UNIQUE,
|
|
117
|
+
sql_request CHARACTER VARYING NOT NULL,
|
|
118
|
+
created_at TIMESTAMPTZ DEFAULT NOW
|
|
119
|
+
(
|
|
120
|
+
)
|
|
121
|
+
);
|
|
122
|
+
"""
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _extract_schema(self) -> dict:
|
|
128
|
+
schema = {"tables": {}, "indexes": [], "foreign_keys": []}
|
|
129
|
+
|
|
130
|
+
requester = PostgresRequester(self.target_dsn_obj)
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
with requester.get_connection() as connection:
|
|
134
|
+
with connection.cursor() as cursor:
|
|
135
|
+
cursor.execute("""
|
|
136
|
+
SELECT table_schema, table_name, column_name, data_type, character_maximum_length,
|
|
137
|
+
numeric_precision, numeric_scale, datetime_precision,
|
|
138
|
+
is_nullable, column_default, ordinal_position
|
|
139
|
+
FROM information_schema.columns
|
|
140
|
+
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
|
|
141
|
+
AND table_name NOT LIKE 'pg_%'
|
|
142
|
+
ORDER BY table_schema, table_name, ordinal_position
|
|
143
|
+
""")
|
|
144
|
+
tables = defaultdict(list)
|
|
145
|
+
for sch, tbl, col, dtype, max_char, precis, scale, date_precis, \
|
|
146
|
+
nullable, default, pos in cursor.fetchall():
|
|
147
|
+
tables[f"{sch}.{tbl}"].append({
|
|
148
|
+
"name": col,
|
|
149
|
+
"type": dtype.strip(),
|
|
150
|
+
"nullable": nullable == "YES",
|
|
151
|
+
"max_char": max_char if max_char else None,
|
|
152
|
+
"precision": precis if precis else None,
|
|
153
|
+
"scale": scale if scale else None,
|
|
154
|
+
"date_precision": date_precis if date_precis else None,
|
|
155
|
+
"default": default.strip() if default else None,
|
|
156
|
+
"position": pos
|
|
157
|
+
})
|
|
158
|
+
schema["tables"] = dict(tables)
|
|
159
|
+
|
|
160
|
+
cursor.execute("""
|
|
161
|
+
SELECT schemaname, tablename, indexname, indexdef
|
|
162
|
+
FROM pg_indexes
|
|
163
|
+
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
|
|
164
|
+
ORDER BY schemaname, tablename, indexname
|
|
165
|
+
""")
|
|
166
|
+
for sch, tbl, idx_name, idx_def in cursor.fetchall():
|
|
167
|
+
clean_def = " ".join(idx_def.split()).replace("public.", "")
|
|
168
|
+
schema["indexes"].append({
|
|
169
|
+
"table": f"{sch}.{tbl}",
|
|
170
|
+
"name": idx_name,
|
|
171
|
+
"definition": clean_def
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
cursor.execute("""
|
|
175
|
+
SELECT
|
|
176
|
+
tc.table_schema, tc.table_name, tc.constraint_name,
|
|
177
|
+
kcu.column_name,
|
|
178
|
+
ccu.table_schema AS fk_schema,
|
|
179
|
+
ccu.table_name AS fk_table,
|
|
180
|
+
ccu.column_name AS fk_column,
|
|
181
|
+
pc.confdeltype as pc_del_type,
|
|
182
|
+
pc.confupdtype as pc_upd_type
|
|
183
|
+
FROM information_schema.table_constraints tc
|
|
184
|
+
JOIN information_schema.key_column_usage kcu
|
|
185
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
186
|
+
AND tc.table_schema = kcu.table_schema
|
|
187
|
+
JOIN information_schema.constraint_column_usage ccu
|
|
188
|
+
ON ccu.constraint_name = tc.constraint_name
|
|
189
|
+
JOIN pg_constraint pc
|
|
190
|
+
ON pc.conname = tc.constraint_name
|
|
191
|
+
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
192
|
+
AND tc.table_schema NOT IN ('pg_catalog', 'information_schema')
|
|
193
|
+
ORDER BY tc.table_schema, tc.table_name, tc.constraint_name, kcu.ordinal_position
|
|
194
|
+
""")
|
|
195
|
+
fks: defaultdict[str, dict[str, list | str | None]] = defaultdict(
|
|
196
|
+
lambda: {"columns": [], "ref_table": None, "ref_columns": [], "del_upd": None})
|
|
197
|
+
for row in cursor.fetchall():
|
|
198
|
+
key = f"{row[0]}.{row[1]}.{row[2]}"
|
|
199
|
+
fks[key]["columns"].append(row[3])
|
|
200
|
+
fks[key]["ref_table"] = f"{row[4]}.{row[5]}"
|
|
201
|
+
fks[key]["ref_columns"].append(row[6])
|
|
202
|
+
fks[key]["del_upd"] = f"{row[7]}.{row[8]}"
|
|
203
|
+
for v in fks.values():
|
|
204
|
+
v["columns"].sort()
|
|
205
|
+
v["ref_columns"].sort()
|
|
206
|
+
|
|
207
|
+
schema["foreign_keys"] = [
|
|
208
|
+
{"constraint_name": k, **v} for k, v in sorted(fks.items())
|
|
209
|
+
]
|
|
210
|
+
schema["foreign_keys"] = [
|
|
211
|
+
{"constraint_name": k, **v} for k, v in fks.items()
|
|
212
|
+
]
|
|
213
|
+
except PsycopgError as e:
|
|
214
|
+
raise RuntimeError(f"Connect or request failed: {e}") from None
|
|
215
|
+
|
|
216
|
+
return schema
|
|
217
|
+
|
|
218
|
+
def _save_schema_diff(self, schema: dict, sql_request: str) -> None:
|
|
219
|
+
requester = PostgresRequester(self.migrations_dsn_obj)
|
|
220
|
+
with requester.get_connection() as connection:
|
|
221
|
+
with connection.cursor() as cursor:
|
|
222
|
+
cursor.execute(
|
|
223
|
+
"""
|
|
224
|
+
INSERT INTO migrations.schemas (name, step, schema, sql_request)
|
|
225
|
+
VALUES
|
|
226
|
+
(
|
|
227
|
+
%s,
|
|
228
|
+
(
|
|
229
|
+
SELECT COALESCE(MAX(step), -1) + 1
|
|
230
|
+
FROM migrations.schemas
|
|
231
|
+
WHERE name = %s
|
|
232
|
+
),
|
|
233
|
+
%s,
|
|
234
|
+
%s
|
|
235
|
+
);
|
|
236
|
+
""", (self.migration_name, self.migration_name, Jsonb(schema), sql_request)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def _schema_compare(self, schema: dict) -> CurrentSchema:
|
|
240
|
+
requester = PostgresRequester(self.migrations_dsn_obj)
|
|
241
|
+
with requester.get_connection() as connection:
|
|
242
|
+
with connection.cursor(row_factory=dict_row) as cursor:
|
|
243
|
+
cursor.execute(
|
|
244
|
+
"""
|
|
245
|
+
SELECT name, step FROM migrations.schemas
|
|
246
|
+
WHERE schema = %s;
|
|
247
|
+
""", (Jsonb(schema),)
|
|
248
|
+
)
|
|
249
|
+
search_result = cursor.fetchone()
|
|
250
|
+
if search_result is None:
|
|
251
|
+
raise RuntimeError("Scheme not found")
|
|
252
|
+
|
|
253
|
+
cursor.execute(
|
|
254
|
+
"""
|
|
255
|
+
SELECT MAX(step) as max_version FROM migrations.schemas
|
|
256
|
+
WHERE name = %s;
|
|
257
|
+
""", (search_result['name'],)
|
|
258
|
+
)
|
|
259
|
+
max_version = cursor.fetchone()['max_version']
|
|
260
|
+
return CurrentSchema(search_result['name'], search_result['step'], max_version)
|
|
261
|
+
|
|
262
|
+
def _generate_map(self, current_version: int, max_version: int) -> list[str]:
|
|
263
|
+
requester = PostgresRequester(self.migrations_dsn_obj)
|
|
264
|
+
if current_version < max_version:
|
|
265
|
+
with requester.get_connection() as connection:
|
|
266
|
+
with connection.cursor(row_factory=dict_row) as cursor:
|
|
267
|
+
cursor.execute(
|
|
268
|
+
"""
|
|
269
|
+
SELECT step, sql_request
|
|
270
|
+
FROM migrations.schemas
|
|
271
|
+
WHERE name = %s
|
|
272
|
+
AND step > %s
|
|
273
|
+
AND step <= %s
|
|
274
|
+
ORDER BY step;
|
|
275
|
+
""", (self.migration_name, current_version, max_version)
|
|
276
|
+
)
|
|
277
|
+
search_result = cursor.fetchall()
|
|
278
|
+
if len(search_result) == 0:
|
|
279
|
+
raise RuntimeError("Can't find schemas for %s, started on %s and end on %s versions".format(
|
|
280
|
+
self.migration_name, current_version, max_version))
|
|
281
|
+
return [row['sql_request'] for row in search_result]
|
|
282
|
+
else:
|
|
283
|
+
logger.debug(f"Schema version {current_version} is already actual")
|
|
284
|
+
return []
|
|
285
|
+
|
|
286
|
+
def _get_migration_map_by_schema(self, schema: dict) -> list[str]:
|
|
287
|
+
current_schema = self._schema_compare(schema)
|
|
288
|
+
return self._generate_map(current_schema.current_version, current_schema.max_version)
|
|
289
|
+
|
|
290
|
+
def _get_migration_map(self, start_version: int = 1, end_version: int | None = None) -> list[str]:
|
|
291
|
+
requester = PostgresRequester(self.migrations_dsn_obj)
|
|
292
|
+
if end_version is None:
|
|
293
|
+
with requester.get_connection() as connection:
|
|
294
|
+
with connection.cursor(row_factory=dict_row) as cursor:
|
|
295
|
+
cursor.execute(
|
|
296
|
+
"""
|
|
297
|
+
SELECT MAX(step) as max_step FROM migrations.schemas
|
|
298
|
+
WHERE name = %s
|
|
299
|
+
""", (self.migration_name,)
|
|
300
|
+
)
|
|
301
|
+
search_result = cursor.fetchone()
|
|
302
|
+
end_version = search_result['max_step']
|
|
303
|
+
return self._generate_map(start_version, end_version)
|
|
304
|
+
|
|
305
|
+
def migrate_to_last_version(self) -> None:
|
|
306
|
+
main_target_requester = PostgresRequester(self.target_main_dns_obj)
|
|
307
|
+
with main_target_requester.get_connection() as connection:
|
|
308
|
+
with connection.cursor() as cursor:
|
|
309
|
+
cursor.execute(
|
|
310
|
+
"""
|
|
311
|
+
SELECT 1
|
|
312
|
+
FROM pg_database
|
|
313
|
+
WHERE datname = %s;
|
|
314
|
+
""", (self.target_dsn_obj.path.lstrip('/'),)
|
|
315
|
+
)
|
|
316
|
+
a = cursor.fetchone()
|
|
317
|
+
if a is None:
|
|
318
|
+
initial_sql = self._get_migration_map(start_version=-1, end_version=0)
|
|
319
|
+
if not initial_sql:
|
|
320
|
+
raise RuntimeError(f"Can't find database for {self.migration_name}, after try to create - can't "
|
|
321
|
+
f"find initial schema")
|
|
322
|
+
cursor.execute(
|
|
323
|
+
sql.SQL(
|
|
324
|
+
"""
|
|
325
|
+
CREATE DATABASE {};
|
|
326
|
+
"""
|
|
327
|
+
).format(sql.Identifier(self.target_dsn_obj.path.lstrip('/')))
|
|
328
|
+
)
|
|
329
|
+
target_requester = PostgresRequester(self.target_dsn_obj)
|
|
330
|
+
with target_requester.get_connection() as target_connection:
|
|
331
|
+
with target_connection.cursor() as target_cursor:
|
|
332
|
+
for migration in initial_sql:
|
|
333
|
+
target_cursor.execute(cast(LiteralString, migration))
|
|
334
|
+
|
|
335
|
+
schema = self._extract_schema()
|
|
336
|
+
migration_map = self._get_migration_map_by_schema(schema)
|
|
337
|
+
requester = PostgresRequester(self.target_dsn_obj)
|
|
338
|
+
with requester.get_connection() as connection:
|
|
339
|
+
with connection.cursor() as cursor:
|
|
340
|
+
for migration in migration_map:
|
|
341
|
+
cursor.execute(cast(LiteralString, migration))
|
|
342
|
+
|
|
343
|
+
def create_migration(self) -> None:
|
|
344
|
+
migrations_requester = PostgresRequester(self.migrations_dsn_obj)
|
|
345
|
+
with migrations_requester.get_connection() as connection:
|
|
346
|
+
with connection.cursor() as cursor:
|
|
347
|
+
cursor.execute(
|
|
348
|
+
"""
|
|
349
|
+
SELECT EXISTS (
|
|
350
|
+
SELECT 1
|
|
351
|
+
FROM migrations.schemas
|
|
352
|
+
WHERE name = %s
|
|
353
|
+
AND step = %s
|
|
354
|
+
)
|
|
355
|
+
""", (self.migration_name, 0)
|
|
356
|
+
)
|
|
357
|
+
exists = cursor.fetchone()[0]
|
|
358
|
+
|
|
359
|
+
main_database_requester = PostgresRequester(self.main_migrations_dsn_obj)
|
|
360
|
+
with main_database_requester.get_connection() as connection:
|
|
361
|
+
with connection.cursor() as cursor:
|
|
362
|
+
cursor.execute(
|
|
363
|
+
"""
|
|
364
|
+
SELECT pg_terminate_backend(pid)
|
|
365
|
+
FROM pg_stat_activity
|
|
366
|
+
WHERE datname = %s
|
|
367
|
+
AND pid <> pg_backend_pid();
|
|
368
|
+
""", (self.test_dsn_obj.path.lstrip('/'),)
|
|
369
|
+
)
|
|
370
|
+
cursor.execute(
|
|
371
|
+
sql.SQL(
|
|
372
|
+
"""
|
|
373
|
+
DROP DATABASE IF EXISTS {};
|
|
374
|
+
"""
|
|
375
|
+
).format(sql.Identifier(self.test_dsn_obj.path.lstrip('/')))
|
|
376
|
+
)
|
|
377
|
+
cursor.execute(
|
|
378
|
+
sql.SQL(
|
|
379
|
+
"""
|
|
380
|
+
CREATE DATABASE {};
|
|
381
|
+
"""
|
|
382
|
+
).format(sql.Identifier(self.test_dsn_obj.path.lstrip('/')))
|
|
383
|
+
)
|
|
384
|
+
test_requester = PostgresRequester(self.test_dsn_obj)
|
|
385
|
+
|
|
386
|
+
if exists:
|
|
387
|
+
full_migration_chain = self._get_migration_map(-1)
|
|
388
|
+
with test_requester.get_connection() as connection:
|
|
389
|
+
with connection.cursor() as cursor:
|
|
390
|
+
for migration in full_migration_chain:
|
|
391
|
+
cursor.execute(cast(LiteralString, migration))
|
|
392
|
+
|
|
393
|
+
diff = (results.db(self.test_dsn_obj.encoded_string()).
|
|
394
|
+
schemadiff_as_sql(results.db(self.target_dsn_obj.encoded_string())))
|
|
395
|
+
|
|
396
|
+
schema = self._extract_schema()
|
|
397
|
+
|
|
398
|
+
if diff:
|
|
399
|
+
self._save_schema_diff(schema, diff)
|
|
400
|
+
else:
|
|
401
|
+
logger.info("No changes made for %s", self.migration_name)
|
|
402
|
+
|
|
403
|
+
with main_database_requester.get_connection() as connection:
|
|
404
|
+
with connection.cursor() as cursor:
|
|
405
|
+
cursor.execute(
|
|
406
|
+
"""
|
|
407
|
+
SELECT pg_terminate_backend(pid)
|
|
408
|
+
FROM pg_stat_activity
|
|
409
|
+
WHERE datname = %s
|
|
410
|
+
AND pid <> pg_backend_pid();
|
|
411
|
+
""", (self.test_dsn_obj.path.lstrip('/'),)
|
|
412
|
+
)
|
|
413
|
+
cursor.execute(
|
|
414
|
+
sql.SQL(
|
|
415
|
+
"""
|
|
416
|
+
DROP
|
|
417
|
+
DATABASE IF EXISTS {};
|
|
418
|
+
"""
|
|
419
|
+
).format(sql.Identifier(self.test_dsn_obj.path.lstrip('/')))
|
|
420
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: psql_vcs
|
|
3
|
+
Version: 0.0.1a0
|
|
4
|
+
Summary: Postgres version control system
|
|
5
|
+
Author-email: Beloborod <me@beloborod.ru>
|
|
6
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
7
|
+
Classifier: Programming Language :: Python
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: results>=2.0.1783086339
|
|
18
|
+
Requires-Dist: psycopg-binary>=3.3.4
|
|
19
|
+
Requires-Dist: psycopg>=3.3.4
|
|
20
|
+
Requires-Dist: pydantic>=2.13.4
|
|
21
|
+
Requires-Dist: psycopg-pool>=3.3.1
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/psql_vcs/__init__.py
|
|
4
|
+
src/psql_vcs.egg-info/PKG-INFO
|
|
5
|
+
src/psql_vcs.egg-info/SOURCES.txt
|
|
6
|
+
src/psql_vcs.egg-info/dependency_links.txt
|
|
7
|
+
src/psql_vcs.egg-info/requires.txt
|
|
8
|
+
src/psql_vcs.egg-info/top_level.txt
|
|
9
|
+
src/psql_vcs/models/__init__.py
|
|
10
|
+
src/psql_vcs/models/migrator_args.py
|
|
11
|
+
src/psql_vcs/models/schemas.py
|
|
12
|
+
src/psql_vcs/modules/__init__.py
|
|
13
|
+
src/psql_vcs/modules/connector.py
|
|
14
|
+
src/psql_vcs/modules/logger.py
|
|
15
|
+
src/psql_vcs/modules/postgres_schema_processing.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
psql_vcs
|