sqlakit 0.3.1__tar.gz → 0.4.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.4.0}/PKG-INFO +3 -1
  2. {sqlakit-0.3.1 → sqlakit-0.4.0}/README.md +2 -0
  3. {sqlakit-0.3.1 → sqlakit-0.4.0}/pyproject.toml +1 -1
  4. {sqlakit-0.3.1 → sqlakit-0.4.0}/pyproject.toml.orig +1 -1
  5. {sqlakit-0.3.1 → sqlakit-0.4.0}/sqlakit/__init__.py +2 -1
  6. {sqlakit-0.3.1 → sqlakit-0.4.0}/sqlakit/_query.py +67 -12
  7. {sqlakit-0.3.1 → sqlakit-0.4.0}/sqlakit/asyncio/orm.py +7 -2
  8. {sqlakit-0.3.1 → sqlakit-0.4.0}/sqlakit/orm.py +7 -2
  9. {sqlakit-0.3.1 → sqlakit-0.4.0}/LICENSE +0 -0
  10. {sqlakit-0.3.1 → sqlakit-0.4.0}/sqlakit/_base.py +0 -0
  11. {sqlakit-0.3.1 → sqlakit-0.4.0}/sqlakit/_db.py +0 -0
  12. {sqlakit-0.3.1 → sqlakit-0.4.0}/sqlakit/_discovery.py +0 -0
  13. {sqlakit-0.3.1 → sqlakit-0.4.0}/sqlakit/_model.py +0 -0
  14. {sqlakit-0.3.1 → sqlakit-0.4.0}/sqlakit/_recording.py +0 -0
  15. {sqlakit-0.3.1 → sqlakit-0.4.0}/sqlakit/_registry.py +0 -0
  16. {sqlakit-0.3.1 → sqlakit-0.4.0}/sqlakit/_routing.py +0 -0
  17. {sqlakit-0.3.1 → sqlakit-0.4.0}/sqlakit/_sql.py +0 -0
  18. {sqlakit-0.3.1 → sqlakit-0.4.0}/sqlakit/asyncio/__init__.py +0 -0
  19. {sqlakit-0.3.1 → sqlakit-0.4.0}/sqlakit/asyncio/_db.py +0 -0
  20. {sqlakit-0.3.1 → sqlakit-0.4.0}/sqlakit/asyncio/_registry.py +0 -0
  21. {sqlakit-0.3.1 → sqlakit-0.4.0}/sqlakit/asyncio/sql.py +0 -0
  22. {sqlakit-0.3.1 → sqlakit-0.4.0}/sqlakit/exceptions.py +0 -0
  23. {sqlakit-0.3.1 → sqlakit-0.4.0}/sqlakit/py.typed +0 -0
  24. {sqlakit-0.3.1 → sqlakit-0.4.0}/sqlakit/sql.py +0 -0
  25. {sqlakit-0.3.1 → sqlakit-0.4.0}/sqlakit/testing.py +0 -0
  26. {sqlakit-0.3.1 → sqlakit-0.4.0}/sqlakit/types.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.4.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.4.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.4.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
