pgdevkit 0.15.0__tar.gz → 0.15.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/PKG-INFO +17 -5
  2. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/README.md +16 -4
  3. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/db/complex_types.py +90 -23
  4. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/db/crud.py +90 -65
  5. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/testdb/schema.py +5 -2
  6. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pyproject.toml +1 -1
  7. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/skills/pgdevkit/SKILL.md +15 -3
  8. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/db/test_complex_types.py +174 -5
  9. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/db/test_crud.py +35 -6
  10. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/test_schema.py +3 -2
  11. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/uv.lock +1 -1
  12. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/.github/workflows/auto-release.yml +0 -0
  13. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/.github/workflows/python-publish.yml +0 -0
  14. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/.github/workflows/python-test.yml +0 -0
  15. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/.gitignore +0 -0
  16. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/.python-version +0 -0
  17. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/docs/database-layout.md +0 -0
  18. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/__init__.py +0 -0
  19. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/areas.py +0 -0
  20. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/backends/__init__.py +0 -0
  21. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/backends/base.py +0 -0
  22. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/backends/mssql.py +0 -0
  23. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/backends/postgres.py +0 -0
  24. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/cli.py +0 -0
  25. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/connection.py +0 -0
  26. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/db/__init__.py +0 -0
  27. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/db/connection.py +0 -0
  28. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/db/fetch.py +0 -0
  29. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/db/loader.py +0 -0
  30. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/db/model.py +0 -0
  31. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/db/mssql_crud.py +0 -0
  32. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/db/mssql_sql.py +0 -0
  33. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/db/transaction.py +0 -0
  34. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/dialect.py +0 -0
  35. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/diff.py +0 -0
  36. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/envtag.py +0 -0
  37. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/fastapi/__init__.py +0 -0
  38. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/fastapi/json_response.py +0 -0
  39. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/fetch_missing.py +0 -0
  40. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/introspect.py +0 -0
  41. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/lakebase.py +0 -0
  42. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/migrate.py +0 -0
  43. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/migrate_mssql.py +0 -0
  44. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/models.py +0 -0
  45. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/mssql_introspect.py +0 -0
  46. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/parser.py +0 -0
  47. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/schemas.py +0 -0
  48. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/sql_text.py +0 -0
  49. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/stats.py +0 -0
  50. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/testdb/__init__.py +0 -0
  51. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/testdb/_docker.py +0 -0
  52. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/testdb/api.py +0 -0
  53. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/testdb/config.py +0 -0
  54. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/testdb/constants.py +0 -0
  55. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/testdb/container.py +0 -0
  56. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/testdb/mssql/__init__.py +0 -0
  57. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/testdb/mssql/api.py +0 -0
  58. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/testdb/mssql/constants.py +0 -0
  59. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/testdb/mssql/container.py +0 -0
  60. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/testdb/naming.py +0 -0
  61. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/pgdevkit/testdb/query.py +0 -0
  62. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/skills/pgdevkit/references/dynamic-sql.md +0 -0
  63. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/skills/pgdevkit/references/temporal-tables.md +0 -0
  64. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/__init__.py +0 -0
  65. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/_asgi.py +0 -0
  66. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/_granian_app.py +0 -0
  67. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/conftest.py +0 -0
  68. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/db/__init__.py +0 -0
  69. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/db/test_connection.py +0 -0
  70. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/db/test_fetch.py +0 -0
  71. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/db/test_fetch_statement_timeout.py +0 -0
  72. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/db/test_loader.py +0 -0
  73. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/db/test_mssql_crud_live.py +0 -0
  74. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/db/test_mssql_crud_sql.py +0 -0
  75. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/db/test_transaction.py +0 -0
  76. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/fixtures/01_schema.sql +0 -0
  77. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/fixtures/02_types.sql +0 -0
  78. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/fixtures/03_tables.sql +0 -0
  79. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/fixtures/04_views.sql +0 -0
  80. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/fixtures/05_functions.sql +0 -0
  81. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/fixtures/06_indexes.sql +0 -0
  82. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/test_areas.py +0 -0
  83. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/test_cli_compare.py +0 -0
  84. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/test_compare.py +0 -0
  85. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/test_compare_mssql_live.py +0 -0
  86. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/test_connection.py +0 -0
  87. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/test_dialect.py +0 -0
  88. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/test_diff_mssql.py +0 -0
  89. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/test_envtag.py +0 -0
  90. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/test_fetch_missing.py +0 -0
  91. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/test_fetch_missing_cli.py +0 -0
  92. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/test_lakebase.py +0 -0
  93. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/test_migrate.py +0 -0
  94. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/test_migrate_areas.py +0 -0
  95. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/test_migrate_env.py +0 -0
  96. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/test_migrate_grants.py +0 -0
  97. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/test_migrate_mssql.py +0 -0
  98. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/test_migrate_mssql_live.py +0 -0
  99. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/test_migrate_schemas.py +0 -0
  100. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/test_mssql_introspect.py +0 -0
  101. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/test_parser_areas.py +0 -0
  102. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/test_parser_mssql.py +0 -0
  103. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/test_parser_schemas.py +0 -0
  104. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/test_pg_json_cancel.py +0 -0
  105. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/test_pg_json_granian.py +0 -0
  106. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/test_pg_json_response.py +0 -0
  107. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/test_pg_json_statement_timeout.py +0 -0
  108. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/test_schemas.py +0 -0
  109. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/test_stats_cli.py +0 -0
  110. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/test_stats_mssql_live.py +0 -0
  111. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/__init__.py +0 -0
  112. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/conftest.py +0 -0
  113. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
  114. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/event.sql +0 -0
  115. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/event_kind.sql +0 -0
  116. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
  117. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
  118. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +0 -0
  119. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/widget.init.sql +0 -0
  120. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
  121. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
  122. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
  123. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
  124. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
  125. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
  126. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
  127. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
  128. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/permissions/grants.sql +0 -0
  129. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/fixtures/database/schema/app.sql +0 -0
  130. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
  131. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
  132. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
  133. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
  134. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
  135. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/test_api.py +0 -0
  136. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/test_api_mssql.py +0 -0
  137. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/test_api_mssql_live.py +0 -0
  138. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/test_cli.py +0 -0
  139. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/test_config.py +0 -0
  140. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/test_config_mssql.py +0 -0
  141. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/test_constants.py +0 -0
  142. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/test_container.py +0 -0
  143. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/test_container_mssql.py +0 -0
  144. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/test_docker.py +0 -0
  145. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/test_mssql_constants.py +0 -0
  146. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/test_naming.py +0 -0
  147. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/test_query.py +0 -0
  148. {pgdevkit-0.15.0 → pgdevkit-0.15.1}/tests/testdb/test_schema_filtering.py +0 -0
  149. {pgdevkit-0.15.0 → 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.15.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
@@ -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. 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
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
@@ -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. 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
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
@@ -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), which of those are registered
32
- on the connection (an adapter registration lives and dies with the connection), and each table column's type."""
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 psycopg-registered types
60
- those columns need, recursing into nested composite fields.
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
- What it learns from the catalog (the types, the columns' types, which ones are registered on the connection) is
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
- return self._cache.registered
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
- self._cache.registered = value
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
- t.typtype
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
- c = await self._get_complex_type(f"{udt_schema}.{udt_name}", res["is_enum"], self.con)
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 _recurse_register(self, info: CompositeInfo | EnumInfo, con: AsyncConnection) -> None:
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 not in self.registered:
245
- if isinstance(info, EnumInfo):
246
- register_enum(info, con)
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, con)
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-registered types those columns need. A no-op (no extra query)
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
- data = await _convert_complex_values(con, table_name, data, complex_helper)
229
- query = SQL("INSERT INTO {tbl} ({cols}) VALUES ({vals}) RETURNING *").format(
230
- tbl=Identifier(*table_name),
231
- cols=SQL(", ").join(Identifier(k) for k in data),
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, this behaves exactly as before (plain values
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
- data = await _convert_complex_values(con, table_name, data, complex_helper)
288
- fields = list(data)
289
- updates = [SQL("{col} = EXCLUDED.{col}").format(col=Identifier(k)) for k in fields]
290
- query = SQL(
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
 
@@ -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 psycopg-registered-type
268
- # conversion. Anything else (plain scalars, and native
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.
@@ -11,7 +11,7 @@ packages = ["pgdevkit"]
11
11
 
12
12
  [project]
13
13
  name = "pgdevkit"
14
- version = "0.15.0"
14
+ version = "0.15.1"
15
15
  description = "A helper for developing with Postgres"
16
16
  readme = "README.md"
17
17
  requires-python = ">=3.14"
@@ -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-registered types
266
- those columns need. `pgdevkit.testdb.schema`'s test-data seeding uses it automatically; the `pg_*` CRUD helpers only do
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
- What the helper learns from the catalog (types, column types, adapter registrations) is cached **per connection**, so
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