pgdevkit 0.12.0__tar.gz → 0.14.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 (147) hide show
  1. pgdevkit-0.12.0/README.md → pgdevkit-0.14.0/PKG-INFO +129 -0
  2. pgdevkit-0.12.0/PKG-INFO → pgdevkit-0.14.0/README.md +104 -21
  3. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/db/__init__.py +5 -0
  4. pgdevkit-0.14.0/pgdevkit/db/fetch.py +315 -0
  5. pgdevkit-0.14.0/pgdevkit/fastapi/__init__.py +3 -0
  6. pgdevkit-0.14.0/pgdevkit/fastapi/json_response.py +248 -0
  7. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pyproject.toml +9 -3
  8. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/skills/pgdevkit/SKILL.md +54 -15
  9. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/skills/pgdevkit/references/dynamic-sql.md +3 -0
  10. pgdevkit-0.14.0/tests/_asgi.py +53 -0
  11. pgdevkit-0.14.0/tests/_granian_app.py +40 -0
  12. pgdevkit-0.14.0/tests/db/test_fetch.py +253 -0
  13. pgdevkit-0.14.0/tests/db/test_fetch_statement_timeout.py +61 -0
  14. pgdevkit-0.14.0/tests/test_pg_json_cancel.py +159 -0
  15. pgdevkit-0.14.0/tests/test_pg_json_granian.py +98 -0
  16. pgdevkit-0.14.0/tests/test_pg_json_response.py +249 -0
  17. pgdevkit-0.14.0/tests/test_pg_json_statement_timeout.py +90 -0
  18. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/uv.lock +169 -4
  19. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/.github/workflows/auto-release.yml +0 -0
  20. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/.github/workflows/python-publish.yml +0 -0
  21. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/.github/workflows/python-test.yml +0 -0
  22. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/.gitignore +0 -0
  23. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/.python-version +0 -0
  24. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/docs/database-layout.md +0 -0
  25. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/__init__.py +0 -0
  26. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/areas.py +0 -0
  27. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/backends/__init__.py +0 -0
  28. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/backends/base.py +0 -0
  29. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/backends/mssql.py +0 -0
  30. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/backends/postgres.py +0 -0
  31. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/cli.py +0 -0
  32. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/connection.py +0 -0
  33. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/db/complex_types.py +0 -0
  34. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/db/connection.py +0 -0
  35. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/db/crud.py +0 -0
  36. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/db/loader.py +0 -0
  37. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/db/model.py +0 -0
  38. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/db/mssql_crud.py +0 -0
  39. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/db/mssql_sql.py +0 -0
  40. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/dialect.py +0 -0
  41. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/diff.py +0 -0
  42. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/envtag.py +0 -0
  43. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/fetch_missing.py +0 -0
  44. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/introspect.py +0 -0
  45. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/lakebase.py +0 -0
  46. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/migrate.py +0 -0
  47. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/migrate_mssql.py +0 -0
  48. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/models.py +0 -0
  49. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/mssql_introspect.py +0 -0
  50. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/parser.py +0 -0
  51. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/schemas.py +0 -0
  52. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/sql_text.py +0 -0
  53. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/stats.py +0 -0
  54. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/testdb/__init__.py +0 -0
  55. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/testdb/_docker.py +0 -0
  56. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/testdb/api.py +0 -0
  57. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/testdb/config.py +0 -0
  58. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/testdb/constants.py +0 -0
  59. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/testdb/container.py +0 -0
  60. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/testdb/mssql/__init__.py +0 -0
  61. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/testdb/mssql/api.py +0 -0
  62. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/testdb/mssql/constants.py +0 -0
  63. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/testdb/mssql/container.py +0 -0
  64. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/testdb/naming.py +0 -0
  65. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/testdb/query.py +0 -0
  66. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/pgdevkit/testdb/schema.py +0 -0
  67. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/skills/pgdevkit/references/temporal-tables.md +0 -0
  68. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/__init__.py +0 -0
  69. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/conftest.py +0 -0
  70. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/db/__init__.py +0 -0
  71. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/db/test_complex_types.py +0 -0
  72. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/db/test_connection.py +0 -0
  73. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/db/test_crud.py +0 -0
  74. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/db/test_loader.py +0 -0
  75. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/db/test_mssql_crud_live.py +0 -0
  76. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/db/test_mssql_crud_sql.py +0 -0
  77. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/fixtures/01_schema.sql +0 -0
  78. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/fixtures/02_types.sql +0 -0
  79. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/fixtures/03_tables.sql +0 -0
  80. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/fixtures/04_views.sql +0 -0
  81. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/fixtures/05_functions.sql +0 -0
  82. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/fixtures/06_indexes.sql +0 -0
  83. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/test_areas.py +0 -0
  84. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/test_cli_compare.py +0 -0
  85. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/test_compare.py +0 -0
  86. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/test_compare_mssql_live.py +0 -0
  87. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/test_connection.py +0 -0
  88. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/test_dialect.py +0 -0
  89. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/test_diff_mssql.py +0 -0
  90. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/test_envtag.py +0 -0
  91. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/test_fetch_missing.py +0 -0
  92. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/test_fetch_missing_cli.py +0 -0
  93. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/test_lakebase.py +0 -0
  94. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/test_migrate.py +0 -0
  95. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/test_migrate_areas.py +0 -0
  96. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/test_migrate_env.py +0 -0
  97. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/test_migrate_grants.py +0 -0
  98. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/test_migrate_mssql.py +0 -0
  99. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/test_migrate_mssql_live.py +0 -0
  100. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/test_migrate_schemas.py +0 -0
  101. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/test_mssql_introspect.py +0 -0
  102. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/test_parser_areas.py +0 -0
  103. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/test_parser_mssql.py +0 -0
  104. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/test_parser_schemas.py +0 -0
  105. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/test_schemas.py +0 -0
  106. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/test_stats_cli.py +0 -0
  107. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/test_stats_mssql_live.py +0 -0
  108. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/__init__.py +0 -0
  109. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/conftest.py +0 -0
  110. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
  111. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/fixtures/database/app/tables/event.sql +0 -0
  112. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/fixtures/database/app/tables/event_kind.sql +0 -0
  113. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
  114. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
  115. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +0 -0
  116. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/fixtures/database/app/tables/widget.init.sql +0 -0
  117. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
  118. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
  119. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
  120. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
  121. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
  122. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
  123. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
  124. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
  125. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/fixtures/database/permissions/grants.sql +0 -0
  126. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/fixtures/database/schema/app.sql +0 -0
  127. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
  128. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
  129. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
  130. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
  131. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
  132. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/test_api.py +0 -0
  133. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/test_api_mssql.py +0 -0
  134. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/test_api_mssql_live.py +0 -0
  135. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/test_cli.py +0 -0
  136. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/test_config.py +0 -0
  137. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/test_config_mssql.py +0 -0
  138. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/test_constants.py +0 -0
  139. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/test_container.py +0 -0
  140. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/test_container_mssql.py +0 -0
  141. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/test_docker.py +0 -0
  142. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/test_mssql_constants.py +0 -0
  143. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/test_naming.py +0 -0
  144. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/test_query.py +0 -0
  145. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/test_schema.py +0 -0
  146. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/test_schema_filtering.py +0 -0
  147. {pgdevkit-0.12.0 → pgdevkit-0.14.0}/tests/testdb/test_schema_mssql.py +0 -0
