sqlakit 0.4.0__tar.gz → 0.5.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.4.0 → sqlakit-0.5.1}/PKG-INFO +1 -1
- {sqlakit-0.4.0 → sqlakit-0.5.1}/pyproject.toml +1 -1
- {sqlakit-0.4.0 → sqlakit-0.5.1}/pyproject.toml.orig +1 -1
- {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/__init__.py +15 -1
- {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/_base.py +34 -5
- {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/_model.py +57 -0
- {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/_query.py +121 -10
- {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/asyncio/orm.py +3 -0
- {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/exceptions.py +36 -0
- {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/orm.py +3 -0
- {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/types.py +16 -2
- {sqlakit-0.4.0 → sqlakit-0.5.1}/LICENSE +0 -0
- {sqlakit-0.4.0 → sqlakit-0.5.1}/README.md +0 -0
- {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/_db.py +0 -0
- {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/_discovery.py +0 -0
- {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/_recording.py +0 -0
- {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/_registry.py +0 -0
- {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/_routing.py +0 -0
- {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/_sql.py +0 -0
- {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/asyncio/__init__.py +0 -0
- {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/asyncio/_db.py +0 -0
- {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/asyncio/_registry.py +0 -0
- {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/asyncio/sql.py +0 -0
- {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/py.typed +0 -0
- {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/sql.py +0 -0
- {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/testing.py +0 -0
|
@@ -7,15 +7,18 @@ from ._registry import Databases, db
|
|
|
7
7
|
from ._routing import Router
|
|
8
8
|
from .exceptions import (
|
|
9
9
|
DEFAULT_ALIAS,
|
|
10
|
+
AliasInUseError,
|
|
10
11
|
AsyncFilterError,
|
|
11
12
|
BulkQueryError,
|
|
12
13
|
ConflictingDatabaseUrlError,
|
|
13
14
|
DatabaseAlreadyConfiguredError,
|
|
14
15
|
DatabaseNotConfiguredError,
|
|
16
|
+
DefaultAliasError,
|
|
15
17
|
DetachedInstanceError,
|
|
16
18
|
InstanceNotFoundError,
|
|
17
19
|
InvalidCursorError,
|
|
18
20
|
InvalidDatabaseConfigError,
|
|
21
|
+
InvalidNullsError,
|
|
19
22
|
InvalidOrderFieldError,
|
|
20
23
|
KeyLookupError,
|
|
21
24
|
MissingConnectionError,
|
|
@@ -40,13 +43,21 @@ from .exceptions import (
|
|
|
40
43
|
UnknownOrderFieldError,
|
|
41
44
|
UnorderedPageError,
|
|
42
45
|
)
|
|
43
|
-
from .types import
|
|
46
|
+
from .types import (
|
|
47
|
+
DatabaseConfig,
|
|
48
|
+
EngineArgs,
|
|
49
|
+
QueryStats,
|
|
50
|
+
SessionArgs,
|
|
51
|
+
TemplatesLike,
|
|
52
|
+
UrlParts,
|
|
53
|
+
)
|
|
44
54
|
|
|
45
55
|
__all__ = [
|
|
46
56
|
"CASE_INSENSITIVE_COLLATIONS",
|
|
47
57
|
"DEFAULT_ALIAS",
|
|
48
58
|
"DEFAULT_ENGINE_ARGS",
|
|
49
59
|
"DEFAULT_SESSION_ARGS",
|
|
60
|
+
"AliasInUseError",
|
|
50
61
|
"AsyncFilterError",
|
|
51
62
|
"BulkQueryError",
|
|
52
63
|
"ConflictingDatabaseUrlError",
|
|
@@ -56,11 +67,13 @@ __all__ = [
|
|
|
56
67
|
"DatabaseConfig",
|
|
57
68
|
"DatabaseNotConfiguredError",
|
|
58
69
|
"Databases",
|
|
70
|
+
"DefaultAliasError",
|
|
59
71
|
"DetachedInstanceError",
|
|
60
72
|
"EngineArgs",
|
|
61
73
|
"InstanceNotFoundError",
|
|
62
74
|
"InvalidCursorError",
|
|
63
75
|
"InvalidDatabaseConfigError",
|
|
76
|
+
"InvalidNullsError",
|
|
64
77
|
"InvalidOrderFieldError",
|
|
65
78
|
"KeyLookupError",
|
|
66
79
|
"MissingConnectionError",
|
|
@@ -85,6 +98,7 @@ __all__ = [
|
|
|
85
98
|
"Statement",
|
|
86
99
|
"StrayParameterError",
|
|
87
100
|
"TemplateNotFoundError",
|
|
101
|
+
"TemplatesLike",
|
|
88
102
|
"Transaction",
|
|
89
103
|
"TransactionRolledBackError",
|
|
90
104
|
"UncomparableOrderingError",
|
|
@@ -35,9 +35,11 @@ from ._recording import (
|
|
|
35
35
|
from ._routing import Router, as_router
|
|
36
36
|
from .exceptions import (
|
|
37
37
|
DEFAULT_ALIAS,
|
|
38
|
+
AliasInUseError,
|
|
38
39
|
ConflictingDatabaseUrlError,
|
|
39
40
|
DatabaseAlreadyConfiguredError,
|
|
40
41
|
DatabaseNotConfiguredError,
|
|
42
|
+
DefaultAliasError,
|
|
41
43
|
MissingConnectionError,
|
|
42
44
|
MissingDatabaseUrlError,
|
|
43
45
|
MissingDefaultDatabaseError,
|
|
@@ -49,16 +51,18 @@ from .exceptions import (
|
|
|
49
51
|
if TYPE_CHECKING:
|
|
50
52
|
import logging
|
|
51
53
|
from collections.abc import Iterator, Sequence
|
|
52
|
-
from pathlib import Path
|
|
53
54
|
|
|
54
55
|
from sqlalchemy.engine import Engine
|
|
55
56
|
|
|
56
|
-
from .
|
|
57
|
-
|
|
57
|
+
from .types import (
|
|
58
|
+
DatabaseConfig,
|
|
59
|
+
EngineArgs,
|
|
60
|
+
SessionArgs,
|
|
61
|
+
TemplatesLike,
|
|
62
|
+
UrlParts,
|
|
63
|
+
)
|
|
58
64
|
|
|
59
65
|
RouterFunction = Callable[[type[Any]], str | None]
|
|
60
|
-
TemplatesLike = str | Path | Sequence[str | Path] | Templates
|
|
61
|
-
"""Where a database's SQL templates are: a path, several, or the object."""
|
|
62
66
|
|
|
63
67
|
__all__ = [
|
|
64
68
|
"DEFAULT_ALIAS",
|
|
@@ -691,6 +695,31 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
|
|
|
691
695
|
def __contains__(self, alias: str) -> bool:
|
|
692
696
|
return alias == DEFAULT_ALIAS or alias in self._aliased
|
|
693
697
|
|
|
698
|
+
def register(self, alias: str, db: DatabaseT) -> None:
|
|
699
|
+
"""Put a database already built under an alias.
|
|
700
|
+
|
|
701
|
+
`configure` takes settings and builds the databases. This takes one you
|
|
702
|
+
built yourself, for a shard that only exists once the application is
|
|
703
|
+
running, or for a registry that never reads settings at all:
|
|
704
|
+
|
|
705
|
+
```python
|
|
706
|
+
db.register("shard-7", Database(SHARD_URL))
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
The alias has to be free. Replacing one under a name already in use
|
|
710
|
+
would leave the code that holds the old database talking to it.
|
|
711
|
+
|
|
712
|
+
Raises:
|
|
713
|
+
AliasInUseError: if another database holds that alias.
|
|
714
|
+
DefaultAliasError: if the alias is `default`, which this registry is.
|
|
715
|
+
|
|
716
|
+
"""
|
|
717
|
+
if alias == DEFAULT_ALIAS:
|
|
718
|
+
raise DefaultAliasError
|
|
719
|
+
if alias in self._aliased:
|
|
720
|
+
raise AliasInUseError(alias)
|
|
721
|
+
self._aliased[alias] = self._named(alias, db)
|
|
722
|
+
|
|
694
723
|
@contextmanager
|
|
695
724
|
def recording(
|
|
696
725
|
self,
|
|
@@ -37,7 +37,9 @@ __all__ = [
|
|
|
37
37
|
"BaseModel",
|
|
38
38
|
"BaseSoftDeletes",
|
|
39
39
|
"DatabaseDescriptor",
|
|
40
|
+
"DatabaseRegistry",
|
|
40
41
|
"DatabaseSource",
|
|
42
|
+
"RegistryDescriptor",
|
|
41
43
|
"db_for",
|
|
42
44
|
"resolve_alias",
|
|
43
45
|
"soft_delete_column",
|
|
@@ -66,6 +68,19 @@ class DatabaseDescriptor(Generic[DatabaseT]):
|
|
|
66
68
|
return cast("DatabaseT", db_for(owner))
|
|
67
69
|
|
|
68
70
|
|
|
71
|
+
class DatabaseRegistry(DatabaseSource, Protocol):
|
|
72
|
+
"""A source that also takes a database under an alias it does not have."""
|
|
73
|
+
|
|
74
|
+
def register(self, alias: str, db: Any) -> None: ... # noqa: ANN401
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class RegistryDescriptor:
|
|
78
|
+
"""Reads ``__dbs__``, on the class as well as on an instance."""
|
|
79
|
+
|
|
80
|
+
def __get__(self, instance: object | None, owner: type[Any]) -> Any: # noqa: ANN401
|
|
81
|
+
return owner.__dbs__
|
|
82
|
+
|
|
83
|
+
|
|
69
84
|
class BaseModel(Generic[DatabaseT]):
|
|
70
85
|
"""What the sync and async models share: everything that is not IO.
|
|
71
86
|
|
|
@@ -87,6 +102,7 @@ class BaseModel(Generic[DatabaseT]):
|
|
|
87
102
|
# Unannotated on purpose: an annotation here reads as a field to
|
|
88
103
|
# pydantic, and SQLModel models would refuse to build.
|
|
89
104
|
db = DatabaseDescriptor[DatabaseT]()
|
|
105
|
+
dbs = RegistryDescriptor()
|
|
90
106
|
|
|
91
107
|
@classmethod
|
|
92
108
|
def set_db(cls, db: str | DatabaseT) -> None:
|
|
@@ -103,6 +119,37 @@ class BaseModel(Generic[DatabaseT]):
|
|
|
103
119
|
"""
|
|
104
120
|
cls.__db__ = db
|
|
105
121
|
|
|
122
|
+
@classmethod
|
|
123
|
+
def register_db(cls, db: DatabaseT, *, alias: str) -> None:
|
|
124
|
+
"""Give this model a database under an alias, and the ones under it too.
|
|
125
|
+
|
|
126
|
+
The registry it goes in belongs to this class, so nothing global is
|
|
127
|
+
configured and two sets of models can each have their own `shard`:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
Base.register_db(Database(DB1_URL), alias="db1")
|
|
131
|
+
Base.register_db(Database(DB2_URL), alias="db2")
|
|
132
|
+
|
|
133
|
+
with Base.dbs.using("db2").transaction():
|
|
134
|
+
User(name="ada").save()
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Which database a model resolves to is still `__db__`, the routers and
|
|
138
|
+
the open `using()` block, in that order. A model left on the default
|
|
139
|
+
alias follows `using()`, which is what makes the switch above work.
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
AliasInUseError: if another database holds that alias.
|
|
143
|
+
DefaultAliasError: if the alias is `default`.
|
|
144
|
+
|
|
145
|
+
"""
|
|
146
|
+
if _owns_no_registry(cls):
|
|
147
|
+
# A registry of its own: registering into the importable one would
|
|
148
|
+
# configure it for every model in the process. A class under one
|
|
149
|
+
# that already has its own registers into that one.
|
|
150
|
+
cls.__dbs__ = type(cls.__dbs__)()
|
|
151
|
+
cast("DatabaseRegistry", cls.__dbs__).register(alias, db)
|
|
152
|
+
|
|
106
153
|
def update(self, values: Mapping[str, Any]) -> Self:
|
|
107
154
|
"""Set these fields on this instance, and return it.
|
|
108
155
|
|
|
@@ -187,6 +234,16 @@ class BaseModel(Generic[DatabaseT]):
|
|
|
187
234
|
self.db.session.add(self)
|
|
188
235
|
|
|
189
236
|
|
|
237
|
+
def _owns_no_registry(model: type[Any]) -> bool:
|
|
238
|
+
"""Whether this model still looks its aliases up in the importable registry."""
|
|
239
|
+
declared = next(
|
|
240
|
+
(klass for klass in model.__mro__ if "__dbs__" in klass.__dict__), None
|
|
241
|
+
)
|
|
242
|
+
return (
|
|
243
|
+
declared is None or declared.__module__.split(".")[0] == __name__.split(".")[0]
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
190
247
|
def db_for(model: type[Any]) -> BaseDatabase[Any, Any]:
|
|
191
248
|
"""Return the database a model lives on.
|
|
192
249
|
|
|
@@ -10,6 +10,7 @@ from typing import (
|
|
|
10
10
|
TYPE_CHECKING,
|
|
11
11
|
Any,
|
|
12
12
|
Generic,
|
|
13
|
+
Literal,
|
|
13
14
|
NamedTuple,
|
|
14
15
|
Protocol,
|
|
15
16
|
Self,
|
|
@@ -35,6 +36,7 @@ from .exceptions import (
|
|
|
35
36
|
BulkQueryError,
|
|
36
37
|
InstanceNotFoundError,
|
|
37
38
|
InvalidCursorError,
|
|
39
|
+
InvalidNullsError,
|
|
38
40
|
InvalidOrderFieldError,
|
|
39
41
|
KeyLookupError,
|
|
40
42
|
MultipleInstancesFoundError,
|
|
@@ -58,6 +60,7 @@ __all__ = [
|
|
|
58
60
|
"BaseQuery",
|
|
59
61
|
"CaseInsensitive",
|
|
60
62
|
"CursorPage",
|
|
63
|
+
"NullsPlacement",
|
|
61
64
|
"OrderBy",
|
|
62
65
|
"Page",
|
|
63
66
|
"one_row",
|
|
@@ -76,6 +79,11 @@ Name the collation you created, once, before any query runs:
|
|
|
76
79
|
sqlakit.CASE_INSENSITIVE_COLLATIONS["postgresql"] = "und-ci-ai"
|
|
77
80
|
```
|
|
78
81
|
|
|
82
|
+
`lower()` folds the case and leaves the accents, so it orders differently
|
|
83
|
+
from a collation like `und-ci-ai`, and it cannot read an index built on the
|
|
84
|
+
column. A column that already carries a case-insensitive collation needs no
|
|
85
|
+
`ignore_case` at all: name the same collation here, or leave it off.
|
|
86
|
+
|
|
79
87
|
A collation decides the whole order, the alphabet and the accents along with
|
|
80
88
|
the case. This one is asked for only by `ignore_case`. To sort by another,
|
|
81
89
|
name it on the column: `User.name.collate("de-DE")`.
|
|
@@ -123,6 +131,44 @@ def _compile_case_insensitive(
|
|
|
123
131
|
return compiler.process(sa.collate(element.element, collation), **kw)
|
|
124
132
|
|
|
125
133
|
|
|
134
|
+
class NullsPlacement(sa.ColumnElement[Any]):
|
|
135
|
+
"""An ordering clause that says where the rows with no value go.
|
|
136
|
+
|
|
137
|
+
`MySQL` and `MariaDB` have no `NULLS FIRST` or `NULLS LAST`, so there the
|
|
138
|
+
clause comes out as the two the standard is short for: whether the value is
|
|
139
|
+
null, and then the ordering itself.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
inherit_cache = True
|
|
143
|
+
|
|
144
|
+
def __init__(self, clause: Any, *, last: bool) -> None: # noqa: ANN401
|
|
145
|
+
self.clause = clause
|
|
146
|
+
self.last = last
|
|
147
|
+
self.type = sa.Boolean()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@compiles(NullsPlacement)
|
|
151
|
+
def _compile_nulls(
|
|
152
|
+
element: NullsPlacement,
|
|
153
|
+
compiler: Any, # noqa: ANN401
|
|
154
|
+
**kw: Any, # noqa: ANN401
|
|
155
|
+
) -> str:
|
|
156
|
+
placed = sa.nulls_last if element.last else sa.nulls_first
|
|
157
|
+
return compiler.process(placed(element.clause), **kw)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@compiles(NullsPlacement, "mysql")
|
|
161
|
+
def _compile_nulls_for_mysql(
|
|
162
|
+
element: NullsPlacement,
|
|
163
|
+
compiler: Any, # noqa: ANN401
|
|
164
|
+
**kw: Any, # noqa: ANN401
|
|
165
|
+
) -> str:
|
|
166
|
+
column, _ = _direction(element.clause)
|
|
167
|
+
empty = column.is_(None)
|
|
168
|
+
first = compiler.process(empty.asc() if element.last else empty.desc(), **kw)
|
|
169
|
+
return f"{first}, {compiler.process(element.clause, **kw)}"
|
|
170
|
+
|
|
171
|
+
|
|
126
172
|
@dataclass(frozen=True, slots=True)
|
|
127
173
|
class Page(Generic[ModelT]):
|
|
128
174
|
"""One page of rows, and how many there are in total."""
|
|
@@ -466,6 +512,7 @@ class BaseQuery(Generic[ModelT]):
|
|
|
466
512
|
self,
|
|
467
513
|
*criteria: Any, # noqa: ANN401
|
|
468
514
|
ignore_case: bool | Sequence[str] = False,
|
|
515
|
+
nulls: Literal["first", "last"] | None = None,
|
|
469
516
|
) -> Self:
|
|
470
517
|
"""Order the rows, by columns or by the sort strings a request carries.
|
|
471
518
|
|
|
@@ -498,14 +545,26 @@ class BaseQuery(Generic[ModelT]):
|
|
|
498
545
|
A model sorts by its own mapped columns. `orderable` says how to offer
|
|
499
546
|
others, including fields that are not columns at all.
|
|
500
547
|
|
|
548
|
+
``nulls`` says where the rows with no value go, `first` or `last`. It
|
|
549
|
+
fills in only what neither the sort string nor the model said, which the
|
|
550
|
+
database would otherwise answer for itself, differently by dialect and
|
|
551
|
+
by direction.
|
|
552
|
+
|
|
501
553
|
Raises:
|
|
502
554
|
UnknownOrderFieldError: if a string names a field the model does not
|
|
503
555
|
offer.
|
|
556
|
+
InvalidNullsError: if ``nulls`` is neither `first` nor `last`.
|
|
504
557
|
|
|
505
558
|
"""
|
|
506
559
|
self._reject_statement("order_by")
|
|
507
560
|
return self.with_select(
|
|
508
|
-
ordered(
|
|
561
|
+
ordered(
|
|
562
|
+
self._select,
|
|
563
|
+
self._orderable(),
|
|
564
|
+
criteria,
|
|
565
|
+
ignore_case=ignore_case,
|
|
566
|
+
nulls=nulls,
|
|
567
|
+
)
|
|
509
568
|
)
|
|
510
569
|
|
|
511
570
|
def _directed(self, column: Any, *, descending: bool) -> Any: # noqa: ANN401
|
|
@@ -892,20 +951,29 @@ def ordered(
|
|
|
892
951
|
criteria: Iterable[Any],
|
|
893
952
|
*,
|
|
894
953
|
ignore_case: bool | Sequence[str] = False,
|
|
954
|
+
nulls: str | None = None,
|
|
895
955
|
) -> sa.Select[Any]:
|
|
896
956
|
"""Return the statement ordered by these criteria, joining what they need.
|
|
897
957
|
|
|
898
958
|
Raises:
|
|
899
959
|
UnknownOrderFieldError: if a name is not one of the fields.
|
|
960
|
+
InvalidNullsError: if ``nulls`` is neither "first" nor "last".
|
|
900
961
|
|
|
901
962
|
"""
|
|
902
963
|
named = list(_flatten(criteria))
|
|
903
964
|
if not named:
|
|
904
965
|
return select
|
|
966
|
+
if nulls not in (None, "first", "last"):
|
|
967
|
+
raise InvalidNullsError(nulls)
|
|
968
|
+
folded = (
|
|
969
|
+
ignore_case
|
|
970
|
+
if isinstance(ignore_case, bool)
|
|
971
|
+
else {_field_named(one, fields) for one in ignore_case}
|
|
972
|
+
)
|
|
905
973
|
clauses = []
|
|
906
974
|
for criterion in named:
|
|
907
|
-
clause, join = _ordering_for(criterion, fields, ignore_case=
|
|
908
|
-
clauses.append(clause)
|
|
975
|
+
clause, join = _ordering_for(criterion, fields, ignore_case=folded)
|
|
976
|
+
clauses.append(_with_nulls(clause, nulls))
|
|
909
977
|
if join is not None:
|
|
910
978
|
target, onclause = join
|
|
911
979
|
if not _is_joined(select, target):
|
|
@@ -917,26 +985,50 @@ def _ordering_for(
|
|
|
917
985
|
criterion: Any, # noqa: ANN401
|
|
918
986
|
fields: Mapping[str, Any],
|
|
919
987
|
*,
|
|
920
|
-
ignore_case: bool |
|
|
988
|
+
ignore_case: bool | set[str],
|
|
921
989
|
) -> tuple[Any, Any]:
|
|
922
990
|
"""Return the clause a criterion stands for, and the table it needs."""
|
|
923
991
|
if isinstance(criterion, OrderBy):
|
|
924
992
|
return criterion.expression, (criterion.join, criterion.on)
|
|
925
993
|
if not isinstance(criterion, str):
|
|
926
994
|
return criterion, None
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
raise UnknownOrderFieldError(name, list(fields))
|
|
995
|
+
asked, descending, nulls = _parse_sort_field(criterion)
|
|
996
|
+
name = _field_named(asked, fields)
|
|
930
997
|
field = fields[name]
|
|
931
998
|
if isinstance(field, OrderBy):
|
|
932
999
|
column, join = field.expression, (field.join, field.on)
|
|
933
1000
|
else:
|
|
934
1001
|
column, join = field, None
|
|
935
|
-
if ignore_case is True or (ignore_case and name in ignore_case):
|
|
1002
|
+
if ignore_case is True or (ignore_case is not False and name in ignore_case):
|
|
936
1003
|
column = _case_insensitive(column)
|
|
937
1004
|
return _sort_clause(column, descending=descending, nulls=nulls), join
|
|
938
1005
|
|
|
939
1006
|
|
|
1007
|
+
def _field_named(asked: str, fields: Mapping[str, Any]) -> str:
|
|
1008
|
+
"""Return the field a request means, whichever case convention it uses.
|
|
1009
|
+
|
|
1010
|
+
An API sends `userName` for a `user_name` the model declares. The spelling
|
|
1011
|
+
is a matter of convention on either side, so it is not what tells a field
|
|
1012
|
+
from one nobody offers.
|
|
1013
|
+
|
|
1014
|
+
Raises:
|
|
1015
|
+
UnknownOrderFieldError: if no field, or more than one, answers to it.
|
|
1016
|
+
|
|
1017
|
+
"""
|
|
1018
|
+
if asked in fields:
|
|
1019
|
+
return asked
|
|
1020
|
+
folded = _fold_name(asked)
|
|
1021
|
+
matches = [name for name in fields if _fold_name(name) == folded]
|
|
1022
|
+
if len(matches) != 1:
|
|
1023
|
+
raise UnknownOrderFieldError(asked, list(fields))
|
|
1024
|
+
return matches[0]
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def _fold_name(name: str) -> str:
|
|
1028
|
+
"""Return a name with the case and the separators taken out of it."""
|
|
1029
|
+
return name.replace("_", "").replace("-", "").lower()
|
|
1030
|
+
|
|
1031
|
+
|
|
940
1032
|
def _chain(loader: Any, keys: Sequence[Any]) -> Any: # noqa: ANN401
|
|
941
1033
|
option = loader(keys[0])
|
|
942
1034
|
for key in keys[1:]:
|
|
@@ -952,6 +1044,8 @@ def _direction(clause: Any) -> tuple[sa.ColumnElement[Any], bool]: # noqa: ANN4
|
|
|
952
1044
|
"""
|
|
953
1045
|
descending = False
|
|
954
1046
|
element = clause
|
|
1047
|
+
while isinstance(element, NullsPlacement):
|
|
1048
|
+
element = element.clause
|
|
955
1049
|
while isinstance(element, sa.UnaryExpression):
|
|
956
1050
|
if element.modifier is operators.desc_op:
|
|
957
1051
|
descending = True
|
|
@@ -989,12 +1083,27 @@ def _sort_clause(column: Any, *, descending: bool, nulls: str | None) -> Any: #
|
|
|
989
1083
|
clause = sa.desc(column) if descending else sa.asc(column)
|
|
990
1084
|
nulls = nulls or wrapped
|
|
991
1085
|
if nulls == "nulls_first":
|
|
992
|
-
return clause
|
|
1086
|
+
return NullsPlacement(clause, last=False)
|
|
993
1087
|
if nulls == "nulls_last":
|
|
994
|
-
return clause
|
|
1088
|
+
return NullsPlacement(clause, last=True)
|
|
995
1089
|
return clause
|
|
996
1090
|
|
|
997
1091
|
|
|
1092
|
+
def _with_nulls(clause: Any, nulls: str | None) -> Any: # noqa: ANN401
|
|
1093
|
+
"""Return the clause with the nulls it was told to put where it asked for none.
|
|
1094
|
+
|
|
1095
|
+
A sort string and a field the model declared each say where their nulls go.
|
|
1096
|
+
This fills in only what neither of them said, which the database would
|
|
1097
|
+
otherwise answer for itself, differently by dialect and by direction.
|
|
1098
|
+
"""
|
|
1099
|
+
if nulls is None:
|
|
1100
|
+
return clause
|
|
1101
|
+
_, already = _split_nulls(clause)
|
|
1102
|
+
if already is not None:
|
|
1103
|
+
return clause
|
|
1104
|
+
return NullsPlacement(clause, last=nulls == "last")
|
|
1105
|
+
|
|
1106
|
+
|
|
998
1107
|
def _flatten(criteria: Iterable[Any]) -> Iterator[Any]:
|
|
999
1108
|
"""Yield the ordering criteria, taking lists apart and dropping the Nones."""
|
|
1000
1109
|
for criterion in criteria:
|
|
@@ -1059,6 +1168,8 @@ def _case_insensitive(column: Any) -> Any: # noqa: ANN401
|
|
|
1059
1168
|
|
|
1060
1169
|
def _split_nulls(column: Any) -> tuple[Any, str | None]: # noqa: ANN401
|
|
1061
1170
|
"""Separate a column from the nulls modifier wrapped around it."""
|
|
1171
|
+
if isinstance(column, NullsPlacement):
|
|
1172
|
+
return column.clause, "nulls_last" if column.last else "nulls_first"
|
|
1062
1173
|
if isinstance(column, sa.UnaryExpression):
|
|
1063
1174
|
if column.modifier is operators.nulls_last_op:
|
|
1064
1175
|
return column.element, "nulls_last"
|
|
@@ -6,6 +6,7 @@ from typing import (
|
|
|
6
6
|
Any,
|
|
7
7
|
ClassVar,
|
|
8
8
|
Generic,
|
|
9
|
+
Literal,
|
|
9
10
|
Self,
|
|
10
11
|
TypeVar,
|
|
11
12
|
cast,
|
|
@@ -433,6 +434,7 @@ class ColumnQuery(Generic[RowT]):
|
|
|
433
434
|
self,
|
|
434
435
|
*criteria: Any, # noqa: ANN401
|
|
435
436
|
ignore_case: bool | Sequence[str] = False,
|
|
437
|
+
nulls: Literal["first", "last"] | None = None,
|
|
436
438
|
) -> Self:
|
|
437
439
|
"""Order the rows, by columns or by the names the model offers."""
|
|
438
440
|
return self.with_select(
|
|
@@ -441,6 +443,7 @@ class ColumnQuery(Generic[RowT]):
|
|
|
441
443
|
orderable(self.model),
|
|
442
444
|
criteria,
|
|
443
445
|
ignore_case=ignore_case,
|
|
446
|
+
nulls=nulls,
|
|
444
447
|
)
|
|
445
448
|
)
|
|
446
449
|
|
|
@@ -5,15 +5,18 @@ from sqlalchemy.orm import exc as sa_exc
|
|
|
5
5
|
DEFAULT_ALIAS = "default"
|
|
6
6
|
|
|
7
7
|
__all__ = [
|
|
8
|
+
"AliasInUseError",
|
|
8
9
|
"AsyncFilterError",
|
|
9
10
|
"BulkQueryError",
|
|
10
11
|
"ConflictingDatabaseUrlError",
|
|
11
12
|
"DatabaseAlreadyConfiguredError",
|
|
12
13
|
"DatabaseNotConfiguredError",
|
|
14
|
+
"DefaultAliasError",
|
|
13
15
|
"DetachedInstanceError",
|
|
14
16
|
"InstanceNotFoundError",
|
|
15
17
|
"InvalidCursorError",
|
|
16
18
|
"InvalidDatabaseConfigError",
|
|
19
|
+
"InvalidNullsError",
|
|
17
20
|
"InvalidOrderFieldError",
|
|
18
21
|
"MissingConnectionError",
|
|
19
22
|
"MissingDatabaseUrlError",
|
|
@@ -106,6 +109,27 @@ class DatabaseAlreadyConfiguredError(SQLAKitError, RuntimeError):
|
|
|
106
109
|
super().__init__(message)
|
|
107
110
|
|
|
108
111
|
|
|
112
|
+
class AliasInUseError(SQLAKitError, ValueError):
|
|
113
|
+
"""Raised when registering a database under an alias another one holds."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, alias: str) -> None:
|
|
116
|
+
self.alias = alias
|
|
117
|
+
super().__init__(
|
|
118
|
+
f"`{alias}` is already registered. Dispose of that database before "
|
|
119
|
+
"registering another under the same name."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class DefaultAliasError(SQLAKitError, ValueError):
|
|
124
|
+
"""Raised when registering a database as the default one."""
|
|
125
|
+
|
|
126
|
+
def __init__(self) -> None:
|
|
127
|
+
super().__init__(
|
|
128
|
+
f"`{DEFAULT_ALIAS}` is the registry itself. Configure it with "
|
|
129
|
+
"`configure()`, and register the others under their own names."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
109
133
|
class UnknownDatabaseError(SQLAKitError, KeyError):
|
|
110
134
|
"""Raised when asking for a database alias that was never configured."""
|
|
111
135
|
|
|
@@ -387,6 +411,18 @@ class InvalidOrderFieldError(SQLAKitError, TypeError):
|
|
|
387
411
|
)
|
|
388
412
|
|
|
389
413
|
|
|
414
|
+
class InvalidNullsError(SQLAKitError, ValueError):
|
|
415
|
+
"""Raised when ``order_by`` is told to put the nulls somewhere else.
|
|
416
|
+
|
|
417
|
+
``nulls`` says where the rows with no value go, and SQL has two answers to
|
|
418
|
+
that.
|
|
419
|
+
"""
|
|
420
|
+
|
|
421
|
+
def __init__(self, nulls: object) -> None:
|
|
422
|
+
self.nulls = nulls
|
|
423
|
+
super().__init__(f"`nulls` is `first` or `last`, not `{nulls!r}`.")
|
|
424
|
+
|
|
425
|
+
|
|
390
426
|
class KeyLookupError(SQLAKitError, TypeError):
|
|
391
427
|
"""Raised when a lookup by primary key is asked to honour what it cannot."""
|
|
392
428
|
|
|
@@ -6,6 +6,7 @@ from typing import (
|
|
|
6
6
|
Any,
|
|
7
7
|
ClassVar,
|
|
8
8
|
Generic,
|
|
9
|
+
Literal,
|
|
9
10
|
Self,
|
|
10
11
|
TypeVar,
|
|
11
12
|
cast,
|
|
@@ -421,6 +422,7 @@ class ColumnQuery(Generic[RowT]):
|
|
|
421
422
|
self,
|
|
422
423
|
*criteria: Any, # noqa: ANN401
|
|
423
424
|
ignore_case: bool | Sequence[str] = False,
|
|
425
|
+
nulls: Literal["first", "last"] | None = None,
|
|
424
426
|
) -> Self:
|
|
425
427
|
"""Order the rows, by columns or by the names the model offers."""
|
|
426
428
|
return self.with_select(
|
|
@@ -429,6 +431,7 @@ class ColumnQuery(Generic[RowT]):
|
|
|
429
431
|
orderable(self.model),
|
|
430
432
|
criteria,
|
|
431
433
|
ignore_case=ignore_case,
|
|
434
|
+
nulls=nulls,
|
|
432
435
|
)
|
|
433
436
|
)
|
|
434
437
|
|
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import TYPE_CHECKING, Any, Literal, TypedDict
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Literal, TypeAlias, TypedDict
|
|
4
4
|
|
|
5
5
|
if TYPE_CHECKING:
|
|
6
6
|
from collections.abc import Callable, Mapping, Sequence
|
|
7
|
+
from pathlib import Path
|
|
7
8
|
|
|
8
9
|
import sqlalchemy as sa
|
|
9
10
|
from sqlalchemy.engine import Connection, Engine
|
|
10
11
|
from sqlalchemy.orm import Query, Session
|
|
11
12
|
from sqlalchemy.pool import Pool
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
from ._sql import Templates
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"DatabaseConfig",
|
|
18
|
+
"EngineArgs",
|
|
19
|
+
"SessionArgs",
|
|
20
|
+
"TemplatesLike",
|
|
21
|
+
"UrlParts",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
# Quoted, so importing this module never reaches `Templates` and the
|
|
25
|
+
# `jinja2sql` behind it.
|
|
26
|
+
TemplatesLike: TypeAlias = "str | Path | Sequence[str | Path] | Templates"
|
|
27
|
+
"""Where a database's SQL templates are: a path, several, or the object."""
|
|
14
28
|
|
|
15
29
|
|
|
16
30
|
class EngineArgs(TypedDict, total=False):
|
|
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
|