sqlakit 0.3.0__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.0 → sqlakit-0.4.0}/PKG-INFO +39 -39
  2. {sqlakit-0.3.0 → sqlakit-0.4.0}/README.md +38 -38
  3. {sqlakit-0.3.0 → sqlakit-0.4.0}/pyproject.toml +3 -3
  4. {sqlakit-0.3.0 → sqlakit-0.4.0}/pyproject.toml.orig +3 -5
  5. {sqlakit-0.3.0 → sqlakit-0.4.0}/sqlakit/__init__.py +2 -1
  6. {sqlakit-0.3.0 → sqlakit-0.4.0}/sqlakit/_query.py +67 -12
  7. {sqlakit-0.3.0 → sqlakit-0.4.0}/sqlakit/asyncio/orm.py +7 -2
  8. {sqlakit-0.3.0 → sqlakit-0.4.0}/sqlakit/orm.py +7 -2
  9. {sqlakit-0.3.0 → sqlakit-0.4.0}/LICENSE +0 -0
  10. {sqlakit-0.3.0 → sqlakit-0.4.0}/sqlakit/_base.py +0 -0
  11. {sqlakit-0.3.0 → sqlakit-0.4.0}/sqlakit/_db.py +0 -0
  12. {sqlakit-0.3.0 → sqlakit-0.4.0}/sqlakit/_discovery.py +0 -0
  13. {sqlakit-0.3.0 → sqlakit-0.4.0}/sqlakit/_model.py +0 -0
  14. {sqlakit-0.3.0 → sqlakit-0.4.0}/sqlakit/_recording.py +0 -0
  15. {sqlakit-0.3.0 → sqlakit-0.4.0}/sqlakit/_registry.py +0 -0
  16. {sqlakit-0.3.0 → sqlakit-0.4.0}/sqlakit/_routing.py +0 -0
  17. {sqlakit-0.3.0 → sqlakit-0.4.0}/sqlakit/_sql.py +0 -0
  18. {sqlakit-0.3.0 → sqlakit-0.4.0}/sqlakit/asyncio/__init__.py +0 -0
  19. {sqlakit-0.3.0 → sqlakit-0.4.0}/sqlakit/asyncio/_db.py +0 -0
  20. {sqlakit-0.3.0 → sqlakit-0.4.0}/sqlakit/asyncio/_registry.py +0 -0
  21. {sqlakit-0.3.0 → sqlakit-0.4.0}/sqlakit/asyncio/sql.py +0 -0
  22. {sqlakit-0.3.0 → sqlakit-0.4.0}/sqlakit/exceptions.py +0 -0
  23. {sqlakit-0.3.0 → sqlakit-0.4.0}/sqlakit/py.typed +0 -0
  24. {sqlakit-0.3.0 → sqlakit-0.4.0}/sqlakit/sql.py +0 -0
  25. {sqlakit-0.3.0 → sqlakit-0.4.0}/sqlakit/testing.py +0 -0
  26. {sqlakit-0.3.0 → 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.0
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
@@ -185,44 +185,6 @@ feed.next_cursor
185
185
  feed.previous_cursor
186
186
  ```
187
187
 
188
- ## Active Record
189
-
190
- An instance saves and deletes itself, and the query is available on the class:
191
-
192
- ```python
193
- from sqlalchemy.orm import Mapped, mapped_column
194
-
195
- from sqlakit import Database
196
- from sqlakit.orm import Model
197
-
198
- db = Database("postgresql+psycopg://localhost/app")
199
-
200
-
201
- class Note(Model):
202
- __tablename__ = "notes"
203
-
204
- id: Mapped[int] = mapped_column(primary_key=True)
205
- text: Mapped[str]
206
-
207
-
208
- Note.set_db(db)
209
-
210
- with db.transaction():
211
- note = Note(text="ada")
212
- note.save()
213
-
214
- Note.query.where(Note.text == "ada").all()
215
- note.delete()
216
- ```
217
-
218
- `set_db()` binds a model to a database. Call it on a base class and every model
219
- under it inherits the binding. With the global `db` from the section below you
220
- don't need it at all: the model uses the global registry automatically.
221
-
222
- This layer is optional. Everything else works on plain `SQLAlchemy` models, so
223
- if saving belongs in your repositories or services, skip `sqlakit.orm`
224
- entirely.
225
-
226
188
  ## Testing
227
189
 
228
190
  A test runs inside a transaction that rolls back at the end, so nothing the
@@ -328,6 +290,42 @@ with db.using("replica").connect():
328
290
  list_users() # the models read the replica
329
291
  ```
330
292
 
293
+ ## Active Record
294
+
295
+ An instance saves and deletes itself, and the query is available on the class.
296
+ A model on the registry needs no wiring of its own:
297
+
298
+ ```python
299
+ from sqlalchemy.orm import Mapped, mapped_column
300
+
301
+ from sqlakit import db
302
+ from sqlakit.orm import Model
303
+
304
+
305
+ class Note(Model):
306
+ __tablename__ = "notes"
307
+
308
+ id: Mapped[int] = mapped_column(primary_key=True)
309
+ text: Mapped[str]
310
+
311
+
312
+ with db.transaction():
313
+ note = Note(text="ada").save()
314
+
315
+ Note.query.where(Note.text == "ada").all()
316
+ note.delete()
317
+ ```
318
+
319
+ A model that belongs on another database in the registry names its alias with
320
+ `__db__ = "warehouse"`. With a `Database` of your own, `set_db()` binds the
321
+ model to it. Either goes on a base class, and every model under it inherits
322
+ the binding.
323
+
324
+ This layer is optional. Everything else works on plain `SQLAlchemy` models, so
325
+ if saving belongs in your repositories or services, skip `sqlakit.orm`
326
+ entirely. `SQLModel` classes are `SQLAlchemy` models, and work either way: the
327
+ [examples](examples/) show both.
328
+
331
329
  ## The async API
332
330
 
333
331
  The async API is identical: the same classes, the same methods. Only the import
@@ -408,3 +406,5 @@ test from an empty file. The rest is under [`docs/`](docs/):
408
406
  [debugging](docs/debugging.md), [multiple databases](docs/routing.md) and
409
407
  [the reference](docs/reference.md). Complete example apps live in
410
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).
@@ -153,44 +153,6 @@ feed.next_cursor
153
153
  feed.previous_cursor
