plain.postgres 0.114.0__tar.gz → 0.115.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 (232) hide show
  1. plain_postgres-0.114.0/plain/postgres/README.md → plain_postgres-0.115.0/PKG-INFO +23 -4
  2. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/CHANGELOG.md +23 -0
  3. plain_postgres-0.114.0/PKG-INFO → plain_postgres-0.115.0/plain/postgres/README.md +10 -17
  4. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/__init__.py +1 -2
  5. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +2 -1
  6. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/base.py +16 -14
  7. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/connection.py +4 -10
  8. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/constraints.py +4 -35
  9. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/convergence/analysis.py +146 -98
  10. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/convergence/corrections.py +65 -25
  11. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/convergence/planning.py +8 -0
  12. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/ddl.py +0 -16
  13. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/deletion.py +3 -19
  14. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/dialect.py +0 -3
  15. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/related.py +37 -2
  16. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/introspection/schema.py +2 -0
  17. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/meta.py +17 -1
  18. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/serializer.py +3 -3
  19. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/query.py +3 -3
  20. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/test/pytest.py +0 -12
  21. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/utils.py +9 -0
  22. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/pyproject.toml +1 -1
  23. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -13
  24. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/delete.py +2 -9
  25. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_convergence_constraints.py +0 -60
  26. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_convergence_fk.py +223 -71
  27. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_convergence_timeouts.py +1 -0
  28. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_migration_executor.py +34 -1
  29. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_delete_behaviors.py +18 -59
  30. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_integrity_error_mapping.py +27 -0
  31. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/.gitignore +0 -0
  32. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/CLAUDE.md +0 -0
  33. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/LICENSE +0 -0
  34. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/README.md +0 -0
  35. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/adapters.py +0 -0
  36. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
  37. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/aggregates.py +0 -0
  38. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/cli/__init__.py +0 -0
  39. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/cli/converge.py +0 -0
  40. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/cli/core.py +0 -0
  41. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/cli/decorators.py +0 -0
  42. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/cli/diagnose.py +0 -0
  43. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/cli/migrations.py +0 -0
  44. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/cli/schema.py +0 -0
  45. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/cli/sync.py +0 -0
  46. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/config.py +0 -0
  47. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/constants.py +0 -0
  48. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/convergence/__init__.py +0 -0
  49. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/database_url.py +0 -0
  50. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/databases.py +0 -0
  51. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/db.py +0 -0
  52. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/default_settings.py +0 -0
  53. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/entrypoints.py +0 -0
  54. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/enums.py +0 -0
  55. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/exceptions.py +0 -0
  56. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/expressions.py +0 -0
  57. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/__init__.py +0 -0
  58. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/base.py +0 -0
  59. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/binary.py +0 -0
  60. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/boolean.py +0 -0
  61. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/duration.py +0 -0
  62. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/encrypted.py +0 -0
  63. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/json.py +0 -0
  64. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/mixins.py +0 -0
  65. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/network.py +0 -0
  66. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/numeric.py +0 -0
  67. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/primary_key.py +0 -0
  68. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/related_descriptors.py +0 -0
  69. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/related_lookups.py +0 -0
  70. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/related_managers.py +0 -0
  71. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
  72. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/reverse_related.py +0 -0
  73. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/temporal.py +0 -0
  74. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/text.py +0 -0
  75. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/timezones.py +0 -0
  76. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/fields/uuid.py +0 -0
  77. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/forms.py +0 -0
  78. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/functions/__init__.py +0 -0
  79. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/functions/comparison.py +0 -0
  80. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/functions/datetime.py +0 -0
  81. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/functions/math.py +0 -0
  82. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/functions/mixins.py +0 -0
  83. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/functions/random.py +0 -0
  84. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/functions/text.py +0 -0
  85. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/functions/uuid.py +0 -0
  86. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/functions/window.py +0 -0
  87. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/indexes.py +0 -0
  88. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/introspection/__init__.py +0 -0
  89. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/introspection/health/__init__.py +0 -0
  90. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
  91. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
  92. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/introspection/health/checks_structural.py +0 -0
  93. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/introspection/health/context.py +0 -0
  94. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/introspection/health/helpers.py +0 -0
  95. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/introspection/health/ownership.py +0 -0
  96. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/introspection/health/runner.py +0 -0
  97. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/introspection/health/types.py +0 -0
  98. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/lookups.py +0 -0
  99. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/middleware.py +0 -0
  100. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/__init__.py +0 -0
  101. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/autodetector.py +0 -0
  102. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/exceptions.py +0 -0
  103. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/executor.py +0 -0
  104. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/graph.py +0 -0
  105. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/loader.py +0 -0
  106. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/migration.py +0 -0
  107. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/operations/__init__.py +0 -0
  108. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/operations/base.py +0 -0
  109. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/operations/fields.py +0 -0
  110. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/operations/models.py +0 -0
  111. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/operations/special.py +0 -0
  112. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/optimizer.py +0 -0
  113. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/questioner.py +0 -0
  114. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/recorder.py +0 -0
  115. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/state.py +0 -0
  116. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/utils.py +0 -0
  117. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/migrations/writer.py +0 -0
  118. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/options.py +0 -0
  119. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/otel.py +0 -0
  120. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/preflight/__init__.py +0 -0
  121. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/preflight/database.py +0 -0
  122. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/preflight/indexes.py +0 -0
  123. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/preflight/models.py +0 -0
  124. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/query_utils.py +0 -0
  125. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/registry.py +0 -0
  126. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/schema.py +0 -0
  127. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/schema_lock.py +0 -0
  128. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/sources.py +0 -0
  129. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/sql/__init__.py +0 -0
  130. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/sql/compiler.py +0 -0
  131. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/sql/constants.py +0 -0
  132. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/sql/datastructures.py +0 -0
  133. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/sql/query.py +0 -0
  134. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/sql/where.py +0 -0
  135. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/test/__init__.py +0 -0
  136. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/test/database.py +0 -0
  137. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/transaction.py +0 -0
  138. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/types.py +0 -0
  139. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/plain/postgres/types.pyi +0 -0
  140. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/forms.py +0 -0
  141. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0001_initial.py +0 -0
  142. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  143. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
  144. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
  145. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
  146. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
  147. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
  148. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
  149. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
  150. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
  151. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
  152. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
  153. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
  154. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
  155. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
  156. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
  157. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/0018_storageparametersexample.py +0 -0
  158. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/migrations/__init__.py +0 -0
  159. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/__init__.py +0 -0
  160. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/constraints.py +0 -0
  161. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/defaults.py +0 -0
  162. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/encrypted.py +0 -0
  163. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/forms.py +0 -0
  164. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/indexes.py +0 -0
  165. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/iteration.py +0 -0
  166. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/mixins.py +0 -0
  167. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/nullability.py +0 -0
  168. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/querysets.py +0 -0
  169. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/relationships.py +0 -0
  170. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/storage_parameters.py +0 -0
  171. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/trees.py +0 -0
  172. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/models/unregistered.py +0 -0
  173. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/urls.py +0 -0
  174. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/examples/views.py +0 -0
  175. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/settings.py +0 -0
  176. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/app/urls.py +0 -0
  177. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/conftest.py +0 -0
  178. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/conftest_convergence.py +0 -0
  179. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_apply_replan.py +0 -0
  180. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_autodetector_not_null_errors.py +0 -0
  181. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_autodetector_type_change.py +0 -0
  182. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_connection_isolation.py +0 -0
  183. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_connection_lifecycle.py +0 -0
  184. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_connection_pool.py +0 -0
  185. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_connection_self_heal.py +0 -0
  186. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_constraint_violation_error.py +0 -0
  187. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_convergence.py +0 -0
  188. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_convergence_defaults.py +0 -0
  189. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_convergence_indexes.py +0 -0
  190. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_convergence_nullability.py +0 -0
  191. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_convergence_storage_parameters.py +0 -0
  192. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_databases_not_on_runtime_path.py +0 -0
  193. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_db_expression_defaults.py +0 -0
  194. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_diagnose.py +0 -0
  195. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_executor_connection_hook.py +0 -0
  196. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_fk_characterization.py +0 -0
  197. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_health.py +0 -0
  198. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_introspection.py +0 -0
  199. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_literal_default_persistence.py +0 -0
  200. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_management_connection.py +0 -0
  201. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_no_callable_defaults.py +0 -0
  202. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_otel_metrics.py +0 -0
  203. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_preflight_duplicate_indexes.py +0 -0
  204. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_preflight_fk_composite_hint.py +0 -0
  205. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_preflight_fk_coverage.py +0 -0
  206. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_rollback_exc_attribution.py +0 -0
  207. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_schema_lock.py +0 -0
  208. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_schema_normalize_type.py +0 -0
  209. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/internal/test_schema_timeouts.py +0 -0
  210. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_create_update.py +0 -0
  211. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_database_url.py +0 -0
  212. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_databases.py +0 -0
  213. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_deferred_loading.py +0 -0
  214. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_encrypted_fields.py +0 -0
  215. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_exceptions.py +0 -0
  216. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_field_defaults.py +0 -0
  217. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_functions_uuid.py +0 -0
  218. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_iterator.py +0 -0
  219. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_m2m.py +0 -0
  220. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_manager_assignment.py +0 -0
  221. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_manual_pk.py +0 -0
  222. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_mixins.py +0 -0
  223. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_modelform_roundtrip.py +0 -0
  224. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_only_empty_defaults.py +0 -0
  225. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_order_by_expressions.py +0 -0
  226. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_queryset_repr.py +0 -0
  227. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_queryset_slicing.py +0 -0
  228. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_random_string_field.py +0 -0
  229. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_raw_query.py +0 -0
  230. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_read_only_transactions.py +0 -0
  231. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_related.py +0 -0
  232. {plain_postgres-0.114.0 → plain_postgres-0.115.0}/tests/public/test_related_instance_filter.py +0 -0
