plain.postgres 0.96.0__tar.gz → 0.98.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. plain_postgres-0.96.0/plain/postgres/README.md → plain_postgres-0.98.0/PKG-INFO +115 -50
  2. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/CHANGELOG.md +78 -0
  3. plain_postgres-0.96.0/PKG-INFO → plain_postgres-0.98.0/plain/postgres/README.md +101 -62
  4. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/__init__.py +5 -1
  5. plain_postgres-0.98.0/plain/postgres/adapters.py +41 -0
  6. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/cli/converge.py +2 -0
  7. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/cli/core.py +19 -7
  8. plain_postgres-0.98.0/plain/postgres/cli/decorators.py +24 -0
  9. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/cli/diagnose.py +2 -0
  10. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/cli/migrations.py +6 -0
  11. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/cli/schema.py +2 -0
  12. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/cli/sync.py +2 -0
  13. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/config.py +4 -0
  14. plain_postgres-0.98.0/plain/postgres/connection.py +719 -0
  15. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/database_url.py +58 -9
  16. plain_postgres-0.98.0/plain/postgres/db.py +158 -0
  17. plain_postgres-0.98.0/plain/postgres/default_settings.py +42 -0
  18. plain_postgres-0.98.0/plain/postgres/middleware.py +37 -0
  19. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/otel.py +192 -11
  20. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/preflight.py +31 -1
  21. plain_postgres-0.98.0/plain/postgres/sources.py +221 -0
  22. plain_postgres-0.98.0/plain/postgres/test/database.py +173 -0
  23. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/test/pytest.py +37 -30
  24. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/transaction.py +8 -12
  25. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/utils.py +24 -29
  26. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/pyproject.toml +3 -3
  27. plain_postgres-0.98.0/tests/conftest.py +11 -0
  28. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_connection_isolation.py +16 -16
  29. plain_postgres-0.98.0/tests/test_connection_lifecycle.py +383 -0
  30. plain_postgres-0.98.0/tests/test_connection_pool.py +162 -0
  31. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_convergence_timeouts.py +2 -1
  32. plain_postgres-0.98.0/tests/test_executor_connection_hook.py +103 -0
  33. plain_postgres-0.98.0/tests/test_management_connection.py +105 -0
  34. plain_postgres-0.98.0/tests/test_otel_metrics.py +243 -0
  35. plain_postgres-0.98.0/tests/test_read_only_transactions.py +87 -0
  36. plain_postgres-0.96.0/plain/postgres/connection.py +0 -1330
  37. plain_postgres-0.96.0/plain/postgres/connections.py +0 -98
  38. plain_postgres-0.96.0/plain/postgres/db.py +0 -37
  39. plain_postgres-0.96.0/plain/postgres/default_settings.py +0 -56
  40. plain_postgres-0.96.0/plain/postgres/test/utils.py +0 -18
  41. plain_postgres-0.96.0/tests/test_connection_lifecycle.py +0 -347
  42. plain_postgres-0.96.0/tests/test_read_only_transactions.py +0 -116
  43. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/.gitignore +0 -0
  44. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/CLAUDE.md +0 -0
  45. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/LICENSE +0 -0
  46. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/README.md +0 -0
  47. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -0
  48. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
  49. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/aggregates.py +0 -0
  50. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/base.py +0 -0
  51. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/cli/__init__.py +0 -0
  52. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/constants.py +0 -0
  53. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/constraints.py +0 -0
  54. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/convergence/__init__.py +0 -0
  55. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/convergence/analysis.py +0 -0
  56. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/convergence/fixes.py +0 -0
  57. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/convergence/planning.py +0 -0
  58. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/ddl.py +0 -0
  59. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/deletion.py +0 -0
  60. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/dialect.py +0 -0
  61. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/entrypoints.py +0 -0
  62. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/enums.py +0 -0
  63. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/exceptions.py +0 -0
  64. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/expressions.py +0 -0
  65. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/__init__.py +0 -0
  66. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/base.py +0 -0
  67. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/binary.py +0 -0
  68. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/boolean.py +0 -0
  69. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/duration.py +0 -0
  70. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/encrypted.py +0 -0
  71. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/json.py +0 -0
  72. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/mixins.py +0 -0
  73. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/network.py +0 -0
  74. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/numeric.py +0 -0
  75. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/primary_key.py +0 -0
  76. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/related.py +0 -0
  77. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/related_descriptors.py +0 -0
  78. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/related_lookups.py +0 -0
  79. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/related_managers.py +0 -0
  80. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
  81. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/reverse_related.py +0 -0
  82. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/temporal.py +0 -0
  83. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/text.py +0 -0
  84. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/timezones.py +0 -0
  85. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/fields/uuid.py +0 -0
  86. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/forms.py +0 -0
  87. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/functions/__init__.py +0 -0
  88. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/functions/comparison.py +0 -0
  89. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/functions/datetime.py +0 -0
  90. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/functions/math.py +0 -0
  91. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/functions/mixins.py +0 -0
  92. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/functions/random.py +0 -0
  93. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/functions/text.py +0 -0
  94. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/functions/uuid.py +0 -0
  95. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/functions/window.py +0 -0
  96. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/indexes.py +0 -0
  97. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/introspection/__init__.py +0 -0
  98. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/introspection/health.py +0 -0
  99. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/introspection/schema.py +0 -0
  100. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/lookups.py +0 -0
  101. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/meta.py +0 -0
  102. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/__init__.py +0 -0
  103. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/autodetector.py +0 -0
  104. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/exceptions.py +0 -0
  105. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/executor.py +0 -0
  106. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/graph.py +0 -0
  107. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/loader.py +0 -0
  108. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/migration.py +0 -0
  109. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/operations/__init__.py +0 -0
  110. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/operations/base.py +0 -0
  111. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/operations/fields.py +0 -0
  112. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/operations/models.py +0 -0
  113. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/operations/special.py +0 -0
  114. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/optimizer.py +0 -0
  115. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/questioner.py +0 -0
  116. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/recorder.py +0 -0
  117. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/serializer.py +0 -0
  118. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/state.py +0 -0
  119. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/utils.py +0 -0
  120. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/migrations/writer.py +0 -0
  121. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/options.py +0 -0
  122. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/query.py +0 -0
  123. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/query_utils.py +0 -0
  124. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/registry.py +0 -0
  125. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/schema.py +0 -0
  126. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/sql/__init__.py +0 -0
  127. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/sql/compiler.py +0 -0
  128. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/sql/constants.py +0 -0
  129. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/sql/datastructures.py +0 -0
  130. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/sql/query.py +0 -0
  131. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/sql/where.py +0 -0
  132. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/test/__init__.py +0 -0
  133. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/types.py +0 -0
  134. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/plain/postgres/types.pyi +0 -0
  135. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/forms.py +0 -0
  136. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0001_initial.py +0 -0
  137. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  138. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
  139. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
  140. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
  141. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
  142. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
  143. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
  144. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
  145. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
  146. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
  147. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
  148. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
  149. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
  150. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
  151. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
  152. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
  153. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/migrations/__init__.py +0 -0
  154. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/models/__init__.py +0 -0
  155. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/models/constraints.py +0 -0
  156. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/models/defaults.py +0 -0
  157. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/models/delete.py +0 -0
  158. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/models/encrypted.py +0 -0
  159. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/models/forms.py +0 -0
  160. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/models/indexes.py +0 -0
  161. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/models/iteration.py +0 -0
  162. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/models/mixins.py +0 -0
  163. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/models/nullability.py +0 -0
  164. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/models/querysets.py +0 -0
  165. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/models/relationships.py +0 -0
  166. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/models/trees.py +0 -0
  167. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/models/unregistered.py +0 -0
  168. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/urls.py +0 -0
  169. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/examples/views.py +0 -0
  170. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/settings.py +0 -0
  171. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/app/urls.py +0 -0
  172. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/conftest_convergence.py +0 -0
  173. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_autodetector_not_null_errors.py +0 -0
  174. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_autodetector_type_change.py +0 -0
  175. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_convergence.py +0 -0
  176. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_convergence_constraints.py +0 -0
  177. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_convergence_defaults.py +0 -0
  178. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_convergence_fk.py +0 -0
  179. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_convergence_indexes.py +0 -0
  180. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_convergence_nullability.py +0 -0
  181. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_database_url.py +0 -0
  182. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_db_expression_defaults.py +0 -0
  183. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_delete_behaviors.py +0 -0
  184. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_encrypted_fields.py +0 -0
  185. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_exceptions.py +0 -0
  186. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_field_defaults.py +0 -0
  187. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_functions_uuid.py +0 -0
  188. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_introspection.py +0 -0
  189. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_iterator.py +0 -0
  190. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_literal_default_persistence.py +0 -0
  191. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_m2m.py +0 -0
  192. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_manager_assignment.py +0 -0
  193. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_migration_executor.py +0 -0
  194. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_mixins.py +0 -0
  195. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_modelform_roundtrip.py +0 -0
  196. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_no_callable_defaults.py +0 -0
  197. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_random_string_field.py +0 -0
  198. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_raw_query.py +0 -0
  199. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_related.py +0 -0
  200. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_schema_normalize_type.py +0 -0
  201. {plain_postgres-0.96.0 → plain_postgres-0.98.0}/tests/test_schema_timeouts.py +0 -0
