sqlakit 0.14.0__tar.gz → 0.16.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.14.0 → sqlakit-0.16.0}/PKG-INFO +1 -1
  2. {sqlakit-0.14.0 → sqlakit-0.16.0}/pyproject.toml +1 -1
  3. {sqlakit-0.14.0 → sqlakit-0.16.0}/pyproject.toml.orig +1 -1
  4. {sqlakit-0.14.0 → sqlakit-0.16.0}/sqlakit/_query.py +107 -5
  5. {sqlakit-0.14.0 → sqlakit-0.16.0}/sqlakit/asyncio/orm.py +24 -4
  6. {sqlakit-0.14.0 → sqlakit-0.16.0}/sqlakit/asyncio/sql.py +36 -13
  7. {sqlakit-0.14.0 → sqlakit-0.16.0}/sqlakit/orm.py +26 -4
  8. {sqlakit-0.14.0 → sqlakit-0.16.0}/sqlakit/sql.py +36 -13
  9. {sqlakit-0.14.0 → sqlakit-0.16.0}/LICENSE +0 -0
  10. {sqlakit-0.14.0 → sqlakit-0.16.0}/README.md +0 -0
  11. {sqlakit-0.14.0 → sqlakit-0.16.0}/sqlakit/__init__.py +0 -0
  12. {sqlakit-0.14.0 → sqlakit-0.16.0}/sqlakit/_base.py +0 -0
  13. {sqlakit-0.14.0 → sqlakit-0.16.0}/sqlakit/_cli.py +0 -0
  14. {sqlakit-0.14.0 → sqlakit-0.16.0}/sqlakit/_db.py +0 -0
  15. {sqlakit-0.14.0 → sqlakit-0.16.0}/sqlakit/_debugserver.py +0 -0
  16. {sqlakit-0.14.0 → sqlakit-0.16.0}/sqlakit/_discovery.py +0 -0
  17. {sqlakit-0.14.0 → sqlakit-0.16.0}/sqlakit/_model.py +0 -0
  18. {sqlakit-0.14.0 → sqlakit-0.16.0}/sqlakit/_recording.py +0 -0
  19. {sqlakit-0.14.0 → sqlakit-0.16.0}/sqlakit/_registry.py +0 -0
  20. {sqlakit-0.14.0 → sqlakit-0.16.0}/sqlakit/_routing.py +0 -0
  21. {sqlakit-0.14.0 → sqlakit-0.16.0}/sqlakit/_sql.py +0 -0
  22. {sqlakit-0.14.0 → sqlakit-0.16.0}/sqlakit/asyncio/__init__.py +0 -0
  23. {sqlakit-0.14.0 → sqlakit-0.16.0}/sqlakit/asyncio/_db.py +0 -0
  24. {sqlakit-0.14.0 → sqlakit-0.16.0}/sqlakit/asyncio/_registry.py +0 -0
  25. {sqlakit-0.14.0 → sqlakit-0.16.0}/sqlakit/debugserver.html +0 -0
  26. {sqlakit-0.14.0 → sqlakit-0.16.0}/sqlakit/exceptions.py +0 -0
  27. {sqlakit-0.14.0 → sqlakit-0.16.0}/sqlakit/py.typed +0 -0
  28. {sqlakit-0.14.0 → sqlakit-0.16.0}/sqlakit/pytest_plugin.py +0 -0
  29. {sqlakit-0.14.0 → sqlakit-0.16.0}/sqlakit/testing.py +0 -0
  30. {sqlakit-0.14.0 → sqlakit-0.16.0}/sqlakit/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlakit
3
- Version: 0.14.0
3
+ Version: 0.16.0
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.14.0"
3
+ version = "0.16.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.14.0"
3
+ version = "0.16.0"
4
4
  description = "A toolkit for SQLAlchemy applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -24,9 +24,14 @@ from sqlalchemy.ext.compiler import compiles
