sqlakit 0.5.1__tar.gz → 0.7.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.
Files changed (26) hide show
  1. {sqlakit-0.5.1 → sqlakit-0.7.0}/PKG-INFO +1 -1
  2. {sqlakit-0.5.1 → sqlakit-0.7.0}/pyproject.toml +1 -1
  3. {sqlakit-0.5.1 → sqlakit-0.7.0}/pyproject.toml.orig +1 -1
  4. {sqlakit-0.5.1 → sqlakit-0.7.0}/sqlakit/__init__.py +12 -1
  5. {sqlakit-0.5.1 → sqlakit-0.7.0}/sqlakit/_model.py +20 -5
  6. {sqlakit-0.5.1 → sqlakit-0.7.0}/sqlakit/_query.py +83 -24
  7. {sqlakit-0.5.1 → sqlakit-0.7.0}/sqlakit/asyncio/orm.py +11 -1
  8. {sqlakit-0.5.1 → sqlakit-0.7.0}/sqlakit/exceptions.py +15 -0
  9. {sqlakit-0.5.1 → sqlakit-0.7.0}/sqlakit/orm.py +13 -1
  10. {sqlakit-0.5.1 → sqlakit-0.7.0}/LICENSE +0 -0
  11. {sqlakit-0.5.1 → sqlakit-0.7.0}/README.md +0 -0
  12. {sqlakit-0.5.1 → sqlakit-0.7.0}/sqlakit/_base.py +0 -0
  13. {sqlakit-0.5.1 → sqlakit-0.7.0}/sqlakit/_db.py +0 -0
  14. {sqlakit-0.5.1 → sqlakit-0.7.0}/sqlakit/_discovery.py +0 -0
  15. {sqlakit-0.5.1 → sqlakit-0.7.0}/sqlakit/_recording.py +0 -0
  16. {sqlakit-0.5.1 → sqlakit-0.7.0}/sqlakit/_registry.py +0 -0
  17. {sqlakit-0.5.1 → sqlakit-0.7.0}/sqlakit/_routing.py +0 -0
  18. {sqlakit-0.5.1 → sqlakit-0.7.0}/sqlakit/_sql.py +0 -0
  19. {sqlakit-0.5.1 → sqlakit-0.7.0}/sqlakit/asyncio/__init__.py +0 -0
  20. {sqlakit-0.5.1 → sqlakit-0.7.0}/sqlakit/asyncio/_db.py +0 -0
  21. {sqlakit-0.5.1 → sqlakit-0.7.0}/sqlakit/asyncio/_registry.py +0 -0
  22. {sqlakit-0.5.1 → sqlakit-0.7.0}/sqlakit/asyncio/sql.py +0 -0
  23. {sqlakit-0.5.1 → sqlakit-0.7.0}/sqlakit/py.typed +0 -0
  24. {sqlakit-0.5.1 → sqlakit-0.7.0}/sqlakit/sql.py +0 -0
  25. {sqlakit-0.5.1 → sqlakit-0.7.0}/sqlakit/testing.py +0 -0
  26. {sqlakit-0.5.1 → sqlakit-0.7.0}/sqlakit/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlakit
3
- Version: 0.5.1
3
+ Version: 0.7.0
4
4
  Summary: A toolkit for SQLAlchemy applications.
5
5
  Keywords: sqlalchemy,database,orm,sql,asyncio
6
6
  Author: Anton Ruhlov
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit"
3
- version = "0.5.1"
3
+ version = "0.7.0"
4
4
  description = "A toolkit for SQLAlchemy applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit"
3
- version = "0.5.1"
3
+ version = "0.7.0"
4
4
  description = "A toolkit for SQLAlchemy applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,7 +1,14 @@
1
1
  from ._base import DEFAULT_ENGINE_ARGS, DEFAULT_SESSION_ARGS
2
2
  from ._db import Database, RetryingTransaction, Transaction
3
3
  from ._discovery import import_models, import_string
4
- from ._query import CASE_INSENSITIVE_COLLATIONS, CursorPage, OrderBy, Page
4
+ from ._query import (
5
+ CASE_INSENSITIVE_COLLATIONS,
6
+ CursorPage,
7
+ OrderBy,
8
+ Page,
9
+ UncountedPage,
10
+ orderable_columns,
11
+ )
5
12
  from ._recording import Recording, Statement
6
13
  from ._registry import Databases, db
7
14
  from ._routing import Router
@@ -25,6 +32,7 @@ from .exceptions import (
25
32
  MissingDatabaseUrlError,
26
33
  MissingDefaultDatabaseError,
27
34
  MissingDependencyError,
35
+ MissingRegistryError,
28
36
  MissingSessionError,
29
37
  MultipleInstancesFoundError,
30
38
  NullCursorValueError,
@@ -80,6 +88,7 @@ __all__ = [
80
88
  "MissingDatabaseUrlError",
81
89
  "MissingDefaultDatabaseError",
82
90
  "MissingDependencyError",
91
+ "MissingRegistryError",
83
92
  "MissingSessionError",
84
93
  "MultipleInstancesFoundError",
85
94
  "NullCursorValueError",
@@ -102,6 +111,7 @@ __all__ = [
102
111
  "Transaction",
103
112
  "TransactionRolledBackError",
104
113
  "UncomparableOrderingError",
114
+ "UncountedPage",
105
115
  "UnknownDatabaseError",
106
116
  "UnknownFieldError",
107
117
  "UnknownImportPathError",
@@ -111,4 +121,5 @@ __all__ = [
111
121
  "db",
112
122
  "import_models",
113
123
  "import_string",
124
+ "orderable_columns",
114
125
  ]
@@ -22,7 +22,7 @@ from sqlalchemy.orm.attributes import set_committed_value
22
22
  from .exceptions import (
23
23
  DEFAULT_ALIAS,
24
24
  DetachedInstanceError,
25
- MissingDefaultDatabaseError,
25
+ MissingRegistryError,
26
26
  SQLAKitError,
27
27
  UnknownFieldError,
28
28
  )
@@ -120,7 +120,7 @@ class BaseModel(Generic[DatabaseT]):
120
120
  cls.__db__ = db
121
121
 
122
122
  @classmethod
123
- def register_db(cls, db: DatabaseT, *, alias: str) -> None:
123
+ def register_db(cls, db: DatabaseT, *, alias: str | None = None) -> None:
124
124
  """Give this model a database under an alias, and the ones under it too.
125
125
 
126
126
  The registry it goes in belongs to this class, so nothing global is
@@ -138,11 +138,21 @@ class BaseModel(Generic[DatabaseT]):
138
138
  the open `using()` block, in that order. A model left on the default
139
139
  alias follows `using()`, which is what makes the switch above work.
140
140
 
141
+ Without an alias it points the model at that database, the same as
142
+ [`set_db`][sqlakit.orm.ModelMixin.set_db], for an application with one:
143
+
144
+ ```python
145
+ Base.register_db(Database(DB_URL))
146
+ ```
147
+
141
148
  Raises:
142
149
  AliasInUseError: if another database holds that alias.
143
150
  DefaultAliasError: if the alias is `default`.
144
151
 
145
152
  """
153
+ if alias is None:
154
+ cls.set_db(db)
155
+ return
146
156
  if _owns_no_registry(cls):
147
157
  # A registry of its own: registering into the importable one would
148
158
  # configure it for every model in the process. A class under one
@@ -257,16 +267,21 @@ def db_for(model: type[Any]) -> BaseDatabase[Any, Any]:
257
267
  placement = model.__db__
258
268
  if isinstance(placement, str):
259
269
  if source is None:
260
- raise MissingDefaultDatabaseError
270
+ raise MissingRegistryError(model.__name__, placement)
261
271
  return source[placement]
262
272
  return placement
263
273
 
264
274
 
265
275
  def resolve_alias(model: type[Any], alias: str) -> BaseDatabase[Any, Any]:
266
- """Return the database a model knows under that alias."""
276
+ """Return the database a model knows under that alias.
277
+
278
+ Raises:
279
+ MissingRegistryError: if the model looks aliases up nowhere.
280
+
281
+ """
267
282
  source = getattr(model, "__dbs__", None)
268
283
  if source is None:
269
- raise MissingDefaultDatabaseError
284
+ raise MissingRegistryError(model.__name__, alias)
270
285
  return source[alias]
271
286
 
272
287
 
@@ -14,7 +14,7 @@ from typing import (
14
14
  NamedTuple,
15
15
  Protocol,
16
16
  Self,
17
- TypeVar,
17
+ TypeAlias,
18
18
  cast,
19
19
  )
20
20
 
@@ -31,6 +31,9 @@ from sqlalchemy.orm import (
31
31
  from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
32
32
  from sqlalchemy.sql import operators
33
33
 
34
+ # `TypeVar` from here, for the default that keeps `Page[User]` the counted one.
35
+ from typing_extensions import TypeVar
36
+
34
37
  from ._model import resolve_alias, soft_delete_column
35
38
  from .exceptions import (
36
39
  BulkQueryError,
@@ -63,9 +66,11 @@ __all__ = [
63
66
  "NullsPlacement",
64
67
  "OrderBy",
65
68
  "Page",
69
+ "UncountedPage",
66
70
  "one_row",
67
71
  "one_row_or_none",
68
72
  "orderable",
73
+ "orderable_columns",
69
74
  "ordered",
70
75
  ]
71
76
 
@@ -100,6 +105,8 @@ ONLY = "only"
100
105
 
101
106
  ModelT = TypeVar("ModelT")
102
107
  OtherT = TypeVar("OtherT")
108
+ TotalT = TypeVar("TotalT", bound="int | None", default=int)
109
+ """What a page knows about its total: an `int`, or `None` when nobody counted."""
103
110
  RowT = TypeVar("RowT")
104
111
  RowT_co = TypeVar("RowT_co", covariant=True)
105
112
 
@@ -170,18 +177,19 @@ def _compile_nulls_for_mysql(
170
177
 
171
178
 
172
179
  @dataclass(frozen=True, slots=True)
173
- class Page(Generic[ModelT]):
174
- """One page of rows, and how many there are in total."""
180
+ class Page(Generic[ModelT, TotalT]):
181
+ """One page of rows, and how many there are in total.
175
182
 
176
- items: Sequence[ModelT]
177
- total: int | None
178
- """How many rows match, or None for a page read without counting them."""
183
+ ``Page[User]`` is the counted page, whose ``total`` is an `int`.
184
+ ``page(total=False)`` returns a `Page[User, None]` instead, so the code
185
+ reading it is not asked about a total nobody counted.
186
+ """
179
187
 
188
+ items: Sequence[ModelT]
189
+ total: TotalT
180
190
  limit: int
181
191
  offset: int
182
-
183
192
  has_next: bool = False
184
- """Whether a page follows this one."""
185
193
 
186
194
  def __post_init__(self) -> None:
187
195
  if self.total is not None:
@@ -189,7 +197,7 @@ class Page(Generic[ModelT]):
189
197
  self, "has_next", self.offset + len(self.items) < self.total
190
198
  )
191
199
 
192
- def map(self, transform: Callable[[ModelT], OtherT]) -> Page[OtherT]:
200
+ def map(self, transform: Callable[[ModelT], OtherT]) -> Page[OtherT, TotalT]:
193
201
  """Return the page with every row put through ``transform``.
194
202
 
195
203
  ```python
@@ -201,7 +209,7 @@ class Page(Generic[ModelT]):
201
209
  def map_all(
202
210
  self,
203
211
  transform: Callable[[Sequence[ModelT]], Sequence[OtherT]],
204
- ) -> Page[OtherT]:
212
+ ) -> Page[OtherT, TotalT]:
205
213
  """Return the page with the rows put through ``transform`` together.
206
214
 
207
215
  For work that reads better in one go than row by row: one query for
@@ -209,7 +217,7 @@ class Page(Generic[ModelT]):
209
217
  """
210
218
  return self.with_items(transform(self.items))
211
219
 
212
- def with_items(self, items: Sequence[OtherT]) -> Page[OtherT]:
220
+ def with_items(self, items: Sequence[OtherT]) -> Page[OtherT, TotalT]:
213
221
  """Return the page carrying these rows instead, counts unchanged.
214
222
 
215
223
  What an asynchronous transform needs: `page.with_items(await serialize(...))`.
@@ -229,16 +237,24 @@ class Page(Generic[ModelT]):
229
237
  )
230
238
 
231
239
 
240
+ UncountedPage: TypeAlias = Page[ModelT, None]
241
+ """A page read with ``total=False``, which counted nothing.
242
+
243
+ The same class, named for what a signature means by it:
244
+
245
+ ```python
246
+ def feed(page: UncountedPage[User]) -> Response: ...
247
+ ```
248
+ """
249
+
250
+
232
251
  @dataclass(frozen=True, slots=True)
233
252
  class CursorPage(Generic[ModelT]):
234
253
  """One page of rows, and the cursors that read the ones either side."""
235
254
 
236
255
  items: Sequence[ModelT]
237
256
  next_cursor: str | None = None
238
- """Hand it back as ``cursor`` to read on."""
239
-
240
257
  previous_cursor: str | None = None
241
- """Hand it back as ``cursor`` to read the page in front of this one."""
242
258
 
243
259
  @property
244
260
  def has_next(self) -> bool:
@@ -278,10 +294,11 @@ class CursorPage(Generic[ModelT]):
278
294
 
279
295
 
280
296
  class OrderBy(NamedTuple):
281
- """A field to order by that lives in another table.
297
+ """A field to order by that a foreign key cannot reach on its own.
282
298
 
283
- Name the table, or the relationship that reaches it, and a query ordered by
284
- that field joins it once, however many fields name it:
299
+ A plain column of another table needs none of this: naming it in
300
+ ``__orderable__`` joins its table on the key between them. This is for what
301
+ that cannot answer, an alias, a subquery, or two paths to one table:
285
302
 
286
303
  ```python
287
304
  {"team": OrderBy(Team.name, join=cls.team)}
@@ -290,11 +307,16 @@ class OrderBy(NamedTuple):
290
307
  Join what holds one row. A collection multiplies the rows, and a page of
291
308
  multiplied rows counts wrong: join a subquery that aggregates them instead,
292
309
  with the ``on`` it needs.
310
+
311
+ The join is an outer one, so ordering by the field returns the rows with
312
+ nothing on the other side as well. ``outer=False`` makes it an inner join,
313
+ which drops them, and ``nulls`` on `order_by` says where they go.
293
314
  """
294
315
 
295
316
  expression: Any
296
317
  join: Any = None
297
318
  on: Any = None
319
+ outer: bool = True
298
320
 
299
321
 
300
322
  class SupportsClause(Protocol):
@@ -921,6 +943,29 @@ class BaseQuery(Generic[ModelT]):
921
943
  return _encode(values, backwards=backwards, ordering=ordering_key)
922
944
 
923
945
 
946
+ def orderable_columns(model: type[Any]) -> Mapping[str, Any]:
947
+ """Return every mapped column of a model, by name.
948
+
949
+ What a model orders by when it declares no ``__orderable__``, and what an
950
+ ``__orderable__`` that adds to the columns rather than replacing them starts
951
+ from:
952
+
953
+ ```python
954
+ @classmethod
955
+ def __orderable__(cls) -> Mapping[str, Any]:
956
+ return {
957
+ **orderable_columns(cls),
958
+ "team": OrderBy(Team.name, join=cls.team),
959
+ }
960
+ ```
961
+
962
+ Calling `orderable` there instead recurses: it reads the very
963
+ ``__orderable__`` that is running.
964
+ """
965
+ mapper = sa.inspect(model, raiseerr=True)
966
+ return {attr.key: getattr(model, attr.key) for attr in mapper.column_attrs}
967
+
968
+
924
969
  def orderable(model: type[Any]) -> Mapping[str, Any]:
925
970
  """Return the fields a model can be ordered by name.
926
971
 
@@ -930,8 +975,7 @@ def orderable(model: type[Any]) -> Mapping[str, Any]:
930
975
  """
931
976
  fields = getattr(model, "__orderable__", None)
932
977
  if fields is None:
933
- mapper = sa.inspect(model, raiseerr=True)
934
- return {attr.key: getattr(model, attr.key) for attr in mapper.column_attrs}
978
+ return orderable_columns(model)
935
979
  if callable(fields):
936
980
  return fields()
937
981
  return {name: _mapped_column(model, name) for name in fields}
@@ -975,9 +1019,9 @@ def ordered(
975
1019
  clause, join = _ordering_for(criterion, fields, ignore_case=folded)
976
1020
  clauses.append(_with_nulls(clause, nulls))
977
1021
  if join is not None:
978
- target, onclause = join
1022
+ target, onclause, outer = join
979
1023
  if not _is_joined(select, target):
980
- select = select.join(target, onclause)
1024
+ select = select.join(target, onclause, isouter=outer)
981
1025
  return select.order_by(*clauses)
982
1026
 
983
1027
 
@@ -989,16 +1033,16 @@ def _ordering_for(
989
1033
  ) -> tuple[Any, Any]:
990
1034
  """Return the clause a criterion stands for, and the table it needs."""
991
1035
  if isinstance(criterion, OrderBy):
992
- return criterion.expression, (criterion.join, criterion.on)
1036
+ return criterion.expression, (criterion.join, criterion.on, criterion.outer)
993
1037
  if not isinstance(criterion, str):
994
1038
  return criterion, None
995
1039
  asked, descending, nulls = _parse_sort_field(criterion)
996
1040
  name = _field_named(asked, fields)
997
1041
  field = fields[name]
998
1042
  if isinstance(field, OrderBy):
999
- column, join = field.expression, (field.join, field.on)
1043
+ column, join = field.expression, (field.join, field.on, field.outer)
1000
1044
  else:
1001
- column, join = field, None
1045
+ column, join = field, _join_for(field)
1002
1046
  if ignore_case is True or (ignore_case is not False and name in ignore_case):
1003
1047
  column = _case_insensitive(column)
1004
1048
  return _sort_clause(column, descending=descending, nulls=nulls), join
@@ -1117,6 +1161,21 @@ def _flatten(criteria: Iterable[Any]) -> Iterator[Any]:
1117
1161
  yield criterion
1118
1162
 
1119
1163
 
1164
+ def _join_for(field: Any) -> tuple[Any, None, bool] | None: # noqa: ANN401
1165
+ """Return the table a field lives in, for a field that is a plain column.
1166
+
1167
+ A column of another table is reachable only through a join, and `SQLAlchemy`
1168
+ works the condition out from the foreign key. An expression may name several
1169
+ tables or none, so it is left alone: `OrderBy` says what to join for those,
1170
+ as it does for an alias or a subquery.
1171
+ """
1172
+ column = _as_column(field)
1173
+ if not isinstance(column, sa.Column):
1174
+ return None
1175
+ table = getattr(column, "table", None)
1176
+ return None if table is None else (table, None, True)
1177
+
1178
+
1120
1179
  def _is_joined(select: sa.Select[Any], target: Any) -> bool: # noqa: ANN401
1121
1180
  """Whether this statement already reaches what a field needs."""
1122
1181
  wanted = _join_identity(target)
@@ -197,9 +197,19 @@ class Query(BaseQuery[ModelT]):
197
197
  """Check whether any row matches."""
198
198
  return bool(await self.db.session.scalar(self._exists_statement()))
199
199
 
200
+ @overload
201
+ async def page(
202
+ self, *, limit: int, offset: int = 0, total: Literal[True] = True
203
+ ) -> Page[ModelT]: ...
204
+
205
+ @overload
206
+ async def page(
207
+ self, *, limit: int, offset: int = 0, total: Literal[False]
208
+ ) -> Page[ModelT, None]: ...
209
+
200
210
  async def page(
201
211
  self, *, limit: int, offset: int = 0, total: bool = True
202
- ) -> Page[ModelT]:
212
+ ) -> Page[ModelT] | Page[ModelT, None]:
203
213
  """Read one page of rows, with the total.
204
214
 
205
215
  The model's key is appended to the ordering, so a row that ties with another
@@ -22,6 +22,7 @@ __all__ = [
22
22
  "MissingDatabaseUrlError",
23
23
  "MissingDefaultDatabaseError",
24
24
  "MissingDependencyError",
25
+ "MissingRegistryError",
25
26
  "MissingSessionError",
26
27
  "MultipleInstancesFoundError",
27
28
  "NullCursorValueError",
@@ -140,6 +141,20 @@ class UnknownDatabaseError(SQLAKitError, KeyError):
140
141
  )
141
142
 
142
143
 
144
+ class MissingRegistryError(SQLAKitError, ValueError):
145
+ """Raised when a model has nowhere to look a database alias up."""
146
+
147
+ def __init__(self, model: str, alias: str | None = None) -> None:
148
+ self.model = model
149
+ self.alias = alias
150
+ named = f"`{alias}`" if alias is not None else "an alias"
151
+ super().__init__(
152
+ f"`{model}` has no registry to look {named} up in. Pass the "
153
+ f"database itself instead of its name, or set `__dbs__` on the "
154
+ f"model to the registry that holds it."
155
+ )
156
+
157
+
143
158
  class MissingDefaultDatabaseError(SQLAKitError, ValueError):
144
159
  """Raised when a configuration keyed by alias carries no default."""
145
160
 
@@ -190,7 +190,19 @@ class Query(BaseQuery[ModelT]):
190
190
  """Check whether any row matches."""
191
191
  return bool(self.db.session.scalar(self._exists_statement()))
192
192
 
193
- def page(self, *, limit: int, offset: int = 0, total: bool = True) -> Page[ModelT]:
193
+ @overload
194
+ def page(
195
+ self, *, limit: int, offset: int = 0, total: Literal[True] = True
196
+ ) -> Page[ModelT]: ...
197
+
198
+ @overload
199
+ def page(
200
+ self, *, limit: int, offset: int = 0, total: Literal[False]
201
+ ) -> Page[ModelT, None]: ...
202
+
203
+ def page(
204
+ self, *, limit: int, offset: int = 0, total: bool = True
205
+ ) -> Page[ModelT] | Page[ModelT, None]:
194
206
  """Read one page of rows, with the total.
195
207
 
196
208
  The model's key is appended to the ordering, so a row that ties with another
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes