plain.postgres 0.99.1__tar.gz → 0.100.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 (205) hide show
  1. plain_postgres-0.99.1/plain/postgres/README.md → plain_postgres-0.100.0/PKG-INFO +47 -5
  2. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/CHANGELOG.md +16 -0
  3. plain_postgres-0.99.1/PKG-INFO → plain_postgres-0.100.0/plain/postgres/README.md +33 -19
  4. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/base.py +2 -8
  5. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/constraints.py +55 -80
  6. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/pyproject.toml +1 -1
  7. plain_postgres-0.100.0/tests/test_constraint_violation_error.py +216 -0
  8. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_otel_metrics.py +57 -57
  9. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/.gitignore +0 -0
  10. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/CLAUDE.md +0 -0
  11. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/LICENSE +0 -0
  12. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/README.md +0 -0
  13. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/__init__.py +0 -0
  14. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/adapters.py +0 -0
  15. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -0
  16. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
  17. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/aggregates.py +0 -0
  18. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/cli/__init__.py +0 -0
  19. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/cli/converge.py +0 -0
  20. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/cli/core.py +0 -0
  21. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/cli/decorators.py +0 -0
  22. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/cli/diagnose.py +0 -0
  23. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/cli/migrations.py +0 -0
  24. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/cli/schema.py +0 -0
  25. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/cli/sync.py +0 -0
  26. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/config.py +0 -0
  27. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/connection.py +0 -0
  28. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/constants.py +0 -0
  29. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/convergence/__init__.py +0 -0
  30. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/convergence/analysis.py +0 -0
  31. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/convergence/fixes.py +0 -0
  32. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/convergence/planning.py +0 -0
  33. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/database_url.py +0 -0
  34. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/db.py +0 -0
  35. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/ddl.py +0 -0
  36. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/default_settings.py +0 -0
  37. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/deletion.py +0 -0
  38. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/dialect.py +0 -0
  39. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/entrypoints.py +0 -0
  40. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/enums.py +0 -0
  41. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/exceptions.py +0 -0
  42. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/expressions.py +0 -0
  43. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/__init__.py +0 -0
  44. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/base.py +0 -0
  45. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/binary.py +0 -0
  46. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/boolean.py +0 -0
  47. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/duration.py +0 -0
  48. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/encrypted.py +0 -0
  49. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/json.py +0 -0
  50. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/mixins.py +0 -0
  51. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/network.py +0 -0
  52. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/numeric.py +0 -0
  53. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/primary_key.py +0 -0
  54. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/related.py +0 -0
  55. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/related_descriptors.py +0 -0
  56. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/related_lookups.py +0 -0
  57. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/related_managers.py +0 -0
  58. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
  59. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/reverse_related.py +0 -0
  60. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/temporal.py +0 -0
  61. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/text.py +0 -0
  62. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/timezones.py +0 -0
  63. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/fields/uuid.py +0 -0
  64. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/forms.py +0 -0
  65. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/functions/__init__.py +0 -0
  66. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/functions/comparison.py +0 -0
  67. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/functions/datetime.py +0 -0
  68. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/functions/math.py +0 -0
  69. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/functions/mixins.py +0 -0
  70. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/functions/random.py +0 -0
  71. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/functions/text.py +0 -0
  72. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/functions/uuid.py +0 -0
  73. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/functions/window.py +0 -0
  74. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/indexes.py +0 -0
  75. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/introspection/__init__.py +0 -0
  76. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/introspection/health/__init__.py +0 -0
  77. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
  78. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
  79. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/introspection/health/checks_structural.py +0 -0
  80. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/introspection/health/context.py +0 -0
  81. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/introspection/health/helpers.py +0 -0
  82. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/introspection/health/ownership.py +0 -0
  83. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/introspection/health/runner.py +0 -0
  84. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/introspection/health/types.py +0 -0
  85. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/introspection/schema.py +0 -0
  86. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/lookups.py +0 -0
  87. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/meta.py +0 -0
  88. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/middleware.py +0 -0
  89. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/__init__.py +0 -0
  90. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/autodetector.py +0 -0
  91. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/exceptions.py +0 -0
  92. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/executor.py +0 -0
  93. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/graph.py +0 -0
  94. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/loader.py +0 -0
  95. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/migration.py +0 -0
  96. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/operations/__init__.py +0 -0
  97. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/operations/base.py +0 -0
  98. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/operations/fields.py +0 -0
  99. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/operations/models.py +0 -0
  100. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/operations/special.py +0 -0
  101. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/optimizer.py +0 -0
  102. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/questioner.py +0 -0
  103. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/recorder.py +0 -0
  104. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/serializer.py +0 -0
  105. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/state.py +0 -0
  106. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/utils.py +0 -0
  107. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/migrations/writer.py +0 -0
  108. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/options.py +0 -0
  109. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/otel.py +0 -0
  110. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/preflight.py +0 -0
  111. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/query.py +0 -0
  112. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/query_utils.py +0 -0
  113. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/registry.py +0 -0
  114. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/schema.py +0 -0
  115. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/sources.py +0 -0
  116. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/sql/__init__.py +0 -0
  117. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/sql/compiler.py +0 -0
  118. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/sql/constants.py +0 -0
  119. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/sql/datastructures.py +0 -0
  120. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/sql/query.py +0 -0
  121. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/sql/where.py +0 -0
  122. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/test/__init__.py +0 -0
  123. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/test/database.py +0 -0
  124. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/test/pytest.py +0 -0
  125. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/transaction.py +0 -0
  126. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/types.py +0 -0
  127. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/types.pyi +0 -0
  128. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/plain/postgres/utils.py +0 -0
  129. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/forms.py +0 -0
  130. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0001_initial.py +0 -0
  131. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  132. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
  133. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
  134. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
  135. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
  136. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
  137. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
  138. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
  139. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
  140. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
  141. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
  142. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
  143. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
  144. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
  145. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
  146. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
  147. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/migrations/__init__.py +0 -0
  148. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/models/__init__.py +0 -0
  149. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/models/constraints.py +0 -0
  150. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/models/defaults.py +0 -0
  151. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/models/delete.py +0 -0
  152. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/models/encrypted.py +0 -0
  153. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/models/forms.py +0 -0
  154. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/models/indexes.py +0 -0
  155. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/models/iteration.py +0 -0
  156. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/models/mixins.py +0 -0
  157. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/models/nullability.py +0 -0
  158. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/models/querysets.py +0 -0
  159. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/models/relationships.py +0 -0
  160. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/models/trees.py +0 -0
  161. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/models/unregistered.py +0 -0
  162. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/urls.py +0 -0
  163. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/examples/views.py +0 -0
  164. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/settings.py +0 -0
  165. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/app/urls.py +0 -0
  166. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/conftest.py +0 -0
  167. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/conftest_convergence.py +0 -0
  168. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_autodetector_not_null_errors.py +0 -0
  169. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_autodetector_type_change.py +0 -0
  170. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_connection_isolation.py +0 -0
  171. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_connection_lifecycle.py +0 -0
  172. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_connection_pool.py +0 -0
  173. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_convergence.py +0 -0
  174. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_convergence_constraints.py +0 -0
  175. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_convergence_defaults.py +0 -0
  176. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_convergence_fk.py +0 -0
  177. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_convergence_indexes.py +0 -0
  178. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_convergence_nullability.py +0 -0
  179. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_convergence_timeouts.py +0 -0
  180. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_database_url.py +0 -0
  181. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_db_expression_defaults.py +0 -0
  182. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_delete_behaviors.py +0 -0
  183. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_diagnose.py +0 -0
  184. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_encrypted_fields.py +0 -0
  185. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_exceptions.py +0 -0
  186. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_executor_connection_hook.py +0 -0
  187. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_field_defaults.py +0 -0
  188. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_functions_uuid.py +0 -0
  189. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_health.py +0 -0
  190. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_introspection.py +0 -0
  191. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_iterator.py +0 -0
  192. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_literal_default_persistence.py +0 -0
  193. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_m2m.py +0 -0
  194. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_management_connection.py +0 -0
  195. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_manager_assignment.py +0 -0
  196. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_migration_executor.py +0 -0
  197. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_mixins.py +0 -0
  198. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_modelform_roundtrip.py +0 -0
  199. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_no_callable_defaults.py +0 -0
  200. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_random_string_field.py +0 -0
  201. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_raw_query.py +0 -0
  202. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_read_only_transactions.py +0 -0
  203. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_related.py +0 -0
  204. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_schema_normalize_type.py +0 -0
  205. {plain_postgres-0.99.1 → plain_postgres-0.100.0}/tests/test_schema_timeouts.py +0 -0
