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.
- oxyde/__init__.py +151 -0
- oxyde/_msgpack.py +3 -0
- oxyde/cli/__init__.py +7 -0
- oxyde/cli/app.py +107 -0
- oxyde/cli/codegen.py +66 -0
- oxyde/cli/config.py +84 -0
- oxyde/cli/migrations.py +536 -0
- oxyde/codegen/__init__.py +15 -0
- oxyde/codegen/stub_generator.py +1229 -0
- oxyde/core/__init__.py +90 -0
- oxyde/core/column_types.py +367 -0
- oxyde/core/ir.py +380 -0
- oxyde/core/types.py +69 -0
- oxyde/core/wrapper.py +242 -0
- oxyde/db/__init__.py +230 -0
- oxyde/db/pool.py +305 -0
- oxyde/db/registry.py +113 -0
- oxyde/db/schema.py +51 -0
- oxyde/db/transaction.py +392 -0
- oxyde/exceptions.py +89 -0
- oxyde/migrations/__init__.py +49 -0
- oxyde/migrations/config.py +187 -0
- oxyde/migrations/context.py +598 -0
- oxyde/migrations/executor.py +456 -0
- oxyde/migrations/extract.py +316 -0
- oxyde/migrations/generator.py +534 -0
- oxyde/migrations/replay.py +367 -0
- oxyde/migrations/squash.py +109 -0
- oxyde/migrations/tracker.py +162 -0
- oxyde/migrations/utils.py +145 -0
- oxyde/models/__init__.py +83 -0
- oxyde/models/base.py +871 -0
- oxyde/models/decorators.py +111 -0
- oxyde/models/field.py +204 -0
- oxyde/models/lookups.py +667 -0
- oxyde/models/metadata.py +174 -0
- oxyde/models/registry.py +170 -0
- oxyde/models/serializers.py +144 -0
- oxyde/models/utils.py +116 -0
- oxyde/py.typed +0 -0
- oxyde/queries/__init__.py +79 -0
- oxyde/queries/aggregates.py +145 -0
- oxyde/queries/base.py +249 -0
- oxyde/queries/conditions.py +92 -0
- oxyde/queries/expressions.py +171 -0
- oxyde/queries/insert.py +124 -0
- oxyde/queries/joins.py +66 -0
- oxyde/queries/manager.py +409 -0
- oxyde/queries/mixins/__init__.py +68 -0
- oxyde/queries/mixins/aggregation.py +335 -0
- oxyde/queries/mixins/debug.py +154 -0
- oxyde/queries/mixins/execution.py +701 -0
- oxyde/queries/mixins/filtering.py +144 -0
- oxyde/queries/mixins/joining.py +160 -0
- oxyde/queries/mixins/mutation.py +508 -0
- oxyde/queries/mixins/pagination.py +145 -0
- oxyde/queries/q.py +250 -0
- oxyde/queries/raw.py +114 -0
- oxyde/queries/select.py +345 -0
- oxyde/queries/typed.py +213 -0
- oxyde_postgres-0.1.0.dist-info/METADATA +141 -0
- oxyde_postgres-0.1.0.dist-info/RECORD +66 -0
- oxyde_postgres-0.1.0.dist-info/WHEEL +5 -0
- oxyde_postgres-0.1.0.dist-info/entry_points.txt +2 -0
- oxyde_postgres-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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)
|