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
oxyde/db/__init__.py ADDED
@@ -0,0 +1,230 @@
1
+ """Database connection management and transaction support.
2
+
3
+ This module provides the primary interface for database connectivity in Oxyde.
4
+ It wraps the Rust connection pool and provides Python-friendly APIs.
5
+
6
+ High-Level Functions:
7
+ init(**databases, settings=None):
8
+ Initialize one or more database connections at startup.
9
+ Example: await db.init(default="postgresql://...")
10
+
11
+ close():
12
+ Disconnect all database connections.
13
+ Example: await db.close()
14
+
15
+ connect(url, name="default"):
16
+ Context manager for single database connection.
17
+ Example: async with db.connect("postgresql://localhost/test"): ...
18
+
19
+ lifespan(**databases):
20
+ FastAPI/Starlette lifespan context manager.
21
+ Example: app = FastAPI(lifespan=db.lifespan(default="..."))
22
+
23
+ Classes:
24
+ AsyncDatabase: Connection pool wrapper with execute() method.
25
+ AsyncTransaction: Transaction wrapper with execute() method.
26
+ PoolSettings: Configuration for connection pool and PostgreSQL settings.
27
+
28
+ Transaction API (Django-style):
29
+ from oxyde.db import transaction
30
+
31
+ async with transaction.atomic():
32
+ await User.objects.create(name="Alice")
33
+ # Auto-commits on success, rolls back on exception
34
+
35
+ Nested transactions use savepoints:
36
+ async with transaction.atomic():
37
+ await User.objects.create(name="Alice")
38
+ async with transaction.atomic():
39
+ await User.objects.create(name="Bob")
40
+ # Inner failure rolls back to savepoint, not entire transaction
41
+
42
+ Registry Functions:
43
+ register_connection(): Add connection to global registry.
44
+ get_connection(): Get connection by name (default: "default").
45
+ disconnect_all(): Close all connections.
46
+
47
+ Connection Resolution:
48
+ Queries automatically use the active transaction if inside atomic(),
49
+ otherwise they use the connection from the registry.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ from collections.abc import AsyncIterator, Callable
55
+ from contextlib import AbstractAsyncContextManager, asynccontextmanager
56
+ from typing import Any
57
+
58
+ from . import transaction
59
+ from .pool import AsyncDatabase, PoolSettings
60
+ from .registry import (
61
+ disconnect_all,
62
+ get_connection,
63
+ register_connection,
64
+ unregister_connection,
65
+ )
66
+ from .schema import create_tables, drop_tables
67
+ from .transaction import (
68
+ AsyncTransaction,
69
+ TransactionTimeoutError,
70
+ atomic,
71
+ get_active_transaction,
72
+ get_transaction_depth,
73
+ )
74
+
75
+
76
+ async def init(
77
+ *,
78
+ settings: PoolSettings | None = None,
79
+ **databases: str,
80
+ ) -> None:
81
+ """
82
+ Initialize database connections.
83
+
84
+ Args:
85
+ settings: Default pool settings for all connections.
86
+ **databases: Mapping of name -> URL (e.g., default="postgresql://...")
87
+
88
+ Raises:
89
+ ValueError: If no databases provided.
90
+ Exception: If any database fails to connect (all connections are rolled back).
91
+
92
+ Example:
93
+ await db.init(
94
+ default="postgresql://user:pass@localhost/mydb",
95
+ analytics="postgresql://user:pass@localhost/analytics",
96
+ settings=PoolSettings(max_connections=20),
97
+ )
98
+ """
99
+ if not databases:
100
+ raise ValueError(
101
+ "At least one database URL required (e.g., default='postgresql://...')"
102
+ )
103
+
104
+ connected: list[AsyncDatabase] = []
105
+ try:
106
+ for name, url in databases.items():
107
+ database = AsyncDatabase(
108
+ url,
109
+ name=name,
110
+ settings=settings,
111
+ auto_register=True,
112
+ overwrite=True,
113
+ )
114
+ await database.connect()
115
+ connected.append(database)
116
+ except Exception:
117
+ # Rollback all successfully connected databases
118
+ for db in connected:
119
+ try:
120
+ await db.disconnect()
121
+ except Exception:
122
+ pass # Ignore errors during cleanup
123
+ raise
124
+
125
+
126
+ async def close() -> None:
127
+ """
128
+ Close all database connections.
129
+
130
+ Example:
131
+ await db.close()
132
+ """
133
+ await disconnect_all()
134
+
135
+
136
+ @asynccontextmanager
137
+ async def connect(
138
+ url: str,
139
+ *,
140
+ name: str = "default",
141
+ settings: PoolSettings | None = None,
142
+ ) -> AsyncIterator[AsyncDatabase]:
143
+ """
144
+ Context manager for a single database connection.
145
+
146
+ Useful for scripts and tests where you want automatic cleanup.
147
+
148
+ Args:
149
+ url: Database connection URL.
150
+ name: Connection name (default: "default").
151
+ settings: Optional pool settings.
152
+
153
+ Example:
154
+ async with db.connect("postgresql://localhost/test") as conn:
155
+ users = await User.objects.all()
156
+ """
157
+ database = AsyncDatabase(
158
+ url,
159
+ name=name,
160
+ settings=settings,
161
+ auto_register=True,
162
+ overwrite=True,
163
+ )
164
+ try:
165
+ await database.connect()
166
+ yield database
167
+ finally:
168
+ await database.disconnect()
169
+ unregister_connection(name)
170
+
171
+
172
+ def lifespan(
173
+ *,
174
+ settings: PoolSettings | None = None,
175
+ **databases: str,
176
+ ) -> Callable[[Any], AbstractAsyncContextManager[None]]:
177
+ """
178
+ Create a lifespan context manager for FastAPI/Starlette.
179
+
180
+ Args:
181
+ settings: Default pool settings for all connections.
182
+ **databases: Mapping of name -> URL.
183
+
184
+ Returns:
185
+ Async context manager compatible with FastAPI's lifespan parameter.
186
+
187
+ Example:
188
+ from fastapi import FastAPI
189
+ from oxyde import db
190
+
191
+ app = FastAPI(lifespan=db.lifespan(
192
+ default="postgresql://user:pass@localhost/mydb",
193
+ ))
194
+ """
195
+
196
+ @asynccontextmanager
197
+ async def _lifespan(app: Any) -> AsyncIterator[None]:
198
+ await init(settings=settings, **databases)
199
+ try:
200
+ yield
201
+ finally:
202
+ await close()
203
+
204
+ return _lifespan
205
+
206
+
207
+ __all__ = [
208
+ # High-level API
209
+ "init",
210
+ "close",
211
+ "connect",
212
+ "lifespan",
213
+ "get_connection",
214
+ # Classes
215
+ "AsyncDatabase",
216
+ "AsyncTransaction",
217
+ "PoolSettings",
218
+ "TransactionTimeoutError",
219
+ # Transactions (Django-style: transaction.atomic())
220
+ "transaction",
221
+ "atomic",
222
+ "get_active_transaction",
223
+ "get_transaction_depth",
224
+ # Schema management
225
+ "create_tables",
226
+ "drop_tables",
227
+ # Low-level (for advanced use)
228
+ "register_connection",
229
+ "disconnect_all",
230
+ ]
oxyde/db/pool.py ADDED
@@ -0,0 +1,305 @@
1
+ """Connection pool wrapper and configuration.
2
+
3
+ This module provides AsyncDatabase - the Python wrapper around the Rust
4
+ connection pool managed by sqlx. It implements the SupportsExecute protocol.
5
+
6
+ AsyncDatabase:
7
+ Wraps a Rust connection pool identified by name. The actual pool lives
8
+ in Rust; this class provides Python async interface.
9
+
10
+ Methods:
11
+ connect(): Initialize Rust pool with URL and settings.
12
+ disconnect(): Close Rust pool.
13
+ execute(ir): Send query IR to Rust, get MessagePack bytes back.
14
+
15
+ Usage:
16
+ db = AsyncDatabase("postgresql://...", name="default")
17
+ await db.connect()
18
+ result_bytes = await db.execute({"type": "select", ...})
19
+ await db.disconnect()
20
+
21
+ PoolSettings:
22
+ Configuration dataclass for pool tuning.
23
+
24
+ PostgreSQL pool settings:
25
+ max_connections: Maximum pool size (default: auto)
26
+ min_connections: Minimum idle connections
27
+ acquire_timeout: Max wait time for connection
28
+ idle_timeout: Close idle connections after
29
+ max_lifetime: Max connection age
30
+ test_before_acquire: Ping before using connection
31
+
32
+ Transaction settings:
33
+ transaction_timeout: Max transaction duration (default: 5 min)
34
+ transaction_cleanup_interval: Cleanup check interval (default: 1 min)
35
+
36
+ Rust Integration:
37
+ _init_pool(): Create pool in Rust registry
38
+ _execute(): Send IR bytes to Rust, execute via sqlx
39
+ close_pool(): Close specific pool
40
+ close_all_pools(): Close all pools (used by disconnect_all())
41
+
42
+ URL Schemes:
43
+ postgres://, postgresql:// → PostgreSQL
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import asyncio
49
+ from dataclasses import dataclass
50
+ from datetime import timedelta
51
+ from typing import Any
52
+
53
+ from oxyde._msgpack import msgpack
54
+ from oxyde.core.types import serialize_value
55
+ from oxyde.db.registry import register_connection
56
+
57
+ try:
58
+ from oxyde.core import close_all_pools, close_pool
59
+ from oxyde.core import execute as _execute
60
+ from oxyde.core import init_pool as _init_pool
61
+ from oxyde.core import init_pool_overwrite as _init_pool_overwrite
62
+ from oxyde.core import pool_backend as _pool_backend
63
+ except ImportError:
64
+ # Stub for when the Rust module is not built
65
+ async def _execute(pool_name: str, ir_bytes: bytes) -> bytes:
66
+ raise RuntimeError(
67
+ "Rust core module not available. Please install oxyde-postgres-core."
68
+ )
69
+
70
+ async def _init_pool(name: str, url: str, settings: dict[str, Any] | None) -> None:
71
+ raise RuntimeError(
72
+ "Rust core module not available. Please install oxyde-postgres-core."
73
+ )
74
+
75
+ async def _init_pool_overwrite(
76
+ name: str, url: str, settings: dict[str, Any] | None
77
+ ) -> None:
78
+ raise RuntimeError(
79
+ "Rust core module not available. Please install oxyde-postgres-core."
80
+ )
81
+
82
+ async def close_pool(name: str) -> None:
83
+ raise RuntimeError(
84
+ "Rust core module not available. Please install oxyde-postgres-core."
85
+ )
86
+
87
+ async def close_all_pools() -> None:
88
+ raise RuntimeError(
89
+ "Rust core module not available. Please install oxyde-postgres-core."
90
+ )
91
+
92
+ async def _pool_backend(pool_name: str) -> str:
93
+ raise RuntimeError(
94
+ "Rust core module not available. Please install oxyde-postgres-core."
95
+ )
96
+
97
+
98
+ def _msgpack_encoder(obj: Any) -> Any:
99
+ """Encode non-native types for msgpack via TYPE_REGISTRY."""
100
+ return serialize_value(obj)
101
+
102
+
103
+ def _normalize_duration(value: float | int | timedelta | None) -> float | None:
104
+ """Convert duration to float seconds."""
105
+ if value is None:
106
+ return None
107
+ if isinstance(value, timedelta):
108
+ result = value.total_seconds()
109
+ elif isinstance(value, (int, float)):
110
+ result = float(value)
111
+ else:
112
+ raise TypeError(
113
+ f"Duration value must be int, float, or timedelta, got {type(value).__name__}"
114
+ )
115
+ if result < 0:
116
+ raise ValueError(f"Duration must be non-negative, got {result}")
117
+ return result
118
+
119
+
120
+ def _validate_url_scheme(url: str) -> None:
121
+ """Accept PostgreSQL connection URL schemes only."""
122
+ if not url.startswith(("postgres://", "postgresql://")):
123
+ raise ValueError(
124
+ "PostgreSQL 18 or newer is required; use postgres:// or postgresql://."
125
+ )
126
+
127
+
128
+ @dataclass
129
+ class PoolSettings:
130
+ """Convenience container for pool configuration."""
131
+
132
+ max_connections: int | None = None
133
+ min_connections: int | None = None
134
+ acquire_timeout: float | int | timedelta | None = None
135
+ idle_timeout: float | int | timedelta | None = None
136
+ max_lifetime: float | int | timedelta | None = None
137
+ test_before_acquire: bool | None = None
138
+
139
+ # Transaction cleanup settings
140
+ transaction_timeout: float | int | timedelta | None = (
141
+ 300 # 5 minutes (max age before cleanup)
142
+ )
143
+ transaction_cleanup_interval: float | int | timedelta | None = (
144
+ 60 # 1 minute (cleanup interval)
145
+ )
146
+
147
+ # TLS settings (PostgreSQL)
148
+ # ssl_mode controls both TLS requirement and certificate verification:
149
+ # PG: "disable", "allow", "prefer", "require", "verify-ca", "verify-full"
150
+ ssl_mode: str | None = None
151
+ ssl_root_cert: str | None = None # Path to CA certificate
152
+ ssl_client_cert: str | None = None # Path to client certificate (mTLS)
153
+ ssl_client_key: str | None = None # Path to client private key (mTLS)
154
+
155
+ # PostgreSQL-specific
156
+ pg_application_name: str | None = None # Visible in pg_stat_activity
157
+ pg_statement_cache_capacity: int | None = None # Prepared statement cache size
158
+
159
+ def to_payload(self) -> dict[str, Any] | None:
160
+ payload: dict[str, Any] = {}
161
+
162
+ if self.max_connections is not None:
163
+ payload["max_connections"] = int(self.max_connections)
164
+ if self.min_connections is not None:
165
+ payload["min_connections"] = int(self.min_connections)
166
+ if (value := _normalize_duration(self.idle_timeout)) is not None:
167
+ payload["idle_timeout"] = value
168
+ if (value := _normalize_duration(self.acquire_timeout)) is not None:
169
+ payload["acquire_timeout"] = value
170
+ if (value := _normalize_duration(self.max_lifetime)) is not None:
171
+ payload["max_lifetime"] = value
172
+ if self.test_before_acquire is not None:
173
+ payload["test_before_acquire"] = bool(self.test_before_acquire)
174
+
175
+ # Add transaction cleanup settings
176
+ if (value := _normalize_duration(self.transaction_timeout)) is not None:
177
+ payload["transaction_timeout"] = value
178
+ if (
179
+ value := _normalize_duration(self.transaction_cleanup_interval)
180
+ ) is not None:
181
+ payload["transaction_cleanup_interval"] = value
182
+
183
+ # TLS settings
184
+ if self.ssl_mode is not None:
185
+ payload["ssl_mode"] = str(self.ssl_mode)
186
+ if self.ssl_root_cert is not None:
187
+ payload["ssl_root_cert"] = str(self.ssl_root_cert)
188
+ if self.ssl_client_cert is not None:
189
+ payload["ssl_client_cert"] = str(self.ssl_client_cert)
190
+ if self.ssl_client_key is not None:
191
+ payload["ssl_client_key"] = str(self.ssl_client_key)
192
+
193
+ # PostgreSQL-specific
194
+ if self.pg_application_name is not None:
195
+ payload["pg_application_name"] = str(self.pg_application_name)
196
+ if self.pg_statement_cache_capacity is not None:
197
+ payload["pg_statement_cache_capacity"] = int(
198
+ self.pg_statement_cache_capacity
199
+ )
200
+
201
+ return payload or None
202
+
203
+
204
+ class AsyncDatabase:
205
+ """Async database connection manager."""
206
+
207
+ def __init__(
208
+ self,
209
+ url: str,
210
+ *,
211
+ name: str = "default",
212
+ settings: PoolSettings | None = None,
213
+ auto_register: bool = True,
214
+ overwrite: bool = False,
215
+ ):
216
+ """
217
+ Initialize database connection wrapper.
218
+
219
+ Args:
220
+ url: Database connection URL (e.g., "postgresql://user:pass@host/db").
221
+ name: Identifier of the pool (used to look it up in Rust registry).
222
+ settings: Optional pool configuration.
223
+ auto_register: Automatically store this instance for later retrieval.
224
+ overwrite: Replace previously registered connection with the same name.
225
+ """
226
+ _validate_url_scheme(url)
227
+
228
+ self.url = url
229
+ self.name = name
230
+ self.settings = settings or PoolSettings()
231
+ self.backend: str | None = None
232
+ self._connected = False
233
+ self._connect_lock = asyncio.Lock()
234
+ self._overwrite = overwrite
235
+
236
+ if auto_register:
237
+ register_connection(self, overwrite=overwrite)
238
+
239
+ @property
240
+ def connected(self) -> bool:
241
+ """Return True if the connection pool has been initialised."""
242
+ return self._connected
243
+
244
+ async def connect(self) -> None:
245
+ """Establish database connection pool."""
246
+ async with self._connect_lock:
247
+ if self._connected:
248
+ return
249
+
250
+ payload = self.settings.to_payload()
251
+ if self._overwrite:
252
+ await _init_pool_overwrite(self.name, self.url, payload)
253
+ else:
254
+ await _init_pool(self.name, self.url, payload)
255
+ self.backend = await _pool_backend(self.name)
256
+ self._connected = True
257
+
258
+ async def disconnect(self) -> None:
259
+ """Close database connection pool."""
260
+ async with self._connect_lock:
261
+ if not self._connected:
262
+ return
263
+
264
+ await close_pool(self.name)
265
+ self._connected = False
266
+
267
+ async def ensure_connected(self) -> None:
268
+ """Connect on demand if not connected yet."""
269
+ if not self._connected:
270
+ await self.connect()
271
+
272
+ async def execute(self, ir: dict[str, Any]) -> bytes:
273
+ """
274
+ Execute a query using the IR format against this database.
275
+
276
+ Args:
277
+ ir: Query intermediate representation.
278
+
279
+ Returns:
280
+ MessagePack bytes containing query results.
281
+ """
282
+ if not self._connected:
283
+ raise RuntimeError(
284
+ f"Database '{self.name}' not connected. Call connect() first."
285
+ )
286
+
287
+ ir_bytes = msgpack.packb(ir, default=_msgpack_encoder)
288
+ result_bytes: bytes = await _execute(self.name, ir_bytes)
289
+ return result_bytes
290
+
291
+ async def __aenter__(self) -> AsyncDatabase:
292
+ """Context manager entry."""
293
+ await self.connect()
294
+ return self
295
+
296
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
297
+ """Context manager exit."""
298
+ await self.disconnect()
299
+
300
+
301
+ __all__ = [
302
+ "AsyncDatabase",
303
+ "PoolSettings",
304
+ "close_all_pools",
305
+ ]
oxyde/db/registry.py ADDED
@@ -0,0 +1,113 @@
1
+ """Global registry for named database connections.
2
+
3
+ This module maintains a dict mapping connection names to AsyncDatabase instances.
4
+ The "default" connection is used when no explicit name is specified.
5
+
6
+ Why a Registry?
7
+ Queries need database connections, but models don't store connection refs.
8
+ The registry provides a global lookup: Model.objects uses get_connection()
9
+ to find the right pool.
10
+
11
+ # At startup
12
+ await db.init(default="postgresql://...", analytics="postgresql://...")
13
+
14
+ # In queries (implicit lookup via registry)
15
+ users = await User.objects.all() # Uses "default"
16
+ stats = await Metric.objects.all(using="analytics") # Uses "analytics"
17
+
18
+ Functions:
19
+ register_connection(database, overwrite=False):
20
+ Add AsyncDatabase to registry. Raises if name exists and not overwrite.
21
+
22
+ unregister_connection(name):
23
+ Remove from registry (used during cleanup).
24
+
25
+ get_connection(name="default", ensure_connected=True):
26
+ Look up by name. Auto-connects if ensure_connected=True.
27
+ Raises KeyError if not found.
28
+
29
+ disconnect_all():
30
+ Close all pools (both Python registry and Rust pools).
31
+ Clears the registry. Used at shutdown.
32
+
33
+ Thread Safety:
34
+ The registry is a simple dict. In async context, this is safe because
35
+ Python's GIL prevents concurrent dict mutations. Registration typically
36
+ happens at startup before concurrent access.
37
+
38
+ Transaction Integration:
39
+ QueryManager._resolve_client() checks get_active_transaction() before
40
+ falling back to get_connection(). This ensures queries inside atomic()
41
+ use the transaction, not a new pool connection.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ from typing import TYPE_CHECKING
47
+
48
+ from oxyde.core import close_all_pools
49
+
50
+ if TYPE_CHECKING:
51
+ from oxyde.db.pool import AsyncDatabase
52
+
53
+ # Global connection registry
54
+ _CONNECTIONS: dict[str, AsyncDatabase] = {}
55
+
56
+
57
+ def register_connection(
58
+ database: AsyncDatabase, *, overwrite: bool = False
59
+ ) -> AsyncDatabase:
60
+ """Register a connection instance so it can be retrieved later."""
61
+ if not overwrite and database.name in _CONNECTIONS:
62
+ raise ValueError(f"Connection '{database.name}' already registered")
63
+ _CONNECTIONS[database.name] = database
64
+ return database
65
+
66
+
67
+ def unregister_connection(name: str) -> None:
68
+ """Remove a connection from the registry."""
69
+ _CONNECTIONS.pop(name, None)
70
+
71
+
72
+ async def get_connection(
73
+ name: str = "default",
74
+ *,
75
+ ensure_connected: bool = True,
76
+ ) -> AsyncDatabase:
77
+ """
78
+ Retrieve a registered connection by name.
79
+
80
+ Args:
81
+ name: Connection identifier.
82
+ ensure_connected: Automatically call connect() if the pool is not ready.
83
+ """
84
+ try:
85
+ database = _CONNECTIONS[name]
86
+ except KeyError as exc:
87
+ raise KeyError(f"Connection '{name}' is not registered") from exc
88
+
89
+ if ensure_connected and not database.connected:
90
+ await database.connect()
91
+
92
+ return database
93
+
94
+
95
+ async def disconnect_all() -> None:
96
+ """Disconnect all registered pools and clear the registry."""
97
+ # Mark all Python-side connections as disconnected
98
+ for db in _CONNECTIONS.values():
99
+ db._connected = False
100
+
101
+ # Clear Python registry
102
+ _CONNECTIONS.clear()
103
+
104
+ # Close all Rust pools (also rolls back active transactions)
105
+ await close_all_pools()
106
+
107
+
108
+ __all__ = [
109
+ "register_connection",
110
+ "unregister_connection",
111
+ "get_connection",
112
+ "disconnect_all",
113
+ ]
oxyde/db/schema.py ADDED
@@ -0,0 +1,51 @@
1
+ """Schema management. create/drop tables from registered models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from oxyde.core import migration_compute_diff, migration_to_sql
9
+ from oxyde.migrations.extract import extract_current_schema
10
+ from oxyde.migrations.utils import detect_dialect
11
+ from oxyde.queries.raw import execute_raw
12
+
13
+ if TYPE_CHECKING:
14
+ from oxyde.db.pool import AsyncDatabase
15
+
16
+
17
+ async def create_tables(database: AsyncDatabase) -> None:
18
+ """Create all tables for registered models.
19
+
20
+ Generates dialect-aware DDL via sea-query and executes it.
21
+ No migration files or tracking table needed.
22
+
23
+ Args:
24
+ database: Connected AsyncDatabase instance.
25
+ """
26
+ dialect = detect_dialect(database.url)
27
+ current = extract_current_schema(dialect=dialect)
28
+ empty: dict[str, Any] = {"version": 1, "tables": {}}
29
+ ops_json = migration_compute_diff(json.dumps(empty), json.dumps(current))
30
+ statements = migration_to_sql(ops_json, dialect)
31
+ for sql in statements:
32
+ await execute_raw(sql, client=database)
33
+
34
+
35
+ async def drop_tables(database: AsyncDatabase) -> None:
36
+ """Drop all tables for registered models.
37
+
38
+ Handles FK constraints per dialect.
39
+
40
+ Args:
41
+ database: Connected AsyncDatabase instance.
42
+ """
43
+ dialect = detect_dialect(database.url)
44
+ current = extract_current_schema(dialect=dialect)
45
+ table_names = list(current.get("tables", {}).keys())
46
+
47
+ if not table_names:
48
+ return
49
+
50
+ tables = ", ".join(table_names)
51
+ await execute_raw(f"DROP TABLE IF EXISTS {tables} CASCADE", client=database)