@@ -1,3 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: plain.postgres
3
+ Version: 0.100.0
4
+ Summary: Model your data and store it in a database.
5
+ Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
+ License-Expression: BSD-3-Clause
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.13
9
+ Requires-Dist: plain<1.0.0,>=0.134.0
10
+ Requires-Dist: psycopg-pool>=3.2
11
+ Requires-Dist: psycopg>=3.2
12
+ Requires-Dist: sqlparse>=0.3.1
13
+ Description-Content-Type: text/markdown
14
+
1
15
  # plain.postgres
2
16
 
3
17
  **Model your data and store it in a database.**
@@ -1003,7 +1017,7 @@ author.books.query.published()
1003
1017
 
1004
1018
  ### Validation
1005
1019
 
1006
- You can validate models before saving:
1020
+ `save()` runs `full_clean()` by default — field validators, model `clean()`, and any constraints with a `validate()` method are all checked, raising `ValidationError` on violation. Pass `clean_and_validate=False` to skip it (e.g. for trusted bulk loads).
1007
1021
 
1008
1022
  ```python
1009
1023
  @postgres.register_model
@@ -1020,10 +1034,6 @@ class User(postgres.Model):
1020
1034
  def clean(self):
1021
1035
  if self.age < 18:
1022
1036
  raise ValidationError("User must be 18 or older")
1023
-
1024
- def save(self, *args, **kwargs):
1025
- self.full_clean() # Runs validation
1026
- super().save(*args, **kwargs)
1027
1037
  ```
