patera-database 0.1.0__tar.gz
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.
- patera_database-0.1.0/PKG-INFO +7 -0
- patera_database-0.1.0/pyproject.toml +19 -0
- patera_database-0.1.0/src/patera/database/__init__.py +0 -0
- patera_database-0.1.0/src/patera/database/nosql/__init__.py +7 -0
- patera_database-0.1.0/src/patera/database/nosql/backends/__init__.py +7 -0
- patera_database-0.1.0/src/patera/database/nosql/backends/async_nosql_backend_protocol.py +140 -0
- patera_database-0.1.0/src/patera/database/nosql/backends/mongo_backend.py +197 -0
- patera_database-0.1.0/src/patera/database/nosql/nosql_database.py +241 -0
- patera_database-0.1.0/src/patera/database/sql/__init__.py +22 -0
- patera_database-0.1.0/src/patera/database/sql/declarative_base.py +206 -0
- patera_database-0.1.0/src/patera/database/sql/dialect_overview_extras.py +217 -0
- patera_database-0.1.0/src/patera/database/sql/sql_database.py +386 -0
- patera_database-0.1.0/src/patera/database/sql/sqlalchemy_async_query.py +166 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "patera-database"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
requires-python = ">=3.12"
|
|
5
|
+
dependencies = [
|
|
6
|
+
"patera",
|
|
7
|
+
"sqlalchemy",
|
|
8
|
+
"aiosqlite",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[tool.uv.sources]
|
|
12
|
+
patera = { workspace = true }
|
|
13
|
+
|
|
14
|
+
[tool.uv.build-backend]
|
|
15
|
+
module-name = "patera.database"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["uv_build>=0.10.9,<0.11.0"]
|
|
19
|
+
build-backend = "uv_build"
|
|
File without changes
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Protocls for async NoSQL backends.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Optional, Callable, Any, Iterable, Mapping, TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from patera import Patera
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AsyncNoSqlBackendBase(ABC):
|
|
13
|
+
"""
|
|
14
|
+
Minimal async adapter interface a backend must implement.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def configure_from_app(
|
|
20
|
+
cls, app: "Patera", configs: dict[str, Any]
|
|
21
|
+
) -> "AsyncNoSqlBackendBase":
|
|
22
|
+
"""
|
|
23
|
+
Classmethod to configure backend from app config.
|
|
24
|
+
Called during NoSqlDatabase.init_app().
|
|
25
|
+
"""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
async def connect(self) -> None: ...
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
async def disconnect(self) -> None: ...
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def database_handle(self) -> Any:
|
|
36
|
+
"""
|
|
37
|
+
Returns an object representing the 'database' to use inside handlers.
|
|
38
|
+
For backends without a database concept, return a client/root handle.
|
|
39
|
+
"""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def supports_transactions(self) -> bool: ...
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
async def start_session(self) -> Any:
|
|
47
|
+
"""
|
|
48
|
+
Return a session/context object usable in transactions (or None if unsupported).
|
|
49
|
+
"""
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
async def with_transaction(
|
|
54
|
+
self, fn: Callable[..., Any], *args, session: Any = None, **kwargs
|
|
55
|
+
) -> Any:
|
|
56
|
+
"""
|
|
57
|
+
Execute fn inside a transaction if supported; otherwise call fn directly.
|
|
58
|
+
"""
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def get_collection(self, name: str) -> Any: ...
|
|
63
|
+
|
|
64
|
+
@abstractmethod
|
|
65
|
+
async def find_one(
|
|
66
|
+
self,
|
|
67
|
+
collection: str,
|
|
68
|
+
filter: Mapping[str, Any],
|
|
69
|
+
*,
|
|
70
|
+
session: Any = None,
|
|
71
|
+
**kwargs,
|
|
72
|
+
) -> Any: ...
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
async def find_many(
|
|
76
|
+
self,
|
|
77
|
+
collection: str,
|
|
78
|
+
filter: Mapping[str, Any] | None = None,
|
|
79
|
+
*,
|
|
80
|
+
session: Any = None,
|
|
81
|
+
limit: Optional[int] = None,
|
|
82
|
+
skip: Optional[int] = None,
|
|
83
|
+
sort: Optional[Iterable[tuple[str, int]]] = None,
|
|
84
|
+
**kwargs,
|
|
85
|
+
) -> list[Any]: ...
|
|
86
|
+
|
|
87
|
+
@abstractmethod
|
|
88
|
+
async def insert_one(
|
|
89
|
+
self, collection: str, doc: Mapping[str, Any], *, session: Any = None, **kwargs
|
|
90
|
+
) -> Any: ...
|
|
91
|
+
|
|
92
|
+
@abstractmethod
|
|
93
|
+
async def insert_many(
|
|
94
|
+
self,
|
|
95
|
+
collection: str,
|
|
96
|
+
docs: Iterable[Mapping[str, Any]],
|
|
97
|
+
*,
|
|
98
|
+
session: Any = None,
|
|
99
|
+
**kwargs,
|
|
100
|
+
) -> Any: ...
|
|
101
|
+
|
|
102
|
+
@abstractmethod
|
|
103
|
+
async def update_one(
|
|
104
|
+
self,
|
|
105
|
+
collection: str,
|
|
106
|
+
filter: Mapping[str, Any],
|
|
107
|
+
update: Mapping[str, Any],
|
|
108
|
+
*,
|
|
109
|
+
upsert: bool = False,
|
|
110
|
+
session: Any = None,
|
|
111
|
+
**kwargs,
|
|
112
|
+
) -> Any: ...
|
|
113
|
+
|
|
114
|
+
@abstractmethod
|
|
115
|
+
async def delete_one(
|
|
116
|
+
self,
|
|
117
|
+
collection: str,
|
|
118
|
+
filter: Mapping[str, Any],
|
|
119
|
+
*,
|
|
120
|
+
session: Any = None,
|
|
121
|
+
**kwargs,
|
|
122
|
+
) -> Any: ...
|
|
123
|
+
|
|
124
|
+
@abstractmethod
|
|
125
|
+
async def aggregate(
|
|
126
|
+
self,
|
|
127
|
+
collection: str,
|
|
128
|
+
pipeline: Iterable[Mapping[str, Any]],
|
|
129
|
+
*,
|
|
130
|
+
session: Any = None,
|
|
131
|
+
**kwargs,
|
|
132
|
+
) -> list[Any]: ...
|
|
133
|
+
|
|
134
|
+
@abstractmethod
|
|
135
|
+
async def execute_raw(self, *args, **kwargs) -> Any:
|
|
136
|
+
"""
|
|
137
|
+
Backend escape hatch for commands that don't fit the generic surface.
|
|
138
|
+
For MongoDB, this could be db.command(...), collection.bulk_write(...), etc.
|
|
139
|
+
"""
|
|
140
|
+
...
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MongoDB backend
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Optional, Callable, Any, Iterable, Mapping, TYPE_CHECKING, cast
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from motor.motor_asyncio import AsyncIOMotorClient
|
|
9
|
+
except Exception:
|
|
10
|
+
raise
|
|
11
|
+
|
|
12
|
+
from .async_nosql_backend_protocol import AsyncNoSqlBackendBase
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from patera import Patera
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MongoBackend(AsyncNoSqlBackendBase):
|
|
19
|
+
def __init__(self, uri: str, database: Optional[str] = None) -> None:
|
|
20
|
+
self._client: Optional[Any] = None
|
|
21
|
+
self._db: Optional[Any] = None
|
|
22
|
+
self._uri: str = uri
|
|
23
|
+
self._database: Optional[str] = database
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def configure_from_app(
|
|
27
|
+
cls, app: "Patera", configs: dict[str, Any]
|
|
28
|
+
) -> "AsyncNoSqlBackendBase":
|
|
29
|
+
uri = cast(str, configs.get("DATABASE_URI"))
|
|
30
|
+
database = cast(str, configs.get("DATABASE"))
|
|
31
|
+
return cls(uri, database)
|
|
32
|
+
|
|
33
|
+
async def connect(self) -> None:
|
|
34
|
+
# Connect client
|
|
35
|
+
self._client = AsyncIOMotorClient(self._uri)
|
|
36
|
+
# Choose DB (or leave None; database_handle() will return client)
|
|
37
|
+
self._db = self._client[self._database] if self._database else None
|
|
38
|
+
|
|
39
|
+
# quick ping to verify connectivity
|
|
40
|
+
try:
|
|
41
|
+
await self._client.admin.command("ping")
|
|
42
|
+
except Exception as exc: # pragma: no cover
|
|
43
|
+
raise RuntimeError(
|
|
44
|
+
f"Failed to connect to MongoDB at {self._uri}: {exc}"
|
|
45
|
+
) from exc
|
|
46
|
+
|
|
47
|
+
async def disconnect(self) -> None:
|
|
48
|
+
if self._client is not None:
|
|
49
|
+
self._client.close()
|
|
50
|
+
self._client = None
|
|
51
|
+
self._db = None
|
|
52
|
+
|
|
53
|
+
def database_handle(self) -> Any:
|
|
54
|
+
# Prefer a db handle if configured; otherwise, return client
|
|
55
|
+
return self._db if self._db is not None else self._client
|
|
56
|
+
|
|
57
|
+
def supports_transactions(self) -> bool:
|
|
58
|
+
# Mongo supports transactions on replica sets / sharded clusters with WiredTiger.
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
async def start_session(self) -> Any:
|
|
62
|
+
if not self._client:
|
|
63
|
+
raise RuntimeError("Mongo client not connected.")
|
|
64
|
+
return await self._client.start_session()
|
|
65
|
+
|
|
66
|
+
async def with_transaction(
|
|
67
|
+
self, fn: Callable[..., Any], *args, session: Any = None, **kwargs
|
|
68
|
+
) -> Any:
|
|
69
|
+
# If session provided, run a transaction; else just call directly.
|
|
70
|
+
if session is None:
|
|
71
|
+
return await fn(*args, **kwargs)
|
|
72
|
+
|
|
73
|
+
async def runner(sess):
|
|
74
|
+
return await fn(*args, session=sess, **kwargs)
|
|
75
|
+
|
|
76
|
+
# Motor session has method 'with_transaction'
|
|
77
|
+
return await session.with_transaction(runner)
|
|
78
|
+
|
|
79
|
+
# ---- Collection / CRUD ----
|
|
80
|
+
|
|
81
|
+
def get_collection(self, name: str) -> Any:
|
|
82
|
+
db_or_client = self.database_handle()
|
|
83
|
+
if hasattr(db_or_client, "__getitem__"): # db['collection']
|
|
84
|
+
return db_or_client[name]
|
|
85
|
+
# If only client exists but no db name given:
|
|
86
|
+
raise RuntimeError(
|
|
87
|
+
"No database selected. Set NOSQL_DATABASE or use database['<db>'][collection]."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
async def find_one(
|
|
91
|
+
self,
|
|
92
|
+
collection: str,
|
|
93
|
+
filter: Mapping[str, Any],
|
|
94
|
+
*,
|
|
95
|
+
session: Any = None,
|
|
96
|
+
**kwargs,
|
|
97
|
+
) -> Any:
|
|
98
|
+
coll = self.get_collection(collection)
|
|
99
|
+
return await coll.find_one(filter, session=session, **kwargs)
|
|
100
|
+
|
|
101
|
+
async def find_many(
|
|
102
|
+
self,
|
|
103
|
+
collection: str,
|
|
104
|
+
filter: Mapping[str, Any] | None = None,
|
|
105
|
+
*,
|
|
106
|
+
session: Any = None,
|
|
107
|
+
limit: Optional[int] = None,
|
|
108
|
+
skip: Optional[int] = None,
|
|
109
|
+
sort: Optional[Iterable[tuple[str, int]]] = None,
|
|
110
|
+
**kwargs,
|
|
111
|
+
) -> list[Any]:
|
|
112
|
+
filter = filter or {}
|
|
113
|
+
coll = self.get_collection(collection)
|
|
114
|
+
cursor = coll.find(filter, session=session, **kwargs)
|
|
115
|
+
if sort:
|
|
116
|
+
cursor = cursor.sort(list(sort))
|
|
117
|
+
if skip:
|
|
118
|
+
cursor = cursor.skip(skip)
|
|
119
|
+
if limit:
|
|
120
|
+
cursor = cursor.limit(limit)
|
|
121
|
+
return await cursor.to_list(length=limit or 0)
|
|
122
|
+
|
|
123
|
+
async def insert_one(
|
|
124
|
+
self, collection: str, doc: Mapping[str, Any], *, session: Any = None, **kwargs
|
|
125
|
+
) -> Any:
|
|
126
|
+
coll = self.get_collection(collection)
|
|
127
|
+
return await coll.insert_one(doc, session=session, **kwargs)
|
|
128
|
+
|
|
129
|
+
async def insert_many(
|
|
130
|
+
self,
|
|
131
|
+
collection: str,
|
|
132
|
+
docs: Iterable[Mapping[str, Any]],
|
|
133
|
+
*,
|
|
134
|
+
session: Any = None,
|
|
135
|
+
**kwargs,
|
|
136
|
+
) -> Any:
|
|
137
|
+
coll = self.get_collection(collection)
|
|
138
|
+
return await coll.insert_many(list(docs), session=session, **kwargs)
|
|
139
|
+
|
|
140
|
+
async def update_one(
|
|
141
|
+
self,
|
|
142
|
+
collection: str,
|
|
143
|
+
filter: Mapping[str, Any],
|
|
144
|
+
update: Mapping[str, Any],
|
|
145
|
+
*,
|
|
146
|
+
upsert: bool = False,
|
|
147
|
+
session: Any = None,
|
|
148
|
+
**kwargs,
|
|
149
|
+
) -> Any:
|
|
150
|
+
coll = self.get_collection(collection)
|
|
151
|
+
return await coll.update_one(
|
|
152
|
+
filter, update, upsert=upsert, session=session, **kwargs
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
async def delete_one(
|
|
156
|
+
self,
|
|
157
|
+
collection: str,
|
|
158
|
+
filter: Mapping[str, Any],
|
|
159
|
+
*,
|
|
160
|
+
session: Any = None,
|
|
161
|
+
**kwargs,
|
|
162
|
+
) -> Any:
|
|
163
|
+
coll = self.get_collection(collection)
|
|
164
|
+
return await coll.delete_one(filter, session=session, **kwargs)
|
|
165
|
+
|
|
166
|
+
async def aggregate(
|
|
167
|
+
self,
|
|
168
|
+
collection: str,
|
|
169
|
+
pipeline: Iterable[Mapping[str, Any]],
|
|
170
|
+
*,
|
|
171
|
+
session: Any = None,
|
|
172
|
+
**kwargs,
|
|
173
|
+
) -> list[Any]:
|
|
174
|
+
coll = self.get_collection(collection)
|
|
175
|
+
cursor = coll.aggregate(list(pipeline), session=session, **kwargs)
|
|
176
|
+
return await cursor.to_list(length=None)
|
|
177
|
+
|
|
178
|
+
async def execute_raw(self, *args, **kwargs) -> Any:
|
|
179
|
+
"""
|
|
180
|
+
For MongoDB, you can call arbitrary db.command(...) via:
|
|
181
|
+
await db.execute_raw("command", {"ping": 1})
|
|
182
|
+
Or pass a callable to run with the database handle.
|
|
183
|
+
"""
|
|
184
|
+
db = self.database_handle()
|
|
185
|
+
if callable(args[0]):
|
|
186
|
+
fn = args[0]
|
|
187
|
+
return await fn(db, *args[1:], **kwargs)
|
|
188
|
+
|
|
189
|
+
# Simple command passthrough:
|
|
190
|
+
if isinstance(args[0], str):
|
|
191
|
+
cmd = args[0]
|
|
192
|
+
arg = args[1] if len(args) > 1 else {}
|
|
193
|
+
return await db.command(
|
|
194
|
+
{cmd: arg} if not isinstance(arg, Mapping) else {cmd: 1, **arg}
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
raise TypeError("Unsupported execute_raw usage for MongoBackend.")
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NoSQL Database Module
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
from typing import (
|
|
7
|
+
Optional,
|
|
8
|
+
Callable,
|
|
9
|
+
Any,
|
|
10
|
+
Iterable,
|
|
11
|
+
Mapping,
|
|
12
|
+
TYPE_CHECKING,
|
|
13
|
+
TypedDict,
|
|
14
|
+
cast,
|
|
15
|
+
Type,
|
|
16
|
+
NotRequired,
|
|
17
|
+
)
|
|
18
|
+
from functools import wraps
|
|
19
|
+
|
|
20
|
+
from .backends.async_nosql_backend_protocol import AsyncNoSqlBackendBase
|
|
21
|
+
from patera.base_extension import BaseExtension
|
|
22
|
+
from patera.utilities import run_sync_or_async
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from patera import Patera
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _NoSqlDatabaseConfig(BaseModel):
|
|
30
|
+
"""Configuration options for NoSqlDatabase extension."""
|
|
31
|
+
|
|
32
|
+
BACKEND: Type[AsyncNoSqlBackendBase] = Field(
|
|
33
|
+
description="Backend class implementing AsyncNoSqlBackendBase."
|
|
34
|
+
)
|
|
35
|
+
DATABASE_URI: str = Field(description="Connection string for the NoSQL backend.")
|
|
36
|
+
DATABASE_NAME: Optional[str] = Field(
|
|
37
|
+
default=None, description="Database / keyspace name (if backend uses it)."
|
|
38
|
+
)
|
|
39
|
+
DB_INJECT_NAME: Optional[str] = Field(
|
|
40
|
+
default="db", description="Kwarg name injected by decorators (database handle)."
|
|
41
|
+
)
|
|
42
|
+
SESSION_NAME: Optional[str] = Field(
|
|
43
|
+
default="session",
|
|
44
|
+
description="Kwarg name injected by @managed_database (session/transaction handle).",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class NoSqlDatabaseConfig(TypedDict):
|
|
49
|
+
BACKEND: Type[AsyncNoSqlBackendBase]
|
|
50
|
+
DATABASE_URI: str
|
|
51
|
+
DATABASE_NAME: NotRequired[str]
|
|
52
|
+
INJECT_NAME: NotRequired[str]
|
|
53
|
+
SESSION_NAME: NotRequired[str]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class NoSqlDatabase(BaseExtension):
|
|
57
|
+
"""
|
|
58
|
+
A simple async NoSQL Database interface with pluggable backends.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self, db_name: str = "nosql", configs_name: str = "NOSQL_DATABASE"
|
|
63
|
+
) -> None:
|
|
64
|
+
self._app: Optional["Patera"] = None
|
|
65
|
+
self._configs_name = configs_name
|
|
66
|
+
self._configs: dict[str, Any] = {}
|
|
67
|
+
self.__db_name__ = db_name
|
|
68
|
+
|
|
69
|
+
# Effective config
|
|
70
|
+
self.backend_name: Optional[str] = None
|
|
71
|
+
self.uri: Optional[str] = None
|
|
72
|
+
self.database: Optional[str] = None
|
|
73
|
+
self.inject_name: str = "db"
|
|
74
|
+
self.session_name: str = "session"
|
|
75
|
+
|
|
76
|
+
# Backend instance
|
|
77
|
+
self._backend: AsyncNoSqlBackendBase = cast(AsyncNoSqlBackendBase, None)
|
|
78
|
+
|
|
79
|
+
# ---- App lifecycle ----
|
|
80
|
+
|
|
81
|
+
def init_app(self, app: "Patera") -> None:
|
|
82
|
+
"""
|
|
83
|
+
Initializes the NoSQL interface.
|
|
84
|
+
Required config keys (with optional variable_prefix):
|
|
85
|
+
- BACKEND
|
|
86
|
+
- DATABASE_URI
|
|
87
|
+
Optional:
|
|
88
|
+
- DATABASE
|
|
89
|
+
- INJECT_NAME (default "db")
|
|
90
|
+
- SESSION_NAME (default "session")
|
|
91
|
+
"""
|
|
92
|
+
self._app = app
|
|
93
|
+
self._configs = app.get_conf(self._configs_name, None)
|
|
94
|
+
if self._configs is None:
|
|
95
|
+
raise ValueError(f"Missing {self._configs_name} configuration.")
|
|
96
|
+
self._configs = self.validate_configs(self._configs, _NoSqlDatabaseConfig)
|
|
97
|
+
|
|
98
|
+
self.backend_cls = self._configs.get("BACKEND")
|
|
99
|
+
self.uri = self._configs.get("DATABASE_URI")
|
|
100
|
+
self.database = self._configs.get("DATABASE_NAME")
|
|
101
|
+
self.inject_name = cast(str, self._configs.get("INJECT_NAME"))
|
|
102
|
+
self.session_name = cast(str, self._configs.get("SESSION_NAME"))
|
|
103
|
+
|
|
104
|
+
if not self.backend_cls or not self.uri:
|
|
105
|
+
raise RuntimeError(
|
|
106
|
+
"Missing NOSQL_BACKEND or NOSQL_DATABASE_URI configuration."
|
|
107
|
+
)
|
|
108
|
+
if not issubclass(self.backend_cls, AsyncNoSqlBackendBase):
|
|
109
|
+
raise RuntimeError(
|
|
110
|
+
"NOSQL_BACKEND must be a subclass of AsyncNoSqlBackendBase."
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
self._backend = cast(
|
|
114
|
+
AsyncNoSqlBackendBase, self.backend_cls
|
|
115
|
+
).configure_from_app(app, self._configs)
|
|
116
|
+
|
|
117
|
+
app.add_extension(self)
|
|
118
|
+
app.add_on_startup_method(self.connect)
|
|
119
|
+
app.add_on_shutdown_method(self.disconnect)
|
|
120
|
+
|
|
121
|
+
async def connect(self) -> None:
|
|
122
|
+
"""
|
|
123
|
+
Creates the backend client/connection. Runs on lifespan.start
|
|
124
|
+
"""
|
|
125
|
+
if not self._backend:
|
|
126
|
+
raise RuntimeError("Backend not initialized. Call init_app() first.")
|
|
127
|
+
await self._backend.connect()
|
|
128
|
+
|
|
129
|
+
async def disconnect(self) -> None:
|
|
130
|
+
"""
|
|
131
|
+
Disposes backend resources. Runs on lifespan.shutdown
|
|
132
|
+
"""
|
|
133
|
+
if self._backend:
|
|
134
|
+
await self._backend.disconnect()
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def db_name(self) -> str:
|
|
138
|
+
return self.__db_name__
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def backend(self) -> AsyncNoSqlBackendBase:
|
|
142
|
+
if not self._backend:
|
|
143
|
+
raise RuntimeError("Backend not connected. Was init_app/connect called?")
|
|
144
|
+
return self._backend
|
|
145
|
+
|
|
146
|
+
def database_handle(self) -> Any:
|
|
147
|
+
return self.backend.database_handle()
|
|
148
|
+
|
|
149
|
+
def get_collection(self, name: str) -> Any:
|
|
150
|
+
return self.backend.get_collection(name)
|
|
151
|
+
|
|
152
|
+
async def find_one(
|
|
153
|
+
self, collection: str, filter: Mapping[str, Any], **kwargs
|
|
154
|
+
) -> Any:
|
|
155
|
+
return await self.backend.find_one(collection, filter, **kwargs)
|
|
156
|
+
|
|
157
|
+
async def find_many(
|
|
158
|
+
self, collection: str, filter: Mapping[str, Any] | None = None, **kwargs
|
|
159
|
+
) -> list[Any]:
|
|
160
|
+
return await self.backend.find_many(collection, filter, **kwargs)
|
|
161
|
+
|
|
162
|
+
async def insert_one(
|
|
163
|
+
self, collection: str, doc: Mapping[str, Any], **kwargs
|
|
164
|
+
) -> Any:
|
|
165
|
+
return await self.backend.insert_one(collection, doc, **kwargs)
|
|
166
|
+
|
|
167
|
+
async def insert_many(
|
|
168
|
+
self, collection: str, docs: Iterable[Mapping[str, Any]], **kwargs
|
|
169
|
+
) -> Any:
|
|
170
|
+
return await self.backend.insert_many(collection, docs, **kwargs)
|
|
171
|
+
|
|
172
|
+
async def update_one(
|
|
173
|
+
self,
|
|
174
|
+
collection: str,
|
|
175
|
+
filter: Mapping[str, Any],
|
|
176
|
+
update: Mapping[str, Any],
|
|
177
|
+
**kwargs,
|
|
178
|
+
) -> Any:
|
|
179
|
+
return await self.backend.update_one(collection, filter, update, **kwargs)
|
|
180
|
+
|
|
181
|
+
async def delete_one(
|
|
182
|
+
self, collection: str, filter: Mapping[str, Any], **kwargs
|
|
183
|
+
) -> Any:
|
|
184
|
+
return await self.backend.delete_one(collection, filter, **kwargs)
|
|
185
|
+
|
|
186
|
+
async def aggregate(
|
|
187
|
+
self, collection: str, pipeline: Iterable[Mapping[str, Any]], **kwargs
|
|
188
|
+
) -> list[Any]:
|
|
189
|
+
return await self.backend.aggregate(collection, pipeline, **kwargs)
|
|
190
|
+
|
|
191
|
+
async def execute_raw(self, *args, **kwargs) -> Any:
|
|
192
|
+
"""
|
|
193
|
+
Escape hatch for backend-specific commands. See MongoBackend.execute_raw docstring.
|
|
194
|
+
"""
|
|
195
|
+
return await self.backend.execute_raw(*args, **kwargs)
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def managed_database(self) -> Callable:
|
|
199
|
+
"""
|
|
200
|
+
Decorator that:
|
|
201
|
+
- Injects a database/client handle into handler kwargs under NOSQL_INJECT_NAME ("db" by default).
|
|
202
|
+
- If the backend supports transactions, opens a session+transaction for the duration
|
|
203
|
+
and commits/aborts automatically on exit.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
def decorator(handler: Callable) -> Callable:
|
|
207
|
+
@wraps(handler)
|
|
208
|
+
async def wrapper(*args, **kwargs):
|
|
209
|
+
backend = self.backend
|
|
210
|
+
inject_key = self.inject_name
|
|
211
|
+
handle = backend.database_handle()
|
|
212
|
+
|
|
213
|
+
# default injection
|
|
214
|
+
kwargs[inject_key] = handle
|
|
215
|
+
|
|
216
|
+
if not backend.supports_transactions():
|
|
217
|
+
# No transaction semantics; just run.
|
|
218
|
+
return await run_sync_or_async(handler, *args, **kwargs)
|
|
219
|
+
|
|
220
|
+
# Transaction-capable flow
|
|
221
|
+
session = await backend.start_session()
|
|
222
|
+
|
|
223
|
+
async def _call_with_session(*_args, **_kwargs):
|
|
224
|
+
# Some backends (Mongo) accept 'session' in calls; handlers can pass it through.
|
|
225
|
+
_kwargs.setdefault(self.session_name, session)
|
|
226
|
+
return await run_sync_or_async(handler, *_args, **_kwargs)
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
return await backend.with_transaction(
|
|
230
|
+
_call_with_session, *args, **kwargs, session=session
|
|
231
|
+
)
|
|
232
|
+
finally:
|
|
233
|
+
# session ended in with_transaction, but just in case
|
|
234
|
+
try:
|
|
235
|
+
await session.end_session() # type: ignore[attr-defined]
|
|
236
|
+
except Exception:
|
|
237
|
+
pass
|
|
238
|
+
|
|
239
|
+
return wrapper
|
|
240
|
+
|
|
241
|
+
return decorator
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
database module of patera
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
# re-export of some commonly used sqlalchemy objects
|
|
6
|
+
# and methods for convenience.
|
|
7
|
+
from sqlalchemy import select, Select
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
|
+
|
|
10
|
+
from .sql_database import SqlDatabase, SqlDatabaseConfig
|
|
11
|
+
from .sqlalchemy_async_query import AsyncQuery
|
|
12
|
+
from .declarative_base import DeclarativeBaseModel
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"SqlDatabase",
|
|
16
|
+
"select",
|
|
17
|
+
"Select",
|
|
18
|
+
"AsyncSession",
|
|
19
|
+
"AsyncQuery",
|
|
20
|
+
"DeclarativeBaseModel",
|
|
21
|
+
"SqlDatabaseConfig",
|
|
22
|
+
]
|