sqlakit 0.18.0__tar.gz → 0.19.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 (30) hide show
  1. {sqlakit-0.18.0 → sqlakit-0.19.0}/PKG-INFO +7 -6
  2. {sqlakit-0.18.0 → sqlakit-0.19.0}/README.md +6 -5
  3. {sqlakit-0.18.0 → sqlakit-0.19.0}/pyproject.toml +1 -1
  4. {sqlakit-0.18.0 → sqlakit-0.19.0}/pyproject.toml.orig +1 -1
  5. {sqlakit-0.18.0 → sqlakit-0.19.0}/sqlakit/__init__.py +2 -0
  6. {sqlakit-0.18.0 → sqlakit-0.19.0}/sqlakit/_query.py +61 -2
  7. {sqlakit-0.18.0 → sqlakit-0.19.0}/sqlakit/asyncio/orm.py +57 -0
  8. {sqlakit-0.18.0 → sqlakit-0.19.0}/sqlakit/exceptions.py +16 -0
  9. {sqlakit-0.18.0 → sqlakit-0.19.0}/sqlakit/orm.py +51 -0
  10. {sqlakit-0.18.0 → sqlakit-0.19.0}/LICENSE +0 -0
  11. {sqlakit-0.18.0 → sqlakit-0.19.0}/sqlakit/_base.py +0 -0
  12. {sqlakit-0.18.0 → sqlakit-0.19.0}/sqlakit/_cli.py +0 -0
  13. {sqlakit-0.18.0 → sqlakit-0.19.0}/sqlakit/_db.py +0 -0
  14. {sqlakit-0.18.0 → sqlakit-0.19.0}/sqlakit/_debugserver.py +0 -0
  15. {sqlakit-0.18.0 → sqlakit-0.19.0}/sqlakit/_discovery.py +0 -0
  16. {sqlakit-0.18.0 → sqlakit-0.19.0}/sqlakit/_model.py +0 -0
  17. {sqlakit-0.18.0 → sqlakit-0.19.0}/sqlakit/_recording.py +0 -0
  18. {sqlakit-0.18.0 → sqlakit-0.19.0}/sqlakit/_registry.py +0 -0
  19. {sqlakit-0.18.0 → sqlakit-0.19.0}/sqlakit/_routing.py +0 -0
  20. {sqlakit-0.18.0 → sqlakit-0.19.0}/sqlakit/_sql.py +0 -0
  21. {sqlakit-0.18.0 → sqlakit-0.19.0}/sqlakit/asyncio/__init__.py +0 -0
  22. {sqlakit-0.18.0 → sqlakit-0.19.0}/sqlakit/asyncio/_db.py +0 -0
  23. {sqlakit-0.18.0 → sqlakit-0.19.0}/sqlakit/asyncio/_registry.py +0 -0
  24. {sqlakit-0.18.0 → sqlakit-0.19.0}/sqlakit/asyncio/sql.py +0 -0
  25. {sqlakit-0.18.0 → sqlakit-0.19.0}/sqlakit/debugserver.html +0 -0
  26. {sqlakit-0.18.0 → sqlakit-0.19.0}/sqlakit/py.typed +0 -0
  27. {sqlakit-0.18.0 → sqlakit-0.19.0}/sqlakit/pytest_plugin.py +0 -0
  28. {sqlakit-0.18.0 → sqlakit-0.19.0}/sqlakit/sql.py +0 -0
  29. {sqlakit-0.18.0 → sqlakit-0.19.0}/sqlakit/testing.py +0 -0
  30. {sqlakit-0.18.0 → sqlakit-0.19.0}/sqlakit/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlakit
3
- Version: 0.18.0
3
+ Version: 0.19.0
4
4
  Summary: A toolkit for SQLAlchemy applications.
5
5
  Keywords: sqlalchemy,database,orm,sql,asyncio
6
6
  Author: Anton Ruhlov
@@ -429,10 +429,11 @@ On shutdown, `dispose()` closes the pool.
429
429
 
430
430
  [Getting started](docs/getting-started.md) builds a database, a model and a
431
431
  test from an empty file. The rest is under [`docs/`](docs/):
