pgdevkit 0.14.2__tar.gz → 0.15.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 (151) hide show
  1. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/PKG-INFO +66 -8
  2. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/README.md +65 -7
  3. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/db/__init__.py +18 -1
  4. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/db/complex_types.py +91 -16
  5. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/db/connection.py +31 -7
  6. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/db/crud.py +124 -38
  7. pgdevkit-0.15.0/pgdevkit/db/fetch.py +587 -0
  8. pgdevkit-0.15.0/pgdevkit/db/transaction.py +39 -0
  9. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/fastapi/json_response.py +10 -6
  10. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pyproject.toml +1 -1
  11. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/skills/pgdevkit/SKILL.md +51 -14
  12. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/skills/pgdevkit/references/dynamic-sql.md +1 -1
  13. pgdevkit-0.15.0/tests/db/test_complex_types.py +289 -0
  14. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/db/test_connection.py +110 -0
  15. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/db/test_crud.py +69 -0
  16. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/db/test_fetch.py +149 -1
  17. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/db/test_fetch_statement_timeout.py +46 -1
  18. pgdevkit-0.15.0/tests/db/test_transaction.py +106 -0
  19. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_pg_json_response.py +38 -0
  20. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_pg_json_statement_timeout.py +14 -0
  21. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/uv.lock +1 -1
  22. pgdevkit-0.14.2/pgdevkit/db/fetch.py +0 -315
  23. pgdevkit-0.14.2/tests/db/test_complex_types.py +0 -138
  24. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/.github/workflows/auto-release.yml +0 -0
  25. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/.github/workflows/python-publish.yml +0 -0
  26. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/.github/workflows/python-test.yml +0 -0
  27. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/.gitignore +0 -0
  28. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/.python-version +0 -0
  29. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/docs/database-layout.md +0 -0
  30. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/__init__.py +0 -0
  31. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/areas.py +0 -0
  32. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/backends/__init__.py +0 -0
  33. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/backends/base.py +0 -0
  34. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/backends/mssql.py +0 -0
  35. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/backends/postgres.py +0 -0
  36. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/cli.py +0 -0
  37. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/connection.py +0 -0
  38. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/db/loader.py +0 -0
  39. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/db/model.py +0 -0
  40. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/db/mssql_crud.py +0 -0
  41. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/db/mssql_sql.py +0 -0
  42. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/dialect.py +0 -0
  43. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/diff.py +0 -0
  44. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/envtag.py +0 -0
  45. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/fastapi/__init__.py +0 -0
  46. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/fetch_missing.py +0 -0
  47. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/introspect.py +0 -0
  48. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/lakebase.py +0 -0
  49. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/migrate.py +0 -0
  50. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/migrate_mssql.py +0 -0
  51. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/models.py +0 -0
  52. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/mssql_introspect.py +0 -0
  53. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/parser.py +0 -0
  54. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/schemas.py +0 -0
  55. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/sql_text.py +0 -0
  56. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/stats.py +0 -0
  57. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/testdb/__init__.py +0 -0
  58. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/testdb/_docker.py +0 -0
  59. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/testdb/api.py +0 -0
  60. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/testdb/config.py +0 -0
  61. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/testdb/constants.py +0 -0
  62. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/testdb/container.py +0 -0
  63. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/testdb/mssql/__init__.py +0 -0
  64. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/testdb/mssql/api.py +0 -0
  65. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/testdb/mssql/constants.py +0 -0
  66. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/testdb/mssql/container.py +0 -0
  67. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/testdb/naming.py +0 -0
  68. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/testdb/query.py +0 -0
  69. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/testdb/schema.py +0 -0
  70. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/skills/pgdevkit/references/temporal-tables.md +0 -0
  71. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/__init__.py +0 -0
  72. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/_asgi.py +0 -0
  73. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/_granian_app.py +0 -0
  74. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/conftest.py +0 -0
  75. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/db/__init__.py +0 -0
  76. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/db/test_loader.py +0 -0
  77. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/db/test_mssql_crud_live.py +0 -0
  78. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/db/test_mssql_crud_sql.py +0 -0
  79. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/fixtures/01_schema.sql +0 -0
  80. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/fixtures/02_types.sql +0 -0
  81. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/fixtures/03_tables.sql +0 -0
  82. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/fixtures/04_views.sql +0 -0
  83. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/fixtures/05_functions.sql +0 -0
  84. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/fixtures/06_indexes.sql +0 -0
  85. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_areas.py +0 -0
  86. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_cli_compare.py +0 -0
  87. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_compare.py +0 -0
  88. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_compare_mssql_live.py +0 -0
  89. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_connection.py +0 -0
  90. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_dialect.py +0 -0
  91. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_diff_mssql.py +0 -0
  92. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_envtag.py +0 -0
  93. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_fetch_missing.py +0 -0
  94. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_fetch_missing_cli.py +0 -0
  95. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_lakebase.py +0 -0
  96. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_migrate.py +0 -0
  97. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_migrate_areas.py +0 -0
  98. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_migrate_env.py +0 -0
  99. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_migrate_grants.py +0 -0
  100. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_migrate_mssql.py +0 -0
  101. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_migrate_mssql_live.py +0 -0
  102. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_migrate_schemas.py +0 -0
  103. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_mssql_introspect.py +0 -0
  104. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_parser_areas.py +0 -0
  105. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_parser_mssql.py +0 -0
  106. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_parser_schemas.py +0 -0
  107. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_pg_json_cancel.py +0 -0
  108. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_pg_json_granian.py +0 -0
  109. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_schemas.py +0 -0
  110. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_stats_cli.py +0 -0
  111. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_stats_mssql_live.py +0 -0
  112. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/__init__.py +0 -0
  113. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/conftest.py +0 -0
  114. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
  115. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/tables/event.sql +0 -0
  116. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/tables/event_kind.sql +0 -0
  117. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
  118. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
  119. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +0 -0
  120. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/tables/widget.init.sql +0 -0
  121. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
  122. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
  123. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
  124. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
  125. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
  126. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
  127. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
  128. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
  129. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/permissions/grants.sql +0 -0
  130. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/schema/app.sql +0 -0
  131. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
  132. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
  133. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
  134. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
  135. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
  136. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_api.py +0 -0
  137. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_api_mssql.py +0 -0
  138. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_api_mssql_live.py +0 -0
  139. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_cli.py +0 -0
  140. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_config.py +0 -0
  141. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_config_mssql.py +0 -0
  142. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_constants.py +0 -0
  143. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_container.py +0 -0
  144. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_container_mssql.py +0 -0
  145. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_docker.py +0 -0
  146. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_mssql_constants.py +0 -0
  147. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_naming.py +0 -0
  148. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_query.py +0 -0
  149. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_schema.py +0 -0
  150. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_schema_filtering.py +0 -0
  151. {pgdevkit-0.14.2 → pgdevkit-0.15.0}/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.0
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,57 @@ 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. What it learns from the catalog (types, column types, adapter
475
+ registrations) is cached per connection, so a helper created per call queries the catalog only the first time a
476
+ connection is used (the SELECT/INSERT itself is then the only query). The cache cannot see later DDL, nor DDL that
477
+ was rolled back after it had been cached: call `helper.clear_cache()` after `CREATE TYPE`/`ALTER TYPE`/column
478
+ changes or rollbacks of those (typical for tests that create types inside a rolled-back transaction on a reused
479
+ connection). `ComplexHelper(con, cache=False)` gives the helper a private cache and the pre-cache behaviour.
480
+ - **CRUD functions** — `pg_retrieve`, `pg_retrieve_many` (equality `filters`, plus optional `where=` as a t-string or
481
+ a `psycopg.sql` composable with `params=`, `order_by=` as a column name, a *list* of names or
482
+ `(name, "asc"|"desc")` pairs, or a composable, and `limit=`; prefer a t-string, since in a composable a literal `%`
483
+ must be written `%%` whenever filters or params are present), `pg_insert`,
449
484
  `pg_insert_many`, `pg_update`, `pg_update_dict`, `pg_upsert`,
