sqlakit 0.3.1__tar.gz → 0.5.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.
- {sqlakit-0.3.1 → sqlakit-0.5.0}/PKG-INFO +3 -1
- {sqlakit-0.3.1 → sqlakit-0.5.0}/README.md +2 -0
- {sqlakit-0.3.1 → sqlakit-0.5.0}/pyproject.toml +1 -1
- {sqlakit-0.3.1 → sqlakit-0.5.0}/pyproject.toml.orig +1 -1
- {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/__init__.py +13 -2
- {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/_base.py +7 -5
- {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/_query.py +184 -18
- {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/asyncio/orm.py +10 -2
- {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/exceptions.py +13 -0
- {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/orm.py +10 -2
- {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/types.py +16 -2
- {sqlakit-0.3.1 → sqlakit-0.5.0}/LICENSE +0 -0
- {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/_db.py +0 -0
- {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/_discovery.py +0 -0
- {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/_model.py +0 -0
- {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/_recording.py +0 -0
- {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/_registry.py +0 -0
- {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/_routing.py +0 -0
- {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/_sql.py +0 -0
- {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/asyncio/__init__.py +0 -0
- {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/asyncio/_db.py +0 -0
- {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/asyncio/_registry.py +0 -0
- {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/asyncio/sql.py +0 -0
- {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/py.typed +0 -0
- {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/sql.py +0 -0
- {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/testing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlakit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: A toolkit for SQLAlchemy applications.
|
|
5
5
|
Keywords: sqlalchemy,database,orm,sql,asyncio
|
|
6
6
|
Author: Anton Ruhlov
|
|
@@ -406,3 +406,5 @@ test from an empty file. The rest is under [`docs/`](docs/):
|
|
|
406
406
|
[debugging](docs/debugging.md), [multiple databases](docs/routing.md) and
|
|
407
407
|
[the reference](docs/reference.md). Complete example apps live in
|
|
408
408
|
[`examples/`](examples/), and each one is run by the test suite.
|
|
409
|
+
|
|
410
|
+
What changed in each version is in the [changelog](CHANGELOG.md).
|
|
@@ -374,3 +374,5 @@ test from an empty file. The rest is under [`docs/`](docs/):
|
|
|
374
374
|
[debugging](docs/debugging.md), [multiple databases](docs/routing.md) and
|
|
375
375
|
[the reference](docs/reference.md). Complete example apps live in
|
|
376
376
|
[`examples/`](examples/), and each one is run by the test suite.
|
|
377
|
+
|
|
378
|
+
What changed in each version is in the [changelog](CHANGELOG.md).
|
|
@@ -1,7 +1,7 @@
|
|
|
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 CursorPage, OrderBy, Page
|
|
4
|
+
from ._query import CASE_INSENSITIVE_COLLATIONS, CursorPage, OrderBy, Page
|
|
5
5
|
from ._recording import Recording, Statement
|
|
6
6
|
from ._registry import Databases, db
|
|
7
7
|
from ._routing import Router
|
|
@@ -16,6 +16,7 @@ from .exceptions import (
|
|
|
16
16
|
InstanceNotFoundError,
|
|
17
17
|
InvalidCursorError,
|
|
18
18
|
InvalidDatabaseConfigError,
|
|
19
|
+
InvalidNullsError,
|
|
19
20
|
InvalidOrderFieldError,
|
|
20
21
|
KeyLookupError,
|
|
21
22
|
MissingConnectionError,
|
|
@@ -40,9 +41,17 @@ from .exceptions import (
|
|
|
40
41
|
UnknownOrderFieldError,
|
|
41
42
|
UnorderedPageError,
|
|
42
43
|
)
|
|
43
|
-
from .types import
|
|
44
|
+
from .types import (
|
|
45
|
+
DatabaseConfig,
|
|
46
|
+
EngineArgs,
|
|
47
|
+
QueryStats,
|
|
48
|
+
SessionArgs,
|
|
49
|
+
TemplatesLike,
|
|
50
|
+
UrlParts,
|
|
51
|
+
)
|
|
44
52
|
|
|
45
53
|
__all__ = [
|
|
54
|
+
"CASE_INSENSITIVE_COLLATIONS",
|
|
46
55
|
"DEFAULT_ALIAS",
|
|
47
56
|
"DEFAULT_ENGINE_ARGS",
|
|
48
57
|
"DEFAULT_SESSION_ARGS",
|
|
@@ -60,6 +69,7 @@ __all__ = [
|
|
|
60
69
|
"InstanceNotFoundError",
|
|
61
70
|
"InvalidCursorError",
|
|
62
71
|
"InvalidDatabaseConfigError",
|
|
72
|
+
"InvalidNullsError",
|
|
63
73
|
"InvalidOrderFieldError",
|
|
64
74
|
"KeyLookupError",
|
|
65
75
|
"MissingConnectionError",
|
|
@@ -84,6 +94,7 @@ __all__ = [
|
|
|
84
94
|
"Statement",
|
|
85
95
|
"StrayParameterError",
|
|
86
96
|
"TemplateNotFoundError",
|
|
97
|
+
"TemplatesLike",
|
|
87
98
|
"Transaction",
|
|
88
99
|
"TransactionRolledBackError",
|
|
89
100
|
"UncomparableOrderingError",
|
|
@@ -49,16 +49,18 @@ from .exceptions import (
|
|
|
49
49
|
if TYPE_CHECKING:
|
|
50
50
|
import logging
|
|
51
51
|
from collections.abc import Iterator, Sequence
|
|
52
|
-
from pathlib import Path
|
|
53
52
|
|
|
54
53
|
from sqlalchemy.engine import Engine
|
|
55
54
|
|
|
56
|
-
from .
|
|
57
|
-
|
|
55
|
+
from .types import (
|
|
56
|
+
DatabaseConfig,
|
|
57
|
+
EngineArgs,
|
|
58
|
+
SessionArgs,
|
|
59
|
+
TemplatesLike,
|
|
60
|
+
UrlParts,
|
|
61
|
+
)
|
|
58
62
|
|
|
59
63
|
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
64
|
|
|
63
65
|
__all__ = [
|
|
64
66
|
"DEFAULT_ALIAS",
|
|
@@ -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,
|
|
@@ -19,6 +20,7 @@ from typing import (
|
|
|
19
20
|
|
|
20
21
|
import sqlalchemy as sa
|
|
21
22
|
from sqlalchemy import exc as sa_exc
|
|
23
|
+
from sqlalchemy.ext.compiler import compiles
|
|
22
24
|
from sqlalchemy.orm import (
|
|
23
25
|
InstrumentedAttribute,
|
|
24
26
|
contains_eager,
|
|
@@ -34,6 +36,7 @@ from .exceptions import (
|
|
|
34
36
|
BulkQueryError,
|
|
35
37
|
InstanceNotFoundError,
|
|
36
38
|
InvalidCursorError,
|
|
39
|
+
InvalidNullsError,
|
|
37
40
|
InvalidOrderFieldError,
|
|
38
41
|
KeyLookupError,
|
|
39
42
|
MultipleInstancesFoundError,
|
|
@@ -53,8 +56,11 @@ if TYPE_CHECKING:
|
|
|
53
56
|
from sqlalchemy.sql.selectable import ForUpdateParameter
|
|
54
57
|
|
|
55
58
|
__all__ = [
|
|
59
|
+
"CASE_INSENSITIVE_COLLATIONS",
|
|
56
60
|
"BaseQuery",
|
|
61
|
+
"CaseInsensitive",
|
|
57
62
|
"CursorPage",
|
|
63
|
+
"NullsPlacement",
|
|
58
64
|
"OrderBy",
|
|
59
65
|
"Page",
|
|
60
66
|
"one_row",
|
|
@@ -63,6 +69,26 @@ __all__ = [
|
|
|
63
69
|
"ordered",
|
|
64
70
|
]
|
|
65
71
|
|
|
72
|
+
CASE_INSENSITIVE_COLLATIONS: dict[str, str] = {"sqlite": "NOCASE"}
|
|
73
|
+
"""The collation `ignore_case` orders by, per dialect.
|
|
74
|
+
|
|
75
|
+
A dialect with no entry orders by `lower(...)`, which every database has.
|
|
76
|
+
Name the collation you created, once, before any query runs:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
sqlakit.CASE_INSENSITIVE_COLLATIONS["postgresql"] = "und-ci-ai"
|
|
80
|
+
```
|
|
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
|
+
|
|
87
|
+
A collation decides the whole order, the alphabet and the accents along with
|
|
88
|
+
the case. This one is asked for only by `ignore_case`. To sort by another,
|
|
89
|
+
name it on the column: `User.name.collate("de-DE")`.
|
|
90
|
+
"""
|
|
91
|
+
|
|
66
92
|
HIDDEN = "hidden"
|
|
67
93
|
"""The rows a soft delete marked are left out, as a read does by default."""
|
|
68
94
|
|
|
@@ -78,6 +104,71 @@ RowT = TypeVar("RowT")
|
|
|
78
104
|
RowT_co = TypeVar("RowT_co", covariant=True)
|
|
79
105
|
|
|
80
106
|
|
|
107
|
+
class CaseInsensitive(sa.ColumnElement[Any]):
|
|
108
|
+
"""A column compared without regard to case, however the dialect does it.
|
|
109
|
+
|
|
110
|
+
The dialect is the one the query runs on, not the one it was built against,
|
|
111
|
+
so a model ordered this way works on `SQLite` under test and on the server
|
|
112
|
+
it ships to.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
inherit_cache = True
|
|
116
|
+
|
|
117
|
+
def __init__(self, element: sa.ColumnElement[Any]) -> None:
|
|
118
|
+
self.element = element
|
|
119
|
+
self.type = element.type
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@compiles(CaseInsensitive)
|
|
123
|
+
def _compile_case_insensitive(
|
|
124
|
+
element: CaseInsensitive,
|
|
125
|
+
compiler: Any, # noqa: ANN401
|
|
126
|
+
**kw: Any, # noqa: ANN401
|
|
127
|
+
) -> str:
|
|
128
|
+
collation = CASE_INSENSITIVE_COLLATIONS.get(compiler.dialect.name)
|
|
129
|
+
if collation is None:
|
|
130
|
+
return compiler.process(sa.func.lower(element.element), **kw)
|
|
131
|
+
return compiler.process(sa.collate(element.element, collation), **kw)
|
|
132
|
+
|
|
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
|
+
|
|
81
172
|
@dataclass(frozen=True, slots=True)
|
|
82
173
|
class Page(Generic[ModelT]):
|
|
83
174
|
"""One page of rows, and how many there are in total."""
|
|
@@ -420,7 +511,8 @@ class BaseQuery(Generic[ModelT]):
|
|
|
420
511
|
def order_by(
|
|
421
512
|
self,
|
|
422
513
|
*criteria: Any, # noqa: ANN401
|
|
423
|
-
|
|
514
|
+
ignore_case: bool | Sequence[str] = False,
|
|
515
|
+
nulls: Literal["first", "last"] | None = None,
|
|
424
516
|
) -> Self:
|
|
425
517
|
"""Order the rows, by columns or by the sort strings a request carries.
|
|
426
518
|
|
|
@@ -437,21 +529,42 @@ class BaseQuery(Generic[ModelT]):
|
|
|
437
529
|
A `None` is skipped and a list is taken apart, so a request that names no
|
|
438
530
|
sort, or several, passes straight through.
|
|
439
531
|
|
|
440
|
-
``
|
|
441
|
-
|
|
442
|
-
|
|
532
|
+
``ignore_case`` compares text without regard to case: `True` for every
|
|
533
|
+
field of this call, or the names of the ones it applies to, for a sort
|
|
534
|
+
that arrived as a list:
|
|
535
|
+
|
|
536
|
+
```python
|
|
537
|
+
User.query.order_by("name", ignore_case=True)
|
|
538
|
+
User.query.order_by(request.sort, ignore_case=["name"])
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
Which SQL that becomes is the dialect's to decide, and
|
|
542
|
+
`CASE_INSENSITIVE_COLLATIONS` names the collation. A cursor cannot page
|
|
543
|
+
it: use it with `page`.
|
|
443
544
|
|
|
444
545
|
A model sorts by its own mapped columns. `orderable` says how to offer
|
|
445
546
|
others, including fields that are not columns at all.
|
|
446
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
|
+
|
|
447
553
|
Raises:
|
|
448
554
|
UnknownOrderFieldError: if a string names a field the model does not
|
|
449
555
|
offer.
|
|
556
|
+
InvalidNullsError: if ``nulls`` is neither `first` nor `last`.
|
|
450
557
|
|
|
451
558
|
"""
|
|
452
559
|
self._reject_statement("order_by")
|
|
453
560
|
return self.with_select(
|
|
454
|
-
ordered(
|
|
561
|
+
ordered(
|
|
562
|
+
self._select,
|
|
563
|
+
self._orderable(),
|
|
564
|
+
criteria,
|
|
565
|
+
ignore_case=ignore_case,
|
|
566
|
+
nulls=nulls,
|
|
567
|
+
)
|
|
455
568
|
)
|
|
456
569
|
|
|
457
570
|
def _directed(self, column: Any, *, descending: bool) -> Any: # noqa: ANN401
|
|
@@ -836,22 +949,31 @@ def ordered(
|
|
|
836
949
|
select: sa.Select[Any],
|
|
837
950
|
fields: Mapping[str, Any],
|
|
838
951
|
criteria: Iterable[Any],
|
|
839
|
-
|
|
952
|
+
*,
|
|
953
|
+
ignore_case: bool | Sequence[str] = False,
|
|
954
|
+
nulls: str | None = None,
|
|
840
955
|
) -> sa.Select[Any]:
|
|
841
956
|
"""Return the statement ordered by these criteria, joining what they need.
|
|
842
957
|
|
|
843
958
|
Raises:
|
|
844
959
|
UnknownOrderFieldError: if a name is not one of the fields.
|
|
960
|
+
InvalidNullsError: if ``nulls`` is neither "first" nor "last".
|
|
845
961
|
|
|
846
962
|
"""
|
|
847
963
|
named = list(_flatten(criteria))
|
|
848
964
|
if not named:
|
|
849
965
|
return select
|
|
850
|
-
|
|
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
|
+
)
|
|
851
973
|
clauses = []
|
|
852
974
|
for criterion in named:
|
|
853
|
-
clause, join = _ordering_for(criterion, fields,
|
|
854
|
-
clauses.append(clause)
|
|
975
|
+
clause, join = _ordering_for(criterion, fields, ignore_case=folded)
|
|
976
|
+
clauses.append(_with_nulls(clause, nulls))
|
|
855
977
|
if join is not None:
|
|
856
978
|
target, onclause = join
|
|
857
979
|
if not _is_joined(select, target):
|
|
@@ -862,26 +984,51 @@ def ordered(
|
|
|
862
984
|
def _ordering_for(
|
|
863
985
|
criterion: Any, # noqa: ANN401
|
|
864
986
|
fields: Mapping[str, Any],
|
|
865
|
-
|
|
987
|
+
*,
|
|
988
|
+
ignore_case: bool | set[str],
|
|
866
989
|
) -> tuple[Any, Any]:
|
|
867
990
|
"""Return the clause a criterion stands for, and the table it needs."""
|
|
868
991
|
if isinstance(criterion, OrderBy):
|
|
869
992
|
return criterion.expression, (criterion.join, criterion.on)
|
|
870
993
|
if not isinstance(criterion, str):
|
|
871
994
|
return criterion, None
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
raise UnknownOrderFieldError(name, list(fields))
|
|
995
|
+
asked, descending, nulls = _parse_sort_field(criterion)
|
|
996
|
+
name = _field_named(asked, fields)
|
|
875
997
|
field = fields[name]
|
|
876
998
|
if isinstance(field, OrderBy):
|
|
877
999
|
column, join = field.expression, (field.join, field.on)
|
|
878
1000
|
else:
|
|
879
1001
|
column, join = field, None
|
|
880
|
-
if name in
|
|
1002
|
+
if ignore_case is True or (ignore_case is not False and name in ignore_case):
|
|
881
1003
|
column = _case_insensitive(column)
|
|
882
1004
|
return _sort_clause(column, descending=descending, nulls=nulls), join
|
|
883
1005
|
|
|
884
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
|
+
|
|
885
1032
|
def _chain(loader: Any, keys: Sequence[Any]) -> Any: # noqa: ANN401
|
|
886
1033
|
option = loader(keys[0])
|
|
887
1034
|
for key in keys[1:]:
|
|
@@ -897,6 +1044,8 @@ def _direction(clause: Any) -> tuple[sa.ColumnElement[Any], bool]: # noqa: ANN4
|
|
|
897
1044
|
"""
|
|
898
1045
|
descending = False
|
|
899
1046
|
element = clause
|
|
1047
|
+
while isinstance(element, NullsPlacement):
|
|
1048
|
+
element = element.clause
|
|
900
1049
|
while isinstance(element, sa.UnaryExpression):
|
|
901
1050
|
if element.modifier is operators.desc_op:
|
|
902
1051
|
descending = True
|
|
@@ -934,12 +1083,27 @@ def _sort_clause(column: Any, *, descending: bool, nulls: str | None) -> Any: #
|
|
|
934
1083
|
clause = sa.desc(column) if descending else sa.asc(column)
|
|
935
1084
|
nulls = nulls or wrapped
|
|
936
1085
|
if nulls == "nulls_first":
|
|
937
|
-
return clause
|
|
1086
|
+
return NullsPlacement(clause, last=False)
|
|
938
1087
|
if nulls == "nulls_last":
|
|
939
|
-
return clause
|
|
1088
|
+
return NullsPlacement(clause, last=True)
|
|
940
1089
|
return clause
|
|
941
1090
|
|
|
942
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
|
+
|
|
943
1107
|
def _flatten(criteria: Iterable[Any]) -> Iterator[Any]:
|
|
944
1108
|
"""Yield the ordering criteria, taking lists apart and dropping the Nones."""
|
|
945
1109
|
for criterion in criteria:
|
|
@@ -990,11 +1154,11 @@ def _selectable_of(target: Any) -> Any: # noqa: ANN401
|
|
|
990
1154
|
|
|
991
1155
|
|
|
992
1156
|
def _case_insensitive(column: Any) -> Any: # noqa: ANN401
|
|
993
|
-
"""Return the column
|
|
1157
|
+
"""Return the column compared without regard to case, if it holds text."""
|
|
994
1158
|
inner, nulls = _split_nulls(column)
|
|
995
1159
|
if not isinstance(getattr(inner, "type", None), sa.String):
|
|
996
1160
|
return column
|
|
997
|
-
folded =
|
|
1161
|
+
folded = CaseInsensitive(inner)
|
|
998
1162
|
if nulls == "nulls_last":
|
|
999
1163
|
return sa.nulls_last(folded)
|
|
1000
1164
|
if nulls == "nulls_first":
|
|
@@ -1004,6 +1168,8 @@ def _case_insensitive(column: Any) -> Any: # noqa: ANN401
|
|
|
1004
1168
|
|
|
1005
1169
|
def _split_nulls(column: Any) -> tuple[Any, str | None]: # noqa: ANN401
|
|
1006
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"
|
|
1007
1173
|
if isinstance(column, sa.UnaryExpression):
|
|
1008
1174
|
if column.modifier is operators.nulls_last_op:
|
|
1009
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,
|
|
@@ -432,11 +433,18 @@ class ColumnQuery(Generic[RowT]):
|
|
|
432
433
|
def order_by(
|
|
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(
|
|
439
|
-
ordered(
|
|
441
|
+
ordered(
|
|
442
|
+
self._select,
|
|
443
|
+
orderable(self.model),
|
|
444
|
+
criteria,
|
|
445
|
+
ignore_case=ignore_case,
|
|
446
|
+
nulls=nulls,
|
|
447
|
+
)
|
|
440
448
|
)
|
|
441
449
|
|
|
442
450
|
def distinct(self) -> Self:
|
|
@@ -14,6 +14,7 @@ __all__ = [
|
|
|
14
14
|
"InstanceNotFoundError",
|
|
15
15
|
"InvalidCursorError",
|
|
16
16
|
"InvalidDatabaseConfigError",
|
|
17
|
+
"InvalidNullsError",
|
|
17
18
|
"InvalidOrderFieldError",
|
|
18
19
|
"MissingConnectionError",
|
|
19
20
|
"MissingDatabaseUrlError",
|
|
@@ -387,6 +388,18 @@ class InvalidOrderFieldError(SQLAKitError, TypeError):
|
|
|
387
388
|
)
|
|
388
389
|
|
|
389
390
|
|
|
391
|
+
class InvalidNullsError(SQLAKitError, ValueError):
|
|
392
|
+
"""Raised when ``order_by`` is told to put the nulls somewhere else.
|
|
393
|
+
|
|
394
|
+
``nulls`` says where the rows with no value go, and SQL has two answers to
|
|
395
|
+
that.
|
|
396
|
+
"""
|
|
397
|
+
|
|
398
|
+
def __init__(self, nulls: object) -> None:
|
|
399
|
+
self.nulls = nulls
|
|
400
|
+
super().__init__(f"`nulls` is `first` or `last`, not `{nulls!r}`.")
|
|
401
|
+
|
|
402
|
+
|
|
390
403
|
class KeyLookupError(SQLAKitError, TypeError):
|
|
391
404
|
"""Raised when a lookup by primary key is asked to honour what it cannot."""
|
|
392
405
|
|
|
@@ -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,
|
|
@@ -420,11 +421,18 @@ class ColumnQuery(Generic[RowT]):
|
|
|
420
421
|
def order_by(
|
|
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(
|
|
427
|
-
ordered(
|
|
429
|
+
ordered(
|
|
430
|
+
self._select,
|
|
431
|
+
orderable(self.model),
|
|
432
|
+
criteria,
|
|
433
|
+
ignore_case=ignore_case,
|
|
434
|
+
nulls=nulls,
|
|
435
|
+
)
|
|
428
436
|
)
|
|
429
437
|
|
|
430
438
|
def distinct(self) -> Self:
|
|
@@ -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
|