plain.postgres 0.103.0__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.0 → plain_postgres-0.103.2}/PKG-INFO +1 -1
  2. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/CHANGELOG.md +22 -0
  3. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/constraints.py +4 -0
  4. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/indexes.py +4 -0
  5. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/introspection/health/checks_structural.py +61 -31
  6. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/preflight.py +106 -19
  7. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/pyproject.toml +1 -1
  8. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_diagnose.py +97 -0
  9. plain_postgres-0.103.2/tests/internal/test_preflight_fk_coverage.py +157 -0
  10. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/.gitignore +0 -0
  11. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/CLAUDE.md +0 -0
  12. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/LICENSE +0 -0
  13. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/README.md +0 -0
  14. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/README.md +0 -0
  15. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/__init__.py +0 -0
  16. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/adapters.py +0 -0
  17. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -0
  18. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
  19. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/aggregates.py +0 -0
  20. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/base.py +0 -0
  21. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/cli/__init__.py +0 -0
  22. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/cli/converge.py +0 -0
  23. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/cli/core.py +0 -0
  24. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/cli/decorators.py +0 -0
  25. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/cli/diagnose.py +0 -0
  26. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/cli/migrations.py +0 -0
  27. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/cli/schema.py +0 -0
  28. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/cli/sync.py +0 -0
  29. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/config.py +0 -0
  30. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/connection.py +0 -0
  31. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/constants.py +0 -0
  32. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/convergence/__init__.py +0 -0
  33. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/convergence/analysis.py +0 -0
  34. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/convergence/fixes.py +0 -0
  35. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/convergence/planning.py +0 -0
  36. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/database_url.py +0 -0
  37. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/db.py +0 -0
  38. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/ddl.py +0 -0
  39. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/default_settings.py +0 -0
  40. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/deletion.py +0 -0
  41. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/dialect.py +0 -0
  42. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/entrypoints.py +0 -0
  43. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/enums.py +0 -0
  44. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/exceptions.py +0 -0
  45. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/expressions.py +0 -0
  46. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/__init__.py +0 -0
  47. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/base.py +0 -0
  48. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/binary.py +0 -0
  49. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/boolean.py +0 -0
  50. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/duration.py +0 -0
  51. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/encrypted.py +0 -0
  52. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/json.py +0 -0
  53. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/mixins.py +0 -0
  54. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/network.py +0 -0
  55. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/numeric.py +0 -0
  56. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/primary_key.py +0 -0
  57. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/related.py +0 -0
  58. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/related_descriptors.py +0 -0
  59. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/related_lookups.py +0 -0
  60. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/related_managers.py +0 -0
  61. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/reverse_descriptors.py +0 -0
  62. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/reverse_related.py +0 -0
  63. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/temporal.py +0 -0
  64. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/text.py +0 -0
  65. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/timezones.py +0 -0
  66. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/fields/uuid.py +0 -0
  67. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/forms.py +0 -0
  68. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/functions/__init__.py +0 -0
  69. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/functions/comparison.py +0 -0
  70. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/functions/datetime.py +0 -0
  71. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/functions/math.py +0 -0
  72. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/functions/mixins.py +0 -0
  73. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/functions/random.py +0 -0
  74. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/functions/text.py +0 -0
  75. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/functions/uuid.py +0 -0
  76. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/functions/window.py +0 -0
  77. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/introspection/__init__.py +0 -0
  78. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/introspection/health/__init__.py +0 -0
  79. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
  80. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
  81. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/introspection/health/context.py +0 -0
  82. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/introspection/health/helpers.py +0 -0
  83. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/introspection/health/ownership.py +0 -0
  84. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/introspection/health/runner.py +0 -0
  85. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/introspection/health/types.py +0 -0
  86. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/introspection/schema.py +0 -0
  87. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/lookups.py +0 -0
  88. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/meta.py +0 -0
  89. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/middleware.py +0 -0
  90. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/__init__.py +0 -0
  91. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/autodetector.py +0 -0
  92. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/exceptions.py +0 -0
  93. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/executor.py +0 -0
  94. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/graph.py +0 -0
  95. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/loader.py +0 -0
  96. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/migration.py +0 -0
  97. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/operations/__init__.py +0 -0
  98. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/operations/base.py +0 -0
  99. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/operations/fields.py +0 -0
  100. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/operations/models.py +0 -0
  101. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/operations/special.py +0 -0
  102. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/optimizer.py +0 -0
  103. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/questioner.py +0 -0
  104. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/recorder.py +0 -0
  105. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/serializer.py +0 -0
  106. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/state.py +0 -0
  107. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/utils.py +0 -0
  108. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/migrations/writer.py +0 -0
  109. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/options.py +0 -0
  110. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/otel.py +0 -0
  111. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/query.py +0 -0
  112. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/query_utils.py +0 -0
  113. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/registry.py +0 -0
  114. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/schema.py +0 -0
  115. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/sources.py +0 -0
  116. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/sql/__init__.py +0 -0
  117. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/sql/compiler.py +0 -0
  118. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/sql/constants.py +0 -0
  119. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/sql/datastructures.py +0 -0
  120. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/sql/query.py +0 -0
  121. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/sql/where.py +0 -0
  122. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/test/__init__.py +0 -0
  123. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/test/database.py +0 -0
  124. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/test/pytest.py +0 -0
  125. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/transaction.py +0 -0
  126. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/types.py +0 -0
  127. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/types.pyi +0 -0
  128. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/plain/postgres/utils.py +0 -0
  129. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/forms.py +0 -0
  130. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0001_initial.py +0 -0
  131. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  132. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
  133. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
  134. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
  135. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0006_secretstore.py +0 -0
  136. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
  137. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
  138. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
  139. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
  140. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
  141. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
  142. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
  143. {plain_postgres-0.103.0 → 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.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
  145. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0016_formsexample.py +0 -0
  146. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
  147. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/0018_storageparametersexample.py +0 -0
  148. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/migrations/__init__.py +0 -0
  149. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/__init__.py +0 -0
  150. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/constraints.py +0 -0
  151. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/defaults.py +0 -0
  152. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/delete.py +0 -0
  153. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/encrypted.py +0 -0
  154. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/forms.py +0 -0
  155. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/indexes.py +0 -0
  156. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/iteration.py +0 -0
  157. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/mixins.py +0 -0
  158. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/nullability.py +0 -0
  159. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/querysets.py +0 -0
  160. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/relationships.py +0 -0
  161. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/storage_parameters.py +0 -0
  162. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/trees.py +0 -0
  163. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/models/unregistered.py +0 -0
  164. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/urls.py +0 -0
  165. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/examples/views.py +0 -0
  166. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/settings.py +0 -0
  167. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/app/urls.py +0 -0
  168. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/conftest.py +0 -0
  169. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/conftest_convergence.py +0 -0
  170. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_autodetector_not_null_errors.py +0 -0
  171. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_autodetector_type_change.py +0 -0
  172. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_connection_isolation.py +0 -0
  173. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_connection_lifecycle.py +0 -0
  174. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_connection_pool.py +0 -0
  175. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_constraint_violation_error.py +0 -0
  176. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_convergence.py +0 -0
  177. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_convergence_constraints.py +0 -0
  178. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_convergence_defaults.py +0 -0
  179. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_convergence_fk.py +0 -0
  180. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_convergence_indexes.py +0 -0
  181. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_convergence_nullability.py +0 -0
  182. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_convergence_storage_parameters.py +0 -0
  183. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_convergence_timeouts.py +0 -0
  184. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_db_expression_defaults.py +0 -0
  185. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_executor_connection_hook.py +0 -0
  186. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_health.py +0 -0
  187. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_introspection.py +0 -0
  188. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_literal_default_persistence.py +0 -0
  189. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_management_connection.py +0 -0
  190. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_migration_executor.py +0 -0
  191. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_no_callable_defaults.py +0 -0
  192. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_otel_metrics.py +0 -0
  193. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_schema_normalize_type.py +0 -0
  194. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/internal/test_schema_timeouts.py +0 -0
  195. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/public/test_database_url.py +0 -0
  196. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/public/test_delete_behaviors.py +0 -0
  197. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/public/test_encrypted_fields.py +0 -0
  198. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/public/test_exceptions.py +0 -0
  199. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/public/test_field_defaults.py +0 -0
  200. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/public/test_functions_uuid.py +0 -0
  201. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/public/test_iterator.py +0 -0
  202. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/public/test_m2m.py +0 -0
  203. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/public/test_manager_assignment.py +0 -0
  204. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/public/test_mixins.py +0 -0
  205. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/public/test_modelform_roundtrip.py +0 -0
  206. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/public/test_random_string_field.py +0 -0
  207. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/public/test_raw_query.py +0 -0
  208. {plain_postgres-0.103.0 → plain_postgres-0.103.2}/tests/public/test_read_only_transactions.py +0 -0
  209. {plain_postgres-0.103.0 → 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.0
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,27 @@
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
+
15
+ ## [0.103.1](https://github.com/dropseed/plain/releases/plain-postgres@0.103.1) (2026-05-06)
16
+
17
+ ### What's changed
18
+
19
+ - **`postgres.duplicate_indexes` now flags exact-column duplicates**, not just prefix-redundancy. Previously the check required the redundant index to be strictly shorter than the index covering it, so an `Index(fields=["x"])` declared next to a same-column `UniqueConstraint(fields=["x"])` slipped past — even though the unique-backed btree already covers the same lookups and enforces uniqueness. The check (and the matching preflight) now flag the non-unique side of a same-column pair. Two non-unique indexes on identical columns flag the alphabetically later name (deterministic). ([253513b9](https://github.com/dropseed/plain/commit/253513b9))
20
+
21
+ ### Upgrade instructions
22
+
23
+ - No changes required. After upgrading, `plain postgres diagnose` and `plain preflight` may surface previously-undetected duplicate indexes — drop the redundant `Index(...)` declaration on the model and run `plain postgres sync`.
24
+
3
25
  ## [0.103.0](https://github.com/dropseed/plain/releases/plain-postgres@0.103.0) (2026-05-06)
4
26
 
5
27
  ### 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
@@ -66,7 +66,17 @@ def check_invalid_indexes(
66
66
  def check_duplicate_indexes(
67
67
  cursor: Any, table_owners: dict[str, TableOwner]
68
68
  ) -> CheckResult:
69
- """Indexes where one is a column-prefix of another on the same table.
69
+ """Indexes redundant with another on the same table.
70
+
71
+ Two flavors are flagged:
72
+
73
+ - **Prefix duplicate** — a non-unique index whose columns are a strict
74
+ prefix of another index's. The longer index covers the same queries.
75
+ - **Exact duplicate** — same columns and access method as another index.
76
+ If the pair contains a unique-backed btree, the non-unique one is the
77
+ redundant one (the unique already covers the queries and enforces
78
+ uniqueness). If both are non-unique, the alphabetically later name
79
+ gets flagged (deterministic).
70
80
 
71
81
  Each index column's canonical definition comes from
72
82
  ``pg_get_indexdef(indexrelid, k, false)`` — that text includes the
@@ -112,46 +122,56 @@ def check_duplicate_indexes(
112
122
  for table_name, indexes in by_table.items():
113
123
  for i, idx_a in enumerate(indexes):
114
124
  for idx_b in indexes[i + 1 :]:
115
- # Check both directions: is either a prefix of the other?
125
+ # Try both orderings prefix-redundancy is asymmetric, and
126
+ # exact-duplicate flagging picks one side deterministically.
116
127
  for shorter, longer in [(idx_a, idx_b), (idx_b, idx_a)]:
117
128
  name_s, am_s, defs_s, unique_s, size_s = shorter
118
- name_l, am_l, defs_l, _, _ = longer
129
+ name_l, am_l, defs_l, unique_l, _ = longer
119
130
  # Different access methods serve different operators
120
- # (e.g. hash supports `=` only, btree supports ordering),
121
- # and unique indexes serve a constraint purpose.
122
- if (
123
- name_s not in flagged
124
- and am_s == am_l
125
- and not unique_s
131
+ # (e.g. hash supports `=` only, btree supports ordering).
132
+ if name_s in flagged or am_s != am_l:
133
+ continue
134
+
135
+ is_prefix_dup = (
136
+ not unique_s
126
137
  and len(defs_s) < len(defs_l)
127
138
  and defs_l[: len(defs_s)] == defs_s
128
- ):
129
- source, package, model_class, model_file = _table_info(
130
- table_name, table_owners
131
- )
132
- app_suggestion = f'Remove "{name_s}" from model indexes/constraints, then run plain postgres sync'
139
+ )
140
+ is_exact_dup = (
141
+ defs_s == defs_l
142
+ and not unique_s
143
+ and (unique_l or name_s > name_l)
144
+ )
145
+
146
+ if not (is_prefix_dup or is_exact_dup):
147
+ continue
148
+
149
+ source, package, model_class, model_file = _table_info(
150
+ table_name, table_owners
151
+ )
152
+ app_suggestion = f'Remove "{name_s}" from model indexes/constraints, then run plain postgres sync'
133
153
 
134
- items.append(
135
- CheckItem(
136
- table=table_name,
137
- name=name_s,
138
- detail=f"{size_s}, redundant with {name_l}",
154
+ items.append(
155
+ CheckItem(
156
+ table=table_name,
157
+ name=name_s,
158
+ detail=f"{size_s}, redundant with {name_l}",
159
+ source=source,
160
+ package=package,
161
+ model_class=model_class,
162
+ model_file=model_file,
163
+ suggestion=_index_suggestion(
139
164
  source=source,
140
165
  package=package,
141
166
  model_class=model_class,
142
167
  model_file=model_file,
143
- suggestion=_index_suggestion(
144
- source=source,
145
- package=package,
146
- model_class=model_class,
147
- model_file=model_file,
148
- app_suggestion=app_suggestion,
149
- unmanaged_suggestion=f'DROP INDEX CONCURRENTLY "{name_s}";',
150
- ),
151
- caveats=[],
152
- )
168
+ app_suggestion=app_suggestion,
169
+ unmanaged_suggestion=f'DROP INDEX CONCURRENTLY "{name_s}";',
170
+ ),
171
+ caveats=[],
153
172
  )
154
- flagged.add(name_s)
173
+ )
174
+ flagged.add(name_s)
155
175
 
156
176
  return CheckResult(
157
177
  name="duplicate_indexes",
@@ -167,7 +187,16 @@ def check_duplicate_indexes(
167
187
  def check_missing_fk_indexes(
168
188
  cursor: Any, table_owners: dict[str, TableOwner]
169
189
  ) -> CheckResult:
170
- """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
+ """
171
200
  cursor.execute("""
172
201
  SELECT
173
202
  ct.relname AS table_name,
@@ -187,6 +216,7 @@ def check_missing_fk_indexes(
187
216
  FROM pg_catalog.pg_index i
188
217
  WHERE i.indrelid = c.conrelid
189
218
  AND i.indkey[0] = c.conkey[1]
219
+ AND i.indpred IS NULL
190
220
  )
191
221
  ORDER BY ct.relname, a.attname
192
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 (
@@ -412,7 +465,12 @@ class CheckMissingFKIndexes(PreflightCheck):
412
465
 
413
466
  @register_check("postgres.duplicate_indexes")
414
467
  class CheckDuplicateIndexes(PreflightCheck):
415
- """Warns about indexes that are prefix-redundant with other indexes or constraints."""
468
+ """Warns about indexes redundant with other indexes or constraints.
469
+
470
+ Catches both prefix-redundancy (a 1-column index shadowed by a wider
471
+ composite) and exact-column duplicates (an `Index(fields=["x"])` that
472
+ duplicates a same-column `UniqueConstraint`).
473
+ """
416
474
 
417
475
  def run(self) -> list[PreflightResult]:
418
476
  results = []
@@ -425,23 +483,52 @@ class CheckDuplicateIndexes(PreflightCheck):
425
483
  for idx_b in all_indexes[i + 1 :]:
426
484
  for shorter, longer in [(idx_a, idx_b), (idx_b, idx_a)]:
427
485
  s_name, s_fields, s_unique = shorter
428
- l_name, l_fields, _ = longer
429
- if (
430
- s_name not in flagged
486
+ l_name, l_fields, l_unique = longer
487
+
488
+ if s_name in flagged:
489
+ continue
490
+
491
+ is_prefix_dup = (
492
+ not s_unique
431
493
  and len(s_fields) < len(l_fields)
432
494
  and l_fields[: len(s_fields)] == s_fields
495
+ )
496
+ is_exact_dup = (
497
+ s_fields == l_fields
433
498
  and not s_unique
434
- ):
435
- results.append(
436
- PreflightResult(
437
- fix=f"Index '{s_name}' on [{', '.join(s_fields)}] "
438
- f"is redundant with '{l_name}' on [{', '.join(l_fields)}]. "
439
- f"The longer index covers the same queries.",
440
- obj=model.model_options.label,
441
- id="postgres.duplicate_index",
442
- warning=True,
443
- )
499
+ and (l_unique or s_name > l_name)
500
+ )
501
+
502
+ if not (is_prefix_dup or is_exact_dup):
503
+ continue
504
+
505
+ if is_prefix_dup:
506
+ fix = (
507
+ f"Index '{s_name}' on [{', '.join(s_fields)}] "
508
+ f"is redundant with '{l_name}' on [{', '.join(l_fields)}]. "
509
+ f"The longer index covers the same queries."
444
510
  )
445
- flagged.add(s_name)
511
+ elif l_unique:
512
+ fix = (
513
+ f"Index '{s_name}' on [{', '.join(s_fields)}] "
514
+ f"is redundant with '{l_name}' on the same columns. "
515
+ f"The unique-backed index already covers these queries."
516
+ )
517
+ else:
518
+ fix = (
519
+ f"Index '{s_name}' on [{', '.join(s_fields)}] "
520
+ f"is an exact duplicate of '{l_name}'. "
521
+ f"Drop one of them."
522
+ )
523
+
524
+ results.append(
525
+ PreflightResult(
526
+ fix=fix,
527
+ obj=model.model_options.label,
528
+ id="postgres.duplicate_index",
529
+ warning=True,
530
+ )
531
+ )
532
+ flagged.add(s_name)
446
533
 
447
534
  return results
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "plain.postgres"
3
- version = "0.103.0"
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"
@@ -266,6 +266,75 @@ class TestStructuralScenarios:
266
266
  f"expected no duplicates flagged across access methods, got {flagged}"
267
267
  )
268
268
 
269
+ def test_duplicate_indexes_detected_when_non_unique_shadows_unique(self) -> None:
270
+ """A non-unique `Index(fields=["x"])` declared alongside a same-column
271
+ `UniqueConstraint(fields=["x"])` is pure overhead — the unique-backed
272
+ btree already covers the same queries. Flag the non-unique side."""
273
+ _execute(
274
+ 'CREATE TABLE "_diag_dup_exact" ('
275
+ '"id" serial PRIMARY KEY, "uuid" uuid NOT NULL)'
276
+ )
277
+ _execute(
278
+ 'CREATE UNIQUE INDEX "_diag_dup_exact_unique_uuid" '
279
+ 'ON "_diag_dup_exact" ("uuid")'
280
+ )
281
+ _execute(
282
+ 'CREATE INDEX "_diag_dup_exact_uuid_idx" ON "_diag_dup_exact" ("uuid")'
283
+ )
284
+
285
+ conn = get_connection()
286
+ with conn.cursor() as cursor:
287
+ result = check_duplicate_indexes(cursor, {})
288
+
289
+ flagged = [i for i in result["items"] if i["table"] == "_diag_dup_exact"]
290
+ assert len(flagged) == 1, (
291
+ f"expected one duplicate on _diag_dup_exact, got {flagged}"
292
+ )
293
+ assert flagged[0]["name"] == "_diag_dup_exact_uuid_idx"
294
+ assert "_diag_dup_exact_unique_uuid" in flagged[0]["detail"]
295
+
296
+ def test_duplicate_indexes_detected_when_two_non_unique_match_exactly(
297
+ self,
298
+ ) -> None:
299
+ """Two non-unique indexes on identical columns — flag the alphabetically
300
+ later name (deterministic) so we don't double-report or oscillate."""
301
+ _execute('CREATE TABLE "_diag_dup_pair" ("id" serial PRIMARY KEY, "x" int)')
302
+ _execute('CREATE INDEX "_diag_dup_pair_a_idx" ON "_diag_dup_pair" ("x")')
303
+ _execute('CREATE INDEX "_diag_dup_pair_z_idx" ON "_diag_dup_pair" ("x")')
304
+
305
+ conn = get_connection()
306
+ with conn.cursor() as cursor:
307
+ result = check_duplicate_indexes(cursor, {})
308
+
309
+ flagged = [i for i in result["items"] if i["table"] == "_diag_dup_pair"]
310
+ assert len(flagged) == 1, (
311
+ f"expected one duplicate on _diag_dup_pair, got {flagged}"
312
+ )
313
+ assert flagged[0]["name"] == "_diag_dup_pair_z_idx"
314
+
315
+ def test_duplicate_indexes_not_flagged_for_two_unique_same_columns(self) -> None:
316
+ """Two unique indexes on identical columns is a Postgres-level redundancy
317
+ but neither is "the" overhead — both enforce uniqueness. Don't flag."""
318
+ _execute(
319
+ 'CREATE TABLE "_diag_dup_two_uniq" ('
320
+ '"id" serial PRIMARY KEY, "x" int NOT NULL)'
321
+ )
322
+ _execute(
323
+ 'CREATE UNIQUE INDEX "_diag_dup_two_uniq_a" ON "_diag_dup_two_uniq" ("x")'
324
+ )
325
+ _execute(
326
+ 'CREATE UNIQUE INDEX "_diag_dup_two_uniq_b" ON "_diag_dup_two_uniq" ("x")'
327
+ )
328
+
329
+ conn = get_connection()
330
+ with conn.cursor() as cursor:
331
+ result = check_duplicate_indexes(cursor, {})
332
+
333
+ flagged = [i for i in result["items"] if i["table"] == "_diag_dup_two_uniq"]
334
+ assert flagged == [], (
335
+ f"expected no duplicates flagged for two unique same-column indexes, got {flagged}"
336
+ )
337
+
269
338
  def test_duplicate_indexes_not_flagged_when_longer_starts_with_expression(
270
339
  self,
271
340
  ) -> None:
@@ -335,6 +404,34 @@ class TestStructuralScenarios:
335
404
  flagged = [i for i in result["items"] if i["table"] == "_diag_fk_child2"]
336
405
  assert flagged == [], f"indexed FK should not be flagged; got {flagged}"
337
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
+
338
435
  def test_sequence_exhaustion_critical_above_90pct(self) -> None:
339
436
  _execute('CREATE TABLE "_diag_seq" ("id" serial PRIMARY KEY, "n" int)')
340
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)