sqlakit 0.18.0__tar.gz → 0.20.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.18.0 → sqlakit-0.20.0}/PKG-INFO +14 -9
- {sqlakit-0.18.0 → sqlakit-0.20.0}/README.md +13 -8
- {sqlakit-0.18.0 → sqlakit-0.20.0}/pyproject.toml +2 -5
- {sqlakit-0.18.0 → sqlakit-0.20.0}/pyproject.toml.orig +2 -5
- {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/__init__.py +2 -2
- {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/_base.py +8 -9
- {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/_query.py +61 -2
- {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/asyncio/orm.py +57 -0
- {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/exceptions.py +16 -0
- {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/orm.py +51 -0
- {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/pytest_plugin.py +24 -10
- sqlakit-0.18.0/sqlakit/_cli.py +0 -101
- sqlakit-0.18.0/sqlakit/_debugserver.py +0 -396
- sqlakit-0.18.0/sqlakit/debugserver.html +0 -103
- {sqlakit-0.18.0 → sqlakit-0.20.0}/LICENSE +0 -0
- {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/_db.py +0 -0
- {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/_discovery.py +0 -0
- {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/_model.py +0 -0
- {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/_recording.py +0 -0
- {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/_registry.py +0 -0
- {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/_routing.py +0 -0
- {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/_sql.py +0 -0
- {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/asyncio/__init__.py +0 -0
- {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/asyncio/_db.py +0 -0
- {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/asyncio/_registry.py +0 -0
- {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/asyncio/sql.py +0 -0
- {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/py.typed +0 -0
- {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/sql.py +0 -0
- {sqlakit-0.18.0 → sqlakit-0.20.0}/sqlakit/testing.py +0 -0
- {sqlakit-0.18.0 → sqlakit-0.20.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.20.0
|
|
4
4
|
Summary: A toolkit for SQLAlchemy applications.
|
|
5
5
|
Keywords: sqlalchemy,database,orm,sql,asyncio
|
|
6
6
|
Author: Anton Ruhlov
|
|
@@ -249,16 +249,20 @@ project has `rich`, the output is colored too.
|
|
|
249
249
|
|
|
250
250
|
### The debug server
|
|
251
251
|
|
|
252
|
-
`sqlakit
|
|
252
|
+
[`sqlakit-debugserver`](https://github.com/sqlakit/sqlakit-debugserver) serves a page that fills as the recordings
|
|
253
|
+
arrive. It is a package of its own:
|
|
253
254
|
|
|
254
255
|
```console
|
|
255
|
-
$ sqlakit
|
|
256
|
+
$ pip install sqlakit-debugserver
|
|
257
|
+
$ sqlakit-debugserver
|
|
256
258
|
|
|
257
259
|
SQLAKit debug server on http://localhost:5555
|
|
258
260
|
|
|
259
261
|
Send recordings to it:
|
|
260
262
|
|
|
261
|
-
│
|
|
263
|
+
│ from sqlakit_debugserver import DebugServer
|
|
264
|
+
│
|
|
265
|
+
│ with db.recording("GET /users", send_to=DebugServer("localhost", 5555)):
|
|
262
266
|
│ list_users()
|
|
263
267
|
```
|
|
264
268
|
|
|
@@ -429,10 +433,11 @@ On shutdown, `dispose()` closes the pool.
|
|
|
429
433
|
|
|
430
434
|
[Getting started](docs/getting-started.md) builds a database, a model and a
|
|
431
435
|
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
|
|
436
|
+
[compared to SQLAlchemy](docs/comparison.md), [queries](docs/queries.md),
|
|
437
|
+
[SQL templates](docs/sql.md), [models](docs/models.md),
|
|
438
|
+
[testing](docs/testing.md), [debugging](docs/debugging.md),
|
|
439
|
+
[multiple databases](docs/routing.md) and [the reference](docs/reference.md).
|
|
440
|
+
Complete example apps live in [`examples/`](examples/), and each one is run by
|
|
441
|
+
the test suite.
|
|
437
442
|
|
|
438
443
|
What changed in each version is in the [changelog](CHANGELOG.md).
|
|
@@ -216,16 +216,20 @@ project has `rich`, the output is colored too.
|
|
|
216
216
|
|
|
217
217
|
### The debug server
|
|
218
218
|
|
|
219
|
-
`sqlakit
|
|
219
|
+
[`sqlakit-debugserver`](https://github.com/sqlakit/sqlakit-debugserver) serves a page that fills as the recordings
|
|
220
|
+
arrive. It is a package of its own:
|
|
220
221
|
|
|
221
222
|
```console
|
|
222
|
-
$ sqlakit
|
|
223
|
+
$ pip install sqlakit-debugserver
|
|
224
|
+
$ sqlakit-debugserver
|
|
223
225
|
|
|
224
226
|
SQLAKit debug server on http://localhost:5555
|
|
225
227
|
|
|
226
228
|
Send recordings to it:
|
|
227
229
|
|
|
228
|
-
│
|
|
230
|
+
│ from sqlakit_debugserver import DebugServer
|
|
231
|
+
│
|
|
232
|
+
│ with db.recording("GET /users", send_to=DebugServer("localhost", 5555)):
|
|
229
233
|
│ list_users()
|
|
230
234
|
```
|
|
231
235
|
|
|
@@ -396,10 +400,11 @@ On shutdown, `dispose()` closes the pool.
|
|
|
396
400
|
|
|
397
401
|
[Getting started](docs/getting-started.md) builds a database, a model and a
|
|
398
402
|
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
|
|
403
|
+
[compared to SQLAlchemy](docs/comparison.md), [queries](docs/queries.md),
|
|
404
|
+
[SQL templates](docs/sql.md), [models](docs/models.md),
|
|
405
|
+
[testing](docs/testing.md), [debugging](docs/debugging.md),
|
|
406
|
+
[multiple databases](docs/routing.md) and [the reference](docs/reference.md).
|
|
407
|
+
Complete example apps live in [`examples/`](examples/), and each one is run by
|
|
408
|
+
the test suite.
|
|
404
409
|
|
|
405
410
|
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
|
+
version = "0.20.0"
|
|
4
4
|
description = "A toolkit for SQLAlchemy applications."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -35,9 +35,6 @@ dependencies = [
|
|
|
35
35
|
name = "Anton Ruhlov"
|
|
36
36
|
email = "antonruhlov@gmail.com"
|
|
37
37
|
|
|
38
|
-
[project.scripts]
|
|
39
|
-
sqlakit = "sqlakit._cli:main"
|
|
40
|
-
|
|
41
38
|
[project.entry-points.pytest11]
|
|
42
39
|
sqlakit = "sqlakit.pytest_plugin"
|
|
43
40
|
|
|
@@ -178,7 +175,7 @@ default_item_type = "cmd"
|
|
|
178
175
|
|
|
179
176
|
[tool.codespell]
|
|
180
177
|
ignore-words-list = "froms"
|
|
181
|
-
skip = "./.venv,./site,./uv.lock,./.git
|
|
178
|
+
skip = "./.venv,./site,./uv.lock,./.git"
|
|
182
179
|
|
|
183
180
|
[dependency-groups]
|
|
184
181
|
dev = [
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sqlakit"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.20.0"
|
|
4
4
|
description = "A toolkit for SQLAlchemy applications."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -26,9 +26,6 @@ dependencies = [
|
|
|
26
26
|
"typing-extensions>=4.13",
|
|
27
27
|
]
|
|
28
28
|
|
|
29
|
-
[project.scripts]
|
|
30
|
-
sqlakit = "sqlakit._cli:main"
|
|
31
|
-
|
|
32
29
|
[project.entry-points.pytest11]
|
|
33
30
|
sqlakit = "sqlakit.pytest_plugin"
|
|
34
31
|
|
|
@@ -211,4 +208,4 @@ default_item_type = "cmd"
|
|
|
211
208
|
[tool.codespell]
|
|
212
209
|
# `froms` is SQLAlchemy's own word for what a SELECT selects from.
|
|
213
210
|
ignore-words-list = "froms"
|
|
214
|
-
skip = "./.venv,./site,./uv.lock,./.git
|
|
211
|
+
skip = "./.venv,./site,./uv.lock,./.git"
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
from ._base import DEFAULT_ENGINE_ARGS, DEFAULT_SESSION_ARGS
|
|
2
2
|
from ._db import Database, RetryingTransaction, Transaction
|
|
3
|
-
from ._debugserver import DebugServer
|
|
4
3
|
from ._discovery import import_models, import_string
|
|
5
4
|
from ._query import (
|
|
6
5
|
CASE_INSENSITIVE_COLLATIONS,
|
|
@@ -24,6 +23,7 @@ from .exceptions import (
|
|
|
24
23
|
DatabaseNotConfiguredError,
|
|
25
24
|
DefaultAliasError,
|
|
26
25
|
DetachedInstanceError,
|
|
26
|
+
DuplicateKeyError,
|
|
27
27
|
InstanceNotFoundError,
|
|
28
28
|
InvalidCursorError,
|
|
29
29
|
InvalidDatabaseConfigError,
|
|
@@ -80,9 +80,9 @@ __all__ = [
|
|
|
80
80
|
"DatabaseConfig",
|
|
81
81
|
"DatabaseNotConfiguredError",
|
|
82
82
|
"Databases",
|
|
83
|
-
"DebugServer",
|
|
84
83
|
"DefaultAliasError",
|
|
85
84
|
"DetachedInstanceError",
|
|
85
|
+
"DuplicateKeyError",
|
|
86
86
|
"EngineArgs",
|
|
87
87
|
"InstanceNotFoundError",
|
|
88
88
|
"InvalidCursorError",
|
|
@@ -24,7 +24,6 @@ import sqlalchemy as sa
|
|
|
24
24
|
import sqlalchemy.event
|
|
25
25
|
from typing_extensions import Unpack
|
|
26
26
|
|
|
27
|
-
from ._debugserver import DebugServer, send_recording
|
|
28
27
|
from ._discovery import import_string
|
|
29
28
|
from ._recording import (
|
|
30
29
|
KEEP,
|
|
@@ -358,7 +357,7 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
|
|
|
358
357
|
stacks: bool = False,
|
|
359
358
|
skip_queries_from: Sequence[str | PathLike[str]] = (),
|
|
360
359
|
into: Recording | None = None,
|
|
361
|
-
|
|
360
|
+
send_to: Callable[[Recording], object] | None = None,
|
|
362
361
|
) -> Iterator[Recording]:
|
|
363
362
|
"""Record the statements of this block, and what they add up to.
|
|
364
363
|
|
|
@@ -373,8 +372,8 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
|
|
|
373
372
|
|
|
374
373
|
``logger`` writes a summary when the block ends, at a level the numbers
|
|
375
374
|
choose. ``echo`` prints the statements instead, coloured where `rich` is
|
|
376
|
-
installed. ``
|
|
377
|
-
|
|
375
|
+
installed. ``send_to`` is called with the recording, to hand it on:
|
|
376
|
+
`sqlakit_debugserver.DebugServer` is one. ``stacks`` has every
|
|
378
377
|
statement remember the frames that led to it, at the cost of a stack walk
|
|
379
378
|
each time. ``skip_queries_from`` names the files whose statements are none of
|
|
380
379
|
your business: what those run is not recorded at all, which leaves a test's
|
|
@@ -403,8 +402,8 @@ class BaseDatabase(Generic[ConnectionT, SessionT]):
|
|
|
403
402
|
recording.log(logger)
|
|
404
403
|
if echo:
|
|
405
404
|
recording.echo()
|
|
406
|
-
if
|
|
407
|
-
|
|
405
|
+
if send_to is not None:
|
|
406
|
+
send_to(recording)
|
|
408
407
|
|
|
409
408
|
@contextmanager
|
|
410
409
|
def assert_queries(
|
|
@@ -886,7 +885,7 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
|
|
|
886
885
|
stacks: bool = False,
|
|
887
886
|
skip_queries_from: Sequence[str | PathLike[str]] = (),
|
|
888
887
|
into: Recording | None = None,
|
|
889
|
-
|
|
888
|
+
send_to: Callable[[Recording], object] | None = None,
|
|
890
889
|
using: str | DatabaseT | Sequence[str | DatabaseT] | None = None,
|
|
891
890
|
) -> Iterator[Recording]:
|
|
892
891
|
"""Record every database this registry has, not the default one alone.
|
|
@@ -936,8 +935,8 @@ class _DatabaseRegistryMixin(BaseDatabase[Any, Any], Generic[DatabaseT]):
|
|
|
936
935
|
together.log(logger)
|
|
937
936
|
if echo:
|
|
938
937
|
together.echo()
|
|
939
|
-
if
|
|
940
|
-
|
|
938
|
+
if send_to is not None:
|
|
939
|
+
send_to(together)
|
|
941
940
|
|
|
942
941
|
@contextmanager
|
|
943
942
|
def unbound(self) -> Iterator[None]:
|
|
@@ -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.
|
|
@@ -38,6 +38,7 @@ Every other test connects to nothing, and reaching for a session there raises
|
|
|
38
38
|
from __future__ import annotations
|
|
39
39
|
|
|
40
40
|
import asyncio
|
|
41
|
+
import importlib.util
|
|
41
42
|
import inspect
|
|
42
43
|
import pathlib
|
|
43
44
|
import time
|
|
@@ -48,7 +49,6 @@ from typing import TYPE_CHECKING, Any
|
|
|
48
49
|
|
|
49
50
|
import pytest
|
|
50
51
|
|
|
51
|
-
from ._debugserver import as_payload, write_report
|
|
52
52
|
from ._recording import Recording, check, require_expectation
|
|
53
53
|
from ._registry import db as importable_db
|
|
54
54
|
from .exceptions import UnknownDatabaseError
|
|
@@ -63,7 +63,7 @@ if TYPE_CHECKING:
|
|
|
63
63
|
MARKER = "db"
|
|
64
64
|
SYNC_FIXTURE = "_sqlakit_transaction"
|
|
65
65
|
ASYNC_FIXTURE = "_sqlakit_async_transaction"
|
|
66
|
-
REPORT = pytest.StashKey[list[
|
|
66
|
+
REPORT = pytest.StashKey[list[tuple[Recording, str, list[str]]]]()
|
|
67
67
|
WHERE = pytest.StashKey["pathlib.Path | None"]()
|
|
68
68
|
NAMED_BY_THE_CLOCK = ""
|
|
69
69
|
"""What `--sqlakit-report` with no path means."""
|
|
@@ -114,9 +114,18 @@ def pytest_terminal_summary(
|
|
|
114
114
|
) -> None:
|
|
115
115
|
"""Write the report at the end, and say where it went."""
|
|
116
116
|
where = config.stash.get(WHERE, None)
|
|
117
|
-
|
|
118
|
-
if where is None or not
|
|
117
|
+
reported = config.stash.get(REPORT, [])
|
|
118
|
+
if where is None or not reported:
|
|
119
119
|
return
|
|
120
|
+
# Installed apart from `sqlakit`, and only when a report is asked for.
|
|
121
|
+
from sqlakit_debugserver import ( # noqa: PLC0415 # ty: ignore[unresolved-import]
|
|
122
|
+
as_payload,
|
|
123
|
+
write_report,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
records = [
|
|
127
|
+
as_payload(recording, app=app, tags=tags) for recording, app, tags in reported
|
|
128
|
+
]
|
|
120
129
|
tests = len({record["app"] + record["label"] for record in records})
|
|
121
130
|
queries = sum(record["count"] for record in records)
|
|
122
131
|
written = write_report(where, records, about=f"{tests} tests · {queries} queries")
|
|
@@ -133,9 +142,8 @@ def _report_path(config: pytest.Config) -> pathlib.Path | None:
|
|
|
133
142
|
if asked is None:
|
|
134
143
|
return None
|
|
135
144
|
if asked == NAMED_BY_THE_CLOCK:
|
|
136
|
-
|
|
137
|
-
where
|
|
138
|
-
if where.is_dir():
|
|
145
|
+
where = config.rootpath / time.strftime("sqlakit-%Y%m%d-%H%M%S.html")
|
|
146
|
+
elif (where := pathlib.Path(asked)).is_dir():
|
|
139
147
|
# `--sqlakit-report tests/unit` reads as the path to write to.
|
|
140
148
|
message = (
|
|
141
149
|
f"--sqlakit-report was given `{asked}`, which is a directory. "
|
|
@@ -143,6 +151,12 @@ def _report_path(config: pytest.Config) -> pathlib.Path | None:
|
|
|
143
151
|
f"for a name with the time in it."
|
|
144
152
|
)
|
|
145
153
|
raise pytest.UsageError(message)
|
|
154
|
+
if importlib.util.find_spec("sqlakit_debugserver") is None:
|
|
155
|
+
message = (
|
|
156
|
+
"--sqlakit-report writes the debug server's page, which comes with "
|
|
157
|
+
"it: pip install sqlakit-debugserver"
|
|
158
|
+
)
|
|
159
|
+
raise pytest.UsageError(message)
|
|
146
160
|
return where
|
|
147
161
|
|
|
148
162
|
|
|
@@ -388,10 +402,10 @@ def _reported(request: pytest.FixtureRequest, db: Any) -> Iterator[None]: # noq
|
|
|
388
402
|
)
|
|
389
403
|
yield
|
|
390
404
|
request.config.stash[REPORT].append(
|
|
391
|
-
|
|
405
|
+
(
|
|
392
406
|
recording,
|
|
393
|
-
|
|
394
|
-
|
|
407
|
+
str(node.path.relative_to(request.config.rootpath)),
|
|
408
|
+
[mark.name for mark in node.iter_markers() if mark.name != MARKER],
|
|
395
409
|
)
|
|
396
410
|
)
|
|
397
411
|
|
sqlakit-0.18.0/sqlakit/_cli.py
DELETED
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
"""`sqlakit`, the command line."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import argparse
|
|
6
|
-
import os
|
|
7
|
-
import sys
|
|
8
|
-
|
|
9
|
-
from ._debugserver import create_server
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def main(argv: list[str] | None = None) -> int:
|
|
13
|
-
"""Run a command, and return what the shell should make of it."""
|
|
14
|
-
parser = argparse.ArgumentParser(prog="sqlakit", description="SQLAKit.")
|
|
15
|
-
commands = parser.add_subparsers(dest="command", required=True)
|
|
16
|
-
|
|
17
|
-
debugserver = commands.add_parser(
|
|
18
|
-
"debugserver", help="watch the recordings an application sends"
|
|
19
|
-
)
|
|
20
|
-
debugserver.add_argument("-H", "--host", default="localhost")
|
|
21
|
-
debugserver.add_argument("-p", "--port", type=int, default=5555)
|
|
22
|
-
|
|
23
|
-
arguments = parser.parse_args(argv)
|
|
24
|
-
if arguments.command == "debugserver":
|
|
25
|
-
return _debugserver(arguments.host, arguments.port)
|
|
26
|
-
return 1
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def _debugserver(host: str, port: int) -> int:
|
|
30
|
-
"""Serve the recordings until the terminal says otherwise."""
|
|
31
|
-
try:
|
|
32
|
-
server = create_server(host, port)
|
|
33
|
-
except OSError as error:
|
|
34
|
-
_say(f"\n{_paint('cannot listen', BOLD, RED)} on {host}:{port} — {error}\n")
|
|
35
|
-
return 1
|
|
36
|
-
_greeting(host, port)
|
|
37
|
-
try:
|
|
38
|
-
server.serve_forever()
|
|
39
|
-
except KeyboardInterrupt:
|
|
40
|
-
_say(_paint("\nstopped", DIM))
|
|
41
|
-
finally:
|
|
42
|
-
server.server_close()
|
|
43
|
-
return 0
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
BOLD = "1"
|
|
47
|
-
DIM = "2"
|
|
48
|
-
TEAL = "36"
|
|
49
|
-
RED = "31"
|
|
50
|
-
GREEN = "32"
|
|
51
|
-
VIOLET = "35"
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def _colours() -> bool:
|
|
55
|
-
"""Whether to paint: a terminal that wants it, and was not told otherwise."""
|
|
56
|
-
if os.environ.get("NO_COLOR"):
|
|
57
|
-
return False
|
|
58
|
-
if os.environ.get("FORCE_COLOR"):
|
|
59
|
-
return True
|
|
60
|
-
return sys.stdout.isatty()
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def _paint(text: str, *codes: str) -> str:
|
|
64
|
-
"""Return the text in those colours, or as it is where colour is unwanted."""
|
|
65
|
-
if not codes or not _colours():
|
|
66
|
-
return text
|
|
67
|
-
return f"\033[{';'.join(codes)}m{text}\033[0m"
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def _say(text: str) -> None:
|
|
71
|
-
"""Print, and flush: the server then blocks, and a pipe would hold this."""
|
|
72
|
-
print(text, flush=True) # noqa: T201
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def _block(*lines: str) -> str:
|
|
76
|
-
"""Return these lines set off by a rule, the way a page sets off code."""
|
|
77
|
-
rule = _paint("│", DIM)
|
|
78
|
-
return "".join(f"\n {rule} {line}" for line in lines) + "\n"
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def _greeting(host: str, port: int) -> None:
|
|
82
|
-
"""Print where the page is, and the block that fills it."""
|
|
83
|
-
where = _paint(f"http://{host}:{port}", TEAL, BOLD)
|
|
84
|
-
label = _paint('"GET /users"', GREEN)
|
|
85
|
-
named = _paint(f'"{host}"', GREEN)
|
|
86
|
-
number = _paint(str(port), VIOLET)
|
|
87
|
-
_say(
|
|
88
|
-
f"\nSQLAKit debug server on {where}\n\n"
|
|
89
|
-
+ _paint(
|
|
90
|
-
"Send recordings to it:\n",
|
|
91
|
-
DIM,
|
|
92
|
-
)
|
|
93
|
-
+ _block(
|
|
94
|
-
f"with db.recording({label}, debugserver=({named}, {number})):",
|
|
95
|
-
" list_users()",
|
|
96
|
-
)
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if __name__ == "__main__":
|
|
101
|
-
sys.exit(main())
|