sqlakit 0.4.0__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.4.0 → sqlakit-0.5.0}/PKG-INFO +1 -1
  2. {sqlakit-0.4.0 → sqlakit-0.5.0}/pyproject.toml +1 -1
  3. {sqlakit-0.4.0 → sqlakit-0.5.0}/pyproject.toml.orig +1 -1
  4. {sqlakit-0.4.0 → sqlakit-0.5.0}/sqlakit/__init__.py +11 -1
  5. {sqlakit-0.4.0 → sqlakit-0.5.0}/sqlakit/_base.py +7 -5
  6. {sqlakit-0.4.0 → sqlakit-0.5.0}/sqlakit/_query.py +121 -10
  7. {sqlakit-0.4.0 → sqlakit-0.5.0}/sqlakit/asyncio/orm.py +3 -0
  8. {sqlakit-0.4.0 → sqlakit-0.5.0}/sqlakit/exceptions.py +13 -0
  9. {sqlakit-0.4.0 → sqlakit-0.5.0}/sqlakit/orm.py +3 -0
  10. {sqlakit-0.4.0 → sqlakit-0.5.0}/sqlakit/types.py +16 -2
  11. {sqlakit-0.4.0 → sqlakit-0.5.0}/LICENSE +0 -0
  12. {sqlakit-0.4.0 → sqlakit-0.5.0}/README.md +0 -0
  13. {sqlakit-0.4.0 → sqlakit-0.5.0}/sqlakit/_db.py +0 -0
  14. {sqlakit-0.4.0 → sqlakit-0.5.0}/sqlakit/_discovery.py +0 -0
  15. {sqlakit-0.4.0 → sqlakit-0.5.0}/sqlakit/_model.py +0 -0
  16. {sqlakit-0.4.0 → sqlakit-0.5.0}/sqlakit/_recording.py +0 -0
  17. {sqlakit-0.4.0 → sqlakit-0.5.0}/sqlakit/_registry.py +0 -0
  18. {sqlakit-0.4.0 → sqlakit-0.5.0}/sqlakit/_routing.py +0 -0
  19. {sqlakit-0.4.0 → sqlakit-0.5.0}/sqlakit/_sql.py +0 -0
  20. {sqlakit-0.4.0 → sqlakit-0.5.0}/sqlakit/asyncio/__init__.py +0 -0
  21. {sqlakit-0.4.0 → sqlakit-0.5.0}/sqlakit/asyncio/_db.py +0 -0
  22. {sqlakit-0.4.0 → sqlakit-0.5.0}/sqlakit/asyncio/_registry.py +0 -0
  23. {sqlakit-0.4.0 → sqlakit-0.5.0}/sqlakit/asyncio/sql.py +0 -0
  24. {sqlakit-0.4.0 → sqlakit-0.5.0}/sqlakit/py.typed +0 -0
  25. {sqlakit-0.4.0 → sqlakit-0.5.0}/sqlakit/sql.py +0 -0
  26. {sqlakit-0.4.0 → 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.4.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit"
3
- version = "0.4.0"
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.4.0"
3
+ version = "0.5.0"
4
4
  description = "A toolkit for SQLAlchemy applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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,7 +41,14 @@ 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__ = [
46
54
  "CASE_INSENSITIVE_COLLATIONS",
@@ -61,6 +69,7 @@ __all__ = [
61
69
  "InstanceNotFoundError",
62
70
  "InvalidCursorError",
63
71
  "InvalidDatabaseConfigError",
72
+ "InvalidNullsError",
64
73
  "InvalidOrderFieldError",
65
74
  "KeyLookupError",
66
75
  "MissingConnectionError",
@@ -85,6 +94,7 @@ __all__ = [
85
94
  "Statement",
86
95
  "StrayParameterError",
87
96
  "TemplateNotFoundError",
97
+ "TemplatesLike",
88
98
  "Transaction",
89
99
  "TransactionRolledBackError",
90
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,
@@ -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(self._select, self._orderable(), criteria, ignore_case=ignore_case)
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=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 | Sequence[str],
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
- name, descending, nulls = _parse_sort_field(criterion)
928
- if name not in fields:
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.nulls_first()
1086
+ return NullsPlacement(clause, last=False)
993
1087
  if nulls == "nulls_last":
994
- return clause.nulls_last()
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
 
@@ -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,
@@ -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
- __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
File without changes