432
- [queries](docs/queries.md), [SQL templates](docs/sql.md),
433
- [models](docs/models.md), [testing](docs/testing.md),
434
- [debugging](docs/debugging.md), [multiple databases](docs/routing.md) and
435
- [the reference](docs/reference.md). Complete example apps live in
436
- [`examples/`](examples/), and each one is run by the test suite.
432
+ [compared to SQLAlchemy](docs/comparison.md), [queries](docs/queries.md),
433
+ [SQL templates](docs/sql.md), [models](docs/models.md),
434
+ [testing](docs/testing.md), [debugging](docs/debugging.md),
435
+ [multiple databases](docs/routing.md) and [the reference](docs/reference.md).
436
+ Complete example apps live in [`examples/`](examples/), and each one is run by
437
+ the test suite.
437
438
 
438
439
  What changed in each version is in the [changelog](CHANGELOG.md).
@@ -396,10 +396,11 @@ On shutdown, `dispose()` closes the pool.
396
396
 
397
397
  [Getting started](docs/getting-started.md) builds a database, a model and a
398
398
  test from an empty file. The rest is under [`docs/`](docs/):
399
- [queries](docs/queries.md), [SQL templates](docs/sql.md),
400
- [models](docs/models.md), [testing](docs/testing.md),
401
- [debugging](docs/debugging.md), [multiple databases](docs/routing.md) and
402
- [the reference](docs/reference.md). Complete example apps live in
403
- [`examples/`](examples/), and each one is run by the test suite.
399
+ [compared to SQLAlchemy](docs/comparison.md), [queries](docs/queries.md),
400
+ [SQL templates](docs/sql.md), [models](docs/models.md),
401
+ [testing](docs/testing.md), [debugging](docs/debugging.md),
402
+ [multiple databases](docs/routing.md) and [the reference](docs/reference.md).
403
+ Complete example apps live in [`examples/`](examples/), and each one is run by
404
+ the test suite.
404
405
 
405
406
  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.18.0"
3
+ version = "0.19.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.18.0"
3
+ version = "0.19.0"
4
4
  description = "A toolkit for SQLAlchemy applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -24,6 +24,7 @@ from .exceptions import (
24
24
  DatabaseNotConfiguredError,
25
25
  DefaultAliasError,
26
26
  DetachedInstanceError,
27
+ DuplicateKeyError,
27
28
  InstanceNotFoundError,
28
29
  InvalidCursorError,
29
30
  InvalidDatabaseConfigError,
