plain.postgres 0.108.0__tar.gz → 0.109.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 (222) hide show
  1. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/PKG-INFO +2 -2
  2. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/CHANGELOG.md +27 -0
  3. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/README.md +1 -1
  4. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/connection.py +14 -2
  5. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/convergence/analysis.py +163 -86
  6. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/__init__.py +1 -3
  7. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/related.py +5 -98
  8. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/related_descriptors.py +5 -23
  9. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/reverse_related.py +1 -32
  10. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/otel.py +30 -19
  11. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/query.py +0 -17
  12. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/types.pyi +0 -9
  13. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/pyproject.toml +1 -1
  14. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -18
  15. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/delete.py +0 -11
  16. plain_postgres-0.109.0/tests/conftest.py +26 -0
  17. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_connection_pool.py +0 -14
  18. plain_postgres-0.109.0/tests/internal/test_connection_self_heal.py +131 -0
  19. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_convergence_constraints.py +112 -47
  20. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_convergence_fk.py +1 -15
  21. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_convergence_indexes.py +20 -20
  22. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_literal_default_persistence.py +5 -5
  23. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_otel_metrics.py +29 -0
  24. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_delete_behaviors.py +0 -24
  25. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_related.py +0 -27
  26. plain_postgres-0.108.0/tests/conftest.py +0 -11
  27. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/.gitignore +0 -0
  28. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/CLAUDE.md +0 -0
  29. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/LICENSE +0 -0
  30. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/README.md +0 -0
  31. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/__init__.py +0 -0
  32. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/adapters.py +0 -0
  33. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -0
  34. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
  35. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/aggregates.py +0 -0
  36. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/base.py +0 -0
  37. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/cli/__init__.py +0 -0
  38. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/cli/converge.py +0 -0
  39. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/cli/core.py +0 -0
  40. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/cli/decorators.py +0 -0
  41. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/cli/diagnose.py +0 -0
  42. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/cli/migrations.py +0 -0
  43. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/cli/schema.py +0 -0
  44. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/cli/sync.py +0 -0
  45. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/config.py +0 -0
  46. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/constants.py +0 -0
  47. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/constraints.py +0 -0
  48. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/convergence/__init__.py +0 -0
  49. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/convergence/fixes.py +0 -0
  50. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/convergence/planning.py +0 -0
  51. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/database_url.py +0 -0
  52. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/db.py +0 -0
  53. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/ddl.py +0 -0
  54. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/default_settings.py +0 -0
  55. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/deletion.py +0 -0
  56. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/dialect.py +0 -0
  57. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/entrypoints.py +0 -0
  58. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/enums.py +0 -0
  59. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/exceptions.py +0 -0
  60. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/expressions.py +0 -0
  61. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/base.py +0 -0
  62. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/binary.py +0 -0
  63. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/boolean.py +0 -0
  64. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/duration.py +0 -0
  65. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/encrypted.py +0 -0
  66. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/json.py +0 -0
  67. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/mixins.py +0 -0
  68. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/network.py +0 -0
  69. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/numeric.py +0 -0
  70. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/primary_key.py +0 -0
  71. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/related_lookups.py +0 -0
  72. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/related_managers.py +0 -0
  73. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
  74. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/temporal.py +0 -0
  75. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/text.py +0 -0
  76. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/timezones.py +0 -0
  77. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/fields/uuid.py +0 -0
  78. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/forms.py +0 -0
  79. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/functions/__init__.py +0 -0
  80. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/functions/comparison.py +0 -0
  81. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/functions/datetime.py +0 -0
  82. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/functions/math.py +0 -0
  83. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/functions/mixins.py +0 -0
  84. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/functions/random.py +0 -0
  85. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/functions/text.py +0 -0
  86. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/functions/uuid.py +0 -0
  87. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/functions/window.py +0 -0
  88. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/indexes.py +0 -0
  89. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/introspection/__init__.py +0 -0
  90. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/introspection/health/__init__.py +0 -0
  91. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
  92. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
  93. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/introspection/health/checks_structural.py +0 -0
  94. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/introspection/health/context.py +0 -0
  95. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/introspection/health/helpers.py +0 -0
  96. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/introspection/health/ownership.py +0 -0
  97. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/introspection/health/runner.py +0 -0
  98. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/introspection/health/types.py +0 -0
  99. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/introspection/schema.py +0 -0
  100. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/lookups.py +0 -0
  101. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/meta.py +0 -0
  102. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/middleware.py +0 -0
  103. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/__init__.py +0 -0
  104. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/autodetector.py +0 -0
  105. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/exceptions.py +0 -0
  106. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/executor.py +0 -0
  107. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/graph.py +0 -0
  108. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/loader.py +0 -0
  109. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/migration.py +0 -0
  110. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/operations/__init__.py +0 -0
  111. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/operations/base.py +0 -0
  112. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/operations/fields.py +0 -0
  113. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/operations/models.py +0 -0
  114. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/operations/special.py +0 -0
  115. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/optimizer.py +0 -0
  116. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/questioner.py +0 -0
  117. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/recorder.py +0 -0
  118. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/serializer.py +0 -0
  119. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/state.py +0 -0
  120. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/utils.py +0 -0
  121. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/migrations/writer.py +0 -0
  122. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/options.py +0 -0
  123. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/preflight/__init__.py +0 -0
  124. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/preflight/database.py +0 -0
  125. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/preflight/indexes.py +0 -0
  126. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/preflight/models.py +0 -0
  127. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/query_utils.py +0 -0
  128. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/registry.py +0 -0
  129. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/schema.py +0 -0
  130. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/sources.py +0 -0
  131. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/sql/__init__.py +0 -0
  132. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/sql/compiler.py +0 -0
  133. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/sql/constants.py +0 -0
  134. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/sql/datastructures.py +0 -0
  135. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/sql/query.py +0 -0
  136. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/sql/where.py +0 -0
  137. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/test/__init__.py +0 -0
  138. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/test/database.py +0 -0
  139. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/test/pytest.py +0 -0
  140. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/transaction.py +0 -0
  141. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/types.py +0 -0
  142. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/plain/postgres/utils.py +0 -0
  143. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/forms.py +0 -0
  144. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0001_initial.py +0 -0
  145. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  146. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
  147. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
  148. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
  149. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
  150. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
  151. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
  152. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
  153. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
  154. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
  155. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
  156. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
  157. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
  158. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
  159. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
  160. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/0018_storageparametersexample.py +0 -0
  161. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/migrations/__init__.py +0 -0
  162. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/__init__.py +0 -0
  163. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/constraints.py +0 -0
  164. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/defaults.py +0 -0
  165. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/encrypted.py +0 -0
  166. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/forms.py +0 -0
  167. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/indexes.py +0 -0
  168. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/iteration.py +0 -0
  169. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/mixins.py +0 -0
  170. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/nullability.py +0 -0
  171. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/querysets.py +0 -0
  172. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/relationships.py +0 -0
  173. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/storage_parameters.py +0 -0
  174. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/trees.py +0 -0
  175. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/models/unregistered.py +0 -0
  176. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/urls.py +0 -0
  177. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/examples/views.py +0 -0
  178. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/settings.py +0 -0
  179. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/app/urls.py +0 -0
  180. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/conftest_convergence.py +0 -0
  181. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_autodetector_not_null_errors.py +0 -0
  182. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_autodetector_type_change.py +0 -0
  183. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_connection_isolation.py +0 -0
  184. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_connection_lifecycle.py +0 -0
  185. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_constraint_violation_error.py +0 -0
  186. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_convergence.py +0 -0
  187. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_convergence_defaults.py +0 -0
  188. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_convergence_nullability.py +0 -0
  189. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_convergence_storage_parameters.py +0 -0
  190. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_convergence_timeouts.py +0 -0
  191. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_db_expression_defaults.py +0 -0
  192. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_diagnose.py +0 -0
  193. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_executor_connection_hook.py +0 -0
  194. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_fk_characterization.py +0 -0
  195. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_health.py +0 -0
  196. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_introspection.py +0 -0
  197. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_management_connection.py +0 -0
  198. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_migration_executor.py +0 -0
  199. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_no_callable_defaults.py +0 -0
  200. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_preflight_duplicate_indexes.py +0 -0
  201. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_preflight_fk_composite_hint.py +0 -0
  202. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_preflight_fk_coverage.py +0 -0
  203. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_schema_normalize_type.py +0 -0
  204. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/internal/test_schema_timeouts.py +0 -0
  205. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_create_update.py +0 -0
  206. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_database_url.py +0 -0
  207. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_deferred_loading.py +0 -0
  208. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_encrypted_fields.py +0 -0
  209. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_exceptions.py +0 -0
  210. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_field_defaults.py +0 -0
  211. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_functions_uuid.py +0 -0
  212. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_integrity_error_mapping.py +0 -0
  213. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_iterator.py +0 -0
  214. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_m2m.py +0 -0
  215. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_manager_assignment.py +0 -0
  216. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_manual_pk.py +0 -0
  217. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_mixins.py +0 -0
  218. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_modelform_roundtrip.py +0 -0
  219. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_queryset_repr.py +0 -0
  220. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_random_string_field.py +0 -0
  221. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_raw_query.py +0 -0
  222. {plain_postgres-0.108.0 → plain_postgres-0.109.0}/tests/public/test_read_only_transactions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain.postgres
