sqlakit 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.
- sqlakit/__init__.py +99 -0
- sqlakit/_base.py +995 -0
- sqlakit/_db.py +540 -0
- sqlakit/_discovery.py +79 -0
- sqlakit/_model.py +275 -0
- sqlakit/_query.py +1156 -0
- sqlakit/_recording.py +385 -0
- sqlakit/_registry.py +69 -0
- sqlakit/_routing.py +43 -0
- sqlakit/_sql.py +408 -0
- sqlakit/asyncio/__init__.py +4 -0
- sqlakit/asyncio/_db.py +552 -0
- sqlakit/asyncio/_registry.py +57 -0
- sqlakit/asyncio/orm.py +662 -0
- sqlakit/asyncio/sql.py +222 -0
- sqlakit/exceptions.py +423 -0
- sqlakit/orm.py +645 -0
- sqlakit/py.typed +0 -0
- sqlakit/sql.py +211 -0
- sqlakit/testing.py +91 -0
- sqlakit/types.py +104 -0
- sqlakit-0.1.0.dist-info/METADATA +408 -0
- sqlakit-0.1.0.dist-info/RECORD +25 -0
- sqlakit-0.1.0.dist-info/WHEEL +4 -0
- sqlakit-0.1.0.dist-info/licenses/LICENSE +21 -0
sqlakit/_model.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
# Imported here rather than under TYPE_CHECKING: SQLAlchemy resolves the
|
|
4
|
+
# annotation of `deleted_at` in this module, and needs both names at runtime.
|
|
5
|
+
from datetime import datetime # noqa: TC003
|
|
6
|
+
from typing import (
|
|
7
|
+
TYPE_CHECKING,
|
|
8
|
+
Any,
|
|
9
|
+
ClassVar,
|
|
10
|
+
Generic,
|
|
11
|
+
Protocol,
|
|
12
|
+
Self,
|
|
13
|
+
TypeVar,
|
|
14
|
+
cast,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
import sqlalchemy as sa
|
|
18
|
+
import sqlalchemy.orm
|
|
19
|
+
from sqlalchemy.orm import Mapped # noqa: TC002
|
|
20
|
+
from sqlalchemy.orm.attributes import set_committed_value
|
|
21
|
+
|
|
22
|
+
from .exceptions import (
|
|
23
|
+
DEFAULT_ALIAS,
|
|
24
|
+
DetachedInstanceError,
|
|
25
|
+
MissingDefaultDatabaseError,
|
|
26
|
+
SQLAKitError,
|
|
27
|
+
UnknownFieldError,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from collections.abc import Mapping
|
|
32
|
+
|
|
33
|
+
from ._base import BaseDatabase
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"BaseModel",
|
|
38
|
+
"BaseSoftDeletes",
|
|
39
|
+
"DatabaseDescriptor",
|
|
40
|
+
"DatabaseSource",
|
|
41
|
+
"db_for",
|
|
42
|
+
"resolve_alias",
|
|
43
|
+
"soft_delete_column",
|
|
44
|
+
"tables_for",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
DatabaseT = TypeVar("DatabaseT", bound="BaseDatabase[Any, Any]")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DatabaseSource(Protocol):
|
|
51
|
+
"""Where an alias is looked up: the importable registry, or a stand-in."""
|
|
52
|
+
|
|
53
|
+
def __getitem__(self, alias: str) -> BaseDatabase[Any, Any]: ...
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DatabaseDescriptor(Generic[DatabaseT]):
|
|
57
|
+
"""Resolves ``__db__``, on the class as well as on an instance."""
|
|
58
|
+
|
|
59
|
+
def __get__(
|
|
60
|
+
self,
|
|
61
|
+
instance: object | None,
|
|
62
|
+
owner: type[BaseModel[DatabaseT]],
|
|
63
|
+
) -> DatabaseT:
|
|
64
|
+
# `ClassVar` cannot hold a type variable, so the class declares what
|
|
65
|
+
# every model has and the subclass narrows it to its own database.
|
|
66
|
+
return cast("DatabaseT", db_for(owner))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class BaseModel(Generic[DatabaseT]):
|
|
70
|
+
"""What the sync and async models share: everything that is not IO.
|
|
71
|
+
|
|
72
|
+
A model works on the database named by ``__db__``: an alias in the
|
|
73
|
+
importable registry, or a database of its own.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
__db__: ClassVar[str | BaseDatabase[Any, Any]] = DEFAULT_ALIAS
|
|
77
|
+
"""The database this model works on: an alias, or one of its own."""
|
|
78
|
+
|
|
79
|
+
__dbs__: ClassVar[DatabaseSource]
|
|
80
|
+
"""Where an alias in ``__db__`` is looked up."""
|
|
81
|
+
|
|
82
|
+
# Declared, not assigned: the declarative base a model is built on brings
|
|
83
|
+
# them, and saying so here is what lets the helpers below read them.
|
|
84
|
+
registry: ClassVar[sa.orm.registry]
|
|
85
|
+
metadata: ClassVar[sa.MetaData]
|
|
86
|
+
|
|
87
|
+
# Unannotated on purpose: an annotation here reads as a field to
|
|
88
|
+
# pydantic, and SQLModel models would refuse to build.
|
|
89
|
+
db = DatabaseDescriptor[DatabaseT]()
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def set_db(cls, db: str | DatabaseT) -> None:
|
|
93
|
+
"""Point this model, and the ones under it, at a database.
|
|
94
|
+
|
|
95
|
+
Takes an alias in the importable registry, or a database of its own for an
|
|
96
|
+
application that never configures that one. Set it on your own base rather
|
|
97
|
+
than on the one this library ships:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
Base.set_db(Database(DB_URL))
|
|
101
|
+
Base.set_db("replica")
|
|
102
|
+
```
|
|
103
|
+
"""
|
|
104
|
+
cls.__db__ = db
|
|
105
|
+
|
|
106
|
+
def update(self, values: Mapping[str, Any]) -> Self:
|
|
107
|
+
"""Set these fields on this instance, and return it.
|
|
108
|
+
|
|
109
|
+
For a request that carries only the fields it means to change:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
user.update(payload.model_dump(exclude_unset=True)).save()
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Every key has to be a field of the model, so a typo is an error rather than
|
|
116
|
+
an attribute nobody reads. A `None` is a value like any other: it is how a
|
|
117
|
+
request clears a nullable field.
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
UnknownFieldError: if a key is not a field of this model.
|
|
121
|
+
|
|
122
|
+
"""
|
|
123
|
+
fields = sa.inspect(type(self), raiseerr=True).attrs
|
|
124
|
+
for name, value in values.items():
|
|
125
|
+
if name not in fields:
|
|
126
|
+
raise UnknownFieldError(type(self).__name__, name)
|
|
127
|
+
setattr(self, name, value)
|
|
128
|
+
return self
|
|
129
|
+
|
|
130
|
+
def set_loaded(self, name: str, value: Any) -> Self: # noqa: ANN401
|
|
131
|
+
"""Give a relationship a value the database is not asked for, and return this.
|
|
132
|
+
|
|
133
|
+
The value is taken as one that was loaded, so reading it costs nothing:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
campaign.set_loaded("esp", esp) # the one this code just used
|
|
137
|
+
campaign.set_loaded("thumbnail", None) # known to be empty
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
What a `lazy="raise"` relationship needs when the value is already in hand:
|
|
141
|
+
rows fetched for a whole page at once, a row this block created, or an
|
|
142
|
+
instance that outlives the session that loaded it.
|
|
143
|
+
|
|
144
|
+
It says what the database holds rather than changing it: the value is not
|
|
145
|
+
written on save, does not mark the instance dirty, and does not reach the
|
|
146
|
+
other side of the relationship. Say something untrue and the instance says it
|
|
147
|
+
too. `refresh(attribute_names=["esp"])` is the other way to fill a
|
|
148
|
+
relationship nobody loaded: right by construction, one query, session still
|
|
149
|
+
open.
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
UnknownFieldError: if the model has no such field.
|
|
153
|
+
|
|
154
|
+
"""
|
|
155
|
+
if name not in sa.inspect(type(self), raiseerr=True).attrs:
|
|
156
|
+
raise UnknownFieldError(type(self).__name__, name)
|
|
157
|
+
set_committed_value(self, name, value)
|
|
158
|
+
return self
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def is_persisted(self) -> bool:
|
|
162
|
+
"""Whether a row exists, or existed, for this instance."""
|
|
163
|
+
return sa.inspect(self, raiseerr=True).has_identity
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def is_modified(self) -> bool:
|
|
167
|
+
"""Whether any attribute changed since the last flush."""
|
|
168
|
+
return bool(self.modified_fields)
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def was_deleted(self) -> bool:
|
|
172
|
+
"""Whether this instance was deleted, even once it is detached."""
|
|
173
|
+
return sa.inspect(self, raiseerr=True).was_deleted
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def modified_fields(self) -> set[str]:
|
|
177
|
+
"""The attributes changed since the last flush."""
|
|
178
|
+
state = sa.inspect(self, raiseerr=True)
|
|
179
|
+
return set(state.mapper.attrs.keys()) - state.unmodified
|
|
180
|
+
|
|
181
|
+
def _prepare_save(self) -> None:
|
|
182
|
+
"""Put this instance in the session, or say why it cannot go there."""
|
|
183
|
+
state = sa.inspect(self, raiseerr=True)
|
|
184
|
+
if state.detached:
|
|
185
|
+
raise DetachedInstanceError(type(self).__name__)
|
|
186
|
+
if state.transient:
|
|
187
|
+
self.db.session.add(self)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def db_for(model: type[Any]) -> BaseDatabase[Any, Any]:
|
|
191
|
+
"""Return the database a model lives on.
|
|
192
|
+
|
|
193
|
+
``__db__`` says it, unless the source it names knows better: a registry
|
|
194
|
+
asks the block's `using()` and the routers first.
|
|
195
|
+
"""
|
|
196
|
+
source = getattr(model, "__dbs__", None)
|
|
197
|
+
resolve = getattr(source, "db_for", None)
|
|
198
|
+
if resolve is not None:
|
|
199
|
+
return cast("BaseDatabase[Any, Any]", resolve(model))
|
|
200
|
+
placement = model.__db__
|
|
201
|
+
if isinstance(placement, str):
|
|
202
|
+
if source is None:
|
|
203
|
+
raise MissingDefaultDatabaseError
|
|
204
|
+
return source[placement]
|
|
205
|
+
return placement
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def resolve_alias(model: type[Any], alias: str) -> BaseDatabase[Any, Any]:
|
|
209
|
+
"""Return the database a model knows under that alias."""
|
|
210
|
+
source = getattr(model, "__dbs__", None)
|
|
211
|
+
if source is None:
|
|
212
|
+
raise MissingDefaultDatabaseError
|
|
213
|
+
return source[alias]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def tables_for(
|
|
217
|
+
model: type[BaseModel[Any]],
|
|
218
|
+
db: BaseDatabase[Any, Any],
|
|
219
|
+
) -> list[sa.Table] | None:
|
|
220
|
+
"""Return the tables of this model's metadata that live on that database.
|
|
221
|
+
|
|
222
|
+
None when they all do, which is what `create_all` wants for the ordinary
|
|
223
|
+
case of one database. Otherwise the tables of the models pointed at it,
|
|
224
|
+
and the tables that only reference those. An association table belongs
|
|
225
|
+
with the rows it joins.
|
|
226
|
+
"""
|
|
227
|
+
ours: set[sa.TableClause] = set()
|
|
228
|
+
theirs: set[sa.TableClause] = set()
|
|
229
|
+
for mapper in model.registry.mappers:
|
|
230
|
+
try:
|
|
231
|
+
owner = getattr(mapper.class_, "db", None)
|
|
232
|
+
except SQLAKitError:
|
|
233
|
+
# A model pointed at a database this run does not configure is not
|
|
234
|
+
# on this one, and its tables are not ours to create.
|
|
235
|
+
owner = None
|
|
236
|
+
where = ours if owner is db else theirs
|
|
237
|
+
where.update(mapper.tables)
|
|
238
|
+
|
|
239
|
+
if not theirs:
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
for table in model.metadata.tables.values():
|
|
243
|
+
if table in ours or table in theirs:
|
|
244
|
+
continue
|
|
245
|
+
referenced = {key.column.table for key in table.foreign_keys}
|
|
246
|
+
if referenced and referenced <= ours:
|
|
247
|
+
ours.add(table)
|
|
248
|
+
return [table for table in sorted(ours, key=str) if isinstance(table, sa.Table)]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class BaseSoftDeletes:
|
|
252
|
+
"""Rows this model marks as deleted instead of removing.
|
|
253
|
+
|
|
254
|
+
Mixed into a model, it adds a ``deleted_at`` column, hides the rows that
|
|
255
|
+
carry one, and has `delete()` stamp that column rather than issue a `DELETE`.
|
|
256
|
+
``__soft_delete__`` names the column, so a model with one of its own can set
|
|
257
|
+
that and skip the mixin.
|
|
258
|
+
|
|
259
|
+
Hiding the marked rows is its own step, not a ``__query_filter__``. A model
|
|
260
|
+
can carry both, and `with_deleted()` and `unfiltered()` then lift one each.
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
__soft_delete__: ClassVar[str] = "deleted_at"
|
|
264
|
+
|
|
265
|
+
deleted_at: Mapped[datetime | None] = sa.orm.mapped_column(
|
|
266
|
+
# `timestamptz` on PostgreSQL, and nothing to SQLite: a mark compared
|
|
267
|
+
# across time zones has to carry one.
|
|
268
|
+
sa.DateTime(timezone=True),
|
|
269
|
+
default=None,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def soft_delete_column(model: type[Any]) -> str | None:
|
|
274
|
+
"""Return the column a model marks deleted rows with, if it marks them."""
|
|
275
|
+
return getattr(model, "__soft_delete__", None)
|