plain.postgres 0.103.1__tar.gz → 0.103.2__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 (209) hide show
  1. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/PKG-INFO +1 -1
  2. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/CHANGELOG.md +12 -0
  3. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/constraints.py +4 -0
  4. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/indexes.py +4 -0
  5. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/introspection/health/checks_structural.py +11 -1
  6. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/preflight.py +57 -4
  7. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/pyproject.toml +1 -1
  8. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/internal/test_diagnose.py +28 -0
  9. plain_postgres-0.103.2/tests/internal/test_preflight_fk_coverage.py +157 -0
  10. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/.gitignore +0 -0
  11. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/CLAUDE.md +0 -0
  12. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/LICENSE +0 -0
  13. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/README.md +0 -0
  14. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/README.md +0 -0
  15. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/__init__.py +0 -0
  16. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/adapters.py +0 -0
  17. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -0
  18. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
  19. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/aggregates.py +0 -0
  20. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/base.py +0 -0
  21. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/cli/__init__.py +0 -0
  22. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/cli/converge.py +0 -0
  23. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/cli/core.py +0 -0
  24. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/cli/decorators.py +0 -0
  25. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/cli/diagnose.py +0 -0
  26. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/cli/migrations.py +0 -0
  27. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/cli/schema.py +0 -0
  28. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/cli/sync.py +0 -0
  29. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/config.py +0 -0
  30. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/connection.py +0 -0
  31. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/constants.py +0 -0
  32. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/convergence/__init__.py +0 -0
  33. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/convergence/analysis.py +0 -0
  34. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/convergence/fixes.py +0 -0
  35. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/convergence/planning.py +0 -0
  36. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/database_url.py +0 -0
  37. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/db.py +0 -0
  38. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/ddl.py +0 -0
  39. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/default_settings.py +0 -0
  40. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/deletion.py +0 -0
  41. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/dialect.py +0 -0
  42. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/entrypoints.py +0 -0
  43. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/enums.py +0 -0
  44. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/exceptions.py +0 -0
  45. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/expressions.py +0 -0
  46. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/fields/__init__.py +0 -0
  47. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/fields/base.py +0 -0
  48. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/fields/binary.py +0 -0
  49. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/fields/boolean.py +0 -0
  50. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/fields/duration.py +0 -0
  51. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/fields/encrypted.py +0 -0
  52. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/fields/json.py +0 -0
  53. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/fields/mixins.py +0 -0
  54. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/fields/network.py +0 -0
  55. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/fields/numeric.py +0 -0
  56. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/fields/primary_key.py +0 -0
  57. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/fields/related.py +0 -0
  58. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/fields/related_descriptors.py +0 -0
  59. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/fields/related_lookups.py +0 -0
  60. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/fields/related_managers.py +0 -0
  61. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/fields/reverse_descriptors.py +0 -0
  62. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/fields/reverse_related.py +0 -0
  63. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/fields/temporal.py +0 -0
  64. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/fields/text.py +0 -0
  65. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/fields/timezones.py +0 -0
  66. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/fields/uuid.py +0 -0
  67. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/forms.py +0 -0
  68. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/functions/__init__.py +0 -0
  69. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/functions/comparison.py +0 -0
  70. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/functions/datetime.py +0 -0
  71. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/functions/math.py +0 -0
  72. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/functions/mixins.py +0 -0
  73. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/functions/random.py +0 -0
  74. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/functions/text.py +0 -0
  75. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/functions/uuid.py +0 -0
  76. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/functions/window.py +0 -0
  77. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/introspection/__init__.py +0 -0
  78. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/introspection/health/__init__.py +0 -0
  79. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
  80. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
  81. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/introspection/health/context.py +0 -0
  82. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/introspection/health/helpers.py +0 -0
  83. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/introspection/health/ownership.py +0 -0
  84. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/introspection/health/runner.py +0 -0
  85. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/introspection/health/types.py +0 -0
  86. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/introspection/schema.py +0 -0
  87. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/lookups.py +0 -0
  88. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/meta.py +0 -0
  89. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/middleware.py +0 -0
  90. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/migrations/__init__.py +0 -0
  91. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/migrations/autodetector.py +0 -0
  92. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/migrations/exceptions.py +0 -0
  93. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/migrations/executor.py +0 -0
  94. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/migrations/graph.py +0 -0
  95. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/migrations/loader.py +0 -0
  96. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/migrations/migration.py +0 -0
  97. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/migrations/operations/__init__.py +0 -0
  98. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/migrations/operations/base.py +0 -0
  99. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/migrations/operations/fields.py +0 -0
  100. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/migrations/operations/models.py +0 -0
  101. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/migrations/operations/special.py +0 -0
  102. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/migrations/optimizer.py +0 -0
  103. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/migrations/questioner.py +0 -0
  104. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/migrations/recorder.py +0 -0
  105. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/migrations/serializer.py +0 -0
  106. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/migrations/state.py +0 -0
  107. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/migrations/utils.py +0 -0
  108. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/migrations/writer.py +0 -0
  109. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/options.py +0 -0
  110. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/otel.py +0 -0
  111. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/query.py +0 -0
  112. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/query_utils.py +0 -0
  113. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/registry.py +0 -0
  114. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/schema.py +0 -0
  115. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/sources.py +0 -0
  116. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/sql/__init__.py +0 -0
  117. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/sql/compiler.py +0 -0
  118. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/sql/constants.py +0 -0
  119. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/sql/datastructures.py +0 -0
  120. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/sql/query.py +0 -0
  121. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/sql/where.py +0 -0
  122. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/test/__init__.py +0 -0
  123. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/test/database.py +0 -0
  124. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/test/pytest.py +0 -0
  125. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/transaction.py +0 -0
  126. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/types.py +0 -0
  127. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/types.pyi +0 -0
  128. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/plain/postgres/utils.py +0 -0
  129. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/forms.py +0 -0
  130. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/migrations/0001_initial.py +0 -0
  131. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  132. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
  133. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
  134. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
  135. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/migrations/0006_secretstore.py +0 -0
  136. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
  137. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
  138. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
  139. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
  140. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
  141. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
  142. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
  143. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
  144. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
  145. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/migrations/0016_formsexample.py +0 -0
  146. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
  147. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/migrations/0018_storageparametersexample.py +0 -0
  148. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/migrations/__init__.py +0 -0
  149. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/models/__init__.py +0 -0
  150. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/models/constraints.py +0 -0
  151. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/models/defaults.py +0 -0
  152. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/models/delete.py +0 -0
  153. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/models/encrypted.py +0 -0
  154. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/models/forms.py +0 -0
  155. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/models/indexes.py +0 -0
  156. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/models/iteration.py +0 -0
  157. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/models/mixins.py +0 -0
  158. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/models/nullability.py +0 -0
  159. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/models/querysets.py +0 -0
  160. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/models/relationships.py +0 -0
  161. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/models/storage_parameters.py +0 -0
  162. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/models/trees.py +0 -0
  163. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/models/unregistered.py +0 -0
  164. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/urls.py +0 -0
  165. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/examples/views.py +0 -0
  166. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/settings.py +0 -0
  167. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/app/urls.py +0 -0
  168. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/conftest.py +0 -0
  169. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/conftest_convergence.py +0 -0
  170. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/internal/test_autodetector_not_null_errors.py +0 -0
  171. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/internal/test_autodetector_type_change.py +0 -0
  172. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/internal/test_connection_isolation.py +0 -0
  173. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/internal/test_connection_lifecycle.py +0 -0
  174. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/internal/test_connection_pool.py +0 -0
  175. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/internal/test_constraint_violation_error.py +0 -0
  176. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/internal/test_convergence.py +0 -0
  177. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/internal/test_convergence_constraints.py +0 -0
  178. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/internal/test_convergence_defaults.py +0 -0
  179. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/internal/test_convergence_fk.py +0 -0
  180. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/internal/test_convergence_indexes.py +0 -0
  181. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/internal/test_convergence_nullability.py +0 -0
  182. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/internal/test_convergence_storage_parameters.py +0 -0
  183. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/internal/test_convergence_timeouts.py +0 -0
  184. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/internal/test_db_expression_defaults.py +0 -0
  185. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/internal/test_executor_connection_hook.py +0 -0
  186. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/internal/test_health.py +0 -0
  187. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/internal/test_introspection.py +0 -0
  188. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/internal/test_literal_default_persistence.py +0 -0
  189. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/internal/test_management_connection.py +0 -0
  190. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/internal/test_migration_executor.py +0 -0
  191. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/internal/test_no_callable_defaults.py +0 -0
  192. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/internal/test_otel_metrics.py +0 -0
  193. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/internal/test_schema_normalize_type.py +0 -0
  194. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/internal/test_schema_timeouts.py +0 -0
  195. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/public/test_database_url.py +0 -0
  196. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/public/test_delete_behaviors.py +0 -0
  197. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/public/test_encrypted_fields.py +0 -0
  198. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/public/test_exceptions.py +0 -0
  199. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/public/test_field_defaults.py +0 -0
  200. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/public/test_functions_uuid.py +0 -0
  201. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/public/test_iterator.py +0 -0
  202. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/public/test_m2m.py +0 -0
  203. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/public/test_manager_assignment.py +0 -0
  204. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/public/test_mixins.py +0 -0
  205. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/public/test_modelform_roundtrip.py +0 -0
  206. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/public/test_random_string_field.py +0 -0
  207. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/public/test_raw_query.py +0 -0
  208. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/public/test_read_only_transactions.py +0 -0
  209. {plain_postgres-0.103.1 → plain_postgres-0.103.2}/tests/public/test_related.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain.postgres