3
- Version: 0.108.0
3
+ Version: 0.109.0
4
4
  Summary: Model your data and store it in a database.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-Expression: BSD-3-Clause
@@ -957,7 +957,7 @@ book.author.name # one query — loads the rest of the row
957
957
 
958
958
  The first access to any non-key field loads the whole row in a single query. There is no separate `author_id` attribute — `book.author.id` is the foreign key value, and it is type-checked because `book.author` is an `Author`. In loops, use `select_related()` to load related rows up front and avoid a query per row.
959
959
 
960
- The partial-instance shortcut relies on the database guaranteeing the row exists. A foreign key declared with `db_constraint=False` has no such guarantee, so it is queried on access instead — a stale key raises `DoesNotExist` right away rather than yielding a placeholder.
960
+ The partial-instance shortcut is safe because Plain always creates a database foreign-key constraint, so the referenced row is guaranteed to exist.
961
961
 
962
962
  ### Reverse relationships
963
963
 
@@ -1,5 +1,32 @@
1
1
  # plain-postgres changelog
2
2
 
3
+ ## [0.109.0](https://github.com/dropseed/plain/releases/plain-postgres@0.109.0) (2026-06-17)
4
+
5
+ ### What's changed
6
+
7
+ - **The `db_constraint` argument is removed from `ForeignKeyField` — every foreign key is now backed by a database `FOREIGN KEY` constraint, always.** Because the database guarantees the referenced row exists, accessing a foreign key always builds a partial related instance from just the stored id (no query); the old "query on access when unconstrained" path is gone. The `fields.foreign_key_unconstrained_requires_no_action` preflight check is also removed. ([9ec559db38](https://github.com/dropseed/plain/commit/9ec559db38))
8
+ - **`limit_choices_to` is removed from `ForeignKeyField` and `ManyToManyField`.** The supporting machinery goes with it: `RelatedField.get_choices()` / `get_limit_choices_to()`, `ForeignObjectRel.get_choices()`, and `QuerySet.complex_filter()` are removed, and `BLANK_CHOICE_DASH` is no longer exported from `plain.postgres.fields`. To restrict the rows offered for a relation, filter the queryset explicitly in your form or view. ([68b6ced4f0](https://github.com/dropseed/plain/commit/68b6ced4f0))
9
+ - **The redundant foreign-key existence pre-check is removed.** `ForeignKeyField.validate()` no longer runs a `SELECT ... EXISTS` query to confirm the target row exists — one fewer query per FK validation. The database `FOREIGN KEY` constraint already enforces this, rejecting an invalid reference at write time (as a `psycopg.IntegrityError`). ([68b6ced4f0](https://github.com/dropseed/plain/commit/68b6ced4f0))
10
+ - Internal: convergence (schema-drift) analysis now reuses a single session-private temp "probe" table per model across all of that model's round-trip comparisons, instead of creating and dropping one per index/constraint/default comparison — fewer DDL statements during schema checks. The internal `canon*` helpers were renamed to `probe`/`normalize`. ([67167a11ee](https://github.com/dropseed/plain/commit/67167a11ee), [06650bea63](https://github.com/dropseed/plain/commit/06650bea63))
11
+
12
+ ### Upgrade instructions
13
+
14
+ - Remove any `db_constraint=False` arguments from `ForeignKeyField`. Foreign keys are always DB-constrained now; an unconstrained FK (e.g. for soft/cross-system references) is no longer supported.
15
+ - Remove `limit_choices_to=` from `ForeignKeyField` / `ManyToManyField` definitions. To constrain the rows offered for a relation, filter the related queryset explicitly in your form or view.
16
+ - Replace any `QuerySet.complex_filter(...)` calls with `.filter(...)` — pass a dict of lookups or a `Q` object directly.
17
+ - Most projects use none of these and need no changes. The `/plain-upgrade` skill handles the rewrites.
18
+
19
+ ## [0.108.1](https://github.com/dropseed/plain/releases/plain-postgres@0.108.1) (2026-06-09)
20
+
21
+ ### What's changed
22
+
23
+ - `ensure_connection()` now detects a pooled connection the server has closed while it was held (Postgres restart, failover, idle timeout) and discards it before establishing a fresh one — logging a "Discarding dead database connection" warning — instead of repeatedly failing on the dead connection. Inside an atomic block the dead connection is intentionally left in place so the transaction's normal error handling runs, rather than silently continuing the rest of the block outside its transaction. ([9e82cb4454](https://github.com/dropseed/plain/commit/9e82cb4454))
24
+ - Query span instrumentation no longer pays for expensive attributes when the span is sampled out: the per-query Python stack walk (for `code.*` attributes) and the `DEBUG`-mode stringification of query parameters now only happen when the span is actually recording. The cheap connection attributes are still passed at span creation so attribute-aware samplers can see them in `should_sample()`. ([af736043b0](https://github.com/dropseed/plain/commit/af736043b0))
25
+
26
+ ### Upgrade instructions
27
+
28
+ - No changes required.
29
+
3
30
  ## [0.108.0](https://github.com/dropseed/plain/releases/plain-postgres@0.108.0) (2026-06-09)
4
31
 
5
32
  ### What's changed
@@ -944,7 +944,7 @@ book.author.name # one query — loads the rest of the row
944
944
 
945
945
  The first access to any non-key field loads the whole row in a single query. There is no separate `author_id` attribute — `book.author.id` is the foreign key value, and it is type-checked because `book.author` is an `Author`. In loops, use `select_related()` to load related rows up front and avoid a query per row.
946
946
 
947
- The partial-instance shortcut relies on the database guaranteeing the row exists. A foreign key declared with `db_constraint=False` has no such guarantee, so it is queried on access instead — a stale key raises `DoesNotExist` right away rather than yielding a placeholder.
947
+ The partial-instance shortcut is safe because Plain always creates a database foreign-key constraint, so the referenced row is guaranteed to exist.
948
948
 
949
949
  ### Reverse relationships
950
950
 
@@ -170,7 +170,19 @@ class DatabaseConnection:
170
170
  self.set_autocommit(True)
171
171
 
172
172
  def ensure_connection(self) -> None:
173
- """Guarantee that a connection to the database is established."""
173
+ """Guarantee that a live connection to the database is established."""
174
+ if (
175
+ self.connection is not None
176
+ and self.connection.closed
177
+ and not self.in_atomic_block
178
+ ):
179
+ # The server closed this connection while we held it (restart,
180
+ # failover, idle timeout) — psycopg marks it closed once an
181
+ # operation fails on it. Mid-atomic, swapping it would silently
182
+ # run the rest of the block outside its transaction — leave the
183
+ # dead connection for Atomic.__exit__'s error recovery instead.
184
+ logger.warning("Discarding dead database connection")
185
+ self.close()
174
186
  if self.connection is None:
175
187
  self.connect()
176
188
 
@@ -634,7 +646,7 @@ class DatabaseConnection:
634
646
  }
635
647
  # Now get indexes. Sort order, opclasses, INCLUDE, and predicates all
636
648
  # ride along inside `pg_get_indexdef` and are compared via the
637
- # canonical-tail round-trip in convergence — no need to introspect
649
+ # normalized-tail round-trip in convergence — no need to introspect
638
650
  # them here as separate columns.
639
651
  cursor.execute(
640
652
  """
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import contextvars
3
4
  import json
4
5
  import re
5
6
  from collections.abc import Iterator
@@ -370,14 +371,15 @@ def analyze_model(
370
371
  table_issues=["table missing from database"],
371
372
  )
372
373
 
373
- return ModelAnalysis(
374
- label=model.model_options.label,
375
- table=table_name,
376
- columns=_compare_columns(model, db, table_name, cursor),
377
- indexes=_compare_indexes(cursor, model, db, table_name),
378
- constraints=_compare_constraints(cursor, model, db, table_name),
379
- storage_parameter_drifts=_compare_storage_parameters(model, db, table_name),
380
- )
374
+ with _probe_session(cursor, model):
375
+ return ModelAnalysis(
376
+ label=model.model_options.label,
377
+ table=table_name,
378
+ columns=_compare_columns(model, db, table_name, cursor),
379
+ indexes=_compare_indexes(cursor, model, db, table_name),
380
+ constraints=_compare_constraints(cursor, model, db, table_name),
381
+ storage_parameter_drifts=_compare_storage_parameters(model, db, table_name),
382
+ )
381
383
 
382
384
 
383
385
  def _compare_storage_parameters(
@@ -538,7 +540,7 @@ def _extract_jsonb_literal(sql: str) -> str | None:
538
540
  return m.group(1).replace("''", "'")
539
541
 
540
542
 
541
- def _canonicalize_default_expr(
543
+ def _normalize_default_expr(
542
544
  cursor: CursorWrapper, model: type[Model], column: str, default_sql: str
543
545
  ) -> str:
544
546
  """Round-trip a column DEFAULT through Postgres so both sides of the
@@ -550,9 +552,9 @@ def _canonicalize_default_expr(
550
552
  comparison then sees inequality and reports drift.
551
553
  """
552
554
  try:
553
- with _canon_table(cursor, model):
555
+ with _probe_table(cursor, model):
554
556
  cursor.execute(
555
- f"ALTER TABLE {_CANON_TABLE} ALTER COLUMN {quote_name(column)} "
557
+ f"ALTER TABLE {_PROBE_TABLE} ALTER COLUMN {quote_name(column)} "
556
558
  f"SET DEFAULT {default_sql}"
557
559
  )
558
560
  cursor.execute(
@@ -561,11 +563,11 @@ def _canonicalize_default_expr(
561
563
  "WHERE a.attrelid = (SELECT oid FROM pg_class WHERE relname = %s "
562
564
  "AND relnamespace = pg_my_temp_schema()) "
563
565
  "AND a.attname = %s",
564
- [_CANON_TABLE, column],
566
+ [_PROBE_TABLE, column],
565
567
  )
566
568
  row = cursor.fetchone()
567
569
  return row[0] if row else ""
568
- except _CANON_FALLBACK_ERRORS:
570
+ except _PROBE_FALLBACK_ERRORS:
569
571
  return ""
570
572
 
571
573
 
@@ -589,14 +591,14 @@ def _compare_column_default(
589
591
  model_default_sql=expected_sql,
590
592
  )
591
593
 
592
- canonical_expected = _canonicalize_default_expr(
594
+ normalized_expected = _normalize_default_expr(
593
595
  cursor, field.model, field.column, expected_sql
594
596
  )
595
- if canonical_expected == actual.default_sql:
597
+ if normalized_expected == actual.default_sql:
596
598
  return None
597
- # Semantic compare for jsonb — PG canonicalizes object keys, which
599
+ # Semantic compare for jsonb — PG normalizes object keys, which
598
600
  # won't match Python's dict-insertion order even after a round-trip.
599
- m_json = _extract_jsonb_literal(canonical_expected)
601
+ m_json = _extract_jsonb_literal(normalized_expected)
600
602
  d_json = _extract_jsonb_literal(actual.default_sql)
601
603
  if m_json is not None and d_json is not None:
602
604
  try:
@@ -681,7 +683,7 @@ def _compare_indexes(
681
683
 
682
684
  # Check if definition matches
683
685
  if db_idx.definition:
684
- issue = _compare_canonical_index(
686
+ issue = _compare_normalized_index(
685
687
  cursor=cursor,
686
688
  model=model,
687
689
  expressions=index.expressions,
@@ -725,9 +727,9 @@ def _compare_indexes(
725
727
  if non_unique_indexes[n].access_method in MANAGED_INDEX_ACCESS_METHODS
726
728
  ]
727
729
 
728
- # Detect renames by canonical (round-tripped) index body. Build the cheap
730
+ # Detect renames by normalized (round-tripped) index body. Build the cheap
729
731
  # already-introspected side first so we can skip the per-missing
730
- # canonicalization loop on the steady-state path.
732
+ # normalization loop on the steady-state path.
731
733
  renamed_missing: set[str] = set()
732
734
  renamed_extra: set[str] = set()
733
735
 
@@ -740,7 +742,7 @@ def _compare_indexes(
740
742
  missing_by_def: dict[str, list[Index]] = {}
741
743
  if extra_by_def:
742
744
  for index in missing:
743
- expected_tail = _canonicalize_index_def(
745
+ expected_tail = _normalize_index_def(
744
746
  cursor,
745
747
  model,
746
748
  expressions=index.expressions,
@@ -750,7 +752,7 @@ def _compare_indexes(
750
752
  include=index.include,
751
753
  )
752
754
  if not expected_tail:
753
- # Canonicalization failed; bucketing under "" would conflate
755
+ # Normalization failed; bucketing under "" would conflate
754
756
  # multiple sentinel-failing indexes and disable rename
755
757
  # detection for the rest.
756
758
  continue
@@ -908,7 +910,7 @@ def _compare_unique_constraints(
908
910
  if expected_def:
909
911
  issue = f"definition differs: DB has {actual_def!r}, model expects {expected_def!r}"
910
912
  else:
911
- # Round-trip canonicalization couldn't complete; canonical
913
+ # Round-trip normalization couldn't complete; normalized
912
914
  # model text is unavailable for the diagnostic.
913
915
  issue = f"definition differs: DB has {actual_def!r}"
914
916
  drift = ConstraintDrift(
@@ -1013,7 +1015,7 @@ def _compare_check_constraints(
1013
1015
  if expected_def:
1014
1016
  issue = f"definition differs: DB has {actual_def!r}, model expects {expected_def!r}"
1015
1017
  else:
1016
- # Round-trip canonicalization couldn't complete; canonical
1018
+ # Round-trip normalization couldn't complete; normalized
1017
1019
  # model text is unavailable for the diagnostic.
1018
1020
  issue = f"definition differs: DB has {actual_def!r}"
1019
1021
  drift = ConstraintDrift(
@@ -1099,7 +1101,7 @@ def _compare_foreign_keys(
1099
1101
  # Value: (field_name, constraint_name, expected_on_delete_clause, expected_confdeltype)
1100
1102
  expected_fks: dict[tuple[str, str, str], tuple[str, str, str, str]] = {}
1101
1103
  for f in model._model_meta.local_fields:
1102
- if isinstance(f, ForeignKeyField) and f.db_constraint:
1104
+ if isinstance(f, ForeignKeyField):
1103
1105
  assert f.name is not None
1104
1106
  to_table = f.target_field.model.model_options.db_table
1105
1107
  to_column = f.target_field.column
@@ -1251,7 +1253,7 @@ def _detect_unique_renames(
1251
1253
  """Match missing and extra unique constraints by structure.
1252
1254
 
1253
1255
  Constraint-backed (not index_only): matched by resolved column tuple.
1254
- Index-only (condition/expression/opclass): matched by canonical
1256
+ Index-only (condition/expression/opclass): matched by normalized
1255
1257
  (round-tripped) index definition, which captures the full semantics
1256
1258
  including WHERE clauses, opclasses, and expressions.
1257
1259
  """
@@ -1290,7 +1292,7 @@ def _detect_unique_renames(
1290
1292
  old_def = actual_dict[old_name].definition
1291
1293
  if not old_def:
1292
1294
  continue
1293
- expected_tail = _canonicalize_index_def(
1295
+ expected_tail = _normalize_index_def(
1294
1296
  cursor,
1295
1297
  model,
1296
1298
  expressions=constraint.expressions,
@@ -1322,7 +1324,7 @@ def _detect_unique_renames(
1322
1324
  renamed_missing.add(constraint.name)
1323
1325
  renamed_extra.add(old_name)
1324
1326
 
1325
- # Phase 2: Expression-based — match by canonical (round-tripped) index
1327
+ # Phase 2: Expression-based — match by normalized (round-tripped) index
1326
1328
  # body. Build the cheap already-introspected extras side first so we can
1327
1329
  # skip the per-missing round-trip on the steady-state path.
1328
1330
  extra_by_def: dict[str, list[str]] = {}
@@ -1340,7 +1342,7 @@ def _detect_unique_renames(
1340
1342
  continue
1341
1343
  # constraint.fields is empty here (filtered above), so this is
1342
1344
  # the expression-based path — fields_orders is unused.
1343
- expected_tail = _canonicalize_index_def(
1345
+ expected_tail = _normalize_index_def(
1344
1346
  cursor,
1345
1347
  model,
1346
1348
  expressions=constraint.expressions,
@@ -1389,12 +1391,12 @@ def _detect_check_renames(
1389
1391
  model: type[Model],
1390
1392
  table: str,
1391
1393
  ) -> tuple[list[ConstraintStatus], set[str], set[str]]:
1392
- """Match missing and extra check constraints by canonical definition."""
1394
+ """Match missing and extra check constraints by normalized definition."""
1393
1395
  statuses: list[ConstraintStatus] = []
1394
1396
  renamed_missing: set[str] = set()
1395
1397
  renamed_extra: set[str] = set()
1396
1398
 
1397
- # Skip the round-trip canonicalization loop if there are no extras to
1399
+ # Skip the round-trip normalization loop if there are no extras to
1398
1400
  # potentially match — the steady-state path with no drift.
1399
1401
  extra_by_def: dict[str, list[str]] = {}
1400
1402
  for name in extra_names:
@@ -1406,7 +1408,7 @@ def _detect_check_renames(
1406
1408
  for constraint in missing:
1407
1409
  expected_def = _get_expected_check_definition(cursor, model, constraint)
1408
1410
  if not expected_def:
1409
- # Canonicalization failed; bucketing under "" would conflate
1411
+ # Normalization failed; bucketing under "" would conflate
1410
1412
  # multiple sentinel-failing constraints and disable rename
1411
1413
  # detection for the rest.
1412
1414
  continue
@@ -1454,7 +1456,7 @@ def _compare_index_only_unique(
1454
1456
  if not actual_def:
1455
1457
  return None, None
1456
1458
 
1457
- issue = _compare_canonical_index(
1459
+ issue = _compare_normalized_index(
1458
1460
  cursor=cursor,
1459
1461
  model=model,
1460
1462
  expressions=constraint.expressions,
@@ -1474,7 +1476,7 @@ def _compare_index_only_unique(
1474
1476
  return None, None
1475
1477
 
1476
1478
 
1477
- def _compare_canonical_index(
1479
+ def _compare_normalized_index(
1478
1480
  *,
1479
1481
  cursor: CursorWrapper,
1480
1482
  model: type[Model],
@@ -1489,11 +1491,11 @@ def _compare_canonical_index(
1489
1491
  """Compare a model index/constraint against pg_get_indexdef text.
1490
1492
 
1491
1493
  Round-trips the model side through Postgres so both sides come from
1492
- pg_get_indexdef, then string-compares the canonical `USING ...` bodies.
1494
+ pg_get_indexdef, then string-compares the normalized `USING ...` bodies.
1493
1495
 
1494
1496
  Returns an issue string if definitions differ, None if they match.
1495
1497
  """
1496
- expected_tail = _canonicalize_index_def(
1498
+ expected_tail = _normalize_index_def(
1497
1499
  cursor,
1498
1500
  model,
1499
1501
  expressions=expressions,
@@ -1505,9 +1507,9 @@ def _compare_canonical_index(
1505
1507
  )
1506
1508
  actual_tail = _index_def_tail(actual_def)
1507
1509
  if not expected_tail:
1508
- # Round-trip canonicalization couldn't complete (model SQL
1510
+ # Round-trip normalization couldn't complete (model SQL
1509
1511
  # incompatible with live shape). Report drift with the actual text;
1510
- # the canonical model text is unavailable for the diagnostic.
1512
+ # the normalized model text is unavailable for the diagnostic.
1511
1513
  return f"definition differs: DB has {actual_tail!r}"
1512
1514
 
1513
1515
  if actual_tail != expected_tail:
@@ -1518,11 +1520,11 @@ def _compare_canonical_index(
1518
1520
  return None
1519
1521
 
1520
1522
 
1521
- # Round-trip canonicalization: feed model-side SQL to Postgres on a
1523
+ # Round-trip normalization: feed model-side SQL to Postgres on a
1522
1524
  # session-private temp table, read back via pg_get_*.
1523
- _CANON_TABLE = "_plain_canon"
1524
- _CANON_CONSTRAINT = "_c"
1525
- _CANON_INDEX = "_canon_ix"
1525
+ _PROBE_TABLE = "_plain_convergence_probe"
1526
+ _PROBE_CONSTRAINT = "_c"
1527
+ _PROBE_INDEX = "_probe_ix"
1526
1528
 
1527
1529
  # Errors raised by Postgres when the model SQL is incompatible with the live
1528
1530
  # table shape (unmigrated column types, references to dropped columns, etc.).
@@ -1534,7 +1536,7 @@ _CANON_INDEX = "_canon_ix"
1534
1536
  # privilege failures (InsufficientPrivilege, also a ProgrammingError) and
1535
1537
  # plain-side syntax bugs must propagate so users get a clear diagnostic
1536
1538
  # instead of silent drift noise.
1537
- _CANON_FALLBACK_ERRORS: tuple[type[Exception], ...] = (
1539
+ _PROBE_FALLBACK_ERRORS: tuple[type[Exception], ...] = (
1538
1540
  psycopg.errors.UndefinedColumn,
1539
1541
  psycopg.errors.UndefinedFunction,
1540
1542
  psycopg.errors.UndefinedObject,
@@ -1553,45 +1555,120 @@ _CANON_FALLBACK_ERRORS: tuple[type[Exception], ...] = (
1553
1555
  class ReadOnlyConnectionError(RuntimeError):
1554
1556
  """Raised when convergence analysis runs on a read-only connection.
1555
1557
 
1556
- Analysis canonicalizes the model side of each comparison by round-tripping
1558
+ Analysis normalizes the model side of each comparison by round-tripping
1557
1559
  SQL through a session-private temp table. That requires DDL, which is
1558
1560
  rejected on standby connections and inside `read_only()` blocks.
1559
1561
  """
1560
1562
 
1561
1563
 
1564
+ _READ_ONLY_MESSAGE = (
1565
+ "Convergence analysis requires write access — it normalizes model SQL by "
1566
+ "creating a session-private temp table. The current connection rejected "
1567
+ "DDL (read-only transaction or standby). Run analysis against a "
1568
+ "primary/writable connection."
1569
+ )
1570
+
1571
+
1572
+ @dataclass
1573
+ class _ProbeSession:
1574
+ """Reuse scope for one model's probe table (see `_probe_session`)."""
1575
+
1576
+ model: type[Model]
1577
+ created: bool = False
1578
+
1579
+
1580
+ # Set while `analyze_model` runs so the per-comparison probes share one temp
1581
+ # table instead of creating and dropping one each. A single connection is
1582
+ # single-threaded, but a ContextVar keeps the scope clean and never leaks the
1583
+ # session if analysis raises.
1584
+ _active_probe_session: contextvars.ContextVar[_ProbeSession | None] = (
1585
+ contextvars.ContextVar("active_probe_session", default=None)
1586
+ )
1587
+
1588
+
1562
1589
  @contextmanager
1563
- def _canon_table(cursor: CursorWrapper, model: type[Model]) -> Iterator[None]:
1564
- """Set up a session-private temp table mirroring the model's real table.
1565
-
1566
- `cursor.connection.transaction()` issues a SAVEPOINT when nested (or BEGIN
1567
- when run in autocommit). Either way, a model SQL statement incompatible
1568
- with the live column shape (e.g. a CHECK referencing a column whose live
1569
- type differs from what the model now declares) rolls back to this scope
1570
- rather than poisoning the surrounding analyze transaction. Helpers catch
1571
- psycopg errors and fall back to a sentinel drift still gets reported,
1572
- just without the canonical model text.
1573
-
1574
- The trailing DROP is schema-qualified to `pg_temp` so a stray real table
1575
- sharing the name (in the user's own schema) can't be hit by mistake. A
1576
- single connection is always single-threaded, so reusing the name across
1577
- helpers is safe.
1590
+ def _probe_session(cursor: CursorWrapper, model: type[Model]) -> Iterator[None]:
1591
+ """Reuse one probe table across every round-trip in a model's analysis.
1592
+
1593
+ The temp table is created lazily by the first probe inside this scope and
1594
+ dropped once on exit, so a model with no expression/constraint round-trips
1595
+ creates nothing while a model with many shares a single table instead of
1596
+ churning one per comparison. The exit DROP runs only on the success path —
1597
+ on an error it's skipped to avoid dropping against an aborted connection, so
1598
+ the table can briefly outlive the analysis (autocommit commits the CREATE);
1599
+ the next analysis recreates it cleanly (see `_create_probe_table`).
1600
+ """
1601
+ session = _ProbeSession(model=model)
1602
+ token = _active_probe_session.set(session)
1603
+ try:
1604
+ yield
1605
+ finally:
1606
+ _active_probe_session.reset(token)
1607
+ if session.created:
1608
+ cursor.execute(f"DROP TABLE pg_temp.{_PROBE_TABLE}")
1609
+
1610
+
1611
+ def _create_probe_table(cursor: CursorWrapper, model: type[Model]) -> None:
1612
+ """(Re)create the session-private probe table mirroring the model's real table.
1613
+
1614
+ Drops any stale table of the same name first so a leak can't wedge analysis:
1615
+ the shipped CLI runs convergence in autocommit, so the CREATE commits before
1616
+ later probes run, and a non-fallback error escaping `analyze_model` would
1617
+ otherwise leave the table on a pooled connection to collide with the next
1618
+ run's CREATE. The
1619
+ DROP is schema-qualified to `pg_temp` so a real table sharing the name (in
1620
+ the user's own schema) can't be hit by mistake. Raises ReadOnlyConnectionError
1621
+ when the connection rejects the DDL.
1578
1622
  """
1579
1623
  table = quote_name(model.model_options.db_table)
1580
1624
  try:
1581
1625
  with cursor.connection.transaction():
1582
- cursor.execute(f"CREATE TEMP TABLE {_CANON_TABLE} (LIKE {table})")
1583
- yield
1584
- cursor.execute(f"DROP TABLE pg_temp.{_CANON_TABLE}")
1626
+ cursor.execute(f"DROP TABLE IF EXISTS pg_temp.{_PROBE_TABLE}")
1627
+ cursor.execute(f"CREATE TEMP TABLE {_PROBE_TABLE} (LIKE {table})")
1585
1628
  except psycopg.errors.ReadOnlySqlTransaction as exc:
1586
- raise ReadOnlyConnectionError(
1587
- "Convergence analysis requires write access — it canonicalizes "
1588
- "model SQL by creating a session-private temp table. The current "
1589
- "connection rejected DDL (read-only transaction or standby). Run "
1590
- "analysis against a primary/writable connection."
1591
- ) from exc
1629
+ raise ReadOnlyConnectionError(_READ_ONLY_MESSAGE) from exc
1630
+
1631
+
1632
+ @contextmanager
1633
+ def _probe_table(cursor: CursorWrapper, model: type[Model]) -> Iterator[None]:
1634
+ """Provide an empty temp table mirroring the model's real table for one
1635
+ round-trip, isolating the probe's DDL so it can't leak into the analyze
1636
+ transaction.
1637
+
1638
+ Inside an active `_probe_session` for the same model, the table is created
1639
+ once and reused: each probe runs in a SAVEPOINT that is always rolled back,
1640
+ undoing the probe's ADD/ALTER while leaving the shared table in place.
1641
+ Outside a session, the table is created and dropped for this one probe.
1642
+
1643
+ `cursor.connection.transaction()` issues a SAVEPOINT when nested (or BEGIN in
1644
+ autocommit), so model SQL incompatible with the live column shape rolls back
1645
+ to this scope instead of poisoning the surrounding transaction; helpers catch
1646
+ the psycopg error and fall back to a sentinel.
1647
+ """
1648
+ session = _active_probe_session.get()
1649
+ if session is not None and session.model is model:
1650
+ # Reuse the session's shared table; `_probe_session` owns its lifetime.
1651
+ if not session.created:
1652
+ _create_probe_table(cursor, model)
1653
+ session.created = True
1654
+ drop_on_exit = False
1655
+ else:
1656
+ # No session: this probe owns the table for its lifetime.
1657
+ _create_probe_table(cursor, model)
1658
+ drop_on_exit = True
1659
+
1660
+ try:
1661
+ with cursor.connection.transaction() as savepoint:
1662
+ yield
1663
+ # Probe read its definition; undo the ADD/ALTER but keep the table.
1664
+ # psycopg rolls the SAVEPOINT back without surfacing an error.
1665
+ raise psycopg.Rollback(savepoint)
1666
+ finally:
1667
+ if drop_on_exit:
1668
+ cursor.execute(f"DROP TABLE pg_temp.{_PROBE_TABLE}")
1592
1669
 
1593
1670
 
1594
- def _canonicalize_constraint_def(
1671
+ def _normalize_constraint_def(
1595
1672
  cursor: CursorWrapper, model: type[Model], constraint_clause: str
1596
1673
  ) -> str:
1597
1674
  """Round-trip a constraint clause through Postgres so both sides of the
@@ -1601,28 +1678,28 @@ def _canonicalize_constraint_def(
1601
1678
 
1602
1679
  Returns "" if the model SQL is incompatible with the live table shape
1603
1680
  (e.g. unmigrated column-type drift). Drift still gets reported via the
1604
- inequality with the actual live definition; only the canonical model
1681
+ inequality with the actual live definition; only the normalized model
1605
1682
  text is omitted from the diagnostic.
1606
1683
  """
1607
1684
  try:
1608
- with _canon_table(cursor, model):
1685
+ with _probe_table(cursor, model):
1609
1686
  # Add as validated: the temp table is empty so the implicit scan is
1610
1687
  # instant. NOT VALID would leave a trailing " NOT VALID" suffix in
1611
1688
  # pg_get_constraintdef that the live constraint won't have.
1612
1689
  cursor.execute(
1613
- f"ALTER TABLE {_CANON_TABLE} "
1614
- f"ADD CONSTRAINT {_CANON_CONSTRAINT} {constraint_clause}"
1690
+ f"ALTER TABLE {_PROBE_TABLE} "
1691
+ f"ADD CONSTRAINT {_PROBE_CONSTRAINT} {constraint_clause}"
1615
1692
  )
1616
1693
  cursor.execute(
1617
1694
  "SELECT pg_get_constraintdef(c.oid) FROM pg_constraint c "
1618
1695
  "WHERE c.conname = %s "
1619
1696
  "AND c.conrelid = (SELECT oid FROM pg_class WHERE relname = %s "
1620
1697
  "AND relnamespace = pg_my_temp_schema())",
1621
- [_CANON_CONSTRAINT, _CANON_TABLE],
1698
+ [_PROBE_CONSTRAINT, _PROBE_TABLE],
1622
1699
  )
1623
1700
  row = cursor.fetchone()
1624
1701
  return row[0] if row else ""
1625
- except _CANON_FALLBACK_ERRORS:
1702
+ except _PROBE_FALLBACK_ERRORS:
1626
1703
  return ""
1627
1704
 
1628
1705
 
@@ -1630,10 +1707,10 @@ def _get_expected_check_definition(
1630
1707
  cursor: CursorWrapper, model: type[Model], constraint: CheckConstraint
1631
1708
  ) -> str:
1632
1709
  check_sql = compile_expression_sql(model, constraint.check)
1633
- return _canonicalize_constraint_def(cursor, model, f"CHECK ({check_sql})")
1710
+ return _normalize_constraint_def(cursor, model, f"CHECK ({check_sql})")
1634
1711
 
1635
1712
 
1636
- def _canonicalize_index_def(
1713
+ def _normalize_index_def(
1637
1714
  cursor: CursorWrapper,
1638
1715
  model: type[Model],
1639
1716
  *,
@@ -1644,7 +1721,7 @@ def _canonicalize_index_def(
1644
1721
  include: tuple[str, ...] | None = None,
1645
1722
  unique: bool = False,
1646
1723
  ) -> str:
1647
- """Round-trip an index through Postgres and return its canonical body.
1724
+ """Round-trip an index through Postgres and return its normalized body.
1648
1725
 
1649
1726
  Returns the `USING ... [INCLUDE (...)] [WHERE (...)]` tail of pg_get_indexdef,
1650
1727
  safe to compare directly against `_index_def_tail(actual_def)` from the
@@ -1653,7 +1730,7 @@ def _canonicalize_index_def(
1653
1730
 
1654
1731
  Returns "" if the model SQL is incompatible with the live table shape;
1655
1732
  comparison sites then see inequality and report drift without the
1656
- canonical model text.
1733
+ normalized model text.
1657
1734
  """
1658
1735
  if expressions:
1659
1736
  columns_sql = compile_index_expressions_sql(model, expressions)
@@ -1676,25 +1753,25 @@ def _canonicalize_index_def(
1676
1753
  create_kw = "CREATE UNIQUE INDEX" if unique else "CREATE INDEX"
1677
1754
 
1678
1755
  try:
1679
- with _canon_table(cursor, model):
1756
+ with _probe_table(cursor, model):
1680
1757
  cursor.execute(
1681
- f"{create_kw} {_CANON_INDEX} ON {_CANON_TABLE} "
1758
+ f"{create_kw} {_PROBE_INDEX} ON {_PROBE_TABLE} "
1682
1759
  f"({columns_sql}){include_sql}{where_sql}"
1683
1760
  )
1684
1761
  cursor.execute(
1685
1762
  "SELECT pg_get_indexdef(c.oid) FROM pg_class c "
1686
1763
  "WHERE c.relname = %s AND c.relnamespace = pg_my_temp_schema()",
1687
- [_CANON_INDEX],
1764
+ [_PROBE_INDEX],
1688
1765
  )
1689
1766
  row = cursor.fetchone()
1690
1767
  return _index_def_tail(row[0]) if row else ""
1691
- except _CANON_FALLBACK_ERRORS:
1768
+ except _PROBE_FALLBACK_ERRORS:
1692
1769
  return ""
1693
1770
 
1694
1771
 
1695
1772
  def _index_def_tail(definition: str) -> str:
1696
1773
  """Strip the `CREATE [UNIQUE] INDEX <name> ON <schema>.<table>` prefix
1697
- from a pg_get_indexdef output, leaving the canonical body that's safe to
1774
+ from a pg_get_indexdef output, leaving the normalized body that's safe to
1698
1775
  compare across different index names/tables."""
1699
1776
  using_pos = definition.find("USING ")
1700
1777
  return definition[using_pos:] if using_pos >= 0 else definition
@@ -1703,7 +1780,7 @@ def _index_def_tail(definition: str) -> str:
1703
1780
  def _get_expected_unique_definition(
1704
1781
  cursor: CursorWrapper, model: type[Model], constraint: UniqueConstraint
1705
1782
  ) -> str:
1706
- """Canonical UNIQUE definition for the model's constraint, as Postgres
1783
+ """Normalized UNIQUE definition for the model's constraint, as Postgres
1707
1784
  prints it.
1708
1785
 
1709
1786
  PostgreSQL only stores field-based unique constraints (with optional
@@ -1718,4 +1795,4 @@ def _get_expected_unique_definition(
1718
1795
  include_sql = build_include_sql(model, constraint.include)
1719
1796
  defer_sql = deferrable_sql(constraint.deferrable)
1720
1797
  clause = f"UNIQUE ({columns_sql}){include_sql}{defer_sql}"
1721
- return _canonicalize_constraint_def(cursor, model, clause)
1798
+ return _normalize_constraint_def(cursor, model, clause)
@@ -1,12 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from .base import DATABASE_DEFAULT as DATABASE_DEFAULT
3
4
  from .base import (
4
- BLANK_CHOICE_DASH,
5
5
  NOT_PROVIDED,
6
6
  Empty,
7
7
  Field,
8
8
  )
9
- from .base import DATABASE_DEFAULT as DATABASE_DEFAULT
10
9
  from .base import ChoicesField as ChoicesField
11
10
  from .base import DatabaseDefault as DatabaseDefault
12
11
  from .binary import BinaryField
@@ -26,7 +25,6 @@ from .text import EmailField, RandomStringField, TextField, URLField
26
25
  from .uuid import UUIDField
27
26
 
28
27
  __all__ = [
29
- "BLANK_CHOICE_DASH",
30
28
  "PrimaryKeyField",
31
29
  "BigIntegerField",
32
30
  "BinaryField",