450
485
  `pg_upsert_dict`, `pg_upsert_many`, `pg_upsert_many_dict`, `pg_delete`,
451
486
  `pg_delete_dict` — typed (`TableModel`-based) or dict-based CRUD against a
@@ -544,7 +579,9 @@ Postgres or for the client.
544
579
  Don't use it afterwards, and don't count on earlier writes in that transaction.
545
580
 
546
581
  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
582
+ help, see below). With `PgPool` (40 connections by default; `max_size=`) that means a few slow readers, or many parallel streams of
583
+ big results, can use up the pool and make every other request wait for a connection. Enforce a write timeout in the
584
+ reverse proxy and size the pool accordingly. Disconnect behaviour can only be tested over a real socket
548
585
  (`httpx.ASGITransport` never sends `http.disconnect`); see `tests/test_pg_json_granian.py`.
549
586
 
550
587
  ### Statement timeout
@@ -552,9 +589,15 @@ help, see below). Enforce a write timeout in the reverse proxy and size the pool
552
589
  `statement_timeout=<seconds>` (optional, also on `fetch_all`) lets **Postgres** abort the query after that long. It
553
590
  is set with `SET LOCAL` semantics for this one query: transaction-scoped, so it can't leak into the pool and works
554
591
  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
592
+ 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
593
  needs a connection that is not in autocommit mode. Without it, the connection's own `statement_timeout` applies.