@@ -1,9 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: plain.postgres
3
+ Version: 0.98.0
4
+ Summary: Model your data and store it in a database.
5
+ Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
+ License-Expression: BSD-3-Clause
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.13
9
+ Requires-Dist: plain<1.0.0,>=0.134.0
10
+ Requires-Dist: psycopg-pool>=3.2
11
+ Requires-Dist: psycopg>=3.2
12
+ Requires-Dist: sqlparse>=0.3.1
13
+ Description-Content-Type: text/markdown
14
+
1
15
  # plain.postgres
2
16
 
3
17
  **Model your data and store it in a database.**
4
18
 
5
19
  - [Overview](#overview)
6
20
  - [Database connection](#database-connection)
21
+ - [Middleware](#middleware)
22
+ - [Bypassing a connection pooler for management operations](#bypassing-a-connection-pooler-for-management-operations)
7
23
  - [Querying](#querying)
8
24
  - [Schema management](#schema-management)
9
25
  - [Syncing](#syncing)
@@ -70,35 +86,82 @@ admin_users = User.query.filter(is_admin=True)
70
86
 
71
87
  ## Database connection
72
88
 
73
- To connect to a database, you can provide a `DATABASE_URL` environment variable:
89
+ Configure the database with a single URL. The canonical Plain setting is `POSTGRES_URL`:
90
+
91
+ ```python
92
+ # app/settings.py
93
+ POSTGRES_URL = "postgresql://user:password@localhost:5432/dbname"
94
+ ```
95
+
96
+ Or via environment variable:
97
+
98
+ ```sh
99
+ PLAIN_POSTGRES_URL=postgresql://user:password@localhost:5432/dbname
100
+ ```
101
+
102
+ Plain also reads the `DATABASE_URL` environment variable as a fallback — it's the widely-used convention for Postgres connection strings, so most hosting setups work without extra configuration:
74
103
 
75
104
  ```sh
76
105
  DATABASE_URL=postgresql://user:password@localhost:5432/dbname
77
106
  ```
78
107
 
79
- Or you can set the individual `POSTGRES_*` settings (via `PLAIN_POSTGRES_*` environment variables or in `app/settings.py`):
108
+ Precedence (highest to lowest): `PLAIN_POSTGRES_URL` `POSTGRES_URL` in `settings.py` `DATABASE_URL` environment variable.
109
+
110
+ The URL supports any libpq connection parameter as a query string — for example `?sslmode=require&application_name=web&connect_timeout=10`. These are parsed and passed through to the driver.
111
+
112
+ To explicitly disable the database (e.g. during Docker builds where no database is available), set the URL to the string `none`:
113
+
114
+ ```sh
115
+ PLAIN_POSTGRES_URL=none
116
+ ```
117
+
118
+ ### Middleware
119
+
120
+ Connections are checked out lazily on first use and returned to the pool when the HTTP request finishes. That's handled by [`DatabaseConnectionMiddleware`](./middleware.py#DatabaseConnectionMiddleware) — add it to `MIDDLEWARE` once you install `plain.postgres`:
80
121
 
81
122
  ```python
82
123
  # app/settings.py
83
- POSTGRES_HOST = "localhost"
84
- POSTGRES_PORT = 5432
85
- POSTGRES_DATABASE = "dbname"
86
- POSTGRES_USER = "user"
87
- POSTGRES_PASSWORD = "password"
124
+ MIDDLEWARE = [
125
+ "plain.postgres.DatabaseConnectionMiddleware",
126
+ # ...other middleware
127
+ ]
88
128
  ```
89
129
 
90
- If `DATABASE_URL` is set, it takes priority and the individual connection settings are parsed from it.
130
+ Place it near the top so downstream middleware can use the database inside `before_request` / `after_response` and still have the connection returned cleanly at the end.
91
131
 
92
- To explicitly disable the database (e.g. during Docker builds where no database is available), set `DATABASE_URL=none`.
132
+ For `StreamingResponse` / `AsyncStreamingResponse`, the connection is returned after the body is fully drained (not when the view returns), so generators that lazily query the database for example `Model.query.iterator()` or raw cursor loops — keep their cursor alive until the last chunk is sent.
93
133
 
94
- **PostgreSQL is the only supported database.** You need to install a PostgreSQL driver separately — [psycopg](https://www.psycopg.org/) is recommended:
134
+ Without the middleware, connections keep living on their thread until something explicitly calls `plain.postgres.db.return_database_connection()` (or the process exits). That's fine for short-lived scripts but wastes a connection per thread in long-running servers.
95
135
 
96
- ```bash
97
- uv add psycopg[binary] # Pre-built wheels, easiest for local development
98
- # or
99
- uv add psycopg[c] # Compiled against your system's libpq, recommended for production
136
+ ### Bypassing a connection pooler for management operations
137
+
138
+ Transaction-mode poolers (PlanetScale, Supabase's pooler, Neon's pooler, standalone pgbouncer in transaction mode) can't run DDL, long transactions, or `pg_dump`. To work around this, set a second URL that management commands use to reach Postgres directly:
139
+
140
+ ```sh
141
+ PLAIN_POSTGRES_URL=postgresql://app@pooler:6432/myapp
142
+ PLAIN_POSTGRES_MANAGEMENT_URL=postgresql://app@postgres:5432/myapp
100
143
  ```
101
144
 
145
+ When `POSTGRES_MANAGEMENT_URL` is set, these commands connect through it instead of `POSTGRES_URL`:
146
+
147
+ - `plain migrations create`, `plain migrations apply`, `plain migrations list`, `plain migrations prune`, `plain migrations squash`
148
+ - `plain postgres sync`, `plain postgres converge`, `plain postgres schema`
149
+ - `plain postgres diagnose`, `plain postgres drop-unknown-tables`, `plain postgres shell`
150
+
151
+ When it's unset, all commands use `POSTGRES_URL` — there's no behavior change for existing apps.
152
+
153
+ To route custom code through the management connection, use the `use_management_connection()` context manager:
154
+
155
+ ```python
156
+ from plain.postgres import use_management_connection
157
+
158
+ with use_management_connection():
159
+ # Any get_connection() / ORM calls inside this block use POSTGRES_MANAGEMENT_URL.
160
+ run_custom_schema_change()
161
+ ```
162
+
163
+ You _can_ point the two URLs at different Postgres roles — e.g. a least-privilege DML role for runtime and a DDL-capable role for management. Plain does not currently automate the grant/ownership plumbing that split requires (default privileges for newly-created tables, ownership reassignment, preflight checks that the runtime role can see the schema). If you adopt that pattern, you're responsible for wiring those up yourself.
164
+
102
165
  ## Querying
103
166
 
104
167
  Models come with a powerful query API through their [`QuerySet`](./query.py#QuerySet) interface:
@@ -406,32 +469,34 @@ with transaction.atomic():
406
469
  safe_operation() # This still runs in the outer transaction
407
470
  ```
408
471
 
409
- ### Read-only connections
472
+ ### Read-only transactions
410
473
 
411
- Enforce read-only mode on the current database connection using `read_only()`. Any write (INSERT, UPDATE, DELETE, DDL) raises `psycopg.errors.ReadOnlySqlTransaction`:
474
+ Run a block of code in a read-only transaction using `read_only()`. Any write (INSERT, UPDATE, DELETE, DDL) raises `psycopg.errors.ReadOnlySqlTransaction`:
412
475
 
413
476
  ```python
414
- from plain.postgres.connections import read_only
477
+ from plain.postgres.db import read_only
415
478
 
416
479
  with read_only():
417
480
  users = User.query.all() # reads work
418
481
  User.query.create(name="x") # raises psycopg.errors.ReadOnlySqlTransaction
419
482
  ```
420
483
 
421
- This works with both autocommit queries and explicit `atomic()` blocks.
484
+ `read_only()` opens a single `BEGIN READ ONLY` transaction for the block. Nested `atomic()` blocks inside become savepoints of the outer read-only transaction and inherit read-only.
422
485
 
423
- For sticky read-only mode (e.g., a shell session), use `set_read_only()` on the connection directly:
486
+ Because it opens its own transaction, `read_only()` cannot be entered inside an existing `atomic()` block doing so raises `TransactionManagementError`.
424
487
 
425
- ```python
426
- from plain.postgres.db import get_connection
488
+ Because the whole block is one transaction, catching a database error inside `read_only()` and trying to keep reading will fail — the transaction is aborted and any further query raises `TransactionManagementError`. Wrap the write in a nested `atomic()` savepoint if you need to recover and continue:
427
489
 
428
- conn = get_connection()
429
- conn.set_read_only(True) # all subsequent queries are read-only
430
- conn.set_read_only(False) # back to normal
490
+ ```python
491
+ with read_only():
492
+ try:
493
+ with atomic():
494
+ User.query.create(name="x") # raises, savepoint rolls back
495
+ except psycopg.errors.ReadOnlySqlTransaction:
496
+ pass
497
+ User.query.count() # still works — outer txn is healthy
431
498
  ```
432
499
 
433
- Read-only mode must be set outside a transaction — calling it inside `atomic()` raises `TransactionManagementError`.
434
-
435
500
  ## Schema management
436
501
 
437
502
  Schema changes fall into three categories, each with a different author and apply model:
@@ -1194,27 +1259,20 @@ These are static, code-level checks that catch issues before you deploy. The `di
1194
1259
 
1195
1260
  ## Settings
1196
1261
 
1197
- Connection settings are configured via `DATABASE_URL` or individual `POSTGRES_*` settings.
1198
-
1199
- When `DATABASE_URL` is set, it is parsed into the individual connection settings automatically. When `DATABASE_URL` is not set, the connection settings are required individually.
1200
-
1201
- Set `DATABASE_URL=none` to explicitly disable the database (e.g. during Docker image builds).
1202
-
1203
- | Setting | Type | Default | Env var |
1204
- | ---------------------------------------- | ------------- | ------- | ---------------------------------------------- |
1205
- | `POSTGRES_HOST` | `str` | | `PLAIN_POSTGRES_HOST` |
1206
- | `POSTGRES_PORT` | `int \| None` | `None` | `PLAIN_POSTGRES_PORT` |
1207
- | `POSTGRES_DATABASE` | `str` | | `PLAIN_POSTGRES_DATABASE` |
1208
- | `POSTGRES_USER` | `str` | | `PLAIN_POSTGRES_USER` |
1209
- | `POSTGRES_PASSWORD` | `Secret[str]` | | `PLAIN_POSTGRES_PASSWORD` |
1210
- | `POSTGRES_CONN_MAX_AGE` | `int` | `600` | `PLAIN_POSTGRES_CONN_MAX_AGE` |
1211
- | `POSTGRES_CONN_HEALTH_CHECKS` | `bool` | `True` | `PLAIN_POSTGRES_CONN_HEALTH_CHECKS` |
1212
- | `POSTGRES_OPTIONS` | `dict` | `{}` | — |
1213
- | `POSTGRES_TIME_ZONE` | `str \| None` | `None` | `PLAIN_POSTGRES_TIME_ZONE` |
1214
- | `POSTGRES_MIGRATION_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_LOCK_TIMEOUT` |
1215
- | `POSTGRES_MIGRATION_STATEMENT_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_STATEMENT_TIMEOUT` |
1216
- | `POSTGRES_CONVERGENCE_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_CONVERGENCE_LOCK_TIMEOUT` |
1217
- | `POSTGRES_CONVERGENCE_STATEMENT_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_CONVERGENCE_STATEMENT_TIMEOUT` |
1262
+ The connection is configured with a single URL (`POSTGRES_URL`). `DATABASE_URL` is read as a platform-compat fallback. Set the URL to `none` to explicitly disable the database (e.g. during Docker image builds).
1263
+
1264
+ | Setting | Type | Default | Env var |
1265
+ | ---------------------------------------- | ------------- | ----------------------- | ---------------------------------------------- |
1266
+ | `POSTGRES_URL` | `Secret[str]` | `$DATABASE_URL` or `""` | `PLAIN_POSTGRES_URL` |
1267
+ | `POSTGRES_MANAGEMENT_URL` | `Secret[str]` | `""` | `PLAIN_POSTGRES_MANAGEMENT_URL` |
1268
+ | `POSTGRES_POOL_MIN_SIZE` | `int` | `4` | `PLAIN_POSTGRES_POOL_MIN_SIZE` |
1269
+ | `POSTGRES_POOL_MAX_SIZE` | `int` | `20` | `PLAIN_POSTGRES_POOL_MAX_SIZE` |
1270
+ | `POSTGRES_POOL_MAX_LIFETIME` | `float` | `3600.0` | `PLAIN_POSTGRES_POOL_MAX_LIFETIME` |
1271
+ | `POSTGRES_POOL_TIMEOUT` | `float` | `30.0` | `PLAIN_POSTGRES_POOL_TIMEOUT` |
1272
+ | `POSTGRES_MIGRATION_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_LOCK_TIMEOUT` |
1273
+ | `POSTGRES_MIGRATION_STATEMENT_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_STATEMENT_TIMEOUT` |
1274
+ | `POSTGRES_CONVERGENCE_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_CONVERGENCE_LOCK_TIMEOUT` |
1275
+ | `POSTGRES_CONVERGENCE_STATEMENT_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_CONVERGENCE_STATEMENT_TIMEOUT` |
1218
1276
 
1219
1277
  See [`default_settings.py`](./default_settings.py) for more details.
1220
1278
 
@@ -1254,13 +1312,15 @@ Currently, Plain supports a single database connection per application. For appl
1254
1312
 
1255
1313
  ## Installation
1256
1314
 
1257
- Install the `plain.postgres` package from [PyPI](https://pypi.org/project/plain.postgres/):
1315
+ Install the `plain.postgres` package from [PyPI](https://pypi.org/project/plain.postgres/). You must also pick a `psycopg` implementation — `plain.postgres` depends on `psycopg` but does not pick one for you, so installing `plain.postgres` alone will not be able to connect.
1258
1316
 
1259
1317
  ```bash
1260
- uv add plain.postgres psycopg[binary]
1318
+ uv add plain.postgres psycopg[binary] # Pre-built wheels, easiest for local development
1319
+ # or
1320
+ uv add plain.postgres psycopg[c] # Compiled against your system's libpq, recommended for production
1261
1321
  ```
1262
1322
 
1263
- Then add to your `INSTALLED_PACKAGES`:
1323
+ Then add to your `INSTALLED_PACKAGES` and register [`DatabaseConnectionMiddleware`](#middleware) so pooled connections are returned at the end of each request:
1264
1324
 
1265
1325
  ```python
1266
1326
  # app/settings.py
@@ -1268,4 +1328,9 @@ INSTALLED_PACKAGES = [
1268
1328
  ...
1269
1329
  "plain.postgres",
1270
1330
  ]
1331
+
1332
+ MIDDLEWARE = [
1333
+ "plain.postgres.DatabaseConnectionMiddleware",
1334
+ ...
1335
+ ]
1271
1336
  ```
@@ -1,5 +1,83 @@
1
1
  # plain-postgres changelog
2
2
 
3
+ ## [0.98.0](https://github.com/dropseed/plain/releases/plain-postgres@0.98.0) (2026-04-22)
4
+
5
+ ### What's changed
6
+
7
+ - **Pool-backed connections via `psycopg_pool.ConnectionPool`.** A new `sources` abstraction routes `DatabaseConnection` through either a long-lived `PoolSource` (runtime) or a `DirectSource` (management / one-shot). Each request checks a connection out of the pool on first use and returns it when the HTTP request finishes. `psycopg>=3.2` and `psycopg-pool>=3.2` are now declared as hard dependencies. ([2a51b25](https://github.com/dropseed/plain/commit/2a51b25))
8
+ - **New `DatabaseConnectionMiddleware` (required).** Add `"plain.postgres.DatabaseConnectionMiddleware"` to `MIDDLEWARE` — it's what returns the pooled connection at the end of each request. For `StreamingResponse` / `AsyncStreamingResponse` the connection is returned after the body fully drains, so generators that lazily query the database (e.g. `Model.query.iterator()`) keep their cursor alive until the last chunk is sent. A new `postgres.middleware_installed` preflight check errors if the middleware is missing. ([2a51b25](https://github.com/dropseed/plain/commit/2a51b25))
9
+ - **Connection settings replaced with pool settings.** `POSTGRES_CONN_MAX_AGE` and `POSTGRES_CONN_HEALTH_CHECKS` are gone. Tune the pool with `POSTGRES_POOL_MIN_SIZE` (default `4`), `POSTGRES_POOL_MAX_SIZE` (default `20`), `POSTGRES_POOL_MAX_LIFETIME` seconds (default `3600.0`), and `POSTGRES_POOL_TIMEOUT` seconds (default `30.0`). Each is also available as a `PLAIN_POSTGRES_POOL_*` environment variable. ([2a51b25](https://github.com/dropseed/plain/commit/2a51b25))
10
+ - **`plain.postgres.connections` module removed.** `get_connection`, `has_connection`, `use_management_connection`, and `read_only` now live in `plain.postgres.db` (the underscore-less counterpart). ([2a51b25](https://github.com/dropseed/plain/commit/2a51b25))
11
+ - **`read_only()` is now pgbouncer-safe.** It opens a single `BEGIN READ ONLY` transaction for the block (previously a session-level `SET default_transaction_read_only = on`). Nested `atomic()` blocks become savepoints of the outer read-only transaction. Entering `read_only()` inside an existing `atomic()` block now raises `TransactionManagementError`. The old `DatabaseConnection.set_read_only()` method is removed. ([ebdec30](https://github.com/dropseed/plain/commit/ebdec30))
12
+ - **Added OTel pool + rowcount metrics and semconv polish.** Wires the `db.client.connection.*` metric family (count, max, idle.min/max, pending_requests, wait_time, use_time, timeouts) from the pool's stats and the acquire/release path, plus `db.client.response.returned_rows` for SELECT queries including streamed iterators. Query spans now carry `server.address` / `server.port` alongside `network.peer.*`, and the tracer/meter are tagged with the `plain.postgres` package version for `InstrumentationScope`. ([61278d5](https://github.com/dropseed/plain/commit/61278d5))
13
+ - **Moved `psql` CLI orchestration off `DatabaseConnection`.** New `postgres_cli_args` / `postgres_cli_env` helpers in `plain.postgres.database_url` build the arguments and environment for `psql`, `pg_dump`, etc.; `plain postgres shell` and the `plain-dev` backup client both use them. `DatabaseConnection.runshell()` and `executable_name` are gone. ([5b4a488](https://github.com/dropseed/plain/commit/5b4a488))
14
+ - **Removed dead connection-lifecycle plumbing.** `close_if_unusable_or_obsolete`, `close_if_health_check_failed`, `closed_in_transaction`, `is_usable`, `health_check_enabled`, `health_check_done`, `close_at`, `_maintenance_cursor`, and `DatabaseConnection.from_url` are gone — the pool handles recycling, health checks, and URL parsing. `close()` now validates there's no open atomic block instead of silently deferring. ([044e942](https://github.com/dropseed/plain/commit/044e942), [2a51b25](https://github.com/dropseed/plain/commit/2a51b25))
15
+ - **Inlined `pg_version` and removed `temporary_connection()`.** The single caller now reads `connection.info.server_version` directly; `temporary_connection()` has no remaining users. ([319f6ac](https://github.com/dropseed/plain/commit/319f6ac))
16
+ - **`APIResult` shorthand returns moved out of `View`.** Any internal views that relied on dict/int shorthand now wrap their returns in `JsonResponse` / `Response(status_code=...)` to match plain 0.134.0's narrower `View` handler return type. ([1935f3f](https://github.com/dropseed/plain/commit/1935f3f))
17
+ - **Adapter registration extracted to `plain.postgres.adapters`.** `PlainRangeDumper` and `get_adapters_template()` moved out of `connection.py` into their own module.
18
+
19
+ ### Upgrade instructions
20
+
21
+ - Requires `plain>=0.134.0`.
22
+ - **Add the middleware** to `app/settings.py`:
23
+
24
+ ```python
25
+ MIDDLEWARE = [
26
+ "plain.postgres.DatabaseConnectionMiddleware",
27
+ # ...the rest of your middleware
28
+ ]
29
+ ```
30
+
31
+ Place it near the top so downstream middleware can use the database inside `before_request` / `after_response` and still have the connection returned cleanly. Preflight will error if it's missing.
32
+
33
+ - **Replace `POSTGRES_CONN_MAX_AGE` / `POSTGRES_CONN_HEALTH_CHECKS`** with the pool settings (`POSTGRES_POOL_MIN_SIZE`, `POSTGRES_POOL_MAX_SIZE`, `POSTGRES_POOL_MAX_LIFETIME`, `POSTGRES_POOL_TIMEOUT`) or remove them to take the defaults.
34
+
35
+ - **Update imports from `plain.postgres.connections`** to `plain.postgres.db`:
36
+
37
+ ```python
38
+ # Before
39
+ from plain.postgres.connections import get_connection, read_only, use_management_connection
40
+
41
+ # After
42
+ from plain.postgres.db import get_connection, read_only, use_management_connection
43
+ ```
44
+
45
+ - **If you called `DatabaseConnection.set_read_only(True)`** for a sticky read-only session, switch to the `read_only()` context manager around the block you want read-only. If you need session-level enforcement outside a transaction, open a `DirectSource` connection yourself and issue `SET default_transaction_read_only = on` on it.
46
+
47
+ - **If you entered `read_only()` inside an `atomic()` block**, move `read_only()` to the outer position — it now owns the transaction. Nested `atomic()` blocks inside `read_only()` are fine (they become savepoints).
48
+
49
+ - **If you pinned `psycopg` via your own dependency**, make sure it's `>=3.2`, and add `psycopg-pool>=3.2` if you were installing psycopg without extras.
50
+
51
+ ## [0.97.0](https://github.com/dropseed/plain/releases/plain-postgres@0.97.0) (2026-04-21)
52
+
53
+ ### What's changed
54
+
55
+ - **Replaced individual `POSTGRES_*` connection fields with a single `POSTGRES_URL` setting.** `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DATABASE`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_OPTIONS`, and `POSTGRES_TIME_ZONE` are gone — configure the connection with one URL (e.g. `postgresql://user:pass@host:5432/db?sslmode=require`). `DATABASE_URL` is still read as a fallback. Set the URL to `none` to explicitly disable the database (e.g. during Docker image builds). ([770a74606463](https://github.com/dropseed/plain/commit/770a74606463))
56
+ - **Added `POSTGRES_MANAGEMENT_URL` for routing DDL through a separate connection.** When set, `plain migrations create|apply|list|prune|squash`, `plain postgres sync|converge|schema|diagnose|drop-unknown-tables|shell` connect through this URL instead of `POSTGRES_URL`. Use it to bypass transaction-mode poolers (PlanetScale, Supabase's pooler, Neon's pooler, pgbouncer) for schema changes, long transactions, and `pg_dump`. A new `use_management_connection()` context manager routes custom code through the same connection. When unset, all commands use `POSTGRES_URL` — no behavior change for existing apps. ([d1cc9630d049](https://github.com/dropseed/plain/commit/d1cc9630d049))
57
+ - **Extracted the test-database lifecycle off `DatabaseConnection`.** Test setup/teardown now lives in `plain.postgres.test` instead of coupling it to the runtime connection class. ([ea67f82c746c](https://github.com/dropseed/plain/commit/ea67f82c746c))
58
+ - **Removed thin psycopg re-export wrappers.** Internal code now imports directly from `psycopg` rather than the redundant Plain-level passthroughs. ([d1cb74100e0d](https://github.com/dropseed/plain/commit/d1cb74100e0d))
59
+
60
+ ### Upgrade instructions
61
+
62
+ - **Replace individual `POSTGRES_*` settings with `POSTGRES_URL`** in `app/settings.py` (or `PLAIN_POSTGRES_URL` in the environment). For example:
63
+
64
+ ```python
65
+ # Before
66
+ POSTGRES_HOST = "localhost"
67
+ POSTGRES_PORT = 5432
68
+ POSTGRES_DATABASE = "myapp"
69
+ POSTGRES_USER = "app"
70
+ POSTGRES_PASSWORD = "secret"
71
+
72
+ # After
73
+ POSTGRES_URL = "postgresql://app:secret@localhost:5432/myapp"
74
+ ```
75
+
76
+ Apps that already set `DATABASE_URL` in the environment don't need any change.
77
+
78
+ - **If `POSTGRES_OPTIONS` or `POSTGRES_TIME_ZONE` were set**, move them into the URL as query parameters (e.g. `?application_name=web&timezone=UTC`).
79
+ - **If you run behind a transaction-mode pooler**, consider setting `POSTGRES_MANAGEMENT_URL` to a direct-to-Postgres connection string so `plain migrations` and `plain postgres sync` can issue DDL.
80
+
3
81
  ## [0.96.0](https://github.com/dropseed/plain/releases/plain-postgres@0.96.0) (2026-04-17)
4
82
 
5
83
  ### What's changed
@@ -1,21 +1,11 @@
1
- Metadata-Version: 2.4
2
- Name: plain.postgres
3
- Version: 0.96.0
4
- Summary: Model your data and store it in a database.
5
- Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
- License-Expression: BSD-3-Clause
7
- License-File: LICENSE
8
- Requires-Python: >=3.13
9
- Requires-Dist: plain<1.0.0,>=0.132.0
10
- Requires-Dist: sqlparse>=0.3.1
11
- Description-Content-Type: text/markdown
12
-
13
1
  # plain.postgres
14
2
 
15
3
  **Model your data and store it in a database.**
16
4
 
17
5
  - [Overview](#overview)
18
6
  - [Database connection](#database-connection)
7
+ - [Middleware](#middleware)
8
+ - [Bypassing a connection pooler for management operations](#bypassing-a-connection-pooler-for-management-operations)
19
9
  - [Querying](#querying)
20
10
  - [Schema management](#schema-management)
21
11
  - [Syncing](#syncing)
@@ -82,35 +72,82 @@ admin_users = User.query.filter(is_admin=True)
82
72
 
83
73
  ## Database connection
84
74
 
85
- To connect to a database, you can provide a `DATABASE_URL` environment variable:
75
+ Configure the database with a single URL. The canonical Plain setting is `POSTGRES_URL`:
76
+
77
+ ```python
78
+ # app/settings.py
79
+ POSTGRES_URL = "postgresql://user:password@localhost:5432/dbname"
80
+ ```
81
+
82
+ Or via environment variable:
83
+
84
+ ```sh
85
+ PLAIN_POSTGRES_URL=postgresql://user:password@localhost:5432/dbname
86
+ ```
87
+
88
+ Plain also reads the `DATABASE_URL` environment variable as a fallback — it's the widely-used convention for Postgres connection strings, so most hosting setups work without extra configuration:
86
89
 
87
90
  ```sh
88
91
  DATABASE_URL=postgresql://user:password@localhost:5432/dbname
89
92
  ```
90
93
 
91
- Or you can set the individual `POSTGRES_*` settings (via `PLAIN_POSTGRES_*` environment variables or in `app/settings.py`):
94
+ Precedence (highest to lowest): `PLAIN_POSTGRES_URL` `POSTGRES_URL` in `settings.py` `DATABASE_URL` environment variable.
95
+
96
+ The URL supports any libpq connection parameter as a query string — for example `?sslmode=require&application_name=web&connect_timeout=10`. These are parsed and passed through to the driver.
97
+
98
+ To explicitly disable the database (e.g. during Docker builds where no database is available), set the URL to the string `none`:
99
+
100
+ ```sh
101
+ PLAIN_POSTGRES_URL=none
102
+ ```
103
+
104
+ ### Middleware
105
+
106
+ Connections are checked out lazily on first use and returned to the pool when the HTTP request finishes. That's handled by [`DatabaseConnectionMiddleware`](./middleware.py#DatabaseConnectionMiddleware) — add it to `MIDDLEWARE` once you install `plain.postgres`:
92
107
 
93
108
  ```python
94
109
  # app/settings.py
95
- POSTGRES_HOST = "localhost"
96
- POSTGRES_PORT = 5432
97
- POSTGRES_DATABASE = "dbname"
98
- POSTGRES_USER = "user"
99
- POSTGRES_PASSWORD = "password"
110
+ MIDDLEWARE = [
111
+ "plain.postgres.DatabaseConnectionMiddleware",
112
+ # ...other middleware
113
+ ]
100
114
  ```
101
115
 
102
- If `DATABASE_URL` is set, it takes priority and the individual connection settings are parsed from it.
116
+ Place it near the top so downstream middleware can use the database inside `before_request` / `after_response` and still have the connection returned cleanly at the end.
103
117
 
104
- To explicitly disable the database (e.g. during Docker builds where no database is available), set `DATABASE_URL=none`.
118
+ For `StreamingResponse` / `AsyncStreamingResponse`, the connection is returned after the body is fully drained (not when the view returns), so generators that lazily query the database for example `Model.query.iterator()` or raw cursor loops — keep their cursor alive until the last chunk is sent.
105
119
 
106
- **PostgreSQL is the only supported database.** You need to install a PostgreSQL driver separately — [psycopg](https://www.psycopg.org/) is recommended:
120
+ Without the middleware, connections keep living on their thread until something explicitly calls `plain.postgres.db.return_database_connection()` (or the process exits). That's fine for short-lived scripts but wastes a connection per thread in long-running servers.
107
121
 
108
- ```bash
109
- uv add psycopg[binary] # Pre-built wheels, easiest for local development
110
- # or
111
- uv add psycopg[c] # Compiled against your system's libpq, recommended for production
122
+ ### Bypassing a connection pooler for management operations
123
+
124
+ Transaction-mode poolers (PlanetScale, Supabase's pooler, Neon's pooler, standalone pgbouncer in transaction mode) can't run DDL, long transactions, or `pg_dump`. To work around this, set a second URL that management commands use to reach Postgres directly:
125
+
126
+ ```sh
127
+ PLAIN_POSTGRES_URL=postgresql://app@pooler:6432/myapp
128
+ PLAIN_POSTGRES_MANAGEMENT_URL=postgresql://app@postgres:5432/myapp
112
129
  ```
113
130
 
131
+ When `POSTGRES_MANAGEMENT_URL` is set, these commands connect through it instead of `POSTGRES_URL`:
132
+
133
+ - `plain migrations create`, `plain migrations apply`, `plain migrations list`, `plain migrations prune`, `plain migrations squash`
134
+ - `plain postgres sync`, `plain postgres converge`, `plain postgres schema`
135
+ - `plain postgres diagnose`, `plain postgres drop-unknown-tables`, `plain postgres shell`
136
+
137
+ When it's unset, all commands use `POSTGRES_URL` — there's no behavior change for existing apps.
138
+
139
+ To route custom code through the management connection, use the `use_management_connection()` context manager:
140
+
141
+ ```python
142
+ from plain.postgres import use_management_connection
143
+
144
+ with use_management_connection():
145
+ # Any get_connection() / ORM calls inside this block use POSTGRES_MANAGEMENT_URL.
146
+ run_custom_schema_change()
147
+ ```
148
+
149
+ You _can_ point the two URLs at different Postgres roles — e.g. a least-privilege DML role for runtime and a DDL-capable role for management. Plain does not currently automate the grant/ownership plumbing that split requires (default privileges for newly-created tables, ownership reassignment, preflight checks that the runtime role can see the schema). If you adopt that pattern, you're responsible for wiring those up yourself.
150
+
114
151
  ## Querying
115
152
 
116
153
  Models come with a powerful query API through their [`QuerySet`](./query.py#QuerySet) interface:
@@ -418,32 +455,34 @@ with transaction.atomic():
418
455
  safe_operation() # This still runs in the outer transaction
419
456
  ```
420
457
 
421
- ### Read-only connections
458
+ ### Read-only transactions
422
459
 
423
- Enforce read-only mode on the current database connection using `read_only()`. Any write (INSERT, UPDATE, DELETE, DDL) raises `psycopg.errors.ReadOnlySqlTransaction`:
460
+ Run a block of code in a read-only transaction using `read_only()`. Any write (INSERT, UPDATE, DELETE, DDL) raises `psycopg.errors.ReadOnlySqlTransaction`:
424
461
 
425
462
  ```python
426
- from plain.postgres.connections import read_only
463
+ from plain.postgres.db import read_only
427
464
 
428
465
  with read_only():
429
466
  users = User.query.all() # reads work
430
467
  User.query.create(name="x") # raises psycopg.errors.ReadOnlySqlTransaction
431
468
  ```
432
469
 
433
- This works with both autocommit queries and explicit `atomic()` blocks.
470
+ `read_only()` opens a single `BEGIN READ ONLY` transaction for the block. Nested `atomic()` blocks inside become savepoints of the outer read-only transaction and inherit read-only.
434
471
 
435
- For sticky read-only mode (e.g., a shell session), use `set_read_only()` on the connection directly:
472
+ Because it opens its own transaction, `read_only()` cannot be entered inside an existing `atomic()` block doing so raises `TransactionManagementError`.
436
473
 
437
- ```python
438
- from plain.postgres.db import get_connection
474
+ Because the whole block is one transaction, catching a database error inside `read_only()` and trying to keep reading will fail — the transaction is aborted and any further query raises `TransactionManagementError`. Wrap the write in a nested `atomic()` savepoint if you need to recover and continue:
439
475
 
440
- conn = get_connection()
441
- conn.set_read_only(True) # all subsequent queries are read-only
442
- conn.set_read_only(False) # back to normal
476
+ ```python
477
+ with read_only():
478
+ try:
479
+ with atomic():
480
+ User.query.create(name="x") # raises, savepoint rolls back
481
+ except psycopg.errors.ReadOnlySqlTransaction:
482
+ pass
483
+ User.query.count() # still works — outer txn is healthy
443
484
  ```
444
485
 
445
- Read-only mode must be set outside a transaction — calling it inside `atomic()` raises `TransactionManagementError`.
446
-
447
486
  ## Schema management
448
487
 
449
488
  Schema changes fall into three categories, each with a different author and apply model:
@@ -1206,27 +1245,20 @@ These are static, code-level checks that catch issues before you deploy. The `di
1206
1245
 
1207
1246
  ## Settings
1208
1247
 
1209
- Connection settings are configured via `DATABASE_URL` or individual `POSTGRES_*` settings.
1210
-
1211
- When `DATABASE_URL` is set, it is parsed into the individual connection settings automatically. When `DATABASE_URL` is not set, the connection settings are required individually.
1212
-
1213
- Set `DATABASE_URL=none` to explicitly disable the database (e.g. during Docker image builds).
1214
-
1215
- | Setting | Type | Default | Env var |
1216
- | ---------------------------------------- | ------------- | ------- | ---------------------------------------------- |
1217
- | `POSTGRES_HOST` | `str` | | `PLAIN_POSTGRES_HOST` |
1218
- | `POSTGRES_PORT` | `int \| None` | `None` | `PLAIN_POSTGRES_PORT` |
1219
- | `POSTGRES_DATABASE` | `str` | | `PLAIN_POSTGRES_DATABASE` |
1220
- | `POSTGRES_USER` | `str` | | `PLAIN_POSTGRES_USER` |
1221
- | `POSTGRES_PASSWORD` | `Secret[str]` | | `PLAIN_POSTGRES_PASSWORD` |
1222
- | `POSTGRES_CONN_MAX_AGE` | `int` | `600` | `PLAIN_POSTGRES_CONN_MAX_AGE` |
1223
- | `POSTGRES_CONN_HEALTH_CHECKS` | `bool` | `True` | `PLAIN_POSTGRES_CONN_HEALTH_CHECKS` |
1224
- | `POSTGRES_OPTIONS` | `dict` | `{}` | — |
1225
- | `POSTGRES_TIME_ZONE` | `str \| None` | `None` | `PLAIN_POSTGRES_TIME_ZONE` |
1226
- | `POSTGRES_MIGRATION_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_LOCK_TIMEOUT` |
1227
- | `POSTGRES_MIGRATION_STATEMENT_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_STATEMENT_TIMEOUT` |
1228
- | `POSTGRES_CONVERGENCE_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_CONVERGENCE_LOCK_TIMEOUT` |
1229
- | `POSTGRES_CONVERGENCE_STATEMENT_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_CONVERGENCE_STATEMENT_TIMEOUT` |
1248
+ The connection is configured with a single URL (`POSTGRES_URL`). `DATABASE_URL` is read as a platform-compat fallback. Set the URL to `none` to explicitly disable the database (e.g. during Docker image builds).
1249
+
1250
+ | Setting | Type | Default | Env var |
1251
+ | ---------------------------------------- | ------------- | ----------------------- | ---------------------------------------------- |
1252
+ | `POSTGRES_URL` | `Secret[str]` | `$DATABASE_URL` or `""` | `PLAIN_POSTGRES_URL` |
1253
+ | `POSTGRES_MANAGEMENT_URL` | `Secret[str]` | `""` | `PLAIN_POSTGRES_MANAGEMENT_URL` |
1254
+ | `POSTGRES_POOL_MIN_SIZE` | `int` | `4` | `PLAIN_POSTGRES_POOL_MIN_SIZE` |
1255
+ | `POSTGRES_POOL_MAX_SIZE` | `int` | `20` | `PLAIN_POSTGRES_POOL_MAX_SIZE` |
1256
+ | `POSTGRES_POOL_MAX_LIFETIME` | `float` | `3600.0` | `PLAIN_POSTGRES_POOL_MAX_LIFETIME` |
1257
+ | `POSTGRES_POOL_TIMEOUT` | `float` | `30.0` | `PLAIN_POSTGRES_POOL_TIMEOUT` |
1258
+ | `POSTGRES_MIGRATION_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_LOCK_TIMEOUT` |
1259
+ | `POSTGRES_MIGRATION_STATEMENT_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_STATEMENT_TIMEOUT` |
1260
+ | `POSTGRES_CONVERGENCE_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_CONVERGENCE_LOCK_TIMEOUT` |
1261
+ | `POSTGRES_CONVERGENCE_STATEMENT_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_CONVERGENCE_STATEMENT_TIMEOUT` |
1230
1262
 
1231
1263
  See [`default_settings.py`](./default_settings.py) for more details.
1232
1264
 
@@ -1266,13 +1298,15 @@ Currently, Plain supports a single database connection per application. For appl
1266
1298
 
1267
1299
  ## Installation
1268
1300
 
1269
- Install the `plain.postgres` package from [PyPI](https://pypi.org/project/plain.postgres/):
1301
+ Install the `plain.postgres` package from [PyPI](https://pypi.org/project/plain.postgres/). You must also pick a `psycopg` implementation — `plain.postgres` depends on `psycopg` but does not pick one for you, so installing `plain.postgres` alone will not be able to connect.
1270
1302
 
1271
1303
  ```bash
1272
- uv add plain.postgres psycopg[binary]
1304
+ uv add plain.postgres psycopg[binary] # Pre-built wheels, easiest for local development
1305
+ # or
1306
+ uv add plain.postgres psycopg[c] # Compiled against your system's libpq, recommended for production
1273
1307
  ```
1274
1308
 
1275
- Then add to your `INSTALLED_PACKAGES`:
1309
+ Then add to your `INSTALLED_PACKAGES` and register [`DatabaseConnectionMiddleware`](#middleware) so pooled connections are returned at the end of each request:
1276
1310
 
1277
1311
  ```python
1278
1312
  # app/settings.py
@@ -1280,4 +1314,9 @@ INSTALLED_PACKAGES = [
1280
1314
  ...
1281
1315
  "plain.postgres",
1282
1316
  ]
1317
+
1318
+ MIDDLEWARE = [
1319
+ "plain.postgres.DatabaseConnectionMiddleware",
1320
+ ...
1321
+ ]
1283
1322
  ```
@@ -6,7 +6,8 @@ from . import (
6
6
  # Imports that would create circular imports if sorted
7
7
  from .base import Model
8
8
  from .constraints import CheckConstraint, UniqueConstraint
9
- from .db import get_connection
9
+ from .db import get_connection, use_management_connection
10
+ from .middleware import DatabaseConnectionMiddleware
10
11
  from .deletion import CASCADE, NO_ACTION, RESTRICT, SET_NULL
11
12
  from .expressions import F
12
13
  from .enums import TextChoices
@@ -104,6 +105,9 @@ __all__ = [
104
105
  "ReverseManyToMany",
105
106
  # From db
106
107
  "get_connection",
108
+ "use_management_connection",
109
+ # From middleware
110
+ "DatabaseConnectionMiddleware",
107
111
  # From registry
108
112
  "register_model",
109
113
  "models_registry",
@@ -0,0 +1,41 @@
1
+ """Psycopg adapter registration for Plain.
2
+
3
+ The `AdaptersMap` returned by `get_adapters_template()` is attached to every
4
+ psycopg connection we open (via `build_connection_params` in `sources.py`).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from functools import lru_cache
10
+ from typing import Any
11
+
12
+ from psycopg import adapt, adapters
13
+ from psycopg.abc import PyFormat
14
+ from psycopg.postgres import types as pg_types
15
+ from psycopg.types.range import BaseRangeDumper, Range, RangeDumper
16
+ from psycopg.types.string import TextLoader
17
+
18
+ TSRANGE_OID = pg_types["tsrange"].oid
19
+ TSTZRANGE_OID = pg_types["tstzrange"].oid
20
+
21
+
22
+ class PlainRangeDumper(RangeDumper):
23
+ """A Range dumper customized for Plain."""
24
+
25
+ def upgrade(self, obj: Range[Any], format: PyFormat) -> BaseRangeDumper:
26
+ dumper = super().upgrade(obj, format)
27
+ if dumper is not self and dumper.oid == TSRANGE_OID:
28
+ dumper.oid = TSTZRANGE_OID
29
+ return dumper
30
+
31
+
32
+ @lru_cache
33
+ def get_adapters_template() -> adapt.AdaptersMap:
34
+ ctx = adapt.AdaptersMap(adapters)
35
+ # No-op JSON loader to avoid psycopg3 round trips
36
+ ctx.register_loader("jsonb", TextLoader)
37
+ # Treat inet/cidr as text
38
+ ctx.register_loader("inet", TextLoader)
39
+ ctx.register_loader("cidr", TextLoader)
40
+ ctx.register_dumper(Range, PlainRangeDumper)
41
+ return ctx