1028
1038
 
1029
1039
  Field-level validation happens automatically based on field types and constraints.
@@ -1050,6 +1060,38 @@ class User(postgres.Model):
1050
1060
  )
1051
1061
  ```
1052
1062
 
1063
+ Constraints are also checked during `full_clean()` (which `save()` runs by default — see [Validation](#validation)). Pass `violation_error` to customize the resulting `ValidationError`. It accepts anything `ValidationError(...)` accepts — a string, a `{field: message}` dict, or a fully-formed `ValidationError`:
1064
+
1065
+ ```python
1066
+ # Simple message — lands on NON_FIELD_ERRORS
1067
+ postgres.CheckConstraint(
1068
+ check=postgres.Q(age__gte=0),
1069
+ name="age_positive",
1070
+ violation_error="Age must be zero or greater.",
1071
+ )
1072
+
1073
+ # Dict form — routes to a specific field
1074
+ postgres.CheckConstraint(
1075
+ check=postgres.Q(age__gte=0),
1076
+ name="age_positive",
1077
+ violation_error={"age": "Age must be zero or greater."},
1078
+ )
1079
+
1080
+ # Full ValidationError — for code, params, multiple fields
1081
+ postgres.CheckConstraint(
1082
+ check=postgres.Q(age__gte=0),
1083
+ name="age_positive",
1084
+ violation_error=ValidationError(
1085
+ {"age": "Age must be zero or greater."},
1086
+ code="age_negative",
1087
+ ),
1088
+ )
1089
+ ```
1090
+
1091
+ A `code` becomes `ValidationError.code` — useful for test assertions, error tracking buckets, and code that branches on specific error types without string-matching.
1092
+
1093
+ `UniqueConstraint` accepts the same `violation_error`. With a single-field unique constraint, a string `violation_error="That email is taken."` auto-routes to that field; otherwise (multi-field, expressions, or a CheckConstraint) errors land on `NON_FIELD_ERRORS` unless you pass the dict form. See [BaseConstraint](./constraints.py#BaseConstraint) for the full signature.
1094
+
1053
1095
  ### Schema design
1054
1096
 
1055
1097
  #### Index fields used in filters and ordering
@@ -1,5 +1,21 @@
1
1
  # plain-postgres changelog
2
2
 
3
+ ## [0.100.0](https://github.com/dropseed/plain/releases/plain-postgres@0.100.0) (2026-04-28)
4
+
5
+ ### What's changed
6
+
7
+ - **Replaced the `violation_error_message` / `violation_error_code` triad on `CheckConstraint` and `UniqueConstraint` with a single `violation_error` kwarg.** The new kwarg accepts anything `ValidationError(...)` accepts — a string, a `{field: message}` dict, a list, or a fully-formed `ValidationError` — so message text, error code, and field routing all live on one object. ([8650edc22c09](https://github.com/dropseed/plain/commit/8650edc22c09))
8
+ - **Single-field `UniqueConstraint` now auto-routes flat errors to its field.** A `violation_error="That email is taken."` on `UniqueConstraint(fields=["email"])` lands on the `email` form field instead of `NON_FIELD_ERRORS`. A caller-built `ValidationError({"other_field": ...})` is preserved as-is. ([8650edc22c09](https://github.com/dropseed/plain/commit/8650edc22c09))
9
+ - **Dropped the hardcoded `code == "unique"` routing in `validate_constraints()`.** Routing is now uniform across constraint types: dict-form errors land on fields, flat errors go to `NON_FIELD_ERRORS`. ([8650edc22c09](https://github.com/dropseed/plain/commit/8650edc22c09))
10
+ - **Removed the `%(name)s` interpolation magic** on `BaseConstraint.default_violation_error_message`. The default message still includes the constraint name; users wanting runtime interpolation can pass `ValidationError(..., params={"name": ...})`. ([8650edc22c09](https://github.com/dropseed/plain/commit/8650edc22c09))
11
+ - Documented that `save()` runs `full_clean()` by default (`clean_and_validate=True`); fixed the README's Validation example which previously implied users had to override `save()` to call `full_clean()` manually. ([8650edc22c09](https://github.com/dropseed/plain/commit/8650edc22c09))
12
+
13
+ ### Upgrade instructions
14
+
15
+ - Replace `violation_error_message="..."` and `violation_error_code="..."` on `CheckConstraint` / `UniqueConstraint` with a single `violation_error=ValidationError("...", code="...")` (or a string if you only need the message).
16
+ - If you relied on the implicit single-field-unique routing for a constraint with a custom `violation_error_code`, no change needed — single-field `UniqueConstraint` still auto-routes by default.
17
+ - If you used `%(name)s` in `violation_error_message`, switch to `ValidationError("...", params={"name": "your_constraint_name"})` or hardcode the name.
18
+
3
19
  ## [0.99.1](https://github.com/dropseed/plain/releases/plain-postgres@0.99.1) (2026-04-26)
4
20
 
5
21
  ### What's changed
@@ -1,17 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: plain.postgres
3
- Version: 0.99.1
4
- Summary: Model your data and store it in a database.
5
- Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
- License-Expression: BSD-3-Clause
7
- License-File: LICENSE
8
- Requires-Python: >=3.13
9
- Requires-Dist: plain<1.0.0,>=0.134.0
10
- Requires-Dist: psycopg-pool>=3.2
11
- Requires-Dist: psycopg>=3.2
12
- Requires-Dist: sqlparse>=0.3.1
13
- Description-Content-Type: text/markdown
14
-
15
1
  # plain.postgres
16
2
 
17
3
  **Model your data and store it in a database.**
@@ -1017,7 +1003,7 @@ author.books.query.published()
1017
1003
 
1018
1004
  ### Validation
1019
1005
 
1020
- You can validate models before saving:
1006
+ `save()` runs `full_clean()` by default — field validators, model `clean()`, and any constraints with a `validate()` method are all checked, raising `ValidationError` on violation. Pass `clean_and_validate=False` to skip it (e.g. for trusted bulk loads).
1021
1007
 
1022
1008
  ```python