@@ -1,3 +1,16 @@
1
+ Metadata-Version: 2.5
2
+ Name: plain.postgres
3
+ Version: 0.115.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.157.0
10
+ Requires-Dist: psycopg-pool>=3.2
11
+ Requires-Dist: psycopg>=3.2
12
+ Description-Content-Type: text/markdown
13
+
1
14
  # plain.postgres
2
15
 
3
16
  **Model your data and store it in a database.**
@@ -983,6 +996,14 @@ The first access to any non-key field loads the whole row in a single query. The
983
996
 
984
997
  The partial-instance shortcut is safe because Plain always creates a database foreign-key constraint, so the referenced row is guaranteed to exist.
985
998
 
999
+ ### Constraints are checked immediately
1000
+
1001
+ Every constraint is checked at the write that violates it, never at commit — the same as Postgres's own default. Inserting a child row that points at a parent that doesn't exist yet fails at that `INSERT`, with a traceback pointing at the offending write, and deleting a parent with `RESTRICT` children fails at that `DELETE`. Create parents before children. A cycle of foreign keys needs a nullable back-reference: create both rows, then `update()` the back-reference. Two required foreign keys pointing at each other can never be inserted.
1002
+
1003
+ To swap two rows' values under a unique constraint (reordering by `position`, say), move one row to a temporary value first — a single `UPDATE` that swaps them fails, because Postgres checks uniqueness per row.
1004
+
1005
+ A migration can add a column, backfill it in `RunPython`, and drop or alter columns on the same table, all in one transaction.
1006
+
986
1007
  ### Reverse relationships