@@ -83,6 +84,7 @@ __all__ = [
83
84
  "DebugServer",
84
85
  "DefaultAliasError",
85
86
  "DetachedInstanceError",
87
+ "DuplicateKeyError",
86
88
  "EngineArgs",
87
89
  "InstanceNotFoundError",
88
90
  "InvalidCursorError",
@@ -43,6 +43,7 @@ from ._model import resolve_alias, soft_delete_column
43
43
  from .exceptions import (
44
44
  BulkQueryError,
45
45
  ConflictingJoinError,
46
+ DuplicateKeyError,
46
47
  InstanceNotFoundError,
47
48
  InvalidCursorError,
48
49
  InvalidNullsError,
@@ -53,6 +54,7 @@ from .exceptions import (
53
54
  PageItemsMismatchError,
54
55
  RawStatementError,
55
56
  UncomparableOrderingError,
57
+ UnknownFieldError,
56
58
  UnknownOrderFieldError,
57
59
  UnorderedPageError,
58
60
  )
@@ -734,9 +736,13 @@ class BaseQuery(Generic[ModelT]):
734
736
  ```
735
737
 
736
738
  The attribute holds what this statement selects into it, so a count or
737
- a window function arrives on the instance rather than beside it.
739
+ a window function arrives on the instance rather than beside it. An
740
+ instance the session already holds is read again, so the value follows
741
+ the expression given this time rather than the one it was loaded with.
738
742
  """
739
- return self.options(with_expression(key, expression))
743
+ return self.options(with_expression(key, expression)).execution_options(
744
+ populate_existing=True
745
+ )
740
746
 
741
747
  def with_for_update(
742
748
  self,
@@ -1518,6 +1524,59 @@ class _Rows(Protocol[RowT_co]):
1518
1524
  def one_or_none(self) -> RowT_co | None: ...
1519
1525
 
1520
1526
 
1527
+ def key_names(model: type[Any], keys: Sequence[Any]) -> list[str]:
1528
+ """Return the attribute names `in_bulk` keys by, checked against the model.
1529
+
1530
+ With no columns the key is the primary key. A key has to be a column
1531
+ attribute of the model itself: an expression cannot be read off a row, and
1532
+ a column of another table with the same name would read the wrong value.
1533
+
1534
+ Raises:
1535
+ UnknownFieldError: if a key is not a column of the model.
1536
+
1537
+ """
1538
+ mapper = sa.inspect(model, raiseerr=True)
1539
+ if not keys:
1540
+ return [
1541
+ mapper.get_property_by_column(column).key for column in mapper.primary_key
1542
+ ]
1543
+ names = []
1544
+ for key in keys:
1545
+ name = getattr(key, "key", None)
1546
+ owner = getattr(key, "class_", None)
1547
+ if (
1548
+ name is None
1549
+ or owner is None
1550
+ or not issubclass(model, owner)
1551
+ or name not in mapper.column_attrs
1552
+ ):
1553
+ raise UnknownFieldError(model.__name__, str(key))
1554
+ names.append(name)
1555
+ return names
1556
+
1557
+
1558
+ def keyed(
1559
+ rows: Iterable[Any], model: type[Any], names: Sequence[str]
1560
+ ) -> dict[Any, Any]:
1561
+ """Return the rows under the value each has in these attributes, one row per key.
1562
+
1563
+ One name keys by its value, several by the tuple of theirs, in the order
1564
+ the rows arrived.
1565
+
1566
+ Raises:
1567
+ DuplicateKeyError: if two rows share a key.
1568
+
1569
+ """
1570
+ result: dict[Any, Any] = {}
1571
+ for row in rows:
1572
+ values = tuple(getattr(row, name) for name in names)
1573
+ key = values[0] if len(values) == 1 else values
1574
+ if key in result:
1575
+ raise DuplicateKeyError(model.__name__, names, key)
1576
+ result[key] = row
1577
+ return result
1578
+
1579
+
1521
1580
  def one_row(rows: _Rows[RowT], name: str) -> RowT:
1522
1581
  """Return the single row, saying what had none or too many.
1523
1582
 
@@ -31,6 +31,8 @@ from sqlakit._query import (
31
31
  BaseQuery,
32
32
  CursorPage,
33
33
  Page,
34
+ key_names,
35
+ keyed,
34
36
  merged,
35
37
  one_row,
36
38
  one_row_or_none,
@@ -152,6 +154,61 @@ class Query(BaseQuery[ModelT]):
152
154
  result = await self._rows(self._executable())
153
155
  return result.all()
154
156
 
157
+ @overload
158
+ async def in_bulk(self) -> dict[Any, ModelT]: ...
159
+
160
+ @overload
161
+ async def in_bulk(
162
+ self, key: _TypedColumnClauseArgument[C0], /
163
+ ) -> dict[C0, ModelT]: ...
164
+
165
+ @overload
166
+ async def in_bulk(
167
+ self,
168
+ key: _TypedColumnClauseArgument[C0],
169
+ other: _TypedColumnClauseArgument[C1],
170
+ /,
171
+ ) -> dict[tuple[C0, C1], ModelT]: ...
172
+
173
+ @overload
174
+ async def in_bulk(
175
+ self,
176
+ key: _TypedColumnClauseArgument[C0],
177
+ other: _TypedColumnClauseArgument[C1],
178
+ third: _TypedColumnClauseArgument[C2],
179
+ /,
180
+ ) -> dict[tuple[C0, C1, C2], ModelT]: ...
181
+
182
+ @overload
183
+ async def in_bulk(
184
+ self, *keys: _TypedColumnClauseArgument[Any]
185
+ ) -> dict[Any, ModelT]: ...
186
+
187
+ async def in_bulk(
188
+ self, *keys: _TypedColumnClauseArgument[Any]
189
+ ) -> dict[Any, ModelT]:
190
+ """Return every matching row, under the value it has in these columns.
191
+
192
+ ```python
193
+ User.query.where(User.id.in_(ids)).in_bulk() # dict[int, User], by key
194
+ User.query.in_bulk(User.email) # dict[str, User]
195
+ Seat.query.in_bulk(Seat.row, Seat.col) # dict[tuple[int, str], Seat]
196
+ ```
197
+
198
+ One column keys by its value and several by the tuple of theirs, so
199
+ the checker knows the key's type. The rows keep the query's order, and
200
+ a key column the query defers is loaded with the row.
201
+
202
+ Raises:
203
+ DuplicateKeyError: if two rows share a key.
204
+ UnknownFieldError: if a key is not a column of the model.
205
+
206
+ """
207
+ # A key the model defers is read with the row rather than one row at a time.
208
+ names = key_names(self.model, keys)
209
+ rows = await self.undefer(*keys).all()
210
+ return keyed(rows, self.model, names)
211
+
155
212
  async def first(self) -> ModelT | None:
156
213
  """Return the first matching row, or None."""
157
214
  # A raw statement takes no limit; the first row is read off the result.
@@ -479,6 +479,22 @@ class InvalidNullsError(SQLAKitError, ValueError):
479
479
  super().__init__(f"`nulls` is `first` or `last`, not `{nulls!r}`.")
480
480
 
481
481
 
482
+ class DuplicateKeyError(SQLAKitError, ValueError):
483
+ """Raised when `in_bulk` finds two rows under one key."""
484
+
485
+ def __init__(
486
+ self, model: str, key: Iterable[str] = (), value: object = None
487
+ ) -> None:
488
+ super().__init__(
489
+ f"`in_bulk` keys {model} rows by {', '.join(key) or 'the primary key'}, "
490
+ f"and two rows share {value!r}. Key by something unique, or narrow "
491
+ f"the query to one row per key."
492
+ )
493
+ self.model = model
494
+ self.key = tuple(key)
495
+ self.value = value
496
+
497
+
482
498
  class KeyLookupError(SQLAKitError, TypeError):
483
499
  """Raised when a lookup by primary key is asked to honour what it cannot."""
484
500
 
@@ -32,6 +32,8 @@ from ._query import (
32
32
  BaseQuery,
33
33
  CursorPage,
34
34
  Page,
35
+ key_names,
36
+ keyed,
35
37
  merged,
36
38
  one_row,
37
39
  one_row_or_none,
@@ -148,6 +150,55 @@ class Query(BaseQuery[ModelT]):
148
150
  """Return every matching row."""
149
151
  return self._rows(self._executable()).all()
150
152
 
153
+ @overload
154
+ def in_bulk(self) -> dict[Any, ModelT]: ...
155
+
156
+ @overload
157
+ def in_bulk(self, key: _TypedColumnClauseArgument[C0], /) -> dict[C0, ModelT]: ...
158
+
159
+ @overload
160
+ def in_bulk(
161
+ self,
162
+ key: _TypedColumnClauseArgument[C0],
163
+ other: _TypedColumnClauseArgument[C1],
164
+ /,
165
+ ) -> dict[tuple[C0, C1], ModelT]: ...
166
+
167
+ @overload
168
+ def in_bulk(
169
+ self,
170
+ key: _TypedColumnClauseArgument[C0],
171
+ other: _TypedColumnClauseArgument[C1],
172
+ third: _TypedColumnClauseArgument[C2],
173
+ /,
174
+ ) -> dict[tuple[C0, C1, C2], ModelT]: ...
175
+
176
+ @overload
177
+ def in_bulk(self, *keys: _TypedColumnClauseArgument[Any]) -> dict[Any, ModelT]: ...
178
+
179
+ def in_bulk(self, *keys: _TypedColumnClauseArgument[Any]) -> dict[Any, ModelT]:
180
+ """Return every matching row, under the value it has in these columns.
181
+
182
+ ```python
183
+ User.query.where(User.id.in_(ids)).in_bulk() # dict[int, User], by key
184
+ User.query.in_bulk(User.email) # dict[str, User]
185
+ Seat.query.in_bulk(Seat.row, Seat.col) # dict[tuple[int, str], Seat]
186
+ ```
187
+
188
+ One column keys by its value and several by the tuple of theirs, so
189
+ the checker knows the key's type. The rows keep the query's order, and
190
+ a key column the query defers is loaded with the row.
191
+
192
+ Raises:
193
+ DuplicateKeyError: if two rows share a key.
194
+ UnknownFieldError: if a key is not a column of the model.
195
+
196
+ """
197
+ # A key the model defers is read with the row rather than one row at a time.
198
+ names = key_names(self.model, keys)
199
+ rows = self.undefer(*keys).all()
200
+ return keyed(rows, self.model, names)
201
+
151
202
  def first(self) -> ModelT | None:
152
203
  """Return the first matching row, or None."""
153
204
  # A raw statement takes no limit; the first row is read off the result.
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