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.
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/PKG-INFO +66 -8
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/README.md +65 -7
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/db/__init__.py +18 -1
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/db/complex_types.py +91 -16
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/db/connection.py +31 -7
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/db/crud.py +124 -38
- pgdevkit-0.15.0/pgdevkit/db/fetch.py +587 -0
- pgdevkit-0.15.0/pgdevkit/db/transaction.py +39 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/fastapi/json_response.py +10 -6
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pyproject.toml +1 -1
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/skills/pgdevkit/SKILL.md +51 -14
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/skills/pgdevkit/references/dynamic-sql.md +1 -1
- pgdevkit-0.15.0/tests/db/test_complex_types.py +289 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/db/test_connection.py +110 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/db/test_crud.py +69 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/db/test_fetch.py +149 -1
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/db/test_fetch_statement_timeout.py +46 -1
- pgdevkit-0.15.0/tests/db/test_transaction.py +106 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_pg_json_response.py +38 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_pg_json_statement_timeout.py +14 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/uv.lock +1 -1
- pgdevkit-0.14.2/pgdevkit/db/fetch.py +0 -315
- pgdevkit-0.14.2/tests/db/test_complex_types.py +0 -138
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/.github/workflows/auto-release.yml +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/.github/workflows/python-publish.yml +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/.github/workflows/python-test.yml +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/.gitignore +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/.python-version +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/docs/database-layout.md +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/__init__.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/areas.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/backends/__init__.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/backends/base.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/backends/mssql.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/backends/postgres.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/cli.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/connection.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/db/loader.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/db/model.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/db/mssql_crud.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/db/mssql_sql.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/dialect.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/diff.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/envtag.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/fastapi/__init__.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/fetch_missing.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/introspect.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/lakebase.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/migrate.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/migrate_mssql.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/models.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/mssql_introspect.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/parser.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/schemas.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/sql_text.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/stats.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/testdb/__init__.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/testdb/_docker.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/testdb/api.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/testdb/config.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/testdb/constants.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/testdb/container.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/testdb/mssql/__init__.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/testdb/mssql/api.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/testdb/mssql/constants.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/testdb/mssql/container.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/testdb/naming.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/testdb/query.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/pgdevkit/testdb/schema.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/skills/pgdevkit/references/temporal-tables.md +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/__init__.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/_asgi.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/_granian_app.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/conftest.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/db/__init__.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/db/test_loader.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/db/test_mssql_crud_live.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/db/test_mssql_crud_sql.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/fixtures/01_schema.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/fixtures/02_types.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/fixtures/03_tables.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/fixtures/04_views.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/fixtures/05_functions.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/fixtures/06_indexes.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_areas.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_cli_compare.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_compare.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_compare_mssql_live.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_connection.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_dialect.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_diff_mssql.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_envtag.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_fetch_missing.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_fetch_missing_cli.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_lakebase.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_migrate.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_migrate_areas.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_migrate_env.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_migrate_grants.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_migrate_mssql.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_migrate_mssql_live.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_migrate_schemas.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_mssql_introspect.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_parser_areas.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_parser_mssql.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_parser_schemas.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_pg_json_cancel.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_pg_json_granian.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_schemas.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_stats_cli.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/test_stats_mssql_live.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/__init__.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/conftest.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/tables/event.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/tables/event_kind.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/tables/widget.init.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/permissions/grants.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database/schema/app.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_api.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_api_mssql.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_api_mssql_live.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_cli.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_config.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_config_mssql.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_constants.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_container.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_container_mssql.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_docker.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_mssql_constants.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_naming.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_query.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_schema.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.0}/tests/testdb/test_schema_filtering.py +0 -0
- {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.
|
|
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()
|
|
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)
|
|
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
|
-
|
|
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).
|
|
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"}
|
|
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()
|
|
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)
|
|
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
|
-
|
|
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).
|
|
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"}
|
|
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
|
|
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
|
-
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
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()
|