557
594
 
595
+ A plain value *replaces* whatever timeout the connection already has, in both directions: with a database-level
596
+ safety net of 600 s, a per-call `statement_timeout=120` shortens it for that call, but `statement_timeout=900` loosens
597
+ it. To make a per-call value only ever tighten it, wrap it: `statement_timeout=at_most(120)`
598
+ (`from pgdevkit.db import at_most`) uses `min(current, 120)`, where a current timeout of 0 (none) counts as unlimited.
599
+ `at_most` works anywhere `statement_timeout` does, `PostgresJsonResponse` included.
600
+
558
601
  It limits *query execution*, nothing else: Postgres defers a cancel while it is blocked writing to a client that
559
602
  has stopped reading, so a stalled reader keeps its connection regardless (a backend was observed staying `active`
560
603
  in `ClientWrite` well past a 1 s timeout). For that, use a write timeout in the reverse proxy.
@@ -570,12 +613,27 @@ class AppJsonResponse(PostgresJsonResponse):
570
613
  expose_errors = IS_DEV
571
614
  ```
572
615
 
616
+ FastAPI's own error handlers answer with `{"detail": ...}` instead. If a frontend already handles that shape, set
617
+ `error_key = "detail"` in the subclass and these responses use it too.
618
+
573
619
  After the first byte the status line is gone: the error is logged and re-raised, and the response ends **without
574
620
  the closing `]`**, so clients fail to parse it rather than accepting a silently shortened array. (Granian, which our
575
621
  apps run on, ends such a response cleanly with the truncated body.)
576
622
 
577
623
  ### Notes
578
624
 
625
+ Timestamps: Postgres' `row_to_json` writes a `timestamptz` as `2026-01-01T10:00:00+00:00` (with the session time
626
+ zone's offset, e.g. `+01:00`), where Pydantic/FastAPI serialize UTC values as `...Z`. Both are valid ISO-8601 and
627
+ parse to the same instant, but code that compares or displays the raw strings sees a difference; normalize in the
628
+ frontend, or in the query (`to_char(...)`).
629
+
630
+ `NaN`/`Infinity`: JSON has no such numbers, so `row_to_json` writes a `float`/`numeric` NaN or Infinity as the
631
+ *string* `"NaN"`/`"Infinity"`. A frontend expecting numbers must handle those strings, or the query should map them
632
+ (`nullif(x, 'NaN')`).
633
+
634
+ Behind Starlette's `BaseHTTPMiddleware` a failure after the first byte has been reported to end as a clean `200` with a
635
+ truncated body: the missing closing `]` is then the only signal (clients fail to parse it), the status cannot carry it.
636
+
579
637
  It does no authorization: scope the query yourself. The first byte is sent once `batch_size` rows (or the whole
580
638
  result) are available, so lower `batch_size` for earlier output; Postgres itself also sends rows in 8 kB buffers,
581
639
  so a slow query with tiny rows delivers late either way.
@@ -405,22 +405,57 @@ 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. What it learns from the catalog (types, column types, adapter
450
+ registrations) is cached per connection, so a helper created per call queries the catalog only the first time a
451
+ connection is used (the SELECT/INSERT itself is then the only query). The cache cannot see later DDL, nor DDL that
452
+ was rolled back after it had been cached: call `helper.clear_cache()` after `CREATE TYPE`/`ALTER TYPE`/column
453
+ changes or rollbacks of those (typical for tests that create types inside a rolled-back transaction on a reused
454
+ connection). `ComplexHelper(con, cache=False)` gives the helper a private cache and the pre-cache behaviour.
455
+ - **CRUD functions** — `pg_retrieve`, `pg_retrieve_many` (equality `filters`, plus optional `where=` as a t-string or
456
+ a `psycopg.sql` composable with `params=`, `order_by=` as a column name, a *list* of names or
457
+ `(name, "asc"|"desc")` pairs, or a composable, and `limit=`; prefer a t-string, since in a composable a literal `%`
458
+ must be written `%%` whenever filters or params are present), `pg_insert`,
424
459
  `pg_insert_many`, `pg_update`, `pg_update_dict`, `pg_upsert`,
425
460
  `pg_upsert_dict`, `pg_upsert_many`, `pg_upsert_many_dict`, `pg_delete`,
426
461
  `pg_delete_dict` — typed (`TableModel`-based) or dict-based CRUD against a
@@ -519,7 +554,9 @@ Postgres or for the client.
519
554
  Don't use it afterwards, and don't count on earlier writes in that transaction.
520
555
 
521
556
  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
557
+ help, see below). With `PgPool` (40 connections by default; `max_size=`) that means a few slow readers, or many parallel streams of
558
+ big results, can use up the pool and make every other request wait for a connection. Enforce a write timeout in the
559
+ reverse proxy and size the pool accordingly. Disconnect behaviour can only be tested over a real socket
523
560
  (`httpx.ASGITransport` never sends `http.disconnect`); see `tests/test_pg_json_granian.py`.
524
561
 
525
562
  ### Statement timeout
@@ -527,9 +564,15 @@ help, see below). Enforce a write timeout in the reverse proxy and size the pool
527
564
  `statement_timeout=<seconds>` (optional, also on `fetch_all`) lets **Postgres** abort the query after that long. It
528
565
  is set with `SET LOCAL` semantics for this one query: transaction-scoped, so it can't leak into the pool and works
529
566
  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
567
+ 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
568
  needs a connection that is not in autocommit mode. Without it, the connection's own `statement_timeout` applies.
532
569
 
570
+ A plain value *replaces* whatever timeout the connection already has, in both directions: with a database-level
571
+ safety net of 600 s, a per-call `statement_timeout=120` shortens it for that call, but `statement_timeout=900` loosens
572
+ it. To make a per-call value only ever tighten it, wrap it: `statement_timeout=at_most(120)`
573
+ (`from pgdevkit.db import at_most`) uses `min(current, 120)`, where a current timeout of 0 (none) counts as unlimited.
574
+ `at_most` works anywhere `statement_timeout` does, `PostgresJsonResponse` included.
575
+
533
576
  It limits *query execution*, nothing else: Postgres defers a cancel while it is blocked writing to a client that
534
577
  has stopped reading, so a stalled reader keeps its connection regardless (a backend was observed staying `active`
535
578
  in `ClientWrite` well past a 1 s timeout). For that, use a write timeout in the reverse proxy.
@@ -545,12 +588,27 @@ class AppJsonResponse(PostgresJsonResponse):
545
588
  expose_errors = IS_DEV
546
589
  ```
