sqlakit 0.17.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.
- {sqlakit-0.17.0 → sqlakit-0.19.0}/PKG-INFO +7 -6
- {sqlakit-0.17.0 → sqlakit-0.19.0}/README.md +6 -5
- {sqlakit-0.17.0 → sqlakit-0.19.0}/pyproject.toml +1 -1
- {sqlakit-0.17.0 → sqlakit-0.19.0}/pyproject.toml.orig +1 -1
- {sqlakit-0.17.0 → sqlakit-0.19.0}/sqlakit/__init__.py +2 -0
- {sqlakit-0.17.0 → sqlakit-0.19.0}/sqlakit/_query.py +61 -2
- {sqlakit-0.17.0 → sqlakit-0.19.0}/sqlakit/asyncio/orm.py +74 -1
- {sqlakit-0.17.0 → sqlakit-0.19.0}/sqlakit/exceptions.py +16 -0
- {sqlakit-0.17.0 → sqlakit-0.19.0}/sqlakit/orm.py +68 -1
- {sqlakit-0.17.0 → sqlakit-0.19.0}/LICENSE +0 -0
- {sqlakit-0.17.0 → sqlakit-0.19.0}/sqlakit/_base.py +0 -0
- {sqlakit-0.17.0 → sqlakit-0.19.0}/sqlakit/_cli.py +0 -0
- {sqlakit-0.17.0 → sqlakit-0.19.0}/sqlakit/_db.py +0 -0
- {sqlakit-0.17.0 → sqlakit-0.19.0}/sqlakit/_debugserver.py +0 -0
- {sqlakit-0.17.0 → sqlakit-0.19.0}/sqlakit/_discovery.py +0 -0
- {sqlakit-0.17.0 → sqlakit-0.19.0}/sqlakit/_model.py +0 -0
- {sqlakit-0.17.0 → sqlakit-0.19.0}/sqlakit/_recording.py +0 -0
- {sqlakit-0.17.0 → sqlakit-0.19.0}/sqlakit/_registry.py +0 -0
- {sqlakit-0.17.0 → sqlakit-0.19.0}/sqlakit/_routing.py +0 -0
- {sqlakit-0.17.0 → sqlakit-0.19.0}/sqlakit/_sql.py +0 -0
- {sqlakit-0.17.0 → sqlakit-0.19.0}/sqlakit/asyncio/__init__.py +0 -0
- {sqlakit-0.17.0 → sqlakit-0.19.0}/sqlakit/asyncio/_db.py +0 -0
- {sqlakit-0.17.0 → sqlakit-0.19.0}/sqlakit/asyncio/_registry.py +0 -0
- {sqlakit-0.17.0 → sqlakit-0.19.0}/sqlakit/asyncio/sql.py +0 -0
- {sqlakit-0.17.0 → sqlakit-0.19.0}/sqlakit/debugserver.html +0 -0
- {sqlakit-0.17.0 → sqlakit-0.19.0}/sqlakit/py.typed +0 -0
- {sqlakit-0.17.0 → sqlakit-0.19.0}/sqlakit/pytest_plugin.py +0 -0
- {sqlakit-0.17.0 → sqlakit-0.19.0}/sqlakit/sql.py +0 -0
- {sqlakit-0.17.0 → sqlakit-0.19.0}/sqlakit/testing.py +0 -0
- {sqlakit-0.17.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.
|
|
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
|
-
[
|
|
433
|
-
[
|
|
434
|
-
[
|
|
435
|
-
[the reference](docs/reference.md).
|
|
436
|
-
[`examples/`](examples/), and each one is run by
|
|
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
|
-
[
|
|
400
|
-
[
|
|
401
|
-
[
|
|
402
|
-
[the reference](docs/reference.md).
|
|
403
|
-
[`examples/`](examples/), and each one is run by
|
|
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).
|
|
@@ -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.
|
|
@@ -545,12 +602,28 @@ class QueryDescriptor(Generic[QueryT]):
|
|
|
545
602
|
|
|
546
603
|
query = QueryDescriptor(AppQuery)
|
|
547
604
|
```
|
|
605
|
+
|
|
606
|
+
Given `Query` itself, as `ModelMixin` does, it reads as a query of the model
|
|
607
|
+
it is read from: `User.query` is a `Query[User]`, so `User.query.get(1)` is
|
|
608
|
+
a `User | None`.
|
|
548
609
|
"""
|
|
549
610
|
|
|
550
611
|
def __init__(self, query_class: type[QueryT]) -> None:
|
|
551
612
|
self.query_class = query_class
|
|
552
613
|
|
|
553
|
-
|
|
614
|
+
# `ClassVar` cannot hold a type variable, so the base declares the query
|
|
615
|
+
# every model has and a read narrows it to the model it is read from.
|
|
616
|
+
@overload
|
|
617
|
+
def __get__(
|
|
618
|
+
self: QueryDescriptor[Query[Any]],
|
|
619
|
+
instance: object | None,
|
|
620
|
+
owner: type[ModelT],
|
|
621
|
+
) -> Query[ModelT]: ...
|
|
622
|
+
|
|
623
|
+
@overload
|
|
624
|
+
def __get__(self, instance: object | None, owner: type[Any]) -> QueryT: ...
|
|
625
|
+
|
|
626
|
+
def __get__(self, instance: object | None, owner: type[Any]) -> Query[Any]:
|
|
554
627
|
return self.query_class(owner, owner.db)
|
|
555
628
|
|
|
556
629
|
|
|
@@ -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.
|
|
@@ -533,12 +584,28 @@ class QueryDescriptor(Generic[QueryT]):
|
|
|
533
584
|
|
|
534
585
|
query = QueryDescriptor(AppQuery)
|
|
535
586
|
```
|
|
587
|
+
|
|
588
|
+
Given `Query` itself, as `ModelMixin` does, it reads as a query of the model
|
|
589
|
+
it is read from: `User.query` is a `Query[User]`, so `User.query.get(1)` is
|
|
590
|
+
a `User | None`.
|
|
536
591
|
"""
|
|
537
592
|
|
|
538
593
|
def __init__(self, query_class: type[QueryT]) -> None:
|
|
539
594
|
self.query_class = query_class
|
|
540
595
|
|
|
541
|
-
|
|
596
|
+
# `ClassVar` cannot hold a type variable, so the base declares the query
|
|
597
|
+
# every model has and a read narrows it to the model it is read from.
|
|
598
|
+
@overload
|
|
599
|
+
def __get__(
|
|
600
|
+
self: QueryDescriptor[Query[Any]],
|
|
601
|
+
instance: object | None,
|
|
602
|
+
owner: type[ModelT],
|
|
603
|
+
) -> Query[ModelT]: ...
|
|
604
|
+
|
|
605
|
+
@overload
|
|
606
|
+
def __get__(self, instance: object | None, owner: type[Any]) -> QueryT: ...
|
|
607
|
+
|
|
608
|
+
def __get__(self, instance: object | None, owner: type[Any]) -> Query[Any]:
|
|
542
609
|
return self.query_class(owner, owner.db)
|
|
543
610
|
|
|
544
611
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|