plain.postgres 0.114.0__tar.gz → 0.116.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.116.0/PKG-INFO +23 -4
  2. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/CHANGELOG.md +43 -0
  3. plain_postgres-0.114.0/PKG-INFO → plain_postgres-0.116.0/plain/postgres/README.md +10 -17
  4. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/__init__.py +1 -2
  5. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +2 -1
  6. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/base.py +44 -68
  7. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/connection.py +5 -13
  8. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/constraints.py +6 -37
  9. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/convergence/analysis.py +149 -106
  10. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/convergence/corrections.py +65 -25
  11. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/convergence/planning.py +8 -0
  12. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/ddl.py +0 -16
  13. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/deletion.py +3 -19
  14. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/dialect.py +0 -3
  15. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/fields/base.py +11 -22
  16. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/fields/binary.py +1 -1
  17. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/fields/encrypted.py +1 -1
  18. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/fields/json.py +1 -1
  19. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/fields/network.py +1 -1
  20. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/fields/numeric.py +1 -1
  21. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/fields/primary_key.py +1 -1
  22. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/fields/related.py +46 -13
  23. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/fields/related_managers.py +4 -9
  24. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/fields/reverse_related.py +16 -7
  25. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/fields/temporal.py +1 -2
  26. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/fields/text.py +2 -2
  27. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/fields/timezones.py +1 -1
  28. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/fields/uuid.py +1 -1
  29. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/forms.py +2 -4
  30. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/introspection/health/ownership.py +1 -1
  31. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/introspection/schema.py +2 -0
  32. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/meta.py +50 -89
  33. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/migrations/serializer.py +3 -3
  34. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/migrations/state.py +2 -2
  35. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/preflight/indexes.py +1 -1
  36. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/query.py +11 -14
  37. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/query_utils.py +5 -5
  38. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/schema.py +1 -1
  39. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/sql/compiler.py +2 -2
  40. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/sql/datastructures.py +3 -3
  41. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/sql/query.py +8 -12
  42. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/test/pytest.py +0 -12
  43. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/utils.py +9 -0
  44. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/pyproject.toml +1 -1
  45. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -13
  46. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/models/delete.py +2 -9
  47. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_convergence_constraints.py +0 -60
  48. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_convergence_fk.py +223 -71
  49. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_convergence_timeouts.py +1 -0
  50. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_migration_executor.py +34 -1
  51. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/public/test_delete_behaviors.py +18 -59
  52. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/public/test_integrity_error_mapping.py +27 -0
  53. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/.gitignore +0 -0
  54. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/CLAUDE.md +0 -0
  55. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/LICENSE +0 -0
  56. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/README.md +0 -0
  57. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/adapters.py +0 -0
  58. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
  59. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/aggregates.py +0 -0
  60. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/cli/__init__.py +0 -0
  61. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/cli/converge.py +0 -0
  62. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/cli/core.py +0 -0
  63. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/cli/decorators.py +0 -0
  64. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/cli/diagnose.py +0 -0
  65. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/cli/migrations.py +0 -0
  66. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/cli/schema.py +0 -0
  67. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/cli/sync.py +0 -0
  68. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/config.py +0 -0
  69. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/constants.py +0 -0
  70. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/convergence/__init__.py +0 -0
  71. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/database_url.py +0 -0
  72. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/databases.py +0 -0
  73. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/db.py +0 -0
  74. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/default_settings.py +0 -0
  75. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/entrypoints.py +0 -0
  76. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/enums.py +0 -0
  77. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/exceptions.py +0 -0
  78. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/expressions.py +0 -0
  79. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/fields/__init__.py +0 -0
  80. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/fields/boolean.py +0 -0
  81. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/fields/duration.py +0 -0
  82. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/fields/mixins.py +0 -0
  83. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/fields/related_descriptors.py +0 -0
  84. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/fields/related_lookups.py +0 -0
  85. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
  86. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/functions/__init__.py +0 -0
  87. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/functions/comparison.py +0 -0
  88. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/functions/datetime.py +0 -0
  89. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/functions/math.py +0 -0
  90. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/functions/mixins.py +0 -0
  91. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/functions/random.py +0 -0
  92. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/functions/text.py +0 -0
  93. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/functions/uuid.py +0 -0
  94. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/functions/window.py +0 -0
  95. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/indexes.py +0 -0
  96. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/introspection/__init__.py +0 -0
  97. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/introspection/health/__init__.py +0 -0
  98. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
  99. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
  100. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/introspection/health/checks_structural.py +0 -0
  101. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/introspection/health/context.py +0 -0
  102. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/introspection/health/helpers.py +0 -0
  103. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/introspection/health/runner.py +0 -0
  104. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/introspection/health/types.py +0 -0
  105. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/lookups.py +0 -0
  106. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/middleware.py +0 -0
  107. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/migrations/__init__.py +0 -0
  108. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/migrations/autodetector.py +0 -0
  109. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/migrations/exceptions.py +0 -0
  110. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/migrations/executor.py +0 -0
  111. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/migrations/graph.py +0 -0
  112. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/migrations/loader.py +0 -0
  113. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/migrations/migration.py +0 -0
  114. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/migrations/operations/__init__.py +0 -0
  115. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/migrations/operations/base.py +0 -0
  116. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/migrations/operations/fields.py +0 -0
  117. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/migrations/operations/models.py +0 -0
  118. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/migrations/operations/special.py +0 -0
  119. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/migrations/optimizer.py +0 -0
  120. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/migrations/questioner.py +0 -0
  121. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/migrations/recorder.py +0 -0
  122. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/migrations/utils.py +0 -0
  123. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/migrations/writer.py +0 -0
  124. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/options.py +0 -0
  125. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/otel.py +0 -0
  126. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/preflight/__init__.py +0 -0
  127. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/preflight/database.py +0 -0
  128. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/preflight/models.py +0 -0
  129. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/registry.py +0 -0
  130. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/schema_lock.py +0 -0
  131. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/sources.py +0 -0
  132. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/sql/__init__.py +0 -0
  133. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/sql/constants.py +0 -0
  134. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/sql/where.py +0 -0
  135. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/test/__init__.py +0 -0
  136. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/test/database.py +0 -0
  137. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/transaction.py +0 -0
  138. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/types.py +0 -0
  139. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/plain/postgres/types.pyi +0 -0
  140. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/forms.py +0 -0
  141. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/migrations/0001_initial.py +0 -0
  142. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  143. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
  144. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
  145. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
  146. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
  147. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
  148. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
  149. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
  150. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
  151. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
  152. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
  153. {plain_postgres-0.114.0 → plain_postgres-0.116.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.116.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
  155. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
  156. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
  157. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/migrations/0018_storageparametersexample.py +0 -0
  158. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/migrations/__init__.py +0 -0
  159. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/models/__init__.py +0 -0
  160. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/models/constraints.py +0 -0
  161. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/models/defaults.py +0 -0
  162. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/models/encrypted.py +0 -0
  163. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/models/forms.py +0 -0
  164. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/models/indexes.py +0 -0
  165. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/models/iteration.py +0 -0
  166. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/models/mixins.py +0 -0
  167. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/models/nullability.py +0 -0
  168. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/models/querysets.py +0 -0
  169. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/models/relationships.py +0 -0
  170. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/models/storage_parameters.py +0 -0
  171. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/models/trees.py +0 -0
  172. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/models/unregistered.py +0 -0
  173. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/urls.py +0 -0
  174. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/examples/views.py +0 -0
  175. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/settings.py +0 -0
  176. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/app/urls.py +0 -0
  177. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/conftest.py +0 -0
  178. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/conftest_convergence.py +0 -0
  179. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_apply_replan.py +0 -0
  180. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_autodetector_not_null_errors.py +0 -0
  181. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_autodetector_type_change.py +0 -0
  182. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_connection_isolation.py +0 -0
  183. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_connection_lifecycle.py +0 -0
  184. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_connection_pool.py +0 -0
  185. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_connection_self_heal.py +0 -0
  186. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_constraint_violation_error.py +0 -0
  187. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_convergence.py +0 -0
  188. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_convergence_defaults.py +0 -0
  189. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_convergence_indexes.py +0 -0
  190. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_convergence_nullability.py +0 -0
  191. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_convergence_storage_parameters.py +0 -0
  192. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_databases_not_on_runtime_path.py +0 -0
  193. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_db_expression_defaults.py +0 -0
  194. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_diagnose.py +0 -0
  195. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_executor_connection_hook.py +0 -0
  196. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_fk_characterization.py +0 -0
  197. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_health.py +0 -0
  198. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_introspection.py +0 -0
  199. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_literal_default_persistence.py +0 -0
  200. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_management_connection.py +0 -0
  201. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_no_callable_defaults.py +0 -0
  202. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_otel_metrics.py +0 -0
  203. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_preflight_duplicate_indexes.py +0 -0
  204. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_preflight_fk_composite_hint.py +0 -0
  205. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_preflight_fk_coverage.py +0 -0
  206. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_rollback_exc_attribution.py +0 -0
  207. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_schema_lock.py +0 -0
  208. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_schema_normalize_type.py +0 -0
  209. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/internal/test_schema_timeouts.py +0 -0
  210. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/public/test_create_update.py +0 -0
  211. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/public/test_database_url.py +0 -0
  212. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/public/test_databases.py +0 -0
  213. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/public/test_deferred_loading.py +0 -0
  214. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/public/test_encrypted_fields.py +0 -0
  215. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/public/test_exceptions.py +0 -0
  216. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/public/test_field_defaults.py +0 -0
  217. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/public/test_functions_uuid.py +0 -0
  218. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/public/test_iterator.py +0 -0
  219. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/public/test_m2m.py +0 -0
  220. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/public/test_manager_assignment.py +0 -0
  221. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/public/test_manual_pk.py +0 -0
  222. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/public/test_mixins.py +0 -0
  223. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/public/test_modelform_roundtrip.py +0 -0
  224. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/public/test_only_empty_defaults.py +0 -0
  225. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/public/test_order_by_expressions.py +0 -0
  226. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/public/test_queryset_repr.py +0 -0
  227. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/public/test_queryset_slicing.py +0 -0
  228. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/public/test_random_string_field.py +0 -0
  229. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/public/test_raw_query.py +0 -0
  230. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/public/test_read_only_transactions.py +0 -0
  231. {plain_postgres-0.114.0 → plain_postgres-0.116.0}/tests/public/test_related.py +0 -0
  232. {plain_postgres-0.114.0 → plain_postgres-0.116.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.116.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,48 @@
1
1
  # plain-postgres changelog
2
2
 
3
+ ## [0.116.0](https://github.com/dropseed/plain/releases/plain-postgres@0.116.0) (2026-09-04)
4
+
5
+ ### What's changed
6
+
7
+ - **`Meta` now exposes exactly two forward-field collections: `fields` and `many_to_many`.** `local_fields`, `local_many_to_many`, `concrete_fields`, and `local_concrete_fields` are gone. Plain has no model inheritance and no non-concrete forward fields, so "local" and "concrete" drew distinctions that could never differ — four names for two lists, each with its own cached property and its own filtering pass. `fields` is every column-backed field (primary key first, then sorted by name) and `many_to_many` is every `ManyToManyField` (sorted by name); both now read straight from the storage `add_field()` fills, instead of re-filtering `_get_fields()` on each access ([9f0d0da22d](https://github.com/dropseed/plain/commit/9f0d0da22d))
8
+ - `Meta.fields` is typed as `ImmutableList[ColumnField]` and `Meta.many_to_many` as `ImmutableList[ManyToManyField]`, so the column attributes (`column`, `db_type()`, `primary_key`) are readable off a field without an `isinstance` check first. `add_field()` enforces that by raising `TypeError` for a field that is neither a `ColumnField` nor a `ManyToManyField` ([996c099e2b](https://github.com/dropseed/plain/commit/996c099e2b), [9f0d0da22d](https://github.com/dropseed/plain/commit/9f0d0da22d))
9
+ - The `postgres.non_local_field_reference` preflight check is removed. It rejected an `indexes` or `constraints` entry naming a field "not local to" the model — unreachable without inheritance, since a forward field found on the model is always the model's own ([9f0d0da22d](https://github.com/dropseed/plain/commit/9f0d0da22d))
10
+ - `Field.name` is typed `str` instead of `str | None` — it starts as `""` and becomes the real name in `set_attributes_from_name()`. That drops roughly twenty `assert self.name is not None` guards from the field descriptor, save, and preflight paths. `Field.deconstruct()` (and the `ColumnField`, `DefaultableField`, `ChoicesField`, `RelatedField`, `ForeignKeyField`, and `ManyToManyField` overrides) returns `tuple[str, str, list, dict]` accordingly ([51cb71f758](https://github.com/dropseed/plain/commit/51cb71f758))
11
+ - `get_joining_columns()` moved from `ForeignObjectRel` to `ForeignKeyRel`. It delegates to `field.get_joining_columns(reverse_join=True)`, which only a foreign key has ([51cb71f758](https://github.com/dropseed/plain/commit/51cb71f758))
12
+ - `PathInfo.join_field` is typed `ForeignKeyField | ForeignKeyRel` (was `ForeignKeyField | ForeignObjectRel`), and `select_related_descend()` is a `TypeGuard[RelatedField]` so a passing field narrows at the call site ([51cb71f758](https://github.com/dropseed/plain/commit/51cb71f758))
13
+ - `QuerySet._update()`, `_insert()`, `_batched_insert()`, `UpdateQuery.add_update_fields()`, and `InsertQuery.insert_values()` accept a `Sequence` of fields rather than requiring a `list`, so an `ImmutableList` from `Meta` can be passed straight through ([51cb71f758](https://github.com/dropseed/plain/commit/51cb71f758))
14
+
15
+ ### Upgrade instructions
16
+
17
+ - Replace `_model_meta.local_fields`, `_model_meta.concrete_fields`, and `_model_meta.local_concrete_fields` with `_model_meta.fields`. In Plain these were always the same set of fields.
18
+ - Replace `_model_meta.local_many_to_many` with `_model_meta.many_to_many`.
19
+ - Replace `_model_meta._non_pk_concrete_field_names` with `_model_meta._non_pk_field_names`.
20
+ - If you have a custom `Field` subclass, `deconstruct()` now returns `str` as its first element rather than `str | None`, and `self.name` is `""` (not `None`) before the field is contributed to a model class. Update any annotation or `is None` check accordingly.
21
+ - If you called `get_joining_columns()` on a reverse relation, it is now available on `ForeignKeyRel` only.
22
+
23
+ ## [0.115.0](https://github.com/dropseed/plain/releases/plain-postgres@0.115.0) (2026-09-02)
24
+
25
+ ### What's changed
26
+
27
+ - **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))
28
+ - 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))
29
+ - `NO_ACTION` is removed. Its only meaning was deferred checking — use `RESTRICT` ([8cbfa0cb40](https://github.com/dropseed/plain/commit/8cbfa0cb40))
30
+ - 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))
31
+ - 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))
32
+ - **`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))
33
+ - `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))
34
+ - 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))
35
+ - 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))
36
+
37
+ ### Upgrade instructions
38
+
39
+ - 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.
40
+ - 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.
41
+ - 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`.
42
+ - 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.
43
+ - 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.
44
+ - If you called `connection.check_constraints()`, remove the call.
45
+
3
46
  ## [0.114.0](https://github.com/dropseed/plain/releases/plain-postgres@0.114.0) (2026-08-12)
4
47
 
5
48
  ### 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.
@@ -27,7 +27,6 @@ from plain.postgres.exceptions import (
27
27
  )
28
28
  from plain.postgres.expressions import RawSQL, Value
29
29
  from plain.postgres.fields import DATABASE_DEFAULT, Field
30
- from plain.postgres.fields.base import ColumnField
31
30
  from plain.postgres.fields.related import RelatedField
32
31
  from plain.postgres.fields.reverse_related import ForeignObjectRel
33
32
  from plain.postgres.meta import Meta
@@ -123,10 +122,6 @@ class Model(metaclass=ModelBase):
123
122
  for field in meta.fields:
124
123
  from plain.postgres.fields.related import RelatedField
125
124
 
126
- # meta.fields excludes ManyToManyField, so every iterated field
127
- # is column-backed and exposes the ColumnField surface.
128
- assert isinstance(field, ColumnField)
129
-
130
125
  is_related_object = False
131
126
  # Virtual field
132
127
  if field.name not in kwargs and field.column is None:
@@ -193,16 +188,14 @@ class Model(metaclass=ModelBase):
193
188
 
194
189
  @classmethod
195
190
  def from_db(cls, field_names: Iterable[str], values: Sequence[Any]) -> Model:
196
- if len(values) != len(cls._model_meta.concrete_fields):
191
+ if len(values) != len(cls._model_meta.fields):
197
192
  values_iter = iter(values)
198
193
  values = [
199
194
  next(values_iter) if f.name in field_names else DEFERRED
200
- for f in cls._model_meta.concrete_fields
195
+ for f in cls._model_meta.fields
201
196
  ]
202
197
  # Build kwargs dict from field names and values
203
- field_dict = dict(
204
- zip((f.name for f in cls._model_meta.concrete_fields), values)
205
- )
198
+ field_dict = dict(zip((f.name for f in cls._model_meta.fields), values))
206
199
  new = cls(_from_db=True, **field_dict)
207
200
  new._state.adding = False
208
201
  return new
@@ -279,11 +272,7 @@ class Model(metaclass=ModelBase):
279
272
  """
280
273
  Return a set containing names of deferred fields for this instance.
281
274
  """
282
- return {
283
- f.name
284
- for f in self._model_meta.concrete_fields
285
- if f.name not in self.__dict__
286
- }
275
+ return {f.name for f in self._model_meta.fields if f.name not in self.__dict__}
287
276
 
288
277
  def refresh_from_db(self, fields: list[str] | None = None) -> None:
289
278
  """
@@ -320,15 +309,13 @@ class Model(metaclass=ModelBase):
320
309
  db_instance_qs = db_instance_qs.only(*fields)
321
310
  elif deferred_fields:
322
311
  fields = [
323
- f.name
324
- for f in self._model_meta.concrete_fields
325
- if f.name not in deferred_fields
312
+ f.name for f in self._model_meta.fields if f.name not in deferred_fields
326
313
  ]
327
314
  db_instance_qs = db_instance_qs.only(*fields)
328
315
 
329
316
  db_instance = db_instance_qs.get()
330
317
  non_loaded_fields = db_instance.get_deferred_fields()
331
- for field in self._model_meta.concrete_fields:
318
+ for field in self._model_meta.fields:
332
319
  if field.name in non_loaded_fields:
333
320
  # This field wasn't refreshed - skip ahead.
334
321
  continue
@@ -404,7 +391,7 @@ class Model(metaclass=ModelBase):
404
391
  if not fields:
405
392
  return self # explicit "update nothing" -- no-op
406
393
  fields = frozenset(fields)
407
- field_names = self._model_meta._non_pk_concrete_field_names
394
+ field_names = self._model_meta._non_pk_field_names
408
395
  non_model_fields = fields.difference(field_names)
409
396
  if non_model_fields:
410
397
  raise ValueError(
@@ -420,7 +407,7 @@ class Model(metaclass=ModelBase):
420
407
  )
421
408
  elif deferred_fields:
422
409
  # Loaded via .only()/.defer() -- write just the loaded fields.
423
- loaded = self._model_meta._non_pk_concrete_field_names - deferred_fields
410
+ loaded = self._model_meta._non_pk_field_names - deferred_fields
424
411
  if loaded:
425
412
  fields = loaded
426
413
  self._prepare_related_fields_for_save(
@@ -438,16 +425,20 @@ class Model(metaclass=ModelBase):
438
425
  """
439
426
  Map a Postgres constraint violation back to the constraint that raised
440
427
  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.
428
+ constraint or foreign key, or None when the violation doesn't
429
+ correspond to a declared constraint that can describe it (PK
430
+ collisions and NOT NULL — which carries no constraint name — fall
431
+ through to None and re-raise as the original IntegrityError). A PK
432
+ collision reaches here when create() inserts a hand-set id that's
433
+ already taken.
446
434
  """
447
435
  constraint_name = exc.diag.constraint_name
448
436
  if not constraint_name:
449
437
  return None
450
- constraint = self._model_meta.constraints_by_name.get(constraint_name)
438
+ meta = self._model_meta
439
+ constraint = meta.constraints_by_name.get(
440
+ constraint_name
441
+ ) or meta.foreign_keys_by_constraint_name.get(constraint_name)
451
442
  if constraint is None:
452
443
  return None
453
444
  error = constraint._db_violation_error(self, self.__class__)
@@ -480,7 +471,7 @@ class Model(metaclass=ModelBase):
480
471
  fields from RETURNING. Omits id from the INSERT when unset so Postgres
481
472
  generates the identity value."""
482
473
  meta = self._model_meta
483
- fields = list(meta.local_concrete_fields)
474
+ fields = list(meta.fields)
484
475
  if self.id is None:
485
476
  id_field = meta.get_forward_field("id")
486
477
  fields = [f for f in fields if f is not id_field]
@@ -490,7 +481,6 @@ class Model(metaclass=ModelBase):
490
481
  )
491
482
  if results:
492
483
  for value, field in zip(results[0], returning_fields):
493
- assert field.name is not None
494
484
  setattr(self, field.name, value)
495
485
 
496
486
  def _update_row(self, fields: Iterable[str] | None) -> None:
@@ -498,7 +488,7 @@ class Model(metaclass=ModelBase):
498
488
  row matched -- update() targets an existing row and has no INSERT
499
489
  fallback (that's create())."""
500
490
  meta = self._model_meta
501
- non_pks = [f for f in meta.local_concrete_fields if not f.primary_key]
491
+ non_pks = [f for f in meta.fields if not f.primary_key]
502
492
  if fields:
503
493
  non_pks = [f for f in non_pks if f.name in fields]
504
494
  values = [(f, f.pre_save(self, False)) for f in non_pks]
@@ -521,7 +511,7 @@ class Model(metaclass=ModelBase):
521
511
  ) -> None:
522
512
  # Ensure that a model instance without a PK hasn't been assigned to
523
513
  # a ForeignKeyField on this model. If the field is nullable, allowing the save would result in silent data loss.
524
- for field in self._model_meta.concrete_fields:
514
+ for field in self._model_meta.fields:
525
515
  if field_names and field.name not in field_names:
526
516
  continue
527
517
  # If the related field isn't cached, then an instance hasn't been
@@ -531,11 +521,9 @@ class Model(metaclass=ModelBase):
531
521
  if not obj:
532
522
  continue
533
523
  # 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.
524
+ # saved to the database, but we allow the write to proceed and
525
+ # rely on the database's foreign key check (mapped to a
526
+ # ValidationError on the field) if the row doesn't exist.
539
527
  if obj.id is None:
540
528
  raise ValueError(
541
529
  f"{operation_name}() prohibited to prevent data loss due to unsaved "
@@ -570,13 +558,12 @@ class Model(metaclass=ModelBase):
570
558
  # should always be deletable — custom querysets shape reads, not
571
559
  # internal row lifecycle operations.
572
560
  #
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.
561
+ # mark_for_rollback_on_error: RESTRICT violations leave the DB
562
+ # transaction aborted. Mark the connection so outer atomic() blocks
563
+ # see the abort state even if the caller catches IntegrityError.
576
564
  with transaction.mark_for_rollback_on_error():
577
565
  count = self._model_meta.base_queryset.filter(id=self.id)._raw_delete()
578
566
  id_field = self._model_meta.get_forward_field("id")
579
- assert id_field.name is not None
580
567
  setattr(self, id_field.name, None)
581
568
  # Only the id is cleared -- every other field value survives so callers
582
569
  # can still reference a deleted row (correlate it, log it, check it's
@@ -609,7 +596,7 @@ class Model(metaclass=ModelBase):
609
596
  meta = meta or self._model_meta
610
597
  return {
611
598
  field.name: Value(field.value_from_object(self), field)
612
- for field in meta.local_concrete_fields
599
+ for field in meta.fields
613
600
  if field.name not in exclude
614
601
  }
615
602
 
@@ -753,9 +740,9 @@ class Model(metaclass=ModelBase):
753
740
  def _check_fields(cls) -> list[PreflightResult]:
754
741
  """Perform all field checks."""
755
742
  errors: list[PreflightResult] = []
756
- for field in cls._model_meta.local_fields:
743
+ for field in cls._model_meta.fields:
757
744
  errors.extend(field.preflight(from_model=cls))
758
- for field in cls._model_meta.local_many_to_many:
745
+ for field in cls._model_meta.many_to_many:
759
746
  errors.extend(field.preflight(from_model=cls))
760
747
  return errors
761
748
 
@@ -766,7 +753,7 @@ class Model(metaclass=ModelBase):
766
753
  errors: list[PreflightResult] = []
767
754
  seen_intermediary_signatures = []
768
755
 
769
- fields = cls._model_meta.local_many_to_many
756
+ fields = cls._model_meta.many_to_many
770
757
 
771
758
  # Skip when the target model wasn't found.
772
759
  fields = (f for f in fields if isinstance(f.remote_field.model, ModelBase))
@@ -798,9 +785,7 @@ class Model(metaclass=ModelBase):
798
785
  def _check_id_field(cls) -> list[PreflightResult]:
799
786
  """Disallow user-defined fields named ``id``."""
800
787
  if any(
801
- f
802
- for f in cls._model_meta.local_fields
803
- if f.name == "id" and not f.auto_created
788
+ f for f in cls._model_meta.fields if f.name == "id" and not f.auto_created
804
789
  ):
805
790
  return [
806
791
  PreflightResult(
@@ -817,7 +802,7 @@ class Model(metaclass=ModelBase):
817
802
  errors: list[PreflightResult] = []
818
803
  used_fields = {} # name -> field
819
804
 
820
- for f in cls._model_meta.local_fields:
805
+ for f in cls._model_meta.fields:
821
806
  clash = used_fields.get(f.name)
822
807
  # Note that we may detect clash between user-defined non-unique
823
808
  # field "id" and automatically added unique field "id", both
@@ -845,7 +830,7 @@ class Model(metaclass=ModelBase):
845
830
  used_column_names: list[str] = []
846
831
  errors: list[PreflightResult] = []
847
832
 
848
- for f in cls._model_meta.local_fields:
833
+ for f in cls._model_meta.fields:
849
834
  column_name = f.column
850
835
 
851
836
  # Ensure the column name is not already in use.
@@ -913,7 +898,7 @@ class Model(metaclass=ModelBase):
913
898
  @classmethod
914
899
  def _check_single_primary_key(cls) -> list[PreflightResult]:
915
900
  errors: list[PreflightResult] = []
916
- if sum(1 for f in cls._model_meta.local_fields if f.primary_key) > 1:
901
+ if sum(1 for f in cls._model_meta.fields if f.primary_key) > 1:
917
902
  errors.append(
918
903
  PreflightResult(
919
904
  fix="The model cannot have more than one field with "
@@ -964,11 +949,11 @@ class Model(metaclass=ModelBase):
964
949
  include for index in cls.model_options.indexes for include in index.include
965
950
  ]
966
951
  fields += references
967
- errors.extend(cls._check_local_fields(fields, "indexes"))
952
+ errors.extend(cls._check_referenced_fields(fields, "indexes"))
968
953
  return errors
969
954
 
970
955
  @classmethod
971
- def _check_local_fields(
956
+ def _check_referenced_fields(
972
957
  cls, fields: Iterable[str], option: str
973
958
  ) -> list[PreflightResult]:
974
959
  # In order to avoid hitting the relation tree prematurely, we use our
@@ -1001,15 +986,6 @@ class Model(metaclass=ModelBase):
1001
986
  id="postgres.m2m_field_in_meta_option",
1002
987
  )
1003
988
  )
1004
- elif field not in cls._model_meta.local_fields:
1005
- errors.append(
1006
- PreflightResult(
1007
- fix=f"'{option}' refers to field '{field_name}' which is not local to model "
1008
- f"'{cls.model_options.object_name}'.",
1009
- obj=cls,
1010
- id="postgres.non_local_field_reference",
1011
- )
1012
- )
1013
989
  return errors
1014
990
 
1015
991
  @classmethod
@@ -1087,9 +1063,9 @@ class Model(metaclass=ModelBase):
1087
1063
  meta = cls._model_meta
1088
1064
  valid_fields = set(
1089
1065
  chain.from_iterable(
1090
- (f.name,)
1091
- if not (f.auto_created and not f.concrete)
1092
- else (f.field.related_query_name(),)
1066
+ (f.field.related_query_name(),)
1067
+ if isinstance(f, ForeignObjectRel)
1068
+ else (f.name,)
1093
1069
  for f in chain(meta.fields, meta.related_objects)
1094
1070
  )
1095
1071
  )
@@ -1119,7 +1095,7 @@ class Model(metaclass=ModelBase):
1119
1095
  # silently truncate, so we check for names that are too long
1120
1096
  allowed_len = MAX_NAME_LENGTH
1121
1097
 
1122
- for f in cls._model_meta.local_fields:
1098
+ for f in cls._model_meta.fields:
1123
1099
  column_name = f.column
1124
1100
 
1125
1101
  # Check if column name is too long for the database.
@@ -1133,13 +1109,13 @@ class Model(metaclass=ModelBase):
1133
1109
  )
1134
1110
  )
1135
1111
 
1136
- for f in cls._model_meta.local_many_to_many:
1112
+ for f in cls._model_meta.many_to_many:
1137
1113
  # Skip nonexistent models.
1138
1114
  if isinstance(f.remote_field.through, str):
1139
1115
  continue
1140
1116
 
1141
1117
  # Check if column name for the M2M field is too long for the database.
1142
- for m2m in f.remote_field.through._model_meta.local_fields:
1118
+ for m2m in f.remote_field.through._model_meta.fields:
1143
1119
  rel_name = m2m.column
1144
1120
  if rel_name is not None and len(rel_name) > allowed_len:
1145
1121
  errors.append(
@@ -1233,7 +1209,7 @@ class Model(metaclass=ModelBase):
1233
1209
  id="postgres.constraint_refers_to_joined_field",
1234
1210
  )
1235
1211
  )
1236
- errors.extend(cls._check_local_fields(fields, "constraints"))
1212
+ errors.extend(cls._check_referenced_fields(fields, "constraints"))
1237
1213
  return errors
1238
1214
 
1239
1215
 
@@ -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
 
@@ -544,9 +535,7 @@ class DatabaseConnection:
544
535
  tables = set()
545
536
  for model in get_migratable_models():
546
537
  tables.add(model.model_options.db_table)
547
- tables.update(
548
- f.m2m_db_table() for f in model._model_meta.local_many_to_many
549
- )
538
+ tables.update(f.m2m_db_table() for f in model._model_meta.many_to_many)
550
539
  tables = list(tables)
551
540
  if only_existing:
552
541
  existing_tables = set(self.table_names(include_views=include_views))
@@ -615,7 +604,8 @@ class DatabaseConnection:
615
604
  WHERE fka.attrelid = c.confrelid AND fka.attnum = c.confkey[1]),
616
605
  c.convalidated,
617
606
  pg_get_constraintdef(c.oid),
618
- c.confdeltype
607
+ c.confdeltype,
608
+ c.condeferrable
619
609
  FROM pg_constraint AS c
620
610
  JOIN pg_class AS cl ON c.conrelid = cl.oid
621
611
  WHERE cl.relname = %s AND pg_catalog.pg_table_is_visible(cl.oid)
@@ -630,6 +620,7 @@ class DatabaseConnection:
630
620
  validated,
631
621
  constraintdef,
632
622
  confdeltype,
623
+ condeferrable,
633
624
  ) in cursor.fetchall():
634
625
  constraints[constraint] = {
635
626
  "columns": columns,
@@ -639,6 +630,7 @@ class DatabaseConnection:
639
630
  "definition": constraintdef,
640
631
  "validated": validated,
641
632
  "on_delete_action": confdeltype if kind == "f" else None,
633
+ "deferrable": condeferrable if kind == "f" else None,
642
634
  }
643
635
  # Now get indexes. Sort order, opclasses, INCLUDE, and predicates all
644
636
  # ride along inside `pg_get_indexdef` and are compared via the