@@ -1,3 +1,28 @@
1
+ Metadata-Version: 2.5
2
+ Name: pgdevkit
3
+ Version: 0.14.0
4
+ Summary: A helper for developing with Postgres
5
+ Requires-Python: >=3.14
6
+ Requires-Dist: docker>=7.1.0
7
+ Requires-Dist: psycopg[binary]>=3.3.0
8
+ Requires-Dist: sqlglot>=30.11.0
9
+ Provides-Extra: azure
10
+ Requires-Dist: azure-identity>=1.19.0; extra == 'azure'
11
+ Provides-Extra: cli
12
+ Requires-Dist: rich>=13.0.0; extra == 'cli'
13
+ Requires-Dist: tqdm>=4.66.0; extra == 'cli'
14
+ Requires-Dist: typer>=0.26.7; extra == 'cli'
15
+ Provides-Extra: db
16
+ Requires-Dist: psycopg-pool>=3.3.0; extra == 'db'
17
+ Requires-Dist: pydantic>=2.0; extra == 'db'
18
+ Provides-Extra: fastapi
19
+ Requires-Dist: fastapi>=0.118.0; extra == 'fastapi'
20
+ Requires-Dist: psycopg-pool>=3.3.0; extra == 'fastapi'
21
+ Requires-Dist: pydantic>=2.0; extra == 'fastapi'
22
+ Provides-Extra: mssql
23
+ Requires-Dist: mssql-python>=1.0.0; extra == 'mssql'
24
+ Description-Content-Type: text/markdown
25
+
1
26
  # pgdevkit
2
27
 
3
28
  A helper for developing with Postgres.
@@ -406,6 +431,20 @@ Install with the `db` extra: `pip install pgdevkit[db]`.
406
431
  `--entra-user`. For Lakebase hosts, also set the
407
432
  `{env_prefix}DATABRICKS_WORKSPACE_HOST` and `{env_prefix}DATABRICKS_INSTANCE`
408
433
  env vars.
434
+ - **`fetch_all(query, params=None, *, model=None, row_mapper=None, con=None, pool=None, cancel=None, statement_timeout=None)`**
435
+ — run one custom query and get every row back as dicts, as validated
436
+ Pydantic `model` instances, or through a `row_mapper`. Pass `con` to run on a
437
+ connection you already hold (it is borrowed: never committed, closed or
438
+ released); otherwise a connection is taken from `pool` or the pool registered
439
+ with `set_default_pool()`. Set the `cancel` `asyncio.Event` to abort the query on the server
440
+ (`QueryCanceled`; to stream a result as JSON from FastAPI see `PostgresJsonResponse`, which watches for the
441
+ disconnect itself); cancelling the awaiting task cancels it there too, and
442
+ `CancelledError` propagates as usual. `statement_timeout=<seconds>` lets Postgres abort the query after that
443
+ long (`QueryCanceled`, "canceling statement due to statement timeout"); it is applied per call with `SET LOCAL`
444
+ semantics, so it never outlives the call (a borrowed `con` gets its previous setting back). `query` must be a literal string, a sqlglot
445
+ expression, a `psycopg.sql` composable or a t-string — never an f-string (a sqlglot
446
+ expression is only as safe as the strings it was built from: pass user values as
447
+ `exp.Placeholder` + `params`).
409
448
  - **CRUD functions** — `pg_retrieve`, `pg_retrieve_many`, `pg_insert`,