1023
1009
  @postgres.register_model
@@ -1034,10 +1020,6 @@ class User(postgres.Model):
1034
1020
  def clean(self):
1035
1021
  if self.age < 18:
1036
1022
  raise ValidationError("User must be 18 or older")
1037
-
1038
- def save(self, *args, **kwargs):
1039
- self.full_clean() # Runs validation
1040
- super().save(*args, **kwargs)
1041
1023
  ```
1042
1024
 
1043
1025
  Field-level validation happens automatically based on field types and constraints.
@@ -1064,6 +1046,38 @@ class User(postgres.Model):
1064
1046
  )
1065
1047
  ```
1066
1048
 
1049
+ Constraints are also checked during `full_clean()` (which `save()` runs by default — see [Validation](#validation)). Pass `violation_error` to customize the resulting `ValidationError`. It accepts anything `ValidationError(...)` accepts — a string, a `{field: message}` dict, or a fully-formed `ValidationError`:
1050
+
1051
+ ```python
1052
+ # Simple message — lands on NON_FIELD_ERRORS
1053
+ postgres.CheckConstraint(
1054
+ check=postgres.Q(age__gte=0),
1055
+ name="age_positive",
1056
+ violation_error="Age must be zero or greater.",
1057
+ )
1058
+
1059
+ # Dict form — routes to a specific field
1060
+ postgres.CheckConstraint(
1061
+ check=postgres.Q(age__gte=0),
1062
+ name="age_positive",
1063
+ violation_error={"age": "Age must be zero or greater."},
1064
+ )
1065
+
1066
+ # Full ValidationError — for code, params, multiple fields
1067
+ postgres.CheckConstraint(
1068
+ check=postgres.Q(age__gte=0),
1069
+ name="age_positive",
1070
+ violation_error=ValidationError(
1071
+ {"age": "Age must be zero or greater."},
1072
+ code="age_negative",
1073
+ ),
1074
+ )
1075
+ ```
1076
+
1077
+ A `code` becomes `ValidationError.code` — useful for test assertions, error tracking buckets, and code that branches on specific error types without string-matching.
1078
+
1079
+ `UniqueConstraint` accepts the same `violation_error`. With a single-field unique constraint, a string `violation_error="That email is taken."` auto-routes to that field; otherwise (multi-field, expressions, or a CheckConstraint) errors land on `NON_FIELD_ERRORS` unless you pass the dict form. See [BaseConstraint](./constraints.py#BaseConstraint) for the full signature.
1080
+
1067
1081
  ### Schema design
1068
1082
 
1069
1083
  #### Index fields used in filters and ordering
@@ -812,19 +812,13 @@ class Model(metaclass=ModelBase):
812
812
  def validate_constraints(self, exclude: set[str] | None = None) -> None:
813
813
  constraints = self.get_constraints()
814
814
 
815
- errors = {}
815
+ errors: dict[str, list[ValidationError]] = {}
816
816
  for model_class, model_constraints in constraints:
817
817
  for constraint in model_constraints:
818
818
  try:
819
819
  constraint.validate(model_class, self, exclude=exclude)
820
820
  except ValidationError as e:
821
- if (
822
- getattr(e, "code", None) == "unique"
823
- and len(constraint.fields) == 1
824
- ):
825
- errors.setdefault(constraint.fields[0], []).append(e)
826
- else:
827
- errors = e.update_error_dict(errors)
821
+ errors = e.update_error_dict(errors)
828
822
  if errors:
829
823
  raise ValidationError(errors)
830
824
 
@@ -28,25 +28,20 @@ if TYPE_CHECKING:
28
28
  __all__ = ["BaseConstraint", "CheckConstraint", "Deferrable", "UniqueConstraint"]
29
29
 
30
30
 
31
+ ViolationError = str | dict[str, Any] | list[Any] | ValidationError
32
+
33
+
31
34
  class BaseConstraint:
32
- default_violation_error_message = 'Constraint "%(name)s" is violated.'
33
- violation_error_code: str | None = None
34
- violation_error_message: str | None = None
35
+ violation_error: ViolationError | None = None
35
36
 
36
37
  def __init__(
37
38
  self,
38
39
  *,
39
40
  name: str,
40
- violation_error_code: str | None = None,
41
- violation_error_message: str | None = None,
41
+ violation_error: ViolationError | None = None,
42
42
  ) -> None:
43
43
  self.name = name
44
- if violation_error_code is not None:
45
- self.violation_error_code = violation_error_code
46
- if violation_error_message is not None:
47
- self.violation_error_message = violation_error_message
48
- else:
49
- self.violation_error_message = self.default_violation_error_message
44
+ self.violation_error = violation_error
50
45
 
51
46
  @property
52
47
  def contains_expressions(self) -> bool:
@@ -64,21 +59,19 @@ class BaseConstraint:
64
59
  "subclasses of BaseConstraint must provide a validate() method"
65
60
  )
66
61
 
67
- def get_violation_error_message(self) -> str:
68
- assert self.violation_error_message is not None
69
- return self.violation_error_message % {"name": self.name}
62
+ def _build_violation_error(self) -> ValidationError:
63
+ if self.violation_error is None:
64
+ return ValidationError(f'Constraint "{self.name}" is violated.')
65
+ if isinstance(self.violation_error, ValidationError):
66
+ return self.violation_error
67
+ return ValidationError(self.violation_error)
70
68
 
71
69
  def deconstruct(self) -> tuple[str, tuple[Any, ...], dict[str, Any]]:
72
70
  path = f"{self.__class__.__module__}.{self.__class__.__name__}"
73
71
  path = path.replace("plain.postgres.constraints", "plain.postgres")
74
72
  kwargs: dict[str, Any] = {"name": self.name}
75
- if (
76
- self.violation_error_message is not None
77
- and self.violation_error_message != self.default_violation_error_message
78
- ):
79
- kwargs["violation_error_message"] = self.violation_error_message
80
- if self.violation_error_code is not None:
81
- kwargs["violation_error_code"] = self.violation_error_code
73
+ if self.violation_error is not None:
74
+ kwargs["violation_error"] = self.violation_error
82
75
  return (path, (), kwargs)
83
76
 
84
77
  def clone(self) -> BaseConstraint:
@@ -92,19 +85,14 @@ class CheckConstraint(BaseConstraint):
92
85
  *,
93
86
  check: Q,
94
87
  name: str,
95
- violation_error_code: str | None = None,
96
- violation_error_message: str | None = None,
88
+ violation_error: ViolationError | None = None,
97
89
  ) -> None:
98
90
  self.check = check
99
91
  if not getattr(check, "conditional", False):
100
92
  raise TypeError(
101
93
  "CheckConstraint.check must be a Q instance or boolean expression."
102
94
  )
103
- super().__init__(
104
- name=name,
105
- violation_error_code=violation_error_code,
106
- violation_error_message=violation_error_message,
107
- )
95
+ super().__init__(name=name, violation_error=violation_error)
108
96
 
109
97
  def to_sql(self, model: type[Model], *, not_valid: bool = False) -> str:
110
98
  """Generate ALTER TABLE ADD CONSTRAINT CHECK SQL as a plain string."""
@@ -122,27 +110,19 @@ class CheckConstraint(BaseConstraint):
122
110
  against = instance._get_field_value_map(meta=model._model_meta, exclude=exclude)
123
111
  try:
124
112
  if not Q(self.check).check(against):
125
- raise ValidationError(
126
- self.get_violation_error_message(), code=self.violation_error_code
127
- )
113
+ raise self._build_violation_error()
128
114
  except FieldError:
129
115
  pass
130
116
 
131
117
  def __repr__(self) -> str:
132
- return "<{}: check={} name={}{}{}>".format(
118
+ return "<{}: check={} name={}{}>".format(
133
119
  self.__class__.__qualname__,
134
120
  self.check,
135
121
  repr(self.name),
136
122
  (
137
123
  ""
138
- if self.violation_error_code is None
139
- else f" violation_error_code={self.violation_error_code!r}"
140
- ),
141
- (
142
- ""
143
- if self.violation_error_message is None
144
- or self.violation_error_message == self.default_violation_error_message
145
- else f" violation_error_message={self.violation_error_message!r}"
124
+ if self.violation_error is None
125
+ else f" violation_error={self.violation_error!r}"
146
126
  ),
147
127
  )
148
128
 
@@ -151,8 +131,7 @@ class CheckConstraint(BaseConstraint):
151
131
  return (
152
132
  self.name == other.name
153
133
  and self.check == other.check
154
- and self.violation_error_code == other.violation_error_code
155
- and self.violation_error_message == other.violation_error_message
134
+ and self.violation_error == other.violation_error
156
135
  )
157
136
  return super().__eq__(other)
158
137
 
@@ -183,8 +162,7 @@ class UniqueConstraint(BaseConstraint):
183
162
  deferrable: Deferrable | None = None,
184
163
  include: tuple[str, ...] | list[str] | None = None,
185
164
  opclasses: tuple[str, ...] | list[str] = (),
186
- violation_error_code: str | None = None,
187
- violation_error_message: str | None = None,
165
+ violation_error: ViolationError | None = None,
188
166
  ) -> None:
189
167
  if not name:
190
168
  raise ValueError("A unique constraint must be named.")
@@ -234,11 +212,7 @@ class UniqueConstraint(BaseConstraint):
234
212
  F(expression) if isinstance(expression, str) else expression
235
213
  for expression in expressions
236
214
  )