24
24
  from sqlalchemy.orm import (
25
25
  InstrumentedAttribute,
26
26
  contains_eager,
27
+ defer,
27
28
  joinedload,
29
+ load_only,
28
30
  selectinload,
29
31
  subqueryload,
32
+ undefer,
33
+ undefer_group,
34
+ with_expression,
30
35
  )
31
36
  from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
32
37
  from sqlalchemy.sql import operators
@@ -112,6 +117,21 @@ RowT = TypeVar("RowT")
112
117
  RowT_co = TypeVar("RowT_co", covariant=True)
113
118
 
114
119
 
120
+ def merged(
121
+ mapping: Mapping[str, Any] | None, keywords: Mapping[str, Any]
122
+ ) -> Mapping[str, Any]:
123
+ """Return the values a call was given, however they were passed.
124
+
125
+ A caller handed a mapping passes it as it is, and the keywords beside it
126
+ win, so one value can be replaced without copying the mapping.
127
+ """
128
+ if not mapping:
129
+ return keywords
130
+ if not keywords:
131
+ return mapping
132
+ return {**mapping, **keywords}
133
+
134
+
115
135
  class CaseInsensitive(sa.ColumnElement[Any]):
116
136
  """A column compared without regard to case, however the dialect does it.
117
137
 
@@ -466,28 +486,49 @@ class BaseQuery(Generic[ModelT]):
466
486
  )
467
487
  return query
468
488
 
469
- def from_sql(self, template: str, /, **context: Any) -> Self: # noqa: ANN401
489
+ def from_sql(
490
+ self,
491
+ template: str,
492
+ /,
493
+ context: Mapping[str, Any] | None = None,
494
+ **values: Any, # noqa: ANN401
495
+ ) -> Self:
470
496
  """Take the rows of a SQL template, mapped onto the model.
471
497
 
472
498
  ```python
473
499
  User.query.from_sql("users/active.sql", team="red").all()
500
+ User.query.from_sql("users/active.sql", context=filters).all()
474
501
  ```
475
502
 
476
503
  Read from the database this query runs on, and rendered for its dialect. As
477
504
  with `from_statement`, nothing can be added afterwards and
478
505
  ``__query_filter__`` is not applied.