410
449
  `pg_insert_many`, `pg_update`, `pg_update_dict`, `pg_upsert`,
411
450
  `pg_upsert_dict`, `pg_upsert_many`, `pg_upsert_many_dict`, `pg_delete`,
@@ -450,6 +489,96 @@ async with pool.connection() as con:
450
489
  await pg_upsert(con, Widget(id=1, name="thing"), Widget)
451
490
  ```
452
491
 
492
+ ## `pgdevkit.fastapi` — streaming JSON responses
493
+
494
+ Requires the `fastapi` extra (`pip install pgdevkit[fastapi]`, FastAPI >= 0.118).
495
+
496
+ `PostgresJsonResponse` runs a query and streams its rows to the client as a JSON array. Postgres builds the JSON
497
+ (`row_to_json`), so there is no row-by-row Python serialization and no pydantic round trip, and memory use stays flat
498
+ for big results. **If the client goes away, the query is cancelled in Postgres** instead of running to completion.
499
+ (`fetch_all(..., cancel=event)` cancels a buffered query too, but you wire the event to the disconnect yourself.)
500
+
501
+ ```python
502
+ from pgdevkit.db import PgPool, set_default_pool
503
+ from pgdevkit.fastapi import PostgresJsonResponse
504
+
505
+ pool = PgPool(env_prefix="APP_POSTGRES_")
506
+ set_default_pool(pool) # once at startup, shared with fetch_all
507
+
508
+ @router.get("/articles", responses={200: {"model": list[ArticleOut]}})
509
+ async def articles(lng: str) -> PostgresJsonResponse:
510
+ return PostgresJsonResponse(
511
+ "select id, name from articles where lng = %(lng)s order by name",
512
+ {"lng": lng},
513
+ )
514
+ ```
515
+
516
+ The `responses=` entry keeps the OpenAPI schema (and a generated frontend client) typed; `response_model` is ignored
517
+ for a `Response`, and the rows are *not* validated against the model.
518
+
519
+ - **Connections** work as in `fetch_all`: the pool registered with `set_default_pool()`, an explicit `pool=` (anything
520
+ with `.connection()`, e.g. `PgPool`), or a `con=` you opened yourself. A pooled connection is acquired when streaming
521
+ starts and released when it ends. A `con` must stay open until the response has been sent, i.e. come from a `Depends`
522
+ with `yield` (FastAPI >= 0.118); `async with pool.connection() as conn: return PostgresJsonResponse(q, con=conn)`
523
+ closes it too early. Prefer the pool.
524
+ - **Query** — a literal string, a `psycopg.sql` composable or a sqlglot expression, with values bound through `params`
525
+ (`%(name)s`). Passing only literals is enforced by the type checker, **not at runtime**: the query is wrapped
526
+ as a subquery, never parsed, so never build it from user input. For a data-modifying CTE, or custom JSON, pass
527
+ `query_produces_json=True` and return one *text* column per row (a `json` column or NULL fails). A trailing `;`
528
+ is fine, a `;` followed by a comment is not. t-strings aren't supported (they can't be wrapped).
529
+ - **Options** — `batch_size` (rows per chunk, >= 1, default 1000; don't take it from user input),
530
+ `statement_timeout` (seconds, see below), `status_code`, `headers` (e.g. `{"Cache-Control": "max-age=3600"}`).
531
+
532
+ ### Cancellation
533
+
534
+ The response watches for `http.disconnect` itself (Starlette only does that for ASGI spec < 2.4), also while the
535
+ query is still running and nothing has been sent yet. It then cancels the query with `cancel_safe()`, shielded from
536
+ the cancellation that is tearing the request down, and closes the connection if it was left mid-query: a pool opens a
537
+ fresh one instead of reusing it. Only a query that is still running is cancelled, so a late cancel can never
538
+ hit the next statement on a reused connection. A frontend `AbortController` / closed tab therefore frees the database.
539
+
540
+ With `con=`, a disconnect aborts the query *and your transaction*, unlike `fetch_all`: the connection is afterwards
541
+ either left in an aborted transaction (idle, with autocommit) or closed, depending on whether the stream was waiting for
542
+ Postgres or for the client.
543
+ Don't use it afterwards, and don't count on earlier writes in that transaction.
544
+
545
+ Connections are held while the client reads, so a client that reads very slowly pins one (`statement_timeout` does not
546
+ help, see below). Enforce a write timeout in the reverse proxy and size the pool accordingly. Disconnect behaviour can only be tested over a real socket
547
+ (`httpx.ASGITransport` never sends `http.disconnect`); see `tests/test_pg_json_granian.py`.
548
+
549
+ ### Statement timeout
550
+
551
+ `statement_timeout=<seconds>` (optional, also on `fetch_all`) lets **Postgres** abort the query after that long. It
552
+ is set with `SET LOCAL` semantics for this one query: transaction-scoped, so it can't leak into the pool and works
553
+ behind PgBouncer's transaction pooling, and a `con` you passed gets its previous setting back. Before the first byte
554
+ the client gets a `504 {"error": "Query timed out"}`; after that the response is cut short like any other error. It
555
+ needs a connection that is not in autocommit mode. Without it, the connection's own `statement_timeout` applies.
556
+
557
+ It limits *query execution*, nothing else: Postgres defers a cancel while it is blocked writing to a client that
558
+ has stopped reading, so a stalled reader keeps its connection regardless (a backend was observed staying `active`
559
+ in `ClientWrite` well past a 1 s timeout). For that, use a write timeout in the reverse proxy.
560
+
561
+ ### Errors
562
+
563
+ An error before the first byte becomes a JSON `{"error": ...}` response (500, or the status and headers of an
564
+ `HTTPException` raised by a custom pool); the text is hidden unless you subclass and set `expose_errors = True`
565
+ (it contains SQL fragments and parameter values: never in production):
566
+
567
+ ```python
568
+ class AppJsonResponse(PostgresJsonResponse):
569
+ expose_errors = IS_DEV
570
+ ```
571
+
572
+ After the first byte the status line is gone: the error is logged and re-raised, and the response ends **without
573
+ the closing `]`**, so clients fail to parse it rather than accepting a silently shortened array. (Granian, which our
574
+ apps run on, ends such a response cleanly with the truncated body.)
575
+
576
+ ### Notes
577
+
578
+ It does no authorization: scope the query yourself. The first byte is sent once `batch_size` rows (or the whole
579
+ result) are available, so lower `batch_size` for earlier output; Postgres itself also sends rows in 8 kB buffers,
580
+ so a slow query with tiny rows delivers late either way.
581
+
453
582
  ## Releasing
454
583
 
455
584
  Bump `version` in `pyproject.toml` as part of your PR, same as any other
@@ -1,24 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: pgdevkit
3
- Version: 0.12.0
4
- Summary: A helper for developing with Postgres
5
- Requires-Python: >=3.14
6
- Requires-Dist: docker>=7.1.0
7
- Requires-Dist: psycopg[binary]>=3.2.0
8
- Requires-Dist: sqlglot>=30.11.0
9
- Provides-Extra: azure
10
- Requires-Dist: azure-identity>=1.19.0; extra == 'azure'
11
- Provides-Extra: cli
12
- Requires-Dist: rich>=13.0.0; extra == 'cli'
13
- Requires-Dist: tqdm>=4.66.0; extra == 'cli'
14
- Requires-Dist: typer>=0.26.7; extra == 'cli'
15
- Provides-Extra: db
16
- Requires-Dist: psycopg-pool>=3.3.0; extra == 'db'
17
- Requires-Dist: pydantic>=2.0; extra == 'db'
18
- Provides-Extra: mssql
19
- Requires-Dist: mssql-python>=1.0.0; extra == 'mssql'
20
- Description-Content-Type: text/markdown
21
-
22
1
  # pgdevkit
23
2
 
24
3
  A helper for developing with Postgres.
@@ -427,6 +406,20 @@ Install with the `db` extra: `pip install pgdevkit[db]`.
427
406
  `--entra-user`. For Lakebase hosts, also set the
428
407
  `{env_prefix}DATABRICKS_WORKSPACE_HOST` and `{env_prefix}DATABRICKS_INSTANCE`
429
408
  env vars.
409
+ - **`fetch_all(query, params=None, *, model=None, row_mapper=None, con=None, pool=None, cancel=None, statement_timeout=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`; to stream a result as JSON from FastAPI see `PostgresJsonResponse`, which watches for the
416
+ disconnect itself); cancelling the awaiting task cancels it there too, and
417
+ `CancelledError` propagates as usual. `statement_timeout=<seconds>` lets Postgres abort the query after that
418
+ long (`QueryCanceled`, "canceling statement due to statement timeout"); it is applied per call with `SET LOCAL`
419
+ semantics, so it never outlives the call (a borrowed `con` gets its previous setting back). `query` must be a literal string, a sqlglot
420
+ expression, a `psycopg.sql` composable or a t-string — never an f-string (a sqlglot
421
+ expression is only as safe as the strings it was built from: pass user values as
422
+ `exp.Placeholder` + `params`).
430
423
  - **CRUD functions** — `pg_retrieve`, `pg_retrieve_many`, `pg_insert`,
431
424
  `pg_insert_many`, `pg_update`, `pg_update_dict`, `pg_upsert`,
432
425
  `pg_upsert_dict`, `pg_upsert_many`, `pg_upsert_many_dict`, `pg_delete`,
@@ -471,6 +464,96 @@ async with pool.connection() as con:
471
464
  await pg_upsert(con, Widget(id=1, name="thing"), Widget)
472
465
  ```
473
466
 
467
+ ## `pgdevkit.fastapi` — streaming JSON responses
468
+
469
+ Requires the `fastapi` extra (`pip install pgdevkit[fastapi]`, FastAPI >= 0.118).
470
+
471
+ `PostgresJsonResponse` runs a query and streams its rows to the client as a JSON array. Postgres builds the JSON
472
+ (`row_to_json`), so there is no row-by-row Python serialization and no pydantic round trip, and memory use stays flat
473
+ for big results. **If the client goes away, the query is cancelled in Postgres** instead of running to completion.
474
+ (`fetch_all(..., cancel=event)` cancels a buffered query too, but you wire the event to the disconnect yourself.)
475
+
476
+ ```python
477
+ from pgdevkit.db import PgPool, set_default_pool
478
+ from pgdevkit.fastapi import PostgresJsonResponse
479
+
480
+ pool = PgPool(env_prefix="APP_POSTGRES_")
481
+ set_default_pool(pool) # once at startup, shared with fetch_all
482
+
483
+ @router.get("/articles", responses={200: {"model": list[ArticleOut]}})
484
+ async def articles(lng: str) -> PostgresJsonResponse:
485
+ return PostgresJsonResponse(
486
+ "select id, name from articles where lng = %(lng)s order by name",
487
+ {"lng": lng},
488
+ )
489
+ ```
490
+
491
+ The `responses=` entry keeps the OpenAPI schema (and a generated frontend client) typed; `response_model` is ignored
492
+ for a `Response`, and the rows are *not* validated against the model.
493
+
494
+ - **Connections** work as in `fetch_all`: the pool registered with `set_default_pool()`, an explicit `pool=` (anything
495
+ with `.connection()`, e.g. `PgPool`), or a `con=` you opened yourself. A pooled connection is acquired when streaming
496
+ starts and released when it ends. A `con` must stay open until the response has been sent, i.e. come from a `Depends`
497
+ with `yield` (FastAPI >= 0.118); `async with pool.connection() as conn: return PostgresJsonResponse(q, con=conn)`
498
+ closes it too early. Prefer the pool.
499
+ - **Query** — a literal string, a `psycopg.sql` composable or a sqlglot expression, with values bound through `params`
500
+ (`%(name)s`). Passing only literals is enforced by the type checker, **not at runtime**: the query is wrapped
501
+ as a subquery, never parsed, so never build it from user input. For a data-modifying CTE, or custom JSON, pass
502
+ `query_produces_json=True` and return one *text* column per row (a `json` column or NULL fails). A trailing `;`
503
+ is fine, a `;` followed by a comment is not. t-strings aren't supported (they can't be wrapped).
504
+ - **Options** — `batch_size` (rows per chunk, >= 1, default 1000; don't take it from user input),
505
+ `statement_timeout` (seconds, see below), `status_code`, `headers` (e.g. `{"Cache-Control": "max-age=3600"}`).
506
+
507
+ ### Cancellation
508
+
509
+ The response watches for `http.disconnect` itself (Starlette only does that for ASGI spec < 2.4), also while the
510
+ query is still running and nothing has been sent yet. It then cancels the query with `cancel_safe()`, shielded from
511
+ the cancellation that is tearing the request down, and closes the connection if it was left mid-query: a pool opens a
512
+ fresh one instead of reusing it. Only a query that is still running is cancelled, so a late cancel can never
513
+ hit the next statement on a reused connection. A frontend `AbortController` / closed tab therefore frees the database.
514
+
515
+ With `con=`, a disconnect aborts the query *and your transaction*, unlike `fetch_all`: the connection is afterwards
516
+ either left in an aborted transaction (idle, with autocommit) or closed, depending on whether the stream was waiting for
517
+ Postgres or for the client.
518
+ Don't use it afterwards, and don't count on earlier writes in that transaction.
519
+
520
+ Connections are held while the client reads, so a client that reads very slowly pins one (`statement_timeout` does not
521
+ help, see below). Enforce a write timeout in the reverse proxy and size the pool accordingly. Disconnect behaviour can only be tested over a real socket
522
+ (`httpx.ASGITransport` never sends `http.disconnect`); see `tests/test_pg_json_granian.py`.
523
+
524
+ ### Statement timeout
525
+
526
+ `statement_timeout=<seconds>` (optional, also on `fetch_all`) lets **Postgres** abort the query after that long. It
527
+ is set with `SET LOCAL` semantics for this one query: transaction-scoped, so it can't leak into the pool and works
528
+ behind PgBouncer's transaction pooling, and a `con` you passed gets its previous setting back. Before the first byte
529
+ the client gets a `504 {"error": "Query timed out"}`; after that the response is cut short like any other error. It
530
+ needs a connection that is not in autocommit mode. Without it, the connection's own `statement_timeout` applies.
531
+
532
+ It limits *query execution*, nothing else: Postgres defers a cancel while it is blocked writing to a client that
533
+ has stopped reading, so a stalled reader keeps its connection regardless (a backend was observed staying `active`
534
+ in `ClientWrite` well past a 1 s timeout). For that, use a write timeout in the reverse proxy.
535
+
536
+ ### Errors
537
+
538
+ An error before the first byte becomes a JSON `{"error": ...}` response (500, or the status and headers of an
539
+ `HTTPException` raised by a custom pool); the text is hidden unless you subclass and set `expose_errors = True`
540
+ (it contains SQL fragments and parameter values: never in production):
541
+
542
+ ```python
543
+ class AppJsonResponse(PostgresJsonResponse):
544
+ expose_errors = IS_DEV
545
+ ```
546
+
547
+ After the first byte the status line is gone: the error is logged and re-raised, and the response ends **without
548
+ the closing `]`**, so clients fail to parse it rather than accepting a silently shortened array. (Granian, which our
549
+ apps run on, ends such a response cleanly with the truncated body.)
550
+
551
+ ### Notes
552
+
553
+ It does no authorization: scope the query yourself. The first byte is sent once `batch_size` rows (or the whole
554
+ result) are available, so lower `batch_size` for earlier output; Postgres itself also sends rows in 8 kB buffers,
555
+ so a slow query with tiny rows delivers late either way.
556
+
474
557
  ## Releasing
475
558
 
476
559
  Bump `version` in `pyproject.toml` as part of your PR, same as any other
@@ -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,315 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import math
6
+ import re
7
+ from collections.abc import AsyncIterator, Callable, Mapping, Sequence
8
+ from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager, suppress
9
+ from string.templatelib import Template
10
+ from typing import TYPE_CHECKING, Any, LiteralString, Protocol, overload
11
+
12
+ from psycopg.connection_async import AsyncConnection
13
+ from psycopg.errors import QueryCanceled
14
+ from psycopg.rows import dict_row
15
+ from psycopg.sql import Composable
16
+ from pydantic import BaseModel
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ if TYPE_CHECKING:
21
+ from sqlglot import exp # imported lazily: only needed to type sqlglot queries (rendering is duck-typed)
22
+
23
+ # Same params shape psycopg's Cursor.execute() accepts: a %(name)s-style mapping,
24
+ # or a positional %s-style sequence.
25
+ QueryParams = Mapping[str, Any] | Sequence[Any] | None
26
+
27
+ # The only things a query may be: a literal string (e.g. from `SqlLoader.load_sql()`), a sqlglot
28
+ # expression, or psycopg's own safe-composition types (`psycopg.sql` / a t-string). A plain `str`
29
+ # is rejected by the type checker on purpose, so user-controlled text can't be concatenated in.
30
+ type SqlQueryNoTemplate = LiteralString | exp.Expression | Composable
31
+ type SqlQuery = SqlQueryNoTemplate | Template
32
+
33
+
34
+ class ConnectionSource(Protocol):
35
+ """Anything that hands out an async connection context manager -- `PgPool` fits."""
36
+
37
+ def connection(self) -> AbstractAsyncContextManager[AsyncConnection]: ...
38
+
39
+
40
+ _default_source: ConnectionSource | None = None
41
+
42
+
43
+ def set_default_pool(pool: ConnectionSource | None) -> None:
44
+ """Register where `fetch_all()` gets its connection when called without `con`/`pool`
45
+ (typically the app's `PgPool`, once at startup). Pass `None` to clear it. This is process-wide
46
+ state meant for single-database apps: with several databases/tenants pass `pool=` explicitly."""
47
+ global _default_source
48
+ _default_source = pool
49
+
50
+
51
+ @asynccontextmanager
52
+ async def _acquire(con: AsyncConnection | None, pool: ConnectionSource | None) -> AsyncIterator[AsyncConnection]:
53
+ """Yield `con` untouched (the caller owns it: no commit/close/return-to-pool here), or a
54
+ connection borrowed from `pool` / the default pool for the duration of the block."""
55
+ if con is not None:
56
+ if pool is not None:
57
+ raise TypeError("Pass either `con` or `pool`, not both.")
58
+ yield con
59
+ return
60
+ source = pool if pool is not None else _default_source
61
+ if source is None:
62
+ raise RuntimeError("No connection: pass `con=`/`pool=`, or register a pool with `set_default_pool()`.")
63
+ async with source.connection() as borrowed:
64
+ yield borrowed
65
+
66
+
67
+ _PLACEHOLDER_NAME = re.compile(r"\w+")
68
+
69
+
70
+ def _render(query: SqlQuery) -> Any:
71
+ if isinstance(query, (str, Composable, Template)):
72
+ return query
73
+ if not hasattr(query, "sql"):
74
+ raise TypeError(f"fetch_all: unsupported query type {type(query).__name__}")
75
+ # Anything else is a sqlglot expression (no sqlglot import needed to know that; it is loaded
76
+ # already). Its postgres dialect renders `exp.Placeholder("id")` as `%(id)s`.
77
+ from sqlglot import exp
78
+
79
+ for node in query.walk():
80
+ # sqlglot renders these verbatim, so they could smuggle arbitrary SQL into the statement.
81
+ if isinstance(node, exp.Command):
82
+ raise ValueError("fetch_all: raw sqlglot Command nodes are not allowed")
83
+ if isinstance(node, exp.Placeholder) and node.name and not _PLACEHOLDER_NAME.fullmatch(node.name):
84
+ raise ValueError(f"fetch_all: invalid placeholder name {node.name!r}")
85
+ return query.sql(dialect="postgres")
86
+
87
+
88
+ def _check_statement_timeout(statement_timeout: float | None) -> None:
89
+ if statement_timeout is not None and statement_timeout <= 0:
90
+ raise ValueError(f"statement_timeout must be a positive number of seconds, got {statement_timeout}")
91
+
92
+
93
+ @asynccontextmanager
94
+ async def _statement_timeout(c: AsyncConnection, seconds: float | None) -> AsyncIterator[None]:
95
+ """Run the block under Postgres' `statement_timeout`, set with `set_config(..., is_local => true)`: it is
96
+ scoped to the current transaction (so it can't leak into a pool, and it is safe behind PgBouncer's
97
+ transaction pooling) and put back afterwards for a borrowed connection that stays in its transaction.
98
+ `None` leaves the connection's own setting alone."""
99
+ if seconds is None:
100
+ yield
101
+ return
102
+ async with AsyncExitStack() as stack:
103
+ if c.autocommit:
104
+ await stack.enter_async_context(c.transaction()) # a local setting needs a transaction to live in
105
+ cur = await c.execute(
106
+ "SELECT current_setting('statement_timeout'), set_config('statement_timeout', %(ms)s, true)",
107
+ {"ms": str(max(1, math.ceil(seconds * 1000)))},
108
+ )
109
+ row = await cur.fetchone()
110
+ assert row is not None
111
+ yield
112
+ await c.execute("SELECT set_config('statement_timeout', %(previous)s, true)", {"previous": row[0]})
113
+
114
+
115
+ async def _run_query(
116
+ c: AsyncConnection, query: Any, params: QueryParams, statement_timeout: float | None
117
+ ) -> list[dict[str, Any]]:
118
+ async with _statement_timeout(c, statement_timeout), c.cursor(row_factory=dict_row) as cur:
119
+ await cur.execute(query, params)
120
+ return await cur.fetchall()
121
+
122
+
123
+ async def _abort(c: AsyncConnection, work: asyncio.Future[Any]) -> None:
124
+ """Cancel `work`'s query on the server and wait until it has unwound, leaving `c` idle (an
125
+ aborted transaction, to be rolled back by its owner). If the cancel request itself can't be
126
+ delivered, the connection's state is unknown, so it is closed (the server abandons the query as
127
+ soon as it next talks to the client) and a pool discards it instead of reusing it."""
128
+ if not work.done():
129
+ try:
130
+ await c.cancel_safe()
131
+ except Exception:
132
+ logger.warning("fetch_all: cancelling the query failed; closing the connection", exc_info=True)
133
+ with suppress(Exception):
134
+ await c.close() # first: a cancelled-but-still-busy connection would make `work` wait out the query
135
+ work.cancel()
136
+ await asyncio.gather(work, return_exceptions=True)
137
+ return
138
+ await asyncio.gather(work, return_exceptions=True) # always consume `work`'s outcome
139
+
140
+
141
+ async def _abort_uninterruptibly(c: AsyncConnection, work: asyncio.Future[Any]) -> None:
142
+ """`_abort`, but a further cancellation of the awaiting task can't cut it short (which would
143
+ leave `work` running); it is re-raised once the abort is complete."""
144
+ task = asyncio.ensure_future(_abort(c, work))
145
+ interrupted = False
146
+ while not task.done():
147
+ try:
148
+ await asyncio.shield(task)
149
+ except asyncio.CancelledError:
150
+ interrupted = True
151
+ task.result()
152
+ if interrupted:
153
+ raise asyncio.CancelledError
154
+
155
+
156
+ async def _fetch_dicts(
157
+ query: Any, # already rendered (see `_render`)
158
+ params: QueryParams,
159
+ con: AsyncConnection | None,
160
+ pool: ConnectionSource | None,
161
+ cancel: asyncio.Event | None,
162
+ statement_timeout: float | None,
163
+ ) -> list[dict[str, Any]]:
164
+ if cancel is not None and cancel.is_set():
165
+ raise QueryCanceled("fetch_all: cancelled before the query started")
166
+ async with _acquire(con, pool) as c:
167
+ work = asyncio.ensure_future(_run_query(c, query, params, statement_timeout))
168
+ stop = asyncio.ensure_future(cancel.wait()) if cancel is not None else None
169
+ try:
170
+ await asyncio.wait([work, *([stop] if stop else [])], return_when=asyncio.FIRST_COMPLETED)
171
+ if not work.done(): # `cancel` was set while the query runs: abort it on the server
172
+ await _abort_uninterruptibly(c, work)
173
+ if work.cancelled():
174
+ raise QueryCanceled("fetch_all: cancelled via `cancel`")
175
+ return work.result() # raises psycopg.errors.QueryCanceled if it was cancelled
176
+ except asyncio.CancelledError:
177
+ # The awaiting task itself was cancelled: abandoning the query client-side would leave it
178
+ # running on the server (and the connection mid-query), so cancel it there and let it
179
+ # unwind first -- the connection is then idle for its owner/the pool.
180
+ await _abort_uninterruptibly(c, work)
181
+ raise
182
+ finally:
183
+ if stop is not None:
184
+ stop.cancel()
185
+
186
+
187
+ @overload
188
+ async def fetch_all[M: BaseModel](
189
+ query: SqlQueryNoTemplate,
190
+ params: QueryParams = None,
191
+ *,
192
+ model: type[M],
193
+ con: AsyncConnection | None = None,
194
+ pool: ConnectionSource | None = None,
195
+ cancel: asyncio.Event | None = None,
196
+ statement_timeout: float | None = None,
197
+ ) -> list[M]: ...
198
+
199
+
200
+ @overload
201
+ async def fetch_all[T](
202
+ query: SqlQueryNoTemplate,
203
+ params: QueryParams = None,
204
+ *,
205
+ row_mapper: Callable[[dict[str, Any]], T],
206
+ con: AsyncConnection | None = None,
207
+ pool: ConnectionSource | None = None,
208
+ cancel: asyncio.Event | None = None,
209
+ statement_timeout: float | None = None,
210
+ ) -> list[T]: ...
211
+
212
+
213
+ @overload
214
+ async def fetch_all(
215
+ query: SqlQueryNoTemplate,
216
+ params: QueryParams = None,
217
+ *,
218
+ model: None = None,
219
+ row_mapper: None = None,
220
+ con: AsyncConnection | None = None,
221
+ pool: ConnectionSource | None = None,
222
+ cancel: asyncio.Event | None = None,
223
+ statement_timeout: float | None = None,
224
+ ) -> list[dict[str, Any]]: ...
225
+
226
+
227
+ @overload
228
+ async def fetch_all[M: BaseModel](
229
+ query: Template,
230
+ *,
231
+ model: type[M],
232
+ con: AsyncConnection | None = None,
233
+ pool: ConnectionSource | None = None,
234
+ cancel: asyncio.Event | None = None,
235
+ statement_timeout: float | None = None,
236
+ ) -> list[M]: ...
237
+
238
+
239
+ @overload
240
+ async def fetch_all[T](
241
+ query: Template,
242
+ *,
243
+ row_mapper: Callable[[dict[str, Any]], T],
244
+ con: AsyncConnection | None = None,
245
+ pool: ConnectionSource | None = None,
246
+ cancel: asyncio.Event | None = None,
247
+ statement_timeout: float | None = None,
248
+ ) -> list[T]: ...
249
+
250
+
251
+ @overload
252
+ async def fetch_all(
253
+ query: Template,
254
+ *,
255
+ model: None = None,
256
+ row_mapper: None = None,
257
+ con: AsyncConnection | None = None,
258
+ pool: ConnectionSource | None = None,
259
+ cancel: asyncio.Event | None = None,
260
+ statement_timeout: float | None = None,
261
+ ) -> list[dict[str, Any]]: ...
262
+
263
+
264
+ async def fetch_all(
265
+ query: SqlQuery,
266
+ params: QueryParams = None,
267
+ *,
268
+ model: type[BaseModel] | None = None,
269
+ row_mapper: Callable[[dict[str, Any]], Any] | None = None,
270
+ con: AsyncConnection | None = None,
271
+ pool: ConnectionSource | None = None,
272
+ cancel: asyncio.Event | None = None,
273
+ statement_timeout: float | None = None,
274
+ ) -> list[Any]:
275
+ """Run one query and return every row.
276
+
277
+ Rows come back as plain dicts, or, with `model=` (a Pydantic model class), validated into
278
+ that model; `row_mapper=` converts each dict row into any other shape. The two are exclusive.
279
+
280
+ Pass `statement_timeout` (seconds) to let Postgres abort the query after that long: `fetch_all` then
281
+ raises `psycopg.errors.QueryCanceled` ("canceling statement due to statement timeout"). It is applied
282
+ with `SET LOCAL` semantics, i.e. for this query's transaction only; a borrowed `con` in autocommit mode
283
+ gets a short transaction around the query, and any other borrowed `con` gets its previous setting back.
284
+ Without it, the connection's own `statement_timeout` (default: none) applies.
285
+
286
+ Pass `con` to run on a connection you already hold (e.g. inside a transaction): it is
287
+ borrowed, never committed, closed or returned to a pool. Without it, a connection is taken
288
+ from `pool` or the pool registered via `set_default_pool()` and released afterwards.
289
+
290
+ Set `cancel` (an `asyncio.Event`, e.g. when the HTTP client disconnects; to stream a large result
291
+ from FastAPI see `pgdevkit.fastapi.PostgresJsonResponse`, which does that wiring itself) to abort a running
292
+ query on the server: `fetch_all` then raises `psycopg.errors.QueryCanceled`. If the awaiting
293
+ task is cancelled instead, the query is cancelled server-side too and `CancelledError`
294
+ propagates, so the query never keeps running unattended. `cancel` aborts whatever statement is
295
+ running on the connection, so don't use it on a `con` that concurrent tasks share. If the
296
+ cancel request itself can't be delivered, the connection is closed.
297
+
298
+ `query` must be a literal string, a sqlglot expression, a `psycopg.sql` composable or a
299
+ t-string -- never text built with f-strings/concatenation (a plain `str` fails type checking).
300
+ A sqlglot expression is only as safe as the strings it was built from (the builders parse plain
301
+ strings as SQL): pass user values as `exp.Placeholder` + `params`, never as literals/raw text.
302
+ In one, write a literal `%` as `%%` when you also pass `params`.
303
+ A t-string already carries its values, so it must not be combined with `params`.
304
+ """
305
+ if model is not None and row_mapper is not None:
306
+ raise TypeError("Pass either `model` or `row_mapper`, not both.")
307
+ if isinstance(query, Template) and params is not None:
308
+ raise TypeError("A t-string query carries its own values; don't pass `params` with it.")
309
+ _check_statement_timeout(statement_timeout)
310
+ rows = await _fetch_dicts(_render(query), params, con, pool, cancel, statement_timeout)
311
+ if model is not None:
312
+ return [model.model_validate(row) for row in rows]
313
+ if row_mapper is not None:
314
+ return [row_mapper(row) for row in rows]
315
+ return rows
@@ -0,0 +1,3 @@
1
+ from .json_response import PostgresJsonResponse
2
+
3
+ __all__ = ["PostgresJsonResponse"]