547
590
 
591
+ FastAPI's own error handlers answer with `{"detail": ...}` instead. If a frontend already handles that shape, set
592
+ `error_key = "detail"` in the subclass and these responses use it too.
593
+
548
594
  After the first byte the status line is gone: the error is logged and re-raised, and the response ends **without
549
595
  the closing `]`**, so clients fail to parse it rather than accepting a silently shortened array. (Granian, which our
550
596
  apps run on, ends such a response cleanly with the truncated body.)
551
597
 
552
598
  ### Notes
553
599
 
600
+ Timestamps: Postgres' `row_to_json` writes a `timestamptz` as `2026-01-01T10:00:00+00:00` (with the session time
601
+ zone's offset, e.g. `+01:00`), where Pydantic/FastAPI serialize UTC values as `...Z`. Both are valid ISO-8601 and
602
+ parse to the same instant, but code that compares or displays the raw strings sees a difference; normalize in the
603
+ frontend, or in the query (`to_char(...)`).
604
+
605
+ `NaN`/`Infinity`: JSON has no such numbers, so `row_to_json` writes a `float`/`numeric` NaN or Infinity as the
606
+ *string* `"NaN"`/`"Infinity"`. A frontend expecting numbers must handle those strings, or the query should map them
607
+ (`nullif(x, 'NaN')`).
608
+
609
+ Behind Starlette's `BaseHTTPMiddleware` a failure after the first byte has been reported to end as a clean `200` with a
610
+ truncated body: the missing closing `]` is then the only signal (clients fail to parse it), the status cannot carry it.
611
+
554
612
  It does no authorization: scope the query yourself. The first byte is sent once `batch_size` rows (or the whole
555
613
  result) are available, so lower `batch_size` for earlier output; Postgres itself also sends rows in 8 kB buffers,
