sqlakit 0.6.0__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.6.0 → sqlakit-0.7.0}/PKG-INFO +1 -1
  2. {sqlakit-0.6.0 → sqlakit-0.7.0}/pyproject.toml +1 -1
  3. {sqlakit-0.6.0 → sqlakit-0.7.0}/pyproject.toml.orig +1 -1
  4. {sqlakit-0.6.0 → sqlakit-0.7.0}/sqlakit/__init__.py +2 -0
  5. {sqlakit-0.6.0 → sqlakit-0.7.0}/sqlakit/_model.py +20 -5
  6. {sqlakit-0.6.0 → sqlakit-0.7.0}/sqlakit/_query.py +29 -8
  7. {sqlakit-0.6.0 → sqlakit-0.7.0}/sqlakit/exceptions.py +15 -0
  8. {sqlakit-0.6.0 → sqlakit-0.7.0}/LICENSE +0 -0
  9. {sqlakit-0.6.0 → sqlakit-0.7.0}/README.md +0 -0
  10. {sqlakit-0.6.0 → sqlakit-0.7.0}/sqlakit/_base.py +0 -0
  11. {sqlakit-0.6.0 → sqlakit-0.7.0}/sqlakit/_db.py +0 -0
  12. {sqlakit-0.6.0 → sqlakit-0.7.0}/sqlakit/_discovery.py +0 -0
  13. {sqlakit-0.6.0 → sqlakit-0.7.0}/sqlakit/_recording.py +0 -0
  14. {sqlakit-0.6.0 → sqlakit-0.7.0}/sqlakit/_registry.py +0 -0
  15. {sqlakit-0.6.0 → sqlakit-0.7.0}/sqlakit/_routing.py +0 -0
  16. {sqlakit-0.6.0 → sqlakit-0.7.0}/sqlakit/_sql.py +0 -0
  17. {sqlakit-0.6.0 → sqlakit-0.7.0}/sqlakit/asyncio/__init__.py +0 -0
  18. {sqlakit-0.6.0 → sqlakit-0.7.0}/sqlakit/asyncio/_db.py +0 -0
  19. {sqlakit-0.6.0 → sqlakit-0.7.0}/sqlakit/asyncio/_registry.py +0 -0
  20. {sqlakit-0.6.0 → sqlakit-0.7.0}/sqlakit/asyncio/orm.py +0 -0
  21. {sqlakit-0.6.0 → sqlakit-0.7.0}/sqlakit/asyncio/sql.py +0 -0
  22. {sqlakit-0.6.0 → sqlakit-0.7.0}/sqlakit/orm.py +0 -0
  23. {sqlakit-0.6.0 → sqlakit-0.7.0}/sqlakit/py.typed +0 -0
  24. {sqlakit-0.6.0 → sqlakit-0.7.0}/sqlakit/sql.py +0 -0
  25. {sqlakit-0.6.0 → sqlakit-0.7.0}/sqlakit/testing.py +0 -0
  26. {sqlakit-0.6.0 → 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.6.0
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.6.0"
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.6.0"
3
+ version = "0.7.0"
4
4
  description = "A toolkit for SQLAlchemy applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -32,6 +32,7 @@ from .exceptions import (
32
32
  MissingDatabaseUrlError,
33
33
  MissingDefaultDatabaseError,
34
34
  MissingDependencyError,
35
+ MissingRegistryError,
35
36
  MissingSessionError,
36
37
  MultipleInstancesFoundError,
37
38
  NullCursorValueError,
@@ -87,6 +88,7 @@ __all__ = [
87
88
  "MissingDatabaseUrlError",
88
89
  "MissingDefaultDatabaseError",
89
90
  "MissingDependencyError",
91
+ "MissingRegistryError",
90
92
  "MissingSessionError",
91
93
  "MultipleInstancesFoundError",
92
94
  "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
 
@@ -294,10 +294,11 @@ class CursorPage(Generic[ModelT]):
294
294
 
295
295
 
296
296
  class OrderBy(NamedTuple):
297
- """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.
298
298
 
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:
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:
301
302
 
302
303
  ```python
303
304
  {"team": OrderBy(Team.name, join=cls.team)}
@@ -306,11 +307,16 @@ class OrderBy(NamedTuple):
306
307
  Join what holds one row. A collection multiplies the rows, and a page of
307
308
  multiplied rows counts wrong: join a subquery that aggregates them instead,
308
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.
309
314
  """
310
315
 
311
316
  expression: Any
312
317
  join: Any = None
313
318
  on: Any = None
319
+ outer: bool = True
314
320
 
315
321
 
316
322
  class SupportsClause(Protocol):
@@ -1013,9 +1019,9 @@ def ordered(
1013
1019
  clause, join = _ordering_for(criterion, fields, ignore_case=folded)
1014
1020
  clauses.append(_with_nulls(clause, nulls))
1015
1021
  if join is not None:
1016
- target, onclause = join
1022
+ target, onclause, outer = join
1017
1023
  if not _is_joined(select, target):
1018
- select = select.join(target, onclause)
1024
+ select = select.join(target, onclause, isouter=outer)
1019
1025
  return select.order_by(*clauses)
1020
1026
 
1021
1027
 
@@ -1027,16 +1033,16 @@ def _ordering_for(
1027
1033
  ) -> tuple[Any, Any]:
1028
1034
  """Return the clause a criterion stands for, and the table it needs."""
1029
1035
  if isinstance(criterion, OrderBy):
1030
- return criterion.expression, (criterion.join, criterion.on)
1036
+ return criterion.expression, (criterion.join, criterion.on, criterion.outer)
1031
1037
  if not isinstance(criterion, str):
1032
1038
  return criterion, None
1033
1039
  asked, descending, nulls = _parse_sort_field(criterion)
1034
1040
  name = _field_named(asked, fields)
1035
1041
  field = fields[name]
1036
1042
  if isinstance(field, OrderBy):
1037
- column, join = field.expression, (field.join, field.on)
1043
+ column, join = field.expression, (field.join, field.on, field.outer)
1038
1044
  else:
1039
- column, join = field, None
1045
+ column, join = field, _join_for(field)
1040
1046
  if ignore_case is True or (ignore_case is not False and name in ignore_case):
1041
1047
  column = _case_insensitive(column)
1042
1048
  return _sort_clause(column, descending=descending, nulls=nulls), join
@@ -1155,6 +1161,21 @@ def _flatten(criteria: Iterable[Any]) -> Iterator[Any]:
1155
1161
  yield criterion
1156
1162
 
1157
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
+
1158
1179
  def _is_joined(select: sa.Select[Any], target: Any) -> bool: # noqa: ANN401
1159
1180
  """Whether this statement already reaches what a field needs."""
1160
1181
  wanted = _join_identity(target)
@@ -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
 
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