987
1008
 
988
1009
  When you define a `ForeignKey` or `ManyToManyField`, Plain automatically creates a reverse accessor on the related model (like `author.book_set`). You can explicitly declare these reverse relationships using [`ReverseForeignKey`](./fields/reverse_descriptors.py#ReverseForeignKey) and [`ReverseManyToMany`](./fields/reverse_descriptors.py#ReverseManyToMany):
@@ -1087,7 +1108,7 @@ class User(postgres.Model):
1087
1108
 
1088
1109
  Field-level validation happens automatically based on field types and constraints.
1089
1110
 
1090
- **The database is authoritative for constraints.** `create()`/`update()` don't pre-check your declared unique/check constraints — they attempt the write, and if Postgres rejects it, translate the `IntegrityError` into a `ValidationError` (routed to the field for single-column uniques, `NON_FIELD_ERRORS` otherwise). You get the same field-level error you'd expect, the write costs no per-constraint `SELECT`, and a raced concurrent insert can't slip through as a 500. (FK violations, `NOT NULL`, and a hand-set primary-key collision have no declared constraint to map to and re-raise as the original `IntegrityError`. `create()` always inserts, so passing a stray `id` that already exists is rejected by Postgres as the original `IntegrityError`.)
1111
+ **The database is authoritative for constraints.** `create()`/`update()` don't pre-check your declared unique/check constraints — they attempt the write, and if Postgres rejects it, translate the `IntegrityError` into a `ValidationError` (routed to the field for single-column uniques, `NON_FIELD_ERRORS` otherwise). You get the same field-level error you'd expect, the write costs no per-constraint `SELECT`, and a raced concurrent insert can't slip through as a 500. A foreign key pointing at a row that doesn't exist maps the same way, as an error on that field. (`NOT NULL` and a hand-set primary-key collision have no declared constraint to map to and re-raise as the original `IntegrityError`. `create()` always inserts, so passing a stray `id` that already exists is rejected by Postgres as the original `IntegrityError`.)
1091
1112
 
1092
1113
  Because the rejected write reaches the database, it aborts the surrounding transaction. If you catch the `ValidationError` and want to keep using the transaction, wrap the write in `transaction.atomic()` so it rolls back to a savepoint:
1093
1114
 
@@ -1101,7 +1122,7 @@ except ValidationError:
1101
1122
 
1102
1123
  Forms are the exception: a `ModelForm` pre-checks constraints explicitly (a `validate_constraints()` call in its `_post_clean`) so it can surface every violation at once, then writes via `form.create()`/`form.update()` with validation already done. A direct `create()`/`update()` reports the first violation Postgres hits.
1103
1124
 
1104
- This applies to instance writes only. Set-based writes — `QuerySet.update()` and `bulk_create()` — raise the raw `psycopg.IntegrityError`, since there's no instance to attribute the error to. If you retry on a unique conflict, catch both:
1125
+ This applies to instance writes only. Set-based writes — `QuerySet.update()` and `bulk_create()` — raise the raw `psycopg.IntegrityError`, since there's no instance to attribute the error to, and so does a `delete()` blocked by `RESTRICT` children. If you retry on a unique conflict, catch both:
1105
1126
 
1106
1127
  ```python
1107
1128
  try:
@@ -1112,8 +1133,6 @@ except (psycopg.IntegrityError, ValidationError):
1112
1133
 
1113
1134
  For a plain insert-or-update with no per-row logic, `bulk_create(..., update_conflicts=True, unique_fields=[...])` is an atomic upsert with no race to catch.
1114
1135
 
1115
- Two caveats. The mapping covers **immediate** constraints — the default. An explicitly deferred constraint (`UniqueConstraint(deferrable=Deferrable.DEFERRED)`) is checked at commit, _after_ the write returns, so its violation still surfaces as a raw `psycopg.IntegrityError`. And when a row violates several constraints at once, a form's pre-check (or an explicit `validate_constraints()`) reports them all, while a direct `create()`/`update()` gets only the first one the database hits.
1116
-
1117
1136
  ### Indexes and constraints
1118
1137
 
1119
1138
  You can optimize queries and ensure data integrity with indexes and constraints. These are managed automatically by [convergence](#convergence) — just declare them on the model and run `postgres sync`.
@@ -1,5 +1,28 @@
1
1
  # plain-postgres changelog
2
2
 
3
+ ## [0.115.0](https://github.com/dropseed/plain/releases/plain-postgres@0.115.0) (2026-09-02)
4
+
5
+ ### What's changed
6
+
7
+ - **Foreign keys are now `NOT DEFERRABLE`** (Postgres's own default) and checked at each write. Plain inherited Django's `DEFERRABLE INITIALLY DEFERRED` foreign keys, added in 2007 so fixtures could contain forward references. Plain has no fixtures, and the deferral was causing real problems: a migration that adds a column, backfills it in `RunPython`, then alters the same table failed with "cannot ALTER TABLE ... because it has pending trigger events" whenever the backfill touched a row, and FK violations only surfaced at `COMMIT` with a traceback pointing at `atomic.__exit__` instead of the offending write ([8cbfa0cb40](https://github.com/dropseed/plain/commit/8cbfa0cb40))
8
+ - Convergence detects an existing `DEFERRABLE` foreign key as drift and fixes it with a catalog-only `ALTER CONSTRAINT ... NOT DEFERRABLE` — no revalidation scan, just a brief `ACCESS EXCLUSIVE` lock bounded by the usual `lock_timeout` — so the first `postgres sync` after upgrading rewrites every FK quickly. A legacy `DEFERRABLE` + `NOT VALID` foreign key gets both corrections in one pass ([8cbfa0cb40](https://github.com/dropseed/plain/commit/8cbfa0cb40))
9
+ - `NO_ACTION` is removed. Its only meaning was deferred checking — use `RESTRICT` ([8cbfa0cb40](https://github.com/dropseed/plain/commit/8cbfa0cb40))
10
+ - A foreign key violation from `create()`/`update()` now surfaces as a `ValidationError` routed to the `ForeignKeyField` — "Author with id 5 does not exist." — with code `invalid_choice` so forms can override it. It lands in the same `IntegrityError` mapping that unique and check constraints already used, so a missing related row reports like any other field error instead of a 500. The message is `ForeignKeyField.does_not_exist_error_message`. Set-based writes (`QuerySet.update()`, `bulk_create()`) and a `delete()` blocked by `RESTRICT` still raise the raw `psycopg` error, since there's no instance to attribute it to ([31df77e665](https://github.com/dropseed/plain/commit/31df77e665))
11
+ - That mapping keys on the foreign key's generated constraint name, so convergence now treats a stale name — left behind by `RenameField`/`RenameModel`, which only rename the column or table — as drift and renames the constraint. Because the write-path mapping depends on those corrections having run, FK renames and the `NOT DEFERRABLE` flip block sync on failure. `generate_fk_constraint_name()` moved to `plain.postgres.utils` and is now the single source of that name for both convergence and the write path ([31df77e665](https://github.com/dropseed/plain/commit/31df77e665))
12
+ - **`UniqueConstraint(deferrable=...)` and the `Deferrable` enum are removed.** With foreign keys immediate, this was the last deferred-constraint concept in Plain, nothing in Plain's own packages used it, and it forced a caveat onto every "constraints are checked at the write" statement while keeping the pending-trigger-events failure alive for one case. Rails (pre-7.1), Ecto, and Laravel never had it for uniques. Every constraint is now checked at the write that violates it, never at commit ([fd1e7f1e13](https://github.com/dropseed/plain/commit/fd1e7f1e13))
13
+ - `DatabaseConnection.check_constraints()` is gone, and the pytest `db` fixture no longer forces deferred constraints to immediate at teardown — there is nothing left to defer ([fd1e7f1e13](https://github.com/dropseed/plain/commit/fd1e7f1e13))
14
+ - Constraint renames now run in convergence pass 1, matching index renames, so anything else planned for the same constraint in that pass addresses it by its new name ([fd1e7f1e13](https://github.com/dropseed/plain/commit/fd1e7f1e13))
15
+ - New README section, "Constraints are checked immediately", covering the patterns this changes: create parents before children, use a nullable back-reference for a foreign key cycle (two required FKs pointing at each other can never be inserted), and move through a temporary value when swapping two rows under a unique constraint, since Postgres checks uniqueness per row even within a single `UPDATE` ([8cbfa0cb40](https://github.com/dropseed/plain/commit/8cbfa0cb40), [fd1e7f1e13](https://github.com/dropseed/plain/commit/fd1e7f1e13))
16
+
17
+ ### Upgrade instructions
18
+
19
+ - Replace `NO_ACTION` with `RESTRICT` in your models **and in any historical migration file that references it** — `postgres.NO_ACTION` no longer exists, so a migration importing it fails to load.
20
+ - Remove `deferrable=` from every `UniqueConstraint(...)` and drop any `Deferrable` import. An existing deferred unique constraint in the database shows as CHANGED drift with staged-rollout guidance — nothing is rewritten silently.
21
+ - Run `plain postgres sync` after upgrading. It flips every existing foreign key to `NOT DEFERRABLE` (catalog-only, no table scan) and renames foreign key constraints left stale by past `RenameField`/`RenameModel`.
22
+ - Code that inserted a child row before its parent, or deleted a parent and re-pointed its children later in the same transaction, now raises `ForeignKeyViolation` at that statement rather than succeeding by commit time. Create parents before children; for a foreign key cycle, create both rows and then `update()` the nullable back-reference.
23
+ - If you catch `psycopg.IntegrityError` around `create()`/`update()` to handle a missing related row, catch `ValidationError` instead — or both, if the same block also covers set-based writes.
24
+ - If you called `connection.check_constraints()`, remove the call.
25
+
3
26
  ## [0.114.0](https://github.com/dropseed/plain/releases/plain-postgres@0.114.0) (2026-08-12)
4
27
 
5
28
  ### What's changed
@@ -1,16 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: plain.postgres
3
- Version: 0.114.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.157.0
10
- Requires-Dist: psycopg-pool>=3.2
11
- Requires-Dist: psycopg>=3.2
12
- Description-Content-Type: text/markdown
13
-
14
1
  # plain.postgres
15
2
 
16
3
  **Model your data and store it in a database.**
@@ -996,6 +983,14 @@ The first access to any non-key field loads the whole row in a single query. The
996
983
 
997
984
  The partial-instance shortcut is safe because Plain always creates a database foreign-key constraint, so the referenced row is guaranteed to exist.
998
985
 
986
+ ### Constraints are checked immediately
987
+
988
+ Every constraint is checked at the write that violates it, never at commit — the same as Postgres's own default. Inserting a child row that points at a parent that doesn't exist yet fails at that `INSERT`, with a traceback pointing at the offending write, and deleting a parent with `RESTRICT` children fails at that `DELETE`. Create parents before children. A cycle of foreign keys needs a nullable back-reference: create both rows, then `update()` the back-reference. Two required foreign keys pointing at each other can never be inserted.
989
+
990
+ To swap two rows' values under a unique constraint (reordering by `position`, say), move one row to a temporary value first — a single `UPDATE` that swaps them fails, because Postgres checks uniqueness per row.
991
+
992
+ A migration can add a column, backfill it in `RunPython`, and drop or alter columns on the same table, all in one transaction.
993
+
999
994
  ### Reverse relationships
1000
995
 
1001
996
  When you define a `ForeignKey` or `ManyToManyField`, Plain automatically creates a reverse accessor on the related model (like `author.book_set`). You can explicitly declare these reverse relationships using [`ReverseForeignKey`](./fields/reverse_descriptors.py#ReverseForeignKey) and [`ReverseManyToMany`](./fields/reverse_descriptors.py#ReverseManyToMany):
@@ -1100,7 +1095,7 @@ class User(postgres.Model):
1100
1095
 
1101
1096
  Field-level validation happens automatically based on field types and constraints.
1102
1097
 
1103
- **The database is authoritative for constraints.** `create()`/`update()` don't pre-check your declared unique/check constraints — they attempt the write, and if Postgres rejects it, translate the `IntegrityError` into a `ValidationError` (routed to the field for single-column uniques, `NON_FIELD_ERRORS` otherwise). You get the same field-level error you'd expect, the write costs no per-constraint `SELECT`, and a raced concurrent insert can't slip through as a 500. (FK violations, `NOT NULL`, and a hand-set primary-key collision have no declared constraint to map to and re-raise as the original `IntegrityError`. `create()` always inserts, so passing a stray `id` that already exists is rejected by Postgres as the original `IntegrityError`.)
1098
+ **The database is authoritative for constraints.** `create()`/`update()` don't pre-check your declared unique/check constraints — they attempt the write, and if Postgres rejects it, translate the `IntegrityError` into a `ValidationError` (routed to the field for single-column uniques, `NON_FIELD_ERRORS` otherwise). You get the same field-level error you'd expect, the write costs no per-constraint `SELECT`, and a raced concurrent insert can't slip through as a 500. A foreign key pointing at a row that doesn't exist maps the same way, as an error on that field. (`NOT NULL` and a hand-set primary-key collision have no declared constraint to map to and re-raise as the original `IntegrityError`. `create()` always inserts, so passing a stray `id` that already exists is rejected by Postgres as the original `IntegrityError`.)
1104
1099
 
1105
1100
  Because the rejected write reaches the database, it aborts the surrounding transaction. If you catch the `ValidationError` and want to keep using the transaction, wrap the write in `transaction.atomic()` so it rolls back to a savepoint:
1106
1101
 
@@ -1114,7 +1109,7 @@ except ValidationError:
1114
1109
 
1115
1110
  Forms are the exception: a `ModelForm` pre-checks constraints explicitly (a `validate_constraints()` call in its `_post_clean`) so it can surface every violation at once, then writes via `form.create()`/`form.update()` with validation already done. A direct `create()`/`update()` reports the first violation Postgres hits.
1116
1111
 
1117
- This applies to instance writes only. Set-based writes — `QuerySet.update()` and `bulk_create()` — raise the raw `psycopg.IntegrityError`, since there's no instance to attribute the error to. If you retry on a unique conflict, catch both:
1112
+ This applies to instance writes only. Set-based writes — `QuerySet.update()` and `bulk_create()` — raise the raw `psycopg.IntegrityError`, since there's no instance to attribute the error to, and so does a `delete()` blocked by `RESTRICT` children. If you retry on a unique conflict, catch both:
1118
1113
 
1119
1114
  ```python
1120
1115
  try:
@@ -1125,8 +1120,6 @@ except (psycopg.IntegrityError, ValidationError):
1125
1120
 
1126
1121
  For a plain insert-or-update with no per-row logic, `bulk_create(..., update_conflicts=True, unique_fields=[...])` is an atomic upsert with no race to catch.
1127
1122
 
1128
- Two caveats. The mapping covers **immediate** constraints — the default. An explicitly deferred constraint (`UniqueConstraint(deferrable=Deferrable.DEFERRED)`) is checked at commit, _after_ the write returns, so its violation still surfaces as a raw `psycopg.IntegrityError`. And when a row violates several constraints at once, a form's pre-check (or an explicit `validate_constraints()`) reports them all, while a direct `create()`/`update()` gets only the first one the database hits.
1129
-
1130
1123
  ### Indexes and constraints
1131
1124
 
1132
1125
  You can optimize queries and ensure data integrity with indexes and constraints. These are managed automatically by [convergence](#convergence) — just declare them on the model and run `postgres sync`.
@@ -12,7 +12,7 @@ from .base import Model
12
12
  from .constraints import CheckConstraint, UniqueConstraint
13
13
  from .db import get_connection, use_management_connection
14
14
  from .middleware import DatabaseConnectionMiddleware
15
- from .deletion import CASCADE, NO_ACTION, RESTRICT, SET_NULL
15
+ from .deletion import CASCADE, RESTRICT, SET_NULL
16
16
  from .expressions import F
17
17
  from .enums import TextChoices
18
18
  from .fields import (
@@ -57,7 +57,6 @@ from . import types
57
57
  # imported from their specific modules (e.g., plain.postgres.aggregates).
58
58
  __all__ = [
59
59
  "CASCADE",
60
- "NO_ACTION",
61
60
  "RESTRICT",
62
61
  "SET_NULL",
63
62
  "BigIntegerField",
@@ -69,7 +69,7 @@ Use `Model.query` to build querysets (e.g., `User.query.filter(is_active=True)`)
69
69
  - Use `.values_list()` when you only need specific columns
70
70
  - Wrap multi-step writes in `transaction.atomic()`
71
71
  - Instance writes are `obj.create()` (always INSERT) and `obj.update()` (always UPDATE; `update(fields=[...])` limits the columns) — there is no `save()`, `force_insert`, or `force_update`. Constructing an instance then `create()`-ing it inserts; a hand-set `id` that collides raises `IntegrityError`.
72
- - `create()`/`update()` raise `ValidationError` (not raw `psycopg.IntegrityError`) on a declared unique/check constraint violation, even a raced one — the DB enforces it, so inside an open `transaction.atomic()` the violation aborts the transaction (wrap the write in its own `atomic()` to catch and keep using the transaction). Set-based writes (`QuerySet.update()`/`bulk_create()`) raise raw `psycopg.IntegrityError`. Retrying on conflict? `except (psycopg.IntegrityError, ValidationError)`, or `bulk_create(..., update_conflicts=True)`
72
+ - `create()`/`update()` raise `ValidationError` (not raw `psycopg.IntegrityError`) on a declared unique/check constraint violation or a foreign key pointing at a missing row, even a raced one — the DB enforces it, so inside an open `transaction.atomic()` the violation aborts the transaction (wrap the write in its own `atomic()` to catch and keep using the transaction). Set-based writes (`QuerySet.update()`/`bulk_create()`) and `delete()` blocked by `RESTRICT` raise raw `psycopg.IntegrityError`. Retrying on conflict? `except (psycopg.IntegrityError, ValidationError)`, or `bulk_create(..., update_conflicts=True)`
73
73
  - Always paginate list queries — unbounded querysets get slower as data grows
74
74
 
75
75
  Run `uv run plain docs postgres` for full patterns with code examples.
@@ -81,6 +81,7 @@ Run `uv run plain docs postgres` for full patterns with code examples.
81
81
  - Indexes: `{table}_{column(s)}_idx`
82
82
  - Constraints: `{table}_{column(s)}_{type}` (e.g., `_unique`, `_check`)
83
83
  - Choose `on_delete` deliberately: CASCADE for owned children, RESTRICT for referenced data, SET_NULL for optional references
84
+ - Foreign keys are checked at each write, not at commit — create parents before children. A backfill followed by DDL on the same table in one migration is fine.
84
85
  - No `allow_null` on string fields — use `default=""`. Optional string fields are `required=False, default=""` — the declared default is what lets the column be added to a populated table (`required=False` alone only affects Python-side validation)
85
86
 
86
87
  Run `uv run plain docs postgres` for full patterns with code examples.
@@ -438,16 +438,20 @@ class Model(metaclass=ModelBase):
438
438
  """
439
439
  Map a Postgres constraint violation back to the constraint that raised
440
440
  it and return the ValidationError the in-Python check produces for that
441
- constraint, or None when the violation doesn't correspond to a declared
442
- constraint that can describe it (PK collisions, FK violations, and NOT
443
- NULL — which carries no constraint name — all fall through to None and
444
- re-raise as the original IntegrityError). A PK collision reaches here
445
- when create() inserts a hand-set id that's already taken.
441
+ constraint or foreign key, or None when the violation doesn't
442
+ correspond to a declared constraint that can describe it (PK
443
+ collisions and NOT NULL — which carries no constraint name — fall
444
+ through to None and re-raise as the original IntegrityError). A PK
445
+ collision reaches here when create() inserts a hand-set id that's
446
+ already taken.
446
447
  """
447
448
  constraint_name = exc.diag.constraint_name
448
449
  if not constraint_name:
449
450
  return None
450
- constraint = self._model_meta.constraints_by_name.get(constraint_name)
451
+ meta = self._model_meta
452
+ constraint = meta.constraints_by_name.get(
453
+ constraint_name
454
+ ) or meta.foreign_keys_by_constraint_name.get(constraint_name)
451
455
  if constraint is None:
452
456
  return None
453
457
  error = constraint._db_violation_error(self, self.__class__)
@@ -531,11 +535,9 @@ class Model(metaclass=ModelBase):
531
535
  if not obj:
532
536
  continue
533
537
  # A pk may have been assigned manually to a model instance not
534
- # saved to the database (or auto-generated in a case like
535
- # UUIDField), but we allow the save to proceed and rely on the
536
- # database to raise an IntegrityError if applicable. If
537
- # constraints aren't supported by the database, there's the
538
- # unavoidable risk of data corruption.
538
+ # saved to the database, but we allow the write to proceed and
539
+ # rely on the database's foreign key check (mapped to a
540
+ # ValidationError on the field) if the row doesn't exist.
539
541
  if obj.id is None:
540
542
  raise ValueError(
541
543
  f"{operation_name}() prohibited to prevent data loss due to unsaved "
@@ -570,9 +572,9 @@ class Model(metaclass=ModelBase):
570
572
  # should always be deletable — custom querysets shape reads, not
571
573
  # internal row lifecycle operations.
572
574
  #
573
- # mark_for_rollback_on_error: FK errors (RESTRICT / NO_ACTION) leave
574
- # the DB transaction aborted. Mark the connection so outer atomic()
575
- # blocks see the abort state even if the caller catches IntegrityError.
575
+ # mark_for_rollback_on_error: RESTRICT violations leave the DB
576
+ # transaction aborted. Mark the connection so outer atomic() blocks
577
+ # see the abort state even if the caller catches IntegrityError.
576
578
  with transaction.mark_for_rollback_on_error():
577
579
  count = self._model_meta.base_queryset.filter(id=self.id)._raw_delete()
578
580
  id_field = self._model_meta.get_forward_field("id")
@@ -148,15 +148,6 @@ class DatabaseConnection:
148
148
  assert self.connection is not None
149
149
  self.connection.autocommit = autocommit
150
150
 
151
- def check_constraints(self, table_names: list[str] | None = None) -> None:
152
- """
153
- Check constraints by setting them to immediate. Return them to deferred
154
- afterward.
155
- """
156
- with self.cursor() as cursor:
157
- cursor.execute("SET CONSTRAINTS ALL IMMEDIATE")
158
- cursor.execute("SET CONSTRAINTS ALL DEFERRED")
159
-
160
151
  def make_debug_cursor(self, cursor: psycopg.Cursor[Any]) -> CursorDebugWrapper:
161
152
  return CursorDebugWrapper(cursor, self)
162
153
 
@@ -615,7 +606,8 @@ class DatabaseConnection:
615
606
  WHERE fka.attrelid = c.confrelid AND fka.attnum = c.confkey[1]),
616
607
  c.convalidated,
617
608
  pg_get_constraintdef(c.oid),
618
- c.confdeltype
609
+ c.confdeltype,
610
+ c.condeferrable
619
611
  FROM pg_constraint AS c
620
612
  JOIN pg_class AS cl ON c.conrelid = cl.oid
621
613
  WHERE cl.relname = %s AND pg_catalog.pg_table_is_visible(cl.oid)
@@ -630,6 +622,7 @@ class DatabaseConnection:
630
622
  validated,
631
623
  constraintdef,
632
624
  confdeltype,
625
+ condeferrable,
633
626
  ) in cursor.fetchall():
634
627
  constraints[constraint] = {
635
628
  "columns": columns,
@@ -639,6 +632,7 @@ class DatabaseConnection:
639
632
  "definition": constraintdef,
640
633
  "validated": validated,
641
634
  "on_delete_action": confdeltype if kind == "f" else None,
635
+ "deferrable": condeferrable if kind == "f" else None,
642
636
  }
643
637
  # Now get indexes. Sort order, opclasses, INCLUDE, and predicates all
644
638
  # ride along inside `pg_get_indexdef` and are compared via the
@@ -1,6 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- from enum import Enum
4
3
  from types import NoneType
5
4
  from typing import TYPE_CHECKING, Any, cast
6
5
 
@@ -10,7 +9,6 @@ from plain.postgres.ddl import (
10
9
  build_include_sql,
11
10
  compile_expression_sql,
12
11
  compile_index_expressions_sql,
13
- deferrable_sql,
14
12
  )
15
13
  from plain.postgres.dialect import quote_name
16
14
  from plain.postgres.exceptions import FieldError
@@ -26,7 +24,7 @@ from plain.postgres.query_utils import Q
26
24
  if TYPE_CHECKING:
27
25
  from plain.postgres.base import Model
28
26
 
29
- __all__ = ["BaseConstraint", "CheckConstraint", "Deferrable", "UniqueConstraint"]
27
+ __all__ = ["BaseConstraint", "CheckConstraint", "UniqueConstraint"]
30
28
 
31
29
 
32
30
  ViolationError = str | dict[str, Any] | list[Any] | ValidationError
@@ -190,15 +188,6 @@ class CheckConstraint(BaseConstraint):
190
188
  return path, args, kwargs
191
189
 
192
190
 
193
- class Deferrable(Enum):
194
- DEFERRED = "deferred"
195
- IMMEDIATE = "immediate"
196
-
197
- # A similar format was proposed for Python 3.10.
198
- def __repr__(self) -> str:
199
- return f"{self.__class__.__qualname__}.{self._name_}"
200
-
201
-
202
191
  class UniqueConstraint(BaseConstraint):
203
192
  expressions: tuple[ReplaceableExpression, ...]
204
193
 
@@ -208,7 +197,6 @@ class UniqueConstraint(BaseConstraint):
208
197
  fields: tuple[str, ...] | list[str] = (),
209
198
  name: str | None = None,
210
199
  condition: Q | None = None,
211
- deferrable: Deferrable | None = None,
212
200
  include: tuple[str, ...] | list[str] | None = None,
213
201
  opclasses: tuple[str, ...] | list[str] = (),
214
202
  violation_error: ViolationError | None = None,
@@ -226,23 +214,11 @@ class UniqueConstraint(BaseConstraint):
226
214
  )
227
215
  if not isinstance(condition, NoneType | Q):
228
216
  raise TypeError("UniqueConstraint.condition must be a Q instance.")
229
- if condition and deferrable:
230
- raise ValueError("UniqueConstraint with conditions cannot be deferred.")
231
- if include and deferrable:
232
- raise ValueError("UniqueConstraint with include fields cannot be deferred.")
233
- if opclasses and deferrable:
234
- raise ValueError("UniqueConstraint with opclasses cannot be deferred.")
235
- if expressions and deferrable:
236
- raise ValueError("UniqueConstraint with expressions cannot be deferred.")
237
217
  if expressions and opclasses:
238
218
  raise ValueError(
239
219
  "UniqueConstraint.opclasses cannot be used with expressions. "
240
220
  "Use a custom OpClass() instead."
241
221
  )
242
- if not isinstance(deferrable, NoneType | Deferrable):
243
- raise TypeError(
244
- "UniqueConstraint.deferrable must be a Deferrable instance."
245
- )
246
222
  if not isinstance(include, NoneType | list | tuple):
247
223
  raise TypeError("UniqueConstraint.include must be a list or tuple.")
248
224
  if not isinstance(opclasses, list | tuple):
@@ -254,7 +230,6 @@ class UniqueConstraint(BaseConstraint):
254
230
  )
255
231
  self.fields = tuple(fields)
256
232
  self.condition = condition
257
- self.deferrable = deferrable
258
233
  self.include = tuple(include) if include else ()
259
234
  self.opclasses = opclasses
260
235
  self.expressions = tuple(
@@ -311,7 +286,7 @@ class UniqueConstraint(BaseConstraint):
311
286
  elif condition or self.include or self.opclasses or self.expressions:
312
287
  return f"CREATE UNIQUE INDEX {name} ON {table} ({columns_sql}){include_sql}{condition_sql}"
313
288
  else:
314
- return f"ALTER TABLE {table} ADD CONSTRAINT {name} UNIQUE ({columns_sql}){deferrable_sql(self.deferrable)}"
289
+ return f"ALTER TABLE {table} ADD CONSTRAINT {name} UNIQUE ({columns_sql})"
315
290
 
316
291
  def to_attach_sql(self, model: type[Model]) -> str:
317
292
  """Generate ALTER TABLE ADD CONSTRAINT UNIQUE USING INDEX SQL.
@@ -321,18 +296,15 @@ class UniqueConstraint(BaseConstraint):
321
296
  """
322
297
  table = quote_name(model.model_options.db_table)
323
298
  name = quote_name(self.name)
324
- sql = f"ALTER TABLE {table} ADD CONSTRAINT {name} UNIQUE USING INDEX {name}"
325
- sql += deferrable_sql(self.deferrable)
326
- return sql
299
+ return f"ALTER TABLE {table} ADD CONSTRAINT {name} UNIQUE USING INDEX {name}"
327
300
 
328
301
  def __repr__(self) -> str:
329
- return "<{}:{}{}{}{}{}{}{}{}>".format(
302
+ return "<{}:{}{}{}{}{}{}{}>".format(
330
303
  self.__class__.__qualname__,
331
304
  "" if not self.fields else f" fields={self.fields!r}",
332
305
  "" if not self.expressions else f" expressions={self.expressions!r}",
333
306
  f" name={self.name!r}",
334
307
  "" if self.condition is None else f" condition={self.condition}",
335
- "" if self.deferrable is None else f" deferrable={self.deferrable!r}",
336
308
  "" if not self.include else f" include={self.include!r}",
337
309
  "" if not self.opclasses else f" opclasses={self.opclasses!r}",
338
310
  (
@@ -348,7 +320,6 @@ class UniqueConstraint(BaseConstraint):
348
320
  self.name == other.name
349
321
  and self.fields == other.fields
350
322
  and self.condition == other.condition
351
- and self.deferrable == other.deferrable
352
323
  and self.include == other.include
353
324
  and self.opclasses == other.opclasses
354
325
  and self.expressions == other.expressions
@@ -362,8 +333,6 @@ class UniqueConstraint(BaseConstraint):
362
333
  kwargs["fields"] = self.fields
363
334
  if self.condition:
364
335
  kwargs["condition"] = self.condition
365
- if self.deferrable:
366
- kwargs["deferrable"] = self.deferrable
367
336
  if self.include:
368
337
  kwargs["include"] = self.include
369
338
  if self.opclasses: