sqlakit 0.6.0__tar.gz → 0.7.1__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.6.0 → sqlakit-0.7.1}/PKG-INFO +1 -1
  2. {sqlakit-0.6.0 → sqlakit-0.7.1}/pyproject.toml +1 -1
  3. {sqlakit-0.6.0 → sqlakit-0.7.1}/pyproject.toml.orig +1 -1
  4. {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/__init__.py +4 -0
  5. {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/_model.py +20 -5
  6. {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/_query.py +59 -8
  7. {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/exceptions.py +28 -0
  8. {sqlakit-0.6.0 → sqlakit-0.7.1}/LICENSE +0 -0
  9. {sqlakit-0.6.0 → sqlakit-0.7.1}/README.md +0 -0
  10. {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/_base.py +0 -0
  11. {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/_db.py +0 -0
  12. {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/_discovery.py +0 -0
  13. {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/_recording.py +0 -0
  14. {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/_registry.py +0 -0
  15. {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/_routing.py +0 -0
  16. {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/_sql.py +0 -0
  17. {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/asyncio/__init__.py +0 -0
  18. {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/asyncio/_db.py +0 -0
  19. {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/asyncio/_registry.py +0 -0
  20. {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/asyncio/orm.py +0 -0
  21. {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/asyncio/sql.py +0 -0
  22. {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/orm.py +0 -0
  23. {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/py.typed +0 -0
  24. {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/sql.py +0 -0
  25. {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/testing.py +0 -0
  26. {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlakit
3
- Version: 0.6.0
3
+ Version: 0.7.1
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.6.0"
3
+ version = "0.7.1"
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.6.0"
3
+ version = "0.7.1"
4
4
  description = "A toolkit for SQLAlchemy applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -18,6 +18,7 @@ from .exceptions import (
18
18
  AsyncFilterError,
19
19
  BulkQueryError,
20
20
  ConflictingDatabaseUrlError,
21
+ ConflictingJoinError,
21
22
  DatabaseAlreadyConfiguredError,
22
23
  DatabaseNotConfiguredError,
23
24
  DefaultAliasError,
@@ -32,6 +33,7 @@ from .exceptions import (
32
33
  MissingDatabaseUrlError,
33
34
  MissingDefaultDatabaseError,
34
35
  MissingDependencyError,
36
+ MissingRegistryError,
35
37
  MissingSessionError,
36
38
  MultipleInstancesFoundError,
37
39
  NullCursorValueError,
@@ -68,6 +70,7 @@ __all__ = [
68
70
  "AsyncFilterError",
69
71
  "BulkQueryError",
70
72
  "ConflictingDatabaseUrlError",
73
+ "ConflictingJoinError",
71
74
  "CursorPage",
72
75
  "Database",
73
76
  "DatabaseAlreadyConfiguredError",
@@ -87,6 +90,7 @@ __all__ = [
87
90
  "MissingDatabaseUrlError",
88
91
  "MissingDefaultDatabaseError",
89
92
  "MissingDependencyError",
93
+ "MissingRegistryError",
90
94
  "MissingSessionError",
91
95
  "MultipleInstancesFoundError",
92
96
  "NullCursorValueError",
@@ -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
 
@@ -37,6 +37,7 @@ from typing_extensions import TypeVar
37
37
  from ._model import resolve_alias, soft_delete_column
38
38
  from .exceptions import (
39
39
  BulkQueryError,
40
+ ConflictingJoinError,
40
41
  InstanceNotFoundError,
41
42
  InvalidCursorError,
42
43
  InvalidNullsError,
@@ -294,10 +295,11 @@ class CursorPage(Generic[ModelT]):
294
295
 
295
296
 
296
297
  class OrderBy(NamedTuple):
297
- """A field to order by that lives in another table.
298
+ """A field to order by that a foreign key cannot reach on its own.
298
299
 
299
- Name the table, or the relationship that reaches it, and a query ordered by
300
- that field joins it once, however many fields name it:
300
+ A plain column of another table needs none of this: naming it in
301
+ ``__orderable__`` joins its table on the key between them. This is for what
302
+ that cannot answer, an alias, a subquery, or two paths to one table:
301
303
 
302
304
  ```python
303
305
  {"team": OrderBy(Team.name, join=cls.team)}
@@ -306,11 +308,16 @@ class OrderBy(NamedTuple):
306
308
  Join what holds one row. A collection multiplies the rows, and a page of
307
309
  multiplied rows counts wrong: join a subquery that aggregates them instead,
308
310
  with the ``on`` it needs.
311
+
312
+ The join is an outer one, so ordering by the field returns the rows with
313
+ nothing on the other side as well. ``outer=False`` makes it an inner join,
314
+ which drops them, and ``nulls`` on `order_by` says where they go.
309
315
  """
310
316
 
311
317
  expression: Any
312
318
  join: Any = None
313
319
  on: Any = None
320
+ outer: bool = True
314
321
 
315
322
 
316
323
  class SupportsClause(Protocol):
@@ -1009,13 +1016,15 @@ def ordered(
1009
1016
  else {_field_named(one, fields) for one in ignore_case}
1010
1017
  )
1011
1018
  clauses = []
1019
+ joined: dict[str, Any] = {}
1012
1020
  for criterion in named:
1013
1021
  clause, join = _ordering_for(criterion, fields, ignore_case=folded)
1014
1022
  clauses.append(_with_nulls(clause, nulls))
1015
1023
  if join is not None:
1016
- target, onclause = join
1024
+ target, onclause, outer = join
1025
+ _reject_conflicting_join(joined, target, onclause)
1017
1026
  if not _is_joined(select, target):
1018
- select = select.join(target, onclause)
1027
+ select = select.join(target, onclause, isouter=outer)
1019
1028
  return select.order_by(*clauses)
1020
1029
 
1021
1030
 
@@ -1027,16 +1036,16 @@ def _ordering_for(
1027
1036
  ) -> tuple[Any, Any]:
1028
1037
  """Return the clause a criterion stands for, and the table it needs."""
1029
1038
  if isinstance(criterion, OrderBy):
1030
- return criterion.expression, (criterion.join, criterion.on)
1039
+ return criterion.expression, (criterion.join, criterion.on, criterion.outer)
1031
1040
  if not isinstance(criterion, str):
1032
1041
  return criterion, None
1033
1042
  asked, descending, nulls = _parse_sort_field(criterion)
1034
1043
  name = _field_named(asked, fields)
1035
1044
  field = fields[name]
1036
1045
  if isinstance(field, OrderBy):
1037
- column, join = field.expression, (field.join, field.on)
1046
+ column, join = field.expression, (field.join, field.on, field.outer)
1038
1047
  else:
1039
- column, join = field, None
1048
+ column, join = field, _join_for(field)
1040
1049
  if ignore_case is True or (ignore_case is not False and name in ignore_case):
1041
1050
  column = _case_insensitive(column)
1042
1051
  return _sort_clause(column, descending=descending, nulls=nulls), join
@@ -1155,6 +1164,48 @@ def _flatten(criteria: Iterable[Any]) -> Iterator[Any]:
1155
1164
  yield criterion
1156
1165
 
1157
1166
 
1167
+ def _join_for(field: Any) -> tuple[Any, None, bool] | None: # noqa: ANN401
1168
+ """Return the table a field lives in, for a field that is a plain column.
1169
+
1170
+ A column of another table is reachable only through a join, and `SQLAlchemy`
1171
+ works the condition out from the foreign key. An expression may name several
1172
+ tables or none, so it is left alone: `OrderBy` says what to join for those,
1173
+ as it does for an alias or a subquery.
1174
+ """
1175
+ column = _as_column(field)
1176
+ if not isinstance(column, sa.Column):
1177
+ return None
1178
+ table = getattr(column, "table", None)
1179
+ return None if table is None else (table, None, True)
1180
+
1181
+
1182
+ def _reject_conflicting_join(
1183
+ joined: dict[str, Any],
1184
+ target: Any, # noqa: ANN401
1185
+ onclause: Any, # noqa: ANN401
1186
+ ) -> None:
1187
+ """Refuse a second join of one table on another condition.
1188
+
1189
+ A statement joins a table once, so the second condition would be dropped
1190
+ and the field would order by the first one's rows.
1191
+
1192
+ Raises:
1193
+ ConflictingJoinError: if the table is already joined on something else.
1194
+
1195
+ """
1196
+ identity = _join_identity(target)
1197
+ if identity is None:
1198
+ return
1199
+ if identity not in joined:
1200
+ joined[identity] = onclause
1201
+ return
1202
+ first = joined[identity]
1203
+ if first is None or onclause is None:
1204
+ return
1205
+ if not first.compare(onclause):
1206
+ raise ConflictingJoinError(identity)
1207
+
1208
+
1158
1209
  def _is_joined(select: sa.Select[Any], target: Any) -> bool: # noqa: ANN401
1159
1210
  """Whether this statement already reaches what a field needs."""
1160
1211
  wanted = _join_identity(target)
@@ -9,6 +9,7 @@ __all__ = [
9
9
  "AsyncFilterError",
10
10
  "BulkQueryError",
11
11
  "ConflictingDatabaseUrlError",
12
+ "ConflictingJoinError",
12
13
  "DatabaseAlreadyConfiguredError",
13
14
  "DatabaseNotConfiguredError",
14
15
  "DefaultAliasError",
@@ -22,6 +23,7 @@ __all__ = [
22
23
  "MissingDatabaseUrlError",
23
24
  "MissingDefaultDatabaseError",
24
25
  "MissingDependencyError",
26
+ "MissingRegistryError",
25
27
  "MissingSessionError",
26
28
  "MultipleInstancesFoundError",
27
29
  "NullCursorValueError",
@@ -140,6 +142,20 @@ class UnknownDatabaseError(SQLAKitError, KeyError):
140
142
  )
141
143
 
142
144
 
145
+ class MissingRegistryError(SQLAKitError, ValueError):
146
+ """Raised when a model has nowhere to look a database alias up."""
147
+
148
+ def __init__(self, model: str, alias: str | None = None) -> None:
149
+ self.model = model
150
+ self.alias = alias
151
+ named = f"`{alias}`" if alias is not None else "an alias"
152
+ super().__init__(
153
+ f"`{model}` has no registry to look {named} up in. Pass the "
154
+ f"database itself instead of its name, or set `__dbs__` on the "
155
+ f"model to the registry that holds it."
156
+ )
157
+
158
+
143
159
  class MissingDefaultDatabaseError(SQLAKitError, ValueError):
144
160
  """Raised when a configuration keyed by alias carries no default."""
145
161
 
@@ -411,6 +427,18 @@ class InvalidOrderFieldError(SQLAKitError, TypeError):
411
427
  )
412
428
 
413
429
 
430
+ class ConflictingJoinError(SQLAKitError, TypeError):
431
+ """Raised when two ordering fields join one table on different conditions."""
432
+
433
+ def __init__(self, table: str) -> None:
434
+ self.table = table
435
+ super().__init__(
436
+ f"Two ordering fields join `{table}` on different conditions, and a "
437
+ f"statement joins it once. Alias it with `sqlalchemy.orm.aliased` so "
438
+ f"that each field joins an alias of its own."
439
+ )
440
+
441
+
414
442
  class InvalidNullsError(SQLAKitError, ValueError):
415
443
  """Raised when ``order_by`` is told to put the nulls somewhere else.
416
444
 
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
File without changes
File without changes