oxyde-postgres 0.1.0__py3-none-any.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.
Files changed (66) hide show
  1. oxyde/__init__.py +151 -0
  2. oxyde/_msgpack.py +3 -0
  3. oxyde/cli/__init__.py +7 -0
  4. oxyde/cli/app.py +107 -0
  5. oxyde/cli/codegen.py +66 -0
  6. oxyde/cli/config.py +84 -0
  7. oxyde/cli/migrations.py +536 -0
  8. oxyde/codegen/__init__.py +15 -0
  9. oxyde/codegen/stub_generator.py +1229 -0
  10. oxyde/core/__init__.py +90 -0
  11. oxyde/core/column_types.py +367 -0
  12. oxyde/core/ir.py +380 -0
  13. oxyde/core/types.py +69 -0
  14. oxyde/core/wrapper.py +242 -0
  15. oxyde/db/__init__.py +230 -0
  16. oxyde/db/pool.py +305 -0
  17. oxyde/db/registry.py +113 -0
  18. oxyde/db/schema.py +51 -0
  19. oxyde/db/transaction.py +392 -0
  20. oxyde/exceptions.py +89 -0
  21. oxyde/migrations/__init__.py +49 -0
  22. oxyde/migrations/config.py +187 -0
  23. oxyde/migrations/context.py +598 -0
  24. oxyde/migrations/executor.py +456 -0
  25. oxyde/migrations/extract.py +316 -0
  26. oxyde/migrations/generator.py +534 -0
  27. oxyde/migrations/replay.py +367 -0
  28. oxyde/migrations/squash.py +109 -0
  29. oxyde/migrations/tracker.py +162 -0
  30. oxyde/migrations/utils.py +145 -0
  31. oxyde/models/__init__.py +83 -0
  32. oxyde/models/base.py +871 -0
  33. oxyde/models/decorators.py +111 -0
  34. oxyde/models/field.py +204 -0
  35. oxyde/models/lookups.py +667 -0
  36. oxyde/models/metadata.py +174 -0
  37. oxyde/models/registry.py +170 -0
  38. oxyde/models/serializers.py +144 -0
  39. oxyde/models/utils.py +116 -0
  40. oxyde/py.typed +0 -0
  41. oxyde/queries/__init__.py +79 -0
  42. oxyde/queries/aggregates.py +145 -0
  43. oxyde/queries/base.py +249 -0
  44. oxyde/queries/conditions.py +92 -0
  45. oxyde/queries/expressions.py +171 -0
  46. oxyde/queries/insert.py +124 -0
  47. oxyde/queries/joins.py +66 -0
  48. oxyde/queries/manager.py +409 -0
  49. oxyde/queries/mixins/__init__.py +68 -0
  50. oxyde/queries/mixins/aggregation.py +335 -0
  51. oxyde/queries/mixins/debug.py +154 -0
  52. oxyde/queries/mixins/execution.py +701 -0
  53. oxyde/queries/mixins/filtering.py +144 -0
  54. oxyde/queries/mixins/joining.py +160 -0
  55. oxyde/queries/mixins/mutation.py +508 -0
  56. oxyde/queries/mixins/pagination.py +145 -0
  57. oxyde/queries/q.py +250 -0
  58. oxyde/queries/raw.py +114 -0
  59. oxyde/queries/select.py +345 -0
  60. oxyde/queries/typed.py +213 -0
  61. oxyde_postgres-0.1.0.dist-info/METADATA +141 -0
  62. oxyde_postgres-0.1.0.dist-info/RECORD +66 -0
  63. oxyde_postgres-0.1.0.dist-info/WHEEL +5 -0
  64. oxyde_postgres-0.1.0.dist-info/entry_points.txt +2 -0
  65. oxyde_postgres-0.1.0.dist-info/licenses/LICENSE +21 -0
  66. oxyde_postgres-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,456 @@
