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,392 @@
1
+ """Transaction management with Django-style atomic() context manager.
2
+
3
+ This module provides transaction support with automatic commit/rollback
4
+ and nested transaction handling via savepoints.
5
+
6
+ Usage:
7
+ from oxyde.db import transaction
8
+
9
+ # Basic transaction
10
+ async with transaction.atomic():
11
+ await User.objects.create(name="Alice")
12
+ await Order.objects.create(user_id=1)
13
+ # Commits on exit, rolls back on exception
14
+
15
+ # Explicit rollback
16
+ async with transaction.atomic() as tx:
17
+ await User.objects.create(name="Alice")
18
+ tx.set_rollback() # Force rollback even without exception
19
+
20
+ # Nested transactions (savepoints)
21
+ async with transaction.atomic():
22
+ await User.objects.create(name="Alice")
23
+ try:
24
+ async with transaction.atomic():
25
+ await User.objects.create(name="Bob")
26
+ raise ValueError("oops")
27
+ except ValueError:
28
+ pass # Bob rolled back to savepoint, Alice still pending
29
+ # Alice commits
30
+
31
+ Classes:
32
+ AsyncTransaction:
33
+ Low-level transaction wrapper. Holds tx_id from Rust.
34
+ execute() sends queries through the transaction.
35
+
36
+ AtomicTransactionContext:
37
+ High-level context manager for atomic(). Manages nesting
38
+ via savepoints. Tracks depth per connection alias.
39
+
40
+ TransactionTimeoutError:
41
+ Raised when transaction exceeds configured timeout.
42
+
43
+ Functions:
44
+ atomic(using="default", timeout=None):
45
+ Create atomic context manager. Django-compatible API.
46
+
47
+ get_active_transaction(using="default"):
48
+ Get current transaction for connection (or None).
49
+ Used by QueryManager to route queries through active tx.
50
+
51
+ get_transaction_depth(using="default"):
52
+ Get atomic() depth for the connection alias and current task.
53
+ Returns 0 outside atomic(), 1 in the outermost block, and 2+ when nested.
54
+
55
+ ContextVar Storage:
56
+ _ACTIVE_TRANSACTIONS stores {alias: {transaction, depth, force_rollback, task}}
57
+ per async context. This allows concurrent requests to have separate
58
+ transaction state. The 'task' field tracks ownership via asyncio.current_task()
59
+ to prevent child tasks from inheriting parent transactions through ContextVar.
60
+
61
+ Savepoint Handling:
62
+ Nested atomic() calls create savepoints (sp_1, sp_2, ...).
63
+ Inner exception rolls back to savepoint, not entire transaction.
64
+ This matches Django/PostgreSQL behavior.
65
+
66
+ Rust Integration:
67
+ _begin_transaction(pool_name) → tx_id
68
+ _execute_in_transaction(pool_name, tx_id, ir_bytes) → bytes
69
+ _commit_transaction(tx_id)
70
+ _rollback_transaction(tx_id)
71
+ _create_savepoint(tx_id, name)
72
+ _rollback_to_savepoint(tx_id, name)
73
+ _release_savepoint(tx_id, name)
74
+ """
75
+
76
+ from __future__ import annotations
77
+
78
+ import asyncio
79
+ from contextvars import ContextVar
80
+ from datetime import timedelta
81
+ from typing import TYPE_CHECKING, Any
82
+
83
+ from oxyde._msgpack import msgpack
84
+ from oxyde.db.pool import _msgpack_encoder, _normalize_duration
85
+ from oxyde.db.registry import get_connection
86
+
87
+ if TYPE_CHECKING:
88
+ from oxyde.db.pool import AsyncDatabase
89
+
90
+ try:
91
+ from oxyde.core import begin_transaction as _begin_transaction
92
+ from oxyde.core import commit_transaction as _commit_transaction
93
+ from oxyde.core import create_savepoint as _create_savepoint
94
+ from oxyde.core import execute_in_transaction as _execute_in_transaction
95
+ from oxyde.core import release_savepoint as _release_savepoint
96
+ from oxyde.core import rollback_to_savepoint as _rollback_to_savepoint
97
+ from oxyde.core import rollback_transaction as _rollback_transaction
98
+ except ImportError:
99
+ # Stub for when the Rust module is not built
100
+ async def _execute_in_transaction(
101
+ pool_name: str, tx_id: int, ir_bytes: bytes
102
+ ) -> bytes:
103
+ raise RuntimeError(
104
+ "Rust core module not available. Please install oxyde-postgres-core."
105
+ )
106
+
107
+ async def _begin_transaction(pool_name: str) -> int:
108
+ raise RuntimeError(
109
+ "Rust core module not available. Please install oxyde-postgres-core."
110
+ )
111
+
112
+ async def _commit_transaction(tx_id: int) -> None:
113
+ raise RuntimeError(
114
+ "Rust core module not available. Please install oxyde-postgres-core."
115
+ )
116
+
117
+ async def _rollback_transaction(tx_id: int) -> None:
118
+ raise RuntimeError(
119
+ "Rust core module not available. Please install oxyde-postgres-core."
120
+ )
121
+
122
+ async def _create_savepoint(tx_id: int, savepoint_name: str) -> None:
123
+ raise RuntimeError(
124
+ "Rust core module not available. Please install oxyde-postgres-core."
125
+ )
126
+
127
+ async def _rollback_to_savepoint(tx_id: int, savepoint_name: str) -> None:
128
+ raise RuntimeError(
129
+ "Rust core module not available. Please install oxyde-postgres-core."
130
+ )
131
+
132
+ async def _release_savepoint(tx_id: int, savepoint_name: str) -> None:
133
+ raise RuntimeError(
134
+ "Rust core module not available. Please install oxyde-postgres-core."
135
+ )
136
+
137
+
138
+ # Active transactions per connection (ContextVar for async safety)
139
+ _ACTIVE_TRANSACTIONS: ContextVar[dict[str, dict[str, Any]]] = ContextVar(
140
+ "oxyde_active_transactions",
141
+ default={},
142
+ )
143
+
144
+
145
+ def _get_owned_entry(using: str) -> dict[str, Any] | None:
146
+ """Return the transaction entry only if it belongs to the current task."""
147
+ entry = _ACTIVE_TRANSACTIONS.get().get(using)
148
+ if not entry:
149
+ return None
150
+ try:
151
+ task = asyncio.current_task()
152
+ except RuntimeError:
153
+ # No running event loop (e.g. a worker thread with a copied context)
154
+ return None
155
+ if entry.get("task") is task:
156
+ return entry
157
+ return None
158
+
159
+
160
+ class TransactionTimeoutError(TimeoutError):
161
+ """Raised when a transaction exceeds the configured timeout."""
162
+
163
+
164
+ class AsyncTransaction:
165
+ """Wrapper for executing queries within a database transaction."""
166
+
167
+ def __init__(self, database: AsyncDatabase, timeout: float | None = None):
168
+ self._database = database
169
+ self._tx_id: int | None = None
170
+ self._timeout = timeout
171
+ self._deadline: float | None = None
172
+ self._timed_out = False
173
+
174
+ @property
175
+ def id(self) -> int:
176
+ if self._tx_id is None:
177
+ raise RuntimeError("Transaction not started")
178
+ return self._tx_id
179
+
180
+ def _remaining_timeout(self) -> float | None:
181
+ if self._deadline is None:
182
+ return None
183
+ loop = asyncio.get_running_loop()
184
+ return self._deadline - loop.time()
185
+
186
+ async def execute(self, ir: dict[str, Any]) -> bytes:
187
+ if self._tx_id is None:
188
+ raise RuntimeError("Transaction not started")
189
+ if self._timed_out:
190
+ raise TransactionTimeoutError(
191
+ "Transaction can no longer be used because its timeout elapsed"
192
+ )
193
+
194
+ ir_bytes = msgpack.packb(ir, default=_msgpack_encoder)
195
+ coro = _execute_in_transaction(self._database.name, self._tx_id, ir_bytes)
196
+
197
+ remaining = self._remaining_timeout()
198
+ if remaining is not None:
199
+ if remaining <= 0:
200
+ self._timed_out = True
201
+ msg = f"Transaction {self._tx_id} exceeded timeout"
202
+ raise TransactionTimeoutError(msg)
203
+ try:
204
+ return await asyncio.wait_for(coro, remaining)
205
+ except asyncio.TimeoutError as exc:
206
+ self._timed_out = True
207
+ msg = f"Transaction {self._tx_id} exceeded timeout"
208
+ raise TransactionTimeoutError(msg) from exc
209
+
210
+ result: bytes = await coro
211
+ return result
212
+
213
+ async def __aenter__(self) -> AsyncTransaction:
214
+ await self._database.ensure_connected()
215
+ self._tx_id = await _begin_transaction(self._database.name)
216
+ if self._timeout is not None:
217
+ loop = asyncio.get_running_loop()
218
+ self._deadline = loop.time() + self._timeout
219
+ return self
220
+
221
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
222
+ tx_id = self._tx_id
223
+ self._tx_id = None
224
+ self._deadline = None
225
+
226
+ if tx_id is None:
227
+ return
228
+
229
+ try:
230
+ if self._timed_out or exc_type is not None:
231
+ await _rollback_transaction(tx_id)
232
+ else:
233
+ await _commit_transaction(tx_id)
234
+ finally:
235
+ self._timed_out = False
236
+
237
+
238
+ class AtomicTransactionContext:
239
+ """Context manager that reuses or creates transactions per connection alias."""
240
+
241
+ def __init__(
242
+ self,
243
+ using: str = "default",
244
+ timeout: float | None = None,
245
+ database: AsyncDatabase | None = None,
246
+ ):
247
+ self.using = using
248
+ self.timeout = timeout
249
+ self._database = database
250
+ self._transaction: AsyncTransaction | None = None
251
+ self._savepoint_name: str | None = None
252
+
253
+ async def __aenter__(self) -> AtomicTransactionContext:
254
+ state = dict(_ACTIVE_TRANSACTIONS.get())
255
+ entry = _get_owned_entry(self.using)
256
+ if entry:
257
+ # Nested transaction - create savepoint
258
+ depth = entry["depth"]
259
+ self._transaction = entry["transaction"]
260
+
261
+ # Create savepoint BEFORE incrementing depth —
262
+ # if this fails, __aexit__ won't be called and depth must stay unchanged
263
+ self._savepoint_name = f"sp_{depth + 1}"
264
+ await _create_savepoint(self._transaction.id, self._savepoint_name)
265
+
266
+ entry["depth"] += 1
267
+ state[self.using] = entry
268
+ _ACTIVE_TRANSACTIONS.set(state)
269
+
270
+ return self
271
+
272
+ # Use provided database instance or get from registry
273
+ database = (
274
+ self._database
275
+ if self._database is not None
276
+ else await get_connection(self.using)
277
+ )
278
+ tx = AsyncTransaction(database, timeout=self.timeout)
279
+ await tx.__aenter__()
280
+ state[self.using] = {
281
+ "transaction": tx,
282
+ "depth": 1,
283
+ "force_rollback": False,
284
+ "task": asyncio.current_task(),
285
+ }
286
+ _ACTIVE_TRANSACTIONS.set(state)
287
+ self._transaction = tx
288
+ return self
289
+
290
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
291
+ state = dict(_ACTIVE_TRANSACTIONS.get())
292
+ entry = _get_owned_entry(self.using)
293
+ if entry is None:
294
+ raise RuntimeError("transaction.atomic context mismatch")
295
+
296
+ entry["depth"] -= 1
297
+ force_rollback = entry.get("force_rollback", False)
298
+
299
+ if entry["depth"] == 0:
300
+ # Top-level transaction - commit or rollback entire transaction
301
+ state.pop(self.using, None)
302
+ _ACTIVE_TRANSACTIONS.set(state)
303
+ tx = entry["transaction"]
304
+ if exc_type is None and force_rollback:
305
+ exc_val = RuntimeError("transaction marked for rollback")
306
+ exc_type = RuntimeError
307
+ await tx.__aexit__(exc_type, exc_val, exc_tb)
308
+ else:
309
+ # Nested transaction - handle savepoint
310
+ if self._savepoint_name:
311
+ if exc_type:
312
+ # Rollback to savepoint - this is the end of this nested transaction
313
+ # Don't mark outer transaction for rollback, savepoint handles it
314
+ await _rollback_to_savepoint(
315
+ entry["transaction"].id, self._savepoint_name
316
+ )
317
+ else:
318
+ # Release savepoint - nested transaction succeeded
319
+ await _release_savepoint(
320
+ entry["transaction"].id, self._savepoint_name
321
+ )
322
+ # Don't modify force_rollback - savepoint already handled the rollback
323
+ else:
324
+ # No savepoint (shouldn't happen with correct nesting)
325
+ if exc_type:
326
+ entry["force_rollback"] = True
327
+
328
+ state[self.using] = entry
329
+ _ACTIVE_TRANSACTIONS.set(state)
330
+
331
+ def set_rollback(self, rollback: bool = True) -> None:
332
+ entry = _get_owned_entry(self.using)
333
+ if entry is None:
334
+ raise RuntimeError("No active transaction to mark for rollback")
335
+ state = dict(_ACTIVE_TRANSACTIONS.get())
336
+ entry["force_rollback"] = bool(rollback)
337
+ state[self.using] = entry
338
+ _ACTIVE_TRANSACTIONS.set(state)
339
+
340
+ @property
341
+ def transaction(self) -> AsyncTransaction:
342
+ if self._transaction is None:
343
+ raise RuntimeError("transaction.atomic context not entered")
344
+ return self._transaction
345
+
346
+ async def execute(self, ir: dict[str, Any]) -> bytes:
347
+ return await self.transaction.execute(ir)
348
+
349
+ def __getattr__(self, item: str) -> Any:
350
+ return getattr(self.transaction, item)
351
+
352
+
353
+ def atomic(
354
+ *,
355
+ using: str = "default",
356
+ timeout: float | int | timedelta | None = None,
357
+ ) -> AtomicTransactionContext:
358
+ """Create an atomic transaction context."""
359
+ normalized = _normalize_duration(timeout)
360
+ return AtomicTransactionContext(using=using, timeout=normalized)
361
+
362
+
363
+ def get_active_transaction(using: str = "default") -> AsyncTransaction | None:
364
+ """Get the currently active transaction for a connection alias."""
365
+ entry = _get_owned_entry(using)
366
+ if entry:
367
+ tx: AsyncTransaction = entry["transaction"]
368
+ return tx
369
+ return None
370
+
371
+
372
+ def get_transaction_depth(using: str = "default") -> int:
373
+ """Return the atomic() depth for a connection alias in the calling task.
374
+
375
+ Returns 0 when no atomic() block is active, 1 in the outermost block,
376
+ and 2+ in nested blocks. Transactions owned by other tasks are excluded.
377
+ """
378
+ entry = _get_owned_entry(using)
379
+ if entry:
380
+ depth: int = entry["depth"]
381
+ return depth
382
+ return 0
383
+
384
+
385
+ __all__ = [
386
+ "AsyncTransaction",
387
+ "TransactionTimeoutError",
388
+ "AtomicTransactionContext",
389
+ "atomic",
390
+ "get_active_transaction",
391
+ "get_transaction_depth",
392
+ ]
oxyde/exceptions.py ADDED
@@ -0,0 +1,89 @@
1
+ """Exception hierarchy for Oxyde ORM.
2
+
3
+ All Oxyde exceptions inherit from OxydeError, allowing catch-all handling:
4
+
5
+ try:
6
+ user = await User.objects.get(id=999)
7
+ except OxydeError as e:
8
+ print(f"ORM error: {e}")
9
+
10
+ Exception hierarchy:
11
+ OxydeError (base)
12
+ ├── FieldError - Invalid field definition or access
13
+ ├── FieldLookupError - Unknown lookup operator (e.g., __xyz)
14
+ │ └── FieldLookupValueError - Invalid value for lookup (e.g., __in=None)
15
+ └── ManagerError - Query execution errors
16
+ ├── NotFoundError - get() returned no rows
17
+ ├── MultipleObjectsReturned - get() returned multiple rows
18
+ └── IntegrityError - Constraint violation
19
+ ├── UniqueViolationError - UNIQUE / PK constraint
20
+ ├── ForeignKeyViolationError - FOREIGN KEY constraint
21
+ ├── NotNullViolationError - NOT NULL constraint
22
+ └── CheckViolationError - CHECK constraint
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+
28
+ class OxydeError(Exception):
29
+ """Base exception for all Oxyde-related errors."""
30
+
31
+
32
+ class FieldError(OxydeError):
33
+ """Raised when a model field is invalid or missing."""
34
+
35
+
36
+ class FieldLookupError(OxydeError):
37
+ """Raised when an unsupported field lookup is requested."""
38
+
39
+
40
+ class FieldLookupValueError(FieldLookupError):
41
+ """Raised when the lookup value is not compatible with the operator."""
42
+
43
+
44
+ class ManagerError(OxydeError):
45
+ """Raised for issues inside the ORM manager layer."""
46
+
47
+
48
+ class NotFoundError(ManagerError):
49
+ """Raised when a query expecting a single row finds none."""
50
+
51
+
52
+ class MultipleObjectsReturned(ManagerError):
53
+ """Raised when a query expecting a single row finds more than one."""
54
+
55
+
56
+ class IntegrityError(ManagerError):
57
+ """Raised when database integrity constraints are violated."""
58
+
59
+
60
+ class UniqueViolationError(IntegrityError):
61
+ """Raised when a UNIQUE (or PRIMARY KEY) constraint is violated."""
62
+
63
+
64
+ class ForeignKeyViolationError(IntegrityError):
65
+ """Raised when a FOREIGN KEY constraint is violated."""
66
+
67
+
68
+ class NotNullViolationError(IntegrityError):
69
+ """Raised when a NOT NULL constraint is violated."""
70
+
71
+
72
+ class CheckViolationError(IntegrityError):
73
+ """Raised when a CHECK constraint is violated."""
74
+
75
+
76
+ __all__ = [
77
+ "OxydeError",
78
+ "FieldError",
79
+ "FieldLookupError",
80
+ "FieldLookupValueError",
81
+ "ManagerError",
82
+ "NotFoundError",
83
+ "MultipleObjectsReturned",
84
+ "IntegrityError",
85
+ "UniqueViolationError",
86
+ "ForeignKeyViolationError",
87
+ "NotNullViolationError",
88
+ "CheckViolationError",
89
+ ]
@@ -0,0 +1,49 @@
1
+ """Oxyde migrations system.
2
+
3
+ This module provides database migration functionality:
4
+ - Schema extraction from models
5
+ - Migration replay in memory
6
+ - MigrationContext for upgrade/downgrade operations
7
+ - Migration file generation
8
+ - Migration tracking and execution
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from oxyde.migrations.context import MigrationContext
14
+ from oxyde.migrations.executor import (
15
+ apply_migrations,
16
+ rollback_migration,
17
+ rollback_migrations,
18
+ )
19
+ from oxyde.migrations.extract import extract_current_schema
20
+ from oxyde.migrations.generator import generate_migration_file
21
+ from oxyde.migrations.replay import SchemaState, replay_migrations
22
+ from oxyde.migrations.squash import SquashResult, squash_migrations
23
+ from oxyde.migrations.tracker import (
24
+ ensure_migrations_table,
25
+ get_applied_migrations,
26
+ get_migration_files,
27
+ get_pending_migrations,
28
+ record_migration,
29
+ remove_migration,
30
+ )
31
+
32
+ __all__ = [
33
+ "SquashResult",
34
+ "squash_migrations",
35
+ "extract_current_schema",
36
+ "SchemaState",
37
+ "replay_migrations",
38
+ "MigrationContext",
39
+ "generate_migration_file",
40
+ "apply_migrations",
41
+ "rollback_migration",
42
+ "rollback_migrations",
43
+ "ensure_migrations_table",
44
+ "get_applied_migrations",
45
+ "get_migration_files",
46
+ "get_pending_migrations",
47
+ "record_migration",
48
+ "remove_migration",
49
+ ]