479
506
  """
480
- return self.from_statement(self.db.sql.from_file(template, **context).statement)
507
+ rows = self.db.sql.from_file(template, context, **values)
508
+ return self.from_statement(rows.statement)
481
509
 
482
510
  def where(self, *criteria: _ColumnExpressionArgument[bool]) -> Self:
483
511
  """Narrow the rows, as `Select.where` does."""
484
512
  self._reject_statement("where")
485
513
  return self.with_select(self._select.where(*criteria))
486
514
 
487
- def filter_by(self, **values: Any) -> Self: # noqa: ANN401
488
- """Narrow the rows by equality, as `Select.filter_by` does."""
515
+ def filter_by(
516
+ self,
517
+ values: Mapping[str, Any] | None = None,
518
+ /,
519
+ **fields: Any, # noqa: ANN401
520
+ ) -> Self:
521
+ """Narrow the rows by equality, as `Select.filter_by` does.
522
+
523
+ ```python
524
+ db.query(User).filter_by(team="red")
525
+ db.query(User).filter_by(request.query_params)
526
+ ```
527
+
528
+ The fields are keywords, a mapping, or both, as `create()` takes them.
529
+ """
489
530
  self._reject_statement("filter_by")
490
- return self.with_select(self._select.filter_by(**values))
531
+ return self.with_select(self._select.filter_by(**merged(values, fields)))
491
532
 
492
533
  def join(
493
534
  self,
@@ -636,6 +677,67 @@ class BaseQuery(Generic[ModelT]):
636
677
  """Read a relationship from a join this query already makes."""
637
678
  return self.options(_chain(contains_eager, keys))
638
679
 
680
+ def load_only(self, *columns: Any) -> Self: # noqa: ANN401
681
+ """Load these columns of the row, and defer the rest.
682
+
683
+ ```python
684
+ db.query(User).load_only(User.id, User.name).all()
685
+ ```
686
+
687
+ The rows are still instances: a column left out is read from the
688
+ database when something touches it, one statement per instance, which
689
+ is the cost this trades the narrower row for. `only_columns` is the
690
+ other one: it gives the instances up and reads values.
691
+ """
692
+ return self.options(load_only(*columns))
693
+
694
+ def defer(self, *columns: Any) -> Self: # noqa: ANN401
695
+ """Leave these columns out of the row until something reads them.
696
+
697
+ ```python
698
+ db.query(Post).defer(Post.body).all()
699
+ ```
700
+
701
+ For the wide column of a table read for everything else.
702
+ """
703
+ return self.options(*(defer(column) for column in columns))
704
+
705
+ def undefer(self, *columns: Any) -> Self: # noqa: ANN401
706
+ """Load these columns with the row, though the model defers them.
707
+
708
+ ```python
709
+ db.query(Post).undefer(Post.body).all()
710
+ ```
711
+
712
+ The other side of `mapped_column(deferred=True)`, for the read that
713
+ wants the column after all.
714
+ """
715
+ return self.options(*(undefer(column) for column in columns))
716
+
717
+ def undefer_group(self, name: str) -> Self:
718
+ """Load the columns a model defers under this group name.
719
+
720
+ ```python
721
+ db.query(Post).undefer_group("body").all()
722
+ ```
723
+
724
+ The group is the one `mapped_column(deferred_group="body")` names, for
725
+ the columns a read wants together or not at all.
726
+ """
727
+ return self.options(undefer_group(name))
728
+
729
+ def with_expression(self, key: Any, expression: Any) -> Self: # noqa: ANN401
730
+ """Give a `query_expression()` attribute its value for this read.
731
+
732
+ ```python
733
+ db.query(Post).with_expression(Post.comments, _comment_count()).all()
734
+ ```
735
+
736
+ 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.
738
+ """
739
+ return self.options(with_expression(key, expression))
740
+
639
741
  def with_for_update(
640
742
  self,
641
743
  *,
@@ -28,6 +28,7 @@ from sqlakit._query import (
28
28
  BaseQuery,
29
29
  CursorPage,
30
30
  Page,
31
+ merged,
31
32
  one_row,
32
33
  one_row_or_none,
33
34
  orderable,
@@ -344,19 +345,28 @@ class Query(BaseQuery[ModelT]):
344
345
  scalar=len(columns) == 1,
345
346
  )
346
347
 
347
- async def create(self, **values: Any) -> ModelT: # noqa: ANN401
348
+ async def create(
349
+ self,
350
+ values: Mapping[str, Any] | None = None,
351
+ /,
352
+ **fields: Any, # noqa: ANN401
353
+ ) -> ModelT:
348
354
  """Write a new row, and return it as an instance.
349
355
 
350
356
  ```python
351
357
  user = await User.query.create(name="ada", team="red")
358
+ user = await User.query.create(payload.model_dump())
352
359
  ```
353
360
 
361
+ The fields are keywords, a mapping, or both, where a keyword replaces
362
+ the value of that name.
363
+
354
364
  The row goes through the session, so defaults, relationships and the identity
355
365
  map behave as they do for a model that saves itself. What it adds is a write
356
366
  that needs no model layer: `Query(User, db).create(...)` works on any mapped
357
367
  class.
358
368
  """
359
- instance = self.model(**values)
369
+ instance = self.model(**merged(values, fields))
360
370
  self.db.session.add(instance)
361
371
  await self._persist()
362
372
  return instance
@@ -377,9 +387,17 @@ class Query(BaseQuery[ModelT]):
377
387
  await self._persist()
378
388
  return len(rows)
379
389
 
380
- async def update(self, values: Mapping[str, Any]) -> int:
390
+ async def update(
391
+ self,
392
+ values: Mapping[str, Any] | None = None,
393
+ /,
394
+ **fields: Any, # noqa: ANN401
395
+ ) -> int:
381
396
  """Write these values to every matching row, and return how many.
382
397
 
398
+ A mapping, keywords, or both: `update({"team": "green"})` and
399
+ `update(team="green")` write the same statement.
400
+
383
401
  One statement, so the session's objects are updated from the database
384
402
  rather than in memory. Only the narrowing carries over.
385
403
 
@@ -387,7 +405,9 @@ class Query(BaseQuery[ModelT]):
387
405
  BulkQueryError: if the query carries anything a statement drops.
388
406
 
389
407
  """
390
- result = await self.db.session.execute(self._update_statement(values))
408
+ result = await self.db.session.execute(
409
+ self._update_statement(merged(values, fields))
410
+ )
391
411
  await self._persist()
392
412
  return cast("CursorResult[Any]", result).rowcount
393
413
 
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any, TypeVar, cast
5
5
  import sqlalchemy as sa
6
6
  from typing_extensions import Unpack
7
7
 
8
+ from sqlakit._query import merged
8
9
  from sqlakit._sql import (
9
10
  BaseSQLQuery,
10
11
  Filter,
@@ -14,7 +15,7 @@ from sqlakit._sql import (
14
15
  )
15
16
 
16
17
  if TYPE_CHECKING:
17
- from collections.abc import AsyncIterator, Sequence
18
+ from collections.abc import AsyncIterator, Mapping, Sequence
18
19
 
19
20
  from sqlalchemy.engine import Result, ScalarResult
20
21
  from sqlalchemy.ext.asyncio import AsyncConnection
@@ -50,27 +51,48 @@ class SQL:
50
51
  def __repr__(self) -> str:
51
52
  return f"{type(self).__name__}({self.db!r})"
52
53
 
53
- def __call__(self, template: str, /, **context: Any) -> SQLQuery: # noqa: ANN401
54
+ def __call__(
55
+ self,
56
+ template: str,
57
+ /,
58
+ context: Mapping[str, Any] | None = None,
59
+ **values: Any, # noqa: ANN401
60
+ ) -> SQLQuery:
54
61
  """Read the rows of a template. Short for `from_file`.
55
62
 
56
63
  ```python
57
64
  await db.sql("users/active.sql", team="red").all()
58
65
  ```
59
66
  """
60
- return self.from_file(template, **context)
61
-
62
- def from_file(self, template: str, /, **context: Any) -> SQLQuery: # noqa: ANN401
67
+ return self.from_file(template, context, **values)
68
+
69
+ def from_file(
70
+ self,
71
+ template: str,
72
+ /,
73
+ context: Mapping[str, Any] | None = None,
74
+ **values: Any, # noqa: ANN401
75
+ ) -> SQLQuery:
63
76
  """Read the rows of a template kept under the database's ``templates=``.
64
77
 
65
78
  ```python
66
79
  await db.sql.from_file("users/active.sql", team="red").all()
80
+ await db.sql.from_file("users/active.sql", context=filters).all()
67
81
  ```
68
82
 
69
- The keyword arguments are the template's context.
83
+ The keyword arguments are the template's context, and ``context`` takes
84
+ the same values as a mapping, for values a caller was handed rather than
85
+ wrote. A value named `context` lives in that mapping.
70
86
  """
71
- return SQLQuery(self.db, template, context)
72
-
73
- def from_string(self, source: str, /, **context: Any) -> SQLQuery: # noqa: ANN401
87
+ return SQLQuery(self.db, template, merged(context, values))
88
+
89
+ def from_string(
90
+ self,
91
+ source: str,
92
+ /,
93
+ context: Mapping[str, Any] | None = None,
94
+ **values: Any, # noqa: ANN401
95
+ ) -> SQLQuery:
74
96
  """Read the rows of SQL written out here rather than kept in a file.
75
97
 
76
98
  ```python
@@ -79,11 +101,12 @@ class SQL:
79
101
  )
80
102
  ```
81
103
 
82
- Values are named in `{{ }}` and passed by keyword, as in a template. A
83
- `:name` or a `?` binds nothing here, and rendering says so rather than
84
- reaching the driver. It needs no ``templates=``.
104
+ Values are named in `{{ }}` and passed by keyword, or as the ``context``
105
+ mapping, as in a template. A `:name` or a `?` binds nothing here, and
106
+ rendering says so rather than reaching the driver. It needs no
107
+ ``templates=``.
85
108
  """
86
- return SQLQuery(self.db, source, context, inline=True)
109
+ return SQLQuery(self.db, source, merged(context, values), inline=True)
87
110
 
88
111
  def from_statement(self, statement: Executable) -> SQLQuery:
89
112
  """Read the rows of a statement built with SQLAlchemy.
@@ -29,6 +29,7 @@ from ._query import (
29
29
  BaseQuery,
30
30
  CursorPage,
31
31
  Page,
32
+ merged,
32
33
  one_row,
33
34
  one_row_or_none,
34
35
  orderable,
@@ -326,6 +327,10 @@ class Query(BaseQuery[ModelT]):
326
327
  ```python
327
328
  names = User.query.where(User.is_active).only_columns(User.name).all()
328
329
  ```
330
+
331
+ One column arrives as values and several as tuples, and neither is an
332
+ instance. `load_only` is the other one: the rows stay instances, and
333
+ the columns it leaves out are read when something touches them.
329
334
  """
330
335
  return ColumnQuery(
331
336
  self.model,
@@ -334,19 +339,28 @@ class Query(BaseQuery[ModelT]):
334
339
  scalar=len(columns) == 1,
335
340
  )
336
341
 
337
- def create(self, **values: Any) -> ModelT: # noqa: ANN401
342
+ def create(
343
+ self,
344
+ values: Mapping[str, Any] | None = None,
345
+ /,
346
+ **fields: Any, # noqa: ANN401
347
+ ) -> ModelT:
338
348
  """Write a new row, and return it as an instance.
339
349
 
340
350
  ```python
341
351
  user = User.query.create(name="ada", team="red")
352
+ user = User.query.create(payload.model_dump())
342
353
  ```
343
354
 
355
+ The fields are keywords, a mapping, or both, where a keyword replaces
356
+ the value of that name.
357
+
344
358
  The row goes through the session, so defaults, relationships and the identity
345
359
  map behave as they do for a model that saves itself. What it adds is a write
346
360
  that needs no model layer: `Query(User, db).create(...)` works on any mapped
347
361
  class.
348
362
  """
349
- instance = self.model(**values)
363
+ instance = self.model(**merged(values, fields))
350
364
  self.db.session.add(instance)
351
365
  self._persist()
352
366
  return instance
@@ -367,9 +381,17 @@ class Query(BaseQuery[ModelT]):
367
381
  self._persist()
368
382
  return len(rows)
369
383
 
370
- def update(self, values: Mapping[str, Any]) -> int:
384
+ def update(
385
+ self,
386
+ values: Mapping[str, Any] | None = None,
387
+ /,
388
+ **fields: Any, # noqa: ANN401
389
+ ) -> int:
371
390
  """Write these values to every matching row, and return how many.
372
391
 
392
+ A mapping, keywords, or both: `update({"team": "green"})` and
393
+ `update(team="green")` write the same statement.
394
+
373
395
  One statement, so the session's objects are updated from the database
374
396
  rather than in memory. Only the narrowing carries over.
375
397
 
@@ -377,7 +399,7 @@ class Query(BaseQuery[ModelT]):
377
399
  BulkQueryError: if the query carries anything a statement drops.
378
400
 
379
401
  """
380
- result = self.db.session.execute(self._update_statement(values))
402
+ result = self.db.session.execute(self._update_statement(merged(values, fields)))
381
403
  self._persist()
382
404
  return cast("CursorResult[Any]", result).rowcount
383
405
 
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any, TypeVar, cast
5
5
  import sqlalchemy as sa
6
6
  from typing_extensions import Unpack
7
7
 
8
+ from ._query import merged
8
9
  from ._sql import (
9
10
  BaseSQLQuery,
10
11
  Filter,
@@ -14,7 +15,7 @@ from ._sql import (
14
15
  )
15
16
 
16
17
  if TYPE_CHECKING:
17
- from collections.abc import Iterator, Sequence
18
+ from collections.abc import Iterator, Mapping, Sequence
18
19
 
19
20
  from sqlalchemy.engine import Result, ScalarResult
20
21
  from sqlalchemy.sql import Executable
@@ -48,38 +49,60 @@ class SQL:
48
49
  def __repr__(self) -> str:
49
50
  return f"{type(self).__name__}({self.db!r})"
50
51
 
51
- def __call__(self, template: str, /, **context: Any) -> SQLQuery: # noqa: ANN401
52
+ def __call__(
53
+ self,
54
+ template: str,
55
+ /,
56
+ context: Mapping[str, Any] | None = None,
57
+ **values: Any, # noqa: ANN401
58
+ ) -> SQLQuery:
52
59
  """Read the rows of a template. Short for `from_file`.
53
60
 
54
61
  ```python
55
62
  db.sql("users/active.sql", team="red").all()
56
63
  ```
57
64
  """
58
- return self.from_file(template, **context)
59
-
60
- def from_file(self, template: str, /, **context: Any) -> SQLQuery: # noqa: ANN401
65
+ return self.from_file(template, context, **values)
66
+
67
+ def from_file(
68
+ self,
69
+ template: str,
70
+ /,
71
+ context: Mapping[str, Any] | None = None,
72
+ **values: Any, # noqa: ANN401
73
+ ) -> SQLQuery:
61
74
  """Read the rows of a template kept under the database's ``templates=``.
62
75
 
63
76
  ```python
64
77
  db.sql.from_file("users/active.sql", team="red").all()
78
+ db.sql.from_file("users/active.sql", context=filters).all()
65
79
  ```
66
80
 
67
- The keyword arguments are the template's context.
81
+ The keyword arguments are the template's context, and ``context`` takes
82
+ the same values as a mapping, for values a caller was handed rather than
83
+ wrote. A value named `context` lives in that mapping.
68
84
  """
69
- return SQLQuery(self.db, template, context)
70
-
71
- def from_string(self, source: str, /, **context: Any) -> SQLQuery: # noqa: ANN401
85
+ return SQLQuery(self.db, template, merged(context, values))
86
+
87
+ def from_string(
88
+ self,
89
+ source: str,
90
+ /,
91
+ context: Mapping[str, Any] | None = None,
92
+ **values: Any, # noqa: ANN401
93
+ ) -> SQLQuery:
72
94
  """Read the rows of SQL written out here rather than kept in a file.
73
95
 
74
96
  ```python
75
97
  db.sql.from_string("SELECT id FROM users WHERE team = {{ team }}", team="red")
76
98
  ```
77
99
 
78
- Values are named in `{{ }}` and passed by keyword, as in a template. A
79
- `:name` or a `?` binds nothing here, and rendering says so rather than
80
- reaching the driver. It needs no ``templates=``.
100
+ Values are named in `{{ }}` and passed by keyword, or as the ``context``
101
+ mapping, as in a template. A `:name` or a `?` binds nothing here, and
102
+ rendering says so rather than reaching the driver. It needs no
103
+ ``templates=``.
81
104
  """
82
- return SQLQuery(self.db, source, context, inline=True)
105
+ return SQLQuery(self.db, source, merged(context, values), inline=True)
83
106
 
84
107
  def from_statement(self, statement: Executable) -> SQLQuery:
85
108
  """Read the rows of a statement built with SQLAlchemy.
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