154
154
  ```
155
155
 
156
- ## Active Record
157
-
158
- An instance saves and deletes itself, and the query is available on the class:
159
-
160
- ```python
161
- from sqlalchemy.orm import Mapped, mapped_column
162
-
163
- from sqlakit import Database
164
- from sqlakit.orm import Model
165
-
166
- db = Database("postgresql+psycopg://localhost/app")
167
-
168
-
169
- class Note(Model):
170
- __tablename__ = "notes"
171
-
172
- id: Mapped[int] = mapped_column(primary_key=True)
173
- text: Mapped[str]
174
-
175
-
176
- Note.set_db(db)
177
-
178
- with db.transaction():
179
- note = Note(text="ada")
180
- note.save()
181
-
182
- Note.query.where(Note.text == "ada").all()
183
- note.delete()
184
- ```
185
-
186
- `set_db()` binds a model to a database. Call it on a base class and every model
187
- under it inherits the binding. With the global `db` from the section below you
188
- don't need it at all: the model uses the global registry automatically.
189
-
190
- This layer is optional. Everything else works on plain `SQLAlchemy` models, so
191
- if saving belongs in your repositories or services, skip `sqlakit.orm`
192
- entirely.
193
-
194
156
  ## Testing
195
157
 
196
158
  A test runs inside a transaction that rolls back at the end, so nothing the
@@ -296,6 +258,42 @@ with db.using("replica").connect():
296
258
  list_users() # the models read the replica
297
259
  ```
298
260
 
261
+ ## Active Record
262
+
263
+ An instance saves and deletes itself, and the query is available on the class.
264
+ A model on the registry needs no wiring of its own:
265
+
266
+ ```python
267
+ from sqlalchemy.orm import Mapped, mapped_column
268
+
269
+ from sqlakit import db
270
+ from sqlakit.orm import Model
271
+
272
+
273
+ class Note(Model):
274
+ __tablename__ = "notes"
275
+
276
+ id: Mapped[int] = mapped_column(primary_key=True)
277
+ text: Mapped[str]
278
+
279
+
280
+ with db.transaction():
281
+ note = Note(text="ada").save()
282
+
283
+ Note.query.where(Note.text == "ada").all()
284
+ note.delete()
285
+ ```
286
+
287
+ A model that belongs on another database in the registry names its alias with
288
+ `__db__ = "warehouse"`. With a `Database` of your own, `set_db()` binds the
289
+ model to it. Either goes on a base class, and every model under it inherits
290
+ the binding.
291
+
292
+ This layer is optional. Everything else works on plain `SQLAlchemy` models, so
293
+ if saving belongs in your repositories or services, skip `sqlakit.orm`
294
+ entirely. `SQLModel` classes are `SQLAlchemy` models, and work either way: the
295
+ [examples](examples/) show both.
296
+
299
297
  ## The async API
300
298
 
301
299
  The async API is identical: the same classes, the same methods. Only the import
@@ -376,3 +374,5 @@ test from an empty file. The rest is under [`docs/`](docs/):
376
374
  [debugging](docs/debugging.md), [multiple databases](docs/routing.md) and
377
375
  [the reference](docs/reference.md). Complete example apps live in
378
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.0"
3
+ version = "0.4.0"
4
4
  description = "A toolkit for SQLAlchemy applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -163,8 +163,8 @@ skip = "./.venv,./site,./uv.lock,./.git"
163
163
  [dependency-groups]
164
164
  dev = [
165
165
  "pytest>=9.1.1",
166
- "ty>=0.0.72",
167
- "ruff>=0.16.3",
166
+ "ty>=0.0.75",
167
+ "ruff>=0.16.5",
168
168
  "poethepoet>=0.48.0",
169
169
  "anyio>=4.14.2",
170
170
  "trio>=0.34.0",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlakit"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "A toolkit for SQLAlchemy applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -30,8 +30,6 @@ Repository = "https://github.com/antonrh/sqlakit"
30
30
  Documentation = "https://github.com/antonrh/sqlakit"
31
31
 
32
32
  [project.optional-dependencies]
33
- # `greenlet`, which the async API runs on. `SQLAlchemy` installs it by itself
34
- # where a wheel exists, and this asks for it on the platforms where none does.
35
33
  asyncio = [
36
34
  "sqlalchemy[asyncio]>=2.0.22",
37
35
  ]
@@ -56,8 +54,8 @@ default-groups = ["dev", "docs"]
56
54
  [dependency-groups]
57
55
  dev = [
58
56
  "pytest>=9.1.1",
59
- "ty>=0.0.72",
60
- "ruff>=0.16.3",
57
+ "ty>=0.0.75",
58
+ "ruff>=0.16.5",
61
59
  "poethepoet>=0.48.0",
62
60
  "anyio>=4.14.2",
63
61
  "trio>=0.34.0",
@@ -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