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.
- {sqlakit-0.6.0 → sqlakit-0.7.1}/PKG-INFO +1 -1
- {sqlakit-0.6.0 → sqlakit-0.7.1}/pyproject.toml +1 -1
- {sqlakit-0.6.0 → sqlakit-0.7.1}/pyproject.toml.orig +1 -1
- {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/__init__.py +4 -0
- {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/_model.py +20 -5
- {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/_query.py +59 -8
- {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/exceptions.py +28 -0
- {sqlakit-0.6.0 → sqlakit-0.7.1}/LICENSE +0 -0
- {sqlakit-0.6.0 → sqlakit-0.7.1}/README.md +0 -0
- {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/_base.py +0 -0
- {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/_db.py +0 -0
- {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/_discovery.py +0 -0
- {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/_recording.py +0 -0
- {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/_registry.py +0 -0
- {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/_routing.py +0 -0
- {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/_sql.py +0 -0
- {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/asyncio/__init__.py +0 -0
- {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/asyncio/_db.py +0 -0
- {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/asyncio/_registry.py +0 -0
- {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/asyncio/orm.py +0 -0
- {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/asyncio/sql.py +0 -0
- {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/orm.py +0 -0
- {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/py.typed +0 -0
- {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/sql.py +0 -0
- {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/testing.py +0 -0
- {sqlakit-0.6.0 → sqlakit-0.7.1}/sqlakit/types.py +0 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
298
|
+
"""A field to order by that a foreign key cannot reach on its own.
|
|
298
299
|
|
|
299
|
-
|
|
300
|
-
|
|
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,
|
|
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
|
|
File without changes
|
|
File without changes
|