3
- Version: 0.103.1
3
+ Version: 0.103.2
4
4
  Summary: Model your data and store it in a database.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-Expression: BSD-3-Clause
@@ -1,5 +1,17 @@
1
1
  # plain-postgres changelog
2
2
 
3
+ ## [0.103.2](https://github.com/dropseed/plain/releases/plain-postgres@0.103.2) (2026-05-06)
4
+
5
+ ### What's changed
6
+
7
+ - **`postgres.missing_fk_indexes` preflight now recognizes bare-column leading expressions.** A `UniqueConstraint(F("team"), Lower("email"))` declared via the `expressions=` API previously slipped past the model-level check — the preflight only inspected `fields=`, so it warned about a missing FK index even though the underlying btree's leading column was the real `team` attribute and the live diagnose check correctly recognized coverage. The preflight now extracts the leading column from `F(...)` and `OrderBy(F(...))` expressions to match Postgres' actual coverage semantics. ([ae3880098f](https://github.com/dropseed/plain/commit/ae3880098f))
8
+ - **Both FK-coverage checks now skip partial indexes.** A partial index like `Index(fields=["team"], condition=Q(deleted_at__isnull=True))` only satisfies queries whose predicate implies the partial-index `WHERE`, so an unfiltered FK lookup or cascade delete still sequential-scans. The preflight (`_fk_covered_field_names`) and the live diagnose check (`check_missing_fk_indexes`) both used to silently treat partial indexes as covering — now they don't, so the warning fires on the real coverage gap. The narrow `WHERE fk IS NOT NULL` case is conservatively also treated as not covering; users wanting guaranteed FK coverage should add a regular non-partial `Index(fields=[...])`. ([b1d13a6b42](https://github.com/dropseed/plain/commit/b1d13a6b42))
9
+ - New `is_partial` property on `Index` and `UniqueConstraint` returning `condition is not None`, for callers that need the distinction. ([b1d13a6b42](https://github.com/dropseed/plain/commit/b1d13a6b42))
10
+
11
+ ### Upgrade instructions
12
+
13
+ - No changes required. After upgrading, `postgres.missing_fk_indexes` may surface previously-undetected FK coverage gaps (where the only matching index was partial) and may suppress previously-warning false positives (where the only matching index used `expressions=`). Add a non-partial `Index(fields=["fk"])` to silence the warning in the partial-index case.
14
+
3
15
  ## [0.103.1](https://github.com/dropseed/plain/releases/plain-postgres@0.103.1) (2026-05-06)
4
16
 
5
17
  ### What's changed
@@ -250,6 +250,10 @@ class UniqueConstraint(BaseConstraint):
250
250
  def contains_expressions(self) -> bool:
251
251
  return bool(self.expressions)
252
252
 
253
+ @property
254
+ def is_partial(self) -> bool:
255
+ return self.condition is not None
256
+
253
257
  @property
254
258
  def index_only(self) -> bool:
255
259
  """Whether PostgreSQL can only store this as a unique index, not a constraint.
@@ -83,6 +83,10 @@ class Index:
83
83
  def contains_expressions(self) -> bool:
84
84
  return bool(self.expressions)
85
85
 
86
+ @property
87
+ def is_partial(self) -> bool:
88
+ return self.condition is not None
89
+
86
90
  def to_sql(self, model: type[Model]) -> str:
87
91
  """Generate CREATE INDEX CONCURRENTLY SQL as a plain string."""
88
92
  table = model.model_options.db_table
@@ -187,7 +187,16 @@ def check_duplicate_indexes(
187
187
  def check_missing_fk_indexes(
188
188
  cursor: Any, table_owners: dict[str, TableOwner]
189
189
  ) -> CheckResult:
190
- """Foreign key columns without a leading index — JOINs on these do sequential scans."""
190
+ """Foreign key columns without a leading index — JOINs on these do sequential scans.
191
+
192
+ Partial indexes (``WHERE`` clause set on ``pg_index.indpred``) don't
193
+ count: Postgres only uses them for queries that imply the partial
194
+ predicate, so FK lookups and cascade deletes outside that predicate
195
+ still sequential-scan. The narrow ``WHERE fk IS NOT NULL`` case —
196
+ which Postgres can match to ``WHERE fk = ?`` — is conservatively
197
+ treated as not covering; users wanting guaranteed FK coverage should
198
+ add a regular non-partial index. Match the preflight's coverage rule.
199
+ """
191
200
  cursor.execute("""
192
201
  SELECT
193
202
  ct.relname AS table_name,
@@ -207,6 +216,7 @@ def check_missing_fk_indexes(
207
216
  FROM pg_catalog.pg_index i
208
217
  WHERE i.indrelid = c.conrelid
209
218
  AND i.indkey[0] = c.conkey[1]
219
+ AND i.indpred IS NULL
210
220
  )
211
221
  ORDER BY ct.relname, a.attname
212
222
  """)
@@ -8,6 +8,7 @@ from typing import Any
8
8
  from plain.packages import packages_registry
9
9
  from plain.postgres.constraints import UniqueConstraint
10
10
  from plain.postgres.db import get_connection
11
+ from plain.postgres.expressions import F, OrderBy
11
12
  from plain.postgres.fields.related import ForeignKeyField
12
13
  from plain.postgres.registry import ModelsRegistry, models_registry
13
14
  from plain.preflight import PreflightCheck, PreflightResult, register_check
@@ -41,6 +42,61 @@ def _collect_model_indexes(model: Any) -> list[tuple[str, list[str], bool]]:
41
42
  return all_indexes
42
43
 
43
44
 
45
+ def _bare_column_name(expr: Any) -> str | None:
46
+ """Return the column name if `expr` resolves to a bare column, else `None`.
47
+
48
+ Postgres can range-scan the leading column of an index for `WHERE col = ?`
49
+ only when that column is a real attribute, not an expression — so a
50
+ compound leading expression like `Lower("email")` returns `None` here.
51
+ Sort direction (`F("col").desc()` / `OrderBy(F)`) doesn't affect equality
52
+ lookups, so we unwrap one layer of `OrderBy` around a bare `F`.
53
+ """
54
+ if isinstance(expr, OrderBy):
55
+ expr = expr.expression
56
+ if isinstance(expr, F):
57
+ return expr.name
58
+ return None
59
+
60
+
61
+ def _fk_covered_field_names(model: Any) -> set[str]:
62
+ """Field names that appear as the leading column of a non-partial index
63
+ or unique constraint — covering arbitrary FK lookups via the index's
64
+ leading column.
65
+
66
+ Partial indexes/constraints (declared with ``condition=Q(...)``) are
67
+ excluded: Postgres can only use them for queries whose predicate
68
+ implies the partial-index predicate, so an FK lookup or cascade
69
+ delete that doesn't filter by that condition still does a sequential
70
+ scan. (The narrow ``WHERE fk IS NOT NULL`` case — which Postgres can
71
+ match to ``WHERE fk = ?`` — is conservatively treated as not
72
+ covering; users wanting guaranteed FK coverage should add a regular
73
+ non-partial ``Index(fields=[...])``.) Includes expression-based
74
+ indexes/constraints whose leading expression is a bare
75
+ ``F(field_name)``.
76
+ """
77
+ covered: set[str] = set()
78
+
79
+ def _record_leading(
80
+ fields: tuple[str, ...] | list[str], expressions: tuple
81
+ ) -> None:
82
+ if fields:
83
+ covered.add(fields[0].lstrip("-"))
84
+ elif expressions:
85
+ name = _bare_column_name(expressions[0])
86
+ if name is not None:
87
+ covered.add(name)
88
+
89
+ for index in model.model_options.indexes:
90
+ if not index.is_partial:
91
+ _record_leading(index.fields, index.expressions)
92
+
93
+ for constraint in model.model_options.constraints:
94
+ if isinstance(constraint, UniqueConstraint) and not constraint.is_partial:
95
+ _record_leading(constraint.fields, constraint.expressions)
96
+
97
+ return covered
98
+
99
+
44
100
  @register_check("postgres.all_models")
45
101
  class CheckAllModels(PreflightCheck):
46
102
  """Validates all model definitions for common issues."""
@@ -386,10 +442,7 @@ class CheckMissingFKIndexes(PreflightCheck):
386
442
  results = []
387
443
 
388
444
  for model in _get_app_models():
389
- # Leading field of each index/constraint covers FK lookups
390
- covered_fields = {
391
- fields[0] for _, fields, _ in _collect_model_indexes(model)
392
- }
445
+ covered_fields = _fk_covered_field_names(model)
393
446
 
394
447
  for field in model._model_meta.local_fields:
395
448
  if (
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "plain.postgres"
3
- version = "0.103.1"
3
+ version = "0.103.2"
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"
@@ -404,6 +404,34 @@ class TestStructuralScenarios:
404
404
  flagged = [i for i in result["items"] if i["table"] == "_diag_fk_child2"]
405
405
  assert flagged == [], f"indexed FK should not be flagged; got {flagged}"
406
406
 
407
+ def test_missing_fk_index_detected_when_only_partial_index_covers(self) -> None:
408
+ """A partial index on the FK column doesn't cover arbitrary FK
409
+ lookups — Postgres can only use it for queries whose predicate
410
+ implies the partial-index `WHERE`. The check must still flag the
411
+ FK as missing index coverage."""
412
+ _execute('CREATE TABLE "_diag_fk_parent3" ("id" serial PRIMARY KEY)')
413
+ _execute(
414
+ 'CREATE TABLE "_diag_fk_child3" ('
415
+ '"id" serial PRIMARY KEY, '
416
+ '"parent_id" int REFERENCES "_diag_fk_parent3"("id"), '
417
+ '"deleted_at" timestamptz)'
418
+ )
419
+ _execute(
420
+ 'CREATE INDEX "_diag_fk_child3_partial_parent_idx" '
421
+ 'ON "_diag_fk_child3" ("parent_id") '
422
+ "WHERE deleted_at IS NULL"
423
+ )
424
+
425
+ conn = get_connection()
426
+ with conn.cursor() as cursor:
427
+ result = check_missing_fk_indexes(cursor, {})
428
+
429
+ flagged = [i for i in result["items"] if i["table"] == "_diag_fk_child3"]
430
+ assert len(flagged) == 1, (
431
+ f"partial index must not satisfy FK coverage; got {flagged}"
432
+ )
433
+ assert flagged[0]["name"] == "_diag_fk_child3.parent_id"
434
+
407
435
  def test_sequence_exhaustion_critical_above_90pct(self) -> None:
408
436
  _execute('CREATE TABLE "_diag_seq" ("id" serial PRIMARY KEY, "n" int)')
409
437
  # int4 sequence max is 2^31-1 = 2147483647; push past 90% to trip critical.
@@ -0,0 +1,157 @@
1
+ """Unit tests for `_fk_covered_field_names` — the helper that powers
2
+ `postgres.missing_fk_indexes`'s preflight check.
3
+
4
+ The check fires on FK fields whose name doesn't appear here. The helper
5
+ must recognize bare `F("col")` leading expressions so a constraint like
6
+ `UniqueConstraint(F("team"), Lower("email"))` counts as covering the
7
+ `team` FK — Postgres' underlying btree's leading column is the real
8
+ `team` attribute, not an expression.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from types import SimpleNamespace
14
+
15
+ from plain.postgres import Q
16
+ from plain.postgres.constraints import UniqueConstraint
17
+ from plain.postgres.expressions import F
18
+ from plain.postgres.functions import Lower
19
+ from plain.postgres.indexes import Index
20
+ from plain.postgres.preflight import _fk_covered_field_names
21
+
22
+
23
+ def _model(*, indexes=(), constraints=()) -> SimpleNamespace:
24
+ """Minimal model_options stand-in for the helper."""
25
+ return SimpleNamespace(
26
+ model_options=SimpleNamespace(
27
+ indexes=list(indexes), constraints=list(constraints)
28
+ )
29
+ )
30
+
31
+
32
+ def test_covers_index_with_fields():
33
+ model = _model(indexes=[Index(name="t_team_idx", fields=["team"])])
34
+ assert _fk_covered_field_names(model) == {"team"}
35
+
36
+
37
+ def test_covers_unique_constraint_with_fields():
38
+ model = _model(
39
+ constraints=[
40
+ UniqueConstraint(fields=["team", "account"], name="t_team_acct_uniq")
41
+ ]
42
+ )
43
+ assert _fk_covered_field_names(model) == {"team"}
44
+
45
+
46
+ def test_covers_unique_constraint_with_bare_f_leading_expression():
47
+ """A unique constraint declared via `expressions=` whose leading
48
+ expression is a bare `F("col")` covers the FK — the underlying btree's
49
+ leading attribute is still the real column."""
50
+ model = _model(
51
+ constraints=[
52
+ UniqueConstraint(
53
+ F("team"),
54
+ Lower("email"),
55
+ name="t_team_email_uniq",
56
+ )
57
+ ]
58
+ )
59
+ assert "team" in _fk_covered_field_names(model)
60
+
61
+
62
+ def test_covers_unique_constraint_with_ordered_bare_f_leading_expression():
63
+ """`F("team").desc()` produces `OrderBy(F("team"))`. Postgres still emits
64
+ `team_id DESC` as a real leading column attribute, so equality FK
65
+ lookups are covered (sort direction doesn't matter for `WHERE = ?`)."""
66
+ model = _model(
67
+ constraints=[
68
+ UniqueConstraint(
69
+ F("team").desc(),
70
+ Lower("email"),
71
+ name="t_team_desc_email_uniq",
72
+ )
73
+ ]
74
+ )
75
+ assert "team" in _fk_covered_field_names(model)
76
+
77
+
78
+ def test_does_not_cover_when_leading_expression_is_compound():
79
+ """`(LOWER(email), team)` cannot satisfy `WHERE team = ?` from the
80
+ leading column — the leading "column" is an expression, so Postgres
81
+ can't range-scan it for a value lookup on team."""
82
+ model = _model(
83
+ constraints=[
84
+ UniqueConstraint(
85
+ Lower("email"),
86
+ F("team"),
87
+ name="t_lower_email_team_uniq",
88
+ )
89
+ ]
90
+ )
91
+ assert "team" not in _fk_covered_field_names(model)
92
+
93
+
94
+ def test_strips_descending_prefix_from_field_name():
95
+ """`fields=["-created_at"]` (descending) still has `created_at` as the
96
+ underlying column. The leading-column extraction must strip the prefix."""
97
+ model = _model(indexes=[Index(name="t_created_idx", fields=["-created_at"])])
98
+ assert _fk_covered_field_names(model) == {"created_at"}
99
+
100
+
101
+ def test_unions_indexes_and_constraints():
102
+ model = _model(
103
+ indexes=[Index(name="t_a_idx", fields=["a"])],
104
+ constraints=[
105
+ UniqueConstraint(fields=["b"], name="t_b_uniq"),
106
+ UniqueConstraint(F("c"), Lower("d"), name="t_c_d_uniq"),
107
+ ],
108
+ )
109
+ assert _fk_covered_field_names(model) == {"a", "b", "c"}
110
+
111
+
112
+ def test_partial_index_does_not_cover():
113
+ """`Index(fields=["team"], condition=Q(...))` only satisfies queries
114
+ whose predicate implies the partial-index `WHERE`. An unfiltered FK
115
+ lookup or cascade delete still sequential-scans, so the partial index
116
+ must not count as covering."""
117
+ model = _model(
118
+ indexes=[
119
+ Index(
120
+ name="t_team_active_idx",
121
+ fields=["team"],
122
+ condition=Q(deleted_at__isnull=True),
123
+ )
124
+ ]
125
+ )
126
+ assert "team" not in _fk_covered_field_names(model)
127
+
128
+
129
+ def test_partial_unique_constraint_does_not_cover():
130
+ """Same logic as the partial index — soft-delete-style partial unique
131
+ constraints don't guarantee FK lookup coverage."""
132
+ model = _model(
133
+ constraints=[
134
+ UniqueConstraint(
135
+ fields=["team"],
136
+ name="t_team_active_uniq",
137
+ condition=Q(deleted_at__isnull=True),
138
+ )
139
+ ]
140
+ )
141
+ assert "team" not in _fk_covered_field_names(model)
142
+
143
+
144
+ def test_full_index_still_covers_when_partial_sibling_exists():
145
+ """A non-partial index on the same column wins — partial-ness is per
146
+ declaration, not per column."""
147
+ model = _model(
148
+ indexes=[
149
+ Index(
150
+ name="t_team_active_idx",
151
+ fields=["team"],
152
+ condition=Q(deleted_at__isnull=True),
153
+ ),
154
+ Index(name="t_team_idx", fields=["team"]),
155
+ ]
156
+ )
157
+ assert "team" in _fk_covered_field_names(model)