@@ -43,6 +43,7 @@ from .exceptions import (
43
43
  from .types import DatabaseConfig, EngineArgs, QueryStats, SessionArgs, UrlParts
44
44
 
45
45
  __all__ = [
46
+ "CASE_INSENSITIVE_COLLATIONS",
46
47
  "DEFAULT_ALIAS",
47
48
  "DEFAULT_ENGINE_ARGS",
48
49
  "DEFAULT_SESSION_ARGS",
@@ -19,6 +19,7 @@ from typing import (
19
19
 
20
20
  import sqlalchemy as sa
21
21
  from sqlalchemy import exc as sa_exc
22
+ from sqlalchemy.ext.compiler import compiles
22
23
  from sqlalchemy.orm import (
23
24
  InstrumentedAttribute,
24
25
  contains_eager,
@@ -53,7 +54,9 @@ if TYPE_CHECKING:
53
54
  from sqlalchemy.sql.selectable import ForUpdateParameter
54
55
 
55
56
  __all__ = [
57
+ "CASE_INSENSITIVE_COLLATIONS",
56
58
  "BaseQuery",
59
+ "CaseInsensitive",
57
60
  "CursorPage",
58
61
  "OrderBy",
59
62
  "Page",
@@ -63,6 +66,21 @@ __all__ = [
63
66
  "ordered",
64
67
  ]
65
68
 
69
+ CASE_INSENSITIVE_COLLATIONS: dict[str, str] = {"sqlite": "NOCASE"}
70
+ """The collation `ignore_case` orders by, per dialect.
71
+
72
+ A dialect with no entry orders by `lower(...)`, which every database has.
73
+ Name the collation you created, once, before any query runs:
74
+
75
+ ```python
76
+ sqlakit.CASE_INSENSITIVE_COLLATIONS["postgresql"] = "und-ci-ai"
77
+ ```
78
+
79
+ A collation decides the whole order, the alphabet and the accents along with
80
+ the case. This one is asked for only by `ignore_case`. To sort by another,
81
+ name it on the column: `User.name.collate("de-DE")`.
82
+ """
83
+
66
84
  HIDDEN = "hidden"
67
85
  """The rows a soft delete marked are left out, as a read does by default."""
68
86
 
@@ -78,6 +96,33 @@ RowT = TypeVar("RowT")
78
96
  RowT_co = TypeVar("RowT_co", covariant=True)
79
97
 
80
98
 
99
+ class CaseInsensitive(sa.ColumnElement[Any]):
100
+ """A column compared without regard to case, however the dialect does it.
101
+
102
+ The dialect is the one the query runs on, not the one it was built against,
103
+ so a model ordered this way works on `SQLite` under test and on the server
104
+ it ships to.
105
+ """
106
+
107
+ inherit_cache = True
108
+
109
+ def __init__(self, element: sa.ColumnElement[Any]) -> None:
110
+ self.element = element
111
+ self.type = element.type
112
+
113
+
114
+ @compiles(CaseInsensitive)
115
+ def _compile_case_insensitive(
116
+ element: CaseInsensitive,
117
+ compiler: Any, # noqa: ANN401
118
+ **kw: Any, # noqa: ANN401
119
+ ) -> str:
120
+ collation = CASE_INSENSITIVE_COLLATIONS.get(compiler.dialect.name)
121
+ if collation is None:
122
+ return compiler.process(sa.func.lower(element.element), **kw)
123
+ return compiler.process(sa.collate(element.element, collation), **kw)
124
+
125
+
81
126
  @dataclass(frozen=True, slots=True)
82
127
  class Page(Generic[ModelT]):
83
128
  """One page of rows, and how many there are in total."""
@@ -420,7 +465,7 @@ class BaseQuery(Generic[ModelT]):
420
465
  def order_by(
421
466
  self,
422
467
  *criteria: Any, # noqa: ANN401
423
- ci_fields: Sequence[str] = (),
468
+ ignore_case: bool | Sequence[str] = False,
424
469
  ) -> Self:
425
470
  """Order the rows, by columns or by the sort strings a request carries.
426
471
 
@@ -437,9 +482,18 @@ class BaseQuery(Generic[ModelT]):
437
482
  A `None` is skipped and a list is taken apart, so a request that names no
438
483
  sort, or several, passes straight through.
439
484
 
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.
485
+ ``ignore_case`` compares text without regard to case: `True` for every
486
+ field of this call, or the names of the ones it applies to, for a sort
487
+ that arrived as a list:
488
+
489
+ ```python
490
+ User.query.order_by("name", ignore_case=True)
491
+ User.query.order_by(request.sort, ignore_case=["name"])
492
+ ```
493
+
494
+ Which SQL that becomes is the dialect's to decide, and
495
+ `CASE_INSENSITIVE_COLLATIONS` names the collation. A cursor cannot page
496
+ it: use it with `page`.
443
497
 
444
498
  A model sorts by its own mapped columns. `orderable` says how to offer
445
499
  others, including fields that are not columns at all.
@@ -451,7 +505,7 @@ class BaseQuery(Generic[ModelT]):
451
505
  """
452
506
  self._reject_statement("order_by")
453
507
  return self.with_select(
454
- ordered(self._select, self._orderable(), criteria, ci_fields)
508
+ ordered(self._select, self._orderable(), criteria, ignore_case=ignore_case)
455
509
  )
456
510
 
457
511
  def _directed(self, column: Any, *, descending: bool) -> Any: # noqa: ANN401
@@ -836,7 +890,8 @@ def ordered(
836
890
  select: sa.Select[Any],
837
891
  fields: Mapping[str, Any],
838
892
  criteria: Iterable[Any],
839
- ci_fields: Sequence[str] = (),
893
+ *,
894
+ ignore_case: bool | Sequence[str] = False,
840
895
  ) -> sa.Select[Any]:
841
896
  """Return the statement ordered by these criteria, joining what they need.
842
897
 
@@ -847,10 +902,9 @@ def ordered(
847
902
  named = list(_flatten(criteria))
848
903
  if not named:
849
904
  return select
850
- ci = set(ci_fields)
851
905
  clauses = []
852
906
  for criterion in named:
853
- clause, join = _ordering_for(criterion, fields, ci)
907
+ clause, join = _ordering_for(criterion, fields, ignore_case=ignore_case)
854
908
  clauses.append(clause)
855
909
  if join is not None:
856
910
  target, onclause = join
@@ -862,7 +916,8 @@ def ordered(
862
916
  def _ordering_for(
863
917
  criterion: Any, # noqa: ANN401
864
918
  fields: Mapping[str, Any],
865
- ci: set[str],
919
+ *,
920
+ ignore_case: bool | Sequence[str],
866
921
  ) -> tuple[Any, Any]:
867
922
  """Return the clause a criterion stands for, and the table it needs."""
868
923
  if isinstance(criterion, OrderBy):
@@ -877,7 +932,7 @@ def _ordering_for(
877
932
  column, join = field.expression, (field.join, field.on)
878
933
  else:
879
934
  column, join = field, None
880
- if name in ci:
935
+ if ignore_case is True or (ignore_case and name in ignore_case):
881
936
  column = _case_insensitive(column)
882
937
  return _sort_clause(column, descending=descending, nulls=nulls), join
883
938
 
@@ -990,11 +1045,11 @@ def _selectable_of(target: Any) -> Any: # noqa: ANN401
990
1045
 
991
1046
 
992
1047
  def _case_insensitive(column: Any) -> Any: # noqa: ANN401
993
- """Return the column folded to one case, if it holds text at all."""
1048
+ """Return the column compared without regard to case, if it holds text."""
994
1049
  inner, nulls = _split_nulls(column)
995
1050
  if not isinstance(getattr(inner, "type", None), sa.String):
996
1051
  return column
997
- folded = sa.func.lower(inner)
1052
+ folded = CaseInsensitive(inner)
998
1053
  if nulls == "nulls_last":
999
1054
  return sa.nulls_last(folded)
1000
1055
  if nulls == "nulls_first":
@@ -432,11 +432,16 @@ class ColumnQuery(Generic[RowT]):
432
432
  def order_by(
433
433
  self,
434
434
  *criteria: Any, # noqa: ANN401
435
- ci_fields: Sequence[str] = (),
435
+ ignore_case: bool | Sequence[str] = False,
436
436
  ) -> Self:
437
437
  """Order the rows, by columns or by the names the model offers."""
438
438
  return self.with_select(
439
- ordered(self._select, orderable(self.model), criteria, ci_fields)
439
+ ordered(
440
+ self._select,
441
+ orderable(self.model),
442
+ criteria,
443
+ ignore_case=ignore_case,
444
+ )
440
445
  )
441
446
 
442
447
  def distinct(self) -> Self:
@@ -420,11 +420,16 @@ class ColumnQuery(Generic[RowT]):
420
420
  def order_by(
421
421
  self,
422
422
  *criteria: Any, # noqa: ANN401
423
- ci_fields: Sequence[str] = (),
423
+ ignore_case: bool | Sequence[str] = False,
424
424
  ) -> Self:
425
425
  """Order the rows, by columns or by the names the model offers."""
426
426
  return self.with_select(
427
- ordered(self._select, orderable(self.model), criteria, ci_fields)
427
+ ordered(
428
+ self._select,
429
+ orderable(self.model),
430
+ criteria,
431
+ ignore_case=ignore_case,
432
+ )
428
433
  )
429
434
 
430
435
  def distinct(self) -> Self:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes