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.
Files changed (26) hide show
  1. {sqlakit-0.3.1 → sqlakit-0.5.0}/PKG-INFO +3 -1
  2. {sqlakit-0.3.1 → sqlakit-0.5.0}/README.md +2 -0
  3. {sqlakit-0.3.1 → sqlakit-0.5.0}/pyproject.toml +1 -1
  4. {sqlakit-0.3.1 → sqlakit-0.5.0}/pyproject.toml.orig +1 -1
  5. {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/__init__.py +13 -2
  6. {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/_base.py +7 -5
  7. {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/_query.py +184 -18
  8. {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/asyncio/orm.py +10 -2
  9. {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/exceptions.py +13 -0
  10. {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/orm.py +10 -2
  11. {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/types.py +16 -2
  12. {sqlakit-0.3.1 → sqlakit-0.5.0}/LICENSE +0 -0
  13. {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/_db.py +0 -0
  14. {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/_discovery.py +0 -0
  15. {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/_model.py +0 -0
  16. {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/_recording.py +0 -0
  17. {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/_registry.py +0 -0
  18. {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/_routing.py +0 -0
  19. {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/_sql.py +0 -0
  20. {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/asyncio/__init__.py +0 -0
  21. {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/asyncio/_db.py +0 -0
  22. {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/asyncio/_registry.py +0 -0
  23. {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/asyncio/sql.py +0 -0
  24. {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/py.typed +0 -0
  25. {sqlakit-0.3.1 → sqlakit-0.5.0}/sqlakit/sql.py +0 -0
  26. {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.1
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit"
3
- version = "0.3.1"
3
+ version = "0.5.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.3.1"
3
+ version = "0.5.0"
4
4
  description = "A toolkit for SQLAlchemy applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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 DatabaseConfig, EngineArgs, QueryStats, SessionArgs, UrlParts
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 ._sql import Templates
57
- from .types import DatabaseConfig, EngineArgs, SessionArgs, UrlParts
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
- ci_fields: Sequence[str] = (),
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
- ``ci_fields`` names the fields to compare without regard to case. It sorts by
441
- `lower(...)`, which a cursor cannot page: use it with `page`, or fold the case
442
- in ``__orderable__`` and index it.
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(self._select, self._orderable(), criteria, ci_fields)
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
- ci_fields: Sequence[str] = (),
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
- ci = set(ci_fields)
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, ci)
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
- ci: set[str],
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
- name, descending, nulls = _parse_sort_field(criterion)
873
- if name not in fields:
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 ci:
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.nulls_first()
1086
+ return NullsPlacement(clause, last=False)
938
1087
  if nulls == "nulls_last":
939
- return clause.nulls_last()
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 folded to one case, if it holds text at all."""
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 = sa.func.lower(inner)
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
- ci_fields: Sequence[str] = (),
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(self._select, orderable(self.model), criteria, ci_fields)
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
- ci_fields: Sequence[str] = (),
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(self._select, orderable(self.model), criteria, ci_fields)
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
- __all__ = ["DatabaseConfig", "EngineArgs", "SessionArgs", "UrlParts"]
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