556
614
  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
  ]
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import weakref
4
+ from dataclasses import dataclass, field
3
5
  from typing import Any, Callable
4
6
 
5
7
  from psycopg import AsyncConnection
@@ -23,6 +25,33 @@ class JsonbArray:
23
25
  ComplexTypeInfo = CompositeInfo | EnumInfo | type[Jsonb] | type[JsonbArray] | None
24
26
 
25
27
 
28
+ @dataclass
29
+ class _TypeCache:
30
+ """What a `ComplexHelper` has learned about one connection's database: the catalog's complex types, the
31
+ CompositeInfo/EnumInfo objects built from them (they carry that database's OIDs), which of those are registered
32
+ on the connection (an adapter registration lives and dies with the connection), and each table column's type."""
33
+
34
+ system_complex_type_dict: dict[Any, tuple[str, str]] | None = None
35
+ complex_types: dict[tuple[str, str], CompositeInfo | EnumInfo] = field(default_factory=dict)
36
+ registered: set[CompositeInfo | EnumInfo] = field(default_factory=set)
37
+ columns: dict[tuple[tuple[str, str], bool], dict[str, ComplexTypeInfo]] = field(default_factory=dict)
38
+ column: dict[tuple[tuple[str, str], str], ComplexTypeInfo] = field(default_factory=dict)
39
+ shared: bool = False # whether other helpers on the same connection use it too (then column lookups are cached)
40
+
41
+
42
+ # One cache per live connection, so helpers created per call (`pg_retrieve(..., complex_helper=ComplexHelper(con))`)
43
+ # share it, and it goes away with the connection: pooled connections keep theirs for as long as the pool keeps them.
44
+ # Per connection rather than per process on purpose: the cached OIDs belong to one database.
45
+ _caches: weakref.WeakKeyDictionary[AsyncConnection, _TypeCache] = weakref.WeakKeyDictionary()
46
+
47
+
48
+ def _cache_for(con: AsyncConnection) -> _TypeCache:
49
+ try:
50
+ return _caches.setdefault(con, _TypeCache(shared=True))
51
+ except TypeError: # not something a weak reference can point to (e.g. a stand-in for a connection): no sharing
52
+ return _TypeCache()
53
+
54
+
26
55
  class ComplexHelper:
