pgdevkit 0.12.0__tar.gz → 0.13.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 (138) hide show
  1. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/PKG-INFO +13 -2
  2. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/README.md +11 -0
  3. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/db/__init__.py +5 -0
  4. pgdevkit-0.13.0/pgdevkit/db/fetch.py +269 -0
  5. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pyproject.toml +3 -3
  6. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/skills/pgdevkit/SKILL.md +27 -13
  7. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/skills/pgdevkit/references/dynamic-sql.md +3 -0
  8. pgdevkit-0.13.0/tests/db/test_fetch.py +253 -0
  9. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/uv.lock +3 -3
  10. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/.github/workflows/auto-release.yml +0 -0
  11. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/.github/workflows/python-publish.yml +0 -0
  12. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/.github/workflows/python-test.yml +0 -0
  13. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/.gitignore +0 -0
  14. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/.python-version +0 -0
  15. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/docs/database-layout.md +0 -0
  16. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/__init__.py +0 -0
  17. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/areas.py +0 -0
  18. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/backends/__init__.py +0 -0
  19. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/backends/base.py +0 -0
  20. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/backends/mssql.py +0 -0
  21. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/backends/postgres.py +0 -0
  22. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/cli.py +0 -0
  23. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/connection.py +0 -0
  24. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/db/complex_types.py +0 -0
  25. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/db/connection.py +0 -0
  26. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/db/crud.py +0 -0
  27. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/db/loader.py +0 -0
  28. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/db/model.py +0 -0
  29. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/db/mssql_crud.py +0 -0
  30. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/db/mssql_sql.py +0 -0
  31. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/dialect.py +0 -0
  32. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/diff.py +0 -0
  33. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/envtag.py +0 -0
  34. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/fetch_missing.py +0 -0
  35. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/introspect.py +0 -0
  36. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/lakebase.py +0 -0
  37. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/migrate.py +0 -0
  38. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/migrate_mssql.py +0 -0
  39. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/models.py +0 -0
  40. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/mssql_introspect.py +0 -0
  41. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/parser.py +0 -0
  42. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/schemas.py +0 -0
  43. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/sql_text.py +0 -0
  44. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/stats.py +0 -0
  45. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/testdb/__init__.py +0 -0
  46. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/testdb/_docker.py +0 -0
  47. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/testdb/api.py +0 -0
  48. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/testdb/config.py +0 -0
  49. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/testdb/constants.py +0 -0
  50. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/testdb/container.py +0 -0
  51. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/testdb/mssql/__init__.py +0 -0
  52. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/testdb/mssql/api.py +0 -0
  53. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/testdb/mssql/constants.py +0 -0
  54. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/testdb/mssql/container.py +0 -0
  55. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/testdb/naming.py +0 -0
  56. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/testdb/query.py +0 -0
  57. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/testdb/schema.py +0 -0
  58. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/skills/pgdevkit/references/temporal-tables.md +0 -0
  59. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/__init__.py +0 -0
  60. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/conftest.py +0 -0
  61. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/db/__init__.py +0 -0
  62. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/db/test_complex_types.py +0 -0
  63. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/db/test_connection.py +0 -0
  64. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/db/test_crud.py +0 -0
  65. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/db/test_loader.py +0 -0
  66. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/db/test_mssql_crud_live.py +0 -0
  67. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/db/test_mssql_crud_sql.py +0 -0
  68. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/fixtures/01_schema.sql +0 -0
  69. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/fixtures/02_types.sql +0 -0
  70. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/fixtures/03_tables.sql +0 -0
  71. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/fixtures/04_views.sql +0 -0
  72. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/fixtures/05_functions.sql +0 -0
  73. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/fixtures/06_indexes.sql +0 -0
  74. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_areas.py +0 -0
  75. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_cli_compare.py +0 -0
  76. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_compare.py +0 -0
  77. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_compare_mssql_live.py +0 -0
  78. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_connection.py +0 -0
  79. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_dialect.py +0 -0
  80. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_diff_mssql.py +0 -0
  81. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_envtag.py +0 -0
  82. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_fetch_missing.py +0 -0
  83. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_fetch_missing_cli.py +0 -0
  84. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_lakebase.py +0 -0
  85. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_migrate.py +0 -0
  86. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_migrate_areas.py +0 -0
  87. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_migrate_env.py +0 -0
  88. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_migrate_grants.py +0 -0
  89. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_migrate_mssql.py +0 -0
  90. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_migrate_mssql_live.py +0 -0
  91. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_migrate_schemas.py +0 -0
  92. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_mssql_introspect.py +0 -0
  93. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_parser_areas.py +0 -0
  94. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_parser_mssql.py +0 -0
  95. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_parser_schemas.py +0 -0
  96. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_schemas.py +0 -0
  97. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_stats_cli.py +0 -0
  98. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_stats_mssql_live.py +0 -0
  99. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/__init__.py +0 -0
  100. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/conftest.py +0 -0
  101. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
  102. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/event.sql +0 -0
  103. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/event_kind.sql +0 -0
  104. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
  105. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
  106. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +0 -0
  107. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/widget.init.sql +0 -0
  108. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
  109. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
  110. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
  111. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
  112. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
  113. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
  114. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
  115. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
  116. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/permissions/grants.sql +0 -0
  117. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/schema/app.sql +0 -0
  118. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
  119. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
  120. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
  121. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
  122. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
  123. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_api.py +0 -0
  124. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_api_mssql.py +0 -0
  125. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_api_mssql_live.py +0 -0
  126. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_cli.py +0 -0
  127. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_config.py +0 -0
  128. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_config_mssql.py +0 -0
  129. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_constants.py +0 -0
  130. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_container.py +0 -0
  131. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_container_mssql.py +0 -0
  132. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_docker.py +0 -0
  133. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_mssql_constants.py +0 -0
  134. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_naming.py +0 -0
  135. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_query.py +0 -0
  136. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_schema.py +0 -0
  137. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_schema_filtering.py +0 -0
  138. {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_schema_mssql.py +0 -0
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pgdevkit
3
- Version: 0.12.0
3
+ Version: 0.13.0
4
4
  Summary: A helper for developing with Postgres
5
5
  Requires-Python: >=3.14
6
6
  Requires-Dist: docker>=7.1.0
7
- Requires-Dist: psycopg[binary]>=3.2.0
7
+ Requires-Dist: psycopg[binary]>=3.3.0
8
8
  Requires-Dist: sqlglot>=30.11.0
9
9
  Provides-Extra: azure
10
10
  Requires-Dist: azure-identity>=1.19.0; extra == 'azure'
@@ -427,6 +427,17 @@ Install with the `db` extra: `pip install pgdevkit[db]`.
427
427
  `--entra-user`. For Lakebase hosts, also set the
428
428
  `{env_prefix}DATABRICKS_WORKSPACE_HOST` and `{env_prefix}DATABRICKS_INSTANCE`
429
429
  env vars.
430
+ - **`fetch_all(query, params=None, *, model=None, row_mapper=None, con=None, pool=None, cancel=None)`**
431
+ — run one custom query and get every row back as dicts, as validated
432
+ Pydantic `model` instances, or through a `row_mapper`. Pass `con` to run on a
433
+ connection you already hold (it is borrowed: never committed, closed or
434
+ released); otherwise a connection is taken from `pool` or the pool registered
435
+ with `set_default_pool()`. Set the `cancel` `asyncio.Event` to abort the query on the server
436
+ (`QueryCanceled`); cancelling the awaiting task cancels it there too, and
437
+ `CancelledError` propagates as usual. `query` must be a literal string, a sqlglot
438
+ expression, a `psycopg.sql` composable or a t-string — never an f-string (a sqlglot
439
+ expression is only as safe as the strings it was built from: pass user values as
440
+ `exp.Placeholder` + `params`).
430
441
  - **CRUD functions** — `pg_retrieve`, `pg_retrieve_many`, `pg_insert`,
431
442
  `pg_insert_many`, `pg_update`, `pg_update_dict`, `pg_upsert`,
432
443
  `pg_upsert_dict`, `pg_upsert_many`, `pg_upsert_many_dict`, `pg_delete`,
@@ -406,6 +406,17 @@ Install with the `db` extra: `pip install pgdevkit[db]`.
406
406
  `--entra-user`. For Lakebase hosts, also set the
407
407
  `{env_prefix}DATABRICKS_WORKSPACE_HOST` and `{env_prefix}DATABRICKS_INSTANCE`
408
408
  env vars.
409
+ - **`fetch_all(query, params=None, *, model=None, row_mapper=None, con=None, pool=None, cancel=None)`**
410
+ — run one custom query and get every row back as dicts, as validated
411
+ Pydantic `model` instances, or through a `row_mapper`. Pass `con` to run on a
412
+ connection you already hold (it is borrowed: never committed, closed or
413
+ released); otherwise a connection is taken from `pool` or the pool registered
414
+ with `set_default_pool()`. Set the `cancel` `asyncio.Event` to abort the query on the server
415
+ (`QueryCanceled`); cancelling the awaiting task cancels it there too, and
416
+ `CancelledError` propagates as usual. `query` must be a literal string, a sqlglot
417
+ expression, a `psycopg.sql` composable or a t-string — never an f-string (a sqlglot
418
+ expression is only as safe as the strings it was built from: pass user values as
419
+ `exp.Placeholder` + `params`).
409
420
  - **CRUD functions** — `pg_retrieve`, `pg_retrieve_many`, `pg_insert`,
410
421
  `pg_insert_many`, `pg_update`, `pg_update_dict`, `pg_upsert`,
411
422
  `pg_upsert_dict`, `pg_upsert_many`, `pg_upsert_many_dict`, `pg_delete`,
@@ -16,15 +16,19 @@ from .crud import (
16
16
  pg_upsert_many,
17
17
  pg_upsert_many_dict,
18
18
  )
19
+ from .fetch import ConnectionSource, SqlQuery, fetch_all, set_default_pool
19
20
  from .loader import SqlLoader
20
21
  from .model import PostgresTableModel, TableModel
21
22
 
22
23
  __all__ = [
23
24
  "ComplexHelper",
25
+ "ConnectionSource",
24
26
  "PgPool",
25
27
  "PostgresTableModel",
26
28
  "SqlLoader",
29
+ "SqlQuery",
27
30
  "TableModel",
31
+ "fetch_all",
28
32
  "pg_delete",
29
33
  "pg_delete_dict",
30
34
  "pg_insert",
@@ -37,4 +41,5 @@ __all__ = [
37
41
  "pg_upsert_dict",
38
42
  "pg_upsert_many",
39
43
  "pg_upsert_many_dict",
44
+ "set_default_pool",
40
45
  ]
@@ -0,0 +1,269 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import re
6
+ from collections.abc import AsyncIterator, Callable, Mapping, Sequence
7
+ from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress
8
+ from string.templatelib import Template
9
+ from typing import TYPE_CHECKING, Any, LiteralString, Protocol, overload
10
+
11
+ from psycopg.connection_async import AsyncConnection
12
+ from psycopg.errors import QueryCanceled
13
+ from psycopg.rows import dict_row
14
+ from psycopg.sql import Composable
15
+ from pydantic import BaseModel
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ if TYPE_CHECKING:
20
+ from sqlglot import exp # imported lazily: only needed to type sqlglot queries (rendering is duck-typed)
21
+
22
+ # Same params shape psycopg's Cursor.execute() accepts: a %(name)s-style mapping,
23
+ # or a positional %s-style sequence.
24
+ QueryParams = Mapping[str, Any] | Sequence[Any] | None
25
+
26
+ # The only things a query may be: a literal string (e.g. from `SqlLoader.load_sql()`), a sqlglot
27
+ # expression, or psycopg's own safe-composition types (`psycopg.sql` / a t-string). A plain `str`
28
+ # is rejected by the type checker on purpose, so user-controlled text can't be concatenated in.
29
+ type SqlQueryNoTemplate = LiteralString | exp.Expression | Composable
30
+ type SqlQuery = SqlQueryNoTemplate | Template
31
+
32
+
33
+ class ConnectionSource(Protocol):
34
+ """Anything that hands out an async connection context manager -- `PgPool` fits."""
35
+
36
+ def connection(self) -> AbstractAsyncContextManager[AsyncConnection]: ...
37
+
38
+
39
+ _default_source: ConnectionSource | None = None
40
+
41
+
42
+ def set_default_pool(pool: ConnectionSource | None) -> None:
43
+ """Register where `fetch_all()` gets its connection when called without `con`/`pool`
44
+ (typically the app's `PgPool`, once at startup). Pass `None` to clear it. This is process-wide
45
+ state meant for single-database apps: with several databases/tenants pass `pool=` explicitly."""
46
+ global _default_source
47
+ _default_source = pool
48
+
49
+
50
+ @asynccontextmanager
51
+ async def _acquire(con: AsyncConnection | None, pool: ConnectionSource | None) -> AsyncIterator[AsyncConnection]:
52
+ """Yield `con` untouched (the caller owns it: no commit/close/return-to-pool here), or a
53
+ connection borrowed from `pool` / the default pool for the duration of the block."""
54
+ if con is not None:
55
+ if pool is not None:
56
+ raise TypeError("Pass either `con` or `pool`, not both.")
57
+ yield con
58
+ return
59
+ source = pool if pool is not None else _default_source
60
+ if source is None:
61
+ raise RuntimeError("No connection: pass `con=`/`pool=`, or register a pool with `set_default_pool()`.")
62
+ async with source.connection() as borrowed:
63
+ yield borrowed
64
+
65
+
66
+ _PLACEHOLDER_NAME = re.compile(r"\w+")
67
+
68
+
69
+ def _render(query: SqlQuery) -> Any:
70
+ if isinstance(query, (str, Composable, Template)):
71
+ return query
72
+ if not hasattr(query, "sql"):
73
+ raise TypeError(f"fetch_all: unsupported query type {type(query).__name__}")
74
+ # Anything else is a sqlglot expression (no sqlglot import needed to know that; it is loaded
75
+ # already). Its postgres dialect renders `exp.Placeholder("id")` as `%(id)s`.
76
+ from sqlglot import exp
77
+
78
+ for node in query.walk():
79
+ # sqlglot renders these verbatim, so they could smuggle arbitrary SQL into the statement.
80
+ if isinstance(node, exp.Command):
81
+ raise ValueError("fetch_all: raw sqlglot Command nodes are not allowed")
82
+ if isinstance(node, exp.Placeholder) and node.name and not _PLACEHOLDER_NAME.fullmatch(node.name):
83
+ raise ValueError(f"fetch_all: invalid placeholder name {node.name!r}")
84
+ return query.sql(dialect="postgres")
85
+
86
+
87
+ async def _run_query(c: AsyncConnection, query: Any, params: QueryParams) -> list[dict[str, Any]]:
88
+ async with c.cursor(row_factory=dict_row) as cur:
89
+ await cur.execute(query, params)
90
+ return await cur.fetchall()
91
+
92
+
93
+ async def _abort(c: AsyncConnection, work: asyncio.Future[Any]) -> None:
94
+ """Cancel `work`'s query on the server and wait until it has unwound, leaving `c` idle (an
95
+ aborted transaction, to be rolled back by its owner). If the cancel request itself can't be
96
+ delivered, the connection's state is unknown, so it is closed (the server abandons the query as
97
+ soon as it next talks to the client) and a pool discards it instead of reusing it."""
98
+ if not work.done():
99
+ try:
100
+ await c.cancel_safe()
101
+ except Exception:
102
+ logger.warning("fetch_all: cancelling the query failed; closing the connection", exc_info=True)
103
+ with suppress(Exception):
104
+ await c.close() # first: a cancelled-but-still-busy connection would make `work` wait out the query
105
+ work.cancel()
106
+ await asyncio.gather(work, return_exceptions=True)
107
+ return
108
+ await asyncio.gather(work, return_exceptions=True) # always consume `work`'s outcome
109
+
110
+
111
+ async def _abort_uninterruptibly(c: AsyncConnection, work: asyncio.Future[Any]) -> None:
112
+ """`_abort`, but a further cancellation of the awaiting task can't cut it short (which would
113
+ leave `work` running); it is re-raised once the abort is complete."""
114
+ task = asyncio.ensure_future(_abort(c, work))
115
+ interrupted = False
116
+ while not task.done():
117
+ try:
118
+ await asyncio.shield(task)
119
+ except asyncio.CancelledError:
120
+ interrupted = True
121
+ task.result()
122
+ if interrupted:
123
+ raise asyncio.CancelledError
124
+
125
+
126
+ async def _fetch_dicts(
127
+ query: Any, # already rendered (see `_render`)
128
+ params: QueryParams,
129
+ con: AsyncConnection | None,
130
+ pool: ConnectionSource | None,
131
+ cancel: asyncio.Event | None,
132
+ ) -> list[dict[str, Any]]:
133
+ if cancel is not None and cancel.is_set():
134
+ raise QueryCanceled("fetch_all: cancelled before the query started")
135
+ async with _acquire(con, pool) as c:
136
+ work = asyncio.ensure_future(_run_query(c, query, params))
137
+ stop = asyncio.ensure_future(cancel.wait()) if cancel is not None else None
138
+ try:
139
+ await asyncio.wait([work, *([stop] if stop else [])], return_when=asyncio.FIRST_COMPLETED)
140
+ if not work.done(): # `cancel` was set while the query runs: abort it on the server
141
+ await _abort_uninterruptibly(c, work)
142
+ if work.cancelled():
143
+ raise QueryCanceled("fetch_all: cancelled via `cancel`")
144
+ return work.result() # raises psycopg.errors.QueryCanceled if it was cancelled
145
+ except asyncio.CancelledError:
146
+ # The awaiting task itself was cancelled: abandoning the query client-side would leave it
147
+ # running on the server (and the connection mid-query), so cancel it there and let it
148
+ # unwind first -- the connection is then idle for its owner/the pool.
149
+ await _abort_uninterruptibly(c, work)
150
+ raise
151
+ finally:
152
+ if stop is not None:
153
+ stop.cancel()
154
+
155
+
156
+ @overload
157
+ async def fetch_all[M: BaseModel](
158
+ query: SqlQueryNoTemplate,
159
+ params: QueryParams = None,
160
+ *,
161
+ model: type[M],
162
+ con: AsyncConnection | None = None,
163
+ pool: ConnectionSource | None = None,
164
+ cancel: asyncio.Event | None = None,
165
+ ) -> list[M]: ...
166
+
167
+
168
+ @overload
169
+ async def fetch_all[T](
170
+ query: SqlQueryNoTemplate,
171
+ params: QueryParams = None,
172
+ *,
173
+ row_mapper: Callable[[dict[str, Any]], T],
174
+ con: AsyncConnection | None = None,
175
+ pool: ConnectionSource | None = None,
176
+ cancel: asyncio.Event | None = None,
177
+ ) -> list[T]: ...
178
+
179
+
180
+ @overload
181
+ async def fetch_all(
182
+ query: SqlQueryNoTemplate,
183
+ params: QueryParams = None,
184
+ *,
185
+ model: None = None,
186
+ row_mapper: None = None,
187
+ con: AsyncConnection | None = None,
188
+ pool: ConnectionSource | None = None,
189
+ cancel: asyncio.Event | None = None,
190
+ ) -> list[dict[str, Any]]: ...
191
+
192
+
193
+ @overload
194
+ async def fetch_all[M: BaseModel](
195
+ query: Template,
196
+ *,
197
+ model: type[M],
198
+ con: AsyncConnection | None = None,
199
+ pool: ConnectionSource | None = None,
200
+ cancel: asyncio.Event | None = None,
201
+ ) -> list[M]: ...
202
+
203
+
204
+ @overload
205
+ async def fetch_all[T](
206
+ query: Template,
207
+ *,
208
+ row_mapper: Callable[[dict[str, Any]], T],
209
+ con: AsyncConnection | None = None,
210
+ pool: ConnectionSource | None = None,
211
+ cancel: asyncio.Event | None = None,
212
+ ) -> list[T]: ...
213
+
214
+
215
+ @overload
216
+ async def fetch_all(
217
+ query: Template,
218
+ *,
219
+ model: None = None,
220
+ row_mapper: None = None,
221
+ con: AsyncConnection | None = None,
222
+ pool: ConnectionSource | None = None,
223
+ cancel: asyncio.Event | None = None,
224
+ ) -> list[dict[str, Any]]: ...
225
+
226
+
227
+ async def fetch_all(
228
+ query: SqlQuery,
229
+ params: QueryParams = None,
230
+ *,
231
+ model: type[BaseModel] | None = None,
232
+ row_mapper: Callable[[dict[str, Any]], Any] | None = None,
233
+ con: AsyncConnection | None = None,
234
+ pool: ConnectionSource | None = None,
235
+ cancel: asyncio.Event | None = None,
236
+ ) -> list[Any]:
237
+ """Run one query and return every row.
238
+
239
+ Rows come back as plain dicts, or, with `model=` (a Pydantic model class), validated into
240
+ that model; `row_mapper=` converts each dict row into any other shape. The two are exclusive.
241
+
242
+ Pass `con` to run on a connection you already hold (e.g. inside a transaction): it is
243
+ borrowed, never committed, closed or returned to a pool. Without it, a connection is taken
244
+ from `pool` or the pool registered via `set_default_pool()` and released afterwards.
245
+
246
+ Set `cancel` (an `asyncio.Event`, e.g. when the HTTP client disconnects) to abort a running
247
+ query on the server: `fetch_all` then raises `psycopg.errors.QueryCanceled`. If the awaiting
248
+ task is cancelled instead, the query is cancelled server-side too and `CancelledError`
249
+ propagates, so the query never keeps running unattended. `cancel` aborts whatever statement is
250
+ running on the connection, so don't use it on a `con` that concurrent tasks share. If the
251
+ cancel request itself can't be delivered, the connection is closed.
252
+
253
+ `query` must be a literal string, a sqlglot expression, a `psycopg.sql` composable or a
254
+ t-string -- never text built with f-strings/concatenation (a plain `str` fails type checking).
255
+ A sqlglot expression is only as safe as the strings it was built from (the builders parse plain
256
+ strings as SQL): pass user values as `exp.Placeholder` + `params`, never as literals/raw text.
257
+ In one, write a literal `%` as `%%` when you also pass `params`.
258
+ A t-string already carries its values, so it must not be combined with `params`.
259
+ """
260
+ if model is not None and row_mapper is not None:
261
+ raise TypeError("Pass either `model` or `row_mapper`, not both.")
262
+ if isinstance(query, Template) and params is not None:
263
+ raise TypeError("A t-string query carries its own values; don't pass `params` with it.")
264
+ rows = await _fetch_dicts(_render(query), params, con, pool, cancel)
265
+ if model is not None:
266
+ return [model.model_validate(row) for row in rows]
267
+ if row_mapper is not None:
268
+ return [row_mapper(row) for row in rows]
269
+ return rows
@@ -11,13 +11,13 @@ packages = ["pgdevkit"]
11
11
 
12
12
  [project]
13
13
  name = "pgdevkit"
14
- version = "0.12.0"
14
+ version = "0.13.0"
15
15
  description = "A helper for developing with Postgres"
16
16
  readme = "README.md"
17
17
  requires-python = ">=3.14"
18
18
  dependencies = [
19
19
  "docker>=7.1.0",
20
- "psycopg[binary]>=3.2.0",
20
+ "psycopg[binary]>=3.3.0",
21
21
  "sqlglot>=30.11.0",
22
22
  ]
23
23
 
@@ -45,7 +45,7 @@ dev = [
45
45
  "ty>=0.0.59",
46
46
  ]
47
47
  test = [
48
- "psycopg[binary]>=3.2.0",
48
+ "psycopg[binary]>=3.3.0",
49
49
  "pytest>=9.1.0",
50
50
  "pytest-asyncio>=0.24.0",
51
51
  "pytest-env>=1.1.0",
@@ -3,8 +3,8 @@ name: pgdevkit
3
3
  plugin: coding
4
4
  description: >
5
5
  Use pgdevkit for any PostgreSQL work in a Python project: a local
6
- Docker/Podman test database (`pgdb testdb`), importable ORM-free CRUD
7
- helpers (`pgdevkit.db`), and the `database/`-folder schema-as-code
6
+ Docker/Podman test database (`pgdb testdb`), importable ORM-free CRUD and
7
+ query helpers (`pgdevkit.db`: `pg_*`, `fetch_all`, `PgPool`, `SqlLoader`), and the `database/`-folder schema-as-code
8
8
  convention. Supersedes the old postgres-test-setup, postgres-best-practices,
9
9
  and database-in-source skills — pgdevkit is a real dependency now, not
10
10
  copy-pasted reference files. Use whenever the user wants to set up a local
@@ -33,7 +33,7 @@ Core rules for every piece of database code in a project using pgdevkit:
33
33
  - **Inline SQL** — trivial queries of **4 lines or fewer** may be written inline in Python. Anything with JOINs, subqueries, CTEs, aggregations, or multiple conditions lives in its own `.sql` file.
34
34
  - **Named parameters** — always `%(name)s` style, never positional `%s`.
35
35
  - **The `database/` folder is the source of truth for the schema** — see [docs/database-layout.md](../../docs/database-layout.md) for the layer/object-type/file-naming conventions.
36
- - **Result mapping** — every query result maps to a Pydantic model; table-mapped models extend `pgdevkit.db.PostgresTableModel`.
36
+ - **Result mapping** — every query result maps to a Pydantic model (or is deliberately returned as dicts / through `row_mapper`); table-mapped models extend `pgdevkit.db.PostgresTableModel`.
37
37
 
38
38
  ---
39
39
 
@@ -113,12 +113,13 @@ SQL files live under `db/queries/<topic>/`. Every custom query gets its own file
113
113
 
114
114
  ```python
115
115
  # db/connection.py
116
- from pgdevkit.db import PgPool
116
+ from pgdevkit.db import PgPool, set_default_pool
117
117
 
118
118
  pool = PgPool(env_prefix="APP_POSTGRES_") # matches ensure_testdb()'s {env_prefix}POSTGRES_* vars
119
119
 
120
120
  async def startup():
121
121
  await pool.open()
122
+ set_default_pool(pool) # lets `fetch_all()` borrow from it when no `con=`/`pool=` is given
122
123
  ```
123
124
 
124
125
  ### Models
@@ -170,7 +171,7 @@ from pgdevkit.db import pg_retrieve, pg_insert, pg_upsert, pg_delete
170
171
  | `pg_insert_many` | Batch insert via `executemany` |
171
172
  | `pg_delete` / `pg_delete_dict` | Delete by PK, returns deleted row |
172
173
 
173
- Use these for simple CRUD. For custom `WHERE` clauses, joins, aggregations, or ordering, write a dedicated `.sql` file and a repository method.
174
+ Use these for simple CRUD. For custom `WHERE` clauses, joins, aggregations, or ordering, write a dedicated `.sql` file and run it with `fetch_all` (below) in a repository method.
174
175
 
175
176
  ### Loading `.sql` files
176
177
 
@@ -184,8 +185,7 @@ sql = SqlLoader(Path(__file__).parent / "queries")
184
185
 
185
186
  ```python
186
187
  # db/repositories/user_repository.py
187
- from psycopg.rows import dict_row
188
- from pgdevkit.db import pg_retrieve, pg_delete
188
+ from pgdevkit.db import fetch_all, pg_retrieve, pg_delete
189
189
  from db.connection import pool
190
190
  from db.loader import sql
191
191
  from models.user_models import UserRow, UserSummary
@@ -196,11 +196,7 @@ class UserRepository:
196
196
  return await pg_retrieve(conn, UserRow, {"id": user_id})
197
197
 
198
198
  async def list_active(self, limit: int = 100) -> list[UserSummary]:
199
- async with pool.connection() as conn:
200
- async with conn.cursor(row_factory=dict_row) as cur:
201
- await cur.execute(sql.load_sql("users", "list_active_users"), {"limit": limit})
202
- rows = await cur.fetchall()
203
- return [UserSummary.model_validate(r) for r in rows]
199
+ return await fetch_all(sql.load_sql("users", "list_active_users"), {"limit": limit}, model=UserSummary)
204
200
 
205
201
  async def delete(self, user: UserRow) -> UserRow | None:
206
202
  async with pool.connection() as conn:
@@ -209,6 +205,24 @@ class UserRepository:
209
205
 
210
206
  Named parameters, `%(name)s` style, dict argument — never positional `%s`, never f-strings or `str.format()` for SQL text.
211
207
 
208
+ ### `fetch_all` — custom queries without cursor boilerplate
209
+
210
+ ```python
211
+ from pgdevkit.db import fetch_all, set_default_pool
212
+
213
+ set_default_pool(pool) # once, at startup -- the connection source when none is passed
214
+
215
+ users = await fetch_all(sql.load_sql("users", "list_active_users"), {"limit": 10}, model=UserSummary) # list[UserSummary]
216
+ rows = await fetch_all(sql.load_sql("users", "list_active_users"), {"limit": 10}) # list[dict]
217
+ async with pool.connection() as conn: # inside a transaction you already hold
218
+ users = await fetch_all(query, params, model=UserSummary, con=conn)
219
+ ```
220
+
221
+ - `model=` validates each row into a Pydantic model; `row_mapper=` maps each dict row to anything else (exclusive with `model`).
222
+ - Passing `con=` runs on *your* connection: `fetch_all` never commits, closes or releases it. Without it, a connection is borrowed from `pool=` (or the `set_default_pool()` pool) and released afterwards.
223
+ - `cancel=` takes an `asyncio.Event`: set it (e.g. when the HTTP client disconnects) and the running query is cancelled on the server, raising `psycopg.errors.QueryCanceled`. If the awaiting task is cancelled instead, the server-side query is cancelled too and `CancelledError` propagates as usual (not `QueryCanceled`), so no query keeps running unattended. Don't use `cancel=` on a `con` that concurrent tasks share (it aborts whatever statement is running on it); if the cancel request itself can't be delivered, the connection is closed. After `QueryCanceled` a borrowed `con` is usable again once you `await con.rollback()` (autocommit connections need nothing).
224
+ - `query` is typed `SqlQuery`: a literal string (`SqlLoader.load_sql()`), a sqlglot expression, a `psycopg.sql` composable or a t-string. A plain `str` fails type checking on purpose, so don't build SQL with f-strings/concatenation. A sqlglot expression is only as safe as the strings it was built from (its builders parse plain strings as SQL): pass user values as `exp.Placeholder` + `params`, never as literals/raw text, and write a literal `%` as `%%` when you also pass `params`. A t-string carries its own values, so don't also pass `params`.
225
+
212
226
  ### Dynamic SQL
213
227
 
214
228
  Avoid it whenever possible — a static `.sql` file is always clearer. See [`references/dynamic-sql.md`](references/dynamic-sql.md) for t-string templates (3.14+) and `psycopg.sql` (< 3.14) when column/table names genuinely vary at runtime.
@@ -316,7 +330,7 @@ Reports drift between the `database/` `.sql` files and the actual schema — tab
316
330
  - [ ] Simple CRUD uses `pgdevkit.db`'s `pg_*` helpers; custom queries use `.sql` files loaded via `SqlLoader`
317
331
  - [ ] Inline SQL only for trivial queries ≤ 4 lines; anything with JOINs/CTEs/aggregations/subqueries uses a `.sql` file
318
332
  - [ ] All parameters use `%(name)s` style with a dict argument
319
- - [ ] Results mapped to a Pydantic model; table-mapped models extend `PostgresTableModel`
333
+ - [ ] Custom read queries use `fetch_all(...)` (`model=` where the shape is stable; pass `con=` inside a transaction); results mapped to a Pydantic model; table-mapped models extend `PostgresTableModel`
320
334
  - [ ] No `LATERAL JOIN` — use a CTE that groups/aggregates first, then joins it
321
335
  - [ ] `.<env>.sql` files (e.g. `.prod.sql`) are skipped by `pgdb testdb` unless it's run with a matching `--env`
322
336
  - [ ] Every table (and non-obvious column) has a `COMMENT ON`, placed in the object's own `.sql` file
@@ -25,6 +25,9 @@ T-strings look like f-strings but are evaluated by psycopg — values are always
25
25
  await cur.execute(t"SELECT * FROM users WHERE id = {user_id}")
26
26
  ```
27
27
 
28
+ `fetch_all` accepts t-strings and `psycopg.sql` composables too (don't also pass `params` with a t-string):
29
+ `await fetch_all(t"SELECT {column:i} FROM users WHERE active = {active}", model=UserRow)`.
30
+
28
31
  **Dynamic identifier (`:i`):**
29
32
 
30
33
  ```python