pgdevkit 0.14.2__tar.gz → 0.15.1__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 (153) hide show
  1. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/PKG-INFO +78 -8
  2. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/README.md +77 -7
  3. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/db/__init__.py +18 -1
  4. pgdevkit-0.15.1/pgdevkit/db/complex_types.py +375 -0
  5. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/db/connection.py +31 -7
  6. pgdevkit-0.15.1/pgdevkit/db/crud.py +433 -0
  7. pgdevkit-0.15.1/pgdevkit/db/fetch.py +587 -0
  8. pgdevkit-0.15.1/pgdevkit/db/transaction.py +39 -0
  9. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/fastapi/json_response.py +10 -6
  10. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/testdb/schema.py +5 -2
  11. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pyproject.toml +1 -1
  12. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/skills/pgdevkit/SKILL.md +64 -15
  13. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/skills/pgdevkit/references/dynamic-sql.md +1 -1
  14. pgdevkit-0.15.1/tests/db/test_complex_types.py +458 -0
  15. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/db/test_connection.py +110 -0
  16. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/db/test_crud.py +104 -6
  17. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/db/test_fetch.py +149 -1
  18. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/db/test_fetch_statement_timeout.py +46 -1
  19. pgdevkit-0.15.1/tests/db/test_transaction.py +106 -0
  20. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_pg_json_response.py +38 -0
  21. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_pg_json_statement_timeout.py +14 -0
  22. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_schema.py +3 -2
  23. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/uv.lock +1 -1
  24. pgdevkit-0.14.2/pgdevkit/db/complex_types.py +0 -233
  25. pgdevkit-0.14.2/pgdevkit/db/crud.py +0 -322
  26. pgdevkit-0.14.2/pgdevkit/db/fetch.py +0 -315
  27. pgdevkit-0.14.2/tests/db/test_complex_types.py +0 -138
  28. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/.github/workflows/auto-release.yml +0 -0
  29. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/.github/workflows/python-publish.yml +0 -0
  30. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/.github/workflows/python-test.yml +0 -0
  31. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/.gitignore +0 -0
  32. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/.python-version +0 -0
  33. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/docs/database-layout.md +0 -0
  34. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/__init__.py +0 -0
  35. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/areas.py +0 -0
  36. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/backends/__init__.py +0 -0
  37. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/backends/base.py +0 -0
  38. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/backends/mssql.py +0 -0
  39. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/backends/postgres.py +0 -0
  40. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/cli.py +0 -0
  41. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/connection.py +0 -0
  42. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/db/loader.py +0 -0
  43. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/db/model.py +0 -0
  44. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/db/mssql_crud.py +0 -0
  45. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/db/mssql_sql.py +0 -0
  46. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/dialect.py +0 -0
  47. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/diff.py +0 -0
  48. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/envtag.py +0 -0
  49. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/fastapi/__init__.py +0 -0
  50. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/fetch_missing.py +0 -0
  51. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/introspect.py +0 -0
  52. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/lakebase.py +0 -0
  53. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/migrate.py +0 -0
  54. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/migrate_mssql.py +0 -0
  55. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/models.py +0 -0
  56. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/mssql_introspect.py +0 -0
  57. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/parser.py +0 -0
  58. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/schemas.py +0 -0
  59. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/sql_text.py +0 -0
  60. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/stats.py +0 -0
  61. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/testdb/__init__.py +0 -0
  62. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/testdb/_docker.py +0 -0
  63. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/testdb/api.py +0 -0
  64. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/testdb/config.py +0 -0
  65. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/testdb/constants.py +0 -0
  66. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/testdb/container.py +0 -0
  67. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/testdb/mssql/__init__.py +0 -0
  68. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/testdb/mssql/api.py +0 -0
  69. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/testdb/mssql/constants.py +0 -0
  70. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/testdb/mssql/container.py +0 -0
  71. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/testdb/naming.py +0 -0
  72. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/testdb/query.py +0 -0
  73. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/skills/pgdevkit/references/temporal-tables.md +0 -0
  74. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/__init__.py +0 -0
  75. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/_asgi.py +0 -0
  76. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/_granian_app.py +0 -0
  77. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/conftest.py +0 -0
  78. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/db/__init__.py +0 -0
  79. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/db/test_loader.py +0 -0
  80. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/db/test_mssql_crud_live.py +0 -0
  81. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/db/test_mssql_crud_sql.py +0 -0
  82. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/fixtures/01_schema.sql +0 -0
  83. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/fixtures/02_types.sql +0 -0
  84. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/fixtures/03_tables.sql +0 -0
  85. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/fixtures/04_views.sql +0 -0
  86. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/fixtures/05_functions.sql +0 -0
  87. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/fixtures/06_indexes.sql +0 -0
  88. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_areas.py +0 -0
  89. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_cli_compare.py +0 -0
  90. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_compare.py +0 -0
  91. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_compare_mssql_live.py +0 -0
  92. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_connection.py +0 -0
  93. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_dialect.py +0 -0
  94. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_diff_mssql.py +0 -0
  95. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_envtag.py +0 -0
  96. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_fetch_missing.py +0 -0
  97. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_fetch_missing_cli.py +0 -0
  98. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_lakebase.py +0 -0
  99. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_migrate.py +0 -0
  100. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_migrate_areas.py +0 -0
  101. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_migrate_env.py +0 -0
  102. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_migrate_grants.py +0 -0
  103. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_migrate_mssql.py +0 -0
  104. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_migrate_mssql_live.py +0 -0
  105. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_migrate_schemas.py +0 -0
  106. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_mssql_introspect.py +0 -0
  107. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_parser_areas.py +0 -0
  108. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_parser_mssql.py +0 -0
  109. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_parser_schemas.py +0 -0
  110. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_pg_json_cancel.py +0 -0
  111. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_pg_json_granian.py +0 -0
  112. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_schemas.py +0 -0
  113. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_stats_cli.py +0 -0
  114. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_stats_mssql_live.py +0 -0
  115. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/__init__.py +0 -0
  116. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/conftest.py +0 -0
  117. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
  118. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/event.sql +0 -0
  119. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/event_kind.sql +0 -0
  120. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
  121. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
  122. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +0 -0
  123. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/widget.init.sql +0 -0
  124. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
  125. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
  126. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
  127. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
  128. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
  129. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
  130. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
  131. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
  132. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/permissions/grants.sql +0 -0
  133. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/schema/app.sql +0 -0
  134. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
  135. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
  136. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
  137. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
  138. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
  139. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_api.py +0 -0
  140. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_api_mssql.py +0 -0
  141. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_api_mssql_live.py +0 -0
  142. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_cli.py +0 -0
  143. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_config.py +0 -0
  144. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_config_mssql.py +0 -0
  145. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_constants.py +0 -0
  146. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_container.py +0 -0
  147. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_container_mssql.py +0 -0
  148. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_docker.py +0 -0
  149. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_mssql_constants.py +0 -0
  150. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_naming.py +0 -0
  151. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_query.py +0 -0
  152. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_schema_filtering.py +0 -0
  153. {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_schema_mssql.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pgdevkit
3
- Version: 0.14.2
3
+ Version: 0.15.1
4
4
  Summary: A helper for developing with Postgres
5
5
  Requires-Python: >=3.14
6
6
  Requires-Dist: docker>=7.1.0
@@ -430,22 +430,69 @@ Install with the `db` extra: `pip install pgdevkit[db]`.
430
430
  password — same host-based auto-detection as `pgdb compare`'s
431
431
  `--entra-user`. For Lakebase hosts, also set the
432
432
  `{env_prefix}DATABRICKS_WORKSPACE_HOST` and `{env_prefix}DATABRICKS_INSTANCE`
433
- env vars.
433
+ env vars. Values (password, `dsn_params`) are quoted properly, so spaces, backslashes and quotes are fine.
434
+ `min_size`, `timeout` (how long to wait for a free connection before `PoolTimeout`), `configure` (async hook run on
435
+ every new connection, e.g. to set session timeouts or `application_name`) and `check` are passed through to the
436
+ psycopg pool; `max_size` defaults to 40 and `min_size` to 4 (or `max_size` if smaller).
434
437
  - **`fetch_all(query, params=None, *, model=None, row_mapper=None, con=None, pool=None, cancel=None, statement_timeout=None)`**
435
438
  — run one custom query and get every row back as dicts, as validated
436
439
  Pydantic `model` instances, or through a `row_mapper`. Pass `con` to run on a
437
440
  connection you already hold (it is borrowed: never committed, closed or
438
441
  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
442
+ with `set_default_pool()` (a `ConnectionSource` such as `PgPool`, or a plain callable returning an
443
+ async connection context manager). Set the `cancel` `asyncio.Event` to abort the query on the server
440
444
  (`QueryCanceled`; to stream a result as JSON from FastAPI see `PostgresJsonResponse`, which watches for the
441
445
  disconnect itself); cancelling the awaiting task cancels it there too, and
442
446
  `CancelledError` propagates as usual. `statement_timeout=<seconds>` lets Postgres abort the query after that
443
447
  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
448
+ semantics, so it never outlives the call (a borrowed `con` gets its previous setting back); see
449
+ [Statement timeout](#statement-timeout) for what it does to a timeout the connection already has. `query` must be a literal string, a sqlglot
445
450
  expression, a `psycopg.sql` composable or a t-string — never an f-string (a sqlglot
446
451
  expression is only as safe as the strings it was built from: pass user values as
447
- `exp.Placeholder` + `params`).
448
- - **CRUD functions** — `pg_retrieve`, `pg_retrieve_many`, `pg_insert`,
452
+ `exp.Placeholder` + `params`). A literal `%` in a sqlglot expression (`LIKE 'a%'`, the modulo operator) works with
453
+ or without `params`: it is escaped for psycopg when `params` is given (plain strings and `psycopg.sql` composables
454
+ are passed on as is, so there you write `%%` yourself when you pass `params`).
455
+ - **`fetch_one`, `fetch_scalar`, `execute`** — siblings of `fetch_all` with the same `con`/`pool`/`cancel`/
456
+ `statement_timeout` keywords and the same query types (including t-strings, which take no `params`), so there is no
457
+ need for hand-written cursor code. `fetch_one(query, params, *, model=None, row_mapper=None)` returns the first row
458
+ (dict, `model` or `row_mapper` result) or `None`, `fetch_scalar(query, params)` the first column of the first row or
459
+ `None` (`await fetch_scalar("SELECT name FROM widget WHERE id = %(id)s", {"id": 1})`), and `execute(query, params)`
460
+ the row count of a write without `RETURNING` (`-1` for statements without one, like DDL). Without `con`, `execute`
461
+ runs on a pooled connection that commits when it returns (true for `PgPool`/`psycopg_pool`; a custom connection
462
+ source decides for itself); on your `con` nothing is committed.
463
+ - **`readonly_transaction(con)`** — async context manager that runs its block in a read-only transaction:
464
+ `async with pool.connection() as con, readonly_transaction(con): await fetch_all(sql, con=con)`.
465
+ Postgres rejects writes with `ReadOnlySqlTransaction`; the transaction is committed when the block ends normally
466
+ and rolled back on error, and `read_only` is **always** put back afterwards (normally to `None`), so a connection
467
+ that goes back to a shared pool is never left read-only. `con` must not be inside a transaction already (a fresh
468
+ pooled connection is fine). **It is a safety net, not a sandbox for untrusted SQL:** text without bound parameters
469
+ uses the simple query protocol, where `COMMIT; INSERT ...` ends the read-only transaction, TEMP tables stay
470
+ writable and `SET` statements stick to the connection. For LLM-generated SQL also connect as a role that only has
471
+ `SELECT` privileges, and consider `statement_timeout=at_most(...)`.
472
+ - **`ComplexHelper`** — handles composite, enum and JSONB columns for the CRUD functions: pass
473
+ `complex_helper=ComplexHelper(con)` (optionally with `normalizers`), or `complex_helper="auto"` for a default one,
474
+ to any `pg_*` function that takes the argument. **Nothing is registered on the connection:** psycopg's type
475
+ registration is global to a connection (and so to the next user of a pooled one: a plain `SELECT` of an enum would
476
+ return psycopg's auto-numbered `Enum` member instead of the label, #54). The helpers therefore register the
477
+ enum/composite adapters only on the *cursor* that runs a write statement (`pg_insert`, `pg_update`/`pg_update_dict`,
478
+ `pg_upsert`/`pg_upsert_dict`, the `*_many` variants), and only for the columns where a dict/list value had to
479
+ be converted. Reads (`pg_retrieve*`) select such columns as `to_jsonb(...)` and need no registration. After any
480
+ `pg_*` call the connection reads enums as label strings and composites as text (e.g. `'(1,2)'`), as before the call.
481
+ `RETURNING` rows of `pg_insert`/`pg_update_dict`/`pg_upsert_dict` (and so `pg_update`/`pg_upsert`) select such
482
+ columns as `to_jsonb(...)` too, so with a helper they are plain Python values (a composite as a dict, an enum as its
483
+ label) whichever columns were written; without one it is a plain `RETURNING *`.
484
+ If you call `recursive_convert` yourself, register the types on the cursor that sends the values, with the `info`
485
+ from `load_all_complex_types`/`load_complex_type`: `await helper.register_on(cur, info)`, then execute the converted
486
+ value on that same `cur` (passing the connection instead would restore psycopg's global behaviour).
487
+ What it learns from the catalog (types and column types) is cached per connection, so a helper created per call
488
+ queries the catalog only the first time a connection is used (the SELECT/INSERT itself is then the only query). The cache cannot see later DDL, nor
489
+ DDL that was rolled back after it had been cached: call `helper.clear_cache()` after `CREATE TYPE`/`ALTER TYPE`/column
490
+ changes or rollbacks of those (typical for tests that create types inside a rolled-back transaction on a reused
491
+ connection). `ComplexHelper(con, cache=False)` gives the helper a private cache and the pre-cache behaviour.
492
+ - **CRUD functions** — `pg_retrieve`, `pg_retrieve_many` (equality `filters`, plus optional `where=` as a t-string or
493
+ a `psycopg.sql` composable with `params=`, `order_by=` as a column name, a *list* of names or
494
+ `(name, "asc"|"desc")` pairs, or a composable, and `limit=`; prefer a t-string, since in a composable a literal `%`
495
+ must be written `%%` whenever filters or params are present), `pg_insert`,
449
496
  `pg_insert_many`, `pg_update`, `pg_update_dict`, `pg_upsert`,
450
497
  `pg_upsert_dict`, `pg_upsert_many`, `pg_upsert_many_dict`, `pg_delete`,
451
498
  `pg_delete_dict` — typed (`TableModel`-based) or dict-based CRUD against a
@@ -544,7 +591,9 @@ Postgres or for the client.
544
591
  Don't use it afterwards, and don't count on earlier writes in that transaction.
545
592
 
546
593
  Connections are held while the client reads, so a client that reads very slowly pins one (`statement_timeout` does not
547
- 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
594
+ help, see below). With `PgPool` (40 connections by default; `max_size=`) that means a few slow readers, or many parallel streams of
595
+ big results, can use up the pool and make every other request wait for a connection. Enforce a write timeout in the
596
+ reverse proxy and size the pool accordingly. Disconnect behaviour can only be tested over a real socket
548
597
  (`httpx.ASGITransport` never sends `http.disconnect`); see `tests/test_pg_json_granian.py`.
549
598
 
550
599
  ### Statement timeout
@@ -552,9 +601,15 @@ help, see below). Enforce a write timeout in the reverse proxy and size the pool
552
601
  `statement_timeout=<seconds>` (optional, also on `fetch_all`) lets **Postgres** abort the query after that long. It
553
602
  is set with `SET LOCAL` semantics for this one query: transaction-scoped, so it can't leak into the pool and works
554
603
  behind PgBouncer's transaction pooling, and a `con` you passed gets its previous setting back. Before the first byte
555
- the client gets a `504 {"error": "Query timed out"}`; after that the response is cut short like any other error. It
604
+ the client gets a `504 {"error": "Query timed out"}` (the key is `error_key`, see Errors); after that the response is cut short like any other error. It
556
605
  needs a connection that is not in autocommit mode. Without it, the connection's own `statement_timeout` applies.
557
606
 
607
+ A plain value *replaces* whatever timeout the connection already has, in both directions: with a database-level
608
+ safety net of 600 s, a per-call `statement_timeout=120` shortens it for that call, but `statement_timeout=900` loosens
609
+ it. To make a per-call value only ever tighten it, wrap it: `statement_timeout=at_most(120)`
610
+ (`from pgdevkit.db import at_most`) uses `min(current, 120)`, where a current timeout of 0 (none) counts as unlimited.
611
+ `at_most` works anywhere `statement_timeout` does, `PostgresJsonResponse` included.
612
+
558
613
  It limits *query execution*, nothing else: Postgres defers a cancel while it is blocked writing to a client that
559
614
  has stopped reading, so a stalled reader keeps its connection regardless (a backend was observed staying `active`
560
615
  in `ClientWrite` well past a 1 s timeout). For that, use a write timeout in the reverse proxy.
@@ -570,12 +625,27 @@ class AppJsonResponse(PostgresJsonResponse):
570
625
  expose_errors = IS_DEV
571
626
  ```
572
627
 
628
+ FastAPI's own error handlers answer with `{"detail": ...}` instead. If a frontend already handles that shape, set
629
+ `error_key = "detail"` in the subclass and these responses use it too.
630
+
573
631
  After the first byte the status line is gone: the error is logged and re-raised, and the response ends **without
574
632
  the closing `]`**, so clients fail to parse it rather than accepting a silently shortened array. (Granian, which our
575
633
  apps run on, ends such a response cleanly with the truncated body.)
576
634
 
577
635
  ### Notes
578
636
 
637
+ Timestamps: Postgres' `row_to_json` writes a `timestamptz` as `2026-01-01T10:00:00+00:00` (with the session time
638
+ zone's offset, e.g. `+01:00`), where Pydantic/FastAPI serialize UTC values as `...Z`. Both are valid ISO-8601 and
639
+ parse to the same instant, but code that compares or displays the raw strings sees a difference; normalize in the
640
+ frontend, or in the query (`to_char(...)`).
641
+
642
+ `NaN`/`Infinity`: JSON has no such numbers, so `row_to_json` writes a `float`/`numeric` NaN or Infinity as the
643
+ *string* `"NaN"`/`"Infinity"`. A frontend expecting numbers must handle those strings, or the query should map them
644
+ (`nullif(x, 'NaN')`).
645
+
646
+ Behind Starlette's `BaseHTTPMiddleware` a failure after the first byte has been reported to end as a clean `200` with a
647
+ truncated body: the missing closing `]` is then the only signal (clients fail to parse it), the status cannot carry it.
648
+
579
649
  It does no authorization: scope the query yourself. The first byte is sent once `batch_size` rows (or the whole
580
650
  result) are available, so lower `batch_size` for earlier output; Postgres itself also sends rows in 8 kB buffers,
581
651
  so a slow query with tiny rows delivers late either way.
@@ -405,22 +405,69 @@ Install with the `db` extra: `pip install pgdevkit[db]`.
405
405
  password — same host-based auto-detection as `pgdb compare`'s
406
406
  `--entra-user`. For Lakebase hosts, also set the
407
407
  `{env_prefix}DATABRICKS_WORKSPACE_HOST` and `{env_prefix}DATABRICKS_INSTANCE`
408
- env vars.
408
+ env vars. Values (password, `dsn_params`) are quoted properly, so spaces, backslashes and quotes are fine.
409
+ `min_size`, `timeout` (how long to wait for a free connection before `PoolTimeout`), `configure` (async hook run on
410
+ every new connection, e.g. to set session timeouts or `application_name`) and `check` are passed through to the
411
+ psycopg pool; `max_size` defaults to 40 and `min_size` to 4 (or `max_size` if smaller).
409
412
  - **`fetch_all(query, params=None, *, model=None, row_mapper=None, con=None, pool=None, cancel=None, statement_timeout=None)`**
410
413
  — run one custom query and get every row back as dicts, as validated
411
414
  Pydantic `model` instances, or through a `row_mapper`. Pass `con` to run on a
412
415
  connection you already hold (it is borrowed: never committed, closed or
413
416
  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
417
+ with `set_default_pool()` (a `ConnectionSource` such as `PgPool`, or a plain callable returning an
418
+ async connection context manager). Set the `cancel` `asyncio.Event` to abort the query on the server
415
419
  (`QueryCanceled`; to stream a result as JSON from FastAPI see `PostgresJsonResponse`, which watches for the
416
420
  disconnect itself); cancelling the awaiting task cancels it there too, and
417
421
  `CancelledError` propagates as usual. `statement_timeout=<seconds>` lets Postgres abort the query after that
418
422
  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
423
+ semantics, so it never outlives the call (a borrowed `con` gets its previous setting back); see
424
+ [Statement timeout](#statement-timeout) for what it does to a timeout the connection already has. `query` must be a literal string, a sqlglot
420
425
  expression, a `psycopg.sql` composable or a t-string — never an f-string (a sqlglot
421
426
  expression is only as safe as the strings it was built from: pass user values as
422
- `exp.Placeholder` + `params`).
423
- - **CRUD functions** — `pg_retrieve`, `pg_retrieve_many`, `pg_insert`,
427
+ `exp.Placeholder` + `params`). A literal `%` in a sqlglot expression (`LIKE 'a%'`, the modulo operator) works with
428
+ or without `params`: it is escaped for psycopg when `params` is given (plain strings and `psycopg.sql` composables
429
+ are passed on as is, so there you write `%%` yourself when you pass `params`).
430
+ - **`fetch_one`, `fetch_scalar`, `execute`** — siblings of `fetch_all` with the same `con`/`pool`/`cancel`/
431
+ `statement_timeout` keywords and the same query types (including t-strings, which take no `params`), so there is no
432
+ need for hand-written cursor code. `fetch_one(query, params, *, model=None, row_mapper=None)` returns the first row
433
+ (dict, `model` or `row_mapper` result) or `None`, `fetch_scalar(query, params)` the first column of the first row or
434
+ `None` (`await fetch_scalar("SELECT name FROM widget WHERE id = %(id)s", {"id": 1})`), and `execute(query, params)`
435
+ the row count of a write without `RETURNING` (`-1` for statements without one, like DDL). Without `con`, `execute`
436
+ runs on a pooled connection that commits when it returns (true for `PgPool`/`psycopg_pool`; a custom connection
437
+ source decides for itself); on your `con` nothing is committed.
438
+ - **`readonly_transaction(con)`** — async context manager that runs its block in a read-only transaction:
439
+ `async with pool.connection() as con, readonly_transaction(con): await fetch_all(sql, con=con)`.
440
+ Postgres rejects writes with `ReadOnlySqlTransaction`; the transaction is committed when the block ends normally
441
+ and rolled back on error, and `read_only` is **always** put back afterwards (normally to `None`), so a connection
442
+ that goes back to a shared pool is never left read-only. `con` must not be inside a transaction already (a fresh
443
+ pooled connection is fine). **It is a safety net, not a sandbox for untrusted SQL:** text without bound parameters
444
+ uses the simple query protocol, where `COMMIT; INSERT ...` ends the read-only transaction, TEMP tables stay
445
+ writable and `SET` statements stick to the connection. For LLM-generated SQL also connect as a role that only has
446
+ `SELECT` privileges, and consider `statement_timeout=at_most(...)`.
447
+ - **`ComplexHelper`** — handles composite, enum and JSONB columns for the CRUD functions: pass
448
+ `complex_helper=ComplexHelper(con)` (optionally with `normalizers`), or `complex_helper="auto"` for a default one,
449
+ to any `pg_*` function that takes the argument. **Nothing is registered on the connection:** psycopg's type
450
+ registration is global to a connection (and so to the next user of a pooled one: a plain `SELECT` of an enum would
451
+ return psycopg's auto-numbered `Enum` member instead of the label, #54). The helpers therefore register the
452
+ enum/composite adapters only on the *cursor* that runs a write statement (`pg_insert`, `pg_update`/`pg_update_dict`,
453
+ `pg_upsert`/`pg_upsert_dict`, the `*_many` variants), and only for the columns where a dict/list value had to
454
+ be converted. Reads (`pg_retrieve*`) select such columns as `to_jsonb(...)` and need no registration. After any
455
+ `pg_*` call the connection reads enums as label strings and composites as text (e.g. `'(1,2)'`), as before the call.
456
+ `RETURNING` rows of `pg_insert`/`pg_update_dict`/`pg_upsert_dict` (and so `pg_update`/`pg_upsert`) select such
457
+ columns as `to_jsonb(...)` too, so with a helper they are plain Python values (a composite as a dict, an enum as its
458
+ label) whichever columns were written; without one it is a plain `RETURNING *`.
459
+ If you call `recursive_convert` yourself, register the types on the cursor that sends the values, with the `info`
460
+ from `load_all_complex_types`/`load_complex_type`: `await helper.register_on(cur, info)`, then execute the converted
461
+ value on that same `cur` (passing the connection instead would restore psycopg's global behaviour).
462
+ What it learns from the catalog (types and column types) is cached per connection, so a helper created per call
463
+ queries the catalog only the first time a connection is used (the SELECT/INSERT itself is then the only query). The cache cannot see later DDL, nor
464
+ DDL that was rolled back after it had been cached: call `helper.clear_cache()` after `CREATE TYPE`/`ALTER TYPE`/column
465
+ changes or rollbacks of those (typical for tests that create types inside a rolled-back transaction on a reused
466
+ connection). `ComplexHelper(con, cache=False)` gives the helper a private cache and the pre-cache behaviour.
467
+ - **CRUD functions** — `pg_retrieve`, `pg_retrieve_many` (equality `filters`, plus optional `where=` as a t-string or
468
+ a `psycopg.sql` composable with `params=`, `order_by=` as a column name, a *list* of names or
469
+ `(name, "asc"|"desc")` pairs, or a composable, and `limit=`; prefer a t-string, since in a composable a literal `%`
470
+ must be written `%%` whenever filters or params are present), `pg_insert`,
424
471
  `pg_insert_many`, `pg_update`, `pg_update_dict`, `pg_upsert`,
425
472
  `pg_upsert_dict`, `pg_upsert_many`, `pg_upsert_many_dict`, `pg_delete`,
426
473
  `pg_delete_dict` — typed (`TableModel`-based) or dict-based CRUD against a
@@ -519,7 +566,9 @@ Postgres or for the client.
519
566
  Don't use it afterwards, and don't count on earlier writes in that transaction.
520
567
 
521
568
  Connections are held while the client reads, so a client that reads very slowly pins one (`statement_timeout` does not
522
- 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
569
+ help, see below). With `PgPool` (40 connections by default; `max_size=`) that means a few slow readers, or many parallel streams of
570
+ big results, can use up the pool and make every other request wait for a connection. Enforce a write timeout in the
571
+ reverse proxy and size the pool accordingly. Disconnect behaviour can only be tested over a real socket
523
572
  (`httpx.ASGITransport` never sends `http.disconnect`); see `tests/test_pg_json_granian.py`.
524
573
 
525
574
  ### Statement timeout
@@ -527,9 +576,15 @@ help, see below). Enforce a write timeout in the reverse proxy and size the pool
527
576
  `statement_timeout=<seconds>` (optional, also on `fetch_all`) lets **Postgres** abort the query after that long. It
528
577
  is set with `SET LOCAL` semantics for this one query: transaction-scoped, so it can't leak into the pool and works
529
578
  behind PgBouncer's transaction pooling, and a `con` you passed gets its previous setting back. Before the first byte
530
- the client gets a `504 {"error": "Query timed out"}`; after that the response is cut short like any other error. It
579
+ the client gets a `504 {"error": "Query timed out"}` (the key is `error_key`, see Errors); after that the response is cut short like any other error. It
531
580
  needs a connection that is not in autocommit mode. Without it, the connection's own `statement_timeout` applies.
532
581
 
582
+ A plain value *replaces* whatever timeout the connection already has, in both directions: with a database-level
583
+ safety net of 600 s, a per-call `statement_timeout=120` shortens it for that call, but `statement_timeout=900` loosens
584
+ it. To make a per-call value only ever tighten it, wrap it: `statement_timeout=at_most(120)`
585
+ (`from pgdevkit.db import at_most`) uses `min(current, 120)`, where a current timeout of 0 (none) counts as unlimited.
586
+ `at_most` works anywhere `statement_timeout` does, `PostgresJsonResponse` included.
587
+
533
588
  It limits *query execution*, nothing else: Postgres defers a cancel while it is blocked writing to a client that
534
589
  has stopped reading, so a stalled reader keeps its connection regardless (a backend was observed staying `active`
535
590
  in `ClientWrite` well past a 1 s timeout). For that, use a write timeout in the reverse proxy.
@@ -545,12 +600,27 @@ class AppJsonResponse(PostgresJsonResponse):
545
600
  expose_errors = IS_DEV
546
601
  ```
547
602
 
603
+ FastAPI's own error handlers answer with `{"detail": ...}` instead. If a frontend already handles that shape, set
604
+ `error_key = "detail"` in the subclass and these responses use it too.
605
+
548
606
  After the first byte the status line is gone: the error is logged and re-raised, and the response ends **without
549
607
  the closing `]`**, so clients fail to parse it rather than accepting a silently shortened array. (Granian, which our
550
608
  apps run on, ends such a response cleanly with the truncated body.)
551
609
 
552
610
  ### Notes
553
611
 
612
+ Timestamps: Postgres' `row_to_json` writes a `timestamptz` as `2026-01-01T10:00:00+00:00` (with the session time
613
+ zone's offset, e.g. `+01:00`), where Pydantic/FastAPI serialize UTC values as `...Z`. Both are valid ISO-8601 and
614
+ parse to the same instant, but code that compares or displays the raw strings sees a difference; normalize in the
615
+ frontend, or in the query (`to_char(...)`).
616
+
617
+ `NaN`/`Infinity`: JSON has no such numbers, so `row_to_json` writes a `float`/`numeric` NaN or Infinity as the
618
+ *string* `"NaN"`/`"Infinity"`. A frontend expecting numbers must handle those strings, or the query should map them
619
+ (`nullif(x, 'NaN')`).
620
+
621
+ Behind Starlette's `BaseHTTPMiddleware` a failure after the first byte has been reported to end as a clean `200` with a
622
+ truncated body: the missing closing `]` is then the only signal (clients fail to parse it), the status cannot carry it.
623
+
554
624
  It does no authorization: scope the query yourself. The first byte is sent once `batch_size` rows (or the whole
555
625
  result) are available, so lower `batch_size` for earlier output; Postgres itself also sends rows in 8 kB buffers,
556
626
  so a slow query with tiny rows delivers late either way.
@@ -16,19 +16,35 @@ 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
+ from .fetch import (
20
+ ConnectionSource,
21
+ PoolLike,
22
+ SqlQuery,
23
+ at_most,
24
+ execute,
25
+ fetch_all,
26
+ fetch_one,
27
+ fetch_scalar,
28
+ set_default_pool,
29
+ )
20
30
  from .loader import SqlLoader
21
31
  from .model import PostgresTableModel, TableModel
32
+ from .transaction import readonly_transaction
22
33
 
23
34
  __all__ = [
24
35
  "ComplexHelper",
25
36
  "ConnectionSource",
26
37
  "PgPool",
38
+ "PoolLike",
27
39
  "PostgresTableModel",
28
40
  "SqlLoader",
29
41
  "SqlQuery",
30
42
  "TableModel",
43
+ "at_most",
44
+ "execute",
31
45
  "fetch_all",
46
+ "fetch_one",
47
+ "fetch_scalar",
32
48
  "pg_delete",
33
49
  "pg_delete_dict",
34
50
  "pg_insert",
@@ -41,5 +57,6 @@ __all__ = [
41
57
  "pg_upsert_dict",
42
58
  "pg_upsert_many",
43
59
  "pg_upsert_many_dict",
60
+ "readonly_transaction",
44
61
  "set_default_pool",
45
62
  ]