sqlakit 0.4.0__tar.gz → 0.5.1__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.1}/PKG-INFO +1 -1
  2. {sqlakit-0.4.0 → sqlakit-0.5.1}/pyproject.toml +1 -1
  3. {sqlakit-0.4.0 → sqlakit-0.5.1}/pyproject.toml.orig +1 -1
  4. {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/__init__.py +15 -1
  5. {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/_base.py +34 -5
  6. {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/_model.py +57 -0
  7. {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/_query.py +121 -10
  8. {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/asyncio/orm.py +3 -0
  9. {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/exceptions.py +36 -0
  10. {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/orm.py +3 -0
  11. {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/types.py +16 -2
  12. {sqlakit-0.4.0 → sqlakit-0.5.1}/LICENSE +0 -0
  13. {sqlakit-0.4.0 → sqlakit-0.5.1}/README.md +0 -0
  14. {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/_db.py +0 -0
  15. {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/_discovery.py +0 -0
  16. {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/_recording.py +0 -0
  17. {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/_registry.py +0 -0
  18. {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/_routing.py +0 -0
  19. {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/_sql.py +0 -0
  20. {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/asyncio/__init__.py +0 -0
  21. {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/asyncio/_db.py +0 -0
  22. {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/asyncio/_registry.py +0 -0
  23. {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/asyncio/sql.py +0 -0
  24. {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/py.typed +0 -0
  25. {sqlakit-0.4.0 → sqlakit-0.5.1}/sqlakit/sql.py +0 -0
  26. {sqlakit-0.4.0 → sqlakit-0.5.1}/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.1
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.1"
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.1"
4
4
  description = "A toolkit for SQLAlchemy applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -7,15 +7,18 @@ from ._registry import Databases, db
7
7
  from ._routing import Router
8
8
  from .exceptions import (
9
9
  DEFAULT_ALIAS,
10
+ AliasInUseError,
10
11
  AsyncFilterError,
11
12
  BulkQueryError,
12
13
  ConflictingDatabaseUrlError,
13
14
  DatabaseAlreadyConfiguredError,
14
15
  DatabaseNotConfiguredError,
16
+ DefaultAliasError,
15
17
  DetachedInstanceError,
16
18
  InstanceNotFoundError,
17
19
  InvalidCursorError,
18
20
  InvalidDatabaseConfigError,
21
+ InvalidNullsError,
19
22
  InvalidOrderFieldError,
20
23
  KeyLookupError,
21
24
  MissingConnectionError,
@@ -40,13 +43,21 @@ from .exceptions import (
40
43
  UnknownOrderFieldError,
41
44
  UnorderedPageError,
42
45
  )
43
- from .types import DatabaseConfig, EngineArgs, QueryStats, SessionArgs, UrlParts
46
+ from .types import (
47
+ DatabaseConfig,
48
+ EngineArgs,
49
+ QueryStats,
50
+ SessionArgs,
51
+ TemplatesLike,
52
+ UrlParts,
53
+ )
44
54
 
45
55
  __all__ = [
46
56
  "CASE_INSENSITIVE_COLLATIONS",
47
57
  "DEFAULT_ALIAS",
48
58
  "DEFAULT_ENGINE_ARGS",
49
59
  "DEFAULT_SESSION_ARGS",
60
+ "AliasInUseError",
50
61
  "AsyncFilterError",
51
62
  "BulkQueryError",
52
63
  "ConflictingDatabaseUrlError",
@@ -56,11 +67,13 @@ __all__ = [
56
67
  "DatabaseConfig",
57
68
  "DatabaseNotConfiguredError",
58
69
  "Databases",
70
+ "DefaultAliasError",
59
71
  "DetachedInstanceError",
60
72
  "EngineArgs",
61
73
  "InstanceNotFoundError",
62
74
  "InvalidCursorError",
63
75
  "InvalidDatabaseConfigError",
76
+ "InvalidNullsError",
64
77
  "InvalidOrderFieldError",
65
78
  "KeyLookupError",
66
79
  "MissingConnectionError",
@@ -85,6 +98,7 @@ __all__ = [
85
98
  "Statement",
86
99
  "StrayParameterError",
87
100
  "TemplateNotFoundError",
101
+ "TemplatesLike",
88
102
  "Transaction",
89
103
  "TransactionRolledBackError",
90
104
  "UncomparableOrderingError",
@@ -35,9 +35,11 @@ from ._recording import (
35
35
  from ._routing import Router, as_router
36
36
  from .exceptions import (
37
37
  DEFAULT_ALIAS,
38
+ AliasInUseError,
38
39
  ConflictingDatabaseUrlError,
39
40
  DatabaseAlreadyConfiguredError,
40
41
  DatabaseNotConfiguredError,
42
+ DefaultAliasError,
41
43
  MissingConnectionError,
42
44
  MissingDatabaseUrlError,
43
45
  MissingDefaultDatabaseError,
@@ -49,16 +51,18 @@ from .exceptions import (
49
51
  if TYPE_CHECKING:
50
52
  import logging
51
53
  from collections.abc import Iterator, Sequence
52
- from pathlib import Path
53
54
 
54
55
  from sqlalchemy.engine import Engine
55
56
 
56
- from ._sql import Templates
57
- from .types import DatabaseConfig, EngineArgs, SessionArgs, UrlParts
57
+ from .types import (
58
+ DatabaseConfig,
59
+ EngineArgs,
60
+ SessionArgs,
61
+ TemplatesLike,
62
+ UrlParts,
63
+ )
58
64
 
59
65
  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
66
 
63
67
  __all__ = [
64
68
  "DEFAULT_ALIAS",
@@ -691,6 +695,31 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
691
695
  def __contains__(self, alias: str) -> bool:
692
696
  return alias == DEFAULT_ALIAS or alias in self._aliased
693
697
 
698
+ def register(self, alias: str, db: DatabaseT) -> None:
699
+ """Put a database already built under an alias.
700
+
701
+ `configure` takes settings and builds the databases. This takes one you
702
+ built yourself, for a shard that only exists once the application is
703
+ running, or for a registry that never reads settings at all:
704
+
705
+ ```python
706
+ db.register("shard-7", Database(SHARD_URL))
707
+ ```
708
+
709
+ The alias has to be free. Replacing one under a name already in use
710
+ would leave the code that holds the old database talking to it.
711
+
712
+ Raises:
713
+ AliasInUseError: if another database holds that alias.
714
+ DefaultAliasError: if the alias is `default`, which this registry is.
715
+
716
+ """
717
+ if alias == DEFAULT_ALIAS:
718
+ raise DefaultAliasError
719
+ if alias in self._aliased:
720
+ raise AliasInUseError(alias)
721
+ self._aliased[alias] = self._named(alias, db)
722
+
694
723
  @contextmanager
695
724
  def recording(
696
725
  self,
@@ -37,7 +37,9 @@ __all__ = [
37
37
  "BaseModel",
38
38
  "BaseSoftDeletes",
39
39
  "DatabaseDescriptor",
40
+ "DatabaseRegistry",
40
41
  "DatabaseSource",
42
+ "RegistryDescriptor",
41
43
  "db_for",
42
44
  "resolve_alias",
43
45
  "soft_delete_column",
@@ -66,6 +68,19 @@ class DatabaseDescriptor(Generic[DatabaseT]):
66
68
  return cast("DatabaseT", db_for(owner))
67
69
 
68
70
 
71
+ class DatabaseRegistry(DatabaseSource, Protocol):
72
+ """A source that also takes a database under an alias it does not have."""
73
+
74
+ def register(self, alias: str, db: Any) -> None: ... # noqa: ANN401
75
+
76
+
77
+ class RegistryDescriptor:
78
+ """Reads ``__dbs__``, on the class as well as on an instance."""
79
+
80
+ def __get__(self, instance: object | None, owner: type[Any]) -> Any: # noqa: ANN401
81
+ return owner.__dbs__
82
+
83
+
69
84
  class BaseModel(Generic[DatabaseT]):
70
85
  """What the sync and async models share: everything that is not IO.
71
86
 
@@ -87,6 +102,7 @@ class BaseModel(Generic[DatabaseT]):
87
102
  # Unannotated on purpose: an annotation here reads as a field to
88
103
  # pydantic, and SQLModel models would refuse to build.
89
104
  db = DatabaseDescriptor[DatabaseT]()
105
+ dbs = RegistryDescriptor()
90
106
 
91
107
  @classmethod
92
108
  def set_db(cls, db: str | DatabaseT) -> None:
@@ -103,6 +119,37 @@ class BaseModel(Generic[DatabaseT]):
103
119
  """
104
120
  cls.__db__ = db
105
121
 
122
+ @classmethod
123
+ def register_db(cls, db: DatabaseT, *, alias: str) -> None:
124
+ """Give this model a database under an alias, and the ones under it too.
125
+
126
+ The registry it goes in belongs to this class, so nothing global is
127
+ configured and two sets of models can each have their own `shard`:
128
+
129
+ ```python
130
+ Base.register_db(Database(DB1_URL), alias="db1")
131
+ Base.register_db(Database(DB2_URL), alias="db2")
132
+
133
+ with Base.dbs.using("db2").transaction():
134
+ User(name="ada").save()
135
+ ```
136
+
137
+ Which database a model resolves to is still `__db__`, the routers and
138
+ the open `using()` block, in that order. A model left on the default
139
+ alias follows `using()`, which is what makes the switch above work.
140
+
141
+ Raises:
142
+ AliasInUseError: if another database holds that alias.
143
+ DefaultAliasError: if the alias is `default`.
144
+
145
+ """
146
+ if _owns_no_registry(cls):
147
+ # A registry of its own: registering into the importable one would
148
+ # configure it for every model in the process. A class under one
149
+ # that already has its own registers into that one.
150
+ cls.__dbs__ = type(cls.__dbs__)()
151
+ cast("DatabaseRegistry", cls.__dbs__).register(alias, db)
152
+
106
153
  def update(self, values: Mapping[str, Any]) -> Self:
107
154
  """Set these fields on this instance, and return it.
108
155
 
@@ -187,6 +234,16 @@ class BaseModel(Generic[DatabaseT]):
187
234
  self.db.session.add(self)
188
235
 
189
236
 
237
+ def _owns_no_registry(model: type[Any]) -> bool:
238
+ """Whether this model still looks its aliases up in the importable registry."""
239
+ declared = next(
240
+ (klass for klass in model.__mro__ if "__dbs__" in klass.__dict__), None
241
+ )
242
+ return (
243
+ declared is None or declared.__module__.split(".")[0] == __name__.split(".")[0]
244
+ )
245
+
246
+
190
247
  def db_for(model: type[Any]) -> BaseDatabase[Any, Any]:
191
248
  """Return the database a model lives on.
192
249
 
@@ -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
 
@@ -5,15 +5,18 @@ from sqlalchemy.orm import exc as sa_exc
5
5
  DEFAULT_ALIAS = "default"
6
6
 
7
7
  __all__ = [
8
+ "AliasInUseError",
8
9
  "AsyncFilterError",
9
10
  "BulkQueryError",
10
11
  "ConflictingDatabaseUrlError",
11
12
  "DatabaseAlreadyConfiguredError",
12
13
  "DatabaseNotConfiguredError",
14
+ "DefaultAliasError",
13
15
  "DetachedInstanceError",
14
16
  "InstanceNotFoundError",
15
17
  "InvalidCursorError",
16
18
  "InvalidDatabaseConfigError",
19
+ "InvalidNullsError",
17
20
  "InvalidOrderFieldError",
18
21
  "MissingConnectionError",
19
22
  "MissingDatabaseUrlError",
@@ -106,6 +109,27 @@ class DatabaseAlreadyConfiguredError(SQLAKitError, RuntimeError):
106
109
  super().__init__(message)
107
110
 
108
111
 
112
+ class AliasInUseError(SQLAKitError, ValueError):
113
+ """Raised when registering a database under an alias another one holds."""
114
+
115
+ def __init__(self, alias: str) -> None:
116
+ self.alias = alias
117
+ super().__init__(
118
+ f"`{alias}` is already registered. Dispose of that database before "
119
+ "registering another under the same name."
120
+ )
121
+
122
+
123
+ class DefaultAliasError(SQLAKitError, ValueError):
124
+ """Raised when registering a database as the default one."""
125
+
126
+ def __init__(self) -> None:
127
+ super().__init__(
128
+ f"`{DEFAULT_ALIAS}` is the registry itself. Configure it with "
129
+ "`configure()`, and register the others under their own names."
130
+ )
131
+
132
+
109
133
  class UnknownDatabaseError(SQLAKitError, KeyError):
110
134
  """Raised when asking for a database alias that was never configured."""
111
135
 
@@ -387,6 +411,18 @@ class InvalidOrderFieldError(SQLAKitError, TypeError):
387
411
  )
388
412
 
389
413
 
414
+ class InvalidNullsError(SQLAKitError, ValueError):
415
+ """Raised when ``order_by`` is told to put the nulls somewhere else.
416
+
417
+ ``nulls`` says where the rows with no value go, and SQL has two answers to
418
+ that.
419
+ """
420
+
421
+ def __init__(self, nulls: object) -> None:
422
+ self.nulls = nulls
423
+ super().__init__(f"`nulls` is `first` or `last`, not `{nulls!r}`.")
424
+
425
+
390
426
  class KeyLookupError(SQLAKitError, TypeError):
391
427
  """Raised when a lookup by primary key is asked to honour what it cannot."""
392
428
 
@@ -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