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/_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)