27
56
  """psycopg adapter for PostgreSQL composite types, enums, and JSONB.
28
57
 
@@ -35,24 +64,62 @@ class ComplexHelper:
35
64
  composite type that needs locale-key backfilling before it's built) —
36
65
  this is intentionally the only project-specific extension point; nothing
37
66
  else about a project's types is hardcoded here.
67
+
68
+ What it learns from the catalog (the types, the columns' types, which ones are registered on the connection) is
69
+ cached per connection, for as long as that connection lives: a helper created for every call, on a connection
70
+ that is reused (e.g. from a pool), only queries the catalog the first time. The cache can't see schema changes
71
+ made afterwards (`CREATE TYPE`, `ALTER TYPE ... ADD VALUE`, altered columns), nor DDL that was rolled back after
72
+ it had been cached (a type's OID is then gone): call `clear_cache()` after such changes. `cache=False` gives the
73
+ helper a private cache instead, which behaves as before the shared cache existed (types are remembered per helper
74
+ instance, column lookups always ask the database).
38
75
  """
39
76
 
40
77
  def __init__(
41
78
  self,
42
79
  con: AsyncConnection,
43
80
  normalizers: dict[str, Callable[[dict], dict]] | None = None,
81
+ *,
82
+ cache: bool = True,
44
83
  ) -> None:
45
84
  self.con = con
46
- self.system_complex_type_dict: dict[Any, tuple[str, str]] | None = None
47
- # Instance-scoped: a CompositeInfo/EnumInfo carries OIDs from a
48
- # specific connection/database, so caching it on the class (shared
49
- # across every connection) would leak stale OIDs across databases
50
- # that happen to reuse the same type name — exactly the case for
51
- # pgdevkit's per-worktree isolated test databases.
52
- self.complex_types: dict[tuple[str, str], CompositeInfo | EnumInfo] = {}
53
- self.registered: set[CompositeInfo | EnumInfo] = set()
85
+ # Never class-scoped: a CompositeInfo/EnumInfo carries OIDs from a specific connection/database, so
86
+ # sharing it across every connection would leak stale OIDs across databases that happen to reuse the
87
+ # same type name — exactly the case for pgdevkit's per-worktree isolated test databases.
88
+ self._cache = _cache_for(con) if cache else _TypeCache()
54
89
  self._normalizers = normalizers or {}
55
90
 
91
+ @property
92
+ def system_complex_type_dict(self) -> dict[Any, tuple[str, str]] | None:
93
+ return self._cache.system_complex_type_dict
94
+
95
+ @system_complex_type_dict.setter
96
+ def system_complex_type_dict(self, value: dict[Any, tuple[str, str]] | None) -> None:
97
+ self._cache.system_complex_type_dict = value
98
+
99
+ @property
100
+ def complex_types(self) -> dict[tuple[str, str], CompositeInfo | EnumInfo]:
101
+ return self._cache.complex_types
102
+
103
+ @complex_types.setter
104
+ def complex_types(self, value: dict[tuple[str, str], CompositeInfo | EnumInfo]) -> None:
105
+ self._cache.complex_types = value
106
+
107
+ @property
108
+ def registered(self) -> set[CompositeInfo | EnumInfo]:
109
+ return self._cache.registered
110
+
111
+ @registered.setter
112
+ def registered(self, value: set[CompositeInfo | EnumInfo]) -> None:
113
+ self._cache.registered = value
114
+
115
+ def clear_cache(self) -> None:
116
+ """Forget what was learned about this connection's database (see the class docstring)."""
117
+ self._cache.system_complex_type_dict = None
118
+ self._cache.complex_types.clear()
119
+ self._cache.registered.clear()
120
+ self._cache.columns.clear()
121
+ self._cache.column.clear()
122
+
56
123
  async def load_complex_type_dict(self) -> None:
57
124
  async with self.con.cursor(row_factory=dict_row) as cur:
58
125
  await cur.execute("""
@@ -94,6 +161,9 @@ class ComplexHelper:
94
161
  async def load_all_complex_types(
95
162
  self, table_name: tuple[str, str], include_generated: bool = False
96
163
  ) -> dict[str, ComplexTypeInfo]:
164
+ cache_key = (table_name, include_generated)
165
+ if self._cache.shared and cache_key in self._cache.columns:
166
+ return dict(self._cache.columns[cache_key])
97
167
  if self.system_complex_type_dict is None:
98
168
  await self.load_complex_type_dict()
99
169
  colquery = """
@@ -120,9 +190,15 @@ class ComplexHelper:
120
190
  },
121
191
  )
122
192
  res = await cur.fetchall()
123
- return {r["column_name"]: await self._load_complex_type_from_colinfos(r) for r in res}
193
+ types = {r["column_name"]: await self._load_complex_type_from_colinfos(r) for r in res}
194
+ if types and self._cache.shared: # (an empty result may just be a table that doesn't exist yet)
195
+ self._cache.columns[cache_key] = types
196
+ return dict(types)
124
197
 
125
198
  async def load_complex_type(self, table_name: tuple[str, str], col_name: str) -> ComplexTypeInfo:
199
+ cache_key = (table_name, col_name)
200
+ if self._cache.shared and cache_key in self._cache.column:
201
+ return self._cache.column[cache_key]
126
202
  if self.system_complex_type_dict is None:
127
203
  await self.load_complex_type_dict()
128
204
  colquery = """
@@ -146,7 +222,10 @@ class ComplexHelper:
146
222
  {"schema": table_name[0], "tbl": table_name[1], "col": col_name},
147
223
  )
148
224
  res = await cur.fetchone()
149
- return await self._load_complex_type_from_colinfos(res)
225
+ info = await self._load_complex_type_from_colinfos(res)
226
+ if res and self._cache.shared: # (a missing column may just not exist yet)
227
+ self._cache.column[cache_key] = info
228
+ return info
150
229
 
151
230
  async def _get_complex_type(self, name: str, is_enum: bool, con: AsyncConnection) -> CompositeInfo | EnumInfo:
152
231
  if name.endswith("[]"):
@@ -154,13 +233,9 @@ class ComplexHelper:
154
233
  schema, type_name = name.split(".") if "." in name else ("public", name)
155
234
  if type_name.startswith("_"): # the array type in PostgreSQL starts with an underscore
156
235
  type_name = type_name[1:]
157
- if is_enum:
158
- ci = await EnumInfo.fetch(con, Identifier(schema, type_name))
159
- assert ci is not None, f"Enum type {name} not found in database"
160
- self.complex_types[(schema, type_name)] = ci
161
236
  if (schema, type_name) not in self.complex_types:
162
- ci = await CompositeInfo.fetch(con, Identifier(schema, type_name))
163
- assert ci is not None, f"Complex type {name} not found in database"
237
+ ci = await (EnumInfo if is_enum else CompositeInfo).fetch(con, Identifier(schema, type_name))
238
+ assert ci is not None, f"{'Enum' if is_enum else 'Complex'} type {name} not found in database"
164
239
  self.complex_types[(schema, type_name)] = ci
165
240
  return self.complex_types[(schema, type_name)]
166
241
 
@@ -2,8 +2,11 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import os
5
- from typing import Literal
5
+ from collections.abc import Awaitable, Callable
6
+ from typing import Any, Literal
6
7
 
8
+ from psycopg import AsyncConnection
9
+ from psycopg.conninfo import make_conninfo
7
10
  from psycopg_pool import AsyncConnectionPool, AsyncNullConnectionPool
8
11
 
9
12
  from ..connection import detect_provider, get_azure_postgres_password, is_azure_postgres_host
@@ -26,6 +29,12 @@ class PgPool:
26
29
  auto-includes `prepare_threshold=None` too, since PgBouncer transaction
27
30
  mode doesn't support server-side prepared statements. Pass an explicit
28
31
  `use_null_pool`/`connection_kwargs` to override either.
32
+
33
+ `min_size`, `timeout` (seconds to wait for a free connection before the pool raises `PoolTimeout`),
34
+ `configure` (an async callable run on every new connection, e.g. to set session timeouts) and `check` (the
35
+ health check run when a connection is handed out; the default is the pool class' `check_connection`) are passed
36
+ through to the underlying psycopg pool when given (`min_size` defaults to 4, or `max_size` if that is smaller; a
37
+ null pool takes no `min_size`).
29
38
  """