1
+ """Migration executor - applies migrations to database."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from oxyde._msgpack import msgpack
9
+ from oxyde.core import (
10
+ begin_transaction,
11
+ commit_transaction,
12
+ execute_in_transaction,
13
+ rollback_transaction,
14
+ )
15
+ from oxyde.core.ir import build_raw_sql_ir
16
+ from oxyde.db.pool import _msgpack_encoder
17
+ from oxyde.db.registry import get_connection as _get_connection_async
18
+ from oxyde.migrations.context import MigrationContext
19
+ from oxyde.migrations.replay import (
20
+ SchemaState,
21
+ _get_migration_dependency,
22
+ _topological_sort_migrations,
23
+ )
24
+ from oxyde.migrations.tracker import (
25
+ ensure_migrations_table,
26
+ get_applied_migrations,
27
+ get_pending_migrations,
28
+ record_migration,
29
+ remove_migration,
30
+ )
31
+ from oxyde.migrations.utils import (
32
+ detect_dialect,
33
+ load_migration_module,
34
+ parse_query_result,
35
+ )
36
+
37
+ if TYPE_CHECKING:
38
+ from oxyde.db.pool import AsyncDatabase
39
+
40
+ # Advisory lock key for migrations (arbitrary unique number)
41
+ MIGRATION_LOCK_KEY = 0x4F587944 # "OxyD" in hex
42
+
43
+
44
+ def _check_migration_dependency(
45
+ migration_path: Path,
46
+ applied: set[str],
47
+ ) -> None:
48
+ """Check that migration's dependency is satisfied.
49
+
50
+ Args:
51
+ migration_path: Path to migration file
52
+ applied: Set of already applied migration names
53
+
54
+ Raises:
55
+ RuntimeError: If dependency is not satisfied
56
+ """
57
+ module = load_migration_module(migration_path)
58
+ if module is None:
59
+ return
60
+
61
+ dependency = _get_migration_dependency(module)
62
+ if dependency is not None and dependency not in applied:
63
+ raise RuntimeError(
64
+ f"Migration '{migration_path.stem}' depends on '{dependency}' "
65
+ f"which has not been applied yet."
66
+ )
67
+
68
+
69
+ async def _acquire_migration_lock(
70
+ db_conn: AsyncDatabase, _dialect: str
71
+ ) -> tuple[bool, int | None]:
72
+ """Acquire advisory lock on a dedicated connection.
73
+
74
+ Uses begin_transaction to pin a connection, ensuring the lock is held
75
+ on the same connection until explicitly released. This prevents the pool
76
+ from routing acquire and release to different connections (advisory locks
77
+ are session-scoped in PostgreSQL).
78
+
79
+ Args:
80
+ db_conn: Database connection
81
+ dialect: Database dialect
82
+
83
+ Returns:
84
+ Tuple of (lock_acquired, lock_tx_id). lock_tx_id must be passed
85
+ to _release_migration_lock to release on the same connection.
86
+ """
87
+
88
+ # Pin a connection via transaction so acquire and release use the same session
89
+ tx_id = await begin_transaction(db_conn.name)
90
+
91
+ try:
92
+ sql = f"SELECT pg_try_advisory_lock({MIGRATION_LOCK_KEY})"
93
+ ir = build_raw_sql_ir(sql=sql)
94
+ ir_bytes = msgpack.packb(ir, default=_msgpack_encoder)
95
+ result_bytes = await execute_in_transaction(db_conn.name, tx_id, ir_bytes)
96
+ result = parse_query_result(result_bytes)
97
+ if result and result[0].get("pg_try_advisory_lock", False):
98
+ return True, tx_id
99
+ await commit_transaction(tx_id)
100
+ return False, None
101
+
102
+ except Exception:
103
+ try:
104
+ await commit_transaction(tx_id)
105
+ except Exception:
106
+ pass
107
+ raise
108
+
109
+
110
+ async def _release_migration_lock(
111
+ db_conn: AsyncDatabase, _dialect: str, lock_tx_id: int | None
112
+ ) -> None:
113
+ """Release advisory lock and close the dedicated connection.
114
+
115
+ Args:
116
+ db_conn: Database connection
117
+ dialect: Database dialect
118
+ lock_tx_id: Transaction ID from _acquire_migration_lock
119
+ """
120
+ if lock_tx_id is None:
121
+ return
122
+
123
+ try:
124
+ sql = f"SELECT pg_advisory_unlock({MIGRATION_LOCK_KEY})"
125
+ ir = build_raw_sql_ir(sql=sql)
126
+ ir_bytes = msgpack.packb(ir, default=_msgpack_encoder)
127
+ await execute_in_transaction(db_conn.name, lock_tx_id, ir_bytes)
128
+
129
+ await commit_transaction(lock_tx_id)
130
+
131
+ except Exception:
132
+ try:
133
+ await rollback_transaction(lock_tx_id)
134
+ except Exception:
135
+ pass
136
+
137
+
138
+ def _require_migration_module(filepath: Path) -> Any:
139
+ """Load a migration module, raising on failure.
140
+
141
+ Args:
142
+ filepath: Path to migration file
143
+
144
+ Returns:
145
+ Loaded module
146
+
147
+ Raises:
148
+ ImportError: If module cannot be loaded
149
+ """
150
+ module = load_migration_module(filepath)
151
+ if module is None:
152
+ raise ImportError(f"Cannot load migration from {filepath}")
153
+ return module
154
+
155
+
156
+ def replay_migrations_up_to(
157
+ migrations_dir: str,
158
+ target_migration: Path,
159
+ include_target: bool = False,
160
+ ) -> SchemaState:
161
+ """Replay migrations up to (but not including) target to get schema state.
162
+
163
+ Args:
164
+ migrations_dir: Path to migrations directory
165
+ target_migration: Target migration path
166
+ include_target: If True, include target migration in replay
167
+
168
+ Returns:
169
+ SchemaState after replaying migrations
170
+ """
171
+ state = SchemaState()
172
+ migration_files = _topological_sort_migrations(
173
+ sorted(Path(migrations_dir).glob("[0-9]*.py"))
174
+ )
175
+
176
+ for filepath in migration_files:
177
+ # Stop before target (or at target if include_target is False)
178
+ if filepath == target_migration and not include_target:
179
+ break
180
+
181
+ module = _require_migration_module(filepath)
182
+
183
+ if hasattr(module, "upgrade"):
184
+ ctx = MigrationContext(mode="collect")
185
+ module.upgrade(ctx)
186
+
187
+ for op in ctx.get_collected_operations():
188
+ state.apply_operation(op)
189
+
190
+ # Stop after target if include_target is True
191
+ if filepath == target_migration and include_target:
192
+ break
193
+
194
+ return state
195
+
196
+
197
+ async def apply_migrations(
198
+ migrations_dir: str = "migrations",
199
+ db_alias: str = "default",
200
+ target: str | None = None,
201
+ fake: bool = False,
202
+ ) -> list[str]:
203
+ """Apply pending migrations to database.
204
+
205
+ Args:
206
+ migrations_dir: Path to migrations directory
207
+ db_alias: Database connection alias
208
+ target: Target migration name (if None, apply all pending)
209
+ fake: If True, mark as applied without executing SQL
210
+
211
+ Returns:
212
+ List of applied migration names
213
+ """
214
+ # Ensure migrations table exists
215
+ await ensure_migrations_table(db_alias)
216
+
217
+ # Get applied and pending migrations
218
+ applied = await get_applied_migrations(db_alias)
219
+ pending = get_pending_migrations(migrations_dir, applied)
220
+
221
+ if not pending:
222
+ return []
223
+
224
+ # Filter by target if specified
225
+ if target:
226
+ target_index = None
227
+ for i, migration_path in enumerate(pending):
228
+ # Match by exact name or prefix (e.g. "0001" matches "0001_create_users_table")
229
+ if migration_path.stem == target or migration_path.stem.startswith(
230
+ target + "_"
231
+ ):
232
+ target_index = i
233
+ break
234
+
235
+ if target_index is None:
236
+ raise ValueError(
237
+ f"Target migration '{target}' not found in pending migrations"
238
+ )
239
+
240
+ pending = pending[: target_index + 1]
241
+
242
+ # Get database connection and detect dialect
243
+ db_conn = await _get_connection_async(db_alias)
244
+
245
+ dialect = detect_dialect(db_conn.url)
246
+
247
+ # Acquire advisory lock to prevent concurrent migrations
248
+ lock_acquired, lock_tx_id = await _acquire_migration_lock(db_conn, dialect)
249
+ if not lock_acquired:
250
+ raise RuntimeError(
251
+ "Cannot acquire migration lock. Another migration process may be running."
252
+ )
253
+
254
+ # Apply each migration
255
+ applied_migrations = []
256
+ applied_set = set(applied) # Track applied migrations for dependency check
257
+
258
+ try:
259
+ # Track schema state as we apply migrations
260
+ schema_state = replay_migrations_up_to(
261
+ migrations_dir, pending[0], include_target=False
262
+ )
263
+
264
+ for migration_path in pending:
265
+ migration_name = migration_path.stem
266
+
267
+ # Check dependency is satisfied
268
+ _check_migration_dependency(migration_path, applied_set)
269
+
270
+ if not fake:
271
+ # Import migration module
272
+ module = _require_migration_module(migration_path)
273
+
274
+ if not hasattr(module, "upgrade"):
275
+ raise RuntimeError(
276
+ f"Migration {migration_name} missing upgrade() function"
277
+ )
278
+
279
+ # Create migration context in execute mode with schema state
280
+ ctx = MigrationContext(
281
+ mode="execute",
282
+ dialect=dialect,
283
+ db_conn=db_conn,
284
+ schema_state=schema_state,
285
+ )
286
+
287
+ # Execute upgrade (collects SQL statements)
288
+ module.upgrade(ctx)
289
+
290
+ # Execute collected SQL (wrapped in transaction for postgres)
291
+ await ctx._execute_collected_sql()
292
+
293
+ # Update schema state with this migration's operations
294
+ ctx_collect = MigrationContext(mode="collect")
295
+ module.upgrade(ctx_collect)
296
+ for op in ctx_collect.get_collected_operations():
297
+ schema_state.apply_operation(op)
298
+
299
+ # Record migration as applied
300
+ await record_migration(migration_name, db_alias)
301
+ applied_migrations.append(migration_name)
302
+ applied_set.add(migration_name) # Update for dependency checking
303
+
304
+ finally:
305
+ # Always release the lock
306
+ await _release_migration_lock(db_conn, dialect, lock_tx_id)
307
+
308
+ return applied_migrations
309
+
310
+
311
+ def _check_rollback_dependency(
312
+ migration_name: str,
313
+ migrations_dir: str,
314
+ applied: list[str],
315
+ ) -> None:
316
+ """Check that no applied migration depends on the one being rolled back.
317
+
318
+ Args:
319
+ migration_name: Name of migration to roll back
320
+ migrations_dir: Path to migrations directory
321
+ applied: List of currently applied migration names
322
+
323
+ Raises:
324
+ RuntimeError: If another applied migration depends on this one
325
+ """
326
+ migrations_path = Path(migrations_dir)
327
+ for applied_name in applied:
328
+ if applied_name == migration_name:
329
+ continue
330
+ migration_path = migrations_path / f"{applied_name}.py"
331
+ if not migration_path.exists():
332
+ continue
333
+
334
+ module = load_migration_module(migration_path)
335
+ if module is None:
336
+ continue
337
+
338
+ dependency = _get_migration_dependency(module)
339
+ if dependency == migration_name:
340
+ msg = (
341
+ f"Cannot roll back '{migration_name}': "
342
+ f"migration '{applied_name}' depends on it."
343
+ )
344
+ raise RuntimeError(msg)
345
+
346
+
347
+ async def rollback_migration(
348
+ migration_name: str,
349
+ migrations_dir: str = "migrations",
350
+ db_alias: str = "default",
351
+ fake: bool = False,
352
+ ) -> None:
353
+ """Roll back a single migration.
354
+
355
+ Args:
356
+ migration_name: Name of migration to roll back
357
+ migrations_dir: Path to migrations directory
358
+ db_alias: Database connection alias
359
+ fake: If True, remove from history without executing SQL
360
+ """
361
+ migration_path = Path(migrations_dir) / f"{migration_name}.py"
362
+ if not migration_path.exists():
363
+ raise FileNotFoundError(f"Migration file not found: {migration_path}")
364
+
365
+ # Check that no applied migration depends on this one
366
+ applied = await get_applied_migrations(db_alias)
367
+ _check_rollback_dependency(migration_name, migrations_dir, applied)
368
+
369
+ # Get database connection and detect dialect
370
+ db_conn = await _get_connection_async(db_alias)
371
+
372
+ dialect = detect_dialect(db_conn.url)
373
+
374
+ # Acquire advisory lock
375
+ lock_acquired, lock_tx_id = await _acquire_migration_lock(db_conn, dialect)
376
+ if not lock_acquired:
377
+ raise RuntimeError(
378
+ "Cannot acquire migration lock. Another migration process may be running."
379
+ )
380
+
381
+ try:
382
+ if not fake:
383
+ # Import migration module
384
+ module = _require_migration_module(migration_path)
385
+
386
+ if not hasattr(module, "downgrade"):
387
+ raise RuntimeError(
388
+ f"Migration {migration_name} missing downgrade() function"
389
+ )
390
+
391
+ # Replay migrations up to and including this one to get schema state
392
+ schema_state = replay_migrations_up_to(
393
+ migrations_dir, migration_path, include_target=True
394
+ )
395
+
396
+ # Create migration context in execute mode with schema state
397
+ ctx = MigrationContext(
398
+ mode="execute",
399
+ dialect=dialect,
400
+ db_conn=db_conn,
401
+ schema_state=schema_state,
402
+ )
403
+
404
+ # Execute downgrade (collects SQL statements)
405
+ module.downgrade(ctx)
406
+
407
+ # Execute collected SQL
408
+ await ctx._execute_collected_sql()
409
+
410
+ # Remove migration from history
411
+ await remove_migration(migration_name, db_alias)
412
+
413
+ finally:
414
+ await _release_migration_lock(db_conn, dialect, lock_tx_id)
415
+
416
+
417
+ async def rollback_migrations(
418
+ steps: int = 1,
419
+ migrations_dir: str = "migrations",
420
+ db_alias: str = "default",
421
+ fake: bool = False,
422
+ ) -> list[str]:
423
+ """Roll back last N migrations.
424
+
425
+ Args:
426
+ steps: Number of migrations to roll back
427
+ migrations_dir: Path to migrations directory
428
+ db_alias: Database connection alias
429
+ fake: If True, remove from history without executing SQL
430
+
431
+ Returns:
432
+ List of rolled back migration names
433
+ """
434
+ # Get applied migrations
435
+ applied = await get_applied_migrations(db_alias)
436
+
437
+ if not applied:
438
+ return []
439
+
440
+ # Get last N migrations to roll back
441
+ to_rollback = applied[-steps:] if steps < len(applied) else applied
442
+ to_rollback = list(reversed(to_rollback)) # Roll back in reverse order
443
+
444
+ rolled_back = []
445
+ for migration_name in to_rollback:
446
+ await rollback_migration(migration_name, migrations_dir, db_alias, fake)
447
+ rolled_back.append(migration_name)
448
+
449
+ return rolled_back
450
+
451
+
452
+ __all__ = [
453
+ "apply_migrations",
454
+ "rollback_migration",
455
+ "rollback_migrations",
456
+ ]