237
- super().__init__(
238
- name=name,
239
- violation_error_code=violation_error_code,
240
- violation_error_message=violation_error_message,
241
- )
215
+ super().__init__(name=name, violation_error=violation_error)
242
216
 
243
217
  @property
244
218
  def contains_expressions(self) -> bool:
@@ -299,7 +273,7 @@ class UniqueConstraint(BaseConstraint):
299
273
  return sql
300
274
 
301
275
  def __repr__(self) -> str:
302
- return "<{}:{}{}{}{}{}{}{}{}{}>".format(
276
+ return "<{}:{}{}{}{}{}{}{}{}>".format(
303
277
  self.__class__.__qualname__,
304
278
  "" if not self.fields else f" fields={repr(self.fields)}",
305
279
  "" if not self.expressions else f" expressions={repr(self.expressions)}",
@@ -310,14 +284,8 @@ class UniqueConstraint(BaseConstraint):
310
284
  "" if not self.opclasses else f" opclasses={repr(self.opclasses)}",
311
285
  (
312
286
  ""
313
- if self.violation_error_code is None
314
- else f" violation_error_code={self.violation_error_code!r}"
315
- ),
316
- (
317
- ""
318
- if self.violation_error_message is None
319
- or self.violation_error_message == self.default_violation_error_message
320
- else f" violation_error_message={self.violation_error_message!r}"
287
+ if self.violation_error is None
288
+ else f" violation_error={self.violation_error!r}"
321
289
  ),
322
290
  )
323
291
 
@@ -331,8 +299,7 @@ class UniqueConstraint(BaseConstraint):
331
299
  and self.include == other.include
332
300
  and self.opclasses == other.opclasses
333
301
  and self.expressions == other.expressions
334
- and self.violation_error_code == other.violation_error_code
335
- and self.violation_error_message == other.violation_error_message
302
+ and self.violation_error == other.violation_error
336
303
  )
337
304
  return super().__eq__(other)
338
305
 
@@ -395,22 +362,7 @@ class UniqueConstraint(BaseConstraint):
395
362
  queryset = queryset.exclude(id=model_class_id)
396
363
  if not self.condition:
397
364
  if queryset.exists():
398
- if self.expressions:
399
- raise ValidationError(
400
- self.get_violation_error_message(),
401
- code=self.violation_error_code,
402
- )
403
- # When fields are defined, use the unique_error_message() for
404
- # backward compatibility.
405
- for constraint_model, constraints in instance.get_constraints():
406
- for constraint in constraints:
407
- if constraint is self:
408
- raise ValidationError(
409
- instance.unique_error_message(
410
- constraint_model,
411
- self.fields,
412
- ),
413
- )
365
+ raise self._build_unique_violation(instance, model)
414
366
  else:
415
367
  against = instance._get_field_value_map(
416
368
  meta=model._model_meta, exclude=exclude
@@ -419,9 +371,32 @@ class UniqueConstraint(BaseConstraint):
419
371
  if (self.condition & Exists(queryset.filter(self.condition))).check(
420
372
  against
421
373
  ):
422
- raise ValidationError(
423
- self.get_violation_error_message(),
424
- code=self.violation_error_code,
425
- )
374
+ raise self._build_unique_violation(instance, model)
426
375
  except FieldError:
427
376
  pass
377
+
378
+ def _build_unique_violation(
379
+ self, instance: Model, model: type[Model]
380
+ ) -> ValidationError:
381
+ """Build the ValidationError for a unique violation.
382
+
383
+ Single-field unique constraints route the error to that field via the
384
+ dict form so it surfaces under the field rather than NON_FIELD_ERRORS.
385
+ """
386
+ single_field = self.fields[0] if len(self.fields) == 1 else None
387
+
388
+ if self.violation_error is not None:
389
+ err = self._build_violation_error()
390
+ # Only auto-route flat errors. A ValidationError that already has
391
+ # an error_dict (from dict-form input or a caller-built instance)
392
+ # already declares its own field routing — don't override it.
393
+ if single_field and not hasattr(err, "error_dict"):
394
+ return ValidationError({single_field: [err]})
395
+ return err
396
+
397
+ if self.fields:
398
+ err = instance.unique_error_message(model, self.fields)
399
+ if single_field:
400
+ return ValidationError({single_field: [err]})
401
+ return err
402
+ return ValidationError(f'Constraint "{self.name}" is violated.')
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "plain.postgres"
3
- version = "0.99.1"
3
+ version = "0.100.0"
4
4
  description = "Model your data and store it in a database."
5
5
  authors = [{ name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev" }]
6
6
  readme = "README.md"
@@ -0,0 +1,216 @@
1
+ """Tests for the `violation_error` kwarg on CheckConstraint and
2
+ UniqueConstraint, plus the full_clean() / save() integration that surfaces
3
+ constraint errors."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import pytest
8
+ from app.examples.models.constraints import ConstraintExample
9
+
10
+ from plain.exceptions import ValidationError
11
+ from plain.postgres import CheckConstraint, Q, UniqueConstraint
12
+ from plain.postgres.forms import ModelForm
13
+ from plain.test import RequestFactory
14
+
15
+
16
+ def _check_constraint() -> CheckConstraint:
17
+ return CheckConstraint(
18
+ check=Q(name__startswith="ok-"),
19
+ name="constraint_must_start_ok",
20
+ violation_error=ValidationError(
21
+ 'Name must start with "ok-".', code="bad_prefix"
22
+ ),
23
+ )
24
+
25
+
26
+ def _add_check_constraint(monkeypatch: pytest.MonkeyPatch) -> None:
27
+ monkeypatch.setattr(
28
+ ConstraintExample.model_options,
29
+ "constraints",
30
+ (*ConstraintExample.model_options.constraints, _check_constraint()),
31
+ )
32
+
33
+
34
+ def test_validate_uses_violation_error(db: None) -> None:
35
+ constraint = _check_constraint()
36
+ instance = ConstraintExample(name="bad", description="d")
37
+
38
+ with pytest.raises(ValidationError) as exc_info:
39
+ constraint.validate(ConstraintExample, instance)
40
+
41
+ err = exc_info.value.error_list[0]
42
+ assert err.message == 'Name must start with "ok-".'
43
+ assert err.code == "bad_prefix"
44
+
45
+
46
+ def test_validate_string_violation_error(db: None) -> None:
47
+ constraint = CheckConstraint(
48
+ check=Q(name__startswith="ok-"),
49
+ name="c",
50
+ violation_error="bad name",
51
+ )
52
+ instance = ConstraintExample(name="bad", description="d")
53
+ with pytest.raises(ValidationError) as exc_info:
54
+ constraint.validate(ConstraintExample, instance)
55
+ assert exc_info.value.messages == ["bad name"]
56
+
57
+
58
+ def test_validate_default_violation_error(db: None) -> None:
59
+ constraint = CheckConstraint(check=Q(name__startswith="ok-"), name="my_constraint")
60
+ instance = ConstraintExample(name="bad", description="d")
61
+ with pytest.raises(ValidationError) as exc_info:
62
+ constraint.validate(ConstraintExample, instance)
63
+ assert exc_info.value.messages == ['Constraint "my_constraint" is violated.']
64
+
65
+
66
+ def test_validate_passes_when_check_satisfied(db: None) -> None:
67
+ constraint = _check_constraint()
68
+ instance = ConstraintExample(name="ok-fine", description="d")
69
+ constraint.validate(ConstraintExample, instance)
70
+
71
+
72
+ def test_full_clean_runs_constraint_validation(
73
+ db: None, monkeypatch: pytest.MonkeyPatch
74
+ ) -> None:
75
+ _add_check_constraint(monkeypatch)
76
+ instance = ConstraintExample(name="bad", description="d")
77
+ with pytest.raises(ValidationError) as exc_info:
78
+ instance.full_clean()
79
+ assert any("Name must start" in m for m in exc_info.value.messages)
80
+
81
+
82
+ def test_save_runs_full_clean_by_default(
83
+ db: None, monkeypatch: pytest.MonkeyPatch
84
+ ) -> None:
85
+ _add_check_constraint(monkeypatch)
86
+ with pytest.raises(ValidationError):
87
+ ConstraintExample(name="bad", description="d").save()
88
+ assert ConstraintExample.query.filter(name="bad").count() == 0
89
+
90
+
91
+ def test_save_clean_and_validate_false_skips_validation(
92
+ db: None, monkeypatch: pytest.MonkeyPatch
93
+ ) -> None:
94
+ _add_check_constraint(monkeypatch)
95
+ ConstraintExample(name="bad", description="d").save(clean_and_validate=False)
96
+ assert ConstraintExample.query.filter(name="bad").count() == 1
97
+
98
+
99
+ def test_check_constraint_dict_violation_error_routes_to_field(
100
+ db: None, monkeypatch: pytest.MonkeyPatch
101
+ ) -> None:
102
+ """Dict-form violation_error attaches the error to the named field."""
103
+ constraint = CheckConstraint(
104
+ check=Q(name__startswith="ok-"),
105
+ name="must_start_ok",
106
+ violation_error={"name": 'Name must start with "ok-".'},
107
+ )
108
+ monkeypatch.setattr(
109
+ ConstraintExample.model_options,
110
+ "constraints",
111
+ (*ConstraintExample.model_options.constraints, constraint),
112
+ )
113
+
114
+ class Form(ModelForm):
115
+ class Meta:
116
+ model = ConstraintExample
117
+ fields = ["name", "description"]
118
+
119
+ rf = RequestFactory()
120
+ form = Form(request=rf.post("/x/", data={"name": "bad", "description": "d"}))
121
+ assert not form.is_valid()
122
+ assert form.errors.get("name"), form.errors
123
+ assert "name" in form.errors
124
+ assert "__all__" not in form.errors
125
+
126
+
127
+ def test_check_constraint_string_violation_error_lands_on_non_field_errors(
128
+ db: None, monkeypatch: pytest.MonkeyPatch
129
+ ) -> None:
130
+ """A bare string violation_error on CheckConstraint goes to NON_FIELD_ERRORS
131
+ because the constraint can't infer which field to attach to from a Q
132
+ expression."""
133
+ constraint = CheckConstraint(
134
+ check=Q(name__startswith="ok-"),
135
+ name="must_start_ok",
136
+ violation_error='Name must start with "ok-".',
137
+ )
138
+ monkeypatch.setattr(
139
+ ConstraintExample.model_options,
140
+ "constraints",
141
+ (*ConstraintExample.model_options.constraints, constraint),
142
+ )
143
+
144
+ class Form(ModelForm):
145
+ class Meta:
146
+ model = ConstraintExample
147
+ fields = ["name", "description"]
148
+
149
+ rf = RequestFactory()
150
+ form = Form(request=rf.post("/x/", data={"name": "bad", "description": "d"}))
151
+ assert not form.is_valid()
152
+ assert "name" not in form.errors
153
+ assert "__all__" in form.errors
154
+
155
+
156
+ def test_unique_constraint_explicit_validation_error_dict_preserved(
157
+ db: None, monkeypatch: pytest.MonkeyPatch
158
+ ) -> None:
159
+ """A caller-built ValidationError with an error_dict declares its own
160
+ field routing — single-field auto-routing must not flatten it back under
161
+ the constrained field."""
162
+ constraint = UniqueConstraint(
163
+ fields=["name"],
164
+ name="unique_name_explicit",
165
+ violation_error=ValidationError(
166
+ {"description": "Pick a different name to free this description."}
167
+ ),
168
+ )
169
+ monkeypatch.setattr(
170
+ ConstraintExample.model_options,
171
+ "constraints",
172
+ (*ConstraintExample.model_options.constraints, constraint),
173
+ )
174
+
175
+ ConstraintExample(name="dup", description="d1").save(clean_and_validate=False)
176
+ instance = ConstraintExample(name="dup", description="d2")
177
+
178
+ with pytest.raises(ValidationError) as exc_info:
179
+ instance.full_clean()
180
+
181
+ err = exc_info.value
182
+ assert hasattr(err, "error_dict")
183
+ assert "description" in err.error_dict
184
+ assert "name" not in err.error_dict
185
+
186
+
187
+ def test_unique_constraint_single_field_string_routes_to_field(
188
+ db: None, monkeypatch: pytest.MonkeyPatch
189
+ ) -> None:
190
+ """Single-field UniqueConstraint auto-routes a string violation_error to
191
+ that field (no special routing in validate_constraints — the dict-form is
192
+ built inside validate())."""
193
+ constraint = UniqueConstraint(
194
+ fields=["name"],
195
+ name="unique_name_only",
196
+ violation_error="That name is taken.",
197
+ )
198
+ monkeypatch.setattr(
199
+ ConstraintExample.model_options,
200
+ "constraints",
201
+ (*ConstraintExample.model_options.constraints, constraint),
202
+ )
203
+
204
+ ConstraintExample(name="dup", description="d1").save(clean_and_validate=False)
205
+
206
+ class Form(ModelForm):
207
+ class Meta:
208
+ model = ConstraintExample
209
+ fields = ["name", "description"]
210
+
211
+ rf = RequestFactory()
212
+ form = Form(request=rf.post("/x/", data={"name": "dup", "description": "d2"}))
213
+ assert not form.is_valid()
214
+ assert any("That name is taken." in m for m in form.errors.get("name", [])), (
215
+ form.errors
216
+ )