fraiseql-confiture 0.1.0__cp311-cp311-manylinux_2_34_x86_64.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.
Potentially problematic release.
This version of fraiseql-confiture might be problematic. Click here for more details.
- confiture/__init__.py +45 -0
- confiture/_core.cpython-311-x86_64-linux-gnu.so +0 -0
- confiture/cli/__init__.py +0 -0
- confiture/cli/main.py +720 -0
- confiture/config/__init__.py +0 -0
- confiture/config/environment.py +190 -0
- confiture/core/__init__.py +0 -0
- confiture/core/builder.py +336 -0
- confiture/core/connection.py +120 -0
- confiture/core/differ.py +522 -0
- confiture/core/migration_generator.py +298 -0
- confiture/core/migrator.py +369 -0
- confiture/core/schema_to_schema.py +592 -0
- confiture/core/syncer.py +540 -0
- confiture/exceptions.py +141 -0
- confiture/integrations/__init__.py +0 -0
- confiture/models/__init__.py +0 -0
- confiture/models/migration.py +95 -0
- confiture/models/schema.py +203 -0
- fraiseql_confiture-0.1.0.dist-info/METADATA +350 -0
- fraiseql_confiture-0.1.0.dist-info/RECORD +24 -0
- fraiseql_confiture-0.1.0.dist-info/WHEEL +4 -0
- fraiseql_confiture-0.1.0.dist-info/entry_points.txt +2 -0
- fraiseql_confiture-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Migration file generator from schema diffs.
|
|
2
|
+
|
|
3
|
+
This module generates Python migration files from SchemaDiff objects.
|
|
4
|
+
Each migration file contains up() and down() methods with the necessary SQL.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from confiture.models.schema import SchemaChange, SchemaDiff
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MigrationGenerator:
|
|
14
|
+
"""Generates Python migration files from schema diffs.
|
|
15
|
+
|
|
16
|
+
Example:
|
|
17
|
+
>>> generator = MigrationGenerator(migrations_dir=Path("db/migrations"))
|
|
18
|
+
>>> diff = SchemaDiff(changes=[...])
|
|
19
|
+
>>> migration_file = generator.generate(diff, name="add_users_table")
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, migrations_dir: Path):
|
|
23
|
+
"""Initialize migration generator.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
migrations_dir: Directory where migration files will be created
|
|
27
|
+
"""
|
|
28
|
+
self.migrations_dir = migrations_dir
|
|
29
|
+
|
|
30
|
+
def generate(self, diff: SchemaDiff, name: str) -> Path:
|
|
31
|
+
"""Generate migration file from schema diff.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
diff: Schema diff containing changes
|
|
35
|
+
name: Name for the migration (snake_case)
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Path to generated migration file
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
ValueError: If diff has no changes
|
|
42
|
+
"""
|
|
43
|
+
if not diff.has_changes():
|
|
44
|
+
raise ValueError("No changes to generate migration from")
|
|
45
|
+
|
|
46
|
+
# Get next version number
|
|
47
|
+
version = self._get_next_version()
|
|
48
|
+
|
|
49
|
+
# Generate file path
|
|
50
|
+
filename = f"{version}_{name}.py"
|
|
51
|
+
filepath = self.migrations_dir / filename
|
|
52
|
+
|
|
53
|
+
# Generate migration code
|
|
54
|
+
code = self._generate_migration_code(diff, version, name)
|
|
55
|
+
|
|
56
|
+
# Write file
|
|
57
|
+
filepath.write_text(code)
|
|
58
|
+
|
|
59
|
+
return filepath
|
|
60
|
+
|
|
61
|
+
def _get_next_version(self) -> str:
|
|
62
|
+
"""Get next sequential migration version number.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Version string (e.g., "001", "002", etc.)
|
|
66
|
+
"""
|
|
67
|
+
if not self.migrations_dir.exists():
|
|
68
|
+
return "001"
|
|
69
|
+
|
|
70
|
+
# Find existing migration files
|
|
71
|
+
migration_files = sorted(self.migrations_dir.glob("*.py"))
|
|
72
|
+
|
|
73
|
+
if not migration_files:
|
|
74
|
+
return "001"
|
|
75
|
+
|
|
76
|
+
# Extract version from last file (e.g., "003_name.py" -> 3)
|
|
77
|
+
last_file = migration_files[-1]
|
|
78
|
+
last_version_str = last_file.name.split("_")[0]
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
last_version = int(last_version_str)
|
|
82
|
+
next_version = last_version + 1
|
|
83
|
+
return f"{next_version:03d}"
|
|
84
|
+
except ValueError:
|
|
85
|
+
# If we can't parse version, start over
|
|
86
|
+
return "001"
|
|
87
|
+
|
|
88
|
+
def _generate_migration_code(self, diff: SchemaDiff, version: str, name: str) -> str:
|
|
89
|
+
"""Generate Python migration code.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
diff: Schema diff containing changes
|
|
93
|
+
version: Version number
|
|
94
|
+
name: Migration name
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Python code as string
|
|
98
|
+
"""
|
|
99
|
+
class_name = self._to_class_name(name)
|
|
100
|
+
timestamp = datetime.now().isoformat()
|
|
101
|
+
|
|
102
|
+
# Generate up and down statements
|
|
103
|
+
up_statements = self._generate_up_statements(diff.changes)
|
|
104
|
+
down_statements = self._generate_down_statements(diff.changes)
|
|
105
|
+
|
|
106
|
+
template = '''"""Migration: {name}
|
|
107
|
+
|
|
108
|
+
Version: {version}
|
|
109
|
+
Generated: {timestamp}
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
from confiture.models.migration import Migration
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class {class_name}(Migration):
|
|
116
|
+
"""Migration: {name}."""
|
|
117
|
+
|
|
118
|
+
version = "{version}"
|
|
119
|
+
name = "{name}"
|
|
120
|
+
|
|
121
|
+
def up(self) -> None:
|
|
122
|
+
"""Apply migration."""
|
|
123
|
+
{up_statements}
|
|
124
|
+
|
|
125
|
+
def down(self) -> None:
|
|
126
|
+
"""Rollback migration."""
|
|
127
|
+
{down_statements}
|
|
128
|
+
'''
|
|
129
|
+
|
|
130
|
+
return template.format(
|
|
131
|
+
name=name,
|
|
132
|
+
version=version,
|
|
133
|
+
class_name=class_name,
|
|
134
|
+
up_statements=up_statements,
|
|
135
|
+
down_statements=down_statements,
|
|
136
|
+
timestamp=timestamp,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def _to_class_name(self, snake_case: str) -> str:
|
|
140
|
+
"""Convert snake_case to PascalCase.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
snake_case: String in snake_case format
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
String in PascalCase format
|
|
147
|
+
|
|
148
|
+
Example:
|
|
149
|
+
>>> gen._to_class_name("add_users_table")
|
|
150
|
+
'AddUsersTable'
|
|
151
|
+
"""
|
|
152
|
+
words = snake_case.split("_")
|
|
153
|
+
return "".join(word.capitalize() for word in words)
|
|
154
|
+
|
|
155
|
+
def _generate_up_statements(self, changes: list[SchemaChange]) -> str:
|
|
156
|
+
"""Generate SQL statements for up migration.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
changes: List of schema changes
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Python code with execute() calls
|
|
163
|
+
"""
|
|
164
|
+
statements = []
|
|
165
|
+
|
|
166
|
+
for change in changes:
|
|
167
|
+
sql = self._change_to_up_sql(change)
|
|
168
|
+
if sql:
|
|
169
|
+
statements.append(f' self.execute("{sql}")')
|
|
170
|
+
|
|
171
|
+
return "\n".join(statements) if statements else " pass # No operations"
|
|
172
|
+
|
|
173
|
+
def _generate_down_statements(self, changes: list[SchemaChange]) -> str:
|
|
174
|
+
"""Generate SQL statements for down migration.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
changes: List of schema changes
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Python code with execute() calls
|
|
181
|
+
"""
|
|
182
|
+
statements = []
|
|
183
|
+
|
|
184
|
+
# Process changes in reverse order for rollback
|
|
185
|
+
for change in reversed(changes):
|
|
186
|
+
sql = self._change_to_down_sql(change)
|
|
187
|
+
if sql:
|
|
188
|
+
statements.append(f' self.execute("{sql}")')
|
|
189
|
+
|
|
190
|
+
return "\n".join(statements) if statements else " pass # No operations"
|
|
191
|
+
|
|
192
|
+
def _change_to_up_sql(self, change: SchemaChange) -> str | None:
|
|
193
|
+
"""Convert schema change to SQL for up migration.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
change: Schema change
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
SQL string or None if not applicable
|
|
200
|
+
"""
|
|
201
|
+
if change.type == "ADD_TABLE":
|
|
202
|
+
# We don't have full schema info, so create a placeholder
|
|
203
|
+
return f"# TODO: ADD_TABLE {change.table}"
|
|
204
|
+
|
|
205
|
+
elif change.type == "DROP_TABLE":
|
|
206
|
+
return f"DROP TABLE {change.table}"
|
|
207
|
+
|
|
208
|
+
elif change.type == "RENAME_TABLE":
|
|
209
|
+
return f"ALTER TABLE {change.old_value} RENAME TO {change.new_value}"
|
|
210
|
+
|
|
211
|
+
elif change.type == "ADD_COLUMN":
|
|
212
|
+
# For ADD_COLUMN, we might have type info in new_value
|
|
213
|
+
col_def = change.new_value if change.new_value else "TEXT"
|
|
214
|
+
return f"ALTER TABLE {change.table} ADD COLUMN {change.column} {col_def}"
|
|
215
|
+
|
|
216
|
+
elif change.type == "DROP_COLUMN":
|
|
217
|
+
return f"ALTER TABLE {change.table} DROP COLUMN {change.column}"
|
|
218
|
+
|
|
219
|
+
elif change.type == "RENAME_COLUMN":
|
|
220
|
+
return (
|
|
221
|
+
f"ALTER TABLE {change.table} RENAME COLUMN {change.old_value} TO {change.new_value}"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
elif change.type == "CHANGE_COLUMN_TYPE":
|
|
225
|
+
return (
|
|
226
|
+
f"ALTER TABLE {change.table} ALTER COLUMN {change.column} TYPE {change.new_value}"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
elif change.type == "CHANGE_COLUMN_NULLABLE":
|
|
230
|
+
if change.new_value == "false":
|
|
231
|
+
return f"ALTER TABLE {change.table} ALTER COLUMN {change.column} SET NOT NULL"
|
|
232
|
+
else:
|
|
233
|
+
return f"ALTER TABLE {change.table} ALTER COLUMN {change.column} DROP NOT NULL"
|
|
234
|
+
|
|
235
|
+
elif change.type == "CHANGE_COLUMN_DEFAULT":
|
|
236
|
+
if change.new_value:
|
|
237
|
+
return f"ALTER TABLE {change.table} ALTER COLUMN {change.column} SET DEFAULT {change.new_value}"
|
|
238
|
+
else:
|
|
239
|
+
return f"ALTER TABLE {change.table} ALTER COLUMN {change.column} DROP DEFAULT"
|
|
240
|
+
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
def _change_to_down_sql(self, change: SchemaChange) -> str | None:
|
|
244
|
+
"""Convert schema change to SQL for down migration (reverse).
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
change: Schema change
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
SQL string or None if not applicable
|
|
251
|
+
"""
|
|
252
|
+
if change.type == "ADD_TABLE":
|
|
253
|
+
# Reverse of ADD is DROP
|
|
254
|
+
return f"DROP TABLE {change.table}"
|
|
255
|
+
|
|
256
|
+
elif change.type == "DROP_TABLE":
|
|
257
|
+
# Can't recreate without schema info
|
|
258
|
+
return f"# WARNING: Cannot auto-generate down migration for DROP_TABLE {change.table}"
|
|
259
|
+
|
|
260
|
+
elif change.type == "RENAME_TABLE":
|
|
261
|
+
# Reverse the rename
|
|
262
|
+
return f"ALTER TABLE {change.new_value} RENAME TO {change.old_value}"
|
|
263
|
+
|
|
264
|
+
elif change.type == "ADD_COLUMN":
|
|
265
|
+
# Reverse of ADD is DROP
|
|
266
|
+
return f"ALTER TABLE {change.table} DROP COLUMN {change.column}"
|
|
267
|
+
|
|
268
|
+
elif change.type == "DROP_COLUMN":
|
|
269
|
+
# Can't recreate without schema info
|
|
270
|
+
return f"# WARNING: Cannot auto-generate down migration for DROP_COLUMN {change.table}.{change.column}"
|
|
271
|
+
|
|
272
|
+
elif change.type == "RENAME_COLUMN":
|
|
273
|
+
# Reverse the rename
|
|
274
|
+
return (
|
|
275
|
+
f"ALTER TABLE {change.table} RENAME COLUMN {change.new_value} TO {change.old_value}"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
elif change.type == "CHANGE_COLUMN_TYPE":
|
|
279
|
+
# Reverse the type change
|
|
280
|
+
return (
|
|
281
|
+
f"ALTER TABLE {change.table} ALTER COLUMN {change.column} TYPE {change.old_value}"
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
elif change.type == "CHANGE_COLUMN_NULLABLE":
|
|
285
|
+
# Reverse the nullable change
|
|
286
|
+
if change.old_value == "false":
|
|
287
|
+
return f"ALTER TABLE {change.table} ALTER COLUMN {change.column} SET NOT NULL"
|
|
288
|
+
else:
|
|
289
|
+
return f"ALTER TABLE {change.table} ALTER COLUMN {change.column} DROP NOT NULL"
|
|
290
|
+
|
|
291
|
+
elif change.type == "CHANGE_COLUMN_DEFAULT":
|
|
292
|
+
# Reverse the default change
|
|
293
|
+
if change.old_value:
|
|
294
|
+
return f"ALTER TABLE {change.table} ALTER COLUMN {change.column} SET DEFAULT {change.old_value}"
|
|
295
|
+
else:
|
|
296
|
+
return f"ALTER TABLE {change.table} ALTER COLUMN {change.column} DROP DEFAULT"
|
|
297
|
+
|
|
298
|
+
return None
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""Migration executor for applying and rolling back database migrations."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import psycopg
|
|
7
|
+
|
|
8
|
+
from confiture.exceptions import MigrationError
|
|
9
|
+
from confiture.models.migration import Migration
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Migrator:
|
|
13
|
+
"""Executes database migrations and tracks their state.
|
|
14
|
+
|
|
15
|
+
The Migrator class is responsible for:
|
|
16
|
+
- Creating and managing the confiture_migrations tracking table
|
|
17
|
+
- Applying migrations (running up() methods)
|
|
18
|
+
- Rolling back migrations (running down() methods)
|
|
19
|
+
- Recording execution time and checksums
|
|
20
|
+
- Ensuring transaction safety
|
|
21
|
+
|
|
22
|
+
Example:
|
|
23
|
+
>>> conn = psycopg.connect("postgresql://localhost/mydb")
|
|
24
|
+
>>> migrator = Migrator(connection=conn)
|
|
25
|
+
>>> migrator.initialize()
|
|
26
|
+
>>> migrator.apply(my_migration)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, connection: psycopg.Connection):
|
|
30
|
+
"""Initialize migrator with database connection.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
connection: psycopg3 database connection
|
|
34
|
+
"""
|
|
35
|
+
self.connection = connection
|
|
36
|
+
|
|
37
|
+
def initialize(self) -> None:
|
|
38
|
+
"""Create confiture_migrations tracking table with modern identity trinity.
|
|
39
|
+
|
|
40
|
+
Identity pattern:
|
|
41
|
+
- id: Auto-incrementing BIGINT (internal, sequential)
|
|
42
|
+
- pk_migration: UUID (stable identifier, external APIs)
|
|
43
|
+
- slug: Human-readable (migration_name + timestamp)
|
|
44
|
+
|
|
45
|
+
This method is idempotent - safe to call multiple times.
|
|
46
|
+
Handles migration from old table structure.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
MigrationError: If table creation fails
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
with self.connection.cursor() as cursor:
|
|
53
|
+
# Enable UUID extension
|
|
54
|
+
cursor.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"')
|
|
55
|
+
|
|
56
|
+
# Check if table exists
|
|
57
|
+
cursor.execute("""
|
|
58
|
+
SELECT EXISTS (
|
|
59
|
+
SELECT FROM information_schema.tables
|
|
60
|
+
WHERE table_name = 'confiture_migrations'
|
|
61
|
+
)
|
|
62
|
+
""")
|
|
63
|
+
result = cursor.fetchone()
|
|
64
|
+
table_exists = result[0] if result else False
|
|
65
|
+
|
|
66
|
+
if table_exists:
|
|
67
|
+
# Check if we need to migrate old table structure
|
|
68
|
+
cursor.execute("""
|
|
69
|
+
SELECT EXISTS (
|
|
70
|
+
SELECT FROM information_schema.columns
|
|
71
|
+
WHERE table_name = 'confiture_migrations'
|
|
72
|
+
AND column_name = 'pk_migration'
|
|
73
|
+
)
|
|
74
|
+
""")
|
|
75
|
+
result = cursor.fetchone()
|
|
76
|
+
has_new_structure = result[0] if result else False
|
|
77
|
+
|
|
78
|
+
if not has_new_structure:
|
|
79
|
+
# Migrate old table structure to new trinity pattern
|
|
80
|
+
cursor.execute("""
|
|
81
|
+
ALTER TABLE confiture_migrations
|
|
82
|
+
ADD COLUMN pk_migration UUID DEFAULT uuid_generate_v4() UNIQUE,
|
|
83
|
+
ADD COLUMN slug TEXT,
|
|
84
|
+
ALTER COLUMN id SET DATA TYPE BIGINT,
|
|
85
|
+
ALTER COLUMN applied_at SET DATA TYPE TIMESTAMPTZ
|
|
86
|
+
""")
|
|
87
|
+
|
|
88
|
+
# Generate slugs for existing migrations
|
|
89
|
+
cursor.execute("""
|
|
90
|
+
UPDATE confiture_migrations
|
|
91
|
+
SET slug = name || '_' || to_char(applied_at, 'YYYYMMDD_HH24MISS')
|
|
92
|
+
WHERE slug IS NULL
|
|
93
|
+
""")
|
|
94
|
+
|
|
95
|
+
# Make slug NOT NULL and UNIQUE
|
|
96
|
+
cursor.execute("""
|
|
97
|
+
ALTER TABLE confiture_migrations
|
|
98
|
+
ALTER COLUMN slug SET NOT NULL,
|
|
99
|
+
ADD CONSTRAINT confiture_migrations_slug_unique UNIQUE (slug)
|
|
100
|
+
""")
|
|
101
|
+
|
|
102
|
+
# Create new indexes
|
|
103
|
+
cursor.execute("""
|
|
104
|
+
CREATE INDEX IF NOT EXISTS idx_confiture_migrations_pk_migration
|
|
105
|
+
ON confiture_migrations(pk_migration)
|
|
106
|
+
""")
|
|
107
|
+
cursor.execute("""
|
|
108
|
+
CREATE INDEX IF NOT EXISTS idx_confiture_migrations_slug
|
|
109
|
+
ON confiture_migrations(slug)
|
|
110
|
+
""")
|
|
111
|
+
|
|
112
|
+
else:
|
|
113
|
+
# Create new table with trinity pattern
|
|
114
|
+
cursor.execute("""
|
|
115
|
+
CREATE TABLE confiture_migrations (
|
|
116
|
+
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
117
|
+
pk_migration UUID NOT NULL DEFAULT uuid_generate_v4() UNIQUE,
|
|
118
|
+
slug TEXT NOT NULL UNIQUE,
|
|
119
|
+
version VARCHAR(255) NOT NULL UNIQUE,
|
|
120
|
+
name VARCHAR(255) NOT NULL,
|
|
121
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
122
|
+
execution_time_ms INTEGER,
|
|
123
|
+
checksum VARCHAR(64)
|
|
124
|
+
)
|
|
125
|
+
""")
|
|
126
|
+
|
|
127
|
+
# Create indexes
|
|
128
|
+
cursor.execute("""
|
|
129
|
+
CREATE INDEX idx_confiture_migrations_pk_migration
|
|
130
|
+
ON confiture_migrations(pk_migration)
|
|
131
|
+
""")
|
|
132
|
+
cursor.execute("""
|
|
133
|
+
CREATE INDEX idx_confiture_migrations_slug
|
|
134
|
+
ON confiture_migrations(slug)
|
|
135
|
+
""")
|
|
136
|
+
cursor.execute("""
|
|
137
|
+
CREATE INDEX idx_confiture_migrations_version
|
|
138
|
+
ON confiture_migrations(version)
|
|
139
|
+
""")
|
|
140
|
+
cursor.execute("""
|
|
141
|
+
CREATE INDEX idx_confiture_migrations_applied_at
|
|
142
|
+
ON confiture_migrations(applied_at DESC)
|
|
143
|
+
""")
|
|
144
|
+
|
|
145
|
+
self.connection.commit()
|
|
146
|
+
except psycopg.Error as e:
|
|
147
|
+
self.connection.rollback()
|
|
148
|
+
raise MigrationError(f"Failed to initialize migrations table: {e}") from e
|
|
149
|
+
|
|
150
|
+
def apply(self, migration: Migration) -> None:
|
|
151
|
+
"""Apply a migration and record it in the tracking table.
|
|
152
|
+
|
|
153
|
+
This method:
|
|
154
|
+
1. Checks if migration was already applied
|
|
155
|
+
2. Executes migration.up() within a transaction
|
|
156
|
+
3. Records migration metadata (version, name, execution time)
|
|
157
|
+
4. Commits transaction
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
migration: Migration instance to apply
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
MigrationError: If migration fails or was already applied
|
|
164
|
+
"""
|
|
165
|
+
# Check if already applied
|
|
166
|
+
if self._is_applied(migration.version):
|
|
167
|
+
raise MigrationError(
|
|
168
|
+
f"Migration {migration.version} ({migration.name}) has already been applied"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
# Start timing
|
|
173
|
+
start_time = time.perf_counter()
|
|
174
|
+
|
|
175
|
+
# Execute migration within transaction
|
|
176
|
+
migration.up()
|
|
177
|
+
|
|
178
|
+
# Calculate execution time
|
|
179
|
+
execution_time_ms = int((time.perf_counter() - start_time) * 1000)
|
|
180
|
+
|
|
181
|
+
# Record in tracking table with human-readable slug
|
|
182
|
+
# Format: migration-name_YYYYMMDD_HHMMSS
|
|
183
|
+
from datetime import datetime
|
|
184
|
+
|
|
185
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
186
|
+
slug = f"{migration.name}_{timestamp}"
|
|
187
|
+
|
|
188
|
+
with self.connection.cursor() as cursor:
|
|
189
|
+
cursor.execute(
|
|
190
|
+
"""
|
|
191
|
+
INSERT INTO confiture_migrations
|
|
192
|
+
(slug, version, name, execution_time_ms)
|
|
193
|
+
VALUES (%s, %s, %s, %s)
|
|
194
|
+
""",
|
|
195
|
+
(slug, migration.version, migration.name, execution_time_ms),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Commit transaction
|
|
199
|
+
self.connection.commit()
|
|
200
|
+
|
|
201
|
+
except Exception as e:
|
|
202
|
+
self.connection.rollback()
|
|
203
|
+
raise MigrationError(
|
|
204
|
+
f"Failed to apply migration {migration.version} ({migration.name}): {e}"
|
|
205
|
+
) from e
|
|
206
|
+
|
|
207
|
+
def rollback(self, migration: Migration) -> None:
|
|
208
|
+
"""Rollback a migration and remove it from tracking table.
|
|
209
|
+
|
|
210
|
+
This method:
|
|
211
|
+
1. Checks if migration was applied
|
|
212
|
+
2. Executes migration.down() within a transaction
|
|
213
|
+
3. Removes migration record from tracking table
|
|
214
|
+
4. Commits transaction
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
migration: Migration instance to rollback
|
|
218
|
+
|
|
219
|
+
Raises:
|
|
220
|
+
MigrationError: If migration fails or was not applied
|
|
221
|
+
"""
|
|
222
|
+
# Check if applied
|
|
223
|
+
if not self._is_applied(migration.version):
|
|
224
|
+
raise MigrationError(
|
|
225
|
+
f"Migration {migration.version} ({migration.name}) "
|
|
226
|
+
"has not been applied, cannot rollback"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
# Execute down() method
|
|
231
|
+
migration.down()
|
|
232
|
+
|
|
233
|
+
# Remove from tracking table
|
|
234
|
+
with self.connection.cursor() as cursor:
|
|
235
|
+
cursor.execute(
|
|
236
|
+
"""
|
|
237
|
+
DELETE FROM confiture_migrations
|
|
238
|
+
WHERE version = %s
|
|
239
|
+
""",
|
|
240
|
+
(migration.version,),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# Commit transaction
|
|
244
|
+
self.connection.commit()
|
|
245
|
+
|
|
246
|
+
except Exception as e:
|
|
247
|
+
self.connection.rollback()
|
|
248
|
+
raise MigrationError(
|
|
249
|
+
f"Failed to rollback migration {migration.version} ({migration.name}): {e}"
|
|
250
|
+
) from e
|
|
251
|
+
|
|
252
|
+
def _is_applied(self, version: str) -> bool:
|
|
253
|
+
"""Check if migration version has been applied.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
version: Migration version to check
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
True if migration has been applied, False otherwise
|
|
260
|
+
"""
|
|
261
|
+
with self.connection.cursor() as cursor:
|
|
262
|
+
cursor.execute(
|
|
263
|
+
"""
|
|
264
|
+
SELECT COUNT(*)
|
|
265
|
+
FROM confiture_migrations
|
|
266
|
+
WHERE version = %s
|
|
267
|
+
""",
|
|
268
|
+
(version,),
|
|
269
|
+
)
|
|
270
|
+
result = cursor.fetchone()
|
|
271
|
+
if result is None:
|
|
272
|
+
return False
|
|
273
|
+
count: int = result[0]
|
|
274
|
+
return count > 0
|
|
275
|
+
|
|
276
|
+
def get_applied_versions(self) -> list[str]:
|
|
277
|
+
"""Get list of all applied migration versions.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
List of migration versions, sorted by applied_at timestamp
|
|
281
|
+
"""
|
|
282
|
+
with self.connection.cursor() as cursor:
|
|
283
|
+
cursor.execute("""
|
|
284
|
+
SELECT version
|
|
285
|
+
FROM confiture_migrations
|
|
286
|
+
ORDER BY applied_at ASC
|
|
287
|
+
""")
|
|
288
|
+
return [row[0] for row in cursor.fetchall()]
|
|
289
|
+
|
|
290
|
+
def find_migration_files(self, migrations_dir: Path | None = None) -> list[Path]:
|
|
291
|
+
"""Find all migration files in the migrations directory.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
migrations_dir: Optional custom migrations directory.
|
|
295
|
+
If None, uses db/migrations/ (default)
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
List of migration file paths, sorted by version number
|
|
299
|
+
|
|
300
|
+
Example:
|
|
301
|
+
>>> migrator = Migrator(connection=conn)
|
|
302
|
+
>>> files = migrator.find_migration_files()
|
|
303
|
+
>>> # [Path("db/migrations/001_create_users.py"), ...]
|
|
304
|
+
"""
|
|
305
|
+
if migrations_dir is None:
|
|
306
|
+
migrations_dir = Path("db") / "migrations"
|
|
307
|
+
|
|
308
|
+
if not migrations_dir.exists():
|
|
309
|
+
return []
|
|
310
|
+
|
|
311
|
+
# Find all .py files (excluding __pycache__, __init__.py)
|
|
312
|
+
migration_files = sorted(
|
|
313
|
+
[
|
|
314
|
+
f
|
|
315
|
+
for f in migrations_dir.glob("*.py")
|
|
316
|
+
if f.name != "__init__.py" and not f.name.startswith("_")
|
|
317
|
+
]
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
return migration_files
|
|
321
|
+
|
|
322
|
+
def find_pending(self, migrations_dir: Path | None = None) -> list[Path]:
|
|
323
|
+
"""Find migrations that have not been applied yet.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
migrations_dir: Optional custom migrations directory
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
List of pending migration file paths
|
|
330
|
+
|
|
331
|
+
Example:
|
|
332
|
+
>>> migrator = Migrator(connection=conn)
|
|
333
|
+
>>> pending = migrator.find_pending()
|
|
334
|
+
>>> print(f"Found {len(pending)} pending migrations")
|
|
335
|
+
"""
|
|
336
|
+
# Get all migration files
|
|
337
|
+
all_migrations = self.find_migration_files(migrations_dir)
|
|
338
|
+
|
|
339
|
+
# Get applied versions
|
|
340
|
+
applied_versions = set(self.get_applied_versions())
|
|
341
|
+
|
|
342
|
+
# Filter to pending only
|
|
343
|
+
pending_migrations = [
|
|
344
|
+
migration_file
|
|
345
|
+
for migration_file in all_migrations
|
|
346
|
+
if self._version_from_filename(migration_file.name) not in applied_versions
|
|
347
|
+
]
|
|
348
|
+
|
|
349
|
+
return pending_migrations
|
|
350
|
+
|
|
351
|
+
def _version_from_filename(self, filename: str) -> str:
|
|
352
|
+
"""Extract version from migration filename.
|
|
353
|
+
|
|
354
|
+
Migration files follow the format: {version}_{name}.py
|
|
355
|
+
Example: "001_create_users.py" -> "001"
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
filename: Migration filename
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
Version string
|
|
362
|
+
|
|
363
|
+
Example:
|
|
364
|
+
>>> migrator._version_from_filename("042_add_column.py")
|
|
365
|
+
"042"
|
|
366
|
+
"""
|
|
367
|
+
# Split on first underscore
|
|
368
|
+
version = filename.split("_")[0]
|
|
369
|
+
return version
|