pgdevkit 0.14.2__tar.gz → 0.15.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/PKG-INFO +78 -8
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/README.md +77 -7
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/db/__init__.py +18 -1
- pgdevkit-0.15.1/pgdevkit/db/complex_types.py +375 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/db/connection.py +31 -7
- pgdevkit-0.15.1/pgdevkit/db/crud.py +433 -0
- pgdevkit-0.15.1/pgdevkit/db/fetch.py +587 -0
- pgdevkit-0.15.1/pgdevkit/db/transaction.py +39 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/fastapi/json_response.py +10 -6
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/testdb/schema.py +5 -2
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pyproject.toml +1 -1
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/skills/pgdevkit/SKILL.md +64 -15
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/skills/pgdevkit/references/dynamic-sql.md +1 -1
- pgdevkit-0.15.1/tests/db/test_complex_types.py +458 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/db/test_connection.py +110 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/db/test_crud.py +104 -6
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/db/test_fetch.py +149 -1
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/db/test_fetch_statement_timeout.py +46 -1
- pgdevkit-0.15.1/tests/db/test_transaction.py +106 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_pg_json_response.py +38 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_pg_json_statement_timeout.py +14 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_schema.py +3 -2
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/uv.lock +1 -1
- pgdevkit-0.14.2/pgdevkit/db/complex_types.py +0 -233
- pgdevkit-0.14.2/pgdevkit/db/crud.py +0 -322
- 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.1}/.github/workflows/auto-release.yml +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/.github/workflows/python-publish.yml +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/.github/workflows/python-test.yml +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/.gitignore +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/.python-version +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/docs/database-layout.md +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/__init__.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/areas.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/backends/__init__.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/backends/base.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/backends/mssql.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/backends/postgres.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/cli.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/connection.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/db/loader.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/db/model.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/db/mssql_crud.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/db/mssql_sql.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/dialect.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/diff.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/envtag.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/fastapi/__init__.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/fetch_missing.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/introspect.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/lakebase.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/migrate.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/migrate_mssql.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/models.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/mssql_introspect.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/parser.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/schemas.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/sql_text.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/stats.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/testdb/__init__.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/testdb/_docker.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/testdb/api.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/testdb/config.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/testdb/constants.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/testdb/container.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/testdb/mssql/__init__.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/testdb/mssql/api.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/testdb/mssql/constants.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/testdb/mssql/container.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/testdb/naming.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/pgdevkit/testdb/query.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/skills/pgdevkit/references/temporal-tables.md +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/__init__.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/_asgi.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/_granian_app.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/conftest.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/db/__init__.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/db/test_loader.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/db/test_mssql_crud_live.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/db/test_mssql_crud_sql.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/fixtures/01_schema.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/fixtures/02_types.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/fixtures/03_tables.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/fixtures/04_views.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/fixtures/05_functions.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/fixtures/06_indexes.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_areas.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_cli_compare.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_compare.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_compare_mssql_live.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_connection.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_dialect.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_diff_mssql.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_envtag.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_fetch_missing.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_fetch_missing_cli.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_lakebase.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_migrate.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_migrate_areas.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_migrate_env.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_migrate_grants.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_migrate_mssql.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_migrate_mssql_live.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_migrate_schemas.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_mssql_introspect.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_parser_areas.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_parser_mssql.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_parser_schemas.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_pg_json_cancel.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_pg_json_granian.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_schemas.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_stats_cli.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/test_stats_mssql_live.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/__init__.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/conftest.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/event.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/event_kind.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/widget.init.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/permissions/grants.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/schema/app.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_api.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_api_mssql.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_api_mssql_live.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_cli.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_config.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_config_mssql.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_constants.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_container.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_container_mssql.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_docker.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_mssql_constants.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_naming.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_query.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_schema_filtering.py +0 -0
- {pgdevkit-0.14.2 → pgdevkit-0.15.1}/tests/testdb/test_schema_mssql.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pgdevkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.15.1
|
|
4
4
|
Summary: A helper for developing with Postgres
|
|
5
5
|
Requires-Python: >=3.14
|
|
6
6
|
Requires-Dist: docker>=7.1.0
|
|
@@ -430,22 +430,69 @@ Install with the `db` extra: `pip install pgdevkit[db]`.
|
|
|
430
430
|
password — same host-based auto-detection as `pgdb compare`'s
|
|
431
431
|
`--entra-user`. For Lakebase hosts, also set the
|
|
432
432
|
`{env_prefix}DATABRICKS_WORKSPACE_HOST` and `{env_prefix}DATABRICKS_INSTANCE`
|
|
433
|
-
env vars.
|
|
433
|
+
env vars. Values (password, `dsn_params`) are quoted properly, so spaces, backslashes and quotes are fine.
|
|
434
|
+
`min_size`, `timeout` (how long to wait for a free connection before `PoolTimeout`), `configure` (async hook run on
|
|
435
|
+
every new connection, e.g. to set session timeouts or `application_name`) and `check` are passed through to the
|
|
436
|
+
psycopg pool; `max_size` defaults to 40 and `min_size` to 4 (or `max_size` if smaller).
|
|
434
437
|
- **`fetch_all(query, params=None, *, model=None, row_mapper=None, con=None, pool=None, cancel=None, statement_timeout=None)`**
|
|
435
438
|
— run one custom query and get every row back as dicts, as validated
|
|
436
439
|
Pydantic `model` instances, or through a `row_mapper`. Pass `con` to run on a
|
|
437
440
|
connection you already hold (it is borrowed: never committed, closed or
|
|
438
441
|
released); otherwise a connection is taken from `pool` or the pool registered
|
|
439
|
-
with `set_default_pool()
|
|
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. **Nothing is registered on the connection:** psycopg's type
|
|
475
|
+
registration is global to a connection (and so to the next user of a pooled one: a plain `SELECT` of an enum would
|
|
476
|
+
return psycopg's auto-numbered `Enum` member instead of the label, #54). The helpers therefore register the
|
|
477
|
+
enum/composite adapters only on the *cursor* that runs a write statement (`pg_insert`, `pg_update`/`pg_update_dict`,
|
|
478
|
+
`pg_upsert`/`pg_upsert_dict`, the `*_many` variants), and only for the columns where a dict/list value had to
|
|
479
|
+
be converted. Reads (`pg_retrieve*`) select such columns as `to_jsonb(...)` and need no registration. After any
|
|
480
|
+
`pg_*` call the connection reads enums as label strings and composites as text (e.g. `'(1,2)'`), as before the call.
|
|
481
|
+
`RETURNING` rows of `pg_insert`/`pg_update_dict`/`pg_upsert_dict` (and so `pg_update`/`pg_upsert`) select such
|
|
482
|
+
columns as `to_jsonb(...)` too, so with a helper they are plain Python values (a composite as a dict, an enum as its
|
|
483
|
+
label) whichever columns were written; without one it is a plain `RETURNING *`.
|
|
484
|
+
If you call `recursive_convert` yourself, register the types on the cursor that sends the values, with the `info`
|
|
485
|
+
from `load_all_complex_types`/`load_complex_type`: `await helper.register_on(cur, info)`, then execute the converted
|
|
486
|
+
value on that same `cur` (passing the connection instead would restore psycopg's global behaviour).
|
|
487
|
+
What it learns from the catalog (types and column types) is cached per connection, so a helper created per call
|
|
488
|
+
queries the catalog only the first time a connection is used (the SELECT/INSERT itself is then the only query). The cache cannot see later DDL, nor
|
|
489
|
+
DDL that was rolled back after it had been cached: call `helper.clear_cache()` after `CREATE TYPE`/`ALTER TYPE`/column
|
|
490
|
+
changes or rollbacks of those (typical for tests that create types inside a rolled-back transaction on a reused
|
|
491
|
+
connection). `ComplexHelper(con, cache=False)` gives the helper a private cache and the pre-cache behaviour.
|
|
492
|
+
- **CRUD functions** — `pg_retrieve`, `pg_retrieve_many` (equality `filters`, plus optional `where=` as a t-string or
|
|
493
|
+
a `psycopg.sql` composable with `params=`, `order_by=` as a column name, a *list* of names or
|
|
494
|
+
`(name, "asc"|"desc")` pairs, or a composable, and `limit=`; prefer a t-string, since in a composable a literal `%`
|
|
495
|
+
must be written `%%` whenever filters or params are present), `pg_insert`,
|
|
449
496
|
`pg_insert_many`, `pg_update`, `pg_update_dict`, `pg_upsert`,
|
|
450
497
|
`pg_upsert_dict`, `pg_upsert_many`, `pg_upsert_many_dict`, `pg_delete`,
|
|
451
498
|
`pg_delete_dict` — typed (`TableModel`-based) or dict-based CRUD against a
|
|
@@ -544,7 +591,9 @@ Postgres or for the client.
|
|
|
544
591
|
Don't use it afterwards, and don't count on earlier writes in that transaction.
|
|
545
592
|
|
|
546
593
|
Connections are held while the client reads, so a client that reads very slowly pins one (`statement_timeout` does not
|
|
547
|
-
help, see below).
|
|
594
|
+
help, see below). With `PgPool` (40 connections by default; `max_size=`) that means a few slow readers, or many parallel streams of
|
|
595
|
+
big results, can use up the pool and make every other request wait for a connection. Enforce a write timeout in the
|
|
596
|
+
reverse proxy and size the pool accordingly. Disconnect behaviour can only be tested over a real socket
|
|
548
597
|
(`httpx.ASGITransport` never sends `http.disconnect`); see `tests/test_pg_json_granian.py`.
|
|
549
598
|
|
|
550
599
|
### Statement timeout
|
|
@@ -552,9 +601,15 @@ help, see below). Enforce a write timeout in the reverse proxy and size the pool
|
|
|
552
601
|
`statement_timeout=<seconds>` (optional, also on `fetch_all`) lets **Postgres** abort the query after that long. It
|
|
553
602
|
is set with `SET LOCAL` semantics for this one query: transaction-scoped, so it can't leak into the pool and works
|
|
554
603
|
behind PgBouncer's transaction pooling, and a `con` you passed gets its previous setting back. Before the first byte
|
|
555
|
-
the client gets a `504 {"error": "Query timed out"}
|
|
604
|
+
the client gets a `504 {"error": "Query timed out"}` (the key is `error_key`, see Errors); after that the response is cut short like any other error. It
|
|
556
605
|
needs a connection that is not in autocommit mode. Without it, the connection's own `statement_timeout` applies.
|
|
557
606
|
|
|
607
|
+
A plain value *replaces* whatever timeout the connection already has, in both directions: with a database-level
|
|
608
|
+
safety net of 600 s, a per-call `statement_timeout=120` shortens it for that call, but `statement_timeout=900` loosens
|
|
609
|
+
it. To make a per-call value only ever tighten it, wrap it: `statement_timeout=at_most(120)`
|
|
610
|
+
(`from pgdevkit.db import at_most`) uses `min(current, 120)`, where a current timeout of 0 (none) counts as unlimited.
|
|
611
|
+
`at_most` works anywhere `statement_timeout` does, `PostgresJsonResponse` included.
|
|
612
|
+
|
|
558
613
|
It limits *query execution*, nothing else: Postgres defers a cancel while it is blocked writing to a client that
|
|
559
614
|
has stopped reading, so a stalled reader keeps its connection regardless (a backend was observed staying `active`
|
|
560
615
|
in `ClientWrite` well past a 1 s timeout). For that, use a write timeout in the reverse proxy.
|
|
@@ -570,12 +625,27 @@ class AppJsonResponse(PostgresJsonResponse):
|
|
|
570
625
|
expose_errors = IS_DEV
|
|
571
626
|
```
|
|
572
627
|
|
|
628
|
+
FastAPI's own error handlers answer with `{"detail": ...}` instead. If a frontend already handles that shape, set
|
|
629
|
+
`error_key = "detail"` in the subclass and these responses use it too.
|
|
630
|
+
|
|
573
631
|
After the first byte the status line is gone: the error is logged and re-raised, and the response ends **without
|
|
574
632
|
the closing `]`**, so clients fail to parse it rather than accepting a silently shortened array. (Granian, which our
|
|
575
633
|
apps run on, ends such a response cleanly with the truncated body.)
|
|
576
634
|
|
|
577
635
|
### Notes
|
|
578
636
|
|
|
637
|
+
Timestamps: Postgres' `row_to_json` writes a `timestamptz` as `2026-01-01T10:00:00+00:00` (with the session time
|
|
638
|
+
zone's offset, e.g. `+01:00`), where Pydantic/FastAPI serialize UTC values as `...Z`. Both are valid ISO-8601 and
|
|
639
|
+
parse to the same instant, but code that compares or displays the raw strings sees a difference; normalize in the
|
|
640
|
+
frontend, or in the query (`to_char(...)`).
|
|
641
|
+
|
|
642
|
+
`NaN`/`Infinity`: JSON has no such numbers, so `row_to_json` writes a `float`/`numeric` NaN or Infinity as the
|
|
643
|
+
*string* `"NaN"`/`"Infinity"`. A frontend expecting numbers must handle those strings, or the query should map them
|
|
644
|
+
(`nullif(x, 'NaN')`).
|
|
645
|
+
|
|
646
|
+
Behind Starlette's `BaseHTTPMiddleware` a failure after the first byte has been reported to end as a clean `200` with a
|
|
647
|
+
truncated body: the missing closing `]` is then the only signal (clients fail to parse it), the status cannot carry it.
|
|
648
|
+
|
|
579
649
|
It does no authorization: scope the query yourself. The first byte is sent once `batch_size` rows (or the whole
|
|
580
650
|
result) are available, so lower `batch_size` for earlier output; Postgres itself also sends rows in 8 kB buffers,
|
|
581
651
|
so a slow query with tiny rows delivers late either way.
|
|
@@ -405,22 +405,69 @@ Install with the `db` extra: `pip install pgdevkit[db]`.
|
|
|
405
405
|
password — same host-based auto-detection as `pgdb compare`'s
|
|
406
406
|
`--entra-user`. For Lakebase hosts, also set the
|
|
407
407
|
`{env_prefix}DATABRICKS_WORKSPACE_HOST` and `{env_prefix}DATABRICKS_INSTANCE`
|
|
408
|
-
env vars.
|
|
408
|
+
env vars. Values (password, `dsn_params`) are quoted properly, so spaces, backslashes and quotes are fine.
|
|
409
|
+
`min_size`, `timeout` (how long to wait for a free connection before `PoolTimeout`), `configure` (async hook run on
|
|
410
|
+
every new connection, e.g. to set session timeouts or `application_name`) and `check` are passed through to the
|
|
411
|
+
psycopg pool; `max_size` defaults to 40 and `min_size` to 4 (or `max_size` if smaller).
|
|
409
412
|
- **`fetch_all(query, params=None, *, model=None, row_mapper=None, con=None, pool=None, cancel=None, statement_timeout=None)`**
|
|
410
413
|
— run one custom query and get every row back as dicts, as validated
|
|
411
414
|
Pydantic `model` instances, or through a `row_mapper`. Pass `con` to run on a
|
|
412
415
|
connection you already hold (it is borrowed: never committed, closed or
|
|
413
416
|
released); otherwise a connection is taken from `pool` or the pool registered
|
|
414
|
-
with `set_default_pool()
|
|
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. **Nothing is registered on the connection:** psycopg's type
|
|
450
|
+
registration is global to a connection (and so to the next user of a pooled one: a plain `SELECT` of an enum would
|
|
451
|
+
return psycopg's auto-numbered `Enum` member instead of the label, #54). The helpers therefore register the
|
|
452
|
+
enum/composite adapters only on the *cursor* that runs a write statement (`pg_insert`, `pg_update`/`pg_update_dict`,
|
|
453
|
+
`pg_upsert`/`pg_upsert_dict`, the `*_many` variants), and only for the columns where a dict/list value had to
|
|
454
|
+
be converted. Reads (`pg_retrieve*`) select such columns as `to_jsonb(...)` and need no registration. After any
|
|
455
|
+
`pg_*` call the connection reads enums as label strings and composites as text (e.g. `'(1,2)'`), as before the call.
|
|
456
|
+
`RETURNING` rows of `pg_insert`/`pg_update_dict`/`pg_upsert_dict` (and so `pg_update`/`pg_upsert`) select such
|
|
457
|
+
columns as `to_jsonb(...)` too, so with a helper they are plain Python values (a composite as a dict, an enum as its
|
|
458
|
+
label) whichever columns were written; without one it is a plain `RETURNING *`.
|
|
459
|
+
If you call `recursive_convert` yourself, register the types on the cursor that sends the values, with the `info`
|
|
460
|
+
from `load_all_complex_types`/`load_complex_type`: `await helper.register_on(cur, info)`, then execute the converted
|
|
461
|
+
value on that same `cur` (passing the connection instead would restore psycopg's global behaviour).
|
|
462
|
+
What it learns from the catalog (types and column types) is cached per connection, so a helper created per call
|
|
463
|
+
queries the catalog only the first time a connection is used (the SELECT/INSERT itself is then the only query). The cache cannot see later DDL, nor
|
|
464
|
+
DDL that was rolled back after it had been cached: call `helper.clear_cache()` after `CREATE TYPE`/`ALTER TYPE`/column
|
|
465
|
+
changes or rollbacks of those (typical for tests that create types inside a rolled-back transaction on a reused
|
|
466
|
+
connection). `ComplexHelper(con, cache=False)` gives the helper a private cache and the pre-cache behaviour.
|
|
467
|
+
- **CRUD functions** — `pg_retrieve`, `pg_retrieve_many` (equality `filters`, plus optional `where=` as a t-string or
|
|
468
|
+
a `psycopg.sql` composable with `params=`, `order_by=` as a column name, a *list* of names or
|
|
469
|
+
`(name, "asc"|"desc")` pairs, or a composable, and `limit=`; prefer a t-string, since in a composable a literal `%`
|
|
470
|
+
must be written `%%` whenever filters or params are present), `pg_insert`,
|
|
424
471
|
`pg_insert_many`, `pg_update`, `pg_update_dict`, `pg_upsert`,
|
|
425
472
|
`pg_upsert_dict`, `pg_upsert_many`, `pg_upsert_many_dict`, `pg_delete`,
|
|
426
473
|
`pg_delete_dict` — typed (`TableModel`-based) or dict-based CRUD against a
|
|
@@ -519,7 +566,9 @@ Postgres or for the client.
|
|
|
519
566
|
Don't use it afterwards, and don't count on earlier writes in that transaction.
|
|
520
567
|
|
|
521
568
|
Connections are held while the client reads, so a client that reads very slowly pins one (`statement_timeout` does not
|
|
522
|
-
help, see below).
|
|
569
|
+
help, see below). With `PgPool` (40 connections by default; `max_size=`) that means a few slow readers, or many parallel streams of
|
|
570
|
+
big results, can use up the pool and make every other request wait for a connection. Enforce a write timeout in the
|
|
571
|
+
reverse proxy and size the pool accordingly. Disconnect behaviour can only be tested over a real socket
|
|
523
572
|
(`httpx.ASGITransport` never sends `http.disconnect`); see `tests/test_pg_json_granian.py`.
|
|
524
573
|
|
|
525
574
|
### Statement timeout
|
|
@@ -527,9 +576,15 @@ help, see below). Enforce a write timeout in the reverse proxy and size the pool
|
|
|
527
576
|
`statement_timeout=<seconds>` (optional, also on `fetch_all`) lets **Postgres** abort the query after that long. It
|
|
528
577
|
is set with `SET LOCAL` semantics for this one query: transaction-scoped, so it can't leak into the pool and works
|
|
529
578
|
behind PgBouncer's transaction pooling, and a `con` you passed gets its previous setting back. Before the first byte
|
|
530
|
-
the client gets a `504 {"error": "Query timed out"}
|
|
579
|
+
the client gets a `504 {"error": "Query timed out"}` (the key is `error_key`, see Errors); after that the response is cut short like any other error. It
|
|
531
580
|
needs a connection that is not in autocommit mode. Without it, the connection's own `statement_timeout` applies.
|
|
532
581
|
|
|
582
|
+
A plain value *replaces* whatever timeout the connection already has, in both directions: with a database-level
|
|
583
|
+
safety net of 600 s, a per-call `statement_timeout=120` shortens it for that call, but `statement_timeout=900` loosens
|
|
584
|
+
it. To make a per-call value only ever tighten it, wrap it: `statement_timeout=at_most(120)`
|
|
585
|
+
(`from pgdevkit.db import at_most`) uses `min(current, 120)`, where a current timeout of 0 (none) counts as unlimited.
|
|
586
|
+
`at_most` works anywhere `statement_timeout` does, `PostgresJsonResponse` included.
|
|
587
|
+
|
|
533
588
|
It limits *query execution*, nothing else: Postgres defers a cancel while it is blocked writing to a client that
|
|
534
589
|
has stopped reading, so a stalled reader keeps its connection regardless (a backend was observed staying `active`
|
|
535
590
|
in `ClientWrite` well past a 1 s timeout). For that, use a write timeout in the reverse proxy.
|
|
@@ -545,12 +600,27 @@ class AppJsonResponse(PostgresJsonResponse):
|
|
|
545
600
|
expose_errors = IS_DEV
|
|
546
601
|
```
|
|
547
602
|
|
|
603
|
+
FastAPI's own error handlers answer with `{"detail": ...}` instead. If a frontend already handles that shape, set
|
|
604
|
+
`error_key = "detail"` in the subclass and these responses use it too.
|
|
605
|
+
|
|
548
606
|
After the first byte the status line is gone: the error is logged and re-raised, and the response ends **without
|
|
549
607
|
the closing `]`**, so clients fail to parse it rather than accepting a silently shortened array. (Granian, which our
|
|
550
608
|
apps run on, ends such a response cleanly with the truncated body.)
|
|
551
609
|
|
|
552
610
|
### Notes
|
|
553
611
|
|
|
612
|
+
Timestamps: Postgres' `row_to_json` writes a `timestamptz` as `2026-01-01T10:00:00+00:00` (with the session time
|
|
613
|
+
zone's offset, e.g. `+01:00`), where Pydantic/FastAPI serialize UTC values as `...Z`. Both are valid ISO-8601 and
|
|
614
|
+
parse to the same instant, but code that compares or displays the raw strings sees a difference; normalize in the
|
|
615
|
+
frontend, or in the query (`to_char(...)`).
|
|
616
|
+
|
|
617
|
+
`NaN`/`Infinity`: JSON has no such numbers, so `row_to_json` writes a `float`/`numeric` NaN or Infinity as the
|
|
618
|
+
*string* `"NaN"`/`"Infinity"`. A frontend expecting numbers must handle those strings, or the query should map them
|
|
619
|
+
(`nullif(x, 'NaN')`).
|
|
620
|
+
|
|
621
|
+
Behind Starlette's `BaseHTTPMiddleware` a failure after the first byte has been reported to end as a clean `200` with a
|
|
622
|
+
truncated body: the missing closing `]` is then the only signal (clients fail to parse it), the status cannot carry it.
|
|
623
|
+
|
|
554
624
|
It does no authorization: scope the query yourself. The first byte is sent once `batch_size` rows (or the whole
|
|
555
625
|
result) are available, so lower `batch_size` for earlier output; Postgres itself also sends rows in 8 kB buffers,
|
|
556
626
|
so a slow query with tiny rows delivers late either way.
|
|
@@ -16,19 +16,35 @@ from .crud import (
|
|
|
16
16
|
pg_upsert_many,
|
|
17
17
|
pg_upsert_many_dict,
|
|
18
18
|
)
|
|
19
|
-
from .fetch import
|
|
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
|
]
|