pgdevkit 0.15.0__tar.gz → 0.15.2__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.15.0 → pgdevkit-0.15.2}/PKG-INFO +18 -6
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/README.md +17 -5
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/db/complex_types.py +90 -23
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/db/crud.py +90 -65
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/db/fetch.py +3 -1
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/testdb/schema.py +5 -2
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pyproject.toml +1 -1
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/skills/pgdevkit/SKILL.md +16 -4
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/db/test_complex_types.py +174 -5
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/db/test_crud.py +35 -6
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/db/test_fetch.py +11 -0
- pgdevkit-0.15.2/tests/db/test_query_expr_types.py +69 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/test_schema.py +3 -2
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/uv.lock +1 -1
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/.github/workflows/auto-release.yml +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/.github/workflows/python-publish.yml +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/.github/workflows/python-test.yml +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/.gitignore +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/.python-version +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/docs/database-layout.md +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/__init__.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/areas.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/backends/__init__.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/backends/base.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/backends/mssql.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/backends/postgres.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/cli.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/connection.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/db/__init__.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/db/connection.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/db/loader.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/db/model.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/db/mssql_crud.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/db/mssql_sql.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/db/transaction.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/dialect.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/diff.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/envtag.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/fastapi/__init__.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/fastapi/json_response.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/fetch_missing.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/introspect.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/lakebase.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/migrate.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/migrate_mssql.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/models.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/mssql_introspect.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/parser.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/schemas.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/sql_text.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/stats.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/testdb/__init__.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/testdb/_docker.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/testdb/api.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/testdb/config.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/testdb/constants.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/testdb/container.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/testdb/mssql/__init__.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/testdb/mssql/api.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/testdb/mssql/constants.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/testdb/mssql/container.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/testdb/naming.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/pgdevkit/testdb/query.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/skills/pgdevkit/references/dynamic-sql.md +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/skills/pgdevkit/references/temporal-tables.md +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/__init__.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/_asgi.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/_granian_app.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/conftest.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/db/__init__.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/db/test_connection.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/db/test_fetch_statement_timeout.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/db/test_loader.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/db/test_mssql_crud_live.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/db/test_mssql_crud_sql.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/db/test_transaction.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/fixtures/01_schema.sql +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/fixtures/02_types.sql +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/fixtures/03_tables.sql +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/fixtures/04_views.sql +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/fixtures/05_functions.sql +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/fixtures/06_indexes.sql +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/test_areas.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/test_cli_compare.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/test_compare.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/test_compare_mssql_live.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/test_connection.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/test_dialect.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/test_diff_mssql.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/test_envtag.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/test_fetch_missing.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/test_fetch_missing_cli.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/test_lakebase.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/test_migrate.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/test_migrate_areas.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/test_migrate_env.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/test_migrate_grants.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/test_migrate_mssql.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/test_migrate_mssql_live.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/test_migrate_schemas.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/test_mssql_introspect.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/test_parser_areas.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/test_parser_mssql.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/test_parser_schemas.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/test_pg_json_cancel.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/test_pg_json_granian.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/test_pg_json_response.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/test_pg_json_statement_timeout.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/test_schemas.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/test_stats_cli.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/test_stats_mssql_live.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/__init__.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/conftest.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/tables/event.sql +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/tables/event_kind.sql +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/tables/widget.init.sql +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/permissions/grants.sql +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/schema/app.sql +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/test_api.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/test_api_mssql.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/test_api_mssql_live.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/test_cli.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/test_config.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/test_config_mssql.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/test_constants.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/test_container.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/test_container_mssql.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/test_docker.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/test_mssql_constants.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/test_naming.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/test_query.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/tests/testdb/test_schema_filtering.py +0 -0
- {pgdevkit-0.15.0 → pgdevkit-0.15.2}/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.15.
|
|
3
|
+
Version: 0.15.2
|
|
4
4
|
Summary: A helper for developing with Postgres
|
|
5
5
|
Requires-Python: >=3.14
|
|
6
6
|
Requires-Dist: docker>=7.1.0
|
|
@@ -471,10 +471,22 @@ Install with the `db` extra: `pip install pgdevkit[db]`.
|
|
|
471
471
|
`SELECT` privileges, and consider `statement_timeout=at_most(...)`.
|
|
472
472
|
- **`ComplexHelper`** — handles composite, enum and JSONB columns for the CRUD functions: pass
|
|
473
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.
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
|
478
490
|
changes or rollbacks of those (typical for tests that create types inside a rolled-back transaction on a reused
|
|
479
491
|
connection). `ComplexHelper(con, cache=False)` gives the helper a private cache and the pre-cache behaviour.
|
|
480
492
|
- **CRUD functions** — `pg_retrieve`, `pg_retrieve_many` (equality `filters`, plus optional `where=` as a t-string or
|
|
@@ -556,7 +568,7 @@ for a `Response`, and the rows are *not* validated against the model.
|
|
|
556
568
|
starts and released when it ends. A `con` must stay open until the response has been sent, i.e. come from a `Depends`
|
|
557
569
|
with `yield` (FastAPI >= 0.118); `async with pool.connection() as conn: return PostgresJsonResponse(q, con=conn)`
|
|
558
570
|
closes it too early. Prefer the pool.
|
|
559
|
-
- **Query** — a literal string, a `psycopg.sql` composable, a sqlglot expression or a t-string, with values bound
|
|
571
|
+
- **Query** — a literal string, a `psycopg.sql` composable, a sqlglot expression (any `exp.Expr`, so Select, Union, Subquery and other `exp.Query` values are fine) or a t-string, with values bound
|
|
560
572
|
through `params` (`%(name)s`; a t-string carries its own values and takes no `params`). Passing only literals
|
|
561
573
|
is enforced by the type checker, **not at runtime**: the query is wrapped as a subquery, never parsed, so never
|
|
562
574
|
build it from user input. For a data-modifying CTE, or custom JSON, pass `query_produces_json=True` and return one
|
|
@@ -446,10 +446,22 @@ Install with the `db` extra: `pip install pgdevkit[db]`.
|
|
|
446
446
|
`SELECT` privileges, and consider `statement_timeout=at_most(...)`.
|
|
447
447
|
- **`ComplexHelper`** — handles composite, enum and JSONB columns for the CRUD functions: pass
|
|
448
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.
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
|
453
465
|
changes or rollbacks of those (typical for tests that create types inside a rolled-back transaction on a reused
|
|
454
466
|
connection). `ComplexHelper(con, cache=False)` gives the helper a private cache and the pre-cache behaviour.
|
|
455
467
|
- **CRUD functions** — `pg_retrieve`, `pg_retrieve_many` (equality `filters`, plus optional `where=` as a t-string or
|
|
@@ -531,7 +543,7 @@ for a `Response`, and the rows are *not* validated against the model.
|
|
|
531
543
|
starts and released when it ends. A `con` must stay open until the response has been sent, i.e. come from a `Depends`
|
|
532
544
|
with `yield` (FastAPI >= 0.118); `async with pool.connection() as conn: return PostgresJsonResponse(q, con=conn)`
|
|
533
545
|
closes it too early. Prefer the pool.
|
|
534
|
-
- **Query** — a literal string, a `psycopg.sql` composable, a sqlglot expression or a t-string, with values bound
|
|
546
|
+
- **Query** — a literal string, a `psycopg.sql` composable, a sqlglot expression (any `exp.Expr`, so Select, Union, Subquery and other `exp.Query` values are fine) or a t-string, with values bound
|
|
535
547
|
through `params` (`%(name)s`; a t-string carries its own values and takes no `params`). Passing only literals
|
|
536
548
|
is enforced by the type checker, **not at runtime**: the query is wrapped as a subquery, never parsed, so never
|
|
537
549
|
build it from user input. For a data-modifying CTE, or custom JSON, pass `query_produces_json=True` and return one
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import warnings
|
|
3
4
|
import weakref
|
|
4
5
|
from dataclasses import dataclass, field
|
|
5
6
|
from typing import Any, Callable
|
|
6
7
|
|
|
7
8
|
from psycopg import AsyncConnection
|
|
9
|
+
from psycopg import adapters as _psycopg_adapters
|
|
10
|
+
from psycopg.abc import AdaptContext
|
|
11
|
+
from psycopg.adapt import AdaptersMap
|
|
8
12
|
from psycopg.rows import dict_row
|
|
9
13
|
from psycopg.sql import Identifier
|
|
10
14
|
from psycopg.types.composite import CompositeInfo, register_composite
|
|
@@ -28,12 +32,11 @@ ComplexTypeInfo = CompositeInfo | EnumInfo | type[Jsonb] | type[JsonbArray] | No
|
|
|
28
32
|
@dataclass
|
|
29
33
|
class _TypeCache:
|
|
30
34
|
"""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)
|
|
32
|
-
|
|
35
|
+
CompositeInfo/EnumInfo objects built from them (they carry that database's OIDs) and each table column's type.
|
|
36
|
+
Never "what is registered where": nothing is registered on the connection (see `ComplexHelper.register_on`)."""
|
|
33
37
|
|
|
34
38
|
system_complex_type_dict: dict[Any, tuple[str, str]] | None = None
|
|
35
39
|
complex_types: dict[tuple[str, str], CompositeInfo | EnumInfo] = field(default_factory=dict)
|
|
36
|
-
registered: set[CompositeInfo | EnumInfo] = field(default_factory=set)
|
|
37
40
|
columns: dict[tuple[tuple[str, str], bool], dict[str, ComplexTypeInfo]] = field(default_factory=dict)
|
|
38
41
|
column: dict[tuple[tuple[str, str], str], ComplexTypeInfo] = field(default_factory=dict)
|
|
39
42
|
shared: bool = False # whether other helpers on the same connection use it too (then column lookups are cached)
|
|
@@ -52,12 +55,28 @@ def _cache_for(con: AsyncConnection) -> _TypeCache:
|
|
|
52
55
|
return _TypeCache()
|
|
53
56
|
|
|
54
57
|
|
|
58
|
+
def _make_composite_object(values: Any, info: CompositeInfo) -> Any:
|
|
59
|
+
# One module-level function, so psycopg's cache of generated loader classes (keyed on it) hits on re-registration
|
|
60
|
+
# instead of growing by two classes per registration.
|
|
61
|
+
assert info.python_type is not None
|
|
62
|
+
return info.python_type(*values)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class _Scope:
|
|
66
|
+
"""An `AdaptContext` of its own (a copy of psycopg's defaults, shared with nothing)."""
|
|
67
|
+
|
|
68
|
+
connection = None
|
|
69
|
+
|
|
70
|
+
def __init__(self) -> None:
|
|
71
|
+
self.adapters = AdaptersMap(_psycopg_adapters)
|
|
72
|
+
|
|
73
|
+
|
|
55
74
|
class ComplexHelper:
|
|
56
75
|
"""psycopg adapter for PostgreSQL composite types, enums, and JSONB.
|
|
57
76
|
|
|
58
77
|
Detects a table's non-scalar columns (composite types, enums, JSONB) and
|
|
59
|
-
converts plain dict/list Python values into the
|
|
60
|
-
those columns
|
|
78
|
+
converts plain dict/list Python values into the Python objects (enum members, composite namedtuples)
|
|
79
|
+
psycopg sends for those columns, recursing into nested composite fields.
|
|
61
80
|
|
|
62
81
|
`normalizers` lets a caller reshape a composite value before conversion,
|
|
63
82
|
keyed by composite type name (e.g. a project with a `locale_labels`
|
|
@@ -65,7 +84,14 @@ class ComplexHelper:
|
|
|
65
84
|
this is intentionally the only project-specific extension point; nothing
|
|
66
85
|
else about a project's types is hardcoded here.
|
|
67
86
|
|
|
68
|
-
|
|
87
|
+
**It never registers anything on the connection.** psycopg's adapter registration is global for the connection
|
|
88
|
+
(and so for the next user of a pooled connection: a plain `SELECT` of an enum would start returning psycopg's
|
|
89
|
+
`Enum` members instead of the label, see issue #54). What `recursive_convert` returns is sent through a cursor
|
|
90
|
+
that has the types registered for itself only (`register_on(cursor, info)`; the `pg_*` helpers do this for the
|
|
91
|
+
cursor that runs their statement), and the connection behaves exactly as before. Reads need no registration at
|
|
92
|
+
all: the `pg_retrieve*` helpers select complex columns as `to_jsonb(...)`.
|
|
93
|
+
|
|
94
|
+
What it learns from the catalog (the types and the columns' types) is
|
|
69
95
|
cached per connection, for as long as that connection lives: a helper created for every call, on a connection
|
|
70
96
|
that is reused (e.g. from a pool), only queries the catalog the first time. The cache can't see schema changes
|
|
71
97
|
made afterwards (`CREATE TYPE`, `ALTER TYPE ... ADD VALUE`, altered columns), nor DDL that was rolled back after
|
|
@@ -106,17 +132,29 @@ class ComplexHelper:
|
|
|
106
132
|
|
|
107
133
|
@property
|
|
108
134
|
def registered(self) -> set[CompositeInfo | EnumInfo]:
|
|
109
|
-
|
|
135
|
+
"""Deprecated: nothing is registered on the connection any more, so this is always empty (a new set each
|
|
136
|
+
time). Use `register_on(cursor, info)` to register types for a statement."""
|
|
137
|
+
warnings.warn(
|
|
138
|
+
"ComplexHelper.registered is deprecated: types are no longer registered on the connection; "
|
|
139
|
+
"use `await helper.register_on(cursor, info)` to register them on a cursor",
|
|
140
|
+
DeprecationWarning,
|
|
141
|
+
stacklevel=2,
|
|
142
|
+
)
|
|
143
|
+
return set()
|
|
110
144
|
|
|
111
145
|
@registered.setter
|
|
112
146
|
def registered(self, value: set[CompositeInfo | EnumInfo]) -> None:
|
|
113
|
-
|
|
147
|
+
warnings.warn(
|
|
148
|
+
"ComplexHelper.registered is deprecated and ignored (types are no longer registered on the connection; "
|
|
149
|
+
"see `register_on`)",
|
|
150
|
+
DeprecationWarning,
|
|
151
|
+
stacklevel=2,
|
|
152
|
+
)
|
|
114
153
|
|
|
115
154
|
def clear_cache(self) -> None:
|
|
116
155
|
"""Forget what was learned about this connection's database (see the class docstring)."""
|
|
117
156
|
self._cache.system_complex_type_dict = None
|
|
118
157
|
self._cache.complex_types.clear()
|
|
119
|
-
self._cache.registered.clear()
|
|
120
158
|
self._cache.columns.clear()
|
|
121
159
|
self._cache.column.clear()
|
|
122
160
|
|
|
@@ -125,7 +163,10 @@ class ComplexHelper:
|
|
|
125
163
|
await cur.execute("""
|
|
126
164
|
SELECT t.oid,
|
|
127
165
|
pg_catalog.format_type ( t.oid, NULL ) AS obj_name,
|
|
128
|
-
|
|
166
|
+
-- an array type reports its element type's typtype, so an enum[] is known to be an enum
|
|
167
|
+
CASE WHEN t.typcategory = 'A'
|
|
168
|
+
THEN ( SELECT et.typtype FROM pg_catalog.pg_type et WHERE et.oid = t.typelem )
|
|
169
|
+
ELSE t.typtype END AS typtype
|
|
129
170
|
FROM pg_catalog.pg_type t
|
|
130
171
|
JOIN pg_catalog.pg_namespace n
|
|
131
172
|
ON n.oid = t.typnamespace
|
|
@@ -154,9 +195,7 @@ class ComplexHelper:
|
|
|
154
195
|
return None
|
|
155
196
|
udt_schema: str = res["udt_schema"]
|
|
156
197
|
udt_name: str = res["udt_name"]
|
|
157
|
-
|
|
158
|
-
await self._recurse_register(c, self.con)
|
|
159
|
-
return c
|
|
198
|
+
return await self._get_complex_type(f"{udt_schema}.{udt_name}", res["is_enum"], self.con)
|
|
160
199
|
|
|
161
200
|
async def load_all_complex_types(
|
|
162
201
|
self, table_name: tuple[str, str], include_generated: bool = False
|
|
@@ -179,7 +218,8 @@ class ComplexHelper:
|
|
|
179
218
|
from information_schema.columns c
|
|
180
219
|
left join enum_types e on e.enum_schema=c.udt_schema
|
|
181
220
|
and (e.enum_name=c.udt_name or (c.data_type='ARRAY' and c.udt_name='_'||e.enum_name))
|
|
182
|
-
where table_schema=%(schema)s and table_name = %(tbl)s and (is_generated <> 'ALWAYS' or %(include_generated)s)
|
|
221
|
+
where table_schema=%(schema)s and table_name = %(tbl)s and (is_generated <> 'ALWAYS' or %(include_generated)s)
|
|
222
|
+
order by c.ordinal_position"""
|
|
183
223
|
async with self.con.cursor(row_factory=dict_row) as cur:
|
|
184
224
|
await cur.execute(
|
|
185
225
|
colquery,
|
|
@@ -236,24 +276,51 @@ class ComplexHelper:
|
|
|
236
276
|
if (schema, type_name) not in self.complex_types:
|
|
237
277
|
ci = await (EnumInfo if is_enum else CompositeInfo).fetch(con, Identifier(schema, type_name))
|
|
238
278
|
assert ci is not None, f"{'Enum' if is_enum else 'Complex'} type {name} not found in database"
|
|
279
|
+
# `recursive_convert` needs the Python types (`ci.enum`, `ci.python_type`) that psycopg only creates when
|
|
280
|
+
# a type is registered: do that in a scratch scope that no connection or cursor ever sees.
|
|
281
|
+
scratch = _Scope()
|
|
282
|
+
if isinstance(ci, EnumInfo):
|
|
283
|
+
register_enum(ci, scratch)
|
|
284
|
+
else:
|
|
285
|
+
register_composite(ci, scratch, make_object=_make_composite_object)
|
|
239
286
|
self.complex_types[(schema, type_name)] = ci
|
|
240
287
|
return self.complex_types[(schema, type_name)]
|
|
241
288
|
|
|
242
|
-
async def
|
|
289
|
+
async def register_on(self, context: AdaptContext, info: ComplexTypeInfo) -> None:
|
|
290
|
+
"""Register the psycopg adapters for `info` (and every composite/enum nested in it) in `context`, so that
|
|
291
|
+
what `recursive_convert` returns for it can be sent by statements run through that context, and values of
|
|
292
|
+
those types come back as those Python objects.
|
|
293
|
+
|
|
294
|
+
`info` is what `load_all_complex_types(table)` / `load_complex_type(table, column)` return for the column
|
|
295
|
+
you are writing, and the value `recursive_convert(value, info, con)` returns must be executed on the same
|
|
296
|
+
`context` (the cursor) that you registered it on.
|
|
297
|
+
|
|
298
|
+
Pass the **cursor** that runs the statement: psycopg gives each cursor its own copy of the adapters, so the
|
|
299
|
+
registration lasts as long as the cursor and nothing else on the connection (hence on a pooled one) is
|
|
300
|
+
affected. Passing the connection itself registers globally for that connection, for good (psycopg's
|
|
301
|
+
default); that is not what the `pg_*` helpers do. A no-op for JSONB (`info` being `Jsonb`/`JsonbArray`) and
|
|
302
|
+
for `None`."""
|
|
303
|
+
if isinstance(info, (CompositeInfo, EnumInfo)):
|
|
304
|
+
if self.system_complex_type_dict is None:
|
|
305
|
+
await self.load_complex_type_dict()
|
|
306
|
+
await self._recurse_register(info, context, set())
|
|
307
|
+
|
|
308
|
+
async def _recurse_register(
|
|
309
|
+
self, info: CompositeInfo | EnumInfo, context: AdaptContext, seen: set[CompositeInfo | EnumInfo]
|
|
310
|
+
) -> None:
|
|
243
311
|
assert self.system_complex_type_dict is not None, "System complex type dictionary not loaded"
|
|
244
|
-
if info
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
else:
|
|
248
|
-
register_composite(info, con)
|
|
249
|
-
self.registered.add(info)
|
|
312
|
+
if info in seen:
|
|
313
|
+
return
|
|
314
|
+
seen.add(info)
|
|
250
315
|
if isinstance(info, EnumInfo):
|
|
316
|
+
register_enum(info, context, enum=info.enum)
|
|
251
317
|
return
|
|
318
|
+
register_composite(info, context, factory=info.python_type, make_object=_make_composite_object)
|
|
252
319
|
for t in info.field_types:
|
|
253
320
|
if t in self.system_complex_type_dict:
|
|
254
321
|
name, typtype = self.system_complex_type_dict[t]
|
|
255
|
-
ci = await self._get_complex_type(name, typtype == "e", con)
|
|
256
|
-
await self._recurse_register(ci,
|
|
322
|
+
ci = await self._get_complex_type(name, typtype == "e", self.con)
|
|
323
|
+
await self._recurse_register(ci, context, seen)
|
|
257
324
|
|
|
258
325
|
async def recursive_convert(
|
|
259
326
|
self,
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
from string.templatelib import Interpolation, Template
|
|
4
4
|
from typing import Any, Callable, Literal, Mapping, Optional, Sequence, Type, TypeVar
|
|
5
5
|
|
|
6
|
+
from psycopg import AsyncCursor
|
|
6
7
|
from psycopg.connection_async import AsyncConnection
|
|
7
8
|
from psycopg.rows import dict_row
|
|
8
9
|
from psycopg.sql import Literal as SqlLiteral
|
|
@@ -26,7 +27,7 @@ def _resolve_helper(con: AsyncConnection, complex_helper: ComplexHelperArg) -> C
|
|
|
26
27
|
async def _select_list(
|
|
27
28
|
con: AsyncConnection, table_name: tuple[str, str], complex_helper: ComplexHelperArg
|
|
28
29
|
) -> Composable:
|
|
29
|
-
"""Column list for a SELECT, wrapping composite/enum/JSONB columns in
|
|
30
|
+
"""Column list for a SELECT or RETURNING, wrapping composite/enum/JSONB columns in
|
|
30
31
|
`to_jsonb(...)` so psycopg gets back plain Python values. Falls back to
|
|
31
32
|
`SELECT *` (no extra query) when no ComplexHelper is given."""
|
|
32
33
|
helper = _resolve_helper(con, complex_helper)
|
|
@@ -44,12 +45,14 @@ async def _select_list(
|
|
|
44
45
|
|
|
45
46
|
async def _convert_complex_values(
|
|
46
47
|
con: AsyncConnection,
|
|
48
|
+
cur: AsyncCursor[Any],
|
|
47
49
|
table_name: tuple[str, str],
|
|
48
50
|
data: dict,
|
|
49
51
|
complex_helper: ComplexHelperArg,
|
|
50
52
|
) -> dict:
|
|
51
53
|
"""Convert dict/list values destined for composite/enum columns into the
|
|
52
|
-
psycopg
|
|
54
|
+
psycopg types those columns need, and register those types on `cur` (the cursor that will run the
|
|
55
|
+
statement) only, never on the connection. A no-op (no extra query)
|
|
53
56
|
unless `data` actually contains dict/list values."""
|
|
54
57
|
helper = _resolve_helper(con, complex_helper)
|
|
55
58
|
if helper is None:
|
|
@@ -61,12 +64,14 @@ async def _convert_complex_values(
|
|
|
61
64
|
for k in candidate_keys:
|
|
62
65
|
info = await helper.load_complex_type(table_name, k)
|
|
63
66
|
if info is not None:
|
|
67
|
+
await helper.register_on(cur, info)
|
|
64
68
|
converted[k] = await helper.recursive_convert(data[k], info, con)
|
|
65
69
|
return converted
|
|
66
70
|
|
|
67
71
|
|
|
68
72
|
async def _convert_complex_values_many(
|
|
69
73
|
con: AsyncConnection,
|
|
74
|
+
cur: AsyncCursor[Any],
|
|
70
75
|
table_name: tuple[str, str],
|
|
71
76
|
rows: Sequence[dict],
|
|
72
77
|
complex_helper: ComplexHelperArg,
|
|
@@ -81,6 +86,8 @@ async def _convert_complex_values_many(
|
|
|
81
86
|
complex_keys = {k for k, info in infos.items() if info is not None}
|
|
82
87
|
if not complex_keys:
|
|
83
88
|
return rows
|
|
89
|
+
for k in complex_keys:
|
|
90
|
+
await helper.register_on(cur, infos[k])
|
|
84
91
|
converted_rows = []
|
|
85
92
|
for row in rows:
|
|
86
93
|
new_row = dict(row)
|
|
@@ -224,14 +231,19 @@ async def pg_insert(
|
|
|
224
231
|
*,
|
|
225
232
|
complex_helper: ComplexHelperArg = None,
|
|
226
233
|
) -> dict[str, Any]:
|
|
227
|
-
"""Insert one row and return the full row (RETURNING
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
vals=SQL(", ").join(Placeholder(k) for k in data),
|
|
233
|
-
)
|
|
234
|
+
"""Insert one row and return the full row (`RETURNING *`).
|
|
235
|
+
|
|
236
|
+
With a `complex_helper`, composite/enum/JSONB columns are returned as `to_jsonb(...)`, exactly like
|
|
237
|
+
`pg_retrieve` does: plain Python values (a composite as a dict, an enum as its label), whichever columns were
|
|
238
|
+
written. Without one it is a plain `RETURNING *` as psycopg reads it."""
|
|
234
239
|
async with con.cursor(row_factory=dict_row) as cur:
|
|
240
|
+
data = await _convert_complex_values(con, cur, table_name, data, complex_helper)
|
|
241
|
+
query = SQL("INSERT INTO {tbl} ({cols}) VALUES ({vals}) RETURNING {returning}").format(
|
|
242
|
+
tbl=Identifier(*table_name),
|
|
243
|
+
cols=SQL(", ").join(Identifier(k) for k in data),
|
|
244
|
+
vals=SQL(", ").join(Placeholder(k) for k in data),
|
|
245
|
+
returning=await _select_list(con, table_name, complex_helper),
|
|
246
|
+
)
|
|
235
247
|
await cur.execute(query, data)
|
|
236
248
|
row = await cur.fetchone()
|
|
237
249
|
assert row is not None
|
|
@@ -246,22 +258,26 @@ async def pg_update_dict(
|
|
|
246
258
|
*,
|
|
247
259
|
complex_helper: ComplexHelperArg = None,
|
|
248
260
|
) -> Any | None:
|
|
249
|
-
"""Update a row identified by primary_keys. Returns the raw row tuple.
|
|
261
|
+
"""Update a row identified by primary_keys. Returns the raw row tuple (in column order).
|
|
262
|
+
|
|
263
|
+
With a `complex_helper`, composite/enum/JSONB columns in it are plain Python values, like `pg_retrieve` returns
|
|
264
|
+
them (a composite as a dict, an enum as its label), whichever columns were written; without one, plain psycopg
|
|
265
|
+
behaviour (`RETURNING *`).
|
|
250
266
|
|
|
251
267
|
Pass `complex_helper` when the table has composite/enum columns among the
|
|
252
|
-
values being set — omitted,
|
|
253
|
-
passed straight through to psycopg)."""
|
|
254
|
-
data = await _convert_complex_values(con, table_name, data, complex_helper)
|
|
255
|
-
set_parts = [
|
|
256
|
-
SQL("{col} = {val}").format(col=Identifier(k), val=Placeholder(k)) for k in data if k not in primary_keys
|
|
257
|
-
]
|
|
258
|
-
where_parts = [SQL("{col} = {val}").format(col=Identifier(pk), val=Placeholder(pk)) for pk in primary_keys]
|
|
259
|
-
query = SQL("UPDATE {tbl} SET {sets} WHERE {where} RETURNING *").format(
|
|
260
|
-
tbl=Identifier(*table_name),
|
|
261
|
-
sets=SQL(", ").join(set_parts),
|
|
262
|
-
where=SQL(" AND ").join(where_parts),
|
|
263
|
-
)
|
|
268
|
+
values being set — omitted, values are passed straight through to psycopg."""
|
|
264
269
|
async with con.cursor() as cur:
|
|
270
|
+
data = await _convert_complex_values(con, cur, table_name, data, complex_helper)
|
|
271
|
+
set_parts = [
|
|
272
|
+
SQL("{col} = {val}").format(col=Identifier(k), val=Placeholder(k)) for k in data if k not in primary_keys
|
|
273
|
+
]
|
|
274
|
+
where_parts = [SQL("{col} = {val}").format(col=Identifier(pk), val=Placeholder(pk)) for pk in primary_keys]
|
|
275
|
+
query = SQL("UPDATE {tbl} SET {sets} WHERE {where} RETURNING {returning}").format(
|
|
276
|
+
tbl=Identifier(*table_name),
|
|
277
|
+
sets=SQL(", ").join(set_parts),
|
|
278
|
+
where=SQL(" AND ").join(where_parts),
|
|
279
|
+
returning=await _select_list(con, table_name, complex_helper),
|
|
280
|
+
)
|
|
265
281
|
await cur.execute(query, data)
|
|
266
282
|
return await cur.fetchone()
|
|
267
283
|
|
|
@@ -283,20 +299,25 @@ async def pg_upsert_dict(
|
|
|
283
299
|
*,
|
|
284
300
|
complex_helper: ComplexHelperArg = None,
|
|
285
301
|
) -> dict:
|
|
286
|
-
"""INSERT ... ON CONFLICT ... DO UPDATE, returns the row as a dict.
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
"INSERT INTO {tbl} ({cols}) VALUES ({vals}) ON CONFLICT ({pks}) DO UPDATE SET {updates} RETURNING *"
|
|
292
|
-
).format(
|
|
293
|
-
tbl=Identifier(*table_name),
|
|
294
|
-
cols=SQL(", ").join(Identifier(k) for k in fields),
|
|
295
|
-
vals=SQL(", ").join(Placeholder(k) for k in fields),
|
|
296
|
-
pks=SQL(", ").join(Identifier(pk) for pk in primary_keys),
|
|
297
|
-
updates=SQL(", ").join(updates),
|
|
298
|
-
)
|
|
302
|
+
"""INSERT ... ON CONFLICT ... DO UPDATE, returns the row as a dict.
|
|
303
|
+
|
|
304
|
+
With a `complex_helper`, composite/enum/JSONB columns in it are plain Python values, like `pg_retrieve` returns
|
|
305
|
+
them (a composite as a dict, an enum as its label), whichever columns were written; without one, plain psycopg
|
|
306
|
+
behaviour (`RETURNING *`)."""
|
|
299
307
|
async with con.cursor(row_factory=dict_row) as cur:
|
|
308
|
+
data = await _convert_complex_values(con, cur, table_name, data, complex_helper)
|
|
309
|
+
fields = list(data)
|
|
310
|
+
updates = [SQL("{col} = EXCLUDED.{col}").format(col=Identifier(k)) for k in fields]
|
|
311
|
+
query = SQL(
|
|
312
|
+
"INSERT INTO {tbl} ({cols}) VALUES ({vals}) ON CONFLICT ({pks}) DO UPDATE SET {updates} RETURNING {returning}"
|
|
313
|
+
).format(
|
|
314
|
+
tbl=Identifier(*table_name),
|
|
315
|
+
cols=SQL(", ").join(Identifier(k) for k in fields),
|
|
316
|
+
vals=SQL(", ").join(Placeholder(k) for k in fields),
|
|
317
|
+
pks=SQL(", ").join(Identifier(pk) for pk in primary_keys),
|
|
318
|
+
updates=SQL(", ").join(updates),
|
|
319
|
+
returning=await _select_list(con, table_name, complex_helper),
|
|
320
|
+
)
|
|
300
321
|
await cur.execute(query, data)
|
|
301
322
|
row = await cur.fetchone()
|
|
302
323
|
assert row is not None
|
|
@@ -328,30 +349,34 @@ async def pg_upsert_many_dict(
|
|
|
328
349
|
want a missing row to be a silent no-op rather than create one."""
|
|
329
350
|
if not data:
|
|
330
351
|
return
|
|
331
|
-
data = await _convert_complex_values_many(con, table_name, data, complex_helper)
|
|
332
|
-
fields = list(data[0])
|
|
333
|
-
if must_exist:
|
|
334
|
-
update_assignments = [
|
|
335
|
-
SQL("{col} = {val}").format(col=Identifier(k), val=Placeholder(k)) for k in fields if k not in primary_keys
|
|
336
|
-
]
|
|
337
|
-
target_eq = SQL(" AND ").join(
|
|
338
|
-
SQL("t.{col} = {val}").format(col=Identifier(pk), val=Placeholder(pk)) for pk in primary_keys
|
|
339
|
-
)
|
|
340
|
-
query = SQL("UPDATE {tbl} t SET {updates} WHERE {target_eq}").format(
|
|
341
|
-
tbl=Identifier(*table_name),
|
|
342
|
-
updates=SQL(", ").join(update_assignments),
|
|
343
|
-
target_eq=target_eq,
|
|
344
|
-
)
|
|
345
|
-
else:
|
|
346
|
-
updates = [SQL("{col} = EXCLUDED.{col}").format(col=Identifier(k)) for k in fields if k not in primary_keys]
|
|
347
|
-
query = SQL("INSERT INTO {tbl} ({cols}) VALUES ({vals}) ON CONFLICT ({pks}) DO UPDATE SET {updates}").format(
|
|
348
|
-
tbl=Identifier(*table_name),
|
|
349
|
-
cols=SQL(", ").join(Identifier(k) for k in fields),
|
|
350
|
-
vals=SQL(", ").join(Placeholder(k) for k in fields),
|
|
351
|
-
pks=SQL(", ").join(Identifier(pk) for pk in primary_keys),
|
|
352
|
-
updates=SQL(", ").join(updates),
|
|
353
|
-
)
|
|
354
352
|
async with con.cursor() as cur:
|
|
353
|
+
data = await _convert_complex_values_many(con, cur, table_name, data, complex_helper)
|
|
354
|
+
fields = list(data[0])
|
|
355
|
+
if must_exist:
|
|
356
|
+
update_assignments = [
|
|
357
|
+
SQL("{col} = {val}").format(col=Identifier(k), val=Placeholder(k))
|
|
358
|
+
for k in fields
|
|
359
|
+
if k not in primary_keys
|
|
360
|
+
]
|
|
361
|
+
target_eq = SQL(" AND ").join(
|
|
362
|
+
SQL("t.{col} = {val}").format(col=Identifier(pk), val=Placeholder(pk)) for pk in primary_keys
|
|
363
|
+
)
|
|
364
|
+
query = SQL("UPDATE {tbl} t SET {updates} WHERE {target_eq}").format(
|
|
365
|
+
tbl=Identifier(*table_name),
|
|
366
|
+
updates=SQL(", ").join(update_assignments),
|
|
367
|
+
target_eq=target_eq,
|
|
368
|
+
)
|
|
369
|
+
else:
|
|
370
|
+
updates = [SQL("{col} = EXCLUDED.{col}").format(col=Identifier(k)) for k in fields if k not in primary_keys]
|
|
371
|
+
query = SQL(
|
|
372
|
+
"INSERT INTO {tbl} ({cols}) VALUES ({vals}) ON CONFLICT ({pks}) DO UPDATE SET {updates}"
|
|
373
|
+
).format(
|
|
374
|
+
tbl=Identifier(*table_name),
|
|
375
|
+
cols=SQL(", ").join(Identifier(k) for k in fields),
|
|
376
|
+
vals=SQL(", ").join(Placeholder(k) for k in fields),
|
|
377
|
+
pks=SQL(", ").join(Identifier(pk) for pk in primary_keys),
|
|
378
|
+
updates=SQL(", ").join(updates),
|
|
379
|
+
)
|
|
355
380
|
await cur.executemany(query, data)
|
|
356
381
|
|
|
357
382
|
|
|
@@ -378,14 +403,14 @@ async def pg_insert_many(
|
|
|
378
403
|
if not data:
|
|
379
404
|
return
|
|
380
405
|
dict_data = [d if isinstance(d, dict) else d.model_dump() for d in data]
|
|
381
|
-
dict_data = await _convert_complex_values_many(con, table_name, dict_data, complex_helper)
|
|
382
|
-
fields = list(dict_data[0])
|
|
383
|
-
query = SQL("INSERT INTO {tbl} ({cols}) VALUES ({vals})").format(
|
|
384
|
-
tbl=Identifier(*table_name),
|
|
385
|
-
cols=SQL(", ").join(Identifier(k) for k in fields),
|
|
386
|
-
vals=SQL(", ").join(Placeholder(k) for k in fields),
|
|
387
|
-
)
|
|
388
406
|
async with con.cursor() as cur:
|
|
407
|
+
dict_data = await _convert_complex_values_many(con, cur, table_name, dict_data, complex_helper)
|
|
408
|
+
fields = list(dict_data[0])
|
|
409
|
+
query = SQL("INSERT INTO {tbl} ({cols}) VALUES ({vals})").format(
|
|
410
|
+
tbl=Identifier(*table_name),
|
|
411
|
+
cols=SQL(", ").join(Identifier(k) for k in fields),
|
|
412
|
+
vals=SQL(", ").join(Placeholder(k) for k in fields),
|
|
413
|
+
)
|
|
389
414
|
await cur.executemany(query, dict_data)
|
|
390
415
|
|
|
391
416
|
|
|
@@ -28,7 +28,9 @@ QueryParams = Mapping[str, Any] | Sequence[Any] | None
|
|
|
28
28
|
# The only things a query may be: a literal string (e.g. from `SqlLoader.load_sql()`), a sqlglot
|
|
29
29
|
# expression, or psycopg's own safe-composition types (`psycopg.sql` / a t-string). A plain `str`
|
|
30
30
|
# is rejected by the type checker on purpose, so user-controlled text can't be concatenated in.
|
|
31
|
-
type
|
|
31
|
+
# The sqlglot type is `exp.Expr` (any query: Select, Union, Subquery...), not `exp.Expression`: in
|
|
32
|
+
# sqlglot 30 `exp.Query` is an `exp.Expr` but not an `exp.Expression` (issue #56), so don't narrow it back.
|
|
33
|
+
type SqlQueryNoTemplate = LiteralString | exp.Expr | Composable
|
|
32
34
|
type SqlQuery = SqlQueryNoTemplate | Template
|
|
33
35
|
|
|
34
36
|
|
|
@@ -260,12 +260,15 @@ async def _insert_test_data(
|
|
|
260
260
|
await copy.write_row(tuple(row[c] for c in col_names))
|
|
261
261
|
return
|
|
262
262
|
|
|
263
|
+
for col in col_names:
|
|
264
|
+
# registered on this cursor only, never on the (possibly pooled) connection
|
|
265
|
+
await complex_helper.register_on(cur, complex_types.get(col))
|
|
263
266
|
for row in rows:
|
|
264
267
|
for col in col_names:
|
|
265
268
|
info = complex_types.get(col)
|
|
266
269
|
if info is not None and isinstance(row[col], (dict, list)):
|
|
267
|
-
# composite/enum/JSONB: needs
|
|
268
|
-
#
|
|
270
|
+
# composite/enum/JSONB: needs conversion to the
|
|
271
|
+
# Python objects registered on `cur` above. Anything else (plain scalars, and native
|
|
269
272
|
# Postgres arrays like text[], which aren't "complex" —
|
|
270
273
|
# psycopg already adapts a Python list to those natively)
|
|
271
274
|
# is left untouched.
|
|
@@ -234,7 +234,7 @@ deleted = await execute("DELETE FROM sessions WHERE user_id = %(id)s", {"id": 1}
|
|
|
234
234
|
- Passing `con=` runs on *your* connection: `fetch_all` never commits, closes or releases it. Without it, a connection is borrowed from `pool=` (or the `set_default_pool()` pool) and released afterwards.
|
|
235
235
|
- `statement_timeout=<seconds>` (optional) lets Postgres abort the query after that long, raising `QueryCanceled`; it is applied with `SET LOCAL` semantics for this call only (a borrowed `con` gets its previous value back). A plain value *replaces* the connection's own setting in both directions, so a per-call 900 s overrides a database-level safety net of 600 s; `statement_timeout=at_most(120)` (`from pgdevkit.db import at_most`) never loosens it: the effective timeout is `min(current, 120)`.
|
|
236
236
|
- `cancel=` takes an `asyncio.Event`: set it (e.g. when the HTTP client disconnects) and the running query is cancelled on the server, raising `psycopg.errors.QueryCanceled`. If the awaiting task is cancelled instead, the server-side query is cancelled too and `CancelledError` propagates as usual (not `QueryCanceled`), so no query keeps running unattended. Don't use `cancel=` on a `con` that concurrent tasks share (it aborts whatever statement is running on it); if the cancel request itself can't be delivered, the connection is closed. After `QueryCanceled` a borrowed `con` is usable again once you `await con.rollback()` (autocommit connections need nothing). To stream a whole result as JSON from FastAPI (it wires the disconnect itself) see `PostgresJsonResponse` below.
|
|
237
|
-
- `query` is typed `SqlQuery`: a literal string (`SqlLoader.load_sql()`), a sqlglot expression, a `psycopg.sql` composable or a t-string. A plain `str` fails type checking on purpose, so don't build SQL with f-strings/concatenation. A sqlglot expression is only as safe as the strings it was built from (its builders parse plain strings as SQL): pass user values as `exp.Placeholder` + `params`, never as literals/raw text. A literal `%` in a sqlglot expression (`LIKE 'a%'`, modulo) just works, with or without `params`; in a plain string or `psycopg.sql` composable it is psycopg's rule: write `%%` when you pass `params`. A t-string carries its own values, so don't also pass `params`.
|
|
237
|
+
- `query` is typed `SqlQuery`: a literal string (`SqlLoader.load_sql()`), a sqlglot expression (any `exp.Expr`, so Select, Union, Subquery and other `exp.Query` values are fine), a `psycopg.sql` composable or a t-string. A plain `str` fails type checking on purpose, so don't build SQL with f-strings/concatenation. A sqlglot expression is only as safe as the strings it was built from (its builders parse plain strings as SQL): pass user values as `exp.Placeholder` + `params`, never as literals/raw text. A literal `%` in a sqlglot expression (`LIKE 'a%'`, modulo) just works, with or without `params`; in a plain string or `psycopg.sql` composable it is psycopg's rule: write `%%` when you pass `params`. A t-string carries its own values, so don't also pass `params`.
|
|
238
238
|
|
|
239
239
|
### Dynamic SQL
|
|
240
240
|
|
|
@@ -262,13 +262,25 @@ See [`references/temporal-tables.md`](references/temporal-tables.md) for row-lev
|
|
|
262
262
|
### Custom Postgres types (composites, enums)
|
|
263
263
|
|
|
264
264
|
`pgdevkit.db.complex_types.ComplexHelper` detects a table's composite/enum/JSONB
|
|
265
|
-
columns and converts plain dict/list values into the psycopg
|
|
266
|
-
those columns
|
|
265
|
+
columns and converts plain dict/list values into the Python objects psycopg sends
|
|
266
|
+
for those columns. `pgdevkit.testdb.schema`'s test-data seeding uses it automatically; the `pg_*` CRUD helpers only do
|
|
267
267
|
when you pass `complex_helper="auto"` (or `complex_helper=ComplexHelper(con, normalizers={...})` to reshape a value
|
|
268
268
|
before conversion, keyed by composite type name, e.g. backfilling missing locale keys); without it values are not
|
|
269
269
|
converted and reads are a plain `SELECT *`.
|
|
270
270
|
|
|
271
|
-
|
|
271
|
+
**The helper never registers types on the connection** (psycopg's registration is connection-global and would stick
|
|
272
|
+
to a pooled connection: a plain `SELECT` of an enum would return psycopg's `Enum` member instead of the label, #54).
|
|
273
|
+
Writes register the enum/composite adapters on the one cursor that runs the statement, only for the columns where a
|
|
274
|
+
dict/list value needed converting; reads (`pg_retrieve*`) use `to_jsonb(...)` and register nothing. So after any
|
|
275
|
+
`pg_*` call the connection reads enums as label strings and composites as text (`'(1,2)'`), as before the call. `RETURNING` rows of
|
|
276
|
+
`pg_insert`/`pg_update_dict`/`pg_upsert_dict` (hence `pg_update`/`pg_upsert`) also use `to_jsonb(...)` for such
|
|
277
|
+
columns: with a helper they are plain Python values (a composite as a dict, an enum as its label) whichever columns
|
|
278
|
+
were written; without one it is a plain `RETURNING *`.
|
|
279
|
+
Using `recursive_convert` yourself: take `info` from `load_all_complex_types`/`load_complex_type`, call
|
|
280
|
+
`await helper.register_on(cur, info)` on your cursor, and execute the converted value on that same `cur` (the
|
|
281
|
+
connection as context would be a global registration again).
|
|
282
|
+
|
|
283
|
+
What the helper learns from the catalog (types and column types) is cached **per connection**, so
|
|
272
284
|
creating a `ComplexHelper(con)` per call (`pg_retrieve(con, M, pks, complex_helper=ComplexHelper(con))`) queries the
|
|
273
285
|
catalog only on that connection's first use, not every time. The cache cannot see later schema changes
|
|
274
286
|
(`CREATE TYPE`, `ALTER TYPE ... ADD VALUE`, new columns), nor DDL that was rolled back after it had been cached (test
|