30
39
 
31
40
  def __init__(
@@ -40,6 +49,10 @@ class PgPool:
40
49
  use_null_pool: bool | Literal["auto"] = "auto",
41
50
  connection_kwargs: dict | None = None,
42
51
  dsn_params: dict[str, str] | None = None,
52
+ min_size: int | None = None,
53
+ timeout: float | None = None,
54
+ configure: Callable[[AsyncConnection], Awaitable[None]] | None = None,
55
+ check: Callable[[AsyncConnection], Awaitable[None]] | None = None,
43
56
  ) -> None:
44
57
  self._env_prefix = env_prefix
45
58
  self._max_size = max_size
@@ -50,6 +63,12 @@ class PgPool:
50
63
  self._use_null_pool = use_null_pool
51
64
  self._connection_kwargs = connection_kwargs
52
65
  self._dsn_params = dsn_params or {}
66
+ # only what was given: the psycopg pool's own defaults apply to the rest
67
+ self._pool_options: dict[str, Any] = {
68
+ k: v for k, v in {"timeout": timeout, "configure": configure}.items() if v is not None
69
+ }
70
+ self._min_size = min_size
71
+ self._check = check
53
72
  self._pool: AsyncConnectionPool | AsyncNullConnectionPool | None = None
54
73
 
55
74
  def _is_azure_postgres(self, host: str) -> bool:
@@ -77,10 +96,8 @@ class PgPool:
77
96
  exclude_interactive_browser_credential=self._exclude_interactive_browser_credential,
78
97
  )
79
98
 
80
- dsn = f"host={host} port={port} dbname={dbname} user={user} password={password}"
81
- for key, value in self._dsn_params.items():
82
- dsn += f" {key}={value}"
83
- return dsn
99
+ # make_conninfo quotes values (a password with a space, backslash or quote would break a hand-built string)
100
+ return make_conninfo(host=host, port=port, dbname=dbname, user=user, password=password, **self._dsn_params)
84
101
 
85
102
  async def open(self) -> None:
86
103
  if self._pool is None:
@@ -93,13 +110,17 @@ class PgPool:
93
110
  connection_kwargs = {"prepare_threshold": None} if is_azure_postgres else {}
94
111
  max_lifetime = 3600.0 if self._max_lifetime is None else self._max_lifetime
95
112
  if use_null_pool:
113
+ null_options: dict[str, Any] = dict(self._pool_options)
114
+ if self._min_size is not None:
115
+ null_options["min_size"] = self._min_size
96
116
  self._pool = AsyncNullConnectionPool(
97
117
  conninfo=self._dsn,
98
118
  open=False,
99
119
  max_size=self._max_size,
100
120
  max_lifetime=max_lifetime,
101
- check=AsyncNullConnectionPool.check_connection,
121
+ check=self._check or AsyncNullConnectionPool.check_connection,
102
122
  kwargs=connection_kwargs,
123
+ **null_options,
103
124
  )
104
125
  else:
105
126
  self._pool = AsyncConnectionPool(
@@ -107,8 +128,11 @@ class PgPool:
107
128
  open=False,
108
129
  max_size=self._max_size,
109
130
  max_lifetime=max_lifetime,
110
- check=AsyncConnectionPool.check_connection,
131
+ check=self._check or AsyncConnectionPool.check_connection,
111
132
  kwargs=connection_kwargs,
133
+ # (psycopg's default min_size of 4 would reject a max_size below 4)
134
+ min_size=min(4, self._max_size) if self._min_size is None else self._min_size,
135
+ **self._pool_options,
112
136
  )
113
137
  if not self._pool._opened:
114
138
  await self._pool.open()